72233e1c6a
* docs(sync): design spec for syncing reading status (#4634) Field-level LWW for reading_status (dedicated reading_status_updated_at), a new 'abandoned' status in the Readest UI, and a koplugin bridge to KOReader's native summary.status (whole-library apply + capture). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(sync): implementation plan for syncing reading status (#4634) Bite-sized TDD tasks across 3 parts: A) cloud field-level LWW (reading_status_updated_at on server upsert + client pull-merge), B) 'abandoned'/On-hold status in the Readest UI, C) koplugin bridge to KOReader summary.status (mapping + reconcile + whole-library apply/capture). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): add reading_status_updated_at for field-level status LWW (#4634) * feat(sync): stamp readingStatusUpdatedAt on status change in updateBookProgress Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): stamp status timestamp on explicit library status edits * feat(sync): resolve reading status by its own timestamp in client pull-merge * feat(sync): resolve reading status by its own timestamp in server upsert (#4634) * fix(sync): tighten reading-status merge typing + strengthen test (A5 review) Replace as-unknown-as double-casts at read sites with typed locals (clientBook/serverBook); retain a single as-unknown-as only at the server-wins construction site where the static type is too narrow. Strengthen test 3 to assert both fields with toEqual. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(library): render the 'On hold' (abandoned) status badge * feat(library): add 'Mark as On hold' actions + i18n for abandoned status Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * i18n: translate 'On hold' and 'Mark as On hold' for the abandoned status (#4634) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(koplugin): add reading-status mapping + reconcile between Readest and KOReader * feat(koplugin): persist + sync reading_status_updated_at in LibraryStore Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(koplugin): bridge reading status to KOReader summary.status on library sync (#4634) * test(sync): cover koplugin v1->v2 migration + tighten status-sync tests (final review) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(sync): redesign KOReader first-sync (decisive-only + bootstrap) (#4634) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(koplugin): safe first-sync of reading status (decisive-only + bootstrap) (#4634) KOReader auto-sets summary.status='reading' on first open, and legacy Readest statuses have reading_status_updated_at=0, so pure timestamp LWW let opening a finished book downgrade it. Restrict sync to deliberate statuses (finished/ complete, abandoned/on-hold, unread->clear); never capture KO 'reading'/'New'. On the unsynced baseline (Readest ts=0) conflicts resolve Readest-authoritative, then stamp now_ms to exit bootstrap into steady-state LWW. reconcile now returns write_ko/write_store flags; statussync captures now_ms once and equalizes both sides (convergent, idempotent, resumable). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(koplugin): cover remaining first-sync graph cells + document sort effect (review) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
204 lines
9.1 KiB
Lua
204 lines
9.1 KiB
Lua
-- readingstatus_spec.lua — contract for library/readingstatus.lua
|
||
require("spec_helper")
|
||
local RS = require("library.readingstatus")
|
||
|
||
-- A "now" clearly larger than any KOReader summary.modified ms used below,
|
||
-- so bootstrap stamps are unambiguous in assertions.
|
||
local NOW = 9000000000000
|
||
|
||
describe("readingstatus mapping (decisive-only)", function()
|
||
it("maps Readest -> KOReader (push targets)", function()
|
||
assert.are.equal("complete", RS.readest_to_ko("finished"))
|
||
assert.are.equal("abandoned", RS.readest_to_ko("abandoned"))
|
||
assert.is_nil(RS.readest_to_ko("unread")) -- clear -> "New"
|
||
assert.is_nil(RS.readest_to_ko("reading")) -- non-decisive, never pushed
|
||
assert.is_nil(RS.readest_to_ko(nil)) -- undefined
|
||
end)
|
||
|
||
it("maps KOReader -> Readest only for decisive statuses", function()
|
||
assert.are.equal("finished", RS.ko_to_readest("complete"))
|
||
assert.are.equal("abandoned", RS.ko_to_readest("abandoned"))
|
||
assert.is_nil(RS.ko_to_readest("reading")) -- auto-set on open: NOT captured
|
||
assert.is_nil(RS.ko_to_readest("New"))
|
||
assert.is_nil(RS.ko_to_readest(nil))
|
||
end)
|
||
|
||
it("knows which Readest statuses are decisive", function()
|
||
assert.is_true(RS.readest_decisive("finished"))
|
||
assert.is_true(RS.readest_decisive("abandoned"))
|
||
assert.is_true(RS.readest_decisive("unread"))
|
||
assert.is_false(RS.readest_decisive("reading"))
|
||
assert.is_false(RS.readest_decisive(nil))
|
||
end)
|
||
|
||
it("parses summary.modified to day ms", function()
|
||
assert.are.equal(os.time({ year = 2026, month = 6, day = 18, hour = 0, min = 0, sec = 0 }) * 1000,
|
||
RS.parse_modified_ms("2026-06-18"))
|
||
assert.is_nil(RS.parse_modified_ms(nil))
|
||
assert.is_nil(RS.parse_modified_ms("garbage"))
|
||
end)
|
||
end)
|
||
|
||
describe("readingstatus reconcile — non-decisive", function()
|
||
it("does nothing when neither side has a decisive status", function()
|
||
local r = RS.reconcile({ reading_status = nil, reading_status_updated_at = 0 },
|
||
{ status = nil, ts = 0 }, NOW)
|
||
assert.is_false(r.write_ko)
|
||
assert.is_false(r.write_store)
|
||
end)
|
||
|
||
it("ignores KOReader auto-'reading' against an undefined Readest book (reported case 2)", function()
|
||
local r = RS.reconcile({ reading_status = nil, reading_status_updated_at = 0 },
|
||
{ status = "reading", ts = 1000 }, NOW)
|
||
assert.is_false(r.write_ko)
|
||
assert.is_false(r.write_store)
|
||
end)
|
||
end)
|
||
|
||
describe("readingstatus reconcile — only one side decisive", function()
|
||
it("pushes a bootstrap Readest 'finished' down to an opened KO book + stamps (reported case 1)", function()
|
||
local r = RS.reconcile({ reading_status = "finished", reading_status_updated_at = 0 },
|
||
{ status = "reading", ts = 1000 }, NOW)
|
||
assert.is_true(r.write_ko)
|
||
assert.are.equal("complete", r.ko_status)
|
||
assert.is_true(r.write_store)
|
||
assert.are.equal("finished", r.readest_status)
|
||
assert.are.equal(NOW, r.ts) -- bootstrap stamp
|
||
end)
|
||
|
||
it("pushes a steady Readest 'finished' down without re-stamping the store", function()
|
||
local r = RS.reconcile({ reading_status = "finished", reading_status_updated_at = 500 },
|
||
{ status = "reading", ts = 1000 }, NOW)
|
||
assert.is_true(r.write_ko)
|
||
assert.are.equal("complete", r.ko_status)
|
||
assert.is_false(r.write_store) -- already finished@500
|
||
assert.are.equal(500, r.ts)
|
||
end)
|
||
|
||
it("captures a KO 'complete' into an undefined Readest book at the KO timestamp", function()
|
||
local r = RS.reconcile({ reading_status = nil, reading_status_updated_at = 0 },
|
||
{ status = "complete", ts = 300 }, NOW)
|
||
assert.is_false(r.write_ko)
|
||
assert.is_true(r.write_store)
|
||
assert.are.equal("finished", r.readest_status)
|
||
assert.are.equal(300, r.ts)
|
||
end)
|
||
|
||
it("captures a KO 'abandoned' even when Readest is in non-decisive 'reading'", function()
|
||
local r = RS.reconcile({ reading_status = "reading", reading_status_updated_at = 100 },
|
||
{ status = "abandoned", ts = 300 }, NOW)
|
||
assert.is_true(r.write_store)
|
||
assert.are.equal("abandoned", r.readest_status)
|
||
assert.are.equal(300, r.ts)
|
||
end)
|
||
end)
|
||
|
||
describe("readingstatus reconcile — both decisive", function()
|
||
it("is a no-op in steady state when both already agree", function()
|
||
local r = RS.reconcile({ reading_status = "finished", reading_status_updated_at = 500 },
|
||
{ status = "complete", ts = 300 }, NOW)
|
||
assert.is_false(r.write_ko)
|
||
assert.is_false(r.write_store)
|
||
end)
|
||
|
||
it("stamps a bootstrap book even when both already agree (exit bootstrap)", function()
|
||
local r = RS.reconcile({ reading_status = "finished", reading_status_updated_at = 0 },
|
||
{ status = "complete", ts = 300 }, NOW)
|
||
assert.is_false(r.write_ko)
|
||
assert.is_true(r.write_store)
|
||
assert.are.equal("finished", r.readest_status)
|
||
assert.are.equal(NOW, r.ts)
|
||
end)
|
||
|
||
it("bootstrap conflict: Readest is authoritative (finished beats KO on-hold)", function()
|
||
local r = RS.reconcile({ reading_status = "finished", reading_status_updated_at = 0 },
|
||
{ status = "abandoned", ts = 300 }, NOW)
|
||
assert.is_true(r.write_ko)
|
||
assert.are.equal("complete", r.ko_status) -- KO pulled to finished
|
||
assert.is_true(r.write_store)
|
||
assert.are.equal("finished", r.readest_status)
|
||
assert.are.equal(NOW, r.ts)
|
||
end)
|
||
|
||
it("steady conflict: Readest wins when its status timestamp is newer", function()
|
||
local r = RS.reconcile({ reading_status = "finished", reading_status_updated_at = 500 },
|
||
{ status = "abandoned", ts = 300 }, NOW)
|
||
assert.is_true(r.write_ko)
|
||
assert.are.equal("complete", r.ko_status)
|
||
assert.is_false(r.write_store)
|
||
assert.are.equal("finished", r.readest_status)
|
||
end)
|
||
|
||
it("steady conflict: KOReader wins when the sidecar change is newer", function()
|
||
local r = RS.reconcile({ reading_status = "finished", reading_status_updated_at = 300 },
|
||
{ status = "abandoned", ts = 500 }, NOW)
|
||
assert.is_false(r.write_ko) -- KO already abandoned
|
||
assert.is_true(r.write_store)
|
||
assert.are.equal("abandoned", r.readest_status)
|
||
assert.are.equal(500, r.ts)
|
||
end)
|
||
|
||
it("bootstrap: a Readest 'unread' reset clears a KO 'complete' (Readest authoritative)", function()
|
||
local r = RS.reconcile({ reading_status = "unread", reading_status_updated_at = 0 },
|
||
{ status = "complete", ts = 300 }, NOW)
|
||
assert.is_true(r.write_ko)
|
||
assert.is_nil(r.ko_status) -- clear -> "New"
|
||
assert.is_true(r.write_store)
|
||
assert.are.equal("unread", r.readest_status)
|
||
assert.are.equal(NOW, r.ts)
|
||
end)
|
||
end)
|
||
|
||
describe("readingstatus reconcile — remaining transfer-graph cells", function()
|
||
it("unread × reading: pushes a clear to KO + stamps", function()
|
||
local r = RS.reconcile({ reading_status = "unread", reading_status_updated_at = 0 },
|
||
{ status = "reading", ts = 1000 }, NOW)
|
||
assert.is_true(r.write_ko)
|
||
assert.is_nil(r.ko_status) -- clear -> "New"
|
||
assert.is_true(r.write_store)
|
||
assert.are.equal("unread", r.readest_status)
|
||
assert.are.equal(NOW, r.ts)
|
||
end)
|
||
|
||
it("abandoned × reading: pushes 'abandoned' down to KO + stamps", function()
|
||
local r = RS.reconcile({ reading_status = "abandoned", reading_status_updated_at = 0 },
|
||
{ status = "reading", ts = 1000 }, NOW)
|
||
assert.is_true(r.write_ko)
|
||
assert.are.equal("abandoned", r.ko_status)
|
||
assert.is_true(r.write_store)
|
||
assert.are.equal("abandoned", r.readest_status)
|
||
end)
|
||
|
||
it("abandoned × complete (bootstrap conflict): Readest 'abandoned' wins, KO pulled to abandoned", function()
|
||
local r = RS.reconcile({ reading_status = "abandoned", reading_status_updated_at = 0 },
|
||
{ status = "complete", ts = 300 }, NOW)
|
||
assert.is_true(r.write_ko)
|
||
assert.are.equal("abandoned", r.ko_status)
|
||
assert.is_true(r.write_store)
|
||
assert.are.equal("abandoned", r.readest_status)
|
||
assert.are.equal(NOW, r.ts)
|
||
end)
|
||
end)
|
||
|
||
describe("readingstatus reconcile — convergence (no ping-pong)", function()
|
||
it("converges after a capture", function()
|
||
local r = RS.reconcile({ reading_status = nil, reading_status_updated_at = 0 },
|
||
{ status = "complete", ts = 300 }, NOW)
|
||
-- equalize: store now holds the captured status; KO already had it
|
||
local r2 = RS.reconcile({ reading_status = r.readest_status, reading_status_updated_at = r.ts },
|
||
{ status = "complete", ts = 300 }, NOW)
|
||
assert.is_false(r2.write_ko)
|
||
assert.is_false(r2.write_store)
|
||
end)
|
||
|
||
it("converges after a bootstrap push", function()
|
||
local r = RS.reconcile({ reading_status = "finished", reading_status_updated_at = 0 },
|
||
{ status = "reading", ts = 1000 }, NOW)
|
||
-- equalize: store stamped to NOW; KO sidecar written to r.ko_status
|
||
local r2 = RS.reconcile({ reading_status = r.readest_status, reading_status_updated_at = r.ts },
|
||
{ status = r.ko_status, ts = 1000 }, NOW)
|
||
assert.is_false(r2.write_ko)
|
||
assert.is_false(r2.write_store)
|
||
end)
|
||
end)
|