mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-19 12:15:31 +02:00
460 lines
16 KiB
Lua
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")
|