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>
108 lines
3.8 KiB
Python
108 lines
3.8 KiB
Python
"""Unit tests for KDL IR emission. Every assertion runs on a real KDL parse tree."""
|
|
|
|
from unittest import TestCase
|
|
|
|
import ckdl
|
|
from pydantic import BaseModel
|
|
|
|
from mizan_core.client.function import client
|
|
from mizan_core.ir import build_ir
|
|
from mizan_core.registry import clear_registry, register
|
|
|
|
|
|
class Prefs(BaseModel):
|
|
live: bool = True
|
|
|
|
|
|
class Settings(BaseModel):
|
|
meta: Prefs = Prefs()
|
|
label: str = "plain"
|
|
quoted: str = 'a "b" \\ c\nd\te'
|
|
retries: int = 3
|
|
ratio: float = 0.5
|
|
enabled: bool = False
|
|
tags: list[str] = []
|
|
mapping: dict[str, str] = {}
|
|
note: str | None = None
|
|
who: str
|
|
|
|
|
|
def _struct_fields(document: ckdl.Document, type_name: str) -> dict[str, ckdl.Node]:
|
|
"""Field nodes of the named struct, keyed by field name."""
|
|
for node in document.nodes:
|
|
if node.name == "type" and node.args[0] == type_name:
|
|
for child in node.children:
|
|
if child.name == "struct":
|
|
return {field.args[0]: field for field in child.children}
|
|
raise AssertionError(f"no struct type {type_name!r} in:\n{document}")
|
|
|
|
|
|
class EmptyDocumentTests(TestCase):
|
|
"""The IR of an empty registry."""
|
|
|
|
def setUp(self):
|
|
clear_registry()
|
|
|
|
def tearDown(self):
|
|
clear_registry()
|
|
|
|
def test_empty_registry_emits_zero_bytes(self):
|
|
"""Nothing registered renders the empty document, not a blank line."""
|
|
self.assertEqual(build_ir(), "")
|
|
|
|
def test_empty_document_parses_to_zero_nodes(self):
|
|
"""The empty document is valid KDL carrying no nodes."""
|
|
self.assertEqual(len(ckdl.parse(build_ir()).nodes), 0)
|
|
|
|
|
|
class FieldDefaultTests(TestCase):
|
|
"""Which Pydantic field defaults reach the document as KDL literals."""
|
|
|
|
def setUp(self):
|
|
clear_registry()
|
|
|
|
@client
|
|
def get_settings(request) -> Settings:
|
|
return Settings(who="anyone")
|
|
|
|
register(get_settings, "get_settings")
|
|
self.fields = _struct_fields(
|
|
ckdl.parse(build_ir()), "getSettingsOutput"
|
|
)
|
|
|
|
def tearDown(self):
|
|
clear_registry()
|
|
|
|
def test_scalar_defaults_survive_the_round_trip(self):
|
|
"""bool, int, float and str defaults parse back to the Python values."""
|
|
self.assertEqual(self.fields["label"].properties["default"], "plain")
|
|
self.assertEqual(self.fields["retries"].properties["default"], 3)
|
|
self.assertEqual(self.fields["ratio"].properties["default"], 0.5)
|
|
self.assertEqual(self.fields["enabled"].properties["default"], False)
|
|
|
|
def test_string_default_escapes_round_trip(self):
|
|
"""Quotes, backslashes and control characters survive KDL escaping."""
|
|
self.assertEqual(
|
|
self.fields["quoted"].properties["default"], Settings.model_fields["quoted"].default
|
|
)
|
|
|
|
def test_model_valued_default_carries_no_literal(self):
|
|
"""A nested-model default has no KDL scalar form, so no `default` is emitted."""
|
|
meta = self.fields["meta"]
|
|
self.assertNotIn("default", meta.properties)
|
|
self.assertIs(meta.properties["required"], False)
|
|
|
|
def test_container_defaults_carry_no_literal(self):
|
|
"""List and dict defaults have no KDL scalar form either."""
|
|
self.assertNotIn("default", self.fields["tags"].properties)
|
|
self.assertNotIn("default", self.fields["mapping"].properties)
|
|
|
|
def test_none_default_carries_no_literal(self):
|
|
"""`= None` leaves the optional shape to say the field may be absent."""
|
|
self.assertNotIn("default", self.fields["note"].properties)
|
|
|
|
def test_required_field_has_no_required_property(self):
|
|
"""A required field is the default, so the property is left off entirely."""
|
|
self.assertNotIn("required", self.fields["who"].properties)
|
|
self.assertNotIn("default", self.fields["who"].properties)
|