Files
bookorbit/koreader-plugin/spec/helpers/sweep_harness.lua
T
neon 1e5fcedb2a fix(koreader): stop a rebuilt library silently dropping reading history
Deleting a library cascades away its books, files, page stats and reading sessions, and a rescan
mints new rows. The device's sync state is keyed by the file's partial md5, so an unchanged file
kept its cursors across that swap: the plugin asked only for events newer than a watermark earned
against rows the server no longer had, and nothing recorded before the swap was ever resent.
"Recheck all book matches" did not help, because it funnels back through setMatched.

Treat a changed book file id or book id as a fresh sync target and keep only the local file path.
Both ids matter: statistics hang off the file row while highlights, status, rating and review hang
off the book row, and an existing-folder merge re-parents a file to another book without changing
its id. Resending costs nothing because the page-stat dedup key makes a replay a server-side no-op.

Order both hash resolvers by book file id so content that exists as two files always resolves to
the same row. They previously took whichever row Postgres returned first, so they could disagree
with each other and, with the reset above, move a device's sync target under it.

Closes #1049
2026-08-21 16:38:51 +00:00

329 lines
12 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
local configured = responses.matches[hash] or {}
local stored = handle.state and handle.state:getBook(hash) or {}
table.insert(matches, {
hash = hash,
bookId = configured.bookId or stored.bookId or 1,
bookFileId = configured.bookFileId or stored.fileId or 2,
})
end
end
return { matches = matches, libraryVersion = responses.library_version }
end,
uploadPageStats = function(_, books)
table.insert(handle.calls.page_stats, books)
if responses.page_stats then return responses.page_stats(books) end
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
- matches: map of hashes to custom { bookId, bookFileId } match results
- page_stats_response: function returning the server response for an upload
- 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 {},
matches = opts.matches or {},
page_stats = opts.page_stats_response,
},
calls = {
match = {},
page_stats = {},
book_states = {},
progress = {},
legacy_annotations = {},
annotation_exchanges = {},
sweep_complete = 0,
event_queries = 0,
latest_event_queries = 0,
scheduled = 0,
sidecar_extracts = {},
history_resolved = {},
},
bookmark_exchanges = {},
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(exchange_opts)
table.insert(handle.calls.annotation_exchanges, exchange_opts)
return { uploaded = #(exchange_opts.annotations or {}), applied = 0, deleted = 0, failed = 0, had_errors = false }
end,
readWatermark = function() return "" end,
advanceWatermark = function() end,
}
package.loaded["bookorbit_bookmarks"] = {
enabled = function() return opts.bookmark_sync == true end,
markUnsupported = function() end,
canSkipExchange = function() return false end,
rememberExchanged = function() end,
exchangeBook = function()
table.insert(handle.bookmark_exchanges, true)
return { uploaded = 0, applied = 0, deleted = 0, failed = 0, had_errors = false }
end,
}
package.loaded["bookorbit_highlight_summary"] = {
normalize = function(value) return value end,
add = function(value) return value end,
addBookmarks = 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