Files
readest/apps/readest.koplugin/spec/library/group_covers_spec.lua
T
Huang Xin 15c0a7a2f2 fix(koplugin): render group cover previews in Library (#4064)
Group cells in the Readest Library view rendered as FakeCover even when
their child books had perfectly good cloud covers — the queue that
fetches <hash>.png covers was only primed for cloud-only book entries
on the visible page, so children of group entries were never requested.
A later partial composite (3/4 covers) was also written to a
content-fingerprinted disk cache and kept serving forever, since the
fingerprint stayed the same after the 4th cover landed.

Two fixes wired together:
- libraryitem.set_visible_hashes now expands visible group entries to
  include their first-N children's hashes, so trigger_download's
  visibility filter no longer rejects them.
- group_covers.child_cover_bb queues a cloud-cover download when the
  fallback path misses for a cloud-present book. Capped at 4 per group
  by the existing cells_for(shape) limit.

Disk caching of composites is dropped entirely; mosaics are recomposed
in memory each paint. New spec/library/group_covers_spec.lua locks the
contract for URI round-trip, child_cover_bb's missing-cover branches,
and libraryitem's group-children expansion.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:01:35 +02:00

297 lines
12 KiB
Lua

-- group_covers_spec.lua
-- Pure-function tests for library/group_covers.lua. The Blitbuffer-driven
-- compose() pipeline calls into KOReader's FFI graphics stack and isn't
-- unit-testable here, so it's exercised by the manual matrix. These specs
-- lock down:
-- * URI builder/parser round-trip (incl. nested group paths with "/")
-- * child_cover_bb's cover-missing branch — must queue a cloud-cover
-- download for cloud-present children so a subsequent paint can
-- compose the mosaic. Without this hook, a freshly-pulled library
-- renders every group as FakeCover until the user drills into each
-- group individually (see https://github.com/readest/readest/… for
-- the bug report).
require("spec_helper")
local lfs = require("lfs")
-- Minimal real-lfs shim for production code that imports
-- "libs/libkoreader-lfs". The KOReader binding extends LuaFileSystem with
-- a few extras we don't need here.
package.preload["libs/libkoreader-lfs"] = function() return lfs end
-- Stub renderimage so cloud_covers.load_cover_bb can be exercised without
-- pulling in the full KOReader graphics stack. We always return nil
-- (= "couldn't decode") so the trigger-download branch is reachable.
package.preload["ui/renderimage"] = function()
return {
renderImageFile = function() return nil end,
}
end
describe("library.group_covers", function()
local group_covers, cloud_covers
before_each(function()
require("spec_helper").reset()
package.loaded["library.cloud_covers"] = nil
package.loaded["library.group_covers"] = nil
cloud_covers = require("library.cloud_covers")
group_covers = require("library.group_covers")
end)
-- =====================================================================
-- build_uri / parse_uri round-trip
-- =====================================================================
describe("build_uri / parse_uri", function()
it("round-trips a simple group name", function()
local uri = group_covers.build_uri("group_name", "Fantasy", "grid")
local g, v, s = group_covers.parse_uri(uri)
assert.are.equal("group_name", g)
assert.are.equal("Fantasy", v)
assert.are.equal("grid", s)
end)
it("preserves slashes inside nested group paths", function()
local uri = group_covers.build_uri("group_name", "Selected/Very", "grid")
local g, v, s = group_covers.parse_uri(uri)
assert.are.equal("group_name", g)
assert.are.equal("Selected/Very", v)
assert.are.equal("grid", s)
end)
it("defaults to grid shape when none is given", function()
local uri = group_covers.build_uri("author", "Asimov")
local _, _, s = group_covers.parse_uri(uri)
assert.are.equal("grid", s)
end)
it("returns nil for non-group URIs", function()
assert.is_nil(group_covers.parse_uri("/local/path/file.epub"))
assert.is_nil(group_covers.parse_uri("readest-cloud://abc.epub"))
end)
end)
-- =====================================================================
-- cells_for: layout cell count
-- =====================================================================
describe("cells_for", function()
it("returns 4 for both grid and list shapes", function()
assert.are.equal(4, group_covers.cells_for("grid"))
assert.are.equal(4, group_covers.cells_for("list"))
end)
it("falls back to grid (4) for unknown shapes", function()
assert.are.equal(4, group_covers.cells_for("nonsense"))
end)
end)
-- =====================================================================
-- child_cover_bb: cloud-cover trigger when cover .png is missing
-- =====================================================================
describe("child_cover_bb missing-cover behaviour", function()
local trigger_calls
before_each(function()
-- Auth must be present for trigger_download to even consider
-- the call; otherwise it bails early (intentional safety net
-- in production).
cloud_covers.set_opts({ sync_auth = "fake-token" })
-- Record trigger calls and short-circuit so we never actually
-- hit the network or try to dequeue.
trigger_calls = {}
cloud_covers.trigger_download = function(hash, opts)
trigger_calls[#trigger_calls + 1] = { hash = hash, opts = opts }
end
end)
it("queues a cloud-cover download for a cloud-only child whose .png isn't on disk", function()
local cover = group_covers.child_cover_bb(
{ hash = "abc12345", cloud_present = 1, local_present = 0 },
nil, nil)
assert.is_nil(cover)
assert.are.equal(1, #trigger_calls)
assert.are.equal("abc12345", trigger_calls[1].hash)
end)
it("queues a download for a hybrid (cloud + local) book when its cover isn't extracted yet", function()
-- BIM hasn't seen this file yet — orig_getBookInfo returns nil.
local orig_getBookInfo = function() return nil end
local cover = group_covers.child_cover_bb(
{
hash = "hybrid01",
cloud_present = 1,
local_present = 1,
file_path = "/no/such/file.epub",
},
orig_getBookInfo, {})
assert.is_nil(cover)
assert.are.equal(1, #trigger_calls)
assert.are.equal("hybrid01", trigger_calls[1].hash)
end)
it("does not queue a download for a local-only child (nothing to fetch)", function()
local orig_getBookInfo = function() return nil end
local cover = group_covers.child_cover_bb(
{
hash = "local001",
cloud_present = 0,
local_present = 1,
file_path = "/no/such/file.epub",
},
orig_getBookInfo, {})
assert.is_nil(cover)
assert.are.equal(0, #trigger_calls)
end)
it("does not queue a download when the child has no hash (defensive)", function()
local cover = group_covers.child_cover_bb(
{ cloud_present = 1, local_present = 0 }, nil, nil)
assert.is_nil(cover)
assert.are.equal(0, #trigger_calls)
end)
end)
end)
-- =====================================================================
-- libraryitem.set_visible_hashes — group children expansion
-- =====================================================================
-- The patched BIM only triggers child cover downloads when the child's
-- hash is in cloud_covers' visibility set. Without expanding visible
-- groups → first-N children, trigger_download in child_cover_bb is a
-- no-op and the mosaic never composes. These tests pin that contract.
describe("library.libraryitem.set_visible_hashes", function()
local libraryitem, cloud_covers
local fake_widget
before_each(function()
require("spec_helper").reset()
package.loaded["library.cloud_covers"] = nil
package.loaded["library.group_covers"] = nil
package.loaded["library.bim_patch"] = nil
package.loaded["library.cloud_icons"] = nil
package.loaded["library.list_strip"] = nil
package.loaded["library.libraryitem"] = nil
package.loaded["library.librarywidget"] = nil
-- Fake LibraryWidget so libraryitem can pick up the store.
-- libraryitem reads package.loaded directly (not require), so a
-- preload won't suffice — populate the loaded slot itself.
fake_widget = { _store = nil }
package.loaded["library.librarywidget"] = fake_widget
cloud_covers = require("library.cloud_covers")
libraryitem = require("library.libraryitem")
end)
after_each(function()
package.loaded["library.librarywidget"] = nil
end)
-- Capture whatever set libraryitem hands cloud_covers, regardless of
-- whether trigger_download itself is exercised.
local function capture_set()
local captured
cloud_covers.set_visible_hashes = function(set) captured = set end
return function() return captured end
end
-- Minimal store stub: returns the books we tell it to, keyed by
-- (group_by, name). listBooksInGroup is the only method the wrapper
-- calls.
local function fake_store(by_group)
return {
listBooksInGroup = function(_self, group_by, name, _n, _opts)
local key = group_by .. ":" .. name
return by_group[key] or {}
end,
}
end
it("clears the visibility set when called with nil menu", function()
local get = capture_set()
libraryitem.set_visible_hashes(nil)
assert.is_nil(get())
end)
it("includes hashes for cloud-only book entries on the visible page", function()
local get = capture_set()
local menu = {
page = 1, perpage = 3,
item_table = {
{ [libraryitem.CLOUD_ONLY_FLAG] = true, file = "readest-cloud://aaa.epub" },
{ [libraryitem.CLOUD_ONLY_FLAG] = true, file = "readest-cloud://bbb.epub" },
-- a local book — should NOT contribute
{ is_file = true, file = "/local/book.epub" },
},
}
libraryitem.set_visible_hashes(menu)
local set = get()
assert.is_truthy(set["aaa"])
assert.is_truthy(set["bbb"])
local n = 0
for _ in pairs(set) do n = n + 1 end
assert.are.equal(2, n)
end)
it("expands visible group entries to include their first-N children's hashes", function()
local get = capture_set()
fake_widget._store = fake_store({
["group_name:Selected/Very"] = {
{ hash = "child01" }, { hash = "child02" },
{ hash = "child03" }, { hash = "child04" },
},
["group_name:Selected/Philosophy"] = {
{ hash = "phil01" }, { hash = "phil02" },
},
})
local menu = {
page = 1, perpage = 3,
item_table = {
{
_readest_group = {
name = "Selected/Very",
_group_by = "group_name",
},
},
{
_readest_group = {
name = "Selected/Philosophy",
_group_by = "group_name",
},
},
-- A book entry on the same page
{ [libraryitem.CLOUD_ONLY_FLAG] = true, file = "readest-cloud://book01.epub" },
},
}
libraryitem.set_visible_hashes(menu)
local set = get()
-- Group children
assert.is_truthy(set["child01"])
assert.is_truthy(set["child04"])
assert.is_truthy(set["phil01"])
assert.is_truthy(set["phil02"])
-- Cloud-only book on the same page
assert.is_truthy(set["book01"])
end)
it("only walks the current page's window, not the full item_table", function()
local get = capture_set()
fake_widget._store = fake_store({
["group_name:OnPage1"] = { { hash = "child01" } },
["group_name:OnPage2"] = { { hash = "child99" } },
})
local menu = {
page = 2, perpage = 1,
item_table = {
{ _readest_group = { name = "OnPage1", _group_by = "group_name" } },
{ _readest_group = { name = "OnPage2", _group_by = "group_name" } },
},
}
libraryitem.set_visible_hashes(menu)
local set = get()
assert.is_nil(set["child01"])
assert.is_truthy(set["child99"])
end)
end)