Files
bookorbit/koreader-plugin/spec/helpers/sweep_harness.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

304 lines
11 KiB
Lua

--[[--
Deterministic environment for driving BookOrbitSweep from a standalone spec.
It installs the KOReader modules the sweep touches, a fake statistics database
backed by helpers/fake_sqlite, a fake scheduler so chunk callbacks advance on
demand instead of on wall time, and a recording API client. Time is frozen
(gettime returns 0), so chunk boundaries fall on the item count rather than on
how fast the machine running the spec happens to be.
Call install() before requiring bookorbit_sweep.
]]
local FakeScheduler = require("helpers/fake_scheduler")
local FakeSqlite = require("helpers/fake_sqlite")
local SweepHarness = {}
local function joinArgs(...)
local parts = {}
for index = 1, select("#", ...) do
table.insert(parts, tostring((select(index, ...))))
end
return table.concat(parts, " ")
end
local function statisticsResponder(handle)
return function(sql, params)
if sql:find("FROM book WHERE md5 IS NOT NULL", 1, true) then
local after_id, limit = params[1] or 0, params[2] or 50
local rows = {}
for _, book in ipairs(handle.books) do
if book.id > after_id and #rows < limit then
table.insert(rows, {
tostring(book.id), book.md5, book.title or "",
book.authors or "", tostring(book.last_open or 0),
})
end
end
return FakeSqlite.resultSet(rows)
end
if sql:find("MAX(start_time)", 1, true) then
handle.calls.latest_event_queries = handle.calls.latest_event_queries + 1
local rows = {}
for id, events in pairs(handle.events) do
local newest = 0
for _, event in ipairs(events) do
if event.start_time > newest then newest = event.start_time end
end
table.insert(rows, { tostring(id), tostring(newest) })
end
table.sort(rows, function(a, b) return a[1] < b[1] end)
return FakeSqlite.resultSet(rows)
end
if sql:find("FROM page_stat_data", 1, true) then
handle.calls.event_queries = handle.calls.event_queries + 1
local watermark = params[#params - 1] or 0
local rows = {}
for index = 1, #params - 2 do
for _, event in ipairs(handle.events[params[index]] or {}) do
if event.start_time > watermark then
table.insert(rows, {
tostring(event.page), tostring(event.start_time),
tostring(event.duration or 60), tostring(event.total_pages or 100),
})
end
end
end
return FakeSqlite.resultSet(rows)
end
return nil
end
end
local function installClient(handle)
local responses = handle.responses
return {
isConfigured = function() return true end,
matchCheck = function(_, hashes)
table.insert(handle.calls.match, hashes)
local matches = {}
for _, hash in ipairs(hashes) do
if responses.unmatched[hash] then
-- left out of the response on purpose
else
table.insert(matches, { hash = hash, bookId = 1, bookFileId = 2 })
end
end
return { matches = matches, libraryVersion = responses.library_version }
end,
uploadPageStats = function(_, books)
table.insert(handle.calls.page_stats, books)
return { results = {}, unmatched = {} }
end,
uploadBookStates = function(_, batch)
table.insert(handle.calls.book_states, batch)
return { results = {}, unmatched = {} }
end,
bulkProgress = function(_, batch)
table.insert(handle.calls.progress, batch)
return { unmatched = {} }
end,
uploadAnnotations = function(_, books)
table.insert(handle.calls.legacy_annotations, books)
return { unmatched = {} }
end,
sweepComplete = function()
handle.calls.sweep_complete = handle.calls.sweep_complete + 1
return { libraryVersion = responses.library_version }
end,
}
end
--[[--
opts:
- books: statistics rows, { md5, id, title, authors, last_open }
- events: map of statistics row id to { page, start_time, duration, total_pages }
- history: ReadHistory entries, { file, time, text }
- state: initial { books, unmatched, files, global }
- library_version: token every server response carries
- unmatched: set of hashes match-check refuses to match
- sidecar: overrides for the BookOrbitSidecar stub
]]
function SweepHarness.install(opts)
opts = opts or {}
-- Plugin modules capture their stubs at load time, so a second harness in
-- the same process has to make them resolve again or it silently keeps
-- talking to the previous fixture.
for name in pairs(package.loaded) do
if type(name) == "string" and name:match("^bookorbit_") then
package.loaded[name] = nil
end
end
local handle = {
books = opts.books or {},
events = opts.events or {},
history = opts.history or {},
responses = {
library_version = opts.library_version,
unmatched = opts.unmatched or {},
},
calls = {
match = {},
page_stats = {},
book_states = {},
progress = {},
legacy_annotations = {},
sweep_complete = 0,
event_queries = 0,
latest_event_queries = 0,
scheduled = 0,
sidecar_extracts = {},
history_resolved = {},
},
flushes = 0,
}
handle.scheduler = FakeScheduler.new()
handle.sqlite = FakeSqlite.install(statisticsResponder(handle), opts.sqlite)
package.loaded["datastorage"] = { getSettingsDir = function() return "/nonexistent" end }
package.loaded["luasettings"] = {
open = function()
return {
data = {},
readSetting = function(_, _, default) return default end,
saveSetting = function() end,
flush = function() end,
}
end,
}
package.loaded["dump"] = function() return "{}" end
package.loaded["logger"] = {
dbg = function() end,
info = function() end,
warn = function() end,
-- A sweep step that throws must fail the spec rather than be swallowed
-- by the step's own error handling.
err = function(...) error(joinArgs(...), 0) end,
}
package.loaded["ffi/util"] = {
template = function(text) return text end,
-- Frozen clock: chunk boundaries then depend only on the item count.
gettime = function() return 0, 0 end,
}
package.loaded["gettext"] = function(value) return value end
package.loaded["util"] = {
partialMD5 = function(file) return "md5-" .. file end,
trim = function(value) return value end,
}
package.loaded["libs/libkoreader-lfs"] = {
attributes = function(_, what)
if what == "mode" then return "file" end
return nil
end,
}
package.loaded["readhistory"] = { hist = handle.history }
package.loaded["docsettings"] = {
hasSidecarFile = function() return opts.has_sidecar_files == true end,
open = function()
return {
readSetting = function() return nil end,
saveSetting = function() end,
makeTrue = function() end,
flush = function() end,
}
end,
}
package.loaded["apps/reader/readerui"] = { instance = nil }
package.loaded["ui/uimanager"] = {
scheduleIn = function(_, delay, callback)
handle.calls.scheduled = handle.calls.scheduled + 1
handle.scheduler:scheduleIn(delay, callback)
end,
show = function() end,
close = function() end,
setDirty = function() end,
}
package.loaded["ui/trapper"] = { wrap = function(_, callback) callback() end }
package.loaded["ui/widget/infomessage"] = { new = function(_, fields) return fields end }
package.loaded["ui/widget/notification"] = { notify = function() end }
package.loaded["ui/widget/buttondialog"] = {
new = function(_, fields)
fields.setTitle = function(self, title) self.title = title end
handle.dialog = fields
return fields
end,
}
local sidecar = {
sidecarMtime = function() return nil end,
extract = function() return nil end,
buildStatePayload = function() return nil end,
stateFromServerResult = function() return nil end,
applyServerStateSidecar = function() return false end,
rememberServerState = function() end,
markStatePulled = function() end,
rememberUploadedState = function() end,
}
for key, value in pairs(opts.sidecar or {}) do
sidecar[key] = value
end
handle.sidecar = sidecar
package.loaded["bookorbit_sidecar"] = sidecar
package.loaded["bookorbit_annotations"] = {
exchangeBook = function()
return { uploaded = 0, applied = 0, deleted = 0, failed = 0, had_errors = false }
end,
readWatermark = function() return "" end,
advanceWatermark = function() end,
}
package.loaded["bookorbit_highlight_summary"] = {
normalize = function(value) return value end,
add = function(value) return value end,
hasCounts = function() return false end,
hasRemoteChanges = function() return false end,
}
package.loaded["bookorbit_api"] = { new = function() return installClient(handle) end }
package.loaded["bookorbit_book_sync"] = { isRunning = function() return false end }
local BookOrbitState = require("bookorbit_state")
local initial = opts.state or {}
handle.state = BookOrbitState.snapshot({
books = initial.books or {},
unmatched = initial.unmatched or {},
files = initial.files or {},
global = initial.global or {},
}, function()
handle.flushes = handle.flushes + 1
end)
package.loaded["bookorbit_state_manager"] = {
session = function() return handle.state end,
summary = function() return {} end,
}
return handle
end
-- Collects the hashes a match-check batch carried, as a set, because the
-- queue is built from pairs() and its order is not defined.
function SweepHarness.hashSet(batch)
local set = {}
for _, hash in ipairs(batch or {}) do
set[hash] = true
end
return set
end
function SweepHarness.sortedKeys(map)
local keys = {}
for key in pairs(map or {}) do
table.insert(keys, key)
end
table.sort(keys)
return keys
end
return SweepHarness