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

217 lines
13 KiB
Lua
Executable File

-- Offline Lua 5.1+ factory tests. Engine JSON is represented by exact JSON
-- fixture strings, not executable deserialization or a fake browser service.
local factory = dofile(assert(arg[1], "character.lua path required"))
local total = 0
local function test(name, fn)
local ok, err = pcall(fn); assert(ok, name .. ": " .. tostring(err))
total = total + 1; print("PASS " .. name)
end
local function copy(t) local o = {}; for k,v in pairs(t) do o[k] = v end; return o end
local function harness()
local h = {sessions = {}, messages = {}, screens = {}, props = {}, closed = {}, commands = {}, connected = {}, json = {}, parses = 0}
local api = {}
function api.write_json(value)
if h.encode_fail then error("PRIVATE encoder detail") end
local fields = {}
for k,v in pairs(value) do fields[#fields+1] = '"' .. k .. '":' .. (type(v)=="number" and v or '"' .. tostring(v) .. '"') end
table.sort(fields)
local raw = "{" .. table.concat(fields,",") .. "}"
h.json[raw] = copy(value); return raw
end
function api.parse_json(raw, null, return_error)
h.parses = h.parses + 1; assert(null == nil and return_error == true)
if h.parse_throw then error("PRIVATE parse detail") end
return h.json[raw] and copy(h.json[raw]) or nil
end
function api.formspec_escape(s)
return (s:gsub("\\","\\\\"):gsub("%[","\\["):gsub("%]","\\]"):gsub(";","\\;"):gsub(",","\\,"))
end
function api.get_player_by_name(name) return h.connected[name] end
function api.register_on_player_receive_fields(fn) h.fields = fn end
function api.register_chatcommand(name, def) h.commands[name] = def end
function api.show_formspec(name, form, spec) h.screens[#h.screens+1] = {name=name,form=form,spec=spec} end
function api.close_formspec(name, form) h.closed[#h.closed+1] = {name,form} end
function h.notify(_, message) h.messages[#h.messages+1] = message end
function h.player(name)
local meta = {raw = "", writes = 0}
function meta:get_string(k) assert(k == "hearth:appearance"); if self.read_fail then error("PRIVATE read") end; return self.raw end
function meta:set_string(k, v)
assert(k == "hearth:appearance"); self.writes = self.writes + 1
if self.fail then error("PRIVATE write") end
if self.reject then return false end
self.raw = v
end
local p = {name=name or "alice", meta=meta, properties={}}
function p:get_player_name() return self.name end
function p:get_meta() return self.meta end
function p:set_properties(props)
for k in pairs(props) do assert(k == "mesh" or k == "textures", "cosmetics cannot alter " .. k) end
h.props[#h.props+1] = props; self.properties = props
end
h.sessions[p.name], h.connected[p.name] = {}, p
return p, meta
end
h.api, h.C = api, factory(h,api)
return h
end
test("factory exports fresh defaults and exact independent option lists", function()
local h = harness(); local C = h.C
local counts = {body=3,skin=6,face=4,eyes=5,hair=7,hairColor=6,beard=5,garment=4,cloth=8,
trousers=4,boots=3,accent=5,accentColor=8,detail=4}
for k,n in pairs(counts) do assert(#C.options()[k]==n,k) end
local d, o = C.defaults(), C.options(); d.skin="BAD"; o.skin[1]="BAD"
assert(C.defaults().skin=="sand" and C.options().skin[1]=="porcelain" and C.VERSION==1)
end)
test("strict schema rejects missing extra typed version and untrusted selections", function()
local C = harness().C
for _,bad in ipairs({false, 1, "{}", {}}) do assert(not C.validate(bad)) end
for _,k in ipairs({"version","body","skin","face","eyes","hair","hairColor","beard","garment","cloth","trousers","boots","accent","accentColor","detail"}) do
local d = C.defaults(); d[k]=nil; assert(not C.validate(d),k)
end
for _,v in ipairs({2,"1",false,{}}) do local d=C.defaults(); d.version=v; assert(not C.validate(d)) end
for _,v in ipairs({"../skin.png","#ffffff","sand^other.png", "];button[0,0;", "https://invalid.example/skin", "", false, {}}) do
local d=C.defaults(); d.skin=v; assert(not C.validate(d))
end
for _,k in ipairs({"name","player","texture","__index",1}) do local d=C.defaults(); d[k]="x"; assert(not C.validate(d)) end
assert(not C.validate(setmetatable(C.defaults(),{})))
end)
test("all option values validate and map only to fixed mesh and layer filenames", function()
local C=harness().C
for k,list in pairs(C.options()) do for _,id in ipairs(list) do
local d=C.defaults(); d[k]=id; assert(C.validate(d))
local mesh, texture=C.preview(d)
assert(mesh:match("^hearth_person[_%a]*%.b3d$"))
local n=0; for layer in texture:gmatch("[^%^]+") do assert(layer:match("^hearth_char_[%a_]+%.png$")); n=n+1 end
assert(n==10)
end end
end)
test("preview exactly follows opaque skin face eyes clothing accent hair beard detail order", function()
local C=harness().C; local mesh,texture=C.preview(C.defaults())
assert(mesh=="hearth_person.b3d")
assert(texture=="hearth_char_skin_sand.png^hearth_char_face_calm.png^hearth_char_eyes_hazel.png^hearth_char_garment_tunic_teal.png^hearth_char_trousers_earth.png^hearth_char_boots_walnut.png^hearth_char_accent_scarf_sage.png^hearth_char_hair_sidepart_chestnut.png^hearth_char_beard_none_chestnut.png^hearth_char_detail_none.png")
assert(not C.preview({}))
end)
test("bounded silent JSON roundtrip refuses malformed oversize nested and invalid data", function()
local h=harness(); local C=h.C; local d=C.defaults(); local raw=assert(C.encode(d))
assert(#raw<2048 and C.decode(raw).hair==d.hair)
for _,raw_bad in ipairs({"", "{oops", "null", "[]", '{"skin":{}}', string.rep("{",1000), string.rep("x",2049)}) do
assert(not C.decode(raw_bad))
end
local before=h.parses; assert(not C.decode(string.rep("x",2049))); assert(h.parses==before)
d.extra=true; assert(not C.decode(h.api.write_json(d)))
h.parse_throw=true; assert(not C.decode(raw))
h.parse_throw=false; h.encode_fail=true; assert(not C.encode(C.defaults()))
end)
test("join applies defaults without saving or opening a form", function()
local h=harness(); local p,m=h.player(); assert(h.C.on_join(p))
assert(m.writes==0 and #h.screens==0 and #h.messages==0 and #h.props==1)
assert(not h.C.state(p).warning)
end)
test("saved JSON restores selected profile and appearance without a rewrite", function()
local h=harness(); local p,m=h.player(); local d=h.C.defaults(); d.body="broad"; d.skin="ebony"
m.raw=assert(h.C.encode(d)); assert(h.C.apply(p))
assert(p.properties.mesh=="hearth_person_broad.b3d" and p.properties.textures[1]:find("skin_ebony",1,true))
assert(m.writes==0 and not h.C.state(p).warning and #h.messages==0)
end)
test("corrupt saved bytes and read errors use defaults warn safely and never kick or overwrite", function()
for _,raw in ipairs({"PRIVATE malformed",string.rep("x",2049)}) do
local h=harness(); local p,m=h.player(); m.raw=raw; assert(h.C.on_join(p))
assert(m.raw==raw and m.writes==0 and #h.messages==1 and h.C.state(p).warning)
assert(not h.messages[1]:find("PRIVATE",1,true)); h.C.show(p)
assert(h.screens[1].spec:find("original data is retained",1,true))
end
local h=harness(); local p,m=h.player(); m.read_fail=true; assert(h.C.on_join(p) and m.writes==0)
end)
test("command requires connected own session and other forms are ignored", function()
local h=harness(); assert(not h.commands.character.func("offline"))
local p=h.player(); assert(h.commands.character.func("alice"))
assert(h.screens[1].name=="alice" and h.screens[1].form==h.C.FORM)
assert(h.fields(p,"hearth:location",{apply=true})==nil)
h.sessions.alice=nil; assert(not h.commands.character.func("alice"))
end)
test("four tab layout contains real escaped one-texture model and bounded controls", function()
local h=harness(); local p=h.player(); h.C.show(p)
for i=1,4 do
h.C.handle(p,{["tab_"..i]=true}); local spec=h.screens[#h.screens].spec
assert(spec:find("size[14,11]",1,true) and spec:find("model[0.65,1.65;5.1,6.4;traveler;hearth_person.b3d;",1,true))
assert(spec:find(";0,180;true;true;0,19;12]",1,true))
assert(spec:find("Face]",1,true) and spec:find("Hair]",1,true) and spec:find("Clothing]",1,true) and spec:find("Finishing]",1,true))
assert(not spec:find("http",1,true) and not spec:find("hearth_person.png",1,true))
for x,y,w,ht in spec:gmatch("button%[([%d.]+),([%d.]+);([%d.]+),([%d.]+);") do
assert(tonumber(x)+tonumber(w)<=14 and tonumber(y)+tonumber(ht)<=11)
end
end
assert(h.screens[#h.screens].spec:find("Freckles and scar",1,true))
-- Escape is applied to texture names too, rather than assuming safe output.
local calls=0; local original=h.api.formspec_escape
h.api.formspec_escape=function(s) calls=calls+1; return original(s) end
h.C.show(p); assert(calls>10)
end)
test("draft tabs and rotations never publish player properties or metadata", function()
local h=harness(); local p,m=h.player(); h.C.show(p); local before=#h.props
assert(h.C.handle(p,{pick_body="1",pick_skin="6",tab_2=true}))
assert(h.C.handle(p,{pick_hair="5",pick_hairColor="6",pick_beard="5"}))
assert(h.C.handle(p,{back=true}) and h.C.state(p).yaw==0)
assert(h.C.handle(p,{profile=true}) and h.C.state(p).yaw==90)
assert(h.C.handle(p,{front=true}) and h.C.state(p).yaw==180)
assert(h.C.state(p).draft.skin=="ebony" and h.C.state(p).draft.hair=="long")
assert(#h.props==before and m.writes==0)
end)
test("cancel and Escape discard draft preserving saved appearance and persistence", function()
for _,field in ipairs({"cancel","quit"}) do
local h=harness(); local p,m=h.player(); h.C.show(p); local old=p.properties.textures[1]
h.C.handle(p,{pick_skin="6"}); h.C.handle(p,{[field]=true})
assert(not h.C.state(p).draft and m.writes==0 and p.properties.textures[1]==old)
h.C.show(p); assert(h.C.state(p).draft.skin=="sand")
end
end)
test("apply writes first then publishes and saved data survives a new session", function()
local h=harness(); local p,m=h.player(); h.C.show(p)
local original=p.set_properties
p.set_properties=function(self,props) assert(m.writes==1 and h.C.decode(m.raw).skin=="ebony"); original(self,props) end
assert(h.C.handle(p,{pick_skin="6",pick_body="3",apply=true}))
assert(m.writes==1 and not h.C.state(p).draft and p.properties.mesh=="hearth_person_broad.b3d")
p.set_properties=original; h.sessions.alice={}; h.C.on_join(p)
assert(h.C.state(p).saved.skin=="ebony" and m.writes==1 and not h.C.state(p).warning)
end)
test("failed metadata or serialization leaves old published appearance and saved bytes", function()
for _,mode in ipairs({"throw","false","encode"}) do
local h=harness(); local p,m=h.player(); h.C.show(p); local old=p.properties.textures[1]
m.fail=mode=="throw"; m.reject=mode=="false"; h.encode_fail=mode=="encode"
h.C.handle(p,{pick_skin="6",apply=true})
assert(m.raw=="" and p.properties.textures[1]==old and h.C.state(p).saved.skin=="sand")
assert(h.C.state(p).draft.skin=="ebony" and h.C.state(p).warning:find("could not be saved",1,true))
assert(not h.screens[#h.screens].spec:find("PRIVATE",1,true))
end
end)
test("corrupt record is replaced only by explicit successful Apply", function()
local h=harness(); local p,m=h.player(); m.raw="PRIVATE bad"
h.C.show(p); h.C.handle(p,{reset=true}); h.C.handle(p,{cancel=true}); assert(m.raw=="PRIVATE bad")
h.C.show(p); h.C.handle(p,{apply=true}); assert(h.C.decode(m.raw) and m.writes==1 and not h.C.state(p).warning)
end)
test("reset and randomization use every allowlist without mutating defaults or publishing", function()
local h=harness(); local p,m=h.player(); h.C.show(p); local before=#h.props
for i=1,40 do h.C.handle(p,{randomize=true}); assert(h.C.validate(h.C.state(p).draft)) end
h.C.handle(p,{reset=true}); local d=h.C.state(p).draft
for k,v in pairs(h.C.defaults()) do assert(d[k]==v) end
assert(m.writes==0 and #h.props==before)
d.skin="BAD"; assert(h.C.state(p).draft.skin=="sand")
end)
test("forged fields indices hidden selections and target identities reject whole event", function()
local h=harness(); local p,m=h.player(); local q,n=h.player("bob"); h.C.show(p); h.C.show(q)
local cases={{pick_skin="0"},{pick_skin="7"},{pick_skin="01"},{pick_skin="1.0"},{pick_skin="NaN"},
{pick_skin={}}, {pick_skin=string.rep("1",2049)}, {pick_skin="];button[0,0;"},
{player="bob",apply=true,pick_skin="6"}, {pick_hair="1",apply=true}, {texture="x",apply=true}}
for _,f in ipairs(cases) do assert(not h.C.handle(p,f)); assert(h.C.state(p).draft.skin=="sand") end
assert(m.writes==0 and n.writes==0 and h.C.state(q).draft.skin=="sand")
assert(not h.C.handle({get_player_name=function() return "alice" end},{apply=true}))
end)
test("stale sessions reconnects closed drafts and disconnected objects cannot save", function()
local h=harness(); local p,m=h.player(); h.C.show(p); h.sessions.alice={}
assert(not h.C.handle(p,{apply=true}) and m.writes==0)
h.C.show(p); h.connected.alice=nil; assert(not h.C.handle(p,{apply=true}))
h.connected.alice=p; h.C.show(p); h.C.handle(p,{cancel=true}); assert(not h.C.handle(p,{apply=true}))
end)
assert(total==18,"Unexpected character test count")
print("PASS " .. total .. " character creator tests")