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>
340 lines
14 KiB
Lua
340 lines
14 KiB
Lua
-- 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
|