Files
readest/apps/readest.koplugin/spec/library/readingstatus_spec.lua
T
Huang Xin 72233e1c6a feat: sync reading status across devices and with KOReader (#4634) (#4656)
* 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>
2026-06-19 05:00:40 +02:00

204 lines
9.1 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- 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)