feat: lib-core.crafting-test v0.1.0 — 12 TAP asserts

Test suite for lib-core.crafting v0.1.0. Covers:

  1. define_recipe + list_recipes round-trip; shallow-copy isolation
     (mutating the returned list does not affect the registry).
  2. define_recipe missing id → Loud-Error.
  3. define_recipe duplicate id → Loud-Error.
  4. define_recipe empty inputs → Loud-Error.
  5. get_recipe known/unknown.
  6. is_known propagates ctx-identity to recipe.is_known(ctx).
  7. can_craft happy-path → ok=true.
  8. can_craft missing inputs → error='missing_inputs' +
     missing-array with {template, needed, have}.
  9. craft happy-path: consumes 2, crafts 1, container has only output.
 10. craft unknown recipe → ok=false, error='unknown_recipe',
     container untouched.
 11. craft is_known=false → ok=false, error='unknown_recipe',
     container untouched.
 12. count-form: 2-of-3 reports missing={template, needed=3, have=2}.

Uses engine.test.assert with string.find for pattern matching
loud-error messages (engine.test has no assert_match helper).
Per-test isolation via crafting._test_clear_all; composition templates
are defined once at module-scope (composition has no clear-all hook).

Depends on lib-core.crafting 0.1.0, lib-core.inventory-list 0.1.0,
lib-core.composition 0.3.0 (all three direct, per non-transitive
engine resolver).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Calic
2026-06-14 12:20:53 +02:00
commit a9bfca1ffe
5 changed files with 391 additions and 0 deletions

339
init.lua Normal file
View File

@@ -0,0 +1,339 @@
-- 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
-- ---------------------------------------------------------------------
-- 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}")
end
return M