-- lib-core.crafting-test — Phase C.1 -- 12 assertions: 5 registry (define_recipe + list + get) + 7 discovery/match/craft. -- 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'") end return M