Files
gen1recomp/tests/engine/sync_engine_test.lua
T
2026-08-19 05:57:44 -04:00

460 lines
16 KiB
Lua

package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = love or require("tests.love_stub")
local Json = require("src.link.Json")
local SyncState = require("src.sync.SyncState")
local SyncEngine = require("src.sync.SyncEngine")
local function scripted(routes)
local t = { sent = {}, routes = routes, handles = {} }
function t:begin(req)
self.sent[#self.sent + 1] = req
local path = req.url:match("^[^?]*"):gsub("^http://sync%.test", "")
local route = self.routes[req.method .. " " .. path]
local reply
if type(route) == "function" then
reply = route(req, self)
else
reply = route
end
reply = reply or { code = 404, body = '{"error":"no route"}' }
self.handles[#self.sent] = {
status = "ok", code = reply.code or 200,
body = reply.body or Json.encode(reply.data or {}),
}
return #self.sent
end
function t:poll(handle) return self.handles[handle] end
function t:release() end
return t
end
local function pump(eng, times)
for _ = 1, (times or 24) do eng:update(0.05) end
end
local function linkedState()
local state = SyncState.defaults()
state.account = "aa11bb22cc33dd44"
state.deviceToken = "tok"
state.enabled = true
return state
end
local function saveEntry(version, id, savedAt, sessionStart, slot)
return {
version = version, slot = slot or "slot1", playthroughId = id,
blob = "return { player = { name = 'ASH' } }",
meta = { savedAt = savedAt, sessionStart = sessionStart,
playthroughId = id, summary = { name = "ASH", badges = 2 } },
}
end
local function fakeSaves(entries)
local writes = {}
return {
writes = writes,
list = function() return entries end,
write = function(version, id, blob, mode)
writes[#writes + 1] = { version = version, playthroughId = id,
blob = blob, mode = mode }
return mode == "new" and "slot9" or "slot1"
end,
}
end
local function engine(routes, entries, state)
local saves = fakeSaves(entries or {})
local transport = scripted(routes)
local eng = SyncEngine.new({
baseUrl = "http://sync.test",
transport = transport,
state = state or linkedState(),
saves = saves,
persist = false,
now = function() return 1700001000 end,
})
return eng, transport, saves
end
do
T.eq(SyncEngine.overlaps({ sessionStart = 10, savedAt = 20 },
{ sessionStart = 15, savedAt = 30 }), true,
"two sessions that ran over the same minutes overlap")
T.eq(SyncEngine.overlaps({ sessionStart = 10, savedAt = 20 },
{ sessionStart = 21, savedAt = 30 }), false,
"a session that started after the other ended does not")
T.eq(SyncEngine.overlaps({ savedAt = 20 }, { sessionStart = 1, savedAt = 30 }),
false, "a save with no session start cannot claim an overlap")
end
do
local eng, transport = engine({
["POST /sync/create"] = { code = 200, body =
'{"account":"aa11","code1":"11112222","code2":"33334444","deviceToken":"tok"}' },
}, {}, SyncState.defaults())
T.eq(eng:linked(), false, "a fresh engine is not linked")
T.eq(eng.status, "Not set up", "and says so")
eng:createAccount("laptop")
pump(eng, 3)
T.eq(eng:linked(), true, "creating an account links this device")
T.eq(eng.state.account, "aa11", "and stores the account id")
T.eq(eng.codes.code1, "1111-2222", "the first code is shown grouped")
T.eq(eng.codes.code2, "3333-4444", "and so is the second")
T.eq(eng.state.code1, nil, "codes never enter the persisted state")
T.eq(eng.phase, "idle", "and the engine settles")
T.eq(#transport.sent, 1, "one request was made")
end
do
local eng, transport = engine({
["POST /sync/link"] = { code = 200,
body = '{"account":"aa11","deviceToken":"tok"}' },
["GET /sync/state"] = { code = 200, body = '{"saves":{}}' },
["PUT /sync/save"] = { code = 200, body = '{"ok":true,"rev":1}' },
}, { saveEntry("red", "abc", 500, 400) }, SyncState.defaults())
eng:linkDevice("1111-2222", "3333 4444", "phone")
pump(eng)
T.eq(eng:linked(), true, "linking with both codes links the device")
T.eq(transport.sent[2].url, "http://sync.test/sync/state",
"and a sync starts immediately")
T.eq(transport.sent[3].method, "PUT",
"the local save the server has never seen is uploaded")
T.eq(SyncState.rev(eng.state, "red/abc"), 1, "the served rev is remembered")
T.eq(SyncState.stamp(eng.state, "red/abc"), 500,
"along with the savedAt that was uploaded")
T.eq(eng.phase, "idle", "and the engine settles")
T.eq(eng.state.lastSyncAt, 1700001000, "the sync time is stamped")
end
do
local eng, transport = engine({}, {}, SyncState.defaults())
eng:linkDevice("12", "34", "phone")
T.eq(#transport.sent, 0, "a malformed code pair is refused locally")
T.eq(eng.phase, "error", "and the engine reports the problem")
T.check(eng.status:find("8 digits", 1, true) ~= nil,
"with copy that says what a code is")
end
do
local eng, transport, saves = engine({
["GET /sync/state"] = { code = 200,
body = '{"saves":{"gold/xyz":{"rev":4,"meta":{"savedAt":900}}}}' },
["GET /sync/save"] = { code = 200,
body = '{"rev":4,"meta":{"savedAt":900},"blob":"return { player = {} }"}' },
}, {})
eng:syncNow()
pump(eng)
T.eq(#saves.writes, 1, "the remote-only save is written locally")
T.eq(saves.writes[1].version, "gold", "into the right game")
T.eq(saves.writes[1].mode, "replace", "as that playthrough's slot")
T.eq(SyncState.rev(eng.state, "gold/xyz"), 4, "and its rev is remembered")
T.eq(eng.phase, "idle", "the engine settles")
T.eq(transport.sent[2].url, "http://sync.test/sync/save?id=xyz&version=gold",
"the download names the playthrough, not the slot")
end
do
local state = linkedState()
SyncState.setRev(state, "red/abc", 7, 500)
local eng, transport = engine({
["GET /sync/state"] = { code = 200,
body = '{"saves":{"red/abc":{"rev":7,"meta":{"savedAt":500}}}}' },
}, { saveEntry("red", "abc", 500, 400) }, state)
eng:syncNow()
pump(eng)
T.eq(#transport.sent, 1, "an unchanged save is neither uploaded nor downloaded")
T.eq(eng.phase, "idle", "and the sync ends idle")
end
local function conflictEngine()
local state = linkedState()
SyncState.setRev(state, "red/abc", 7, 500)
return engine({
["GET /sync/state"] = { code = 200,
body = '{"saves":{"red/abc":{"rev":9,"meta":{"savedAt":760,' ..
'"sessionStart":600,"summary":{"name":"BLUE","badges":4}}}}}' },
["PUT /sync/save"] = { code = 200, body = '{"ok":true,"rev":10}' },
["GET /sync/save"] = { code = 200,
body = '{"rev":9,"meta":{"savedAt":760},"blob":"return { player = {} }"}' },
}, { saveEntry("red", "abc", 700, 650) }, state)
end
do
local eng, transport = conflictEngine()
eng:syncNow()
pump(eng)
T.eq(eng.phase, "conflict", "both sides changing is a conflict")
T.eq(#eng.conflicts, 1, "one conflict is raised")
T.eq(eng.conflicts[1].overlap, true,
"the two sessions ran over the same minutes")
T.eq(eng.status, "These saves were played at the same time.",
"and the status is the wording the player was promised")
T.eq(eng.conflicts[1].remoteMeta.summary.name, "BLUE",
"the other device's save is summarized for the prompt")
T.eq(#transport.sent, 1, "nothing is uploaded while the player decides")
T.eq(#eng.state.pendingConflicts, 1, "the conflict survives in the state")
end
do
local eng, transport = conflictEngine()
eng:syncNow()
pump(eng)
eng:resolveConflict("red/abc", "local")
pump(eng)
local put = transport.sent[2]
T.eq(put.method, "PUT", "keep this device uploads")
T.eq(Json.decode(put.body).force, true, "with the force flag")
T.eq(SyncState.rev(eng.state, "red/abc"), 10, "and adopts the new rev")
T.eq(eng.phase, "idle", "the conflict is cleared")
T.eq(#eng.state.pendingConflicts, 0, "and dropped from the state")
end
do
local eng, transport, saves = conflictEngine()
eng:syncNow()
pump(eng)
eng:resolveConflict("red/abc", "remote")
pump(eng)
T.eq(transport.sent[2].method, "GET", "keep the other device downloads")
T.eq(#saves.writes, 1, "and writes it locally")
T.eq(saves.writes[1].mode, "replace", "over this playthrough's slot")
T.eq(SyncState.rev(eng.state, "red/abc"), 9, "adopting the remote rev")
T.eq(eng.phase, "idle", "the conflict is cleared")
end
do
local eng, transport, saves = conflictEngine()
eng:syncNow()
pump(eng)
eng:resolveConflict("red/abc", "both")
pump(eng)
T.eq(#saves.writes, 1, "keep both imports the other save")
T.eq(saves.writes[1].mode, "new", "into a new slot")
local put = transport.sent[3]
T.eq(put.method, "PUT", "and still uploads this device's save")
T.eq(Json.decode(put.body).force, true, "forcing past the stale rev")
T.eq(eng.phase, "idle", "the conflict is cleared")
end
do
local eng = engine({
["GET /sync/state"] = { code = 200, body = '{"saves":{}}' },
["PUT /sync/save"] = { code = 409, body =
'{"conflict":true,"rev":3,"remoteMeta":{"savedAt":710,"sessionStart":600}}' },
}, { saveEntry("red", "abc", 700, 650) })
eng:syncNow()
pump(eng)
T.eq(eng.phase, "conflict", "a 409 on upload becomes a conflict, not an error")
T.eq(eng.conflicts[1].overlap, true, "with the overlap worked out")
end
do
local eng = engine({
["GET /sync/state"] = function()
return { code = 500, body = '{"error":"server on fire"}' }
end,
}, {})
eng:syncNow()
pump(eng, 3)
T.eq(eng.phase, "error", "a server error stops the sync")
T.check(eng.status:find("server on fire", 1, true) ~= nil,
"and shows what the server said")
end
do
local eng, transport = engine({
["GET /sync/state"] = { code = 200, body = '{"saves":{}}' },
["PUT /sync/save"] = { code = 200, body = '{"ok":true,"rev":1}' },
}, { saveEntry("red", "abc", 500, 400) })
eng:noteSaveWritten()
eng:update(1)
T.eq(#transport.sent, 0, "an in-game save does not sync straight away")
eng:update(SyncEngine.UPLOAD_DEBOUNCE)
T.eq(#transport.sent, 1, "it syncs once the debounce has passed")
pump(eng)
T.eq(transport.sent[2].method, "PUT", "and the save goes up")
end
do
local eng, transport = engine({}, { saveEntry("red", "abc", 500, 400) })
eng:setEnabled(false)
eng:noteSaveWritten()
eng:update(60)
T.eq(#transport.sent, 0, "with sync off an in-game save uploads nothing")
end
do
local eng = engine({
["POST /sync/create"] = { code = 200, body =
'{"account":"aa11","code1":"11112222","code2":"33334444",' ..
'"deviceToken":"tok","device":"0a1b2c3d"}' },
}, {}, SyncState.defaults())
eng:createAccount("laptop")
pump(eng, 3)
T.eq(eng.state.deviceId, "0a1b2c3d",
"creating an account records the id the server gave this device")
T.eq(SyncState.sanitize(eng.state).deviceId, "0a1b2c3d",
"and it survives being persisted")
end
do
local eng = engine({
["POST /sync/link"] = { code = 200,
body = '{"account":"aa11","deviceToken":"tok","device":"beefcafe"}' },
["GET /sync/state"] = { code = 200, body = '{"saves":{}}' },
}, {}, SyncState.defaults())
eng:linkDevice("11112222", "33334444", "phone")
pump(eng)
T.eq(eng.state.deviceId, "beefcafe", "so does linking a second device")
end
do
local state = linkedState()
state.deviceId = "0a1b2c3d"
local eng, transport = engine({
["POST /sync/unlink"] = { code = 200, body = '{"ok":true,"devices":1}' },
}, {}, state)
eng:unlink()
T.eq(eng:linked(), true, "unlink waits for the server before forgetting")
local sent = transport.sent[1]
T.eq(sent.url, "http://sync.test/sync/unlink", "it asks the server first")
T.eq(Json.decode(sent.body).device, "0a1b2c3d",
"naming the device id the server knows, not the platform label")
pump(eng, 3)
T.eq(eng:linked(), false, "and only then drops the credentials")
T.eq(eng.status, "Not set up", "reporting the device as unlinked")
end
do
local state = linkedState()
state.deviceId = "0a1b2c3d"
local eng = engine({
["POST /sync/unlink"] = { code = 500, body = '{"error":"nope"}' },
}, {}, state)
eng:unlink()
pump(eng, 3)
T.eq(eng.phase, "error", "a failed revocation is surfaced")
T.eq(eng:linked(), true,
"and the device stays linked rather than lying about it")
end
do
local state = linkedState()
state.deviceId = "0a1b2c3d"
local eng = engine({
["POST /sync/unlink"] = { code = 401, body = '{"error":"unauthorized"}' },
}, {}, state)
eng:unlink()
pump(eng, 3)
T.eq(eng:linked(), false,
"a token the server already revoked is dropped rather than stuck forever")
end
do
local state = linkedState()
state.deviceId = "0a1b2c3d"
local eng, transport = engine({
["POST /sync/unlink"] = { code = 200, body = '{"ok":true,"devices":1}' },
["GET /sync/state"] = { code = 200, body = '{"saves":{}}' },
}, {}, state)
eng:unlinkDevice("99998888")
T.eq(Json.decode(transport.sent[1].body).device, "99998888",
"another device is revoked by its id")
pump(eng, 3)
T.eq(eng:linked(), true, "without logging this device out")
end
do
local state = linkedState()
state.deviceId = "0a1b2c3d"
local eng = engine({
["GET /sync/state"] = { code = 200, body =
'{"saves":{},"devices":[{"id":"0a1b2c3d","label":"OS X","current":true},' ..
'{"id":"99998888","label":"Android"}]}' },
}, {}, state)
eng:syncNow()
pump(eng)
T.eq(#eng.devices, 2, "the linked devices are kept for the modal to show")
T.eq(eng.devices[1].current, true, "this device is marked")
T.eq(eng.devices[2].label, "Android", "and the others are named")
end
do
local eng = conflictEngine()
eng:syncNow()
pump(eng)
eng:syncNow()
pump(eng)
eng:syncNow()
pump(eng)
T.eq(#eng.state.pendingConflicts, 1,
"syncing again over the same conflict does not stack up rows")
T.eq(#eng.conflicts, 1, "and the prompt still has exactly one to answer")
end
do
local eng = engine({}, {})
local order, seen = {}, {}
eng.modDeps = {
installed = function() return {} end,
indexes = function() return {} end,
addIndex = function(url) order[#order + 1] = "index" return { feed = url } end,
findEntry = function() return nil end,
install = function(entry) order[#order + 1] = "install:" .. entry.id return true end,
setEnabled = function(id) order[#order + 1] = "enable:" .. id return true end,
}
eng.modPlan = {
indexes = { "https://mods.example/i.json" },
toInstall = { { id = "beta", entry = { id = "beta" } } },
toEnable = { { id = "beta", version = "red" } },
missing = {},
}
eng:applyModPlan(function(done, total, label, finished)
seen[#seen + 1] = ("%d/%d %s"):format(done, total, tostring(finished))
end)
T.eq(#order, 0, "starting an apply installs nothing on the spot")
T.eq(eng:busy(), true, "the launcher can see it is working")
eng:update(0.016)
T.eq(#order, 1, "one step runs per frame, so the progress line can draw")
eng:update(0.016)
eng:update(0.016)
T.eq(#order, 3, "until the whole plan has run")
T.eq(order[3], "enable:beta", "in plan order")
T.eq(eng.modApply, nil, "the job is done")
T.eq(eng.modPlan, nil, "and the plan is spent")
T.eq(eng.status, "Mods applied", "the status says so")
T.eq(seen[#seen], "3/3 true", "and the last progress call reports the end")
end
do
local eng = engine({}, {})
eng.modDeps = {
installed = function() return {} end,
indexes = function() return {} end,
addIndex = function() return true end,
findEntry = function() return nil end,
install = function() return nil, "download failed" end,
setEnabled = function() return true end,
}
eng.modPlan = { indexes = {}, toInstall = { { id = "beta", entry = { id = "beta" } } },
toEnable = {}, missing = {} }
eng:applyModPlan()
eng:update(0.016)
T.eq(eng.modApply, nil, "a failing step still ends the job")
T.check(eng.status:find("download failed", 1, true) ~= nil,
"and the failure reaches the status line")
end
T.finish("sync_engine")