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

247 lines
11 KiB
Lua

-- Dogear sync: normalization, skip logic, live apply, sidecar apply,
-- deletion detection and the ack identity the server links devices by.
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;koreader-plugin/spec/?.lua;" .. package.path
local sidecar_store
local sidecar_flags
package.loaded["docsettings"] = {
open = function()
return {
readSetting = function(_, key)
if key == "annotations" then return sidecar_store end
end,
saveSetting = function(_, key, value)
if key == "annotations" then sidecar_store = value end
end,
makeTrue = function(_, key) sidecar_flags[key] = true end,
flush = function() sidecar_flags.flushed = true end,
}
end,
hasSidecarFile = function() return false end,
findSidecarFile = function() return nil end,
}
local handled_events = {}
package.loaded["ui/event"] = {
new = function(_, name, payload)
return { name = name, payload = payload }
end,
}
local dirty_calls = 0
package.loaded["ui/uimanager"] = {
setDirty = function() dirty_calls = dirty_calls + 1 end,
}
package.loaded["logger"] = { dbg = function() end }
-- Identity md5 is replaced by the input itself so assertions stay readable.
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 }
local capability_answer = true
local marked_unsupported = false
package.loaded["bookorbit_capabilities"] = {
supports = function(_, feature)
if feature ~= "bookmarkSync" then return false end
return capability_answer
end,
markUnsupported = function() marked_unsupported = true end,
}
local BookOrbitSidecar = require("bookorbit_sidecar")
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
-- normalizeBookmarks
local raw = {
{ page = "/body/DocFragment[2]/body/p[1]/text().0", datetime = "2026-06-01 10:00:00", chapter = "One", pageno = 4 },
{ page = "/body/DocFragment[3]", datetime = "2026-06-02 11:00:00", note = "Look", pageno = 9 },
-- A highlight: has a drawer, belongs to the annotation exchange.
{ page = "/body/DocFragment[4]", pos0 = "/body/DocFragment[4]", drawer = "lighten", datetime = "2026-06-03 12:00:00" },
-- A paging document dogear: the position is a page number, out of scope.
{ page = 12, datetime = "2026-06-04 13:00:00" },
-- Unusable datetime.
{ page = "/body/DocFragment[5]", datetime = "nonsense" },
}
local bookmarks, max_datetime, signature = BookOrbitSidecar.normalizeBookmarks(raw)
assertEqual(#bookmarks, 2, "only rolling dogears are normalized")
assertEqual(bookmarks[1].pos, "/body/DocFragment[2]/body/p[1]/text().0", "position comes from page")
assertEqual(bookmarks[1].chapter, "One", "chapter is carried")
assertEqual(bookmarks[1].pageno, 4, "pageno is carried")
assertEqual(bookmarks[2].note, "Look", "note is carried")
assertEqual(max_datetime, "2026-06-02 11:00:00", "max datetime ignores highlights")
local renamed = {
{ page = "/body/DocFragment[2]/body/p[1]/text().0", datetime = "2026-06-01 10:00:00", chapter = "One", pageno = 4 },
{ page = "/body/DocFragment[3]", datetime = "2026-06-02 11:00:00", note = "Renamed", pageno = 9 },
}
local _renamed, _renamed_max, renamed_signature = BookOrbitSidecar.normalizeBookmarks(renamed)
assertTrue(renamed_signature ~= signature,
"a note edit changes the signature even though KOReader leaves datetime_updated alone")
-- The highlight signature must not move, or every book re-exchanges after an update.
local annotations_before = select(3, BookOrbitSidecar.normalizeAnnotations(raw))
local annotations_after = select(3, BookOrbitSidecar.normalizeAnnotations(raw))
assertEqual(annotations_before, annotations_after, "highlight signature is stable")
-- keys and skip logic
local keys = BookOrbitBookmarks.collectKeys(bookmarks)
assertEqual(#keys, 2, "one key per bookmark")
assertEqual(keys[1].k, "2026-06-01 10:00:00|/body/DocFragment[2]/body/p[1]/text().0", "key is datetime|pos")
assertEqual(keys[1].dt, "2026-06-01 10:00:00", "key carries its datetime")
local book = {}
assertEqual(BookOrbitBookmarks.canSkipExchange(book, signature, 1000), false, "never exchanged, cannot skip")
BookOrbitBookmarks.rememberExchanged(book, signature, 1000)
assertEqual(BookOrbitBookmarks.canSkipExchange(book, signature, 1100), true, "unchanged set may skip")
assertEqual(BookOrbitBookmarks.canSkipExchange(book, renamed_signature, 1100), false, "changed set may not skip")
assertEqual(BookOrbitBookmarks.canSkipExchange(book, signature, 1000 + BookOrbitBookmarks.EXCHANGE_MAX_AGE + 1), false,
"a stale stamp may not skip")
assertEqual(BookOrbitBookmarks.canSkipExchange(book, signature, 500), false, "a stamp in the future may not skip")
-- capability gate
assertEqual(BookOrbitBookmarks.enabled({}, false), false, "the two-way toggle gates bookmark sync")
assertEqual(BookOrbitBookmarks.enabled({}, true), true, "an advertising server enables bookmark sync")
capability_answer = false
assertEqual(BookOrbitBookmarks.enabled({}, true), false, "a server without the capability stays dormant")
capability_answer = true
BookOrbitBookmarks.markUnsupported({})
assertTrue(marked_unsupported, "a confirmed 404 downgrades the capability")
-- live apply
local resolvable = {}
local annotations = {}
local added_items = {}
local removed_indexes = {}
local ui = {
document = {
isXPointerInDocument = function(_, pos) return resolvable[pos] == true end,
},
annotation = {
annotations = annotations,
addItem = function(self, item)
item.datetime = item.datetime or "2026-07-01 09:00:00"
table.insert(self.annotations, item)
table.insert(added_items, item)
return #self.annotations
end,
},
bookmark = {
getCurrentPageNumber = function() return "/body/DocFragment[1]" end,
setDogearVisibility = function() end,
removeItemByIndex = function(_, index)
table.insert(removed_indexes, index)
table.remove(annotations, index)
end,
},
toc = {
getTocTitleByPage = function(_, pos)
if pos == "/body/DocFragment[7]" then return "Chapter Seven" end
return ""
end,
},
handleEvent = function(_, event) table.insert(handled_events, event) end,
}
resolvable["/body/DocFragment[7]"] = true
resolvable["/body/DocFragment[8]"] = true
local applied, deleted, touched, deleted_touched = BookOrbitBookmarks.applyLive(ui, {
add = {
{ serverId = 1, pos = "/body/DocFragment[7]", title = "Chapter Seven" },
{ serverId = 2, pos = "/body/DocFragment[8]", title = "My own label" },
{ serverId = 3, pos = "/body/DocFragment[9]", title = "Broken" },
},
})
assertEqual(touched, 2, "two resolvable positions were added")
assertEqual(deleted_touched, 0, "nothing was deleted")
assertEqual(#deleted, 0, "no delete acks")
assertEqual(applied[1].status, "applied", "resolvable entry applied")
assertEqual(applied[1].key, "2026-07-01 09:00:00|/body/DocFragment[7]", "ack carries the device identity")
assertEqual(applied[1].pos, "/body/DocFragment[7]", "ack carries the device position")
assertEqual(added_items[1].chapter, "Chapter Seven", "the device fills the chapter from its own toc")
assertEqual(added_items[1].note, nil, "a title matching the chapter needs no note")
assertEqual(added_items[2].note, "My own label", "a title that is not the chapter becomes the note")
assertEqual(applied[3].status, "failed", "an unresolvable position fails instead of landing")
assertTrue(dirty_calls > 0, "the screen is refreshed after a change")
-- An entry already present locally is updated and acked with the local identity, not re-added.
local before_count = #annotations
local applied_again, _, updated_touched = BookOrbitBookmarks.applyLive(ui, {
add = { { serverId = 1, pos = "/body/DocFragment[7]", title = "Renamed elsewhere" } },
})
assertEqual(#annotations, before_count, "an already present position is not duplicated")
assertEqual(updated_touched, 1, "an existing dogear title is updated")
assertEqual(annotations[1].note, "Renamed elsewhere", "the updated title becomes the dogear note")
assertEqual(applied_again[1].key, "2026-07-01 09:00:00|/body/DocFragment[7]", "the existing identity is acked")
-- Delete by the identity the server was acked with.
local _a, deletes, _t, delete_touched = BookOrbitBookmarks.applyLive(ui, {
delete = {
{ serverId = 1, key = "2026-07-01 09:00:00|/body/DocFragment[7]", datetime = "2026-07-01 09:00:00" },
{ serverId = 99, key = "never|seen", datetime = "2020-01-01 00:00:00" },
},
})
assertEqual(delete_touched, 1, "only the known entry was removed")
assertEqual(#deletes, 2, "an already absent entry is still acked")
assertEqual(deletes[2].status, "applied", "a missing local entry counts as applied")
-- sidecar apply
sidecar_store = {
{ page = "/body/DocFragment[2]", datetime = "2026-06-01 10:00:00" },
{ page = "/body/DocFragment[4]", drawer = "lighten", pos0 = "/body/DocFragment[4]", datetime = "2026-06-03 12:00:00" },
}
sidecar_flags = {}
local s_applied, s_deleted, s_touched, s_deleted_touched = BookOrbitBookmarks.applySidecar("/books/one.epub", {
add = { { serverId = 5, pos = "/body/DocFragment[6]", title = "From the web", pageno = 12 } },
delete = { { serverId = 6, key = "2026-06-01 10:00:00|/body/DocFragment[2]", datetime = "2026-06-01 10:00:00" } },
})
assertEqual(s_touched, 2, "one add and one delete touched the sidecar")
assertEqual(s_deleted_touched, 1, "one delete landed")
assertEqual(#s_applied, 1, "one apply ack")
assertEqual(#s_deleted, 1, "one delete ack")
assertEqual(#sidecar_store, 2, "the highlight stayed and the new dogear was appended")
assertEqual(sidecar_store[2].note, "From the web", "a closed book has no toc, so the title lands in the note")
assertEqual(sidecar_store[2].pageno, 12, "the server pageno is kept")
assertTrue(sidecar_flags.annotations_externally_modified, "KOReader is asked to re-validate on open")
assertTrue(sidecar_flags.flushed, "the sidecar was flushed")
sidecar_flags = {}
local s_updated, _, s_update_touched = BookOrbitBookmarks.applySidecar("/books/one.epub", {
add = { { serverId = 5, pos = "/body/DocFragment[6]", title = "Renamed while closed", pageno = 13 } },
})
assertEqual(#s_updated, 1, "an existing sidecar dogear is acked")
assertEqual(s_update_touched, 1, "an existing sidecar dogear title is updated")
assertEqual(sidecar_store[2].note, "Renamed while closed", "the sidecar keeps the server title")
assertEqual(sidecar_store[2].pageno, 13, "the sidecar refreshes the page number")
assertTrue(sidecar_flags.flushed, "an updated sidecar is flushed")
-- A highlight must never be touched by the bookmark exchange.
assertEqual(sidecar_store[1].drawer, "lighten", "the highlight survived the dogear delete")
print("bookorbit_bookmarks_test.lua: ok")