Files
last-hearth/tests/persistence_spec.lua
T
2026-11-03 12:41:09 -05:00

321 lines
15 KiB
Lua
Executable File

local factory = dofile(assert(arg[1], "persistence.lua path required"))
local total = 0
local function test(name, fn)
local ok, err = pcall(fn)
assert(ok, name .. ": " .. tostring(err)); total = total + 1
end
local function clone(value)
if type(value) ~= "table" then return value end
local result = {}; for k, v in pairs(value) do result[k] = clone(v) end; return result
end
local defaults = {food = 85, water = 85, warmth = 80, wet = 0, stamina = 100}
-- Adapter unit tests inject this contract double, not a second schema
-- implementation. schema_spec owns numeric/field validation. An explicit second
-- argument (or a real hearth.schema in integration smoke) uses the real module.
local schema = {
defaults = function() return clone(defaults) end,
decode = function(kind, value)
assert(type(value) == "table", "PRIVATE schema error")
local migrated = value.schema == nil
local data = value
if not migrated then
assert(value.schema == 1 and value.kind == kind, "PRIVATE schema error")
data = value.data
end
assert(type(data) == "table" and not data.invalid, "PRIVATE schema error")
return {schema = 1, kind = kind, data = clone(data)}, migrated
end,
}
if arg[2] then schema = dofile(arg[2])
elseif hearth and hearth.schema then schema = hearth.schema end
local fixtures = {
needs = {raw = ' { "food":85, "water":85, "warmth":80, "wet":0, "stamina":100 }\n', data = defaults},
claims = {raw = '{ "fixture_player" : {"x":-12,"y":4,"z":8} }',
data = {fixture_player = {x = -12, y = 4, z = 8}}},
homes = {raw = '{ "fixture_player" : {"x":-12,"y":5,"z":8} }',
data = {fixture_player = {x = -12, y = 5, z = 8}}},
access = {raw = '{ "fixture_player" : {"housemate":"resident"} }',
data = {fixture_player = {housemate = "resident"}}},
location = {raw = ' {"latitude":45.25, "longitude":-73.5} ', data = {latitude = 45.25, longitude = -73.5}},
spawn = {raw = '{"x":0.5,"y":5.25,"z":-0.125}\n', data = {x = 0.5, y = 5.25, z = -0.125}},
}
if arg[3] then
local historic = dofile(arg[3])
for kind, fixture in pairs(historic) do fixtures[kind] = fixture end
end
local function fingerprint(value)
if type(value) ~= "table" then return type(value) .. ":" .. tostring(value) end
local keys, parts = {}, {}
for key in pairs(value) do keys[#keys + 1] = key end
table.sort(keys)
for _, key in ipairs(keys) do parts[#parts + 1] = key .. "=" .. fingerprint(value[key]) end
return "{" .. table.concat(parts, ";") .. "}"
end
local function harness()
local json = {}
for _, fixture in pairs(fixtures) do json[fixture.raw] = clone(fixture.data) end
json.future = {schema = 2, kind = "claims", data = {}}
json.invalid = {schema = 1, kind = "needs", data = {invalid = true}}
json.scalar = false
local C = {}
function C.parse_json(raw)
if raw == "throw" then error("PRIVATE parser input") end
return clone(json[raw])
end
function C.write_json(value)
local raw = "encoded:" .. fingerprint(value)
json[raw] = clone(value); return raw
end
local function store()
local s = {values = {}, writes = {}}
function s:get_string(key)
if self.read_hook then self.read_hook(self, key) end
return self.values[key] or ""
end
function s:set_string(key, value)
self.writes[#self.writes + 1] = {key = key, value = value}
if self.hook and self.hook(self, key, value) then return end
self.values[key] = value
end
return s
end
local world, meta = store(), store()
local player = {get_meta = function() return meta end}
return factory(schema, C, world), world, meta, player, C, json
end
local function rejects(fn)
local ok, err = pcall(fn)
assert(not ok and err == "Last Hearth: persistence failed; save retained for recovery",
"Expected sanitized fail-closed persistence error")
end
test("missing records return independent defaults without writes", function()
local p, w, m, player = harness()
assert(next(p.load_world("claims")) == nil)
assert(next(p.load_world("access")) == nil)
assert(p.load_world("location") == nil and p.load_world("spawn") == nil)
local a = p.load_player(player); assert(a.food == 85 and a.stamina == 100)
a.food = 0; assert(p.load_player(player).food == 85)
assert(#w.writes == 0 and #m.writes == 0)
end)
for _, kind in ipairs({"claims", "homes", "access", "location", "spawn", "needs"}) do
test(kind .. " backs up exact legacy bytes before migration and reloads read-only", function()
local p, w, m, player, C = harness()
local s = kind == "needs" and m or w
local key = kind == "needs" and "hearth:needs" or kind
local bk = kind == "needs" and "hearth:m0_backup:needs" or "m0_backup:" .. kind
s.values[key] = fixtures[kind].raw
local function load() return kind == "needs" and p.load_player(player) or p.load_world(kind) end
assert(fingerprint(load()) == fingerprint(fixtures[kind].data))
assert(#s.writes == 2 and s.writes[1].key == bk and s.writes[2].key == key)
assert(s.values[bk] == fixtures[kind].raw)
local envelope = C.parse_json(s.values[key])
assert(envelope.schema == 1 and envelope.kind == kind)
assert(fingerprint(load()) == fingerprint(fixtures[kind].data) and #s.writes == 2)
-- A restored legacy primary must never replace the first migration backup.
s.values[key] = fixtures[kind].raw
load(); assert(#s.writes == 3 and s.values[bk] == fixtures[kind].raw)
end)
end
test("existing backup remains immutable even if it is not JSON", function()
local p, w = harness()
w.values.claims, w.values["m0_backup:claims"] = fixtures.claims.raw, "PRIVATE recovery bytes"
p.load_world("claims")
assert(#w.writes == 1 and w.values["m0_backup:claims"] == "PRIVATE recovery bytes")
end)
test("empty legacy claims migrate without being mistaken for absent storage", function()
local p, w, _, _, _, json = harness()
json["{}"] = {}; w.values.claims = "{}"
assert(next(p.load_world("claims")) == nil and #w.writes == 2)
assert(w.values["m0_backup:claims"] == "{}")
end)
test("malformed scalar and future world records fail load and save with zero writes", function()
for _, raw in ipairs({"malformed PRIVATE", "throw", "scalar", "future"}) do
local p, w = harness(); w.values.claims = raw
rejects(function() p.load_world("claims") end)
rejects(function() p.save_world("claims", {}) end)
assert(w.values.claims == raw and #w.writes == 0)
end
end)
test("invalid player saves remain byte exact on both load and save", function()
for _, raw in ipairs({"malformed PRIVATE", "throw", "scalar", "future", "invalid"}) do
local p, _, m, player = harness(); m.values["hearth:needs"] = raw
rejects(function() p.load_player(player) end)
rejects(function() p.save_player(player, clone(defaults)) end)
assert(m.values["hearth:needs"] == raw and #m.writes == 0)
end
end)
test("invalid new data and unknown world kinds never write", function()
local p, w, m, player = harness()
rejects(function() p.save_world("claims", {invalid = true}) end)
rejects(function() p.save_player(player, {invalid = true}) end)
for _, kind in ipairs({"needs", "PRIVATE", false, {}}) do
rejects(function() p.load_world(kind) end)
rejects(function() p.save_world(kind, {}) end)
end
assert(#w.writes == 0 and #m.writes == 0)
end)
test("all world kinds and player needs save current envelopes", function()
local p, w, m, player, C = harness()
for _, kind in ipairs({"claims", "homes", "access", "location", "spawn"}) do
p.save_world(kind, fixtures[kind].data)
local value = C.parse_json(w.values[kind])
assert(value.schema == 1 and value.kind == kind)
assert(w.values["m0_backup:" .. kind] == nil)
end
p.save_player(player, clone(defaults))
assert(C.parse_json(m.values["hearth:needs"]).kind == "needs" and #m.writes == 1)
p.save_player(player, clone(defaults)); assert(#m.writes == 2)
end)
test("direct save of legacy state still backs up the old bytes", function()
local p, w, m, player = harness()
w.values.claims = fixtures.claims.raw
p.save_world("claims", {})
assert(w.values["m0_backup:claims"] == fixtures.claims.raw and #w.writes == 2)
m.values["hearth:needs"] = fixtures.needs.raw
local updated = clone(defaults); updated.food = 25
p.save_player(player, updated)
assert(m.values["hearth:m0_backup:needs"] == fixtures.needs.raw)
assert(p.load_player(player).food == 25 and #m.writes == 2)
end)
test("writer failure nonstrings and invalid roundtrips never touch originals or backups", function()
for _, mode in ipairs({"throw", "nil", "number", "empty", "malformed", "legacy", "changed", "future"}) do
local p, w, _, _, C, json = harness(); w.values.claims = fixtures.claims.raw
C.write_json = function(value)
if mode == "throw" then error("PRIVATE writer error") end
if mode == "nil" then return nil end
if mode == "number" then return 42 end
if mode == "empty" then return "" end
if mode == "legacy" then return fixtures.claims.raw end
if mode == "future" then return "future" end
if mode == "changed" then
json.changed = {schema = 1, kind = "claims", data = {}}
return "changed"
end
return "malformed PRIVATE"
end
rejects(function() p.load_world("claims") end)
rejects(function() p.save_world("claims", fixtures.claims.data) end)
assert(w.values.claims == fixtures.claims.raw and #w.writes == 0)
end
end)
test("backup failure prevents primary writes", function()
for _, mode in ipairs({"throw", "ignore", "corrupt"}) do
local p, w = harness(); w.values.claims = fixtures.claims.raw
w.hook = function(s, key)
assert(key == "m0_backup:claims")
if mode == "throw" then error("PRIVATE storage error") end
if mode == "corrupt" then s.values[key] = "PRIVATE truncated" end
return true
end
rejects(function() p.load_world("claims") end)
assert(w.values.claims == fixtures.claims.raw and #w.writes == 1)
end
end)
test("primary corruption or throw-after-write restores original and retains backup", function()
for _, mode in ipairs({"corrupt", "throw", "ignore"}) do
local p, w = harness(); w.values.claims = fixtures.claims.raw
local once = true
w.hook = function(s, key, value)
if key == "claims" and once then
once = false
if mode == "throw" then s.values[key] = value; error("PRIVATE storage error") end
if mode == "corrupt" then s.values[key] = "PRIVATE truncated" end
return true
end
end
rejects(function() p.load_world("claims") end)
assert(w.values.claims == fixtures.claims.raw)
assert(w.values["m0_backup:claims"] == fixtures.claims.raw)
end
end)
test("failed rollback quarantines key even if damaged readback looks valid", function()
local p, w, _, _, C = harness(); w.values.claims = fixtures.claims.raw
local replacement = C.write_json({schema = 1, kind = "claims", data = {}})
w.hook = function(s, key)
if key == "claims" then s.values[key] = replacement; error("PRIVATE persistent failure") end
end
rejects(function() p.load_world("claims") end)
local count = #w.writes; w.hook = nil
rejects(function() p.load_world("claims") end)
rejects(function() p.save_world("claims", {}) end)
assert(#w.writes == count and w.values["m0_backup:claims"] == fixtures.claims.raw)
end)
test("player migration write failure rolls back only player metadata", function()
local p, w, m, player = harness(); m.values["hearth:needs"] = fixtures.needs.raw
local once = true
m.hook = function(s, key)
if key == "hearth:needs" and once then once = false; s.values[key] = "PRIVATE partial"; return true end
end
rejects(function() p.load_player(player) end)
assert(m.values["hearth:needs"] == fixtures.needs.raw)
assert(m.values["hearth:m0_backup:needs"] == fixtures.needs.raw and #w.writes == 0)
end)
test("failed first save restores missing storage and creates no migration backup", function()
local p, w = harness(); local once = true
w.hook = function(s, key)
if once then once = false; s.values[key] = "PRIVATE partial"; return true end
end
rejects(function() p.save_world("claims", {}) end)
assert(w.values.claims == "" and w.values["m0_backup:claims"] == nil)
end)
test("failed current-schema save restores exact prior bytes without a migration backup", function()
local p, w, _, _, C = harness()
local original = C.write_json({schema = 1, kind = "claims", data = fixtures.claims.data})
w.values.claims = original
local once = true
w.hook = function(s, key)
if once then once = false; s.values[key] = "PRIVATE truncated"; return true end
end
rejects(function() p.save_world("claims", {}) end)
assert(w.values.claims == original and w.values["m0_backup:claims"] == nil)
end)
test("explicit false setter result fails and rolls back", function()
local p, w = harness(); local setter = w.set_string; local once = true
w.set_string = function(self, key, value)
setter(self, key, value)
if once then once = false; return false end
end
rejects(function() p.save_world("claims", {}) end)
assert(w.values.claims == "" and #w.writes == 2)
end)
test("readback exception triggers verified rollback", function()
local p, w = harness(); local once = true
w.read_hook = function(s, key)
if key == "claims" and #s.writes == 1 and once then
once = false; error("PRIVATE readback error")
end
end
rejects(function() p.save_world("claims", {}) end)
assert(w.values.claims == "" and #w.writes == 2)
end)
test("player quarantine survives fresh metadata wrappers", function()
local p, _, m, _, C = harness()
m.values["hearth:needs"] = fixtures.needs.raw
local calls = 0
local player = {get_meta = function()
calls = calls + 1
return {get_string = function(_, key) return m:get_string(key) end,
set_string = function(_, key, value) return m:set_string(key, value) end}
end}
local changed = clone(defaults); changed.food = 25
local replacement = C.write_json({schema = 1, kind = "needs", data = changed})
m.hook = function(s, key)
if key == "hearth:needs" then s.values[key] = replacement; error("PRIVATE write error") end
end
rejects(function() p.load_player(player) end)
local count = #m.writes; m.hook = nil
rejects(function() p.load_player(player) end)
rejects(function() p.save_player(player, clone(defaults)) end)
assert(calls == 1 and #m.writes == count)
assert(m.values["hearth:m0_backup:needs"] == fixtures.needs.raw)
end)
test("read errors and metadata errors are sanitized without writes", function()
local p, w, m = harness()
w.read_hook = function() error("PRIVATE read error") end
rejects(function() p.load_world("claims") end)
rejects(function() p.save_world("claims", {}) end)
rejects(function() p.load_player({get_meta = function() error("PRIVATE player error") end}) end)
assert(#w.writes == 0 and #m.writes == 0)
end)
print("PASS " .. total .. " persistence tests")