Files
bookorbit/koreader-plugin/spec/bookorbit_dashboard_layout_test.lua
T
neon 80244ea4e2 feat(koreader): rebuild the plugin interface and fix four-way annotation sync
Squash of feat/koreader-widget-board. KOReader plugin 2.0.0 gets the widget-board dashboard, redesigned
book details, menus, lists and dialogs, Decor page and reading companions. Highlights, notes and bookmarks
now stay in step across web, Kobo, KOReader and the phones (defects D-00 to D-20, migration 0121), and
reading progress and narration hand off cleanly between devices.
2026-10-20 16:05:27 -06:00

465 lines
24 KiB
Lua

-- Proves the widget board's layout rules: the grid each screen width gets,
-- dense packing, that every starter page fits every supported screen, how
-- boards are stored, and that editing can never keep a page that does not fit.
package.loaded["gettext"] = function(text)
return text
end
package.loaded["ffi/util"] = {
template = function(text, ...)
local values = { ... }
return (text:gsub("%%(%d+)", function(index)
return tostring(values[tonumber(index)])
end))
end,
}
local shown = {}
package.loaded["ui/uimanager"] = {
show = function(_, widget)
table.insert(shown, widget)
end,
}
package.loaded["ui/widget/infomessage"] = { new = function(_, opts) return opts end }
-- Refusals are told in BookOrbit's pill; record them like a shown widget.
package.loaded["bookorbit_toast"] = {
show = function(text) table.insert(shown, { text = text }) end,
error = function(text) table.insert(shown, { text = text }) end,
}
-- The real metrics module over a stubbed screen, so each device below is
-- measured in board units exactly as it is on the device.
local screen = { w = 600, h = 800, dpi = 167 }
package.loaded["ffi/blitbuffer"] = setmetatable({}, { __index = function() return 0 end })
local last_face_size
package.loaded["ui/font"] = { getFace = function(_, _, size) last_face_size = size return {} end }
package.loaded["device"] = {
screen = {
getWidth = function() return screen.w end,
getHeight = function() return screen.h end,
getDPI = function() return screen.dpi end,
scaleBySize = function(_, value)
return math.ceil(value * math.min(screen.w, screen.h) / 600)
end,
},
}
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
local BoardMetrics = require("bookorbit_board_metrics")
local BoardLayout = require("bookorbit_board_layout")
local BoardStore = require("bookorbit_board_store")
local BoardEdit = require("bookorbit_board_edit")
local DashboardSections = require("bookorbit_dashboard_sections")
local function assertEqual(actual, expected, label)
if actual ~= expected then
error(string.format("%s: expected %s, got %s", label, tostring(expected), tostring(actual)))
end
end
local function px(value) return value end
assertEqual(BoardMetrics.get().scale, 1.05, "metrics start at the default size before settings load")
-- Size codes adapt to the column count: on a two-column board W is M and XL
-- is L.
local cw, rh = BoardLayout.sizeDims("W", 2)
assertEqual(cw .. "x" .. rh, "2x1", "W on two columns is M")
cw, rh = BoardLayout.sizeDims("XL", 6)
assertEqual(cw .. "x" .. rh, "6x2", "XL spans six columns")
cw, rh = BoardLayout.sizeDims("L", 4)
assertEqual(cw .. "x" .. rh, "2x2", "L is two by two")
assertEqual(BoardLayout.columnsFor(559), 2, "a phone-width screen gets two columns")
assertEqual(BoardLayout.columnsFor(560), 4, "an e-reader width gets four columns")
assertEqual(BoardLayout.columnsFor(899), 4, "four columns up to tablet width")
assertEqual(BoardLayout.columnsFor(900), 6, "a landscape tablet gets six columns")
-- The last column absorbs the rounding remainder, so a row of tiles always
-- ends exactly on the right margin.
for _, width in ipairs({ 600, 601, 637, 1001 }) do
local g = BoardLayout.geometry(px, width, 900, width)
local x, total = 0, 0
while x < g.cols do
total = total + BoardLayout.spanWidth(g, x, 1) + (x + 1 < g.cols and g.gap or 0)
x = x + 1
end
assertEqual(total, g.w, "single tiles across a " .. width .. "px row fill it")
assertEqual(BoardLayout.spanWidth(g, 0, g.cols), g.w, "a full-width tile spans the grid at " .. width .. "px")
end
-- Packing is first-fit and dense: a later small widget fills a hole left
-- beside an earlier wide one.
local packed = BoardLayout.pack({
{ type = "a", cw = 3, rh = 1 },
{ type = "b", cw = 2, rh = 1 },
{ type = "c", cw = 1, rh = 1 },
}, 4)
assertEqual(packed.placed[3].x .. "," .. packed.placed[3].y, "3,0", "the small widget fills the first-row hole")
assertEqual(packed.rows, 2, "two rows are used")
assertEqual(packed.holes, 2, "the half-empty last row counts as two holes")
assertEqual(BoardLayout.pack({ { cw = 9, rh = 1 } }, 4).placed[1].cw, 4, "a widget wider than the grid is clamped")
-- A page with more rows than fit drops widgets from its end; a page with room
-- takes extras that leave no new hole.
local g = BoardLayout.geometry(px, 600, 800, 600)
local base = {}
for _ = 1, 12 do table.insert(base, { type = "s", cw = 4, rh = 1 }) end
local fitted = BoardLayout.fitPage(g, base, {})
assertEqual(fitted.row_h >= g.min_row, true, "a crowded page keeps rows at least the minimum height")
assertEqual(#fitted.placed < 12, true, "a crowded page drops widgets from its end")
fitted = BoardLayout.fitPage(g, { { type = "a", cw = 4, rh = 1 } }, { { type = "extra", cw = 4, rh = 1 } })
assertEqual(#fitted.placed, 2, "a roomy page takes an extra")
assertEqual(fitted.row_h <= g.max_row, true, "rows never grow past the maximum height")
-- Every starter page of every layout fits every class of screen KOReader runs
-- on, upright and turned, with and without a frontlight: rows stay between
-- the minimum and maximum height and the grid never runs past its space.
local DEVICES = {
{ "Kindle 4", 600, 800, 167 },
{ "Kobo Mini", 600, 800, 200 },
{ "Kobo Clara", 1072, 1448, 300 },
{ "Kobo Libra", 1264, 1680, 300 },
{ "Palma", 824, 1648, 300 },
{ "Kobo Elipsa", 1404, 1872, 227 },
{ "Kindle Scribe", 1860, 2480, 300 },
{ "phone", 1080, 2400, 420 },
{ "small phone", 720, 1280, 320 },
{ "tablet", 1600, 2560, 320 },
}
local checked = 0
for _, device in ipairs(DEVICES) do
for _, turned in ipairs({ false, true }) do
screen.w, screen.h, screen.dpi = device[2], device[3], device[4]
if turned then screen.w, screen.h = screen.h, screen.w end
for _, ui_scale in ipairs(BoardMetrics.UI_SCALES) do
BoardMetrics.setUserScale(ui_scale)
local m = BoardMetrics.refresh()
local sg = BoardLayout.geometry(BoardMetrics.px, m.w, m.h, m.W)
for _, preset in ipairs(BoardStore.PRESETS) do
for _, has_light in ipairs({ true, false }) do
for page_index, page in ipairs(BoardStore.starterPages(sg.cols, preset, has_light, m.w > m.h)) do
local label = string.format("%s page %d on %s%s at %.1fx",
preset, page_index, device[1], turned and " turned" or "", ui_scale)
local result = BoardLayout.fitPage(sg, page.base, page.extras)
assertEqual(result.row_h >= sg.min_row, true, label .. " keeps rows tall enough")
assertEqual(result.grid_h <= sg.avail, true, label .. " fits the screen")
for _, item in ipairs(result.placed) do
assertEqual(item.x + item.cw <= sg.cols, true,
label .. " keeps " .. item.type .. " inside the grid")
end
checked = checked + 1
end
end
end
end
end
end
assertEqual(checked, #DEVICES * 2 * #BoardMetrics.UI_SCALES * 3 * 2 * BoardStore.PAGE_COUNT,
"every starter page was checked at every interface size")
screen.w, screen.h, screen.dpi = 600, 800, 167
BoardMetrics.setUserScale(1)
BoardMetrics.refresh()
BoardMetrics.setUserScale(1.2)
local scaled = BoardMetrics.refresh()
assertEqual(scaled.scale, 1.2, "the selected interface size is retained")
BoardMetrics.face(10)
assertEqual(last_face_size, 10 * scaled.adj * scaled.scale, "the selected interface size scales fonts")
BoardMetrics.namedFace("cfont", 11)
assertEqual(last_face_size, 11 * scaled.adj * scaled.scale, "named KOReader fonts use the same interface scale")
assertEqual(BoardMetrics.normalizeUserScale(1.149999), 1.15, "minor numeric drift is normalized")
assertEqual(BoardMetrics.normalizeUserScale(1.25), 1.2, "the retired interface size migrates to the supported maximum")
assertEqual(BoardMetrics.normalizeUserScale("1.25"), 1.2, "the retired persisted string size migrates")
assertEqual(BoardMetrics.DEFAULT_UI_SCALE, 1.05, "the default interface size is 1.05x")
assertEqual(BoardMetrics.normalizeUserScale(nil), 1.05, "missing preferences use the default size")
assertEqual(BoardMetrics.normalizeUserScale("invalid"), 1.05, "invalid preferences use the default size")
assertEqual(BoardMetrics.normalizeUserScale(1.17), 1.05, "unsupported interface sizes fall back safely")
assertEqual(BoardMetrics.normalizeUserScale(1), 1, "an explicit saved 1.0x preference is preserved")
BoardMetrics.setUserScale(1)
BoardMetrics.refresh()
-- Each page has one job: no widget appears on two pages, on any screen
-- layout, with or without a frontlight.
for _, cols in ipairs({ 2, 4, 6 }) do
for _, landscape in ipairs({ false, true }) do
for _, has_light in ipairs({ true, false }) do
local seen = {}
for _, page in ipairs(BoardStore.starterPages(cols, "reader", has_light, landscape)) do
for _, list in ipairs({ page.base, page.extras }) do
for _, item in ipairs(list) do
local key = item.type .. ":" .. tostring(item.props and (item.props.source or item.props.link) or "")
assertEqual(seen[key] == nil or seen[key] == page.id, true,
key .. " appears on both " .. tostring(seen[key]) .. " and " .. page.id .. " (" .. cols .. " columns)")
seen[key] = page.id
end
end
end
end
end
end
-- The Stats and Library starters keep the same pages and only change which of
-- the first three comes first; Decor is always last.
local function ids(pages)
local out = {}
for i, page in ipairs(pages) do out[i] = page.id end
return table.concat(out, ",")
end
assertEqual(ids(BoardStore.starterPages(4, "reader", true)), "home,insights,library,decor", "Reader opens on Home")
assertEqual(ids(BoardStore.starterPages(4, "stats", true)), "insights,home,library,decor", "Stats opens on Insights")
assertEqual(ids(BoardStore.starterPages(4, "library", true)), "library,home,insights,decor", "Library opens on Library")
assertEqual(table.concat(BoardStore.pageNames(BoardStore.starterPages(4, "stats", true)), ","), "Insights,Home,Library,Decor",
"page names follow the pages")
-- A device without a frontlight never gets the Light widget.
for _, cols in ipairs({ 2, 4, 6 }) do
for _, preset in ipairs(BoardStore.PRESETS) do
for _, page in ipairs(BoardStore.starterPages(cols, preset, false)) do
for _, list in ipairs({ page.base, page.extras }) do
for _, item in ipairs(list) do
assertEqual(item.type ~= "light", true, preset .. " on " .. cols .. " columns has no Light without a frontlight")
end
end
end
end
end
-- A shelf is never one row tall, in a starter layout or as a size the editor
-- offers: a single row leaves covers too small to recognise.
for _, cols in ipairs({ 2, 4, 6 }) do
for _, preset in ipairs(BoardStore.PRESETS) do
for _, page in ipairs(BoardStore.starterPages(cols, preset, true)) do
for _, list in ipairs({ page.base, page.extras }) do
for _, item in ipairs(list) do
if item.type == "shelf" then
assertEqual(item.rh >= 2, true, preset .. " on " .. cols .. " columns keeps its shelves two rows tall")
end
end
end
end
end
end
for _, entry in ipairs(BoardEdit.LIBRARY) do
if entry.type == "shelf" then
for _, code in ipairs(entry.sizes) do
local _cw, size_rh = BoardLayout.sizeDims(code, 4)
assertEqual(size_rh >= 2, true, entry.name() .. " offers no one-row size")
end
end
end
-- Storage: anything unusable reads as the default Reader board.
local state = BoardStore.read({})
assertEqual(state.preset .. "/" .. state.tiles .. "/" .. tostring(state.started), "reader/outline/false", "no setting reads as the default")
state = BoardStore.read({ [BoardStore.SETTING_KEY] = { version = 1, preset = "nope", tiles = "neon", started = true } })
assertEqual(state.preset .. "/" .. state.tiles, "reader/outline", "unknown values fall back")
assertEqual(state.started, true, "a valid started flag survives")
state = BoardStore.read({ [BoardStore.SETTING_KEY] = { version = 99, preset = "stats" } })
assertEqual(state.preset, "reader", "a setting from another version is not trusted")
-- Custom pages are kept per column count; other counts still get the starter.
local settings = {}
settings[BoardStore.SETTING_KEY] = BoardStore.withPreset(settings, "stats")
settings[BoardStore.SETTING_KEY] = BoardStore.withCustomPages(settings, 4, {
{ items = { { type = "clock", cw = 1, rh = 1 } } },
})
local pages, custom = BoardStore.pages(settings, 4, true)
assertEqual(custom, true, "four columns use the reader's pages")
assertEqual(pages[1].base[1].type, "clock", "the stored widget is drawn")
assertEqual(#pages[2].base, 0, "a page the reader emptied stays empty")
pages, custom = BoardStore.pages(settings, 2, true)
assertEqual(custom, false, "two columns still use the starter layout")
settings[BoardStore.SETTING_KEY] = BoardStore.withPreset(settings, "library")
assertEqual(next(BoardStore.read(settings).custom), nil, "a new layout replaces the reader's pages")
-- Portrait and landscape can share a column count but not a height, so each
-- keeps its own pages.
settings = {}
settings[BoardStore.SETTING_KEY] = BoardStore.withCustomPages(settings, 4, {
{ items = { { type = "clock", cw = 1, rh = 1 } } },
}, true)
pages, custom = BoardStore.pages(settings, 4, true, true)
assertEqual(custom and pages[1].base[1].type, "clock", "landscape reads its own pages")
pages, custom = BoardStore.pages(settings, 4, true, false)
assertEqual(custom, false, "portrait keeps the starter layout")
-- A saved page keeps which page it is, so a Stats board stays named right.
settings = {}
settings[BoardStore.SETTING_KEY] = BoardStore.withCustomPages(settings, 4, {
{ id = "insights", items = {} }, { id = "home", items = {} }, { id = "library", items = {} },
})
pages = BoardStore.pages(settings, 4, true)
assertEqual(ids(pages), "insights,home,library,decor", "saved pages keep their ids")
-- A board saved before the Decor page existed gets it as it starts, and a
-- Decor page the reader emptied stays empty.
assertEqual(#pages[4].base > 0, true, "a three-page board gains the starter Decor page")
settings[BoardStore.SETTING_KEY] = BoardStore.withCustomPages(settings, 4, {
{ id = "insights", items = {} }, { id = "home", items = {} }, { id = "library", items = {} }, { id = "decor", items = {} },
})
pages = BoardStore.pages(settings, 4, true)
assertEqual(#pages[4].base, 0, "an emptied Decor page stays empty")
settings[BoardStore.SETTING_KEY] = BoardStore.withCustomPages(settings, 4, { { items = {} } })
assertEqual(BoardStore.pages(settings, 4, true)[1].id, "home", "pages saved before ids default by position")
-- Pages saved before shelves had a two-row minimum read back with two-row shelves.
settings = {}
settings[BoardStore.SETTING_KEY] = BoardStore.withCustomPages(settings, 4, {
{ items = { { type = "shelf", cw = 4, rh = 1, props = { source = "random" } } } },
})
pages = BoardStore.pages(settings, 4, true)
assertEqual(pages[1].base[1].rh, 2, "a stored one-row shelf is drawn two rows tall")
assertEqual(BoardStore.read(settings).custom["4"].pages[1].items[1].rh, 1, "the stored setting itself is left alone")
-- A shelf's key is its source plus its filter, in a stable order.
assertEqual(BoardStore.shelfKey({ source = "random" }), "random", "a plain shelf keys by source")
assertEqual(BoardStore.shelfKey({ source = "authors", params = { sort = "title", author = "Le Guin" } }),
"authors:author=Le Guin:sort=title", "filters are keyed in sorted order")
assertEqual(BoardStore.shelfKey(nil), "random", "a shelf without props is Discover")
-- Editing, on a four-column board.
local function newCatalog(opts)
opts = opts or {}
local catalog = {
settings = opts.settings or {},
inner_dimen = { w = opts.w or 600, h = opts.h or 800 },
current_context = { kind = "dashboard", board_page = 1, dashboard = { boardShelves = {}, boardNames = {} } },
redraws = 0,
fetches = 0,
}
for name, fn in pairs(BoardEdit) do
if type(fn) == "function" and name:match("^board") then catalog[name] = fn end
end
function catalog:menuChromeHeight() return 0, 0 end
function catalog:dashboardMode() return true end
function catalog:updateItems() self.redraws = self.redraws + 1 end
function catalog:persistSetting(key, value) self.settings[key] = value end
function catalog:boardHasLight() return true end
function catalog:boardPages(cols) return BoardStore.pages(self.settings, cols, true) end
function catalog:boardPage() return self.current_context.board_page end
function catalog:boardSources() return {}, {} end
function catalog:loadDashboardRoot() self.fetches = self.fetches + 1 end
return catalog
end
local catalog = newCatalog()
catalog:boardEnterEdit()
local edit = catalog.board_edit
local eg = catalog:boardGeometry()
local items = edit.pages[1].items
local fitted_home = BoardLayout.fitPage(eg, BoardStore.starterPages(eg.cols, "reader", true)[1].base,
BoardStore.starterPages(eg.cols, "reader", true)[1].extras)
assertEqual(#items, #fitted_home.placed, "editing starts from the starter page as drawn")
-- Earlier and Later swap neighbours.
local first, second = items[1].type, items[2].type
catalog:boardEditRun({ kind = "edit-select", index = 1 })
catalog:boardEditRun({ kind = "edit-move", delta = 1 })
assertEqual(items[1].type .. "," .. items[2].type, second .. "," .. first, "Later swaps with the next widget")
assertEqual(edit.selected, 2, "the selection follows the moved widget")
-- A size that would overflow the page is refused and the widget keeps its size.
shown = {}
local before = items[2].cw .. "x" .. items[2].rh
while catalog:boardPageFits(eg, items) do
table.insert(items, { type = "week", cw = 4, rh = 1 })
end
table.remove(items)
assertEqual(catalog:boardPageFits(eg, items), true, "the page is filled to its limit")
catalog:boardEditRun({ kind = "edit-size", code = "XL" })
assertEqual(items[2].cw .. "x" .. items[2].rh, before, "an overflowing size is refused")
assertEqual(#shown, 1, "the reader is told why")
assertEqual(catalog:boardPageFits(eg, items), true, "the page still fits")
-- Adding to a full page is refused rather than squeezing the page.
shown = {}
local count = #items
catalog:boardEditRun({ kind = "sheet-add", entry = BoardEdit.entryFor({ type = "heatmap" }) })
assertEqual(#items, count, "a widget that cannot fit is not added")
assertEqual(#shown, 1, "the reader is told the page is full")
-- Moving to another page lands on the next page and leaves this one.
catalog:boardEditRun({ kind = "edit-select", index = 1 })
local moved = items[1]
local target_count = #edit.pages[2].items
catalog:boardEditRun({ kind = "edit-page" })
local dest = edit.pages[2].items
if dest[#dest] == moved then
assertEqual(#dest, target_count + 1, "the widget lands on the next page")
assertEqual(items[1] ~= moved, true, "and leaves this one")
else
assertEqual(#dest, target_count, "a full next page refuses the widget")
end
-- Removing frees the room, and leaving edit mode stores the pages for this
-- column count and redraws once.
catalog:boardEditRun({ kind = "edit-remove", index = 1 })
assertEqual(edit.changed, true, "the board knows it was changed")
catalog:boardEditRun({ kind = "edit-done" })
assertEqual(catalog.board_edit, nil, "edit mode ends")
local stored = BoardStore.read(catalog.settings).custom[tostring(eg.cols)]
assertEqual(type(stored) == "table" and #stored.pages, BoardStore.PAGE_COUNT, "every page is stored for this column count")
assertEqual(catalog.fetches, 0, "nothing is fetched when the cache already holds what the board shows")
-- A saved page that no longer fits this screen opens in the editor as it is
-- drawn, so every change can still be checked against the page.
local crowded = {}
for _ = 1, 12 do table.insert(crowded, { type = "week", cw = 4, rh = 1 }) end
catalog = newCatalog()
catalog.settings[BoardStore.SETTING_KEY] = BoardStore.withCustomPages(catalog.settings, eg.cols, { { items = crowded } })
catalog:boardEnterEdit()
assertEqual(#catalog.board_edit.pages[1].items < 12, true, "the editor drops what the screen cannot show")
assertEqual(catalog:boardPageFits(eg, catalog.board_edit.pages[1].items), true, "the edited page fits")
-- Leaving without a change stores nothing.
catalog = newCatalog()
catalog:boardEnterEdit()
catalog:boardEditRun({ kind = "edit-done" })
assertEqual(next(BoardStore.read(catalog.settings).custom), nil, "an untouched board keeps the starter layout")
-- A shelf the board now shows but the cache lacks is fetched after the redraw.
catalog = newCatalog()
function catalog:boardSources() return { ["want-to-read"] = { source = "want-to-read" } }, {} end
catalog:boardRefreshIfMissing()
assertEqual(catalog.redraws, 1, "the board redraws first")
assertEqual(catalog.fetches, 1, "the missing shelf is fetched")
catalog.current_context.dashboard.boardShelves["want-to-read"] = {}
catalog:boardRefreshIfMissing()
assertEqual(catalog.fetches, 1, "a shelf the cache holds, even empty, is not fetched again")
-- Shelves picked on the old four-slot dashboard join the board once it has
-- started, and the old setting is dropped so this happens once.
local legacy = {
{ type = "stats" },
{ type = "continue-reading" },
{ type = "authors", sourceName = "Le Guin", params = { author = "Le Guin" } },
{ type = "browse" },
schemaVersion = DashboardSections.SCHEMA_VERSION,
}
catalog = newCatalog({ settings = { [DashboardSections.SETTING_KEY] = legacy } })
assertEqual(catalog:boardMigrateLegacyShelves(), false, "nothing moves before the board has started")
assertEqual(catalog.settings[DashboardSections.SETTING_KEY] ~= nil, true, "the old setting waits for the start")
catalog.settings[BoardStore.SETTING_KEY] = BoardStore.withPreset(catalog.settings, "reader")
assertEqual(catalog:boardMigrateLegacyShelves(), true, "the migration runs once the board has started")
assertEqual(catalog.settings[DashboardSections.SETTING_KEY], nil, "the old setting is dropped")
local found
for _, page in ipairs(BoardStore.read(catalog.settings).custom[tostring(eg.cols)].pages) do
for _, item in ipairs(page.items) do
if item.type == "shelf" and item.props.source == "authors" then found = item end
end
end
assertEqual(found and found.props.title, "Le Guin", "the picked author became a shelf")
assertEqual(found and found.props.params.author, "Le Guin", "the shelf keeps its filter")
assertEqual(catalog:boardMigrateLegacyShelves(), false, "the migration does not run twice")
assertEqual(BoardLayout.editBarTop(0, 300, 104, 24, 1300), 324, "sparse page toolbar stays below its first tile")
assertEqual(BoardLayout.editBarTop(1000, 250, 104, 24, 1300), 872, "bottom tile toolbar fits above")
assertEqual(BoardLayout.editBarTop(0, 250, 104, 24, 300), 0, "toolbar cannot escape above the grid")
for _, source in ipairs({ "libraries", "collections", "smart-scopes", "authors", "series" }) do
local picked_entry = BoardEdit.entryFor({ type = "shelf", props = { source = source, params = { id = 1 } } })
assertEqual(picked_entry and picked_entry.pick, true, "picked " .. source .. " shelf retains its library entry")
assertEqual(table.concat(picked_entry.sizes, ","), "XL,L", "picked shelf retains both advertised sizes")
end
print("bookorbit_dashboard_layout_test.lua: ok")