Files
2026-11-03 12:41:09 -05:00

165 lines
8.0 KiB
Lua
Executable File

local S = dofile(assert(arg[1], "schema.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 reject(kind, value)
local ok, err = pcall(S.decode, kind, value)
assert(not ok and err == "Last Hearth: invalid save schema or data", "Expected sanitized rejection")
end
local function v1(kind, data)
return {schema = 1, kind = kind, data = data}
end
local samples = {
needs = {food = 85, water = 85, warmth = 80, wet = 0, stamina = 100},
claims = {fixture_player = {x = -12, y = 4, z = 8}},
homes = {fixture_player = {x = -12, y = 5, z = 8}},
access = {fixture_player = {housemate = "resident", keeper = "storekeeper"}},
location = {latitude = 45.25, longitude = -73.5},
spawn = {x = 0.5, y = 5.25, z = -0.125},
}
test("defaults are exact independent needs copies", function()
local a, b = S.defaults(), S.defaults()
for key, value in pairs(samples.needs) do assert(a[key] == value and b[key] == value) end
a.food = 0; assert(b.food == 85 and S.defaults().food == 85)
end)
for _, kind in ipairs({"needs", "claims", "homes", "access", "location", "spawn"}) do
test(kind .. " migrates without mutating or aliasing input", function()
local source = samples[kind]
local envelope, migrated = S.decode(kind, source)
assert(migrated == true and envelope.schema == 1 and envelope.kind == kind)
assert(envelope.data ~= source and source.schema == nil)
if kind == "claims" or kind == "homes" or kind == "access" then assert(envelope.data.fixture_player ~= source.fixture_player) end
local current, again = S.decode(kind, envelope)
assert(again == false and current ~= envelope and current.data ~= envelope.data)
end)
test(kind .. " rejects future versions, wrong kinds and extra envelope fields", function()
for _, version in ipairs({0, -1, 2, 1.5, "1", false, math.huge, 0 / 0}) do
reject(kind, {schema = version, kind = kind, data = samples[kind]})
end
reject(kind, {schema = 1, kind = "unknown", data = samples[kind]})
reject(kind, {schema = 1, kind = kind})
reject(kind, {schema = 1, data = samples[kind]})
reject(kind, {kind = kind, data = samples[kind]})
reject(kind, {schema = 1, kind = kind, data = samples[kind], private = "PRIVATE"})
end)
test(kind .. " rejects scalar values and missing data", function()
reject(kind, nil)
for _, value in ipairs({false, 0, "PRIVATE", function() end}) do
reject(kind, value); reject(kind, v1(kind, value))
end
end)
end
test("unknown kinds and metatables fail without executing input", function()
reject("PRIVATE", {})
reject({}, {})
local hostile = setmetatable({}, {__index = function() error("PRIVATE") end})
reject("needs", hostile); reject("needs", v1("needs", hostile))
reject("claims", {owner = hostile})
end)
test("needs require all five fields and reject unknown fields", function()
for key in pairs(samples.needs) do
local data = S.defaults(); data[key] = nil; reject("needs", data)
end
local data = S.defaults(); data.private = "PRIVATE"; reject("needs", v1("needs", data))
reject("needs", {}); reject("needs", {85, 85, 80, 0, 100})
end)
test("needs boundaries and fractional finite numbers are valid", function()
local data = {food = 0, water = 100, warmth = 0.125, wet = 99.5, stamina = 50}
assert(S.decode("needs", v1("needs", data)).data.warmth == 0.125)
end)
test("every needs field rejects nonnumeric nonfinite or out of range values", function()
for key in pairs(samples.needs) do
for _, value in ipairs({"85", true, {}, -0.001, 100.001, math.huge, -math.huge, 0 / 0}) do
local data = S.defaults(); data[key] = value
reject("needs", v1("needs", data)); reject("needs", data)
end
end
end)
for _, kind in ipairs({"claims", "homes", "access"}) do
test("empty " .. kind .. " and envelope-field player names remain valid", function()
assert(next(S.decode(kind, {}).data) == nil)
assert(next(S.decode(kind, v1(kind, {})).data) == nil)
local claims
if kind == "access" then claims = {schema = {}, kind = {}, data = {}}
else claims = {schema = {x = 0, y = 0, z = 0}, kind = {x = 1, y = 2, z = 3},
data = {x = -1, y = -2, z = -3}} end
local decoded, migrated = S.decode(kind, claims)
assert(migrated)
if kind == "access" then assert(decoded.data.schema and decoded.data.kind and decoded.data.data)
else assert(decoded.data.schema.x == 0 and decoded.data.data.z == -3) end
end)
if kind ~= "access" then test(kind .. " require nonempty string owners and integer complete positions", function()
reject(kind, {{x = 0, y = 0, z = 0}})
reject(kind, {[""] = {x = 0, y = 0, z = 0}})
reject(kind, {owner = {x = 0.5, y = 0, z = 0}})
reject(kind, {owner = {x = 0, y = 0}})
reject(kind, {owner = {x = 0, y = 0, z = 0, private = true}})
reject(kind, {owner = "PRIVATE"})
end) end
end
test("access map accepts only bounded usernames and explicit household roles", function()
local access = {owner = {resident = "resident", keeper = "storekeeper"}}
local decoded = S.decode("access", access)
assert(decoded.data.owner.resident == "resident" and decoded.data.owner.keeper == "storekeeper")
for _, value in ipairs({
{[""] = {}}, {owner = {bad = "admin"}}, {owner = {"resident"}},
{owner = {owner = "resident"}}, {owner = {['bad/name'] = "resident"}},
{owner = "resident"}, {owner = setmetatable({}, {})},
}) do reject("access", value); reject("access", v1("access", value)) end
local long_name = string.rep("a", 33)
reject("access", {[long_name] = {member = "resident"}})
local too_many = {owner = {}}
for i = 1, 17 do too_many.owner["member" .. i] = "resident" end
reject("access", too_many)
end)
for _, kind in ipairs({"spawn", "claims", "homes"}) do
test(kind .. " enforces all coordinate bounds and numeric types", function()
local function wrap(pos) return (kind == "claims" or kind == "homes") and {owner = pos} or pos end
assert(S.decode(kind, wrap({x = -31000, y = 31000, z = 0})))
for _, axis in ipairs({"x", "y", "z"}) do
for _, value in ipairs({"0", true, {}, -31000.001, 31000.001, math.huge, -math.huge, 0 / 0}) do
local pos = {x = 0, y = 0, z = 0}; pos[axis] = value
reject(kind, wrap(pos)); reject(kind, v1(kind, wrap(pos)))
end
local pos = {x = 0, y = 0, z = 0}; pos[axis] = nil; reject(kind, wrap(pos))
end
end)
end
test("spawn supports floats but rejects unknown fields", function()
assert(S.decode("spawn", samples.spawn).data.y == 5.25)
reject("spawn", {x = 0, y = 0, z = 0, private = true})
end)
test("location accepts boundaries and rejects unknown or missing fields", function()
assert(S.decode("location", {latitude = -90, longitude = 180}))
assert(S.decode("location", {latitude = 90, longitude = -180}))
reject("location", {latitude = 0}); reject("location", {longitude = 0})
reject("location", {latitude = 0, longitude = 0, private = "PRIVATE"})
end)
test("every location coordinate rejects strings nonfinite values and overflow", function()
for _, key in ipairs({"latitude", "longitude"}) do
local limit = key == "latitude" and 90 or 180
for _, value in ipairs({"0", false, {}, limit + 0.001, -limit - 0.001, math.huge, -math.huge, 0 / 0}) do
local data = {latitude = 0, longitude = 0}; data[key] = value
reject("location", data); reject("location", v1("location", data))
end
end
end)
-- Optional explicit fixture path for standalone/integration runners. The engine's
-- restricted one-module smoke harness exercises the same values above without
-- needing secondary file access or changing live globals.
if arg[2] then
local fixtures = dofile(arg[2])
test("historic fixture records migrate to schema1", function()
for kind in pairs(fixtures) do
if samples[kind] then
local decoded, migrated = S.decode(kind, fixtures[kind].data)
assert(migrated and decoded.kind == kind and type(fixtures[kind].raw) == "string")
end
end
assert(next(S.decode("claims", fixtures.empty_claims.data).data) == nil)
end)
end
print("PASS " .. total .. " schema tests")