Files
bookorbit/koreader-plugin/spec/bookorbit_progress_reset_test.lua
T
neon 80244ea4e2 feat(koreader): rebuild the plugin interface and fix four-way annotation sync
Squash of feat/koreader-widget-board. KOReader plugin 2.0.0 gets the widget-board dashboard, redesigned
book details, menus, lists and dialogs, Decor page and reading companions. Highlights, notes and bookmarks
now stay in step across web, Kobo, KOReader and the phones (defects D-00 to D-20, migration 0121), and
reading progress and narration hand off cleanly between devices.
2026-10-20 16:05:27 -06:00

239 lines
9.3 KiB
Lua

-- The server can only ask for a reset; the client decides whether to act on it. These cases
-- drive that decision, because a server-side test passes on a body the reader would discard.
package.loaded["gettext"] = function(text)
return text
end
package.loaded["ffi/util"] = {
template = function(text, ...)
local values = { ... }
return (text:gsub("%%(%d+)", function(index)
return tostring(values[tonumber(index)])
end))
end,
}
local confirm_boxes = {}
local shown_texts = {}
-- The prompt is the This book sheet; its Jump and Stay stand in for a
-- confirm box's OK and Cancel.
package.loaded["bookorbit_book_sheet"] = {
openPrompt = function(_plugin, prompt)
table.insert(confirm_boxes, {
prompt = prompt,
ok_callback = prompt.on_jump,
cancel_callback = prompt.on_stay or function() end,
})
end,
}
package.loaded["bookorbit_toast"] = {
show = function(text) if shown_texts then table.insert(shown_texts, text) end end,
error = function(text) if shown_texts then table.insert(shown_texts, text) end end,
}
package.loaded["device"] = { model = "Kobo Libra" }
local events = {}
package.loaded["ui/event"] = {
new = function(_, name, value)
local event = { name = name, value = value }
table.insert(events, event)
return event
end,
}
package.loaded["ui/widget/infomessage"] = { new = function(_, opts) return opts end }
package.loaded["optmath"] = {
roundPercent = function(value) return value end,
round = function(value) return value end,
}
package.loaded["ui/network/manager"] = {
willRerunWhenConnected = function() return false end,
}
package.loaded["ui/uimanager"] = {
show = function(_, message)
table.insert(shown_texts, message.text)
end,
scheduleIn = function() end,
unschedule = function() end,
getElapsedTimeSinceBoot = function() return 10000 end,
}
package.loaded["logger"] = { dbg = function() end, warn = function() end }
package.loaded["ui/time"] = { s = function(value) return value end }
package.loaded["util"] = { partialMD5 = function(file) return "digest:" .. tostring(file) end }
package.loaded["bookorbit_state_manager"] = { repairFileIdentity = function() end }
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
local ProgressSync = require("bookorbit_progress_sync")
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 assertTrue(value, label)
if not value then error(label .. ": expected truthy, got " .. tostring(value)) end
end
--- Builds a plugin sitting at `local_percent` in a book, ready to pull `body` from the server.
local function makePlugin(body, opts)
opts = opts or {}
confirm_boxes = {}
events = {}
shown_texts = {}
local plugin = {
SYNC_STRATEGY = { PROMPT = 1, SILENT = 2, DISABLE = 3 },
}
ProgressSync.install(plugin)
plugin.pull_timestamp = 0
plugin.push_timestamp = 0
plugin.device_id = "this-device"
-- Defaults as shipped: forward prompts, backward is disabled.
plugin.settings = { sync_forward = 1, sync_backward = 3, auto_sync = true }
plugin.last_page_turn_timestamp = opts.last_page_turn_timestamp or 0
plugin.ui = {
document = {
file = "/tmp/book" .. (opts.paged and ".cbz" or ".epub"),
info = { has_pages = opts.paged == true },
},
handleEvent = function() end,
}
plugin.isLoggedIn = function() return true end
plugin.getLastProgress = function() return opts.local_progress or "/body/DocFragment[6]/body" end
plugin.getLastPercent = function() return opts.local_percent or 0.42 end
plugin.requestUpdateCheck = function() end
plugin.recordSyncSuccess = function() end
plugin.recordSyncError = function() end
plugin.newClient = function()
return { getProgress = function() return body end }
end
return plugin
end
local RESET_BODY_REFLOWABLE = {
percentage = 0,
progress = "/body/DocFragment[1]/body",
device = "web",
device_id = "bookorbit-web",
timestamp = 5000,
}
-- 1. The reset must not be mistaken for this device's own progress, or it is skipped outright.
local plugin = makePlugin(RESET_BODY_REFLOWABLE)
plugin:getProgress(false, false)
assertEqual(#confirm_boxes, 1, "a reset from BookOrbit reaches the conflict prompt")
-- 2. Accepting it has to actually move the reader to the start.
confirm_boxes[1].ok_callback()
assertEqual(events[1].name, "GotoXPointer", "reflowable reset navigates by xpointer")
assertEqual(events[1].value, "/body/DocFragment[1]/body", "reflowable reset targets the first fragment")
-- 3. Paged books take a page number, and an empty position would arrive as nil.
local paged = makePlugin({
percentage = 0,
progress = "1",
device = "web",
device_id = "bookorbit-web",
timestamp = 5000,
}, { paged = true, local_progress = "117" })
paged:getProgress(false, false)
assertEqual(#confirm_boxes, 1, "a paged reset reaches the conflict prompt")
confirm_boxes[1].ok_callback()
assertEqual(events[1].name, "GotoPage", "paged reset navigates by page")
assertEqual(events[1].value, 1, "paged reset targets page one")
-- 4. The classification is what decides whether the reader is ever asked. A reset stamped
-- behind the last page turn is read as a backward sync, and backward defaults to disabled,
-- so it is dropped in silence. This is why the server stamps each delivery as current.
local stale = makePlugin(RESET_BODY_REFLOWABLE, { last_page_turn_timestamp = 9000 })
stale:getProgress(false, false)
assertEqual(#confirm_boxes, 0, "a reset older than the last page turn is silently discarded")
local fresh = makePlugin({
percentage = 0,
progress = "/body/DocFragment[1]/body",
device = "web",
device_id = "bookorbit-web",
timestamp = 9500,
}, { last_page_turn_timestamp = 9000 })
fresh:getProgress(false, false)
assertEqual(#confirm_boxes, 1, "a reset stamped after the last page turn still prompts")
-- 5. A device already at the start has nothing to do, and must not be nagged.
local converged = makePlugin(RESET_BODY_REFLOWABLE, { local_percent = 0 })
converged:getProgress(false, false)
assertEqual(#confirm_boxes, 0, "a device already at the start is not prompted")
-- 6. The reset must never be attributed to the pulling device itself, or the guard skips it.
local own = makePlugin({
percentage = 0,
progress = "/body/DocFragment[1]/body",
device = "Kobo Libra",
device_id = "this-device",
timestamp = 5000,
}, { last_page_turn_timestamp = 9000 })
own:getProgress(false, false)
assertEqual(#confirm_boxes, 0, "progress from this device is skipped, which is why the reset is not attributed to it")
local restored_body = {
percentage = 0.75,
progress = "/body/DocFragment[12]/body",
device = "Kobo Libra",
device_id = "this-device",
timestamp = 5000,
}
local manual_restore = makePlugin(restored_body, { local_percent = 0.006, last_page_turn_timestamp = 9000 })
manual_restore:getProgress(false, true)
assertEqual(events[1].name, "GotoXPointer", "explicit pull restores this device's saved position")
assertEqual(events[1].value, restored_body.progress, "explicit pull restores the saved rendition position")
local fresh_copy = makePlugin(restored_body, { local_percent = 0.006 })
fresh_copy:getProgress(false, false)
assertEqual(#confirm_boxes, 1, "fresh copy can recover this device's previous upload")
confirm_boxes[1].ok_callback()
assertEqual(events[1].value, restored_body.progress, "fresh-copy recovery applies the saved position")
local reconciled
local fresh_book_sync = makePlugin(restored_body, { local_percent = 0.006 })
fresh_book_sync:reconcileProgressBeforeBookSync(fresh_book_sync:getDocumentDigest(), function(skip)
reconciled = skip
end)
assertEqual(#confirm_boxes, 1, "manual book sync checks the saved position before a fresh copy upload")
confirm_boxes[1].cancel_callback()
assertEqual(reconciled, true, "declining recovery prevents fresh-copy progress from overwriting the saved position")
local same_position = makePlugin(restored_body, { local_percent = 0.75, local_progress = restored_body.progress })
same_position:getProgress(false, true)
assertEqual(#events, 0, "same-position explicit pull remains idempotent")
for _, interactive in ipairs({ false, true }) do
local position = { local_progress = "/body/DocFragment[6]/body" }
local moving = makePlugin(restored_body, position)
moving.settings.sync_forward = moving.SYNC_STRATEGY.SILENT
moving.newClient = function()
return { getProgress = function()
position.local_progress = "/body/DocFragment[13]/body"
return restored_body
end }
end
moving:getProgress(false, interactive)
assertEqual(#events, 0, "late pull cannot undo navigation during the request")
assertEqual(#confirm_boxes, 0, "late pull cannot prompt for an obsolete local position")
local replaced = makePlugin(restored_body)
replaced.newClient = function()
return { getProgress = function()
replaced.ui.document = nil
return restored_body
end }
end
replaced:getProgress(false, interactive)
assertEqual(#events, 0, "late pull cannot navigate a closed document")
end
print("bookorbit_progress_reset_test.lua: ok")