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

571 lines
34 KiB
Lua
Executable File

-- Isolated journal / recipe / HUD tests; no engine or real player required.
local target = assert(arg[1], "interface.lua path required")
local sibling = target:match("^(.*[/\\])") or ""
local items = {
["hearth:knife"] = {description="Flint knife"}, ["hearth:loose_stone"] = {description="Loose flint"},
["hearth:firestarter"] = {description="Firestarter"}, ["hearth:torch"] = {description="Reed torch"},
["hearth:bedroll"] = {description="Bedroll"}, ["hearth:reeds"] = {description="Reeds"},
["hearth:planks"] = {description="Planks"}, ["hearth:fire"] = {description="Stone hearth"},
["hearth:quartz"] = {description="Quartz crystal"}, ["hearth:window"] = {description="Reed-lattice window"},
["hearth:copper_ore"] = {description="Copper ore"}, ["hearth:charcoal"] = {description="Prepared charcoal"},
["hearth:copper_ingot"] = {description="Copper ingot"}, ["hearth:copper_charge"] = {description="Copper charge"},
["hearth:copper_pick"] = {description="Copper pick"}, ["hearth:sticks"] = {description="Dry sticks"},
["hearth:log"] = {description="Timber log"}, ["hearth:stone"] = {description="Weathered stone"},
["hearth:alluvium"] = {description="Alluvium"},
}
local species = {"oak", "birch", "pine", "willow", "alder", "beech", "aspen", "rowan", "hazel", "spruce"}
local rocks = {"granite", "basalt", "limestone", "slate", "sandstone"}
for _, s in ipairs(species) do
items["hearth:"..s.."_log"] = {description=s.." log"}
items["hearth:"..s.."_planks"] = {description=s.." planks"}
end
for _, rock in ipairs(rocks) do
items["hearth:"..rock] = {description=rock.." bedrock"}
items["hearth:"..rock.."_bricks"] = {description=rock.." masonry"}
end
local registered, screens, commands = {}, {}, {}
core = {registered_items=items, is_singleplayer=function() return true end}
function core.get_modpath() return sibling end
function core.formspec_escape(s)
return (tostring(s):gsub("\\","\\\\"):gsub("%[","\\["):gsub("%]","\\]"):gsub(";","\\;"):gsub(",","\\,"))
end
function core.colorize(color, s) return "\27(c@" .. color .. ")" .. s .. "\27(c@#ffffff)" end
function core.register_on_player_receive_fields(fn) registered.fields=fn end
function core.register_chatcommand(name, def) commands[name]=def end
function core.get_all_craft_recipes(output)
local recipes = {
["hearth:knife"] = {{method="normal",width=3,items={"hearth:loose_stone","hearth:loose_stone",nil,nil,"hearth:loose_stone"},output="hearth:knife"}},
["hearth:firestarter"] = {{method="normal",width=0,items={"hearth:loose_stone","hearth:sticks"},output="hearth:firestarter"}},
["hearth:torch"] = {{method="normal",width=0,items={"hearth:sticks","hearth:reeds"},output="hearth:torch 2"}},
["hearth:bedroll"] = {{method="normal",width=3,items={"hearth:planks","hearth:planks","hearth:planks","hearth:reeds","hearth:reeds","hearth:reeds"},output="hearth:bedroll"}},
["hearth:fire"] = {{method="normal",width=3,items={"hearth:loose_stone"},output="hearth:fire"}},
["hearth:copper_charge"] = {{method="normal",width=0,items={"hearth:copper_ore","hearth:charcoal"},output="hearth:copper_charge"}},
["hearth:copper_pick"] = {{method="normal",width=3,items={"hearth:copper_ingot","hearth:copper_ingot","hearth:copper_ingot","","hearth:sticks","","","hearth:sticks",""},output="hearth:copper_pick"}},
["hearth:window"] = {
{method="normal",width=0,items={"hearth:quartz","hearth:quartz","hearth:quartz"},output="hearth:window"},
{method="normal",width=3,items={"hearth:planks","hearth:reeds","hearth:planks","hearth:planks","hearth:reeds","hearth:planks"},output="hearth:window"}},
["hearth:log"] = {}, ["hearth:planks"] = {}, ["hearth:stone"] = {},
}
for _, s in ipairs(species) do
recipes["hearth:"..s.."_planks"] = {{method="normal",width=0,items={"hearth:"..s.."_log"},output="hearth:"..s.."_planks 4"}}
table.insert(recipes["hearth:log"], {method="normal",width=0,items={"hearth:"..s.."_log","hearth:"..s.."_log"},output="hearth:log 2"})
table.insert(recipes["hearth:planks"], {method="normal",width=0,items={"hearth:"..s.."_planks"},output="hearth:planks"})
end
for _, rock in ipairs(rocks) do
recipes["hearth:"..rock.."_bricks"] = {{method="normal",width=0,items={"hearth:"..rock},output="hearth:"..rock.."_bricks 4"}}
table.insert(recipes["hearth:stone"], {method="normal",width=0,items={"hearth:"..rock,"hearth:"..rock},output="hearth:stone 2"})
end
return recipes[output]
end
function core.show_formspec(name, form, spec) screens[#screens+1]={name=name,form=form,spec=spec} end
hearth = {sessions={alice={}}, model={location=function() end}}
dofile(target)
local player={get_player_name=function() return "alice" end}
function player:get_pos() return {x=10,y=4,z=-2} end
function player:set_inventory_formspec(spec) self.inventory_spec=spec end
local hud = {}
function player:hud_add(def) hud[#hud+1]=def; return #hud end
function player:hud_change(id, field, value) assert(hud[id],"unknown HUD id"); hud[id][field]=value end
function player:hud_set_flags(flags) self.flags=flags end
function player:hud_set_hotbar_itemcount(count) self.hotbar_count=count end
function player:hud_set_hotbar_image(texture) self.hotbar_texture=texture end
function player:hud_set_hotbar_selected_image(texture) self.hotbar_selected=texture end
function core.get_player_by_name(name) return name=="alice" and player or nil end
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 latest() return screens[#screens] end
test("search lists craft recipes and elapsed real-time process instructions", function()
hearth.recipes(player,"",1)
local spec=latest().spec
assert(latest().form=="hearth:recipes" and spec:find("Page 1 /",1,true))
hearth.recipes(player,"knife",1)
assert(latest().spec:find("Flint knife",1,true))
hearth.recipes(player,"Cooked forage",1)
assert(latest().spec:find("6 real seconds",1,true), latest().spec)
hearth.recipes(player,"Boiled water",1)
assert(latest().spec:find("4 real seconds",1,true), latest().spec)
hearth.recipes(player,"Pottery firing",1)
assert(latest().spec:find("120 real seconds",1,true), latest().spec)
hearth.recipes(player,"River fish trap",1)
assert(latest().spec:find("600 real seconds",1,true), latest().spec)
hearth.recipes(player,"Spring fish run",1)
assert(latest().spec:find("scheduled deadline",1,true), latest().spec)
hearth.recipes(player,"River fish trap",1)
assert(latest().spec:find("600 real seconds",1,true), latest().spec)
hearth.recipes(player,"Spring fish run",1)
assert(latest().spec:find("scheduled deadline",1,true), latest().spec)
hearth.recipes(player,"firestarter",1)
assert(latest().spec:find("Firestarter",1,true))
hearth.recipes(player,"bedroll",1)
assert(latest().spec:find("Bedroll",1,true))
end)
test("plain text searches match ingredients and result names", function()
hearth.recipes(player,"Seasonal forage",1)
assert(latest().spec:find("Spring greens",1,true) and latest().spec:find("Autumn mushrooms",1,true))
hearth.recipes(player,"loose_stone",1)
assert(latest().spec:find("Flint knife",1,true))
hearth.recipes(player,"torch",1)
assert(latest().spec:find("Reed torch",1,true))
end)
test("search is bounded and formspec escaped, unknown term reports no matches", function()
local query=string.rep("x",80).."];button[0,0;"
hearth.recipes(player,query,1)
local spec=latest().spec
assert(spec:find("x",1,true) and not spec:find(query,1,true))
hearth.recipes(player,"no-such-item",1)
assert(latest().spec:find("No recipe or process matches",1,true))
end)
test("guide navigation and chat command enter the real recipe index", function()
assert(commands.recipes and commands.recipes.func("alice","reeds"))
assert(latest().form=="hearth:recipes" and latest().spec:find("Reed torch",1,true))
registered.fields(player,"hearth:guide",{recipe_index=true})
assert(latest().form=="hearth:recipes")
registered.fields(player,"hearth:recipes",{close=true})
assert(latest().form=="hearth:guide")
end)
test("all journal surfaces share Mocha controls, lists and botanical dividers", function()
local function themed(spec)
for _,color in ipairs({"#1e1e2e","#181825","#292940","#3c3d56","#cdd6f4","#cba6f7","#94e2d5"}) do
assert(spec:find(color,1,true), "missing palette anchor "..color)
end
assert(spec:find("style_type[button:hovered,button_exit:hovered",1,true))
assert(spec:find("bgimg="..core.formspec_escape("[fill:1x1:#ffffff"),1,true),"borderless buttons need visible backing")
assert(spec:find("style_type[field,textarea;",1,true))
assert(spec:find("listcolors[#292940;#3c3d56;#3c3d56;#181825;#cdd6f4]",1,true))
assert(spec:find("0.16,0.07;#94e2d5]",1,true),"pressed-sprig divider missing")
end
hearth.guide(player,1); themed(latest().spec)
hearth.recipes(player,"",1); themed(latest().spec)
hearth.inventory(player); themed(player.inventory_spec)
hearth.location_form(player); themed(latest().spec)
end)
test("guide keeps eight pages and every sidebar button inside 14 by 10", function()
for page=1,8 do
hearth.guide(player,page)
local spec,count=latest().spec,0
assert(spec:find("size[14,10]",1,true))
assert(spec:find(string.format("%02d / 08",page),1,true))
for x,y,w,h in spec:gmatch("button%[([%d.]+),([%d.]+);([%d.]+),([%d.]+);page_%d+;") do
assert(tonumber(x)+tonumber(w)<=3.3 and tonumber(y)+tonumber(h)<8.5)
count=count+1
end
assert(count==8 and not spec:find("page_9",1,true))
end
hearth.guide(player,6)
assert(latest().spec:find("Living ecosystems",1,true))
assert(latest().spec:find("Dynamic Life",1,true))
assert(not latest().spec:find("Riverward field season",1,true))
assert(latest().spec:find("no offline catch-up",1,true))
hearth.guide(player,0/0); assert(hearth.sessions.alice.page==1)
hearth.guide(player,math.huge); assert(hearth.sessions.alice.page==8)
end)
test("resource searches show quartz windows copper charges picks and process contract", function()
for _, query in ipairs({"quartz", "hearth:quartz"}) do
hearth.recipes(player,query,1)
assert(latest().spec:find("Reed-lattice window <- Quartz crystal + Quartz crystal + Quartz crystal",1,true))
end
hearth.recipes(player,"window",1)
assert(latest().spec:find("Planks + Planks + Planks + Planks + Reeds + Reeds",1,true))
hearth.recipes(player,"copper_ore",1)
assert(latest().spec:find("Copper charge <- Copper ore + Prepared charcoal",1,true))
hearth.recipes(player,"copper_pick",1)
assert(latest().spec:find("Copper pick <- Copper ingot + Copper ingot + Copper ingot + Dry sticks + Dry sticks",1,true))
hearth.recipes(player,"Copper smelting",1)
for _, text in ipairs({"45 real seconds", "30 fuel seconds", "75 fuel seconds", "one hearth:copper_ingot", "full inventory"}) do
assert(latest().spec:find(text,1,true),text)
end
hearth.recipes(player,"Fire fuel",1)
for _, text in ipairs({"180", "90", "240", "600", "nonrenewable", "not consumed"}) do
assert(latest().spec:find(text,1,true),text)
end
end)
for _, s in ipairs(species) do
test(s.." search discovers matching planks and canonical timber conversions", function()
hearth.recipes(player,"hearth:"..s.."_log",1)
assert(latest().spec:find("4 x "..s.." planks <- "..s.." log",1,true))
assert(latest().spec:find("2 x Timber log <- "..s.." log + "..s.." log",1,true))
assert(not latest().spec:find("\nTimber log <- "..s.." log",1,true))
hearth.recipes(player,"hearth:"..s.."_planks",1)
assert(latest().spec:find("Planks <- "..s.." planks",1,true))
end)
end
for _, rock in ipairs(rocks) do
test(rock.." search discovers four bricks and canonical stone", function()
hearth.recipes(player,"hearth:"..rock,1)
assert(latest().spec:find("4 x "..rock.." masonry <- "..rock.." bedrock",1,true))
assert(latest().spec:find("2 x Weathered stone <- "..rock.." bedrock + "..rock.." bedrock",1,true))
assert(not latest().spec:find("\nWeathered stone <- "..rock.." bedrock",1,true))
end)
end
test("regional guide covers ten biomes conversions and operator-controlled old-world seams", function()
hearth.guide(player,8)
for _, text in ipairs({"Temperate meadow", "oak woodland", "birch forest", "pine taiga", "Wet forest", "floodplain",
"Dry scrub", "alpine meadow", "rocky peaks", "riverbank", "canonical log", "45 real seconds", "240 seconds",
"fresh worlds only", "unexplored old-world boundaries", "Operators must explicitly choose", "climate HUD remains shared"}) do
assert(latest().spec:find(text,1,true),text)
end
for _, text in ipairs({"two matching species logs to two canonical logs", "two matching rocks convert to two canonical stones",
"separate crafting slots", "no single-log or single-rock canonical conversion"}) do
assert(latest().spec:find(text,1,true),text)
end
registered.fields(player,"hearth:guide",{page_8=true})
assert(hearth.sessions.alice.page==8)
hearth.guide(player,6)
for _,text in ipairs({"Birch pioneers", "Evergreen pine and spruce", "alluvium/peat", "two nutrient units", "Nitrogen-fixer policy",
"not a separate fertilizer process", "not fully simulated hydrology", "no offline catch-up"}) do
assert(latest().spec:find(text,1,true),text)
end
hearth.recipes(player,"Species timber",1)
assert(latest().spec:find("convert species logs in matching pairs first",1,true))
assert(latest().spec:find("two canonical hearth:log",1,true))
hearth.recipes(player,"Regional geology",1)
assert(latest().spec:find("two matching rocks to two canonical stones",1,true))
hearth.recipes(player,"Finite nitrogen",1)
assert(latest().spec:find("at most two nutrient units",1,true))
end)
test("inventory retains crafting, preview, ten by four storage and transfer rings", function()
hearth.inventory(player)
assert(player.inventory_spec:find("style_type[list;size=1,1;spacing=0.12,0.12]",1,true))
assert(0.6+10+9*0.12<12.4 and 5.7+4+3*0.12<11.3,"inventory grid must fit the paper")
for _,element in ipairs({"list[current_player;craft;0.6,1.8;3,3;]",
"list[current_player;craftpreview;4.8,2.9;1,1;]", "list[current_player;main;0.6,5.7;10,4;]",
"listring[current_player;main]listring[current_player;craft]", "field_guide;Open field notes]"}) do
assert(player.inventory_spec:find(element,1,true),element)
end
registered.fields(player,"",{field_guide=true}); assert(latest().form=="hearth:guide")
end)
test("recipe query escaping, literal matching and pagination remain bounded", function()
hearth.recipes(player,"];button[0,0;1,1;attack;Injected]",0/0)
assert(latest().spec:find(core.formspec_escape("];button[0,0;1,1;attack;Injected]"),1,true))
assert(not latest().spec:find(";attack;Injected]",1,true))
hearth.recipes(player,"%[.*",1)
assert(latest().spec:find("No recipe or process matches",1,true))
hearth.recipes(player,"\n\tfood",1); assert(hearth.sessions.alice.recipe_query==" food")
hearth.recipes(player,"",1)
registered.fields(player,"hearth:recipes",{next=true}); assert(hearth.sessions.alice.recipe_page==2)
registered.fields(player,"hearth:recipes",{previous=true}); assert(hearth.sessions.alice.recipe_page==1)
registered.fields(player,"hearth:recipes",{key_enter=true,query="torch"})
assert(latest().spec:find("Reed torch",1,true))
end)
test("ecosystem command and guide button only read one habitat and status snapshot", function()
local reads, snapshots=0,0
local function forbidden() error("observations must never mutate or load the map") end
for _,name in ipairs({"set_node","swap_node","emerge_area","forceload_block","add_entity","get_objects_inside_radius","get_meta"}) do
core[name]=forbidden
end
hearth.succession={habitat=function(pos)
assert(pos.x==10 and pos.y==4 and pos.z==-2); reads=reads+1
return {grass=3,flowers=2,cover=4,water=1,canopy=1.5,moisture=0.65,light=10,loaded=true,
ground="alluvium",pollination=7,nutrients=2}
end,step=forbidden,forage=forbidden,deposit=forbidden}
hearth.wildlife={status=function()
snapshots=snapshots+1
return {hare=3,deer=2,fox=1,total=6,global_cap=32,local_cap=6,player_cap=6}
end}
assert(commands.ecosystem.func("alice"))
assert(reads==1 and snapshots==1 and latest().form=="hearth:ecosystem")
for _,text in ipairs({"Natural grass cover: 3", "Flower-support cover: 2", "Shrub / fern / aquatic / tree cover: 4",
"Moisture: 65%", "Hares: 3", "Deer: 2", "Foxes: 1", "Active total: 6 / 32 globally",
"not a local census", "Loaded sample", "No terrain is loaded", "Ground: Alluvium",
"Pollinator-support plants: 7", "Nutrient reserve: 2 / 8", "pine and spruce stay evergreen", "deciduous canopy thins"}) do
assert(latest().spec:find(text,1,true),text)
end
registered.fields(player,"hearth:guide",{ecosystem_survey=true})
assert(reads==2 and snapshots==2 and latest().form=="hearth:ecosystem")
registered.fields(player,"hearth:ecosystem",{refresh=true})
assert(reads==3 and snapshots==3)
registered.fields(player,"hearth:ecosystem",{field_notes=true})
assert(hearth.sessions.alice.page==6 and reads==3 and snapshots==3)
assert(not commands.ecosystem.func("offline"))
end)
test("survey discloses unloaded and unavailable data without changing sessions or world", function()
hearth.succession.habitat=function() return {loaded=false,grass=1} end
hearth.ecosystem(player)
assert(latest().spec:find(core.formspec_escape("Incomplete sample / unloaded cells are unknown, not empty"),1,true))
assert(latest().spec:find("Natural grass cover: 1",1,true))
assert(latest().spec:find("Ground: unknown",1,true))
assert(latest().spec:find("Pollinator-support plants: unavailable",1,true))
assert(latest().spec:find("Nutrient reserve: unavailable",1,true))
hearth.succession.habitat=function()
return {loaded=true,ground="];button[0,0;1,1;attack;Injected]",pollination=0/0,nutrients=math.huge}
end
hearth.ecosystem(player)
assert(not latest().spec:find(";attack;Injected]",1,true))
assert(latest().spec:find(core.formspec_escape("hearth:];button[0,0;1,1;attack;Injected]"),1,true))
assert(latest().spec:find("Pollinator-support plants: 0",1,true))
assert(latest().spec:find("Nutrient reserve: 0 / 8",1,true))
hearth.succession,hearth.wildlife=nil,nil
hearth.ecosystem(player)
assert(latest().spec:find("Succession observations are not available",1,true))
assert(latest().spec:find("Wildlife observations are not available",1,true))
local session,count=hearth.sessions.alice,#screens
hearth.sessions.alice=nil
assert(not commands.ecosystem.func("alice"))
registered.fields(player,"hearth:guide",{ecosystem_survey=true})
registered.fields(player,"hearth:ecosystem",{refresh=true})
assert(#screens==count)
hearth.sessions.alice=session
end)
test("botanical guide tolerates absent empty and partial optional catalogue APIs", function()
for _, mock in ipairs({false, {}, {list=function() return {} end}}) do
hearth.botany=mock or nil
hearth.botanical_guide(player,"",math.huge)
assert(latest().form=="hearth:botany" and latest().spec:find("catalogue unavailable",1,true))
assert(hearth.sessions.alice.botany_page==1)
hearth.guide(player,6)
assert(latest().spec:find("catalogue unavailable",1,true))
end
end)
-- Actual catalogue factory/methods, not an invented list of test species.
local botany_path = target:match("^(.*[/\\])") or ""
local actual_botany = dofile(botany_path .. "botany.lua")(hearth,{})
test("actual botanical catalogue displays eighty species in eight ten-row pages", function()
hearth.botany=actual_botany
local records, groups = actual_botany.list(), {}
assert(#records==80)
for page=1,8 do
hearth.botanical_guide(player,"",page)
local spec,rows=latest().spec,0
assert(spec:find("Page "..page.." / 8 / 80 matches",1,true))
for _ in spec:gmatch("Habitat:") do rows=rows+1 end
assert(rows==10,"each page must contain ten species rows")
for i=(page-1)*10+1,page*10 do
local r=records[i]; groups[r.group]=(groups[r.group] or 0)+1
assert(spec:find(core.formspec_escape(r.common.." ("..r.latin..")"),1,true),r.id)
assert(spec:find("Active seasons:",1,true) and spec:find("Roles / conditional policy or descriptors:",1,true))
end
end
local count=0; for _,n in pairs(groups) do assert(n==10); count=count+1 end; assert(count==8)
hearth.guide(player,6)
for _,r in ipairs(records) do assert(latest().spec:find(core.formspec_escape(r.common.." ("..r.latin..")"),1,true),r.id) end
end)
test("botanical searches use real scientific names niches seasons and honest role labels", function()
for _,query in ipairs({"Fagus sylvatica", "beech"}) do
hearth.botanical_guide(player,query,1)
assert(latest().spec:find("European beech (Fagus sylvatica)",1,true))
end
hearth.botanical_guide(player,"fly_agaric",1)
for _,text in ipairs({"Amanita muscaria", "toxic flag: YES", "Summer", "Autumn", "hosts:", "no catalogue food harvest"}) do
assert(latest().spec:find(text,1,true),text)
end
hearth.botanical_guide(player,"hornwort",1)
for _,text in ipairs({"Ceratophyllum demersum", "submerged", "water depth 1-4", "NOT food permission", "habitat engineering (descriptive tag)"}) do
assert(latest().spec:find(core.formspec_escape(text),1,true),text)
end
hearth.botanical_guide(player,"bracken",1)
assert(latest().spec:find("descriptor only",1,true) and latest().spec:find("no killing",1,true))
hearth.botanical_guide(player,"honey_fungus",1)
assert(latest().spec:find("no plant damage",1,true))
hearth.botanical_guide(player,"bilberry",1)
assert(latest().spec:find("seasonal game berry harvest",1,true))
assert(latest().spec:find("NOT a real-world forage guide",1,true))
end)
test("botanical query escaping literal search and finite pagination bounds", function()
local query="];button[0,0;1,1;attack;Injected]"
hearth.botanical_guide(player,query,0/0)
assert(latest().spec:find(core.formspec_escape(query),1,true))
assert(not latest().spec:find(";attack;Injected]",1,true))
hearth.botanical_guide(player,"%[.*",1)
assert(latest().spec:find("No species matches that literal search",1,true))
hearth.botanical_guide(player,string.rep("x",70),999)
assert(#hearth.sessions.alice.botany_query==48 and hearth.sessions.alice.botany_page==1)
hearth.botanical_guide(player,"\n\tlichen",-math.huge)
assert(hearth.sessions.alice.botany_query==" lichen" and hearth.sessions.alice.botany_page==1)
for _,p in ipairs({0/0,-8,"bad",0}) do hearth.botanical_guide(player,"",p); assert(hearth.sessions.alice.botany_page==1) end
hearth.botanical_guide(player,"",math.huge); assert(hearth.sessions.alice.botany_page==8)
local record=actual_botany.list()[1]; record.common=query
hearth.botany={list=function() return {record} end}
hearth.botanical_guide(player,"",1)
assert(latest().spec:find(core.formspec_escape(query),1,true) and not latest().spec:find(";attack;Injected]",1,true))
hearth.botany=actual_botany
end)
test("botanical commands buttons pagination and exits route without world access", function()
assert(commands.botany.func("alice","Quercus robur"))
assert(latest().form=="hearth:botany" and latest().spec:find("Pedunculate oak",1,true))
assert(not commands.botany.func("offline",""))
registered.fields(player,"hearth:guide",{botany_index=true})
assert(latest().form=="hearth:botany")
registered.fields(player,"hearth:botany",{next=true,query="ignored"}); assert(hearth.sessions.alice.botany_page==2)
registered.fields(player,"hearth:botany",{previous=true}); assert(hearth.sessions.alice.botany_page==1)
registered.fields(player,"hearth:botany",{key_enter=true,query="Picea abies"})
assert(latest().spec:find("Norway spruce",1,true))
registered.fields(player,"hearth:botany",{search=true,query="Betula pendula"})
assert(latest().spec:find("Silver birch",1,true) and hearth.sessions.alice.botany_page==1)
registered.fields(player,"hearth:botany",{field_notes=true}); assert(latest().form=="hearth:guide" and hearth.sessions.alice.page==6)
registered.fields(player,"hearth:ecosystem",{botany_index=true}); assert(latest().form=="hearth:botany")
registered.fields(player,"hearth:botany",{ecosystem_survey=true}); assert(latest().form=="hearth:ecosystem")
local session,count=hearth.sessions.alice,#screens; hearth.sessions.alice=nil
assert(not commands.botany.func("alice",""))
hearth.botanical_guide(player,"",1)
registered.fields(player,"hearth:botany",{next=true})
registered.fields(player,"hearth:ecosystem",{botany_index=true})
registered.fields(player,"hearth:guide",{botany_index=true})
assert(#screens==count); hearth.sessions.alice=session
end)
test("botanical navigation fits existing panels and does not add a ninth journal page", function()
hearth.guide(player,6)
assert(latest().spec:find("botany_index;Botanical guide",1,true) and not latest().spec:find("page_9",1,true))
local guide=latest().spec
hearth.ecosystem(player); local survey=latest().spec
hearth.botanical_guide(player,"",4); local browser=latest().spec
for _,spec in ipairs({guide,survey,browser}) do
local right=0
for x,y,w,h in spec:gmatch("button[%w_]*%[([%d.]+),([%d.]+);([%d.]+),([%d.]+);") do
x,y,w,h=tonumber(x),tonumber(y),tonumber(w),tonumber(h)
assert(x+w<=14 and y+h<=10)
if y==8.85 then assert(x>=right,"footer buttons overlap"); right=x+w end
end
end
end)
test("survey exposes actual optional botanical habitat fields without inventing counts", function()
hearth.succession={habitat=function() return {loaded=true,ground="peat",grass=2,flowers=1,cover=4,
near_water=8,water=2,aquatic="submerged",ph=4.5,nursery=3} end}
hearth.ecosystem(player)
for _,text in ipairs({"Nearby water cells: 8", "Water depth: 2", "Game substrate pH: 4.5", "Nursery-support plants: 3", "Aquatic niche: submerged"}) do
assert(latest().spec:find(text,1,true),text)
end
hearth.succession.habitat=function() return {loaded=false} end
hearth.ecosystem(player)
for _,text in ipairs({"Game substrate pH: unavailable", "Nursery-support plants: unavailable", "Water depth: unavailable", "Aquatic niche: unavailable"}) do
assert(latest().spec:find(text,1,true),text)
end
end)
test("fallen log guidance matches existing axe-only sideways cube behavior", function()
hearth.recipes(player,"Fallen logs",1)
for _,text in ipairs({"sideways", "full-cube", "axe", "one canonical hearth:log", "hands do not yield sticks"}) do
assert(latest().spec:find(text,1,true),text)
end
end)
test("location stays local-only, bounded and escaped", function()
local writes, checks=0,0
hearth.set_location=function(lat,lon) assert(lat==45 and lon==0); writes=writes+1 end
hearth.model.location=function(lat,lon) checks=checks+1; return lat=="45" and lon=="0" end
core.is_singleplayer=function() return false end
registered.fields(player,"hearth:location",{save_location=true,latitude="45",longitude="0"})
assert(writes==0 and checks==0 and not commands.location.func("alice"))
core.is_singleplayer=function() return true end
for _,value in ipairs({string.rep("1",17), "];button[0,0;", 42}) do
registered.fields(player,"hearth:location",{save_location=true,latitude=value,longitude="0"})
assert(writes==0 and latest().form=="hearth:location")
end
hearth.location_form(player,"];button[0,0;1,1;attack;Injected]")
assert(latest().spec:find(core.formspec_escape("];button[0,0;1,1;attack;Injected]"),1,true))
assert(not latest().spec:find(";attack;Injected]",1,true))
registered.fields(player,"hearth:location",{save_location=true,latitude="45",longitude="0"})
assert(writes==1 and latest().form=="hearth:guide")
end)
test("HUD preserves runtime keys, tiles ten hotbar slots and uses readable graphical reserves", function()
hearth.make_hud(player)
local ids=hearth.sessions.alice.hud
for _,key in ipairs({"title","clock","needs","context","help"}) do assert(hud[ids[key]],key) end
assert(player.hotbar_count==10 and player.flags.minimap==false)
local expected="[combine:320x32"
for i=0,9 do expected=expected..":"..(i*32)..",0=hearth_hotbar.png" end
assert(player.hotbar_texture==expected and player.hotbar_selected=="hearth_selected.png")
hearth.clock={hour=13.5,season="Autumn",date="2026-11-02"}
hearth.players={alice={warmth=10,water=35,food=75,stamina=100,wet=30}}
hearth.update_hud(player,{temperature=8,shelter=1,rain=true,heat=1,smoke=true})
assert(hud[ids.clock].text:find("Solar 13:30",1,true))
assert(hud[ids.warmth_label].text=="WARMTH 10%" and hud[ids.warmth_label].number==0xf38ba8)
assert(hud[ids.water_label].number==0xf9e2af and hud[ids.food_label].number==0x94e2d5)
assert(hud[ids.food_fill].text=="[fill:165x6:#94e2d5")
assert(hud[ids.context].text:find("SMOKE / open a wall or roof vent",1,true))
assert(hud[ids.context].number==0xf38ba8)
hearth.players.alice.food=0; hearth.players.alice.stamina=200
hearth.update_hud(player,{temperature=8,shelter=0,roof=true,heat=0})
assert(hud[ids.food_fill].text=="[fill:1x6:#18182500")
assert(hud[ids.stamina_label].text=="STAMINA 100%")
end)
test("HUD accepts legacy mocks without optional hotbar image setters", function()
local image,selected=player.hud_set_hotbar_image,player.hud_set_hotbar_selected_image
player.hud_set_hotbar_image,player.hud_set_hotbar_selected_image=nil,nil
hearth.make_hud(player)
assert(hearth.sessions.alice.hud.needs)
player.hud_set_hotbar_image,player.hud_set_hotbar_selected_image=image,selected
end)
assert(total==38,"The original 38 recipe/journal checks must remain intact")
test("inspect command uses exact species and rejects absent session unknown and URL input",function()
hearth.botany=actual_botany
hearth.wiki_articles=dofile(sibling.."wiki_articles.lua")
hearth.species_effects=dofile(sibling.."species_effects.lua")(hearth,{})
assert(commands.inspect and not commands.inspect.privs)
assert(commands.inspect.func("alice","Quercus robur"))
assert(latest().form=="hearth:species" and latest().spec:find("Wikipedia excerpt (offline)",1,true))
assert(not commands.inspect.func("alice","https://en.wikipedia.org/wiki/Oak"))
assert(not commands.inspect.func("alice","Quercus"))
assert(not commands.inspect.func("offline","oak"))
end)
test("botanical article selection uses trusted current-page numeric rows only",function()
hearth.botanical_guide(player,"",2)
assert(latest().spec:find("tooltip[species_choice;Species article]",1,true))
registered.fields(player,"hearth:botany",{species_choice="2",species_article=true,id="oak"})
assert(latest().form=="hearth:species" and hearth.sessions.alice.species_reader.id=="blackberry")
for _,v in ipairs({"oak","0","11","2.5","1e0","999",{},string.rep("1",1000)}) do
hearth.botanical_guide(player,"",2); local n=#screens
registered.fields(player,"hearth:botany",{species_choice=v,species_article=true})
assert(#screens==n,"forged choice opened article")
end
hearth.botanical_guide(player,"no-such-species",1); local n=#screens
registered.fields(player,"hearth:botany",{species_article=true,id="oak"}); assert(#screens==n)
end)
test("article navigation retains server species and returns to selected botanical page",function()
hearth.botanical_guide(player,"",3)
registered.fields(player,"hearth:botany",{species_choice="2",species_article=true})
local id=hearth.sessions.alice.species_reader.id
registered.fields(player,"hearth:species",{reader_tab="2",id="pine",url="bad",text="edited"})
assert(hearth.sessions.alice.species_reader.id==id and latest().spec:find("SIMULATED",1,true))
registered.fields(player,"hearth:species",{reader_botany=true})
assert(latest().form=="hearth:botany" and hearth.sessions.alice.botany_page==3)
registered.fields(player,"hearth:botany",{species_choice="1",species_article=true})
registered.fields(player,"hearth:species",{quit=true}); local n=#screens
registered.fields(player,"hearth:species",{reader_tab="1"}); assert(#screens==n)
end)
test("fieldnotes inspect target uses loaded eye ray without changing other actions",function()
core.get_node_or_nil=function() return {name="hearth:oak_leaves"} end
function player:get_properties() return {eye_height=1.6} end
function player:get_look_dir() return {x=0,y=0,z=1} end
core.raycast=function(start,finish,objects,liquids)
assert(start.y==5.6 and finish.z==4 and not objects and not liquids)
local used=false
return function()
if used then return nil end; used=true
return {type="node",under={x=10,y=6,z=0},intersection_point={x=10,y=5.6,z=0}}
end
end
hearth.guide(player,6); assert(latest().spec:find("inspect_target;Inspect target",1,true))
registered.fields(player,"hearth:guide",{inspect_target=true})
assert(latest().form=="hearth:species" and hearth.sessions.alice.species_reader.id=="oak")
assert(commands.inspect.func("alice",""))
core.get_node_or_nil=function() return nil end
assert(not commands.inspect.func("alice",""))
end)
test("dropdown labels escape cached IDs and absent sessions reject article fields",function()
local r=actual_botany.list()[1]; r.id="];button[0,0;1,1;attack;Injected]"
hearth.botany={list=function() return {r} end}
hearth.botanical_guide(player,"",1)
assert(latest().spec:find(core.formspec_escape(r.id),1,true) and not latest().spec:find(";attack;Injected]",1,true))
hearth.botany=actual_botany
local s,n=hearth.sessions.alice,#screens; hearth.sessions.alice=nil
registered.fields(player,"hearth:botany",{species_choice="1",species_article=true})
registered.fields(player,"hearth:species",{reader_tab="2"})
assert(not commands.inspect.func("alice","oak") and #screens==n)
hearth.sessions.alice=s
end)
test("inventory appearance entry opens only the requesting player's creator", function()
local called = 0
hearth.character = {show = function(p) assert(p == player); called = called + 1 end}
hearth.inventory(player)
assert(player.inventory_spec:find("character_creator;Character appearance]",1,true))
assert(player.inventory_spec:find("Cosmetic only",1,true))
registered.fields(player,"",{character_creator=true}); assert(called == 1)
registered.fields(player,"hearth:recipes",{character_creator=true}); assert(called == 1)
hearth.character = nil
registered.fields(player,"",{character_creator=true}); assert(called == 1)
end)
assert(total==44,"Unexpected recipe/journal/character integration count")
print("PASS "..total.." recipe discovery tests")