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

255 lines
9.7 KiB
Lua

-- The bookmark exchange round trip: full-set upload with chunking, the pull
-- and ack loop, the skip stamp and the error classification the call sites
-- branch on.
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;koreader-plugin/spec/?.lua;" .. package.path
local sidecar_annotations = {}
package.loaded["docsettings"] = {
open = function()
return {
readSetting = function(_, key)
if key == "annotations" then return sidecar_annotations end
end,
saveSetting = function(_, key, value)
if key == "annotations" then sidecar_annotations = value end
end,
makeTrue = function() end,
flush = function() end,
}
end,
hasSidecarFile = function() return false end,
findSidecarFile = function() return nil end,
}
package.loaded["ui/event"] = { new = function(_, name, payload) return { name = name, payload = payload } end }
package.loaded["ui/uimanager"] = { setDirty = function() end }
package.loaded["logger"] = { dbg = function() end }
package.loaded["ffi/sha2"] = { md5 = function(value) return value end }
package.loaded["ffi/util"] = { template = function(pattern, a) return (pattern:gsub("%%1", tostring(a))) end }
package.loaded["gettext"] = function(value) return value end
package.loaded["libs/libkoreader-lfs"] = { attributes = function() return nil end }
package.loaded["ui/widget/booklist"] = { setBookInfoCacheProperty = function() end }
package.loaded["bookorbit_capabilities"] = {
supports = function() return true end,
markUnsupported = function() end,
}
local BookOrbitBookmarks = require("bookorbit_bookmarks")
local function assertEqual(actual, expected, label)
if actual ~= expected then
error(string.format("%s: expected %s, got %s", label, tostring(expected), tostring(actual)), 2)
end
end
local function assertTrue(value, label)
if not value then error(label .. ": expected truthy", 2) end
end
local function makeState(book)
return {
getBook = function() return book end,
setUnmatched = function(self) self.unmatched = true end,
}
end
local function bookmark(datetime, pos)
return { datetime = datetime, pos = pos, pageno = 1 }
end
-- Upload: the whole local set goes out, chunked, with the key list only on the
-- first request. A watermark delta would drop renames, because KOReader leaves
-- datetime_updated alone when a dogear note is edited.
local requests = {}
local client = {
exchangeBookmarks = function(_, books)
table.insert(requests, books[1])
return { results = { { hash = "digest", toApply = { add = {}, delete = {} } } }, unmatched = {} }
end,
exchangeBookmarksAck = function() return { results = {} } end,
}
local many = {}
for index = 1, 120 do
table.insert(many, bookmark(string.format("2026-06-01 10:%02d:00", index % 60), "/body/p[" .. index .. "]"))
end
local book = {}
local result = BookOrbitBookmarks.exchangeBook({
client = client,
state = makeState(book),
digest = "digest",
bookmarks = many,
bm_signature = "sig-120",
apply_mode = "sidecar",
file = "/books/one.epub",
})
assertEqual(result.uploaded, 120, "every bookmark is uploaded")
assertEqual(#requests, 3, "120 bookmarks chunk into three requests")
assertEqual(#requests[1].keys, 120, "the full key set rides the first request")
assertEqual(requests[1].keysComplete, true, "the key set is complete")
assertEqual(#requests[2].keys, 0, "follow-up chunks carry no keys")
assertEqual(requests[2].keysComplete, false, "only the first request may drive deletion detection")
assertEqual(book.bmSignature, "sig-120", "a clean exchange records the skip stamp")
-- An empty local set still exchanges once, so the server can push web bookmarks
-- at a book the device has never bookmarked.
requests = {}
local empty_book = {}
local empty_result = BookOrbitBookmarks.exchangeBook({
client = client,
state = makeState(empty_book),
digest = "digest",
bookmarks = {},
bm_signature = "sig-empty",
apply_mode = "sidecar",
file = "/books/one.epub",
})
assertEqual(#requests, 1, "an empty set still asks the server")
assertEqual(empty_result.uploaded, 0, "nothing was uploaded")
-- Pull, apply and ack, then a follow-up round while the server reports more.
local rounds = 0
local acks = {}
sidecar_annotations = {}
local pull_client = {
exchangeBookmarks = function()
rounds = rounds + 1
if rounds == 1 then
return {
results = { {
hash = "digest",
toApply = { add = { { serverId = 1, pos = "/body/p[1]", title = "First" } }, delete = {} },
more = true,
} },
}
end
return {
results = { {
hash = "digest",
toApply = { add = { { serverId = 2, pos = "/body/p[2]", title = "Second" } }, delete = {} },
more = false,
} },
}
end,
exchangeBookmarksAck = function(_, books)
table.insert(acks, books[1])
return { results = {} }
end,
}
local pull_book = {}
local pull_result = BookOrbitBookmarks.exchangeBook({
client = pull_client,
state = makeState(pull_book),
digest = "digest",
bookmarks = {},
bm_signature = "sig-pull",
apply_mode = "sidecar",
file = "/books/one.epub",
})
assertEqual(pull_result.applied, 2, "both pushed bookmarks landed")
assertEqual(#acks, 2, "each round is acknowledged before the next pull")
assertEqual(acks[1].applied[1].serverId, 1, "the ack names the server id")
assertEqual(acks[1].applied[1].key ~= nil, true, "the ack carries the device identity")
assertEqual(#sidecar_annotations, 2, "both dogears are in the sidecar")
assertEqual(pull_book.bmSignature, "sig-pull", "a fully drained pull records the skip stamp")
-- Skip mode parks server changes instead of applying them, and must not record
-- the stamp: the next exchange has to come back for them.
local skip_book = {}
local skip_result = BookOrbitBookmarks.exchangeBook({
client = {
exchangeBookmarks = function()
return { results = { { hash = "digest", toApply = { add = { { serverId = 9, pos = "/body/p[9]", title = "Later" } } } } } }
end,
exchangeBookmarksAck = function() error("skip mode must not ack") end,
},
state = makeState(skip_book),
digest = "digest",
bookmarks = {},
bm_signature = "sig-skip",
apply_mode = "skip",
})
assertTrue(skip_result.remote_pending, "server changes are parked")
assertEqual(skip_result.remote_pending.to_apply.add[1].serverId, 9, "the parked payload is preserved")
assertEqual(skip_book.bmSignature, nil, "parked changes keep the next exchange mandatory")
-- Error classification the call sites branch on.
local function exchangeWithError(err)
return select(2, BookOrbitBookmarks.exchangeBook({
client = {
exchangeBookmarks = function() return nil, err end,
exchangeBookmarksAck = function() return { results = {} } end,
},
state = makeState({}),
digest = "digest",
bookmarks = {},
bm_signature = "sig-error",
apply_mode = "sidecar",
file = "/books/one.epub",
}))
end
assertEqual(exchangeWithError(401), "auth", "401 is an auth failure")
assertEqual(exchangeWithError(403), "auth", "403 is an auth failure")
assertEqual(exchangeWithError(404), "unsupported_server", "404 downgrades the capability")
assertEqual(exchangeWithError("timeout"), "network", "a transport failure is a network error")
-- An unmatched book stops the exchange and is remembered as unmatched.
local unmatched_state = makeState({})
local unmatched_result, unmatched_err = BookOrbitBookmarks.exchangeBook({
client = {
exchangeBookmarks = function() return { results = {}, unmatched = { "digest" } } end,
exchangeBookmarksAck = function() return { results = {} } end,
},
state = unmatched_state,
digest = "digest",
bookmarks = {},
bm_signature = "sig-unmatched",
apply_mode = "sidecar",
file = "/books/one.epub",
})
assertEqual(unmatched_result, nil, "an unmatched book produces no result")
assertEqual(unmatched_err, "unmatched", "the caller learns the book is unmatched")
assertTrue(unmatched_state.unmatched, "the unmatched digest is recorded")
-- A 500 mid-upload leaves the stamp unset so the next sync retries.
local failing_book = {}
local flaky_calls = 0
local two_chunks = {}
for index = 1, 60 do
table.insert(two_chunks, bookmark(string.format("2026-06-01 10:%02d:00", index % 60), "/body/p[" .. index .. "]"))
end
local flaky_result = BookOrbitBookmarks.exchangeBook({
client = {
exchangeBookmarks = function()
flaky_calls = flaky_calls + 1
if flaky_calls == 1 then
return { results = { { hash = "digest", toApply = { add = {}, delete = {} } } } }
end
return nil, 500
end,
exchangeBookmarksAck = function() return { results = {} } end,
},
state = makeState(failing_book),
digest = "digest",
bookmarks = two_chunks,
bm_signature = "sig-flaky",
apply_mode = "sidecar",
file = "/books/one.epub",
})
assertTrue(flaky_result.had_errors, "a 5xx is reported as an error")
assertEqual(failing_book.bmSignature, nil, "a failed upload does not record the skip stamp")
requests = {}
local copy_state = makeState({})
copy_state.hasOtherCopy = function() return true end
BookOrbitBookmarks.exchangeBook({
client = client, state = copy_state, digest = "digest", bookmarks = {}, bm_signature = "empty-copy",
apply_mode = "sidecar", file = "/books/copy.epub",
})
assertEqual(requests[1].keysComplete, false, "one duplicate sidecar cannot delete another copy's dogears")
print("bookorbit_bookmark_exchange_test.lua: ok")