mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
50fd0e9c7d
scanInbox never consulted ready, and mobileFileBridge already mirrors android on Android/iOS, so the dual guard was a no-op. Co-authored-by: Cursor <cursoragent@cursor.com>
515 lines
21 KiB
Lua
515 lines
21 KiB
Lua
-- NX saves .sav inbox: ensure imports/saves/, MTP hint, AppleDouble/retain/
|
|
-- retire-on-success + hash dedupe (NXSAV + RES harden).
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
if not _G.love then _G.love = require("tests.love_stub") end
|
|
|
|
local S = require("tests.harness").suite("rom importer NX saves inbox")
|
|
local eq = S.eq
|
|
local check = S.check
|
|
|
|
local RomImporter = require("src.import.RomImporter")
|
|
|
|
love.data = love.data or {}
|
|
love.system = love.system or {}
|
|
love.filesystem = love.filesystem or {}
|
|
|
|
local saved = {
|
|
hash = love.data.hash,
|
|
encode = love.data.encode,
|
|
getOS = love.system.getOS,
|
|
getSaveDirectory = love.filesystem.getSaveDirectory,
|
|
createDirectory = love.filesystem.createDirectory,
|
|
remove = love.filesystem.remove,
|
|
}
|
|
|
|
-- Deterministic fake sha1 so content-hash dedupe is testable headless.
|
|
love.data.hash = function(_, data)
|
|
return data
|
|
end
|
|
love.data.encode = function(_, _, digest)
|
|
local s = type(digest) == "string" and digest or tostring(digest)
|
|
local hex = {}
|
|
for i = 1, #s do
|
|
hex[#hex + 1] = string.format("%02x", s:byte(i))
|
|
end
|
|
local h = table.concat(hex)
|
|
if #h < 40 then h = h .. string.rep("0", 40 - #h) end
|
|
return h:sub(1, 40)
|
|
end
|
|
|
|
love.system.getOS = function() return "NX" end
|
|
love.filesystem.getSaveDirectory = function()
|
|
return "sdmc:/switch/gen1recomp/pokemon-love2d"
|
|
end
|
|
|
|
local createdDirs = {}
|
|
love.filesystem.createDirectory = function(name)
|
|
createdDirs[name] = true
|
|
return true
|
|
end
|
|
|
|
local removed = {}
|
|
love.filesystem.remove = function(name)
|
|
removed[name] = true
|
|
return saved.remove(name)
|
|
end
|
|
|
|
package.loaded["src.core.Platform"] = nil
|
|
package.loaded["src.import.RomImporter"] = nil
|
|
RomImporter = require("src.import.RomImporter")
|
|
|
|
local function clearSavesInbox()
|
|
for _, ver in ipairs({ "red", "blue", "yellow" }) do
|
|
local dir = "imports/saves/" .. ver
|
|
for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do
|
|
love.filesystem.remove(dir .. "/" .. name)
|
|
end
|
|
love.filesystem.remove(dir .. "/.imported-sha1")
|
|
end
|
|
for _, name in ipairs(love.filesystem.getDirectoryItems("imports/saves") or {}) do
|
|
love.filesystem.remove("imports/saves/" .. name)
|
|
end
|
|
for _, name in ipairs(love.filesystem.getDirectoryItems("imports/mods") or {}) do
|
|
love.filesystem.remove("imports/mods/" .. name)
|
|
end
|
|
for _, name in ipairs(love.filesystem.getDirectoryItems("imports") or {}) do
|
|
love.filesystem.remove("imports/" .. name)
|
|
end
|
|
end
|
|
|
|
local function freshImporter()
|
|
clearSavesInbox()
|
|
createdDirs = {}
|
|
removed = {}
|
|
package.loaded["src.import.RomImporter"] = nil
|
|
RomImporter = require("src.import.RomImporter")
|
|
return setmetatable({
|
|
isNX = true,
|
|
android = false,
|
|
launcher = true,
|
|
workState = nil,
|
|
tab = "red",
|
|
panelVersion = "red",
|
|
saveNotice = {},
|
|
ready = { red = true, blue = false, yellow = false },
|
|
activeSlot = {},
|
|
slotScroll = {},
|
|
slots = {},
|
|
ensureImportsDir = RomImporter.ensureImportsDir,
|
|
ensureSavesInboxDir = RomImporter.ensureSavesInboxDir,
|
|
ensureModsInboxDir = RomImporter.ensureModsInboxDir,
|
|
_setNxSavesInboxNotice = RomImporter._setNxSavesInboxNotice,
|
|
_resolveSaveVersion = RomImporter._resolveSaveVersion,
|
|
scanSavesInbox = RomImporter.scanSavesInbox,
|
|
scanModsInbox = RomImporter.scanModsInbox,
|
|
scanInbox = RomImporter.scanInbox,
|
|
rescanSavesAction = RomImporter.rescanSavesAction,
|
|
chooseSaveImport = RomImporter.chooseSaveImport,
|
|
exportSave = RomImporter.exportSave,
|
|
_importSave = RomImporter._importSave,
|
|
_savedropTarget = RomImporter._savedropTarget,
|
|
_savesDefaultHint = RomImporter._savesDefaultHint,
|
|
_refreshSlots = function(self, version)
|
|
self._refreshed = (self._refreshed or 0) + 1
|
|
self._refreshVersion = version
|
|
end,
|
|
}, RomImporter)
|
|
end
|
|
|
|
-- RES-07: fixture uses isNX=true, android=false
|
|
local ri = freshImporter()
|
|
eq(ri.isNX, true, "RES-07: fixture isNX=true")
|
|
eq(ri.android, false, "RES-07: fixture android=false")
|
|
|
|
-- RES-01: ensureSavesInboxDir creates imports/, imports/saves/, and per-game dirs
|
|
createdDirs = {}
|
|
ri = freshImporter()
|
|
ri:ensureSavesInboxDir("red")
|
|
check(createdDirs.imports == true,
|
|
"RES-01: ensureSavesInboxDir creates parent imports/")
|
|
check(createdDirs["imports/saves"] == true,
|
|
"RES-01: ensureSavesInboxDir creates imports/saves/")
|
|
check(createdDirs["imports/saves/red"] == true,
|
|
"RES-01: ensureSavesInboxDir creates imports/saves/red/")
|
|
check(createdDirs["imports/saves/blue"] == true,
|
|
"RES-01: ensureSavesInboxDir creates imports/saves/blue/")
|
|
check(createdDirs["imports/saves/yellow"] == true,
|
|
"RES-01: ensureSavesInboxDir creates imports/saves/yellow/")
|
|
|
|
-- NXSAV-02: notice/hint includes save dir + per-game imports/saves/<version>/ MTP path
|
|
ri = freshImporter()
|
|
ri:_setNxSavesInboxNotice("red")
|
|
check(ri.saveNotice.red ~= nil, "NX saves inbox notice is set")
|
|
check(ri.saveNotice.red.text:find("sdmc:/switch/gen1recomp/pokemon-love2d/imports/saves/red/", 1, true),
|
|
"saves notice contains runtime save path + imports/saves/red/")
|
|
check(ri.saveNotice.red.text:find("DBI MTP", 1, true) ~= nil,
|
|
"saves notice contains OpenMTP-oriented hint")
|
|
check(ri.saveNotice.red.text:find("imports/saves/red/", 1, true),
|
|
"hint uses per-game imports/saves/red/ path")
|
|
|
|
-- NXSAV-01 / RES-08: scanSavesInbox returns only *.sav under imports/saves/<version>/
|
|
ri = freshImporter()
|
|
love.filesystem.write("imports/saves/red/valid.sav", string.rep("S", 32))
|
|
love.filesystem.write("imports/saves/red/readme.txt", "nope")
|
|
love.filesystem.write("imports/saves/red/cart.gb", string.rep("R", 16))
|
|
love.filesystem.write("imports/saves/red/pack.zip", "ZIP")
|
|
love.filesystem.write("imports/saves/blue/other.sav", "WRONGGAME")
|
|
love.filesystem.write("imports/other.sav", "WRONGDIR")
|
|
local savs = ri:scanSavesInbox("red")
|
|
eq(#savs, 1, "scanSavesInbox returns one .sav candidate for red")
|
|
eq(savs[1], "imports/saves/red/valid.sav", "scanSavesInbox path is under imports/saves/red/")
|
|
|
|
-- RES-08: ROM scanInbox must not treat imports/saves/*.sav as ROM
|
|
ri = freshImporter()
|
|
love.filesystem.write("imports/saves/red/cart.sav", string.rep("S", 32))
|
|
love.filesystem.write("imports/saves/red/dump.gb", string.rep("G", 16))
|
|
local roms = ri:scanInbox()
|
|
for _, path in ipairs(roms) do
|
|
check(not path:lower():match("%.sav$"),
|
|
"ROM scanInbox ignores .sav: " .. tostring(path))
|
|
check(not path:find("imports/saves/", 1, true),
|
|
"ROM scanInbox ignores imports/saves/: " .. tostring(path))
|
|
end
|
|
|
|
-- RES-08: mod scanModsInbox ignores .sav
|
|
ri = freshImporter()
|
|
ri:ensureModsInboxDir()
|
|
love.filesystem.write("imports/mods/mod.zip", "ZIP")
|
|
love.filesystem.write("imports/saves/red/slot.sav", string.rep("S", 32))
|
|
local zips = ri:scanModsInbox()
|
|
for _, path in ipairs(zips) do
|
|
check(not path:lower():match("%.sav$"),
|
|
"mod scanModsInbox ignores .sav: " .. tostring(path))
|
|
end
|
|
eq(#zips, 1, "mod scanModsInbox still finds only its zip")
|
|
|
|
-- Stub SaveFileIO.importToSlot for rescan tests
|
|
local importCalls = {}
|
|
local importBehavior = {} -- path -> {ok=bool, id=string|err}
|
|
package.loaded["src.import.SaveFileIO"] = {
|
|
importToSlot = function(source, version)
|
|
importCalls[#importCalls + 1] = { source = source, version = version }
|
|
local b = importBehavior[source]
|
|
if not b then return false, "unexpected source: " .. tostring(source) end
|
|
if b.ok then return true, b.id or "slot-1" end
|
|
return false, b.err or "bad sav"
|
|
end,
|
|
exportActiveSlot = function()
|
|
return false, "no save"
|
|
end,
|
|
}
|
|
|
|
-- RES-04: empty inbox rescan → MTP notice, no import
|
|
ri = freshImporter()
|
|
importCalls = {}
|
|
ri:rescanSavesAction("red")
|
|
eq(#importCalls, 0, "empty saves inbox does not call importToSlot")
|
|
check(ri.saveNotice.red ~= nil, "RES-04: empty rescan sets saveNotice")
|
|
check(ri.saveNotice.red.text:find("imports/saves/red/", 1, true),
|
|
"empty rescan shows saves MTP notice")
|
|
|
|
-- RES-02: AppleDouble-only inbox ≡ empty
|
|
ri = freshImporter()
|
|
importCalls = {}
|
|
love.filesystem.write("imports/saves/red/._foo.sav", "APPL")
|
|
ri:rescanSavesAction("red")
|
|
eq(#importCalls, 0, "RES-02: AppleDouble-only does not import")
|
|
check(ri.saveNotice.red ~= nil and ri.saveNotice.red.text:find("imports/saves/red/", 1, true),
|
|
"RES-02: AppleDouble-only shows MTP notice")
|
|
|
|
-- NXSAV-03 / RES-05: success → refresh; bytes kept as *.sav.imported (not re-scanned)
|
|
ri = freshImporter()
|
|
importCalls = {}
|
|
removed = {}
|
|
love.filesystem.write("imports/saves/red/good.sav", "GOODSAV")
|
|
importBehavior["imports/saves/red/good.sav"] = { ok = true, id = "slot-good" }
|
|
ri:rescanSavesAction("red")
|
|
eq(#importCalls, 1, "success path calls importToSlot once")
|
|
eq(importCalls[1].source, "imports/saves/red/good.sav", "importToSlot receives inbox path")
|
|
eq(importCalls[1].version, "red", "importToSlot uses panel version")
|
|
check(ri._refreshed and ri._refreshed >= 1, "success refreshes slots")
|
|
check(ri.saveNotice.red and ri.saveNotice.red.ok, "success sets ok notice")
|
|
check(ri.saveNotice.red.text:find("Pokemon Red", 1, true)
|
|
or ri.saveNotice.red.text:find("Red", 1, true),
|
|
"success notice names the game tab")
|
|
check(love.filesystem.getInfo("imports/saves/red/good.sav") == nil,
|
|
"RES-05: success retires live .sav (no longer a candidate)")
|
|
check(love.filesystem.read("imports/saves/red/good.sav.imported") == "GOODSAV",
|
|
"RES-05: success keeps bytes under .sav.imported")
|
|
check(love.filesystem.getInfo("imports/saves/red/.imported-sha1") ~= nil,
|
|
"success records content hash ledger")
|
|
|
|
-- Re-press Import save must not clone slots (hash ledger + retired file)
|
|
ri = freshImporter()
|
|
importCalls = {}
|
|
love.filesystem.write("imports/saves/red/again.sav", "SAMEBYTES")
|
|
importBehavior["imports/saves/red/again.sav"] = { ok = true, id = "slot-1" }
|
|
ri:rescanSavesAction("red")
|
|
eq(#importCalls, 1, "first import of again.sav")
|
|
-- Put the same bytes back under a new name (player re-copied / renamed)
|
|
love.filesystem.write("imports/saves/red/again-copy.sav", "SAMEBYTES")
|
|
importBehavior["imports/saves/red/again-copy.sav"] = { ok = true, id = "slot-clone" }
|
|
importCalls = {}
|
|
ri:rescanSavesAction("red")
|
|
eq(#importCalls, 0, "harden: same content hash is not imported again")
|
|
check(ri.saveNotice.red and ri.saveNotice.red.ok,
|
|
"harden: already-imported skip sets ok notice")
|
|
check(ri.saveNotice.red.text:find("Already imported", 1, true)
|
|
or ri.saveNotice.red.text:find("skipped", 1, true),
|
|
"harden: notice explains skip")
|
|
check(love.filesystem.getInfo("imports/saves/red/again-copy.sav") == nil,
|
|
"harden: leftover duplicate .sav is retired without importing")
|
|
|
|
-- NXSAV-04 / RES-05: failure → clear notice; .sav retained as-is
|
|
ri = freshImporter()
|
|
importCalls = {}
|
|
removed = {}
|
|
love.filesystem.write("imports/saves/red/bad.sav", "BADSAV")
|
|
importBehavior["imports/saves/red/bad.sav"] = { ok = false, err = "save file must be 32768 bytes" }
|
|
ri:rescanSavesAction("red")
|
|
eq(#importCalls, 1, "failure path still attempts importToSlot")
|
|
check(ri.saveNotice.red and not ri.saveNotice.red.ok, "failure sets clear error notice")
|
|
check(ri.saveNotice.red.text:find("32768", 1, true),
|
|
"failure notice includes import error")
|
|
check(not removed["imports/saves/red/bad.sav"], "RES-05: failure does not remove inbox .sav")
|
|
check(love.filesystem.read("imports/saves/red/bad.sav") == "BADSAV",
|
|
"RES-05: failure leaves .sav in inbox")
|
|
|
|
-- Mixed valid/invalid: attempt each; bad retained, good retired
|
|
ri = freshImporter()
|
|
importCalls = {}
|
|
removed = {}
|
|
love.filesystem.write("imports/saves/red/a-bad.sav", "BAD")
|
|
love.filesystem.write("imports/saves/red/b-good.sav", "GOOD")
|
|
importBehavior["imports/saves/red/a-bad.sav"] = { ok = false, err = "bad checksum" }
|
|
importBehavior["imports/saves/red/b-good.sav"] = { ok = true, id = "slot-b" }
|
|
ri:rescanSavesAction("red")
|
|
eq(#importCalls, 2, "mixed inbox attempts each .sav")
|
|
check(love.filesystem.read("imports/saves/red/a-bad.sav") == "BAD",
|
|
"mixed: bad .sav retained")
|
|
check(love.filesystem.getInfo("imports/saves/red/b-good.sav") == nil,
|
|
"mixed: good .sav retired")
|
|
check(love.filesystem.read("imports/saves/red/b-good.sav.imported") == "GOOD",
|
|
"mixed: good bytes kept as .imported")
|
|
check(ri.saveNotice.red and ri.saveNotice.red.ok, "mixed keeps overall success when one imports")
|
|
check(ri.saveNotice.red.text:find("failed", 1, true),
|
|
"mixed success notice still surfaces sibling failure")
|
|
check(ri.saveNotice.red.text:find("bad checksum", 1, true),
|
|
"mixed success notice includes the failure reason")
|
|
|
|
-- Multi-success notice names count + active slot (not only last ok line)
|
|
ri = freshImporter()
|
|
importCalls = {}
|
|
love.filesystem.write("imports/saves/red/one.sav", "ONE")
|
|
love.filesystem.write("imports/saves/red/two.sav", "TWO")
|
|
importBehavior["imports/saves/red/one.sav"] = { ok = true, id = "slot-one" }
|
|
importBehavior["imports/saves/red/two.sav"] = { ok = true, id = "slot-two" }
|
|
ri:rescanSavesAction("red")
|
|
eq(#importCalls, 2, "multi-success imports each distinct .sav")
|
|
check(ri.saveNotice.red.text:find("Imported 2 saves", 1, true),
|
|
"multi-success notice reports count")
|
|
check(ri.saveNotice.red.text:find("Active:", 1, true),
|
|
"multi-success notice reports active slot")
|
|
eq(ri.activeSlot.red, "slot-two", "multi-success leaves last import active")
|
|
|
|
-- RES-03: Mac MTP AppleDouble (._*.sav) must not be import candidates
|
|
ri = freshImporter()
|
|
importCalls = {}
|
|
love.filesystem.write("imports/saves/red/._cart.sav", "APPL")
|
|
love.filesystem.write("imports/saves/red/cart.sav", "GOOD")
|
|
importBehavior["imports/saves/red/cart.sav"] = { ok = true, id = "slot-cart" }
|
|
ri:rescanSavesAction("red")
|
|
eq(#importCalls, 1, "RES-03: AppleDouble ._*.sav is skipped")
|
|
eq(importCalls[1].source, "imports/saves/red/cart.sav",
|
|
"only the real .sav is imported")
|
|
check(ri.saveNotice.red and ri.saveNotice.red.ok, "AppleDouble skip still shows import success")
|
|
check(not (ri.saveNotice.red.text or ""):find("failed", 1, true),
|
|
"RES-03: AppleDouble-only sibling does not invent a mixed failure line")
|
|
|
|
-- RES-06: NX chooseSaveImport must not call HostShell / chooseSav path
|
|
local hostShellCalls = 0
|
|
package.loaded["src.core.HostShell"] = {
|
|
run = function()
|
|
hostShellCalls = hostShellCalls + 1
|
|
error("HostShell must not run on NX chooseSaveImport")
|
|
end,
|
|
popen = function()
|
|
hostShellCalls = hostShellCalls + 1
|
|
error("HostShell.popen must not run on NX chooseSaveImport")
|
|
end,
|
|
available = function() return false end,
|
|
}
|
|
-- Re-require so chooseSav sees the stubbed HostShell if it were reached.
|
|
package.loaded["src.import.RomImporter"] = nil
|
|
RomImporter = require("src.import.RomImporter")
|
|
ri = freshImporter()
|
|
hostShellCalls = 0
|
|
ri:chooseSaveImport("red")
|
|
eq(hostShellCalls, 0, "RES-06: NX chooseSaveImport does not require HostShell")
|
|
|
|
-- NXSAV-05: chooseSaveImport on NX rescans inbox
|
|
ri = freshImporter()
|
|
importCalls = {}
|
|
hostShellCalls = 0
|
|
love.filesystem.write("imports/saves/red/from-choose.sav", "CHOOSE")
|
|
importBehavior["imports/saves/red/from-choose.sav"] = { ok = true, id = "slot-choose" }
|
|
ri:chooseSaveImport("red")
|
|
eq(hostShellCalls, 0, "NX chooseSaveImport does not use HostShell")
|
|
eq(#importCalls, 1, "NX chooseSaveImport rescans and imports inbox .sav")
|
|
eq(importCalls[1].source, "imports/saves/red/from-choose.sav",
|
|
"NX chooseSaveImport imports from imports/saves/red/")
|
|
check(ri.saveNotice.red and ri.saveNotice.red.ok, "NX chooseSaveImport success notice")
|
|
|
|
-- Empty chooseSaveImport still sets notice (RES-04 via Import save button)
|
|
ri = freshImporter()
|
|
importCalls = {}
|
|
ri:chooseSaveImport("red")
|
|
eq(#importCalls, 0, "empty NX chooseSaveImport does not import")
|
|
check(ri.saveNotice.red ~= nil and ri.saveNotice.red.text:find("imports/saves/red/", 1, true),
|
|
"empty NX chooseSaveImport sets MTP notice")
|
|
|
|
-- Edge: ROM not ready → refuse with existing notice (no silent no-op)
|
|
ri = freshImporter()
|
|
importCalls = {}
|
|
removed = {}
|
|
ri.ready.red = false
|
|
love.filesystem.write("imports/saves/red/need-rom.sav", "NEEDROM")
|
|
importBehavior["imports/saves/red/need-rom.sav"] = { ok = true, id = "should-not-import" }
|
|
ri:chooseSaveImport("red")
|
|
eq(#importCalls, 0, "ROM-not-ready: chooseSaveImport does not call importToSlot")
|
|
check(ri.saveNotice.red and not ri.saveNotice.red.ok,
|
|
"ROM-not-ready: chooseSaveImport sets error notice")
|
|
check(ri.saveNotice.red.text:find("Import the Pokemon Red ROM before importing a save", 1, true),
|
|
"ROM-not-ready: notice tells player to import ROM first")
|
|
check(not removed["imports/saves/red/need-rom.sav"],
|
|
"ROM-not-ready: retains inbox .sav")
|
|
check(love.filesystem.read("imports/saves/red/need-rom.sav") == "NEEDROM",
|
|
"ROM-not-ready: leaves .sav bytes in inbox")
|
|
|
|
ri = freshImporter()
|
|
importCalls = {}
|
|
ri.ready.red = false
|
|
love.filesystem.write("imports/saves/red/need-rom2.sav", "NEEDROM2")
|
|
importBehavior["imports/saves/red/need-rom2.sav"] = { ok = true, id = "should-not" }
|
|
ri:rescanSavesAction("red")
|
|
eq(#importCalls, 0, "ROM-not-ready: rescan does not call importToSlot")
|
|
check(ri.saveNotice.red and not ri.saveNotice.red.ok,
|
|
"ROM-not-ready: rescan sets error notice")
|
|
check(ri.saveNotice.red.text:find("Import the Pokemon Red ROM before importing a save", 1, true),
|
|
"ROM-not-ready: rescan surfaces ROM-first notice")
|
|
|
|
-- Edge: workState == "working" → Import/rescan no-op without clearing notice
|
|
ri = freshImporter()
|
|
importCalls = {}
|
|
ri.saveNotice.red = { ok = true, text = "PRESERVE_ME" }
|
|
ri.workState = "working"
|
|
love.filesystem.write("imports/saves/red/busy.sav", "BUSY")
|
|
importBehavior["imports/saves/red/busy.sav"] = { ok = true, id = "slot-busy" }
|
|
ri:chooseSaveImport("red")
|
|
eq(#importCalls, 0, "workState working: chooseSaveImport does not import")
|
|
eq(ri.saveNotice.red.text, "PRESERVE_ME",
|
|
"workState working: chooseSaveImport leaves saveNotice unchanged")
|
|
ri:rescanSavesAction("red")
|
|
eq(#importCalls, 0, "workState working: rescanSavesAction does not import")
|
|
eq(ri.saveNotice.red.text, "PRESERVE_ME",
|
|
"workState working: rescanSavesAction leaves saveNotice unchanged")
|
|
|
|
-- RES-11 / NXSAV-07: NX default SAVE FILES hint mentions per-game inbox
|
|
ri = freshImporter()
|
|
local defaultHint = ri:_savesDefaultHint("red")
|
|
check(defaultHint:find("imports/saves/red/", 1, true),
|
|
"RES-11: NX default hint mentions imports/saves/red/")
|
|
check(defaultHint:find("DBI MTP", 1, true),
|
|
"RES-11: NX default hint mentions DBI MTP")
|
|
check(not defaultHint:find("system file picker", 1, true),
|
|
"RES-11: NX default hint is not desktop picker wording")
|
|
|
|
-- Desktop keeps picker-oriented default hint (non-NX)
|
|
local desk = setmetatable({
|
|
isNX = false, android = false,
|
|
_savesDefaultHint = RomImporter._savesDefaultHint,
|
|
}, RomImporter)
|
|
check(desk:_savesDefaultHint():find("new slot", 1, true),
|
|
"desktop default save hint stays picker/drop-oriented")
|
|
|
|
-- RES-09 / NXSAV-08/09: NX exportSave success notice + no openURL / no dir
|
|
local exportCalls = {}
|
|
local openURLCalls = 0
|
|
love.system.openURL = function()
|
|
openURLCalls = openURLCalls + 1
|
|
error("openURL must not run on NX exportSave")
|
|
end
|
|
package.loaded["src.import.SaveFileIO"] = {
|
|
importToSlot = function(source, version)
|
|
importCalls[#importCalls + 1] = { source = source, version = version }
|
|
local b = importBehavior[source]
|
|
if not b then return false, "unexpected source: " .. tostring(source) end
|
|
if b.ok then return true, b.id or "slot-1" end
|
|
return false, b.err or "bad sav"
|
|
end,
|
|
exportActiveSlot = function(version)
|
|
exportCalls[#exportCalls + 1] = version
|
|
return true, "sdmc:/switch/gen1recomp/pokemon-love2d/exports/red/gen1recomp-red-slot-1.sav"
|
|
end,
|
|
}
|
|
ri = freshImporter()
|
|
exportCalls = {}
|
|
openURLCalls = 0
|
|
ri:exportSave("red")
|
|
eq(#exportCalls, 1, "NXSAV-08: exportSave calls exportActiveSlot")
|
|
eq(exportCalls[1], "red", "exportSave passes panel version")
|
|
check(ri.saveNotice.red and ri.saveNotice.red.ok, "NXSAV-09: export success sets ok notice")
|
|
check(ri.saveNotice.red.text:find("exports", 1, true),
|
|
"RES-09: export notice mentions exports path")
|
|
check(ri.saveNotice.red.text:find("DBI MTP", 1, true),
|
|
"RES-09: export notice mentions MTP hint")
|
|
check(ri.saveNotice.red.dir == nil,
|
|
"RES-09: NX export does not set open-folder dir")
|
|
eq(openURLCalls, 0, "RES-09: NX exportSave does not call openURL")
|
|
|
|
-- Export failure still sets notice (not silent)
|
|
package.loaded["src.import.SaveFileIO"] = {
|
|
importToSlot = function() return false, "unused" end,
|
|
exportActiveSlot = function() return false, "No save in the active slot." end,
|
|
}
|
|
ri = freshImporter()
|
|
ri:exportSave("red")
|
|
check(ri.saveNotice.red and not ri.saveNotice.red.ok,
|
|
"export failure sets clear error notice")
|
|
check(ri.saveNotice.red.text:find("No save", 1, true),
|
|
"export failure notice includes reason")
|
|
|
|
-- Edge: workState == "working" → exportSave no-op without clearing notice
|
|
package.loaded["src.import.SaveFileIO"] = {
|
|
importToSlot = function() return false, "unused" end,
|
|
exportActiveSlot = function(version)
|
|
exportCalls[#exportCalls + 1] = version
|
|
return true, "exports/should-not-export.sav"
|
|
end,
|
|
}
|
|
ri = freshImporter()
|
|
exportCalls = {}
|
|
ri.saveNotice.red = { ok = true, text = "PRESERVE_EXPORT" }
|
|
ri.workState = "working"
|
|
ri:exportSave("red")
|
|
eq(#exportCalls, 0, "workState working: exportSave does not call exportActiveSlot")
|
|
eq(ri.saveNotice.red.text, "PRESERVE_EXPORT",
|
|
"workState working: exportSave leaves saveNotice unchanged")
|
|
|
|
-- Cleanup + restore stubs
|
|
clearSavesInbox()
|
|
love.filesystem.remove("imports/other.sav")
|
|
package.loaded["src.import.SaveFileIO"] = nil
|
|
package.loaded["src.core.HostShell"] = nil
|
|
love.data.hash = saved.hash
|
|
love.data.encode = saved.encode
|
|
love.system.getOS = saved.getOS
|
|
love.system.openURL = nil
|
|
love.filesystem.getSaveDirectory = saved.getSaveDirectory
|
|
love.filesystem.createDirectory = saved.createDirectory
|
|
love.filesystem.remove = saved.remove
|
|
package.loaded["src.core.Platform"] = nil
|
|
package.loaded["src.import.RomImporter"] = nil
|
|
|
|
S.finish()
|