mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(koreader): add catalog detail info rows and series navigation
Format and render series, publisher, publication date, language, ISBN, page count, and file information rows in the KOReader catalog detail view. Support tappable series navigation and format-aware download button labels with unit test coverage.
This commit is contained in:
@@ -23,6 +23,7 @@ local Geom = require("ui/geometry")
|
||||
local HorizontalGroup = require("ui/widget/horizontalgroup")
|
||||
local HorizontalSpan = require("ui/widget/horizontalspan")
|
||||
local InfoMessage = require("ui/widget/infomessage")
|
||||
local KeyValuePage = require("ui/widget/keyvaluepage")
|
||||
local LineWidget = require("ui/widget/linewidget")
|
||||
local NetworkMgr = require("ui/network/manager")
|
||||
local Screen = require("device").screen
|
||||
@@ -199,8 +200,174 @@ function DetailPillWidget:onTapSelect()
|
||||
return true
|
||||
end
|
||||
|
||||
-- A plain text line that also answers taps. Used for the hero series line and
|
||||
-- the meta line, neither of which gets button chrome: the text is the target.
|
||||
local DetailTapLineWidget = InputContainer:extend{
|
||||
text = nil,
|
||||
width = nil,
|
||||
face = nil,
|
||||
callback = nil,
|
||||
}
|
||||
|
||||
function DetailTapLineWidget:init()
|
||||
local box = TextBoxWidget:new{
|
||||
text = self.text,
|
||||
width = self.width,
|
||||
height = lineHeight(self.face),
|
||||
height_overflow_show_ellipsis = true,
|
||||
face = self.face,
|
||||
}
|
||||
local dimen = Geom:new{ w = self.width, h = box:getSize().h }
|
||||
self.dimen = dimen
|
||||
self.ges_events = {
|
||||
TapSelect = { GestureRange:new{ ges = "tap", range = dimen } },
|
||||
}
|
||||
self[1] = box
|
||||
end
|
||||
|
||||
function DetailTapLineWidget:onTapSelect()
|
||||
if self.callback then
|
||||
self.callback()
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local CatalogDetail = {}
|
||||
|
||||
-- A trailing ".0" is noise on a series index: #3, not #3.0.
|
||||
function CatalogDetail.formatSeriesIndex(index)
|
||||
local value = tonumber(index)
|
||||
if not value then return nil end
|
||||
if value == math.floor(value) then return tostring(math.floor(value)) end
|
||||
local text = string.format("%.2f", value):gsub("0+$", ""):gsub("%.$", "")
|
||||
return text
|
||||
end
|
||||
|
||||
-- Dates arrive as ISO-like strings but are display-only, so an unexpected
|
||||
-- format falls back to the raw text instead of disappearing.
|
||||
local function datePart(value)
|
||||
if type(value) ~= "string" then return nil end
|
||||
local date = value:match("^(%d%d%d%d%-%d%d%-%d%d)")
|
||||
if date then return date end
|
||||
return cleanInlineText(value)
|
||||
end
|
||||
|
||||
function CatalogDetail.detailSeriesParams(detail)
|
||||
if not detail then return nil end
|
||||
if detail.seriesId then
|
||||
return { seriesId = tonumber(detail.seriesId), sort = "series" }
|
||||
end
|
||||
local name = cleanInlineText(detail.seriesName)
|
||||
if not name then return nil end
|
||||
return { series = name, sort = "series" }
|
||||
end
|
||||
|
||||
function CatalogDetail.detailSeriesText(detail)
|
||||
local name = detail and cleanInlineText(detail.seriesName)
|
||||
if not name then return nil end
|
||||
local index = CatalogDetail.formatSeriesIndex(detail.seriesIndex)
|
||||
if not index then return name end
|
||||
return T(_("%1 #%2"), name, index)
|
||||
end
|
||||
|
||||
-- The tappable hero line. The trailing chevron is the whole affordance.
|
||||
function CatalogDetail.detailSeriesLine(detail)
|
||||
local text = CatalogDetail.detailSeriesText(detail)
|
||||
if not text then return nil end
|
||||
return text .. " \u{203A}"
|
||||
end
|
||||
|
||||
local function fileInfoLabel(file)
|
||||
local label = string.upper(file.format or "file")
|
||||
local extras = {}
|
||||
local size = formatBytes(file.sizeBytes)
|
||||
if size ~= "" then table.insert(extras, size) end
|
||||
local duration = formatDuration(file.durationSeconds)
|
||||
if duration then table.insert(extras, duration) end
|
||||
if #extras == 0 then return label end
|
||||
return label .. " - " .. table.concat(extras, ", ")
|
||||
end
|
||||
|
||||
--[[--
|
||||
The long-tail metadata the detail page deliberately keeps off the hero, as
|
||||
key/value rows for the Book info sheet. Pure: it reads the detail table it is
|
||||
given and never fetches, so the offline cache renders the same rows.
|
||||
|
||||
Genres, tags, rating and status are excluded on purpose; they are already on
|
||||
the page or reachable from the action sheet.
|
||||
]]
|
||||
function CatalogDetail.bookInfoRows(detail)
|
||||
detail = detail or {}
|
||||
local rows = {}
|
||||
local function add(key, value, extra)
|
||||
value = cleanInlineText(value)
|
||||
if not value then return end
|
||||
local row = { key = key, value = value }
|
||||
for name, item in pairs(extra or {}) do row[name] = item end
|
||||
table.insert(rows, row)
|
||||
end
|
||||
|
||||
add(_("Series"), CatalogDetail.detailSeriesText(detail), {
|
||||
series_params = CatalogDetail.detailSeriesParams(detail),
|
||||
series_title = cleanInlineText(detail.seriesName),
|
||||
})
|
||||
add(_("Publisher"), detail.publisher)
|
||||
add(_("Published"), datePart(detail.publishedDate)
|
||||
or (detail.publishedYear and tostring(detail.publishedYear) or nil))
|
||||
add(_("Language"), detail.language)
|
||||
add(_("ISBN-13"), detail.isbn13)
|
||||
add(_("ISBN-10"), detail.isbn10)
|
||||
add(_("Pages"), detail.pageCount and tostring(detail.pageCount) or nil)
|
||||
add(_("Library"), detail.libraryName)
|
||||
|
||||
local collections = {}
|
||||
for _, collection in ipairs(detail.collections or {}) do
|
||||
local name = cleanInlineText(collection.name)
|
||||
if name then table.insert(collections, name) end
|
||||
end
|
||||
if #collections > 0 then
|
||||
add(_("Collections"), table.concat(collections, ", "))
|
||||
end
|
||||
|
||||
-- Hoisted: the loop variable would shadow gettext inside the body.
|
||||
local file_key = _("File")
|
||||
for _, file in ipairs(detail.files or {}) do
|
||||
add(file_key, fileInfoLabel(file))
|
||||
end
|
||||
add(_("Added"), datePart(detail.addedAt))
|
||||
|
||||
return rows
|
||||
end
|
||||
|
||||
function CatalogDetail:showBookInfo(detail)
|
||||
local rows = CatalogDetail.bookInfoRows(detail)
|
||||
if #rows == 0 then
|
||||
UIManager:show(InfoMessage:new{ text = _("No further book information."), timeout = 2 })
|
||||
return
|
||||
end
|
||||
|
||||
local page
|
||||
local kv_pairs = {}
|
||||
for _, row in ipairs(rows) do
|
||||
local entry = { row.key, row.value }
|
||||
if row.series_params then
|
||||
entry.callback = function()
|
||||
UIManager:close(page)
|
||||
self:loadBooks(row.series_params, row.series_title or _("Series"), true)
|
||||
end
|
||||
end
|
||||
table.insert(kv_pairs, entry)
|
||||
end
|
||||
page = KeyValuePage:new{ title = _("Book info"), kv_pairs = kv_pairs }
|
||||
UIManager:show(page)
|
||||
end
|
||||
|
||||
function CatalogDetail:openDetailSeries(detail)
|
||||
local params = CatalogDetail.detailSeriesParams(detail)
|
||||
if not params then return end
|
||||
self:loadBooks(params, cleanInlineText(detail.seriesName) or _("Series"), true)
|
||||
end
|
||||
|
||||
function CatalogDetail:detailReadPath(detail)
|
||||
local fallback
|
||||
for _, file in ipairs(self:supportedFiles(detail)) do
|
||||
@@ -429,6 +596,14 @@ function CatalogDetail:showBookActionSheet(detail, opts)
|
||||
end
|
||||
|
||||
if include_details then
|
||||
addRow({
|
||||
text = _("Book info"),
|
||||
callback = function()
|
||||
closeThen(function()
|
||||
self:showBookInfo(detail)
|
||||
end)
|
||||
end,
|
||||
})
|
||||
addRow({
|
||||
text = _("Genres"),
|
||||
enabled = has_genres,
|
||||
@@ -665,6 +840,12 @@ function CatalogDetail:downloadButtonLabel(supported_files)
|
||||
local file = self:nextDownloadFile(supported_files)
|
||||
local format = cleanInlineText(file and file.format)
|
||||
if not format then return _("Download") end
|
||||
-- Size is what decides whether a download is worth starting over a slow
|
||||
-- connection, so it rides the button when the server knows it.
|
||||
local size = formatBytes(file and file.sizeBytes)
|
||||
if size ~= "" then
|
||||
return T(_("Download %1 - %2"), string.upper(format), size)
|
||||
end
|
||||
return T(_("Download (%1)"), string.upper(format))
|
||||
end
|
||||
|
||||
@@ -782,11 +963,12 @@ function CatalogDetail:recalculateDetailDimen()
|
||||
}
|
||||
end
|
||||
|
||||
-- Page count is deliberately absent: the progress line right below already
|
||||
-- carries it, and the Book info sheet lists it again.
|
||||
function CatalogDetail.detailHeroMetaLine(_unused, detail)
|
||||
local parts = {}
|
||||
if detail.publishedYear then table.insert(parts, tostring(detail.publishedYear)) end
|
||||
if detail.publisher then table.insert(parts, detail.publisher) end
|
||||
if detail.pageCount then table.insert(parts, T(_("%1 pages"), detail.pageCount)) end
|
||||
return #parts > 0 and table.concat(parts, " - ") or nil
|
||||
end
|
||||
|
||||
@@ -1088,45 +1270,68 @@ function CatalogDetail:buildDetailHeader(detail, width)
|
||||
self.detail_read_button = nil
|
||||
self.detail_download_button = nil
|
||||
self.detail_more_pills_button = nil
|
||||
self.detail_series_line = nil
|
||||
self.detail_meta_line = nil
|
||||
|
||||
local title_face = Font:getFace("cfont", 21)
|
||||
local top = VerticalGroup:new{ align = "left" }
|
||||
table.insert(top, TextBoxWidget:new{
|
||||
text = BD.auto(detail.title or _("Untitled")),
|
||||
width = text_w,
|
||||
height = 2 * lineHeight(title_face),
|
||||
height_adjust = true,
|
||||
height_overflow_show_ellipsis = true,
|
||||
bold = true,
|
||||
face = title_face,
|
||||
})
|
||||
table.insert(top, VerticalSpan:new{ width = DETAIL_GAP_XS })
|
||||
local author_face = Font:getFace("smallinfofont", 16)
|
||||
table.insert(top, TextBoxWidget:new{
|
||||
text = BD.auto(joinNames(detail.authors) or _("Unknown author")),
|
||||
width = text_w,
|
||||
height = lineHeight(author_face),
|
||||
height_overflow_show_ellipsis = true,
|
||||
face = author_face,
|
||||
})
|
||||
local meta_face = Font:getFace("x_smallinfofont", 13)
|
||||
local series_line = self:detailSeriesLine(detail)
|
||||
local meta_line = self:detailHeroMetaLine(detail)
|
||||
if meta_line then
|
||||
local meta_face = Font:getFace("x_smallinfofont", 13)
|
||||
table.insert(top, VerticalSpan:new{ width = DETAIL_GAP_XS })
|
||||
table.insert(top, TextBoxWidget:new{
|
||||
text = meta_line,
|
||||
|
||||
local function buildTop(with_series)
|
||||
self.detail_series_line = nil
|
||||
self.detail_meta_line = nil
|
||||
local group = VerticalGroup:new{ align = "left" }
|
||||
table.insert(group, TextBoxWidget:new{
|
||||
text = BD.auto(detail.title or _("Untitled")),
|
||||
width = text_w,
|
||||
height = lineHeight(meta_face),
|
||||
height = 2 * lineHeight(title_face),
|
||||
height_adjust = true,
|
||||
height_overflow_show_ellipsis = true,
|
||||
face = meta_face,
|
||||
bold = true,
|
||||
face = title_face,
|
||||
})
|
||||
end
|
||||
table.insert(top, VerticalSpan:new{ width = DETAIL_GAP_S })
|
||||
table.insert(top, self:buildDetailRating(detail, text_w))
|
||||
local pills = self:buildDetailPills(detail, text_w)
|
||||
if pills then
|
||||
table.insert(top, VerticalSpan:new{ width = DETAIL_GAP_S })
|
||||
table.insert(top, pills)
|
||||
table.insert(group, VerticalSpan:new{ width = DETAIL_GAP_XS })
|
||||
table.insert(group, TextBoxWidget:new{
|
||||
text = BD.auto(joinNames(detail.authors) or _("Unknown author")),
|
||||
width = text_w,
|
||||
height = lineHeight(author_face),
|
||||
height_overflow_show_ellipsis = true,
|
||||
face = author_face,
|
||||
})
|
||||
if with_series and series_line then
|
||||
self.detail_series_line = DetailTapLineWidget:new{
|
||||
text = BD.auto(series_line),
|
||||
width = text_w,
|
||||
face = author_face,
|
||||
callback = function()
|
||||
self:openDetailSeries(detail)
|
||||
end,
|
||||
}
|
||||
table.insert(group, VerticalSpan:new{ width = DETAIL_GAP_XS })
|
||||
table.insert(group, self.detail_series_line)
|
||||
end
|
||||
if meta_line then
|
||||
self.detail_meta_line = DetailTapLineWidget:new{
|
||||
text = meta_line,
|
||||
width = text_w,
|
||||
face = meta_face,
|
||||
callback = function()
|
||||
self:showBookInfo(detail)
|
||||
end,
|
||||
}
|
||||
table.insert(group, VerticalSpan:new{ width = DETAIL_GAP_XS })
|
||||
table.insert(group, self.detail_meta_line)
|
||||
end
|
||||
table.insert(group, VerticalSpan:new{ width = DETAIL_GAP_S })
|
||||
table.insert(group, self:buildDetailRating(detail, text_w))
|
||||
local pills = self:buildDetailPills(detail, text_w)
|
||||
if pills then
|
||||
table.insert(group, VerticalSpan:new{ width = DETAIL_GAP_S })
|
||||
table.insert(group, pills)
|
||||
end
|
||||
return group
|
||||
end
|
||||
|
||||
local bottom = VerticalGroup:new{ align = "left" }
|
||||
@@ -1134,6 +1339,14 @@ function CatalogDetail:buildDetailHeader(detail, width)
|
||||
table.insert(bottom, VerticalSpan:new{ width = DETAIL_GAP_M })
|
||||
table.insert(bottom, self:buildDetailButtons(detail, text_w))
|
||||
|
||||
-- The right column is pinned to the cover's top and bottom edges, so the
|
||||
-- optional series line is the first thing to drop when the info block would
|
||||
-- otherwise run into the action block on a small screen.
|
||||
local top = buildTop(true)
|
||||
if series_line and top:getSize().h + bottom:getSize().h > cover_h then
|
||||
top = buildTop(false)
|
||||
end
|
||||
|
||||
local flex = math.max(DETAIL_GAP_M, cover_h - top:getSize().h - bottom:getSize().h)
|
||||
local right = VerticalGroup:new{ align = "left" }
|
||||
table.insert(right, top)
|
||||
@@ -1378,6 +1591,8 @@ function CatalogDetail:updateDetailItems(select_number, no_recalculate_dimen)
|
||||
local used = DETAIL_GAP_S + header:getSize().h
|
||||
|
||||
local focus_row = {}
|
||||
if self.detail_series_line then table.insert(focus_row, self.detail_series_line) end
|
||||
if self.detail_meta_line then table.insert(focus_row, self.detail_meta_line) end
|
||||
for _, star in ipairs(self.detail_rating_stars or {}) do
|
||||
table.insert(focus_row, star)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
-- The Book info sheet is built from a pure helper so the long-tail metadata
|
||||
-- can be asserted without a screen: null skipping, series index trimming, size
|
||||
-- and date formatting, and the download button's size suffix.
|
||||
|
||||
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;koreader-plugin/spec/?.lua;" .. package.path
|
||||
|
||||
local function identity(value) return value end
|
||||
|
||||
local function stubWidget()
|
||||
local widget = {}
|
||||
widget.__index = widget
|
||||
function widget:new(opts)
|
||||
opts = opts or {}
|
||||
return setmetatable(opts, widget)
|
||||
end
|
||||
function widget:extend(opts)
|
||||
local child = setmetatable(opts or {}, { __index = self })
|
||||
child.__index = child
|
||||
child.new = widget.new
|
||||
child.extend = widget.extend
|
||||
return child
|
||||
end
|
||||
function widget:getSize() return { w = 0, h = 0 } end
|
||||
return widget
|
||||
end
|
||||
|
||||
package.loaded["ui/bidi"] = { auto = identity, dirpath = identity, filepath = identity }
|
||||
package.loaded["ffi/blitbuffer"] = { COLOR_GRAY = 0 }
|
||||
package.loaded["ui/widget/button"] = stubWidget()
|
||||
package.loaded["ui/widget/buttondialog"] = stubWidget()
|
||||
package.loaded["ui/widget/container/centercontainer"] = stubWidget()
|
||||
package.loaded["ui/widget/container/leftcontainer"] = stubWidget()
|
||||
package.loaded["ui/widget/container/inputcontainer"] = stubWidget()
|
||||
package.loaded["ui/widget/horizontalgroup"] = stubWidget()
|
||||
package.loaded["ui/widget/horizontalspan"] = stubWidget()
|
||||
package.loaded["ui/widget/verticalgroup"] = stubWidget()
|
||||
package.loaded["ui/widget/verticalspan"] = stubWidget()
|
||||
package.loaded["ui/widget/linewidget"] = stubWidget()
|
||||
package.loaded["ui/widget/textboxwidget"] = stubWidget()
|
||||
package.loaded["ui/widget/textviewer"] = stubWidget()
|
||||
package.loaded["ui/widget/infomessage"] = stubWidget()
|
||||
package.loaded["ui/widget/keyvaluepage"] = stubWidget()
|
||||
package.loaded["ui/geometry"] = stubWidget()
|
||||
package.loaded["ui/gesturerange"] = stubWidget()
|
||||
package.loaded["ui/font"] = { getFace = function() return { size = 12 } end }
|
||||
package.loaded["ui/size"] = { line = { medium = 1 } }
|
||||
package.loaded["ui/network/manager"] = { isConnected = function() return true end }
|
||||
package.loaded["device"] = { screen = { scaleBySize = function(_, value) return value end } }
|
||||
package.loaded["ui/uimanager"] = { show = function() end, close = function() end }
|
||||
package.loaded["document/documentregistry"] = { hasProvider = function() return true end }
|
||||
package.loaded["util"] = {
|
||||
trim = function(value) return tostring(value or ""):match("^%s*(.-)%s*$") end,
|
||||
fixUtf8 = function(value) return value end,
|
||||
htmlToPlainTextIfHtml = identity,
|
||||
}
|
||||
package.loaded["ffi/util"] = {
|
||||
template = function(pattern, a, b)
|
||||
return (pattern:gsub("%%1", tostring(a)):gsub("%%2", tostring(b)))
|
||||
end,
|
||||
}
|
||||
package.loaded["gettext"] = setmetatable({}, { __call = function(_, value) return value end })
|
||||
package.loaded["bookorbit_catalog_widgets"] = {
|
||||
buildCoverWidget = function() end,
|
||||
buildDetailPill = function() end,
|
||||
buildDetailProgressBar = function() end,
|
||||
DetailRelatedCard = stubWidget(),
|
||||
DetailTabButton = stubWidget(),
|
||||
DetailRatingStar = stubWidget(),
|
||||
}
|
||||
|
||||
local CatalogDetail = require("bookorbit_catalog_detail")
|
||||
|
||||
local function assertEqual(actual, expected, label)
|
||||
if actual ~= expected then
|
||||
error(string.format("%s: expected %s, got %s", label, tostring(expected), tostring(actual)), 2)
|
||||
end
|
||||
end
|
||||
|
||||
local function rowValue(rows, key)
|
||||
for _, row in ipairs(rows) do
|
||||
if row.key == key then return row.value end
|
||||
end
|
||||
end
|
||||
|
||||
local function rowCount(rows, key)
|
||||
local count = 0
|
||||
for _, row in ipairs(rows) do
|
||||
if row.key == key then count = count + 1 end
|
||||
end
|
||||
return count
|
||||
end
|
||||
|
||||
-- Series index formatting
|
||||
|
||||
assertEqual(CatalogDetail.formatSeriesIndex(3), "3", "a whole index drops the decimal")
|
||||
assertEqual(CatalogDetail.formatSeriesIndex(3.0), "3", "a trailing .0 is trimmed")
|
||||
assertEqual(CatalogDetail.formatSeriesIndex(1.5), "1.5", "a half index keeps one decimal")
|
||||
assertEqual(CatalogDetail.formatSeriesIndex(1.25), "1.25", "two decimals survive")
|
||||
assertEqual(CatalogDetail.formatSeriesIndex(nil), nil, "no index formats to nothing")
|
||||
assertEqual(CatalogDetail.formatSeriesIndex("nonsense"), nil, "an unparsable index formats to nothing")
|
||||
|
||||
assertEqual(CatalogDetail.detailSeriesText({ seriesName = "Discworld", seriesIndex = 3.0 }), "Discworld #3",
|
||||
"the series text pairs name and index")
|
||||
assertEqual(CatalogDetail.detailSeriesText({ seriesName = "Discworld" }), "Discworld",
|
||||
"a series with no index shows just the name")
|
||||
assertEqual(CatalogDetail.detailSeriesText({ seriesIndex = 3 }), nil, "an index without a name is not a series line")
|
||||
assertEqual(CatalogDetail.detailSeriesLine({ seriesName = "Discworld", seriesIndex = 3 }), "Discworld #3 \u{203A}",
|
||||
"the hero line carries the tap chevron")
|
||||
assertEqual(CatalogDetail.detailSeriesLine({}), nil, "no series means no hero line")
|
||||
|
||||
-- Series navigation params
|
||||
|
||||
local by_id = CatalogDetail.detailSeriesParams({ seriesId = 12, seriesName = "Discworld" })
|
||||
assertEqual(by_id.seriesId, 12, "a series id drives the books context")
|
||||
assertEqual(by_id.sort, "series", "a series list sorts by series order")
|
||||
local by_name = CatalogDetail.detailSeriesParams({ seriesName = "Discworld" })
|
||||
assertEqual(by_name.series, "Discworld", "without an id the name drives the context")
|
||||
assertEqual(by_name.seriesId, nil, "no id is invented")
|
||||
assertEqual(CatalogDetail.detailSeriesParams({}), nil, "a book with no series has no context")
|
||||
|
||||
-- Book info rows
|
||||
|
||||
local full = {
|
||||
seriesId = 12,
|
||||
seriesName = "Discworld",
|
||||
seriesIndex = 3.0,
|
||||
publisher = "Gollancz",
|
||||
publishedDate = "1987-06-01T00:00:00.000Z",
|
||||
publishedYear = 1987,
|
||||
language = "en",
|
||||
isbn13 = "9780552131056",
|
||||
isbn10 = "0552131059",
|
||||
pageCount = 285,
|
||||
libraryName = "Fiction",
|
||||
collections = { { id = 1, name = "Favourites" }, { id = 2, name = "To reread" } },
|
||||
files = {
|
||||
{ id = 1, format = "epub", sizeBytes = 2411724 },
|
||||
{ id = 2, format = "m4b", sizeBytes = nil, durationSeconds = 7200 },
|
||||
},
|
||||
addedAt = "2026-02-14T09:30:00.000Z",
|
||||
-- Deliberately excluded: already on the page or in existing viewers.
|
||||
genres = { "Fantasy" },
|
||||
tags = { "funny" },
|
||||
rating = 5,
|
||||
readStatus = "read",
|
||||
description = "A book.",
|
||||
subtitle = "A Discworld Novel",
|
||||
}
|
||||
|
||||
local rows = CatalogDetail.bookInfoRows(full)
|
||||
assertEqual(rowValue(rows, "Series"), "Discworld #3", "series row carries name and trimmed index")
|
||||
assertEqual(rows[1].key, "Series", "series leads the sheet")
|
||||
assertEqual(rows[1].series_params.seriesId, 12, "the series row is tappable")
|
||||
assertEqual(rows[1].series_title, "Discworld", "the series row names the list it opens")
|
||||
assertEqual(rowValue(rows, "Publisher"), "Gollancz", "publisher is shown")
|
||||
assertEqual(rowValue(rows, "Published"), "1987-06-01", "an ISO timestamp is reduced to its date")
|
||||
assertEqual(rowValue(rows, "Language"), "en", "language is shown")
|
||||
assertEqual(rowValue(rows, "ISBN-13"), "9780552131056", "isbn13 is shown")
|
||||
assertEqual(rowValue(rows, "ISBN-10"), "0552131059", "isbn10 is shown")
|
||||
assertEqual(rowValue(rows, "Pages"), "285", "page count is shown")
|
||||
assertEqual(rowValue(rows, "Library"), "Fiction", "library is shown")
|
||||
assertEqual(rowValue(rows, "Collections"), "Favourites, To reread", "collections are comma joined")
|
||||
assertEqual(rowCount(rows, "File"), 2, "one row per file")
|
||||
assertEqual(rows[10].value, "EPUB - 2.3 MB", "a file row shows format and size")
|
||||
assertEqual(rows[11].value, "M4B - 2 h", "a sizeless audio file falls back to its duration")
|
||||
assertEqual(rowValue(rows, "Added"), "2026-02-14", "added date is reduced to its date")
|
||||
assertEqual(rowValue(rows, "Genres"), nil, "genres stay off the sheet")
|
||||
assertEqual(rowValue(rows, "Tags"), nil, "tags stay off the sheet")
|
||||
assertEqual(rowValue(rows, "Rating"), nil, "rating stays off the sheet")
|
||||
assertEqual(rowValue(rows, "Subtitle"), nil, "subtitle stays off the page and the sheet")
|
||||
|
||||
-- Null skipping
|
||||
|
||||
local sparse = CatalogDetail.bookInfoRows({ libraryName = "Fiction" })
|
||||
assertEqual(#sparse, 1, "rows without a value are skipped entirely")
|
||||
assertEqual(sparse[1].key, "Library", "the one known value survives")
|
||||
assertEqual(#CatalogDetail.bookInfoRows({}), 0, "a book with no metadata collapses to nothing")
|
||||
assertEqual(#CatalogDetail.bookInfoRows(nil), 0, "a missing detail collapses to nothing")
|
||||
|
||||
-- publishedYear is the fallback when there is no full date, and an unexpected
|
||||
-- date string is shown verbatim rather than dropped.
|
||||
assertEqual(rowValue(CatalogDetail.bookInfoRows({ publishedYear = 1987 }), "Published"), "1987",
|
||||
"the year stands in for a missing date")
|
||||
assertEqual(rowValue(CatalogDetail.bookInfoRows({ publishedDate = "Spring 1987" }), "Published"), "Spring 1987",
|
||||
"an unparsable date is display-only, not dropped")
|
||||
|
||||
-- A file with neither size nor duration still names its format.
|
||||
assertEqual(rowValue(CatalogDetail.bookInfoRows({ files = { { format = "cbz" } } }), "File"), "CBZ",
|
||||
"a file with no size still lists its format")
|
||||
|
||||
-- Download button label
|
||||
|
||||
local menu = { nextDownloadFile = function(_, files) return files[1] end }
|
||||
setmetatable(menu, { __index = CatalogDetail })
|
||||
|
||||
assertEqual(menu:downloadButtonLabel({}), "No supported format", "no supported file says so")
|
||||
assertEqual(menu:downloadButtonLabel({ { format = "epub", sizeBytes = 2411724 } }), "Download EPUB - 2.3 MB",
|
||||
"the button carries the format and the size")
|
||||
assertEqual(menu:downloadButtonLabel({ { format = "epub" } }), "Download (EPUB)",
|
||||
"an unknown size leaves the original label untouched")
|
||||
|
||||
print("bookorbit_detail_info_rows_test.lua: ok")
|
||||
Reference in New Issue
Block a user