The IR called them react-message and django-message, so a FastAPI channel had to declare a DjangoMessage. They are client-message and server-message now, and the direction words hold wherever a channel is declared: Params / ClientMessage / ServerMessage, with mizan-core deriving <Pascal>Params and friends so no backend names a type itself. Django's ReactChannel and FastAPI's ReactChannel are both Channel. mizan-fastapi never registered a channels extension, so build_ir() emitted no channel at all and every payload type was invisible to codegen. It registers one now. RegistryExtension is an ABC requiring all(), which is what the IR reads — an extension that cannot enumerate its registrations no longer exists. The gate that should have caught the rename could not: tests/afi registered no channel because mizan-rust had no channel registry to register one in, so a five-package rename of the wire contract passed byte-parity without a channel byte crossing it. mizan-rust grows ChannelSlotKind, a CHANNELS slice, a #[mizan::channel] macro, and KDL emission whose wire_to_pascal matches Python's split; the AFI fixture now carries a channel with every slot and one with a single slot, so all three backends prove the contract byte for byte. MizanChannel held three Option<String> beside three has_*() predicates and unwrapped them with defaults; it holds an ordered slot vector, so an absent slot is absent rather than defaulted. The channels target emitted a React hooks file that a stage1-only consumer could not compile — react emits that now. The codegen's parity tests byte-compared emitted source against baselines without ever compiling it: they compile the generated crate and run its tests, import the generated Python package and call every method, and typecheck each TypeScript target against a consumer. Also fixed at source: app_visitor printed its import diagnostic to stdout, the stream export_mizan_ir writes KDL to, so a failed import silently corrupted the IR; the apps root was hardcoded to "apps"; _default_literal crashed build_ir on any non-JSON-serializable field default; Django and mizan-core derived Pascal names two different ways, disagreeing on every dotted channel name. ir.py builds a document and renders templates/ir/document.kdl.j2 rather than appending KDL strings with hand-tracked indentation, and named types resolve to a fixed point — a model reachable only through a union branch was referenced by a ref that no type block ever defined. The rest is the write-gate's own classifiers run over the standing tree: relative imports, silent swallows, Protocol contracts that should be ABCs, emitters hand-rendering target source, catch-all arms over closed enums, and comments narrating the project rather than the code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
134 lines
4.8 KiB
Rust
134 lines
4.8 KiB
Rust
//! Decodes the AFI fixture into the typed IR structs. Each test reads back a
|
|
//! different facet of the KDL — the function set, per-function field decoding,
|
|
//! context-param elevation, and the named-type table.
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use mizan_codegen::fetch::parse_ir_from_str;
|
|
use mizan_codegen::ir::{AffectKind, CallInput, IsContext, Primitive, Transport};
|
|
|
|
|
|
fn load_fixture() -> mizan_codegen::ir::MizanIR {
|
|
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
.join("tests/fixtures/afi_ir.kdl");
|
|
let raw = std::fs::read_to_string(&path)
|
|
.unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
|
|
parse_ir_from_str(&raw).unwrap_or_else(|e| panic!("parse IR: {e}"))
|
|
}
|
|
|
|
|
|
#[test]
|
|
fn afi_fixture_deserializes_function_set() {
|
|
let ir = load_fixture();
|
|
let names: Vec<&str> = ir.functions.iter().map(|f| f.name.as_str()).collect();
|
|
|
|
assert_eq!(ir.functions.len(), 7, "expected 7 functions, got {}: {names:?}", ir.functions.len());
|
|
|
|
for expected in [
|
|
"echo", "whoami",
|
|
"user_profile", "user_orders",
|
|
"update_profile", "find_user", "rename_user",
|
|
] {
|
|
assert!(names.contains(&expected), "missing function {expected:?} in {names:?}");
|
|
}
|
|
}
|
|
|
|
|
|
#[test]
|
|
fn afi_fixture_function_field_decode() {
|
|
let ir = load_fixture();
|
|
let echo = ir.functions.iter().find(|f| f.name == "echo").unwrap();
|
|
assert_eq!(echo.camel_name, "echo");
|
|
assert_eq!(echo.input, CallInput::Typed("echoInput".to_string()));
|
|
assert_eq!(echo.output_type, "echoOutput");
|
|
assert!(!echo.output_nullable);
|
|
assert_eq!(echo.transport, Transport::Http);
|
|
assert_eq!(echo.is_context, IsContext::No);
|
|
|
|
let whoami = ir.functions.iter().find(|f| f.name == "whoami").unwrap();
|
|
assert_eq!(whoami.input, CallInput::Absent);
|
|
|
|
// `find_user` returns `ProfileOutput | None` — outputNullable must be true.
|
|
let find_user = ir.functions.iter().find(|f| f.name == "find_user").unwrap();
|
|
assert!(find_user.output_nullable, "find_user must be outputNullable");
|
|
|
|
// Context-typed function picks up the context name.
|
|
let user_profile = ir.functions.iter().find(|f| f.name == "user_profile").unwrap();
|
|
assert_eq!(user_profile.is_context.as_str(), Some("user"));
|
|
|
|
// Mutation with `affects="user"` lands in `affects` as a context target.
|
|
let update_profile = ir.functions.iter().find(|f| f.name == "update_profile").unwrap();
|
|
assert_eq!(update_profile.affects.len(), 1);
|
|
assert_eq!(update_profile.affects[0].kind, AffectKind::Context);
|
|
assert_eq!(update_profile.affects[0].name, "user");
|
|
|
|
// Mutation with `merge="user"`.
|
|
let rename_user = ir.functions.iter().find(|f| f.name == "rename_user").unwrap();
|
|
assert_eq!(rename_user.merge, vec!["user".to_string()]);
|
|
}
|
|
|
|
|
|
/// `has-input` and `input` are two spellings of one fact; a document that
|
|
/// spells them against each other is rejected at the KDL crossing rather than
|
|
/// silently reaching a target as a no-argument call.
|
|
#[test]
|
|
fn contradictory_input_spellings_are_rejected() {
|
|
let declared_without_flag = r#"
|
|
function "echo" {
|
|
camel "echo"
|
|
has-input #false
|
|
input "echoInput"
|
|
output "echoOutput"
|
|
}
|
|
"#;
|
|
let err = parse_ir_from_str(declared_without_flag)
|
|
.expect_err("`input` with `has-input #false` must not parse")
|
|
.to_string();
|
|
assert!(err.contains("echoInput"), "fault must name the declared input type: {err}");
|
|
|
|
let flag_without_type = r#"
|
|
function "echo" {
|
|
camel "echo"
|
|
has-input #true
|
|
output "echoOutput"
|
|
}
|
|
"#;
|
|
let err = parse_ir_from_str(flag_without_type)
|
|
.expect_err("`has-input #true` with no `input` must not parse")
|
|
.to_string();
|
|
assert!(err.contains("has-input"), "fault must name `has-input`: {err}");
|
|
}
|
|
|
|
|
|
#[test]
|
|
fn afi_fixture_context_param_elevation() {
|
|
let ir = load_fixture();
|
|
let user = ir.contexts.get("user").expect("user context group");
|
|
|
|
// Both context functions share `user_id` as a required param.
|
|
let user_id = user.params.get("user_id").expect("user_id param");
|
|
assert_eq!(user_id.ty, Primitive::Integer);
|
|
assert!(user_id.required, "user_id is required (declared by every fn in the group)");
|
|
assert!(user_id.shared_by.contains(&"user_profile".to_string()));
|
|
assert!(user_id.shared_by.contains(&"user_orders".to_string()));
|
|
}
|
|
|
|
|
|
#[test]
|
|
fn afi_fixture_named_types_present() {
|
|
let ir = load_fixture();
|
|
// Every function names its `<camelName>Input` / `<camelName>Output` type,
|
|
// so the IR's type table must declare each one.
|
|
for expected in [
|
|
"echoInput", "echoOutput",
|
|
"whoamiOutput",
|
|
"userProfileInput", "userProfileOutput",
|
|
"userOrdersInput",
|
|
"updateProfileInput", "updateProfileOutput",
|
|
"findUserInput", "findUserOutput",
|
|
"renameUserInput", "renameUserOutput",
|
|
] {
|
|
assert!(ir.types.contains_key(expected), "missing type {expected:?}");
|
|
}
|
|
}
|