Files
bookorbit/koreader-plugin/spec/bookorbit_stats_reader_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

137 lines
5.5 KiB
Lua

-- The statistics reader now binds parameters into prepared statements instead
-- of formatting SQL, and one-shot helpers run on a short-lived session. These
-- assertions pin the binding order, the one-connection-per-call lifecycle and
-- the single reopen a busy database gets before the query is given up on.
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;koreader-plugin/spec/?.lua;" .. package.path
local FakeSqlite = require("helpers/fake_sqlite")
package.loaded["datastorage"] = { getSettingsDir = function() return "/nonexistent" end }
package.loaded["logger"] = { dbg = function() end, info = function() end, warn = function() end, err = function() end }
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
local function load(respond, opts)
local stats = FakeSqlite.install(respond, opts)
package.loaded["bookorbit_stats_reader"] = nil
return require("bookorbit_stats_reader"), stats
end
-- Row ids and the digest guard.
do
local seen
local Reader, stats = load(function(sql, params)
seen = { sql = sql, params = params }
if sql:find("SELECT id FROM book", 1, true) then
return FakeSqlite.resultSet{ { "7" }, { "9" } }
end
return nil
end)
local ids = Reader.getBookIds("abc123")
assertEqual(#ids, 2, "both statistics rows of one digest come back")
assertEqual(ids[1], 7, "row ids are numbers")
assertEqual(seen.params[1], "abc123", "the digest is bound, not formatted into the SQL")
assertEqual(stats.opens, 1, "a one-shot helper opens one connection")
assertEqual(stats.closes, 1, "and closes it again")
stats.queries = 0
assertEqual(#Reader.getBookIds("not-hex"), 0, "a non-hex digest returns nothing")
assertEqual(stats.queries, 0, "and never reaches the database")
end
-- Event batching binds the row ids, then the watermark, then the limit.
do
local seen
local Reader = load(function(sql, params)
seen = { sql = sql, params = params }
return FakeSqlite.resultSet{ { "12", "1700000000", "90000", "300" } }
end)
local events = Reader.getEventsAfter({ 3, 4 }, 1500, 500)
assertEqual(#events, 1, "the batch comes back")
assertEqual(events[1].page, 12, "page is numeric")
assertEqual(events[1].durationSeconds, 86400, "an implausible duration is still clamped to a day")
assertEqual(seen.params[1], 3, "the first row id is bound first")
assertEqual(seen.params[2], 4, "then the second")
assertEqual(seen.params[3], 1500, "then the watermark")
assertEqual(seen.params[4], 500, "then the limit")
assertEqual(select(2, seen.sql:gsub("%?", "")), 4, "the statement has one placeholder per bound value")
end
-- Dashboard reading summary.
do
local today = os.date("%Y-%m-%d")
local yesterday = os.date("%Y-%m-%d", os.time() - 86400)
local Reader = load(function(sql)
if sql:find("COALESCE(SUM(", 1, true) then
return FakeSqlite.resultSet{ { "1800" } }
end
if sql:find("SELECT DISTINCT date(", 1, true) then
return FakeSqlite.resultSet{ { today }, { yesterday } }
end
return nil
end)
local summary = Reader.getReadingSummary()
assertEqual(summary.today_seconds, 1800, "today's reading time is summed")
assertEqual(summary.week_seconds, 1800, "the week window is summed too")
assertEqual(summary.streak_days, 2, "consecutive reading days build the streak")
end
-- A busy database gets one reopen before the query is given up on.
do
local Reader, stats = load(function(sql)
if sql:find("SELECT id FROM book", 1, true) then
return FakeSqlite.resultSet{ { "5" } }
end
return nil
end, { fail_queries = 1 })
local ids = Reader.getBookIds("abc123")
assertEqual(#ids, 1, "the retry after a reopen succeeds")
assertEqual(stats.opens, 2, "exactly one reopen is attempted")
end
-- A statistics session reuses one connection and one statement per shape.
do
local Reader, stats = load(function(sql, params)
if sql:find("FROM book WHERE md5 IS NOT NULL", 1, true) then
local after_id = params[1] or 0
if after_id >= 2 then return nil end
return FakeSqlite.resultSet{
{ "1", "aaa", "A", "", "10" },
{ "2", "bbb", "B", "", "20" },
}
end
if sql:find("MAX(start_time)", 1, true) then
return FakeSqlite.resultSet{ { "1", "500" } }
end
return nil
end)
local session = Reader.openSession()
local rows, last_id = session:bookRowsAfter(0, 50)
assertEqual(#rows, 2, "a page of statistics rows comes back")
assertEqual(last_id, 2, "the cursor advances to the highest id in the page")
assertEqual(#session:bookRowsAfter(last_id, 50), 0, "the next page is empty")
local latest = session:latestEventTimes()
assertEqual(latest[1], 500, "the grouped query reports the newest event per row")
assertEqual(latest[2], nil, "a row with no events is absent rather than zero")
assertEqual(stats.opens, 1, "the session opens exactly one connection")
assertEqual(stats.prepares, 2, "one prepared statement per query shape, reused across pages")
session:close()
assertEqual(stats.closes, 1, "closing the session releases the connection")
assertEqual(session:latestEventTimes(), nil, "a closed session answers nothing rather than reopening")
end
print("bookorbit_stats_reader_test.lua: ok")