217 lines
13 KiB
Lua
Executable File
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")
|