321 lines
15 KiB
Lua
Executable File
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")
|