Loading bookloresync.koplugin/main.lua +141 −33 Original line number Diff line number Diff line Loading @@ -31,6 +31,8 @@ local logger = require("logger") local _ = require("gettext") local T = require("ffi/util").template local ffiutil = require("ffi/util") local strbuf = require("string.buffer") local Trapper = require("ui/trapper") local BookloreSync = WidgetContainer:extend{ Loading Loading @@ -4782,14 +4784,78 @@ function BookloreSync:syncFromBookloreShelf(silent, on_complete) end -- Trapper:wrap() hands control to a coroutine on the UI thread. -- dismissableRunInSubprocess() forks a child, polls via UIManager:scheduleIn, -- and resumes the coroutine with the child's return value when done. -- The user can dismiss the progress dialog to cancel mid-loop. -- Trapper:wrap() provides the coroutine context needed for coroutine.yield(). -- We do NOT use Trapper's TrapWidget machinery for cancellation — instead we -- poll the subprocess ourselves and honour cancel_requested set by the dialog. Trapper:wrap(function() -- ── Phase 1: fetch shelf metadata (single subprocess) ────────────── Trapper:info(_("Fetching book list from Booklore shelf...")) -- runSubprocess: fork fn(), poll until done, yield control to UIManager -- between checks. No TrapWidget is created, so no gesture can cancel -- the sync except the explicit Cancel button in status_dialog. -- Returns completed (bool), result (table). local _coroutine = coroutine.running() local function runSubprocess(fn) local pid, read_fd = ffiutil.runInSubProcess(function(_, write_fd) local results = table.pack(fn()) local ok, str = pcall(strbuf.encode, results) if ok then ffiutil.writeToFD(write_fd, str, true) end end, true) if not pid then return false, nil end local check_interval = 0.125 local check_num = 0 while true do check_num = check_num + 1 if check_interval < 1 and check_num % 10 == 0 then check_interval = math.min(check_interval * 2, 1) end local resume_func = function() coroutine.resume(_coroutine) end UIManager:scheduleIn(check_interval, resume_func) coroutine.yield() local done = ffiutil.isSubProcessDone(pid) local ready = read_fd and ffiutil.getNonBlockingReadSize(read_fd) ~= 0 if done or ready then local result if ready then local str = ffiutil.readAllFromFD(read_fd) local ok, t = pcall(strbuf.decode, str) if ok and t then result = table.unpack(t, 1, t.n) end if not done then local function collect() if not ffiutil.isSubProcessDone(pid) then UIManager:scheduleIn(1, collect) end end UIManager:scheduleIn(1, collect) end else ffiutil.readAllFromFD(read_fd) end return true, result end end end local fetch_completed, fetch_result = Trapper:dismissableRunInSubprocess(function() -- Show a persistent status dialog with an explicit Cancel button. -- dismissable=false means tapping outside the dialog does nothing, -- so the user cannot accidentally cancel by tapping the screen. -- Cancel during Phase 2 completes the current book before stopping. local cancel_requested = false local status_dialog status_dialog = ButtonDialog:new{ dismissable = false, title = _("Fetching book list from shelf..."), buttons = {{ { text = _("Cancel"), callback = function() cancel_requested = true UIManager:close(status_dialog) end, } }}, } UIManager:show(status_dialog) -- ── Phase 1: fetch shelf metadata (single subprocess) ────────────── local fetch_completed, fetch_result = runSubprocess(function() -- Child process: no UIManager calls allowed here. local ChildDB = require("booklore_database") local ChildAPI = require("booklore_api_client") Loading @@ -4811,10 +4877,11 @@ function BookloreSync:syncFromBookloreShelf(silent, on_complete) end return { success = true, shelf_id = shelf_id, books = books or {} } end, true) -- true = show dismissable progress dialog end) -- completed=false means the user dismissed (cancelled) -- completed=false means subprocess fork failed if not fetch_completed then UIManager:close(status_dialog) self.sync_in_progress = false if not silent and not self.silent_messages then UIManager:show(InfoMessage:new{ text = _("Sync cancelled"), timeout = 2 }) Loading @@ -4825,6 +4892,7 @@ function BookloreSync:syncFromBookloreShelf(silent, on_complete) -- completed=true but no result table means the subprocess crashed without output if type(fetch_result) ~= "table" then UIManager:close(status_dialog) self.sync_in_progress = false local msg = _("Sync failed: subprocess returned no data") self:logErr("BookloreSync: syncFromBookloreShelf - phase 1 returned no data") Loading @@ -4836,6 +4904,7 @@ function BookloreSync:syncFromBookloreShelf(silent, on_complete) end if not fetch_result.success then UIManager:close(status_dialog) self.sync_in_progress = false local msg = T(_("Sync failed: %1"), fetch_result.error or "unknown error") self:logErr("BookloreSync: syncFromBookloreShelf - phase 1 error:", fetch_result.error) Loading @@ -4856,6 +4925,7 @@ function BookloreSync:syncFromBookloreShelf(silent, on_complete) local books = fetch_result.books if type(books) ~= "table" or #books == 0 then UIManager:close(status_dialog) self.sync_in_progress = false local msg = _("Shelf is empty - no books to sync") if not silent and not self.silent_messages then Loading @@ -4873,6 +4943,32 @@ function BookloreSync:syncFromBookloreShelf(silent, on_complete) local new_books = {} -- accumulate DB writes for Phase 3 local shelf_book_ids = {} -- Update dialog title now that we know the total book count. status_dialog:setTitle(T(_("Syncing shelf: %1 books"), total)) -- Helper: flush accumulated DB writes and report cancellation. local function cancelSync(books_done) self:logInfo("BookloreSync: sync cancelled by user after", books_done, "books") self.sync_in_progress = false if #new_books > 0 then self.db:beginTransaction() local ok_partial = pcall(function() for _, nb in ipairs(new_books) do self.db:saveBookCache(nb.filepath, nb.hash, nb.book_id, nb.title, nb.author, nb.isbn10, nb.isbn13) end end) if ok_partial then self.db:commit() else self.db:rollback() end end if not silent and not self.silent_messages then UIManager:show(InfoMessage:new{ text = T(_("Sync cancelled after %1 of %2 books"), books_done, total), timeout = 3, }) end if on_complete then on_complete(false, _("Sync cancelled")) end end for i, book in ipairs(books) do local book_id = tonumber(book.id) if not book_id then goto book_continue end Loading @@ -4883,10 +4979,11 @@ function BookloreSync:syncFromBookloreShelf(silent, on_complete) local filepath = download_dir .. "/" .. filename local already_exists = lfs.attributes(filepath, "mode") == "file" -- Update the dialog title to show current book progress. if already_exists then Trapper:info(T(_("Skipping: %1 (%2/%3)"), book.title or "Unknown", i, total)) status_dialog:setTitle(T(_("Skipping: %1 (%2/%3)"), book.title or "Unknown", i, total)) else Trapper:info(T(_("Downloading: %1 (%2/%3)"), book.title or "Unknown", i, total)) status_dialog:setTitle(T(_("Downloading: %1 (%2/%3)"), book.title or "Unknown", i, total)) end -- Capture loop-local values for the closure (Lua upvalue semantics) Loading @@ -4897,7 +4994,7 @@ function BookloreSync:syncFromBookloreShelf(silent, on_complete) local cap_isbn10 = book.isbn10 local cap_isbn13 = book.isbn13 local book_completed, book_result = Trapper:dismissableRunInSubprocess(function() local book_completed, book_result = runSubprocess(function() -- Child: check / download / hash one book. if lfs.attributes(cap_filepath, "mode") == "file" then -- File already on disk; check if it needs a cache entry Loading Loading @@ -4931,30 +5028,39 @@ function BookloreSync:syncFromBookloreShelf(silent, on_complete) local hash = calcHash(cap_filepath) return { status = "downloaded", hash = hash } end, true) end) -- completed=false means the user dismissed (cancelled) -- completed=false means the subprocess fork failed -- (should not happen in normal use; defensive). if not book_completed then self:logInfo("BookloreSync: sync cancelled by user after", i - 1, "books") self.sync_in_progress = false if not silent and not self.silent_messages then UIManager:show(InfoMessage:new{ text = T(_("Sync cancelled after %1 of %2 books"), i - 1, total), timeout = 3, }) UIManager:close(status_dialog) cancelSync(i - 1) return end -- Flush any DB writes accumulated so far before returning if #new_books > 0 then self.db:beginTransaction() local ok_partial = pcall(function() for _, nb in ipairs(new_books) do self.db:saveBookCache(nb.filepath, nb.hash, nb.book_id, nb.title, nb.author, nb.isbn10, nb.isbn13) -- Cancel button was pressed: current book just finished cleanly, stop now. if cancel_requested then -- Process this book's result before stopping so it is not lost. if type(book_result) == "table" then if book_result.status == "downloaded" then downloaded = downloaded + 1 table.insert(new_books, { filepath = cap_filepath, hash = book_result.hash, book_id = cap_book_id, title = cap_title, author = cap_author, isbn10 = cap_isbn10, isbn13 = cap_isbn13, }) elseif book_result.status == "skipped_uncached" then skipped = skipped + 1 table.insert(new_books, { filepath = cap_filepath, hash = book_result.hash, book_id = cap_book_id, title = cap_title, author = cap_author, isbn10 = cap_isbn10, isbn13 = cap_isbn13, }) elseif book_result.status == "skipped" then skipped = skipped + 1 end end) if ok_partial then self.db:commit() else self.db:rollback() end end if on_complete then on_complete(false, _("Sync cancelled")) end cancelSync(i) return end Loading Loading @@ -4989,6 +5095,8 @@ function BookloreSync:syncFromBookloreShelf(silent, on_complete) ::book_continue:: end UIManager:close(status_dialog) -- ── Phase 3: DB writes + optional deletions (parent/UI thread) ───── local deleted = 0 Loading test/helpers/stub_koreader.lua +37 −9 Original line number Diff line number Diff line Loading @@ -23,6 +23,7 @@ local STUB_KEYS = { "booklore_metadata_extractor", "gettext", "ffi/util", "string.buffer", "ui/trapper", } Loading Loading @@ -108,7 +109,11 @@ local function install() end package.preload["ui/widget/buttondialog"] = function() return { new = function(_, o) return o or {} end } local BD = {} BD.__index = BD function BD:new(o) o = o or {}; return setmetatable(o, self) end function BD:setTitle(t) self.title = t end return BD end package.preload["ui/widget/menu"] = function() Loading Loading @@ -155,12 +160,33 @@ local function install() end package.preload["ffi/util"] = function() return { template = function(fmt, ...) -- Synchronous subprocess stub: runInSubProcess calls fn inline. -- writeToFD stores data into fake_result; readAllFromFD returns it. local fake_result return { template = function(fmt, ...) local args = { ... } return (fmt:gsub("%%(%d+)", function(idx) return tostring(args[tonumber(idx)] or "") end)) end } end, runInSubProcess = function(fn, _) pcall(fn, 1, 1) -- fn calls writeToFD which sets fake_result return 1, 1 -- fake pid, fake fd end, isSubProcessDone = function() return true end, getNonBlockingReadSize = function() return 1 end, readAllFromFD = function() return fake_result end, writeToFD = function(_, data) fake_result = data end, terminateSubProcess = function() end, } end package.preload["string.buffer"] = function() local M = {} function M.encode(t) return t end function M.decode(t) return t end return M end -- Minimal Trapper stub: runs everything synchronously on the test thread. Loading @@ -170,8 +196,10 @@ local function install() return { wrap = function(_, fn) local co = coroutine.create(fn) repeat local ok, err = coroutine.resume(co) if not ok then error(err) end until coroutine.status(co) == "dead" end, info = function() end, -- Returns (completed, result) matching Trapper's real API contract: Loading test/shelf_sync_spec.lua +36 −4 Original line number Diff line number Diff line Loading @@ -27,7 +27,13 @@ package.preload["ui/widget/eventlistener"] = function() return {} end package.preload["ui/widget/infomessage"] = function() return { new = function(_, o) return o or {} end } end package.preload["ui/widget/inputdialog"] = function() return { new = function(_, o) return o or {} end } end package.preload["ui/widget/confirmbox"] = function() return { new = function(_, o) return o or {} end } end package.preload["ui/widget/buttondialog"] = function() return { new = function(_, o) return o or {} end } end package.preload["ui/widget/buttondialog"] = function() local BD = {} BD.__index = BD function BD:new(o) o = o or {}; return setmetatable(o, self) end function BD:setTitle(t) self.title = t end return BD end package.preload["ui/widget/menu"] = function() return { new = function(_, o) return o or {} end } end package.preload["ui/network/manager"] = function() return { isOnline = function() return false end } end package.preload["luasettings"] = function() return { open = function() return { readSetting = function() return nil end } end } end Loading @@ -37,7 +43,31 @@ package.preload["booklore_updater"] = function() return { new = func package.preload["booklore_file_logger"] = function() return { new = function() return {} end } end package.preload["booklore_metadata_extractor"] = function() return { new = function() return {} end } end package.preload["json"] = function() return { encode = function() return "{}" end, decode = function() return {} end } end package.preload["ffi/util"] = function() return { template = function(fmt, ...) local a={...}; return (fmt:gsub("%%(%d+)", function(i) return tostring(a[tonumber(i)] or "") end)) end } end package.preload["ffi/util"] = function() -- Synchronous subprocess stub: runInSubProcess calls fn inline. -- writeToFD stores data into fake_result; readAllFromFD returns it. local fake_result return { template = function(fmt, ...) local a={...}; return (fmt:gsub("%%(%d+)", function(i) return tostring(a[tonumber(i)] or "") end)) end, runInSubProcess = function(fn, _) pcall(fn, 1, 1) -- fn calls writeToFD which sets fake_result return 1, 1 -- fake pid, fake fd end, isSubProcessDone = function() return true end, getNonBlockingReadSize = function() return 1 end, readAllFromFD = function() return fake_result end, writeToFD = function(_, data) fake_result = data end, terminateSubProcess = function() end, } end package.preload["string.buffer"] = function() -- Passthrough stub: encode wraps the table, decode unwraps it. local M = {} function M.encode(t) return t end function M.decode(t) return t end return M end package.preload["gettext"] = function() -- Return a function that also supports T(fmt, ...) template substitution. Loading Loading @@ -79,8 +109,10 @@ package.preload["ui/trapper"] = function() return { wrap = function(_, fn) local co = coroutine.create(fn) repeat local ok, err = coroutine.resume(co) if not ok then error(err) end until coroutine.status(co) == "dead" end, info = function() end, dismissableRunInSubprocess = function(_, worker_fn, _) Loading Loading
bookloresync.koplugin/main.lua +141 −33 Original line number Diff line number Diff line Loading @@ -31,6 +31,8 @@ local logger = require("logger") local _ = require("gettext") local T = require("ffi/util").template local ffiutil = require("ffi/util") local strbuf = require("string.buffer") local Trapper = require("ui/trapper") local BookloreSync = WidgetContainer:extend{ Loading Loading @@ -4782,14 +4784,78 @@ function BookloreSync:syncFromBookloreShelf(silent, on_complete) end -- Trapper:wrap() hands control to a coroutine on the UI thread. -- dismissableRunInSubprocess() forks a child, polls via UIManager:scheduleIn, -- and resumes the coroutine with the child's return value when done. -- The user can dismiss the progress dialog to cancel mid-loop. -- Trapper:wrap() provides the coroutine context needed for coroutine.yield(). -- We do NOT use Trapper's TrapWidget machinery for cancellation — instead we -- poll the subprocess ourselves and honour cancel_requested set by the dialog. Trapper:wrap(function() -- ── Phase 1: fetch shelf metadata (single subprocess) ────────────── Trapper:info(_("Fetching book list from Booklore shelf...")) -- runSubprocess: fork fn(), poll until done, yield control to UIManager -- between checks. No TrapWidget is created, so no gesture can cancel -- the sync except the explicit Cancel button in status_dialog. -- Returns completed (bool), result (table). local _coroutine = coroutine.running() local function runSubprocess(fn) local pid, read_fd = ffiutil.runInSubProcess(function(_, write_fd) local results = table.pack(fn()) local ok, str = pcall(strbuf.encode, results) if ok then ffiutil.writeToFD(write_fd, str, true) end end, true) if not pid then return false, nil end local check_interval = 0.125 local check_num = 0 while true do check_num = check_num + 1 if check_interval < 1 and check_num % 10 == 0 then check_interval = math.min(check_interval * 2, 1) end local resume_func = function() coroutine.resume(_coroutine) end UIManager:scheduleIn(check_interval, resume_func) coroutine.yield() local done = ffiutil.isSubProcessDone(pid) local ready = read_fd and ffiutil.getNonBlockingReadSize(read_fd) ~= 0 if done or ready then local result if ready then local str = ffiutil.readAllFromFD(read_fd) local ok, t = pcall(strbuf.decode, str) if ok and t then result = table.unpack(t, 1, t.n) end if not done then local function collect() if not ffiutil.isSubProcessDone(pid) then UIManager:scheduleIn(1, collect) end end UIManager:scheduleIn(1, collect) end else ffiutil.readAllFromFD(read_fd) end return true, result end end end local fetch_completed, fetch_result = Trapper:dismissableRunInSubprocess(function() -- Show a persistent status dialog with an explicit Cancel button. -- dismissable=false means tapping outside the dialog does nothing, -- so the user cannot accidentally cancel by tapping the screen. -- Cancel during Phase 2 completes the current book before stopping. local cancel_requested = false local status_dialog status_dialog = ButtonDialog:new{ dismissable = false, title = _("Fetching book list from shelf..."), buttons = {{ { text = _("Cancel"), callback = function() cancel_requested = true UIManager:close(status_dialog) end, } }}, } UIManager:show(status_dialog) -- ── Phase 1: fetch shelf metadata (single subprocess) ────────────── local fetch_completed, fetch_result = runSubprocess(function() -- Child process: no UIManager calls allowed here. local ChildDB = require("booklore_database") local ChildAPI = require("booklore_api_client") Loading @@ -4811,10 +4877,11 @@ function BookloreSync:syncFromBookloreShelf(silent, on_complete) end return { success = true, shelf_id = shelf_id, books = books or {} } end, true) -- true = show dismissable progress dialog end) -- completed=false means the user dismissed (cancelled) -- completed=false means subprocess fork failed if not fetch_completed then UIManager:close(status_dialog) self.sync_in_progress = false if not silent and not self.silent_messages then UIManager:show(InfoMessage:new{ text = _("Sync cancelled"), timeout = 2 }) Loading @@ -4825,6 +4892,7 @@ function BookloreSync:syncFromBookloreShelf(silent, on_complete) -- completed=true but no result table means the subprocess crashed without output if type(fetch_result) ~= "table" then UIManager:close(status_dialog) self.sync_in_progress = false local msg = _("Sync failed: subprocess returned no data") self:logErr("BookloreSync: syncFromBookloreShelf - phase 1 returned no data") Loading @@ -4836,6 +4904,7 @@ function BookloreSync:syncFromBookloreShelf(silent, on_complete) end if not fetch_result.success then UIManager:close(status_dialog) self.sync_in_progress = false local msg = T(_("Sync failed: %1"), fetch_result.error or "unknown error") self:logErr("BookloreSync: syncFromBookloreShelf - phase 1 error:", fetch_result.error) Loading @@ -4856,6 +4925,7 @@ function BookloreSync:syncFromBookloreShelf(silent, on_complete) local books = fetch_result.books if type(books) ~= "table" or #books == 0 then UIManager:close(status_dialog) self.sync_in_progress = false local msg = _("Shelf is empty - no books to sync") if not silent and not self.silent_messages then Loading @@ -4873,6 +4943,32 @@ function BookloreSync:syncFromBookloreShelf(silent, on_complete) local new_books = {} -- accumulate DB writes for Phase 3 local shelf_book_ids = {} -- Update dialog title now that we know the total book count. status_dialog:setTitle(T(_("Syncing shelf: %1 books"), total)) -- Helper: flush accumulated DB writes and report cancellation. local function cancelSync(books_done) self:logInfo("BookloreSync: sync cancelled by user after", books_done, "books") self.sync_in_progress = false if #new_books > 0 then self.db:beginTransaction() local ok_partial = pcall(function() for _, nb in ipairs(new_books) do self.db:saveBookCache(nb.filepath, nb.hash, nb.book_id, nb.title, nb.author, nb.isbn10, nb.isbn13) end end) if ok_partial then self.db:commit() else self.db:rollback() end end if not silent and not self.silent_messages then UIManager:show(InfoMessage:new{ text = T(_("Sync cancelled after %1 of %2 books"), books_done, total), timeout = 3, }) end if on_complete then on_complete(false, _("Sync cancelled")) end end for i, book in ipairs(books) do local book_id = tonumber(book.id) if not book_id then goto book_continue end Loading @@ -4883,10 +4979,11 @@ function BookloreSync:syncFromBookloreShelf(silent, on_complete) local filepath = download_dir .. "/" .. filename local already_exists = lfs.attributes(filepath, "mode") == "file" -- Update the dialog title to show current book progress. if already_exists then Trapper:info(T(_("Skipping: %1 (%2/%3)"), book.title or "Unknown", i, total)) status_dialog:setTitle(T(_("Skipping: %1 (%2/%3)"), book.title or "Unknown", i, total)) else Trapper:info(T(_("Downloading: %1 (%2/%3)"), book.title or "Unknown", i, total)) status_dialog:setTitle(T(_("Downloading: %1 (%2/%3)"), book.title or "Unknown", i, total)) end -- Capture loop-local values for the closure (Lua upvalue semantics) Loading @@ -4897,7 +4994,7 @@ function BookloreSync:syncFromBookloreShelf(silent, on_complete) local cap_isbn10 = book.isbn10 local cap_isbn13 = book.isbn13 local book_completed, book_result = Trapper:dismissableRunInSubprocess(function() local book_completed, book_result = runSubprocess(function() -- Child: check / download / hash one book. if lfs.attributes(cap_filepath, "mode") == "file" then -- File already on disk; check if it needs a cache entry Loading Loading @@ -4931,30 +5028,39 @@ function BookloreSync:syncFromBookloreShelf(silent, on_complete) local hash = calcHash(cap_filepath) return { status = "downloaded", hash = hash } end, true) end) -- completed=false means the user dismissed (cancelled) -- completed=false means the subprocess fork failed -- (should not happen in normal use; defensive). if not book_completed then self:logInfo("BookloreSync: sync cancelled by user after", i - 1, "books") self.sync_in_progress = false if not silent and not self.silent_messages then UIManager:show(InfoMessage:new{ text = T(_("Sync cancelled after %1 of %2 books"), i - 1, total), timeout = 3, }) UIManager:close(status_dialog) cancelSync(i - 1) return end -- Flush any DB writes accumulated so far before returning if #new_books > 0 then self.db:beginTransaction() local ok_partial = pcall(function() for _, nb in ipairs(new_books) do self.db:saveBookCache(nb.filepath, nb.hash, nb.book_id, nb.title, nb.author, nb.isbn10, nb.isbn13) -- Cancel button was pressed: current book just finished cleanly, stop now. if cancel_requested then -- Process this book's result before stopping so it is not lost. if type(book_result) == "table" then if book_result.status == "downloaded" then downloaded = downloaded + 1 table.insert(new_books, { filepath = cap_filepath, hash = book_result.hash, book_id = cap_book_id, title = cap_title, author = cap_author, isbn10 = cap_isbn10, isbn13 = cap_isbn13, }) elseif book_result.status == "skipped_uncached" then skipped = skipped + 1 table.insert(new_books, { filepath = cap_filepath, hash = book_result.hash, book_id = cap_book_id, title = cap_title, author = cap_author, isbn10 = cap_isbn10, isbn13 = cap_isbn13, }) elseif book_result.status == "skipped" then skipped = skipped + 1 end end) if ok_partial then self.db:commit() else self.db:rollback() end end if on_complete then on_complete(false, _("Sync cancelled")) end cancelSync(i) return end Loading Loading @@ -4989,6 +5095,8 @@ function BookloreSync:syncFromBookloreShelf(silent, on_complete) ::book_continue:: end UIManager:close(status_dialog) -- ── Phase 3: DB writes + optional deletions (parent/UI thread) ───── local deleted = 0 Loading
test/helpers/stub_koreader.lua +37 −9 Original line number Diff line number Diff line Loading @@ -23,6 +23,7 @@ local STUB_KEYS = { "booklore_metadata_extractor", "gettext", "ffi/util", "string.buffer", "ui/trapper", } Loading Loading @@ -108,7 +109,11 @@ local function install() end package.preload["ui/widget/buttondialog"] = function() return { new = function(_, o) return o or {} end } local BD = {} BD.__index = BD function BD:new(o) o = o or {}; return setmetatable(o, self) end function BD:setTitle(t) self.title = t end return BD end package.preload["ui/widget/menu"] = function() Loading Loading @@ -155,12 +160,33 @@ local function install() end package.preload["ffi/util"] = function() return { template = function(fmt, ...) -- Synchronous subprocess stub: runInSubProcess calls fn inline. -- writeToFD stores data into fake_result; readAllFromFD returns it. local fake_result return { template = function(fmt, ...) local args = { ... } return (fmt:gsub("%%(%d+)", function(idx) return tostring(args[tonumber(idx)] or "") end)) end } end, runInSubProcess = function(fn, _) pcall(fn, 1, 1) -- fn calls writeToFD which sets fake_result return 1, 1 -- fake pid, fake fd end, isSubProcessDone = function() return true end, getNonBlockingReadSize = function() return 1 end, readAllFromFD = function() return fake_result end, writeToFD = function(_, data) fake_result = data end, terminateSubProcess = function() end, } end package.preload["string.buffer"] = function() local M = {} function M.encode(t) return t end function M.decode(t) return t end return M end -- Minimal Trapper stub: runs everything synchronously on the test thread. Loading @@ -170,8 +196,10 @@ local function install() return { wrap = function(_, fn) local co = coroutine.create(fn) repeat local ok, err = coroutine.resume(co) if not ok then error(err) end until coroutine.status(co) == "dead" end, info = function() end, -- Returns (completed, result) matching Trapper's real API contract: Loading
test/shelf_sync_spec.lua +36 −4 Original line number Diff line number Diff line Loading @@ -27,7 +27,13 @@ package.preload["ui/widget/eventlistener"] = function() return {} end package.preload["ui/widget/infomessage"] = function() return { new = function(_, o) return o or {} end } end package.preload["ui/widget/inputdialog"] = function() return { new = function(_, o) return o or {} end } end package.preload["ui/widget/confirmbox"] = function() return { new = function(_, o) return o or {} end } end package.preload["ui/widget/buttondialog"] = function() return { new = function(_, o) return o or {} end } end package.preload["ui/widget/buttondialog"] = function() local BD = {} BD.__index = BD function BD:new(o) o = o or {}; return setmetatable(o, self) end function BD:setTitle(t) self.title = t end return BD end package.preload["ui/widget/menu"] = function() return { new = function(_, o) return o or {} end } end package.preload["ui/network/manager"] = function() return { isOnline = function() return false end } end package.preload["luasettings"] = function() return { open = function() return { readSetting = function() return nil end } end } end Loading @@ -37,7 +43,31 @@ package.preload["booklore_updater"] = function() return { new = func package.preload["booklore_file_logger"] = function() return { new = function() return {} end } end package.preload["booklore_metadata_extractor"] = function() return { new = function() return {} end } end package.preload["json"] = function() return { encode = function() return "{}" end, decode = function() return {} end } end package.preload["ffi/util"] = function() return { template = function(fmt, ...) local a={...}; return (fmt:gsub("%%(%d+)", function(i) return tostring(a[tonumber(i)] or "") end)) end } end package.preload["ffi/util"] = function() -- Synchronous subprocess stub: runInSubProcess calls fn inline. -- writeToFD stores data into fake_result; readAllFromFD returns it. local fake_result return { template = function(fmt, ...) local a={...}; return (fmt:gsub("%%(%d+)", function(i) return tostring(a[tonumber(i)] or "") end)) end, runInSubProcess = function(fn, _) pcall(fn, 1, 1) -- fn calls writeToFD which sets fake_result return 1, 1 -- fake pid, fake fd end, isSubProcessDone = function() return true end, getNonBlockingReadSize = function() return 1 end, readAllFromFD = function() return fake_result end, writeToFD = function(_, data) fake_result = data end, terminateSubProcess = function() end, } end package.preload["string.buffer"] = function() -- Passthrough stub: encode wraps the table, decode unwraps it. local M = {} function M.encode(t) return t end function M.decode(t) return t end return M end package.preload["gettext"] = function() -- Return a function that also supports T(fmt, ...) template substitution. Loading Loading @@ -79,8 +109,10 @@ package.preload["ui/trapper"] = function() return { wrap = function(_, fn) local co = coroutine.create(fn) repeat local ok, err = coroutine.resume(co) if not ok then error(err) end until coroutine.status(co) == "dead" end, info = function() end, dismissableRunInSubprocess = function(_, worker_fn, _) Loading