- strict-mode matrix: missing variables (top-level, in {{#each}},
in {{#if}} then and else branches) fail typed through both
sinks, never silent; the write sink's no-atomicity contract
pinned by its row
- error-surface completeness: six failure paths each mapping to
exactly one typed variant; registrations never half-survive;
hostile context values render clean through both sinks and both
escape policies with no panic paths
- two-sink correctness: byte-identical output over the ssr-shaped
and code-shaped fixture postures, re-exercised over the proof
fixture files (goldens stay pinned by the proof binaries)
- AGENTS.md: the wasm release build becomes standing and
unconditional; the feature-matrix coincide-today posture and the
handlebars feature-inspection posture documented honestly
(ADR-003, ADR-009)
Verification: cargo test (all green), cargo test --all-features
(identical), clippy -D warnings clean, fmt clean, doc clean, wasm
release build clean, handlebars tree features default-only,
taskgraph validate clean
514 lines
20 KiB
Rust
514 lines
20 KiB
Rust
//! The standing conformance battery: the verification rows the core's
|
|
//! consumers and every later wave lean on, in one home so future waves
|
|
//! grow rows here rather than scattering them. Rows the wave-1 unit
|
|
//! tests and the wave-2 proof binaries already pin golden output are
|
|
//! re-exercised here, not restated: the battery reuses the proof
|
|
//! fixture files under `tests/fixtures/ssr_proof/` and
|
|
//! `tests/fixtures/code_proof/` and asserts the obligations themselves
|
|
//! (strict-mode matrix, error-surface completeness, two-sink equality)
|
|
//! against fresh renders through the public API.
|
|
|
|
use alkgen::{Error, EscapePolicy, TemplateSet};
|
|
use serde_json::json;
|
|
|
|
/// Register a well-formed fixture template, unwrapping registration
|
|
/// only for fixtures the battery authors itself in-file; rows under
|
|
/// test always go through the typed error surface.
|
|
fn fixture_template(set: &mut TemplateSet, name: &str, text: &str) {
|
|
set.register_template(name, text)
|
|
.expect("battery fixture template should be well-formed");
|
|
}
|
|
|
|
fn fixture_partial(set: &mut TemplateSet, name: &str, text: &str) {
|
|
set.register_partial(name, text)
|
|
.expect("battery fixture partial should be well-formed");
|
|
}
|
|
|
|
/// The shared two-sink row helper: the same context through both
|
|
/// sinks, with the sink outputs asserted byte-identical.
|
|
fn assert_both_sinks<T: serde::Serialize>(set: &TemplateSet, name: &str, context: &T) {
|
|
let string_sink = set
|
|
.render(name, context)
|
|
.unwrap_or_else(|_| panic!("{name} should render by the string sink"));
|
|
let mut byte_sink = Vec::new();
|
|
set.render_to_write(name, context, &mut byte_sink)
|
|
.unwrap_or_else(|_| panic!("{name} should render by the write sink"));
|
|
assert_eq!(
|
|
&byte_sink[..],
|
|
string_sink.as_bytes(),
|
|
"both sinks must agree byte for byte on {name}"
|
|
);
|
|
}
|
|
|
|
/// The context shape the hostile-values row renders over. The key
|
|
/// `raw` is avoided on purpose: handlebars treats that name as a
|
|
/// keyword-shaped expression and never resolves it, and the row
|
|
/// should stay inside ordinary data.
|
|
const HOSTILE_TEMPLATE: &str = "a{{value}}b{{& trusted}}c";
|
|
|
|
// ------------------------------------------------------------------
|
|
// Strict-mode behavior matrix (ADR-002 decision 2, core.md)
|
|
//
|
|
// Every row: a missing context variable is a typed `Error::Render`
|
|
// through both sinks, never silent output. Top-level variables and
|
|
// variables nested inside `{{#each}}` and `{{#if}}` branches are each
|
|
// covered, and the write sink's on-failure behavior is stated by its
|
|
// row: a typed error, with the partial bytes already streamed left in
|
|
// place (no atomicity; consumers that need it buffer first).
|
|
// ------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn strict_matrix_missing_top_level_variable_fails_typed_through_both_sinks() {
|
|
let mut set = TemplateSet::new(EscapePolicy::NoOp);
|
|
fixture_template(&mut set, "tpl", "hello {{name}}");
|
|
|
|
let context = json!({});
|
|
|
|
let string_err = set
|
|
.render("tpl", &context)
|
|
.expect_err("missing variable must fail the string sink, never render silently");
|
|
assert!(
|
|
matches!(string_err, Error::Render(_)),
|
|
"the string sink's failure must be a typed Error::Render, got: {string_err:?}"
|
|
);
|
|
|
|
let mut sink = Vec::new();
|
|
let write_err = set
|
|
.render_to_write("tpl", &context, &mut sink)
|
|
.expect_err("missing variable must fail the write sink, never render silently");
|
|
assert!(
|
|
matches!(write_err, Error::Render(_)),
|
|
"the write sink's failure must be a typed Error::Render, got: {write_err:?}"
|
|
);
|
|
assert_eq!(
|
|
String::from_utf8_lossy(&sink),
|
|
"hello ",
|
|
"the write sink streams incrementally: bytes rendered before the failure \
|
|
(the literal text ahead of the variable) stay in the target; consumers \
|
|
needing atomicity buffer first (the wave-1 contract)"
|
|
);
|
|
|
|
let present = set
|
|
.render("tpl", &json!({ "name": "world" }))
|
|
.expect("present variables still render");
|
|
assert_eq!(present, "hello world");
|
|
}
|
|
|
|
#[test]
|
|
fn strict_matrix_missing_variable_inside_each_branch_fails_typed_through_both_sinks() {
|
|
let mut set = TemplateSet::new(EscapePolicy::NoOp);
|
|
fixture_template(
|
|
&mut set,
|
|
"cells",
|
|
"{{#each items}}<td>{{label}}</td>{{/each}}",
|
|
);
|
|
|
|
let context = json!({ "items": [ { "label": "fine" }, {} ] });
|
|
|
|
let string_err = set
|
|
.render("cells", &context)
|
|
.expect_err("a missing variable inside an {{#each}} body must fail the string sink");
|
|
assert!(matches!(string_err, Error::Render(_)));
|
|
assert!(
|
|
format!("{string_err}").contains("label"),
|
|
"the strict-mode detail names the missing variable"
|
|
);
|
|
|
|
let mut sink = Vec::new();
|
|
let write_err = set
|
|
.render_to_write("cells", &context, &mut sink)
|
|
.expect_err("a missing variable inside an {{#each}} body must fail the write sink");
|
|
assert!(matches!(write_err, Error::Render(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn strict_matrix_missing_variable_in_if_then_branch_fails_typed_through_both_sinks() {
|
|
let mut set = TemplateSet::new(EscapePolicy::NoOp);
|
|
fixture_template(
|
|
&mut set,
|
|
"banner",
|
|
"{{#if flag}}<div>{{banner_text}}</div>{{/if}}",
|
|
);
|
|
|
|
let then_context = json!({ "flag": true });
|
|
|
|
let string_err = set
|
|
.render("banner", &then_context)
|
|
.expect_err("a missing variable in a taken {{#if}} branch must fail the string sink");
|
|
assert!(matches!(string_err, Error::Render(_)));
|
|
assert!(format!("{string_err}").contains("banner_text"));
|
|
|
|
let mut sink = Vec::new();
|
|
let write_err = set
|
|
.render_to_write("banner", &then_context, &mut sink)
|
|
.expect_err("a missing variable in a taken {{#if}} branch must fail the write sink");
|
|
assert!(matches!(write_err, Error::Render(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn strict_matrix_missing_variable_in_if_else_branch_fails_typed_through_both_sinks() {
|
|
let mut set = TemplateSet::new(EscapePolicy::NoOp);
|
|
fixture_template(
|
|
&mut set,
|
|
"banner",
|
|
"{{#if flag}}<div>{{banner_text}}</div>{{else}}<p>{{fallback}}</p>{{/if}}",
|
|
);
|
|
|
|
let else_context = json!({ "flag": false });
|
|
|
|
let string_err = set
|
|
.render("banner", &else_context)
|
|
.expect_err("a missing variable in a taken {{else}} branch must fail the string sink");
|
|
assert!(matches!(string_err, Error::Render(_)));
|
|
assert!(format!("{string_err}").contains("fallback"));
|
|
|
|
let mut sink = Vec::new();
|
|
let write_err = set
|
|
.render_to_write("banner", &else_context, &mut sink)
|
|
.expect_err("a missing variable in a taken {{else}} branch must fail the write sink");
|
|
assert!(matches!(write_err, Error::Render(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn write_sink_failure_is_typed_and_carries_the_no_atomicity_contract() {
|
|
let mut set = TemplateSet::new(EscapePolicy::NoOp);
|
|
fixture_template(&mut set, "report", "prefix-ok\n{{missing}}\ntrailing");
|
|
|
|
let context = json!({});
|
|
let mut sink = Vec::new();
|
|
let write_err = set
|
|
.render_to_write("report", &context, &mut sink)
|
|
.expect_err("a strict failure past already-streamed text must still be a typed error");
|
|
assert!(matches!(write_err, Error::Render(_)));
|
|
|
|
assert_eq!(
|
|
String::from_utf8_lossy(&sink),
|
|
"prefix-ok\n",
|
|
"the write sink streams incrementally: bytes rendered before a failure stay \
|
|
in the target, so consumers needing atomicity buffer first (the wave-1 contract)"
|
|
);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Error-surface completeness (core.md error model, convention 2)
|
|
//
|
|
// Every engine-owned failure path maps to exactly one typed variant
|
|
// with no catch-all, nothing survives a failed registration as a
|
|
// half-registered name, and no input the API accepts reaches a panic.
|
|
// ------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn error_surface_malformed_template_text_maps_to_registration_and_never_half_registers() {
|
|
let mut set = TemplateSet::new(EscapePolicy::NoOp);
|
|
fixture_template(&mut set, "keep", "ok");
|
|
|
|
let template_err = set
|
|
.register_template("broken", "{{#if name")
|
|
.expect_err("malformed template text must fail registration");
|
|
assert!(matches!(template_err, Error::Registration(_)));
|
|
assert!(
|
|
!set.is_template("broken"),
|
|
"a failed first registration must not survive as a half-registered name"
|
|
);
|
|
|
|
let partial_err = set
|
|
.register_partial("broken-partial", "{{#each items")
|
|
.expect_err("malformed partial text must fail registration");
|
|
assert!(matches!(partial_err, Error::Registration(_)));
|
|
assert!(!set.is_template("broken-partial"));
|
|
|
|
let overwrite_err = set
|
|
.register_template("keep", "{{#if name")
|
|
.expect_err("malformed overwrite text must fail registration");
|
|
assert!(matches!(overwrite_err, Error::Registration(_)));
|
|
assert!(
|
|
set.is_template("keep"),
|
|
"a failed overwrite leaves the previous registration and its kind intact"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn error_surface_unknown_template_name_maps_to_render_through_both_sinks() {
|
|
let mut set = TemplateSet::new(EscapePolicy::NoOp);
|
|
fixture_template(&mut set, "present", "ok");
|
|
|
|
let context = json!({});
|
|
let string_err = set
|
|
.render("no-such-template", &context)
|
|
.expect_err("an unknown template name must fail the string sink typed");
|
|
assert!(matches!(string_err, Error::Render(_)));
|
|
|
|
let mut sink = Vec::new();
|
|
let write_err = set
|
|
.render_to_write("no-such-template", &context, &mut sink)
|
|
.expect_err("an unknown template name must fail the write sink typed");
|
|
assert!(matches!(write_err, Error::Render(_)));
|
|
assert!(
|
|
sink.is_empty(),
|
|
"nothing leaks into the target when the name never resolves"
|
|
);
|
|
|
|
let present_err = set
|
|
.render("", &context)
|
|
.expect_err("an empty name is an unknown name too");
|
|
assert!(matches!(present_err, Error::Render(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn error_surface_partial_as_render_target_maps_to_render_through_both_sinks() {
|
|
let mut set = TemplateSet::new(EscapePolicy::NoOp);
|
|
fixture_template(&mut set, "body", "{{> chunk}}");
|
|
fixture_partial(&mut set, "chunk", "chunk: {{value}}");
|
|
|
|
let context = json!({ "value": 1 });
|
|
let string_err = set
|
|
.render("chunk", &context)
|
|
.expect_err("a partial's name must be rejected as a render target");
|
|
assert!(matches!(string_err, Error::Render(_)));
|
|
|
|
let mut sink = Vec::new();
|
|
let write_err = set
|
|
.render_to_write("chunk", &context, &mut sink)
|
|
.expect_err("the write sink must reject a partial's name too");
|
|
assert!(matches!(write_err, Error::Render(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn error_surface_strict_missing_variable_maps_to_render_through_both_sinks() {
|
|
let mut set = TemplateSet::new(EscapePolicy::NoOp);
|
|
fixture_template(&mut set, "tpl", "value: {{value}}");
|
|
|
|
let context = json!({});
|
|
let string_err = set
|
|
.render("tpl", &context)
|
|
.expect_err("the strict missing variable must fail the string sink typed");
|
|
assert!(matches!(string_err, Error::Render(_)));
|
|
|
|
let mut sink = Vec::new();
|
|
let write_err = set
|
|
.render_to_write("tpl", &context, &mut sink)
|
|
.expect_err("the strict missing variable must fail the write sink typed");
|
|
assert!(matches!(write_err, Error::Render(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn error_surface_unknown_helper_maps_to_render_through_both_sinks() {
|
|
let mut set = TemplateSet::new(EscapePolicy::NoOp);
|
|
fixture_template(&mut set, "loud", "{{shout word}}");
|
|
|
|
let context = json!({ "word": "echo" });
|
|
let string_err = set
|
|
.render("loud", &context)
|
|
.expect_err("an unregistered helper must fail the string sink typed");
|
|
assert!(matches!(string_err, Error::Render(_)));
|
|
assert!(
|
|
format!("{string_err}").contains("shout"),
|
|
"the failure names the helper that was never registered"
|
|
);
|
|
|
|
let mut sink = Vec::new();
|
|
let write_err = set
|
|
.render_to_write("loud", &context, &mut sink)
|
|
.expect_err("an unregistered helper must fail the write sink typed");
|
|
assert!(matches!(write_err, Error::Render(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn error_surface_hostile_context_values_render_clean_or_typed_never_panic() {
|
|
let hostile_values = [
|
|
"<script>alert(1)</script>",
|
|
"\"quoted\" & 'single' = equal",
|
|
"backslash \\ backtick ` brace }",
|
|
"'=&",
|
|
"emoji \u{1F6A0} unicode e-acute \u{00E9}",
|
|
"control \u{0000} byte and tab \t newline \n",
|
|
];
|
|
|
|
let mut html_set = TemplateSet::new(EscapePolicy::Html);
|
|
fixture_template(&mut html_set, "page", HOSTILE_TEMPLATE);
|
|
|
|
for (i, hostile) in hostile_values.iter().enumerate() {
|
|
let context = json!({ "value": hostile, "trusted": "<b>ok</b>" });
|
|
let rendered = html_set
|
|
.render("page", &context)
|
|
.expect("hostile interpolated values are data: they render typed-clean, never panic");
|
|
assert!(
|
|
!rendered.contains("<script>"),
|
|
"the html policy must not pass interpolated markup raw (case {i})"
|
|
);
|
|
assert!(
|
|
rendered.contains("<b>ok</b>"),
|
|
"the trusted slot stays deliberately raw (case {i})"
|
|
);
|
|
|
|
let mut sink = Vec::new();
|
|
html_set
|
|
.render_to_write("page", &context, &mut sink)
|
|
.expect("the write sink handles the same hostile values the same way");
|
|
assert_eq!(
|
|
&sink[..],
|
|
rendered.as_bytes(),
|
|
"both sinks agree on hostile values too (case {i})"
|
|
);
|
|
}
|
|
|
|
let exact = html_set
|
|
.render(
|
|
"page",
|
|
&json!({ "value": "<script>alert(1)</script>", "trusted": "<b>" }),
|
|
)
|
|
.expect("the pinned hostile value should render");
|
|
assert_eq!(
|
|
exact, "a<script>alert(1)</script>b<b>c",
|
|
"html-policy escaping of the hostile value is entity-escaping, byte-pinned"
|
|
);
|
|
|
|
let mut noop_set = TemplateSet::new(EscapePolicy::NoOp);
|
|
fixture_template(&mut noop_set, "plain", HOSTILE_TEMPLATE);
|
|
|
|
for (i, hostile) in hostile_values.iter().enumerate() {
|
|
let context = json!({ "value": hostile, "trusted": hostile });
|
|
let rendered = noop_set
|
|
.render("plain", &context)
|
|
.expect("the no-op policy passes data through verbatim, never panicking");
|
|
assert_eq!(
|
|
rendered,
|
|
format!("a{hostile}b{hostile}c"),
|
|
"the no-op policy output is the input verbatim (case {i})"
|
|
);
|
|
|
|
let mut sink = Vec::new();
|
|
noop_set
|
|
.render_to_write("plain", &context, &mut sink)
|
|
.expect("the write sink handles the same hostile values the same way");
|
|
assert_eq!(
|
|
&sink[..],
|
|
rendered.as_bytes(),
|
|
"both sinks agree on hostile values too (case {i})"
|
|
);
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Two-sink correctness rows (core.md render entry point)
|
|
//
|
|
// For each fixture posture, `render` and `render_to_write` must
|
|
// produce byte-identical output on the same context. Both postures
|
|
// re-exercise the wave-2 proof fixture files; their goldens stay
|
|
// pinned by the proof binaries.
|
|
// ------------------------------------------------------------------
|
|
|
|
/// The ssr-shaped fixture posture: the wave-2 ssr proof's page and
|
|
/// fragment partials under the HTML escaper, with the caller-supplied
|
|
/// card text registering through the same API (ADR-008 machinery
|
|
/// equality).
|
|
fn ssr_shaped_set() -> TemplateSet {
|
|
const PAGE: &str = include_str!("fixtures/ssr_proof/page.hbs");
|
|
const HEADER: &str = include_str!("fixtures/ssr_proof/header.hbs");
|
|
const NAV: &str = include_str!("fixtures/ssr_proof/nav.hbs");
|
|
const FOOTER: &str = include_str!("fixtures/ssr_proof/footer.hbs");
|
|
const CARD_TEXT: &str =
|
|
"<article class=\"card\" data-kind=\"{{kind}}\"><h3>{{title}}</h3><p>{{body}}</p></article>";
|
|
|
|
let mut set = TemplateSet::new(EscapePolicy::Html);
|
|
fixture_template(&mut set, "page", PAGE);
|
|
fixture_template(&mut set, "card", CARD_TEXT);
|
|
fixture_partial(&mut set, "header", HEADER);
|
|
fixture_partial(&mut set, "nav", NAV);
|
|
fixture_partial(&mut set, "footer", FOOTER);
|
|
set
|
|
}
|
|
|
|
fn ssr_shaped_context() -> serde_json::Value {
|
|
json!({
|
|
"site_name": "Alk Forge",
|
|
"tagline": "Your repositories",
|
|
"nav_links": [
|
|
{ "href": "/", "label": "Home" },
|
|
{ "href": "/search?q=<script>&lang=en", "label": "<script>alert('x')</script>" }
|
|
],
|
|
"cards": [
|
|
{ "kind": "repo", "title": "Repo \"settings\" & <config>", "body": "if (a < b) { alert(\"<b>hi</b>\") }" },
|
|
{ "kind": "link", "title": "Plain card", "body": "A plain body." }
|
|
],
|
|
"show_banner": true,
|
|
"banner_text": "<em>scheduled</em> maintenance",
|
|
"trusted_embed": "<aside id=\"notice\"><b>system notice</b></aside>",
|
|
"year": 2026
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn two_sinks_agree_byte_for_byte_on_the_ssr_shaped_fixture_posture() {
|
|
let set = ssr_shaped_set();
|
|
let context = ssr_shaped_context();
|
|
|
|
assert_both_sinks(&set, "page", &context);
|
|
let card_context = &context["cards"][0];
|
|
assert_both_sinks(&set, "card", card_context);
|
|
|
|
let rendered = set
|
|
.render("page", &context)
|
|
.expect("the ssr-shaped posture should render");
|
|
assert!(
|
|
rendered.contains(r#"<aside id="notice"><b>system notice</b></aside>"#)
|
|
&& rendered.contains(r#"<script>alert('x')</script>"#),
|
|
"the exercised row is the real ssr posture: hostile data escaped, trusted slot raw"
|
|
);
|
|
}
|
|
|
|
/// The code-shaped fixture posture: the wave-2 code proof's writer and
|
|
/// validator templates over the composed field-write-line partial
|
|
/// under the no-op escaper, with document-ordered fields and
|
|
/// builder-decided call names carried straight into the context.
|
|
fn code_shaped_set() -> TemplateSet {
|
|
const WRITER: &str = include_str!("fixtures/code_proof/writer.hbs");
|
|
const VALIDATOR: &str = include_str!("fixtures/code_proof/validator.hbs");
|
|
const FIELD_WRITE_LINE: &str = include_str!("fixtures/code_proof/field_write_line.hbs");
|
|
|
|
let mut set = TemplateSet::new(EscapePolicy::NoOp);
|
|
fixture_template(&mut set, "writer", WRITER);
|
|
fixture_template(&mut set, "validator", VALIDATOR);
|
|
fixture_partial(&mut set, "field-write-line", FIELD_WRITE_LINE);
|
|
set
|
|
}
|
|
|
|
#[test]
|
|
fn two_sinks_agree_byte_for_byte_on_the_code_shaped_fixture_posture() {
|
|
let set = code_shaped_set();
|
|
|
|
let context = json!({
|
|
"struct_name": "PacketHeader",
|
|
"fields": [
|
|
{ "name": "zeta", "writer_call": "write_str", "check_call": "check_str_len", "is_string": true, "max_len_const": "ZETA_MAX" },
|
|
{ "name": "magic", "writer_call": "write_u64", "check_call": "check_u64", "is_string": false, "max_len_const": "MAGIC_MAX" },
|
|
{ "name": "alpha", "writer_call": "write_bool", "check_call": "check_bool", "is_string": false, "max_len_const": "ALPHA_MAX" },
|
|
{ "name": "length", "writer_call": "write_str", "check_call": "check_str_len", "is_string": true, "max_len_const": "LENGTH_MAX" }
|
|
],
|
|
"default_consts": [
|
|
{ "const_name": "ZETA_DEFAULT", "literal": "\"v<1>&\\\"raw\\\"\"" }
|
|
]
|
|
});
|
|
|
|
assert_both_sinks(&set, "writer", &context);
|
|
assert_both_sinks(&set, "validator", &context);
|
|
|
|
let rendered = set
|
|
.render("writer", &context)
|
|
.expect("the code-shaped posture should render");
|
|
assert!(
|
|
rendered.contains(r#"out.write_str(&self.zeta)?;"#)
|
|
&& rendered.contains(r#"pub const ZETA_DEFAULT: &str = "v<1>&\"raw\"";"#),
|
|
"the exercised row is the real code posture: document order and verbatim literals"
|
|
);
|
|
|
|
let validator = set
|
|
.render("validator", &context)
|
|
.expect("the validator should render");
|
|
assert!(
|
|
validator.contains("check_str_len(&self.zeta, ZETA_MAX)?;")
|
|
&& validator.contains("check_u64(&self.magic)?;"),
|
|
"the validator half rides the same document-ordered context"
|
|
);
|
|
}
|