Adds an integration test file that drives PipelineEngine end-to-end through realistic kind chains the canvas-level conflict scanner targets. Each test sets up a module list as plain dict snapshots (PipelineEngine accepts these via coerce_legacy_module for non-fixed_values kinds) and asserts both the resolved user-facing ctx and the relevant warning shape. Coverage: - combine reads upstream sibling writes and emits ONLY its output_var (no input-var leakage into ctx). - combine template referencing an unbound $var emits a runtime `unknown_var` warning while the missing token still collapses silently to empty (lenient surface). - derivation condition.var read against an upstream-bound var fires the matching branch. - derivation else fires when condition.var is missing — _ctx_get returns "", any equals comparison vs. a non-empty value fails, and the else clause writes its target. Confirms the static `missing_template_variable` warning is informative, not blocking. - derivation action.value template with an unbound $var warns AND collapses to empty under the derivation surface. - mixed-kind chain (fixed_values → combine → derivation) produces the expected final ctx, in-order trace, and zero spurious warnings when every $var is satisfied. - disabled module is skipped — no writes, no warnings.
305 lines
11 KiB
Python
305 lines
11 KiB
Python
"""Sequential-chain simulation — fixed_values + wildcard + combine + derivation.
|
|
|
|
Goal: exercise the canvas-level scenarios the static-analysis warnings target.
|
|
Each test runs a full PipelineEngine pass against a synthesised module list
|
|
and asserts the resolved ctx + warning shape match what a user would get
|
|
from the corresponding canvas configuration.
|
|
|
|
Covers the post-5.5.6 fix paths:
|
|
- combine emits ONLY its output_var (no auto-binding of input vars)
|
|
- combine emits an `unknown_var` warning when its template references
|
|
a var nothing upstream provided
|
|
- derivation condition.var read against a missing upstream var still
|
|
evaluates safely (empty string), branch never matches → else fires
|
|
- derivation action.value `$var` substitution against a known var
|
|
- last-write-wins ordering across mixed kinds
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from engine.context import strip_internals
|
|
from engine.pipeline import PipelineEngine
|
|
|
|
|
|
def _run(raw_modules: list[dict], seed: int = 0):
|
|
"""Invoke PipelineEngine directly against plain dict snapshots.
|
|
|
|
`module_from_dict` only deserialises fixed_values into a dataclass;
|
|
PipelineEngine's loop accepts dicts via `coerce_legacy_module` for
|
|
every other kind. Passing dicts uniformly keeps the test fixtures
|
|
declarative without per-kind helpers."""
|
|
return PipelineEngine().run(raw_modules, seed=seed)
|
|
|
|
|
|
# ── 1. combine reads upstream sibling writes (the canonical happy path) ─
|
|
|
|
def test_combine_consumes_upstream_sibling_writes():
|
|
"""fixed_values → combine. Combine reads $subject + $style from siblings,
|
|
emits ONLY $caption. Engine never adds $subject/$style as combine writes."""
|
|
ctx = _run([
|
|
{
|
|
"id": "fv01",
|
|
"type": "fixed_values",
|
|
"entries": [
|
|
{"variable_name": "subject", "value": "knight"},
|
|
{"variable_name": "style", "value": "photoreal"},
|
|
],
|
|
},
|
|
{
|
|
"id": "cb01",
|
|
"type": "combine",
|
|
"payload": {
|
|
"template": "A $style painting of a $subject",
|
|
"output_var": "caption",
|
|
},
|
|
},
|
|
])
|
|
|
|
user = strip_internals(ctx)
|
|
assert user == {
|
|
"subject": "knight",
|
|
"style": "photoreal",
|
|
"caption": "A photoreal painting of a knight",
|
|
}
|
|
# Combine must NOT have leaked input names as its own writes —
|
|
# only `caption` is in the trace's combine writes. Trace entries
|
|
# carry full dicts (variable / value / source / overwrite); just
|
|
# project to variable names for the assertion.
|
|
combine_trace = next(t for t in ctx["__wp_trace__"] if t["id"] == "cb01")
|
|
written_names = [w["variable"] for w in combine_trace["writes"]]
|
|
assert written_names == ["caption"]
|
|
|
|
|
|
# ── 2. combine references a missing var → `$missing` collapses to empty + warns ─
|
|
|
|
def test_combine_missing_var_emits_unknown_var_warning():
|
|
"""Phase 5.5.6 added a runtime `unknown_var` warning so the user gets a
|
|
signal when a combine template references a var nothing upstream
|
|
provides. The missing var still collapses silently to empty in the
|
|
output string (lenient surface), but the warning is now visible."""
|
|
ctx = _run([
|
|
{
|
|
"id": "fv01",
|
|
"type": "fixed_values",
|
|
"entries": [{"variable_name": "subject", "value": "knight"}],
|
|
},
|
|
{
|
|
"id": "cb01",
|
|
"type": "combine",
|
|
"payload": {
|
|
"template": "$subject in $place", # $place never bound
|
|
"output_var": "out",
|
|
},
|
|
},
|
|
])
|
|
|
|
user = strip_internals(ctx)
|
|
assert user["out"] == "knight in " # missing var → empty
|
|
|
|
unknown = [w for w in ctx["__wp_warnings__"] if w["type"] == "unknown_var"]
|
|
assert len(unknown) == 1
|
|
assert unknown[0]["detail"]["name"] == "place"
|
|
assert unknown[0]["detail"]["surface"] == "combine"
|
|
|
|
|
|
# ── 3. derivation condition + action.value templating ─
|
|
|
|
def test_derivation_condition_matches_then_template_resolves():
|
|
"""Derivation reads $age via condition, fires the matching branch, and
|
|
resolves $name inside its action.value template. Validates both:
|
|
(a) condition.var read works against an upstream-provided var, and
|
|
(b) action.value passes through resolve_text under the `derivation`
|
|
surface so $var tokens substitute."""
|
|
ctx = _run([
|
|
{
|
|
"id": "fv01",
|
|
"type": "fixed_values",
|
|
"entries": [
|
|
{"variable_name": "age", "value": "30"},
|
|
{"variable_name": "name", "value": "Alice"},
|
|
],
|
|
},
|
|
{
|
|
"id": "dv01",
|
|
"type": "derivation",
|
|
"payload": {
|
|
"rules": [{
|
|
"id": "r0",
|
|
"branches": [{
|
|
"condition": {"var": "age", "op": "equals", "value": "30"},
|
|
"action": {
|
|
"target_var": "greeting",
|
|
"mode": "replace",
|
|
"value": "hello $name",
|
|
},
|
|
}],
|
|
"else": {"action": {
|
|
"target_var": "greeting",
|
|
"mode": "replace",
|
|
"value": "hi stranger",
|
|
}},
|
|
}],
|
|
},
|
|
},
|
|
])
|
|
|
|
user = strip_internals(ctx)
|
|
assert user["greeting"] == "hello Alice"
|
|
|
|
|
|
def test_derivation_else_fires_when_condition_var_missing():
|
|
"""If condition.var isn't bound upstream, runtime _ctx_get returns "",
|
|
so any equals comparison against a non-empty value fails → else fires.
|
|
Demonstrates that the static `missing_template_variable` warning is
|
|
informative, not blocking — engine still produces output."""
|
|
ctx = _run([
|
|
# No upstream $age — condition will silently miss.
|
|
{
|
|
"id": "dv01",
|
|
"type": "derivation",
|
|
"payload": {
|
|
"rules": [{
|
|
"id": "r0",
|
|
"branches": [{
|
|
"condition": {"var": "age", "op": "equals", "value": "30"},
|
|
"action": {
|
|
"target_var": "mood",
|
|
"mode": "replace",
|
|
"value": "calm",
|
|
},
|
|
}],
|
|
"else": {"action": {
|
|
"target_var": "mood",
|
|
"mode": "replace",
|
|
"value": "fallback",
|
|
}},
|
|
}],
|
|
},
|
|
},
|
|
])
|
|
|
|
user = strip_internals(ctx)
|
|
assert user["mood"] == "fallback"
|
|
|
|
|
|
def test_derivation_action_value_template_with_missing_var_warns():
|
|
"""action.value goes through resolve_text under derivation surface,
|
|
so an unbound $var inside it triggers an `unknown_var` warning AND
|
|
collapses to empty in the output — same lenient behaviour as combine."""
|
|
ctx = _run([
|
|
{
|
|
"id": "dv01",
|
|
"type": "derivation",
|
|
"payload": {
|
|
"rules": [{
|
|
"id": "r0",
|
|
"branches": [{
|
|
"condition": {"var": "age", "op": "not_equals", "value": "ignored"},
|
|
"action": {
|
|
"target_var": "tag",
|
|
"mode": "replace",
|
|
"value": "uses $unbound",
|
|
},
|
|
}],
|
|
}],
|
|
},
|
|
},
|
|
])
|
|
|
|
user = strip_internals(ctx)
|
|
# `_ctx_get(ctx, "age")` → "" → `"" != "ignored"` → True → branch fires.
|
|
assert user["tag"] == "uses "
|
|
|
|
unknown = [w for w in ctx["__wp_warnings__"] if w["type"] == "unknown_var"]
|
|
assert any(w["detail"]["name"] == "unbound" for w in unknown)
|
|
assert all(w["detail"]["surface"] == "derivation" for w in unknown)
|
|
|
|
|
|
# ── 4. mixed-kind chain: fixed_values + combine + derivation pipeline ─
|
|
|
|
def test_full_chain_fixed_values_combine_derivation():
|
|
"""End-to-end: fixed_values seeds vars → combine builds a phrase →
|
|
derivation rewrites a final caption. Mirrors a realistic canvas config
|
|
where each kind contributes a distinct slice of the pipeline."""
|
|
ctx = _run([
|
|
{
|
|
"id": "fv01",
|
|
"type": "fixed_values",
|
|
"entries": [
|
|
{"variable_name": "subject", "value": "knight"},
|
|
{"variable_name": "tone", "value": "noir"},
|
|
{"variable_name": "mood", "value": "stoic"},
|
|
],
|
|
},
|
|
{
|
|
"id": "cb01",
|
|
"type": "combine",
|
|
"payload": {
|
|
"template": "$tone $subject",
|
|
"output_var": "phrase",
|
|
},
|
|
},
|
|
{
|
|
"id": "dv01",
|
|
"type": "derivation",
|
|
"payload": {
|
|
"rules": [{
|
|
"id": "r0",
|
|
"branches": [{
|
|
"condition": {"var": "mood", "op": "equals", "value": "stoic"},
|
|
"action": {
|
|
"target_var": "caption",
|
|
"mode": "replace",
|
|
"value": "[$phrase, calm]",
|
|
},
|
|
}],
|
|
"else": {"action": {
|
|
"target_var": "caption",
|
|
"mode": "replace",
|
|
"value": "[$phrase, ???]",
|
|
}},
|
|
}],
|
|
},
|
|
},
|
|
])
|
|
|
|
user = strip_internals(ctx)
|
|
assert user["phrase"] == "noir knight"
|
|
assert user["caption"] == "[noir knight, calm]"
|
|
|
|
# Trace records all three modules in order, no errors.
|
|
trace_ids = [t["id"] for t in ctx["__wp_trace__"] if t.get("status") != "skipped_disabled"]
|
|
assert trace_ids == ["fv01", "cb01", "dv01"]
|
|
assert all(t.get("error") is None for t in ctx["__wp_trace__"])
|
|
|
|
# No spurious unknown_var warnings — every $var is bound by the
|
|
# time it's read.
|
|
unknown = [w for w in ctx["__wp_warnings__"] if w["type"] == "unknown_var"]
|
|
assert unknown == []
|
|
|
|
|
|
def test_disabled_module_skipped_in_chain():
|
|
"""Disabled modules must not contribute writes OR warnings — the
|
|
static scanner respects this too (combine.test asserts the
|
|
parallel UI behaviour)."""
|
|
ctx = _run([
|
|
{
|
|
"id": "fv01",
|
|
"type": "fixed_values",
|
|
"entries": [{"variable_name": "subject", "value": "knight"}],
|
|
},
|
|
{
|
|
"id": "cb01",
|
|
"type": "combine",
|
|
"enabled": False, # ← disabled
|
|
"payload": {
|
|
"template": "$subject and $missing",
|
|
"output_var": "out",
|
|
},
|
|
},
|
|
])
|
|
|
|
user = strip_internals(ctx)
|
|
assert "out" not in user # combine never ran
|
|
unknown = [w for w in ctx["__wp_warnings__"] if w["type"] == "unknown_var"]
|
|
assert unknown == [] # no warning for disabled
|