Files
bookorbit/koreader-plugin/spec/bookorbit_sweep_chunking_test.lua
T
neon edc2a2ccfc feat(reader): make KOReader plugin sync non-blocking and resumable
Lifecycle handlers previously blocked on network work, so closing or suspending a book stalled the
device on slow or offline links. Persist snapshots first and drain them through a lifecycle outbox
with connected-state gates and phase-aware retries, so the UI stays responsive and pending work
survives restarts.

Move persistence behind a state manager with atomic flushes, and chunk sweeps, bulk downloads, and
annotation exchange through a background queue with checkpoints and transfer policies, so large
libraries make progress incrementally instead of restarting from scratch after an interruption.

Server side, add bulk manifest and page stats endpoints with batched queries and catalog query
filtering so the plugin can reconcile many books per round trip rather than one request per book.
2026-07-28 02:06:50 +00:00

142 lines
5.0 KiB
Lua

-- Library-sized sweep phases must yield. Enumeration and sidecar scanning run
-- in bounded chunks, and because the user can open a book between chunks, each
-- phase iterates a snapshot of its keys and re-resolves every entry when its
-- chunk actually runs. A ReadHistory reorder mid-sweep must therefore neither
-- skip nor double-process an entry.
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;koreader-plugin/spec/?.lua;" .. package.path
local SweepHarness = require("helpers/sweep_harness")
local HISTORY_CHUNK = 40
local SIDECAR_CHUNK = 20
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
-- Runs one scheduled callback at a time and reports how much work each did, so
-- "the phase yields" is proven by the slice sizes rather than assumed.
local function drainMeasuring(harness, count)
local slices = {}
local previous = count()
while harness.scheduler:runOne() do
local current = count()
if current > previous then
table.insert(slices, current - previous)
previous = current
end
end
return slices
end
local function maxOf(values)
local largest = 0
for _, value in ipairs(values) do
if value > largest then largest = value end
end
return largest
end
-- ReadHistory resolution.
do
local history = {}
for index = 1, 100 do
table.insert(history, { file = string.format("/books/%03d.epub", index), time = index, text = "Book" })
end
local harness = SweepHarness.install{
history = history,
has_sidecar_files = true,
library_version = "v1",
}
local seen, total = {}, 0
package.loaded["util"].partialMD5 = function(file)
seen[file] = (seen[file] or 0) + 1
total = total + 1
if total == HISTORY_CHUNK then
-- Opening a book mid-sweep reorders the live list and can drop an
-- entry the snapshot still names.
local reordered = {}
for index = #harness.history, 1, -1 do
local item = harness.history[index]
if item.file ~= "/books/041.epub" then
table.insert(reordered, item)
end
end
package.loaded["readhistory"].hist = reordered
end
return "md5-" .. file
end
local Sweep = require("bookorbit_sweep")
assertEqual(Sweep.run{ api = {}, interactive = true, annotation_sync = false }, true, "the sweep starts")
local slices = drainMeasuring(harness, function() return total end)
assertEqual(total, 99, "every entry that still exists is resolved exactly once")
assertEqual(seen["/books/041.epub"], nil, "an entry removed mid-sweep is tolerated, not resolved")
for index = 1, 100 do
local file = string.format("/books/%03d.epub", index)
if file ~= "/books/041.epub" then
assertEqual(seen[file], 1, "no entry is processed twice after the list reorders")
end
end
assert(#slices > 1, "resolution is split across scheduled callbacks")
assert(maxOf(slices) <= HISTORY_CHUNK,
"no single callback resolves more than the chunk size, got " .. maxOf(slices))
end
-- Matched-book sidecar scanning.
do
local books = {}
for index = 1, 50 do
books[string.format("digest%02d", index)] = {
bookId = index,
fileId = 100 + index,
file = string.format("/books/%02d.epub", index),
statsWatermark = 0,
annWatermark = "",
annCount = 0,
sidecarMtime = 1,
ratingSyncedKnown = true,
reviewSyncedKnown = true,
matchVerifiedAt = os.time(),
matchVerifiedVersion = "v1",
}
end
local scanned, total = {}, 0
local harness = SweepHarness.install{
library_version = "v1",
state = { books = books, global = { libraryVersion = "v1" } },
sidecar = {
sidecarMtime = function(file)
scanned[file] = (scanned[file] or 0) + 1
total = total + 1
return 1
end,
},
}
local Sweep = require("bookorbit_sweep")
assertEqual(Sweep.run{ api = {}, interactive = true, annotation_sync = false }, true, "the sweep starts")
local slices = drainMeasuring(harness, function() return total end)
assertEqual(total, 50, "every matched book is scanned exactly once")
for index = 1, 50 do
assertEqual(scanned[string.format("/books/%02d.epub", index)], 1, "no book is scanned twice")
end
assert(#slices > 1, "sidecar scanning is split across scheduled callbacks")
assert(maxOf(slices) <= SIDECAR_CHUNK,
"no single callback scans more than the chunk size, got " .. maxOf(slices))
assertEqual(#harness.calls.match, 0, "books verified inside the age bound issue no match request")
end
print("bookorbit_sweep_chunking_test.lua: ok")