forked from akai/readest
f805477091
Opening the Library with a large grouped library was slow because each folder's 2x2 cover mosaic was recomposed from scratch on every paint (up to 4 MuPDF cover decodes plus scales per cell). On a 685-book library this dominated the synchronous open path (254ms of 300ms) and ran again on the post-sync refresh. Navigation felt fast only because drilling into a group shows single covers, not mosaics. Mirror cloud_covers' async pattern in group_covers: - Cache the composed master bb per group, keyed by a signature that flips when the child set or any child's cover availability changes; serve cheap copies on a hit. Cache a nil result too, so a group whose covers are not ready yet keeps its placeholder without recomposing on every refresh; a later cover download flips the signature and recomposes once. - Compose off the first-paint path: a miss enqueues a background job (one mosaic per UI tick) and returns nil so the cell paints its FakeCover placeholder immediately; finished mosaics coalesce into one refresh. - Free cached masters when the Library closes. Measured synchronous open path drops from 300ms to 151ms on a 685-book library; mosaic compositing moves off the blocking paint and fills in progressively. Adds open-path timing logs to librarywidget and localscanner for on-device diagnosis of future large-library reports. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
395 lines
16 KiB
Lua
395 lines
16 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)
|
|
|
|
-- =====================================================================
|
|
-- cover availability + mosaic cache key (issue #4954)
|
|
-- =====================================================================
|
|
-- Group mosaics are cached and served as copies instead of recomposed
|
|
-- on every paint. The cache key must change exactly when a mosaic's
|
|
-- inputs change: the child set OR a previously-missing child cover
|
|
-- becoming available. The availability bit is what fixes the historical
|
|
-- "partial composite served forever" bug the old on-disk cache had.
|
|
describe("cover availability + mosaic cache key", function()
|
|
local real_lfs = require("lfs")
|
|
|
|
local function write_file(path)
|
|
local f = assert(io.open(path, "w"))
|
|
f:write("x")
|
|
f:close()
|
|
end
|
|
|
|
local function write_cover(hash)
|
|
local dir = cloud_covers.covers_dir()
|
|
real_lfs.mkdir(dir)
|
|
write_file(dir .. "/" .. hash .. ".png")
|
|
end
|
|
|
|
describe("cloud_covers.cover_exists", function()
|
|
it("is false when no <hash>.png is on disk", function()
|
|
assert.is_false(cloud_covers.cover_exists("missing1"))
|
|
end)
|
|
|
|
it("is true once the <hash>.png file exists", function()
|
|
write_cover("present1")
|
|
assert.is_true(cloud_covers.cover_exists("present1"))
|
|
end)
|
|
end)
|
|
|
|
describe("group_covers.child_cover_available", function()
|
|
it("is true for a cloud child whose cover .png is cached", function()
|
|
write_cover("cloudcov")
|
|
assert.is_true(group_covers.child_cover_available(
|
|
{ hash = "cloudcov", cloud_present = 1, local_present = 0 }))
|
|
end)
|
|
|
|
it("is false for a cloud child whose cover isn't downloaded yet", function()
|
|
assert.is_false(group_covers.child_cover_available(
|
|
{ hash = "nocover1", cloud_present = 1, local_present = 0 }))
|
|
end)
|
|
|
|
it("is true for a local child whose file is on disk", function()
|
|
local path = require("datastorage"):getSettingsDir() .. "/localbook.epub"
|
|
write_file(path)
|
|
assert.is_true(group_covers.child_cover_available(
|
|
{ hash = "localbk1", local_present = 1, file_path = path }))
|
|
end)
|
|
|
|
it("is false when neither a local file nor a cached cover exists", function()
|
|
assert.is_false(group_covers.child_cover_available(
|
|
{ hash = "ghost001", local_present = 1, file_path = "/no/such/file.epub" }))
|
|
end)
|
|
end)
|
|
|
|
describe("group_covers.mosaic_cache_key", function()
|
|
local books = {
|
|
{ hash = "aaa" }, { hash = "bbb" }, { hash = "ccc" }, { hash = "ddd" },
|
|
}
|
|
|
|
it("is stable across calls with identical inputs (enables cache hits)", function()
|
|
assert.are.equal(
|
|
group_covers.mosaic_cache_key("group_name", "Fantasy", "grid", books),
|
|
group_covers.mosaic_cache_key("group_name", "Fantasy", "grid", books))
|
|
end)
|
|
|
|
it("changes when the child set changes", function()
|
|
local other = {
|
|
{ hash = "aaa" }, { hash = "zzz" }, { hash = "ccc" }, { hash = "ddd" },
|
|
}
|
|
assert.are_not.equal(
|
|
group_covers.mosaic_cache_key("group_name", "Fantasy", "grid", books),
|
|
group_covers.mosaic_cache_key("group_name", "Fantasy", "grid", other))
|
|
end)
|
|
|
|
it("changes when a missing child cover becomes available (anti-regression)", function()
|
|
local one = { { hash = "latecov" } }
|
|
local before = group_covers.mosaic_cache_key("author", "Asimov", "grid", one)
|
|
write_cover("latecov")
|
|
local after = group_covers.mosaic_cache_key("author", "Asimov", "grid", one)
|
|
assert.are_not.equal(before, after)
|
|
end)
|
|
|
|
it("distinguishes different groups and shapes", function()
|
|
assert.are_not.equal(
|
|
group_covers.mosaic_cache_key("group_name", "A", "grid", books),
|
|
group_covers.mosaic_cache_key("group_name", "A", "list", books))
|
|
assert.are_not.equal(
|
|
group_covers.mosaic_cache_key("group_name", "A", "grid", books),
|
|
group_covers.mosaic_cache_key("group_name", "B", "grid", books))
|
|
end)
|
|
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)
|