mirror of
https://github.com/gorules/zen.git
synced 2026-10-04 08:02:28 +00:00
* fix: scope policy evaluation to the import closure A policy is now analysed and evaluated together with the policies it imports (transitively), instead of every policy connected to it by imports in either direction. Importers and siblings no longer leak into each other: compiled evaluate() of an imported policy used to run its importers' and siblings' blocks. - Evaluation gate analyses the entry's import closure as one unit, so a child may read fields and reference entities its importer declares when evaluated through that importer - compile() reports a policy that is only valid through an importer with kind "policyImportOnly" instead of failing it as a plain "policy"; errors are computed once per key - Scope diagnostics (duplicate writers, input overrides, data model and dictionary collisions, cycles) are reported for the whole closure, and conflicts introduced by combining imports are reported on the importer - Single-entity scope checks run per import closure instead of workspace-wide * fix: unknown import relationship targets and renames across imports - An unresolved relationship/reference target inside an imported policy now blocks the importer's evaluation and compile(), as on master; the check was still limited to the policy being analysed, so the importer compiled and the broken child was reported as "policyImportOnly" - Rename and find-references analyse each policy's expressions in every importer's context too, so reads of a field declared by an importer are found in the imported policy again
439 lines
19 KiB
TOML
439 lines
19 KiB
TOML
# Policy evaluation tests
|
|
# Each test loads policies, compiles, provides input, and checks output + trace
|
|
#
|
|
# policies: list of fixture JSON filenames to load into the workspace
|
|
# input: JSON object matching the data model (the initial property store)
|
|
# output: JSON object of expected written properties (partial match)
|
|
#
|
|
# Trace expectations (optional):
|
|
# trace.blocks.<block_id>.kind = "assertion" | "decisionTable" | "expression" | "match"
|
|
# trace.blocks.<block_id>.result = true/false (assertion)
|
|
# trace.blocks.<block_id>.conditions = [{id, result}] (assertion)
|
|
# trace.blocks.<block_id>.matched_rows = [0, 1, ...] (decision table)
|
|
# trace.blocks.<block_id>.property = "customer.x" (expression — the write key)
|
|
# trace.blocks.<block_id>.value = <json> (expression / match — the written value)
|
|
# trace.blocks.<block_id>.matchedArm = "armId" (match — id of the first matching arm, omitted if none)
|
|
# trace.blocks.<block_id>.arms = [{id, result}] (match — arm condition results)
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# Assertion tests
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
[[test]]
|
|
name = "assertion — eligible customer (both conditions true)"
|
|
policies = ["assertion_policy.json"]
|
|
input = { customer = { name = "Alice", age = 25, country = "US", companies = [], creditReport = { score = 750, delinquencies = 0, totalDebt = 1000 } } }
|
|
output = { customer = { isEligible = true } }
|
|
|
|
[test.trace.blocks.assert1]
|
|
kind = "assertion"
|
|
result = true
|
|
conditions = [
|
|
{ id = "c1", result = true },
|
|
{ id = "c2", result = true },
|
|
]
|
|
|
|
[[test]]
|
|
name = "assertion — underage customer fails"
|
|
policies = ["assertion_policy.json"]
|
|
input = { customer = { name = "Bob", age = 16, country = "US", companies = [], creditReport = { score = 750, delinquencies = 0, totalDebt = 0 } } }
|
|
output = { customer = { isEligible = false } }
|
|
|
|
[test.trace.blocks.assert1]
|
|
kind = "assertion"
|
|
result = false
|
|
conditions = [
|
|
{ id = "c1", result = false },
|
|
{ id = "c2", result = true },
|
|
]
|
|
|
|
[[test]]
|
|
name = "assertion — low credit score fails"
|
|
policies = ["assertion_policy.json"]
|
|
input = { customer = { name = "Carol", age = 30, country = "UK", companies = [], creditReport = { score = 500, delinquencies = 2, totalDebt = 5000 } } }
|
|
output = { customer = { isEligible = false } }
|
|
|
|
[test.trace.blocks.assert1]
|
|
kind = "assertion"
|
|
result = false
|
|
conditions = [
|
|
{ id = "c1", result = true },
|
|
{ id = "c2", result = false },
|
|
]
|
|
|
|
[[test]]
|
|
name = "assertion — both conditions fail"
|
|
policies = ["assertion_policy.json"]
|
|
input = { customer = { name = "Dave", age = 15, country = "DE", companies = [], creditReport = { score = 400, delinquencies = 5, totalDebt = 10000 } } }
|
|
output = { customer = { isEligible = false } }
|
|
|
|
[test.trace.blocks.assert1]
|
|
kind = "assertion"
|
|
result = false
|
|
conditions = [
|
|
{ id = "c1", result = false },
|
|
{ id = "c2", result = false },
|
|
]
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# Expression + match + decision table pipeline (analysis.json)
|
|
# ds1 (expression): computes totalRevenue
|
|
# f1 (match): computes creditTier (arm db1 condition, arm db2 default)
|
|
# dt1 (table): uses creditTier + totalRevenue to compute discount
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
[[test]]
|
|
name = "analysis — excellent tier, high revenue → 20% discount"
|
|
policies = ["analysis.json"]
|
|
input = { customer = { name = "Alice", age = 35, country = "US", companies = [{ id = "c1", name = "ACME", iban = "DE123", revenue = 300000 }, { id = "c2", name = "Globex", iban = "DE456", revenue = 250000 }], creditReport = { score = 800, delinquencies = 0, totalDebt = 5000 } } }
|
|
output = { customer = { totalRevenue = 550000, creditTier = "excellent", discount = 0.2 } }
|
|
|
|
[test.trace.blocks.ds1]
|
|
kind = "expression"
|
|
property = "customer.totalRevenue"
|
|
value = 550000
|
|
|
|
[test.trace.blocks.f1]
|
|
kind = "match"
|
|
matchedArm = "db1"
|
|
value = "excellent"
|
|
arms = [
|
|
{ id = "db1", result = true },
|
|
]
|
|
|
|
[test.trace.blocks.dt1]
|
|
kind = "decisionTable"
|
|
matched_rows = [0]
|
|
|
|
[[test]]
|
|
name = "analysis — excellent tier, low revenue → 15% discount"
|
|
policies = ["analysis.json"]
|
|
input = { customer = { name = "Bob", age = 40, country = "UK", companies = [{ id = "c1", name = "SmallCo", iban = "GB123", revenue = 50000 }], creditReport = { score = 760, delinquencies = 0, totalDebt = 2000 } } }
|
|
output = { customer = { totalRevenue = 50000, creditTier = "excellent", discount = 0.15 } }
|
|
|
|
[test.trace.blocks.ds1]
|
|
kind = "expression"
|
|
property = "customer.totalRevenue"
|
|
value = 50000
|
|
|
|
[test.trace.blocks.f1]
|
|
kind = "match"
|
|
matchedArm = "db1"
|
|
value = "excellent"
|
|
arms = [
|
|
{ id = "db1", result = true },
|
|
]
|
|
|
|
[test.trace.blocks.dt1]
|
|
kind = "decisionTable"
|
|
matched_rows = [1]
|
|
|
|
[[test]]
|
|
name = "analysis — good tier, any revenue → 5% discount"
|
|
policies = ["analysis.json"]
|
|
input = { customer = { name = "Carol", age = 28, country = "DE", companies = [{ id = "c1", name = "MidCo", iban = "DE789", revenue = 100000 }], creditReport = { score = 650, delinquencies = 1, totalDebt = 15000 } } }
|
|
output = { customer = { totalRevenue = 100000, creditTier = "good", discount = 0.05 } }
|
|
|
|
[test.trace.blocks.ds1]
|
|
kind = "expression"
|
|
property = "customer.totalRevenue"
|
|
value = 100000
|
|
|
|
[test.trace.blocks.f1]
|
|
kind = "match"
|
|
matchedArm = "db2"
|
|
value = "good"
|
|
arms = [
|
|
{ id = "db1", result = false },
|
|
{ id = "db2", result = true },
|
|
]
|
|
|
|
[test.trace.blocks.dt1]
|
|
kind = "decisionTable"
|
|
matched_rows = [2]
|
|
|
|
[[test]]
|
|
name = "analysis — good tier, no companies → 5% discount"
|
|
policies = ["analysis.json"]
|
|
input = { customer = { name = "Dave", age = 50, country = "FR", companies = [], creditReport = { score = 700, delinquencies = 0, totalDebt = 0 } } }
|
|
output = { customer = { totalRevenue = 0, creditTier = "good", discount = 0.05 } }
|
|
|
|
[test.trace.blocks.ds1]
|
|
kind = "expression"
|
|
property = "customer.totalRevenue"
|
|
value = 0
|
|
|
|
[test.trace.blocks.f1]
|
|
kind = "match"
|
|
matchedArm = "db2"
|
|
value = "good"
|
|
arms = [
|
|
{ id = "db1", result = false },
|
|
{ id = "db2", result = true },
|
|
]
|
|
|
|
[test.trace.blocks.dt1]
|
|
kind = "decisionTable"
|
|
matched_rows = [2]
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# Decision table with collect hit policy
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
[[test]]
|
|
name = "collect table — senior gets senior + adult + minor tags"
|
|
policies = ["collect_table.json"]
|
|
input = { customer = { name = "Edna", age = 70, country = "US", companies = [] } }
|
|
output = { customer = { tags = ["senior", "adult", "minor"] } }
|
|
|
|
[test.trace.blocks.dt1]
|
|
kind = "decisionTable"
|
|
matched_rows = [0, 1, 2]
|
|
|
|
[[test]]
|
|
name = "collect table — adult gets adult + minor tags"
|
|
policies = ["collect_table.json"]
|
|
input = { customer = { name = "Frank", age = 30, country = "US", companies = [] } }
|
|
output = { customer = { tags = ["adult", "minor"] } }
|
|
|
|
[test.trace.blocks.dt1]
|
|
kind = "decisionTable"
|
|
matched_rows = [1, 2]
|
|
|
|
[[test]]
|
|
name = "collect table — child gets only minor tag"
|
|
policies = ["collect_table.json"]
|
|
input = { customer = { name = "Grace", age = 10, country = "US", companies = [] } }
|
|
output = { customer = { tags = ["minor"] } }
|
|
|
|
[test.trace.blocks.dt1]
|
|
kind = "decisionTable"
|
|
matched_rows = [2]
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# Decision table — multi-entity writes (multi-scope block → decisionTable)
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
[[test]]
|
|
name = "multi-entity table — writes to customer and company"
|
|
policies = ["multi_entity_block.json"]
|
|
input = { customer = { name = "Hank", age = 40, country = "NL", companies = [], creditReport = { score = 700, delinquencies = 0, totalDebt = 0 } }, company = { id = "c1", name = "TechCo", iban = "NL001", revenue = 500000 } }
|
|
output = { customer = { status = "active" }, company = { status = "verified" } }
|
|
|
|
[test.trace.blocks.tree1]
|
|
kind = "decisionTable"
|
|
matched_rows = [0]
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# Expression — simple statement, per instance (company_block.json)
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
[[test]]
|
|
name = "company block — computes profit margin per instance"
|
|
policies = ["company_block.json"]
|
|
input = { customer = { name = "Ivy", age = 30, country = "US", creditReport = { score = 700, delinquencies = 0, totalDebt = 0 }, companies = [{ id = "c1", name = "BigCo", iban = "US123", revenue = 1000000 }, { id = "c2", name = "Smol", iban = "US456", revenue = 50000 }] } }
|
|
output = { customer = { companies = [{ profitMargin = 100000 }, { profitMargin = 5000 }] } }
|
|
|
|
# Block s1 runs once per company instance; value differs per instance
|
|
# (100000, then 5000), so only the write property is asserted here.
|
|
[test.trace.blocks.s1]
|
|
kind = "expression"
|
|
property = "company.profitMargin"
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# Multi-policy — independent blocks from imported policy both execute
|
|
# merge_policy_a imports merge_policy_b. Both write to customer.
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
[[test]]
|
|
name = "multi-policy import — both policies write to customer"
|
|
policies = ["merge_policy_a.json", "merge_policy_b.json"]
|
|
input = { customer = { name = "Alice", age = 30, country = "France" } }
|
|
output = { customer = { greeting = "Alice", label = "France" } }
|
|
|
|
# Note: both policies have block id "s1" — trace shows the last executed one,
|
|
# so the asserted property/value is order-dependent and left unspecified.
|
|
# Output check verifies both actually ran.
|
|
[test.trace.blocks.s1]
|
|
kind = "expression"
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# Multi-policy — cross-policy dependency
|
|
# cross_dep_base: assertion writes customer.isEligible
|
|
# cross_dep_consumer: imports base, table reads isEligible to compute tier
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
[[test]]
|
|
name = "cross-policy — eligible high-income → platinum"
|
|
policies = ["cross_dep_consumer.json", "cross_dep_base.json"]
|
|
input = { customer = { name = "Alice", age = 30, income = 120000 } }
|
|
output = { customer = { isEligible = true, tier = "platinum" } }
|
|
|
|
[test.trace.blocks.assert1]
|
|
kind = "assertion"
|
|
result = true
|
|
|
|
[test.trace.blocks.dt1]
|
|
kind = "decisionTable"
|
|
matched_rows = [0]
|
|
|
|
[[test]]
|
|
name = "cross-policy — eligible mid-income → gold"
|
|
policies = ["cross_dep_consumer.json", "cross_dep_base.json"]
|
|
input = { customer = { name = "Bob", age = 25, income = 60000 } }
|
|
output = { customer = { isEligible = true, tier = "gold" } }
|
|
|
|
[test.trace.blocks.assert1]
|
|
kind = "assertion"
|
|
result = true
|
|
|
|
[test.trace.blocks.dt1]
|
|
kind = "decisionTable"
|
|
matched_rows = [1]
|
|
|
|
[[test]]
|
|
name = "cross-policy — eligible low-income → silver"
|
|
policies = ["cross_dep_consumer.json", "cross_dep_base.json"]
|
|
input = { customer = { name = "Carol", age = 40, income = 35000 } }
|
|
output = { customer = { isEligible = true, tier = "silver" } }
|
|
|
|
[test.trace.blocks.dt1]
|
|
kind = "decisionTable"
|
|
matched_rows = [2]
|
|
|
|
[[test]]
|
|
name = "cross-policy — ineligible (underage) → rejected"
|
|
policies = ["cross_dep_consumer.json", "cross_dep_base.json"]
|
|
input = { customer = { name = "Dave", age = 16, income = 50000 } }
|
|
output = { customer = { isEligible = false, tier = "rejected" } }
|
|
|
|
[test.trace.blocks.assert1]
|
|
kind = "assertion"
|
|
result = false
|
|
conditions = [
|
|
{ id = "c1", result = false },
|
|
{ id = "c2", result = true },
|
|
]
|
|
|
|
[test.trace.blocks.dt1]
|
|
kind = "decisionTable"
|
|
matched_rows = [3]
|
|
|
|
[[test]]
|
|
name = "cross-policy — ineligible (low income) → rejected"
|
|
policies = ["cross_dep_consumer.json", "cross_dep_base.json"]
|
|
input = { customer = { name = "Eve", age = 25, income = 20000 } }
|
|
output = { customer = { isEligible = false, tier = "rejected" } }
|
|
|
|
[test.trace.blocks.assert1]
|
|
kind = "assertion"
|
|
result = false
|
|
|
|
[test.trace.blocks.dt1]
|
|
kind = "decisionTable"
|
|
matched_rows = [3]
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# Per-instance execution — block scoped to a relationship-array entity runs
|
|
# once per item; reads/writes resolve against the current instance.
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
[[test]]
|
|
name = "per-instance — company-risk runs per company"
|
|
policies = ["per_instance.json"]
|
|
input = { customer = { name = "Alice", companies = [{ name = "Acme", revenue = 300000 }, { name = "Smol", revenue = 50000 }] } }
|
|
output = { customer = { companies = [{ riskLevel = "low" }, { riskLevel = "high" }] } }
|
|
|
|
[[test]]
|
|
name = "inverse relationship — company reads company.customer.age"
|
|
policies = ["inverse_relationship.json"]
|
|
input = { customer = { name = "Alice", age = 42, companies = [{ name = "Acme" }, { name = "Smol" }] } }
|
|
output = { customer = { companies = [{ ownerAge = 42 }, { ownerAge = 42 }] } }
|
|
|
|
[[test]]
|
|
name = "reference iteration — customer.companies ids hydrated from top-level company[]"
|
|
policies = ["reference_iteration.json"]
|
|
input = { customer = { name = "Alice", companies = ["c1", "c2"] }, company = [{ id = "c1", revenue = 300000 }, { id = "c2", revenue = 50000 }] }
|
|
output = { customer = { totalRevenue = 350000 }, company = [{ id = "c1", riskLevel = "low" }, { id = "c2", riskLevel = "high" }] }
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# Scope enrichment — within a single block, later statements read earlier values
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
[[test]]
|
|
name = "scope enrichment — s2 reads computed value from s1"
|
|
policies = ["scope_enrichment.json"]
|
|
input = { customer = { name = "Alice", age = 45 } }
|
|
output = { customer = { ageGroup = true, label = true } }
|
|
|
|
[test.trace.blocks.s1]
|
|
kind = "expression"
|
|
property = "customer.ageGroup"
|
|
value = true
|
|
|
|
[test.trace.blocks.s2]
|
|
kind = "expression"
|
|
property = "customer.label"
|
|
value = true
|
|
|
|
[[test]]
|
|
name = "scope enrichment — young customer"
|
|
policies = ["scope_enrichment.json"]
|
|
input = { customer = { name = "Bob", age = 20 } }
|
|
output = { customer = { ageGroup = false, label = false } }
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# Decision table — no matching rows (strict table, no catch-all)
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
[[test]]
|
|
name = "strict table — US large order, free shipping"
|
|
policies = ["strict_table.json"]
|
|
input = { order = { amount = 200, region = "US" } }
|
|
output = { order = { shippingCost = 0 } }
|
|
|
|
[test.trace.blocks.dt1]
|
|
kind = "decisionTable"
|
|
matched_rows = [0]
|
|
|
|
[[test]]
|
|
name = "strict table — EU small order"
|
|
policies = ["strict_table.json"]
|
|
input = { order = { amount = 50, region = "EU" } }
|
|
output = { order = { shippingCost = 15 } }
|
|
|
|
[test.trace.blocks.dt1]
|
|
kind = "decisionTable"
|
|
matched_rows = [3]
|
|
|
|
# TOML has no null literal — `nulls` lists paths that must be explicitly null.
|
|
[[test]]
|
|
name = "strict table — unknown region, no rows match writes null"
|
|
policies = ["strict_table.json"]
|
|
input = { order = { amount = 50, region = "JP" } }
|
|
output = {}
|
|
nulls = ["order.shippingCost"]
|
|
|
|
[test.trace.blocks.dt1]
|
|
kind = "decisionTable"
|
|
matched_rows = []
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# Multi-policy — siblings importing the same policy evaluate in isolation
|
|
# merge_policy_a and merge_policy_c both import merge_policy_b and both write
|
|
# customer.greeting from different sources
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
[[test]]
|
|
name = "sibling A writes greeting from its own data"
|
|
policies = ["merge_policy_a.json", "merge_policy_b.json", "merge_policy_c.json"]
|
|
input = { customer = { name = "Ann", age = 30, country = "NL" } }
|
|
output = { customer = { greeting = "Ann", label = "NL" } }
|
|
|
|
[[test]]
|
|
name = "sibling C writes greeting from the shared import"
|
|
policies = ["merge_policy_c.json", "merge_policy_b.json", "merge_policy_a.json"]
|
|
input = { customer = { name = "Ann", country = "NL", segment = "retail" } }
|
|
output = { customer = { greeting = "NL", label = "NL", tag = "retail" } }
|
|
|