Files
Calic 17857ccbc2 test: de-duplication + malformed-locale assertions
§19 verifies sources={c, c} is de-duplicated (have=1, not 2).
§20 verifies {sink=c} without sources raises a loud-error
attributing crafting + sources.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-14 19:29:51 +02:00

606 lines
26 KiB
Lua

-- lib-core.crafting-test — Phase C.1 + D.0
-- 20 assertions: 12 base (registry + discovery/match/craft), 6 locale-API
-- (Form-2 happy + greedy-drain + aggregate + Loud-Errors), 2 silent-fail
-- regressions (duplicate-sources de-dup + malformed-locale loud-error).
-- Pattern follows P.2.4 Test-Module-Pattern; TAP output via engine.test.*
local composition = require("lib-core.composition")
local inv = require("lib-core.inventory-list")
local crafting = require("lib-core.crafting")
local T = engine.test
local M = {}
-- ---------------------------------------------------------------------
-- Template definitions (one-time, shared across tests).
-- composition does not expose a clear-all, so we never re-define.
-- All template-ids are unique within this test run.
-- ---------------------------------------------------------------------
composition.define_template{
id = "craft_test.container",
properties = {
sprite_path = "sprites/bag.png",
position = {x = 0, y = 0},
},
tags = {"renderable"},
container = { kind = "list" },
}
composition.define_template{
id = "craft_test.rock",
properties = {
stack_mode = "individual",
sprite_path = "sprites/rock.png",
position = {x = 0, y = 0},
},
tags = {"renderable", "item"},
}
composition.define_template{
id = "craft_test.stick",
properties = {
stack_mode = "individual",
sprite_path = "sprites/stick.png",
position = {x = 0, y = 0},
},
tags = {"renderable", "item"},
}
composition.define_template{
id = "craft_test.rock_pick",
properties = {
stack_mode = "individual",
sprite_path = "sprites/pick.png",
position = {x = 0, y = 0},
},
tags = {"renderable", "item"},
}
-- ---------------------------------------------------------------------
-- Fixture helper: fresh container + populate with items.
-- crafting._test_clear_all() wipes recipe state between tests.
-- ---------------------------------------------------------------------
local function setup_container(items)
crafting._test_clear_all()
local container = composition.create{ template = "craft_test.container" }
for _, spec in ipairs(items) do
for _ = 1, spec.count do
local e = composition.create{ template = spec.template }
inv.add(container, e)
end
end
return container
end
-- Sibling helper for multi-source tests: creates an additional container
-- without resetting the recipe registry. Use AFTER setup_container() to
-- avoid wiping recipes that were just defined for the test.
local function extra_container(items)
local container = composition.create{ template = "craft_test.container" }
for _, spec in ipairs(items) do
for _ = 1, spec.count do
local e = composition.create{ template = spec.template }
inv.add(container, e)
end
end
return container
end
-- ---------------------------------------------------------------------
-- Tests
-- ---------------------------------------------------------------------
function M.run_tests(ctx)
-- ================================================================
-- §1: define_recipe + list_recipes round-trip (with shallow-copy isolation)
-- ================================================================
crafting._test_clear_all()
crafting.define_recipe{
id = "recipe_round_trip",
inputs = {
{ template = "craft_test.rock", count = 1 },
{ template = "craft_test.stick", count = 1 },
},
output = { template = "craft_test.rock_pick", count = 1 },
name = "Round-Trip Recipe",
}
local listed1 = crafting.list_recipes()
local found_rt = false
for _, r in ipairs(listed1) do
if r.id == "recipe_round_trip" then found_rt = true; break end
end
-- Shallow-copy isolation: mutating the returned entry must not leak.
if #listed1 >= 1 then
listed1[1].id = "MUTATED"
end
local listed1b = crafting.list_recipes()
local still_rt = false
for _, r in ipairs(listed1b) do
if r.id == "recipe_round_trip" then still_rt = true; break end
end
T.assert(found_rt and still_rt,
"1. define_recipe + list_recipes round-trip; mutating returned list does not affect registry")
-- ================================================================
-- §2: define_recipe missing id → Loud-Error matching 'crafting.define_recipe.*id'
-- ================================================================
crafting._test_clear_all()
local ok2, err2 = pcall(crafting.define_recipe, {
inputs = { { template = "craft_test.rock", count = 1 } },
output = { template = "craft_test.rock_pick", count = 1 },
})
T.assert(not ok2 and type(err2) == "string"
and string.find(err2, "crafting%.define_recipe") ~= nil
and string.find(err2, "id") ~= nil,
"2. define_recipe missing id → Loud-Error matching 'crafting.define_recipe.*id'")
-- ================================================================
-- §3: define_recipe duplicate id → Loud-Error matching 'crafting.define_recipe.*duplicate'
-- ================================================================
crafting._test_clear_all()
crafting.define_recipe{
id = "dup_r",
inputs = { { template = "craft_test.rock", count = 1 } },
output = { template = "craft_test.rock_pick", count = 1 },
}
local ok3, err3 = pcall(crafting.define_recipe, {
id = "dup_r",
inputs = { { template = "craft_test.rock", count = 1 } },
output = { template = "craft_test.rock_pick", count = 1 },
})
T.assert(not ok3 and type(err3) == "string"
and string.find(err3, "crafting%.define_recipe") ~= nil
and string.find(err3, "duplicate") ~= nil,
"3. define_recipe duplicate id → Loud-Error matching 'crafting.define_recipe.*duplicate'")
-- ================================================================
-- §4: define_recipe empty inputs → Loud-Error matching 'crafting.define_recipe.*inputs'
-- ================================================================
crafting._test_clear_all()
local ok4, err4 = pcall(crafting.define_recipe, {
id = "empty_inputs_r",
inputs = {},
output = { template = "craft_test.rock_pick", count = 1 },
})
T.assert(not ok4 and type(err4) == "string"
and string.find(err4, "crafting%.define_recipe") ~= nil
and string.find(err4, "inputs") ~= nil,
"4. define_recipe empty inputs → Loud-Error matching 'crafting.define_recipe.*inputs'")
-- ================================================================
-- §5: get_recipe known/unknown
-- ================================================================
crafting._test_clear_all()
crafting.define_recipe{
id = "known_r",
inputs = { { template = "craft_test.rock", count = 1 } },
output = { template = "craft_test.rock_pick", count = 1 },
name = "Known Recipe",
}
local got_known = crafting.get_recipe("known_r")
local got_unknown = crafting.get_recipe("nonexistent_r")
T.assert(got_known ~= nil and got_known.id == "known_r"
and got_known.name == "Known Recipe"
and got_unknown == nil,
"5. get_recipe returns def for known id, nil for unknown id")
-- ================================================================
-- §6: is_known calls recipe.is_known with ctx (closure capture)
-- ================================================================
crafting._test_clear_all()
local captured_ctx = nil
crafting.define_recipe{
id = "is_known_r",
inputs = { { template = "craft_test.rock", count = 1 } },
output = { template = "craft_test.rock_pick", count = 1 },
is_known = function(c)
captured_ctx = c
return true
end,
}
local test_ctx = { actor = "abc", marker = "test6" }
local result6 = crafting.is_known("is_known_r", test_ctx)
T.assert(result6 == true and captured_ctx == test_ctx,
"6. is_known calls recipe.is_known with the supplied ctx (identity preserved)")
-- ================================================================
-- §7: can_craft happy-path → ok=true
-- ================================================================
local container7 = setup_container{
{ template = "craft_test.rock", count = 1 },
{ template = "craft_test.stick", count = 1 },
}
crafting.define_recipe{
id = "rock_pick_r",
inputs = {
{ template = "craft_test.rock", count = 1 },
{ template = "craft_test.stick", count = 1 },
},
output = { template = "craft_test.rock_pick", count = 1 },
}
local cc7 = crafting.can_craft("rock_pick_r", container7, {})
T.assert(cc7.ok == true,
"7. can_craft happy-path with all inputs present → ok=true")
-- ================================================================
-- §8: can_craft missing inputs → error='missing_inputs' + missing array
-- ================================================================
local container8 = setup_container{
{ template = "craft_test.rock", count = 1 },
-- no stick
}
crafting.define_recipe{
id = "rock_pick_r",
inputs = {
{ template = "craft_test.rock", count = 1 },
{ template = "craft_test.stick", count = 1 },
},
output = { template = "craft_test.rock_pick", count = 1 },
}
local cc8 = crafting.can_craft("rock_pick_r", container8, {})
local stick_missing_found = false
if cc8.missing then
for _, m in ipairs(cc8.missing) do
if m.template == "craft_test.stick"
and m.needed == 1 and m.have == 0 then
stick_missing_found = true; break
end
end
end
T.assert(cc8.ok == false and cc8.error == "missing_inputs"
and stick_missing_found,
"8. can_craft missing inputs → ok=false, error='missing_inputs', missing entry has correct fields")
-- ================================================================
-- §9: craft happy-path consumes inputs + creates output
-- ================================================================
local container9 = setup_container{
{ template = "craft_test.rock", count = 1 },
{ template = "craft_test.stick", count = 1 },
}
crafting.define_recipe{
id = "rock_pick_r",
inputs = {
{ template = "craft_test.rock", count = 1 },
{ template = "craft_test.stick", count = 1 },
},
output = { template = "craft_test.rock_pick", count = 1 },
}
local res9 = crafting.craft("rock_pick_r", container9, {})
local contents9 = inv.contents(container9)
local pick_count = 0
local rock_count_9 = 0
local stick_count_9 = 0
for _, e in ipairs(contents9) do
local tpl = composition.template_of(e)
if tpl == "craft_test.rock_pick" then pick_count = pick_count + 1 end
if tpl == "craft_test.rock" then rock_count_9 = rock_count_9 + 1 end
if tpl == "craft_test.stick" then stick_count_9 = stick_count_9 + 1 end
end
T.assert(res9.ok == true
and res9.crafted_items ~= nil and #res9.crafted_items == 1
and res9.consumed ~= nil and #res9.consumed == 2
and pick_count == 1 and rock_count_9 == 0 and stick_count_9 == 0,
"9. craft happy-path: ok=true, consumed=2, crafted=1, container has only output")
-- ================================================================
-- §10: craft unknown recipe → ok=false, error='unknown_recipe', container untouched
-- ================================================================
local container10 = setup_container{
{ template = "craft_test.rock", count = 1 },
{ template = "craft_test.stick", count = 1 },
}
local count_before_10 = inv.count(container10)
local res10 = crafting.craft("does_not_exist_r", container10, {})
local count_after_10 = inv.count(container10)
T.assert(res10.ok == false and res10.error == "unknown_recipe"
and count_before_10 == count_after_10,
"10. craft unknown recipe → ok=false, error='unknown_recipe', container untouched")
-- ================================================================
-- §11: craft with is_known=false → ok=false, error='unknown_recipe', container untouched
-- ================================================================
local container11 = setup_container{
{ template = "craft_test.rock", count = 1 },
{ template = "craft_test.stick", count = 1 },
}
crafting.define_recipe{
id = "rock_pick_r",
inputs = {
{ template = "craft_test.rock", count = 1 },
{ template = "craft_test.stick", count = 1 },
},
output = { template = "craft_test.rock_pick", count = 1 },
is_known = function(_) return false end,
}
local count_before_11 = inv.count(container11)
local res11 = crafting.craft("rock_pick_r", container11, {})
local count_after_11 = inv.count(container11)
T.assert(res11.ok == false and res11.error == "unknown_recipe"
and count_before_11 == count_after_11,
"11. craft with is_known=false → ok=false, error='unknown_recipe', container untouched")
-- ================================================================
-- §12: count-form: 2-of-3 reports correct missing
-- ================================================================
local container12 = setup_container{
{ template = "craft_test.rock", count = 2 },
}
crafting.define_recipe{
id = "rock_pile_r",
inputs = {
{ template = "craft_test.rock", count = 3 },
},
output = { template = "craft_test.rock_pick", count = 1 },
}
local cc12 = crafting.can_craft("rock_pile_r", container12, {})
local missing_match_12 = false
if cc12.missing then
for _, m in ipairs(cc12.missing) do
if m.template == "craft_test.rock"
and m.needed == 3 and m.have == 2 then
missing_match_12 = true; break
end
end
end
T.assert(cc12.ok == false and cc12.error == "missing_inputs" and missing_match_12,
"12. count-form: 2-of-3 rocks reports missing={template, needed=3, have=2}")
-- ================================================================
-- §13: Bw-Compat — craft(recipe, entity_handle, ctx) still works
-- with bare-entity-handle locale (Form 1).
-- ================================================================
local container13 = setup_container{
{ template = "craft_test.rock", count = 1 },
{ template = "craft_test.stick", count = 1 },
}
crafting.define_recipe{
id = "rock_pick_r",
inputs = {
{ template = "craft_test.rock", count = 1 },
{ template = "craft_test.stick", count = 1 },
},
output = { template = "craft_test.rock_pick", count = 1 },
}
local res13 = crafting.craft("rock_pick_r", container13, {})
local pick_count_13 = 0
local rock_count_13 = 0
local stick_count_13 = 0
for _, e in ipairs(inv.contents(container13)) do
local tpl = composition.template_of(e)
if tpl == "craft_test.rock_pick" then pick_count_13 = pick_count_13 + 1 end
if tpl == "craft_test.rock" then rock_count_13 = rock_count_13 + 1 end
if tpl == "craft_test.stick" then stick_count_13 = stick_count_13 + 1 end
end
T.assert(res13.ok == true
and res13.crafted_items ~= nil and #res13.crafted_items == 1
and res13.consumed ~= nil and #res13.consumed == 2
and pick_count_13 == 1 and rock_count_13 == 0 and stick_count_13 == 0,
"13. Bw-Compat: craft(recipe, entity_handle, ctx) accepts bare handle, post-state matches v0.1 behavior")
-- ================================================================
-- §14: Form-2 happy-path — 2 sources, sink = source[1].
-- c1=1 rock, c2=1 stick, recipe needs both,
-- locale={sources={c1,c2}, sink=c1}.
-- Post: c1 has 1 rock_pick + 0 rock, c2 has 0 stick.
-- ================================================================
local c1_14 = setup_container{
{ template = "craft_test.rock", count = 1 },
}
local c2_14 = extra_container{
{ template = "craft_test.stick", count = 1 },
}
crafting.define_recipe{
id = "rock_pick_r",
inputs = {
{ template = "craft_test.rock", count = 1 },
{ template = "craft_test.stick", count = 1 },
},
output = { template = "craft_test.rock_pick", count = 1 },
}
local res14 = crafting.craft("rock_pick_r",
{ sources = {c1_14, c2_14}, sink = c1_14 }, {})
local c1_pick_14, c1_rock_14, c1_stick_14 = 0, 0, 0
for _, e in ipairs(inv.contents(c1_14)) do
local tpl = composition.template_of(e)
if tpl == "craft_test.rock_pick" then c1_pick_14 = c1_pick_14 + 1 end
if tpl == "craft_test.rock" then c1_rock_14 = c1_rock_14 + 1 end
if tpl == "craft_test.stick" then c1_stick_14 = c1_stick_14 + 1 end
end
local c2_pick_14, c2_rock_14, c2_stick_14 = 0, 0, 0
for _, e in ipairs(inv.contents(c2_14)) do
local tpl = composition.template_of(e)
if tpl == "craft_test.rock_pick" then c2_pick_14 = c2_pick_14 + 1 end
if tpl == "craft_test.rock" then c2_rock_14 = c2_rock_14 + 1 end
if tpl == "craft_test.stick" then c2_stick_14 = c2_stick_14 + 1 end
end
T.assert(res14.ok == true
and c1_pick_14 == 1 and c1_rock_14 == 0 and c1_stick_14 == 0
and c2_pick_14 == 0 and c2_rock_14 == 0 and c2_stick_14 == 0,
"14. Form-2 happy-path: sources={c1,c2}, sink=c1; c1 ends with rock_pick + no rock, c2 ends empty")
-- ================================================================
-- §15: Greedy-drain-order — source1=2 rocks, source2=5 rocks,
-- recipe needs 3 rocks → source1 fully drained, source2 has 4 left.
-- ================================================================
local c1_15 = setup_container{
{ template = "craft_test.rock", count = 2 },
}
local c2_15 = extra_container{
{ template = "craft_test.rock", count = 5 },
}
crafting.define_recipe{
id = "rock_pile_r",
inputs = {
{ template = "craft_test.rock", count = 3 },
},
output = { template = "craft_test.rock_pick", count = 1 },
}
local res15 = crafting.craft("rock_pile_r",
{ sources = {c1_15, c2_15}, sink = c1_15 }, {})
local c1_rock_15 = 0
for _, e in ipairs(inv.contents(c1_15)) do
if composition.template_of(e) == "craft_test.rock" then
c1_rock_15 = c1_rock_15 + 1
end
end
local c2_rock_15 = 0
for _, e in ipairs(inv.contents(c2_15)) do
if composition.template_of(e) == "craft_test.rock" then
c2_rock_15 = c2_rock_15 + 1
end
end
T.assert(res15.ok == true
and c1_rock_15 == 0 and c2_rock_15 == 4,
"15. Greedy-drain-order: source1 (2 rocks) fully drained, source2 (5 rocks) has 4 remaining after needing 3")
-- ================================================================
-- §16: Aggregate missing-count — source1=1 rock, source2=1 rock,
-- recipe needs 3 → missing.have=2.
-- ================================================================
local c1_16 = setup_container{
{ template = "craft_test.rock", count = 1 },
}
local c2_16 = extra_container{
{ template = "craft_test.rock", count = 1 },
}
crafting.define_recipe{
id = "rock_pile_r",
inputs = {
{ template = "craft_test.rock", count = 3 },
},
output = { template = "craft_test.rock_pick", count = 1 },
}
local cc16 = crafting.can_craft("rock_pile_r",
{ sources = {c1_16, c2_16}, sink = c1_16 }, {})
local missing_match_16 = false
if cc16.missing then
for _, m in ipairs(cc16.missing) do
if m.template == "craft_test.rock"
and m.needed == 3 and m.have == 2 then
missing_match_16 = true; break
end
end
end
T.assert(cc16.ok == false and cc16.error == "missing_inputs"
and missing_match_16,
"16. Aggregate missing-count across sources: 1+1 rocks vs need 3 reports have=2")
-- ================================================================
-- §17: Loud-Error empty sources — locale={sources={}, sink=c1}
-- → error matches "crafting" + "sources" + "non-empty".
-- ================================================================
local c1_17 = setup_container{
{ template = "craft_test.rock", count = 1 },
}
crafting.define_recipe{
id = "rock_pile_r",
inputs = { { template = "craft_test.rock", count = 1 } },
output = { template = "craft_test.rock_pick", count = 1 },
}
local ok17, err17 = pcall(crafting.can_craft, "rock_pile_r",
{ sources = {}, sink = c1_17 }, {})
T.assert(not ok17 and type(err17) == "string"
and string.find(err17, "crafting") ~= nil
and string.find(err17, "sources") ~= nil
and (string.find(err17, "non%-empty") ~= nil
or string.find(err17, "non empty") ~= nil),
"17. Loud-Error: locale.sources={} → error mentions 'crafting', 'sources', 'non-empty'")
-- ================================================================
-- §18: Loud-Error nil sink — locale={sources={c1}, sink=nil}
-- → error matches "crafting" + "sink" + "nil".
-- ================================================================
local c1_18 = setup_container{
{ template = "craft_test.rock", count = 1 },
}
crafting.define_recipe{
id = "rock_pile_r",
inputs = { { template = "craft_test.rock", count = 1 } },
output = { template = "craft_test.rock_pick", count = 1 },
}
local ok18, err18 = pcall(crafting.can_craft, "rock_pile_r",
{ sources = {c1_18}, sink = nil }, {})
T.assert(not ok18 and type(err18) == "string"
and string.find(err18, "crafting") ~= nil
and string.find(err18, "sink") ~= nil
and string.find(err18, "nil") ~= nil,
"18. Loud-Error: locale.sink=nil → error mentions 'crafting', 'sink', 'nil'")
-- ================================================================
-- §19: Duplicate sources are de-duplicated.
-- Single container with 1 rock; pass {c, c} as sources.
-- count_by_template must NOT double-count → can_craft for a
-- recipe needing 2 rocks reports have=1 (not 2).
-- ================================================================
local c_19 = setup_container{
{ template = "craft_test.rock", count = 1 },
}
composition.define_template{
id = "craft_test.rock_pile_19",
properties = {
stack_mode = "individual",
sprite_path = "sprites/pile.png",
position = {x = 0, y = 0},
},
tags = {"renderable", "item"},
}
crafting.define_recipe{
id = "r2rocks_19",
inputs = { { template = "craft_test.rock", count = 2 } },
output = { template = "craft_test.rock_pile_19", count = 1 },
}
local cc19 = crafting.can_craft("r2rocks_19",
{ sources = {c_19, c_19}, sink = c_19 }, {})
local missing_match_19 = false
if cc19.missing then
for _, m in ipairs(cc19.missing) do
if m.template == "craft_test.rock"
and m.needed == 2 and m.have == 1 then
missing_match_19 = true; break
end
end
end
T.assert(cc19.ok == false and cc19.error == "missing_inputs"
and missing_match_19,
"19. Duplicate sources de-duplicated: sources={c,c} with 1 rock reports have=1 (not 2)")
-- ================================================================
-- §20: Loud-Error — Form-2 table without sources field.
-- Caller typo passes {sink=c} without sources. resolve_locale
-- must loud-error attributing crafting + sources, not silently
-- fall through to the Form-1 bw-compat shim (which would then
-- crash deep inside inventory-list).
-- ================================================================
local c_20 = setup_container{}
composition.define_template{
id = "craft_test.x_20",
properties = {
stack_mode = "individual",
sprite_path = "sprites/x.png",
position = {x = 0, y = 0},
},
tags = {"renderable", "item"},
}
crafting.define_recipe{
id = "rp_20",
inputs = { { template = "craft_test.rock", count = 1 } },
output = { template = "craft_test.x_20", count = 1 },
}
local malformed_20 = { sink = c_20 }
local ok20, err20 = pcall(crafting.craft, "rp_20", malformed_20, {})
T.assert(not ok20 and type(err20) == "string"
and string.find(err20, "crafting") ~= nil
and string.find(err20, "sources") ~= nil,
"20. Loud-Error: locale table without 'sources' field → error mentions 'crafting' and 'sources'")
end
return M