* fix(koplugin): pull before push so sync doesn't wipe cloud book fields, closes #4138 The Library sync pushed a touched book row before pulling, so a row still missing the cloud's uploaded_at / metadata / group_id (e.g. one created by lightScan, not yet merged from a cloud pull) was sent with those fields nil. The server's transformBookToDB explicit-nulls uploaded_at and metadata for any field absent from the wire payload, wiping the cloud copy — after which every device that pulled lost the book's upload state. syncBooks("both") now pulls first, then pushes, and takes a before_push callback. syncBooksLibrary passes touchOpenBook through it so the open book's updated_at bump lands after the pull has refreshed the local row, letting the push carry the preserved cloud fields. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(koplugin): hide Library books with neither an uploaded nor a local file The Library showed any row with cloud_present = 1, but a bare cloud *record* whose file was never uploaded (uploaded_at NULL) has no cover and can't be opened — showing it is meaningless. Tighten the visibility predicate to (uploaded_at IS NOT NULL OR local_present = 1) across listBooks, getGroups, listBookshelfGroups and listBooksInGroup. This mirrors Readest, which only adds a synced book to the library when uploadedAt is set and keeps locally-imported books that carry a downloadedAt. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(koplugin): close the Library widget when opening a book Opening a book from the Library called ReaderUI:showReader without closing the Library Menu, so it stayed in the UIManager widget stack with M._menu still set. A later background M.refresh() — a cloud-sync or cover-download completion — then repainted that ghost Library over the reader, making it flash on screen for a few seconds. Add M.close(); route the title-bar X, M.reopen() and both handleTap book-open paths through it. A wrapped onCloseWidget clears M._menu on every close path, so M.refresh() no-ops once the Menu is gone. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
readest.koplugin tests
Unit tests for apps/readest.koplugin/library/ modules. Runs under LuaJIT
2.1 (the runtime KOReader uses) via busted.
Toolchain
One-time per machine:
# macOS
brew install luajit luarocks
# Linux (Debian/Ubuntu)
sudo apt-get install luajit luarocks
# Then, regardless of OS:
luarocks --lua-version=5.1 install busted
luarocks --lua-version=5.1 install lsqlite3complete
The --lua-version=5.1 flag is required: LuaJIT identifies itself as Lua 5.1, and we install rocks against that runtime so production code (which targets LuaJIT) and test code share a Lua interpreter.
Running
From the repo root:
pnpm test:lua
Or from this directory:
eval "$(luarocks --lua-version=5.1 path)"
busted --lua=$(which luajit)
Layout
spec/
├── spec_helper.lua # KOReader stubs + lua-ljsqlite3 shim (loaded once)
├── library/
│ ├── smoke_spec.lua # Sanity check that the harness boots
│ └── *_spec.lua # One per module under library/
└── README.md # This file
What spec_helper provides
require("lua-ljsqlite3/init")→ returns a SQLite shim wrappinglsqlite3complete. Exposes the subset of the lua-ljsqlite3 API our library modules use (open,exec,prepare,bind1,step,reset,clearbind,close, etc).require("logger")→ no-op logger (warn/info/dbg/errcallable).require("datastorage")→ fakeDataStorage:getSettingsDir()returning a per-testmktemp -dpath.require("device")→ stubDevice.canUseWAL() == true,Device.screenwithgetWidth/getHeight.G_reader_settings(global) → in-memoryreadSetting/saveSetting/flush.
Each spec calls require("spec_helper").reset() in before_each to wipe state.
Adding a new module
- Write production code at
apps/readest.koplugin/library/foo.lua. - Write
apps/readest.koplugin/spec/library/foo_spec.lua. - Run
pnpm test:luafrom the repo root. - Run
pnpm lint:luato syntax-check (LuaJIT bytecode compile).
Why LuaJIT and not stock Lua?
KOReader runs LuaJIT exclusively. LuaJIT extends Lua 5.1 with FFI and a few syntax tweaks; stock Lua 5.4 has features (integer division //, bit operators ~, <const> annotations) that LuaJIT rejects. Running tests under LuaJIT catches these incompatibilities at test time instead of when KOReader fails to load the plugin.