Files
bookorbit/koreader-plugin/spec/bookorbit_dashboard_layout_test.lua
T
Jadehawk 1f4420b454 feat(koreader): support customizable dashboard sections (#820)
* feat(koreader): add dashboard row configuration model

* feat(koreader): reuse catalog for dashboard source selection

* feat(koreader): configure dashboard rows on 1.4.0

* chore(koreader): preserve dashboard source line endings

* fix(koreader): keep BookOrbit tools menu loadable

* fix(koreader): keep dashboard requests out of rendering

* revert(koreader): restore stock 1.4.0 dashboard renderer

* revert(koreader): use stock single dashboard row

* fix(koreader): apply selected dashboard row source

* feat(koreader): add second dashboard row setting

* feat(koreader): render second dashboard row

* fix(koreader): fetch configured dashboard rows directly

* fix(koreader): honor configured dashboard row sources

* feat(koreader): support four configurable dashboard sections

* fix(koreader): use real catalog dashboard sources

* fix(koreader): select dashboard shelves from catalog

* fix(koreader): reuse catalog for dashboard source selection

* fix(koreader): close dashboard menu before catalog selection

* test(koreader): split dashboard configuration coverage

* chore(koreader): bump version to 1.5.0

* fix(koreader): address dashboard review feedback

* fix(koreader): address dashboard section review feedback

* test(koreader): cover dashboard behavior at runtime

* test(koreader): use Nest setup for dashboard DTO

* chore(ci): trigger pipeline

* fix(koreader): migrate untagged dashboard section settings

Plugin 1.4.x stored the configurable dashboard rows as a bare ordered
list with no schema marker, so gating the migration on schemaVersion == 1
never matched anything a device had actually written. Every upgrading
user who had ever picked a row was read positionally instead: the Stats
strip disappeared and Discover ended up in two slots, with a stored
SmartScope dropped entirely.

Treat an untagged stored list as the legacy shape and migrate it into the
slots those rows used to occupy. Cover it with the settings shapes 1.4.x
really wrote, rather than a marker nothing ever produced.

Also drop store() and primary(), which no caller uses now that slots are
written through storeAt(), along with the dead grow loop inside storeAt()
that normalize() already makes unreachable.

* fix(koreader): keep dashboard shelves in aspect, inside the page, and deduped

The four-slot renderer handed each shelf a height derived from the
leftover vertical budget while its cover cards kept the width computed
from the natural row, so any page with more than one shelf drew covers
out of aspect. It also had no answer for a page too small for the
configured slots: shelves clamped to a 48px floor and the layout simply
overflowed the available height.

Size shelves from their cards instead. A shelf that does not fit gets
more, narrower cards per row, so its height is always the height those
cards need. When even the narrowest row does not fit, drop slots the way
the pre-four-slot renderer did: the stats strip first, then shelves from
the bottom up.

Fetch each distinct shelf source once per dashboard load. Two slots
showing Discover previously rendered the same list twice, and two slots
on the same catalog filter each paid for their own request; a source that
fails is now also not retried by the next slot that wants it.

Drop what the four-slot rewrite left behind: the unused capabilities
require, the tall-layout helper and its constants, and the section
accessors and request helper that no longer have callers. Name the
per-shelf page size and the minimum cover width instead of repeating the
literals. Add setDashboardSections() so a whole configuration can be
applied in one refresh.

Add a layout spec that drives updateDashboardItems and asserts covers
stay in aspect, the drop order holds, and nothing is laid out past the
available height.

* fix(koreader): close the dashboard slot menu through its own API

Reset to Default wrote section_stale as a boolean where the renderer
reads a per-slot table, so the reset never actually marked anything
pending and stale books stayed on screen under the new headers until the
refresh landed. It also dereferenced current_context without a guard and
wrote the setting behind the catalog's back. Route it through the new
setDashboardSections() so the slots and the cache signature move
together in one refresh.

Close the invoking touch menu with closeMenu() rather than handing the
widget to UIManager:close(), which skips the close_callback that owns its
container.

Drop the section parameter from catalogDashboard() and the unused
catalogDashboardSection() helper: the plugin builds its shelves from the
ordinary catalog endpoints now and sends neither. The server keeps both
routes for devices still on 1.4.x, so its DTO test stays; rename it to
match the file it covers and drop the empty Nest module compile.

* revert(koreader): keep the plugin at 1.4.0

1.4.0 has not shipped yet: 1.3.0 is the last released plugin version, so
the dashboard slot work can land inside 1.4.0 rather than claiming a
version of its own. Devices in the field are on 1.3.0 and see a single
1.3.0 -> 1.4.0 step either way.

* fix(koreader): stop rotating upright read-status badges

buildReadStatusBadge turned every status icon a quarter turn, but only
two of the seven are corner folds. The dogear assets are drawn with their
right angle at the top left and the badge sits in the cover's top-right
corner, so those do need the turn. Bookmark, check and reload are upright
glyphs and were being drawn on their side: a want-to-read cover showed
its bookmark ribbon lying sideways like a chevron.

Rotate only the dogear icons.

---------

Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com>
2026-07-30 13:37:26 -06:00

189 lines
7.9 KiB
Lua

-- Proves the dashboard's vertical budget: that shelves are measured at the
-- height their cover cards actually need, that a page too tight for the
-- configured slots drops the stats strip before any shelf, and that nothing is
-- laid out past the available height.
package.loaded["document/documentregistry"] = { hasProvider = function() return true end }
package.loaded["util"] = {
fixUtf8 = function(value) return value end,
urlEncode = function(value) return value end,
}
package.loaded["ffi/util"] = { template = function(value) return value end }
package.loaded["gettext"] = function(text) return text end
local function widgetClass(height)
return {
new = function(_, opts)
opts = opts or {}
if not opts.getSize then
function opts:getSize()
return { w = 100, h = self.width or height or 20 }
end
end
return opts
end,
}
end
package.loaded["ffi/blitbuffer"] = { COLOR_DARK_GRAY = 1, COLOR_LIGHT_GRAY = 2 }
package.loaded["ui/widget/button"] = widgetClass()
package.loaded["ui/widget/container/centercontainer"] = widgetClass()
package.loaded["ui/font"] = { getFace = function() return {} end }
package.loaded["ui/geometry"] = widgetClass()
package.loaded["ui/widget/horizontalgroup"] = widgetClass(0)
package.loaded["ui/widget/horizontalspan"] = widgetClass(0)
package.loaded["ui/widget/infomessage"] = widgetClass()
package.loaded["ui/widget/linewidget"] = widgetClass()
package.loaded["ui/network/manager"] = { isConnected = function() return true end }
package.loaded["device"] = { screen = { scaleBySize = function(_, value) return value end } }
package.loaded["ui/size"] = {
line = { thin = 1 },
padding = { large = 8 },
span = { horizontal_default = 4, vertical_default = 4 },
}
package.loaded["ui/widget/textboxwidget"] = widgetClass()
package.loaded["ui/uimanager"] = { show = function() end }
package.loaded["ui/widget/verticalgroup"] = widgetClass(0)
package.loaded["ui/widget/verticalspan"] = widgetClass()
package.loaded["bookorbit_stats_reader"] = {
getReadingSummary = function()
return { today_seconds = 600, week_seconds = 3600, streak_days = 3 }
end,
}
local HEADER_HEIGHT = 20
package.loaded["bookorbit_catalog_widgets"] = {
DashboardCoverCard = widgetClass(),
DashboardHeroCard = widgetClass(),
DashboardBrowseRow = widgetClass(),
DashboardIconButton = widgetClass(),
buildDashboardSectionHeader = function()
return { getSize = function() return { w = 100, h = HEADER_HEIGHT } end }
end,
coverCardHeight = function(card_w)
return card_w + 20
end,
}
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
local CatalogDashboard = require("bookorbit_catalog_dashboard")
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 STATS_HEIGHT = 60
local function books(count)
local list = {}
for id = 1, count do list[id] = { id = id } end
return list
end
local function newCatalog(opts)
local slots = {}
for index = 1, DashboardSections.SLOT_COUNT do
slots[index] = { type = opts.sections[index].type, books = books(opts.books or 12) }
end
local catalog = {
settings = {
[DashboardSections.SETTING_KEY] = {
opts.sections[1], opts.sections[2], opts.sections[3], opts.sections[4],
schemaVersion = DashboardSections.SCHEMA_VERSION,
},
},
available_height = opts.avail,
content_w = opts.content_w or 500,
content_inset = 0,
item_group = {},
layout = {},
current_context = {
kind = "dashboard",
dashboard = { continueReading = books(4), dashboardSlots = slots },
},
shelves = {},
headers = {},
}
for name, fn in pairs(CatalogDashboard) do
if name ~= "install" then catalog[name] = fn end
end
function catalog:prepareCustomUpdate() return nil end
function catalog:finishCustomUpdate() end
function catalog:ensureOnDeviceCurrent() end
function catalog:isOnDevice() return false end
function catalog:onDeviceCount() return 0 end
function catalog:readStatusLabel() return nil end
function catalog:buildDashboardStatsStrip()
return { getSize = function() return { w = 100, h = STATS_HEIGHT } end }
end
local addHeader = catalog.addDashboardHeader
function catalog:addDashboardHeader(text, controls)
table.insert(self.headers, text)
return addHeader(self, text, controls)
end
local addGrid = catalog.addDashboardCoverGrid
function catalog:addDashboardCoverGrid(section_id, list, height, with_progress, with_caption, cols, card_w, page, rows)
table.insert(self.shelves, { id = section_id, height = height, cols = cols, card_w = card_w })
return addGrid(self, section_id, list, height, with_progress, with_caption, cols, card_w, page, rows)
end
return catalog
end
local DEFAULTS = {
{ type = "stats" }, { type = "continue-reading" }, { type = "random" }, { type = "browse" },
}
local FOUR_SHELVES = {
{ type = "random" }, { type = "recently-added" }, { type = "in-progress" }, { type = "random" },
}
-- A roomy page renders every configured slot, and each shelf is exactly as tall
-- as its cover cards need to be.
local catalog = newCatalog{ sections = DEFAULTS, avail = 800 }
catalog:updateDashboardItems()
assertEqual(#catalog.shelves, 1, "the default dashboard renders its one shelf")
assertEqual(#catalog.headers, 3, "Continue reading, the shelf and Browse each get a header")
assertEqual(catalog.shelves[1].height, catalog.shelves[1].card_w + 20, "a roomy shelf is drawn in aspect")
assertEqual(catalog.shelves[1].cols, 5, "a roomy shelf keeps its natural slot count")
assertEqual(catalog.dash_used <= 800, true, "a roomy page stays inside the available height")
-- Four shelves share the page and stay in aspect: the cards get narrower rather
-- than being squashed to fit.
catalog = newCatalog{ sections = FOUR_SHELVES, avail = 600 }
catalog:updateDashboardItems()
assertEqual(#catalog.shelves, 4, "four configured shelves all render")
for _, shelf in ipairs(catalog.shelves) do
assertEqual(shelf.height, shelf.card_w + 20, "every shelf is drawn in aspect")
end
assertEqual(catalog.shelves[1].cols > 5, true, "a crowded page fits more cards per row")
assertEqual(catalog.dash_used <= 600, true, "four shelves stay inside the available height")
-- Too tight for everything: the stats strip goes before any shelf does.
catalog = newCatalog{ sections = DEFAULTS, avail = 450 }
catalog:updateDashboardItems()
assertEqual(#catalog.shelves, 1, "the shelf survives a tight page")
assertEqual(catalog.headers[1], "Continue reading", "the stats strip is dropped first")
assertEqual(catalog.dash_used <= 450, true, "dropping stats brings the page back inside its height")
-- Tighter still: shelves go from the bottom up, and the native slots stay.
catalog = newCatalog{ sections = FOUR_SHELVES, avail = 260 }
catalog:updateDashboardItems()
assertEqual(#catalog.shelves < 4, true, "an impossible page drops shelves from the bottom")
assertEqual(catalog.shelves[1].id, "section1", "the first configured shelf is the last to go")
assertEqual(catalog.dash_used <= 260, true, "dropping shelves brings the page back inside its height")
-- A slot waiting on a refresh renders its placeholder rather than the books
-- cached for the previous choice.
catalog = newCatalog{ sections = DEFAULTS, avail = 800 }
catalog.current_context.section_stale = { [3] = true }
catalog:updateDashboardItems()
assertEqual(#catalog.shelves, 0, "a pending shelf renders no cover grid")
assertEqual(catalog.headers[2], "Discover", "a pending shelf still shows its header")
print("bookorbit_dashboard_layout_test.lua: ok")