//! Hash-pin integration tests, relocated from `ailang-core/src/hash.rs` //! `#[cfg(test)] mod tests` to a `tests/*` integration crate in iter //! form-a.1 Task 5. The relocation removes the production-source file's //! ad-hoc `examples/.ail.json` reads in favour of loading the //! `.ail` siblings via `ailang_surface::load_module`, mirroring the //! Form-A authoring discipline of the form-a-default-authoring //! milestone. Hash assertions are unchanged: post-form-a the canonical //! JSON-AST is still the hashable representation, derived in-process //! from `.ail` by the surface loader. //! //! The `examples/.ail.json` fixtures that backed the original //! tests are deleted in Task 8; the `.ail` siblings rendered in Task 2 //! are the authored form going forward. The four schema-stability pins //! (iter13a / iter19b / iter22b1 / ct4) plus the two structural pins //! (hash_is_stable / hash_changes_with_content / classdef-empty-optionals //! / forall-without-constraints) all keep their assertion shape. use ailang_core::ast::*; use ailang_core::canonical; use ailang_core::hash::def_hash; use ailang_test_support::examples_dir; fn sample_fn() -> Def { Def::Fn(FnDef { name: "add".into(), ty: Type::Fn { params: vec![Type::int(), Type::int()], ret: Box::new(Type::int()), effects: vec![], param_modes: vec![ParamMode::Own, ParamMode::Own], ret_mode: ParamMode::Own, }, params: vec!["a".into(), "b".into()], body: Term::App { callee: Box::new(Term::Var { name: "+".into() }), args: vec![ Term::Var { name: "a".into() }, Term::Var { name: "b".into() }, ], tail: false, }, suppress: vec![], doc: None, export: None, }) } #[test] fn hash_is_stable() { let h1 = def_hash(&sample_fn()); let h2 = def_hash(&sample_fn()); assert_eq!(h1, h2); assert_eq!(h1.len(), 16); } #[test] fn hash_changes_with_content() { let mut def = sample_fn(); let h1 = def_hash(&def); if let Def::Fn(ref mut f) = def { f.name = "mul".into(); } let h2 = def_hash(&def); assert_ne!(h1, h2); } /// adding `vars` to TypeDef and `args` to /// `Type::Con` must NOT change canonical-JSON hashes of any pre-13a /// definition. Recorded hashes were captured from on-disk modules /// before the schema extension; if this fires, a /// `skip_serializing_if` is missing or wrong. /// /// 2026-05-21 operator-routing-eq-ord re-pinned (prior: /// db33f57cb329935e): sum.ail's body migrates `(app == n 0)` → /// `(app eq n 0)` per the operator-name deletion; hash follows the /// content change. Pin remains a schema-extension guard against /// future drift from the new content baseline. #[test] fn iter13a_schema_extension_preserves_pre_13a_hashes() { let examples = examples_dir(); let sum_mod = ailang_surface::load_module(&examples.join("sum.ail")) .expect("examples/sum.ail loads"); let sum_def = sum_mod.defs.iter().find(|d| d.name() == "sum").unwrap(); assert_eq!(def_hash(sum_def), "19920ec4123d35d6"); let list_mod = ailang_surface::load_module(&examples.join("list.ail")) .expect("examples/list.ail loads"); let int_list_def = list_mod.defs.iter().find(|d| d.name() == "IntList").unwrap(); assert_eq!(def_hash(int_list_def), "b082192bd0c99202"); } /// loop-recur iter 1 regression: adding `Term::Loop` / `Term::Recur` /// (and `struct LoopBinder`) must NOT change canonical-JSON hashes /// of any pre-loop-recur definition. The additive variant carries a /// new `"t"` tag absent from every pre-existing fixture, so the /// recorded hashes below must stay byte-identical. If this fires, /// the extension is non-additive (a `skip_serializing_if` is missing /// or an existing variant's shape drifted). #[test] fn loop_recur_schema_extension_preserves_pre_loop_recur_hashes() { let examples = examples_dir(); let sum_mod = ailang_surface::load_module(&examples.join("sum.ail")) .expect("examples/sum.ail loads"); let sum_def = sum_mod.defs.iter().find(|d| d.name() == "sum").unwrap(); assert_eq!(def_hash(sum_def), "19920ec4123d35d6"); let list_mod = ailang_surface::load_module(&examples.join("list.ail")) .expect("examples/list.ail loads"); let int_list_def = list_mod.defs.iter().find(|d| d.name() == "IntList").unwrap(); assert_eq!(def_hash(int_list_def), "b082192bd0c99202"); } /// adding `suppress` to FnDef must NOT change /// canonical-JSON hashes of any pre-19b fn whose `suppress` is empty. /// The `skip_serializing_if = "Vec::is_empty"` predicate on the field /// is what enforces this; if it is wrong, every existing fixture's /// hash drifts and `ail diff` / `ail manifest` output breaks. #[test] fn iter19b_empty_suppress_preserves_pre_19b_hashes() { let with_empty_suppress = sample_fn(); // Mutate the bare sample to set suppress explicitly to a // non-empty Vec, then mutate it back to empty: two distinct // construction paths that should still hash identically. let mut with_explicit_empty = sample_fn(); if let Def::Fn(ref mut f) = with_explicit_empty { f.suppress = vec![]; } assert_eq!( def_hash(&with_empty_suppress), def_hash(&with_explicit_empty), "two FnDefs with empty suppress must hash identically" ); // And: a FnDef with a *non-empty* suppress hashes differently. let mut with_suppress = sample_fn(); if let Def::Fn(ref mut f) = with_suppress { f.suppress = vec![Suppress { code: "over-strict-mode".into(), because: "test reason".into(), }]; } assert_ne!( def_hash(&with_empty_suppress), def_hash(&with_suppress), "non-empty suppress must produce a distinct hash" ); } /// an on-disk pre-19b fixture must still load cleanly /// (no `suppress` field present in the JSON) and produce its /// canonical-byte hash unchanged. #[test] fn iter19b_schema_extension_preserves_pre_19b_hashes() { let examples = examples_dir(); let sum_mod = ailang_surface::load_module(&examples.join("sum.ail")) .expect("examples/sum.ail loads"); let sum_def = sum_mod.defs.iter().find(|d| d.name() == "sum").unwrap(); assert_eq!(def_hash(sum_def), "19920ec4123d35d6"); } /// adding `Def::Class` and `Def::Instance` must /// NOT change canonical-JSON hashes of any pre-22b def. The new variants /// are alternatives, not field additions — pre-22b fixtures simply do /// not produce a `Class` / `Instance` `Def`, and the existing arms are /// unchanged. #[test] fn iter22b1_schema_extension_preserves_pre_22b_hashes() { let examples = examples_dir(); let sum_mod = ailang_surface::load_module(&examples.join("sum.ail")) .expect("examples/sum.ail loads"); let sum_def = sum_mod.defs.iter().find(|d| d.name() == "sum").unwrap(); assert_eq!(def_hash(sum_def), "19920ec4123d35d6"); let list_mod = ailang_surface::load_module(&examples.join("list.ail")) .expect("examples/list.ail loads"); let int_list_def = list_mod .defs .iter() .find(|d| d.name() == "IntList") .unwrap(); assert_eq!(def_hash(int_list_def), "b082192bd0c99202"); } /// a ClassDef with no doc, no superclass, and an empty /// methods list serialises to a stable canonical form. Two construction /// paths (default-elided optionals vs. JSON without those keys at all) /// hash bit-identically. #[test] fn iter22b1_classdef_empty_optionals_hash_stable() { let bare = Def::Class(ClassDef { name: "Empty".into(), param: "a".into(), superclass: None, methods: vec![], doc: None, }); let json = r#"{"kind":"class","name":"Empty","param":"a","methods":[]}"#; let parsed: Def = serde_json::from_str(json).unwrap(); assert_eq!( def_hash(&bare), def_hash(&parsed), "ClassDef with elided optionals must hash identically to construction with explicit None" ); } /// adding `constraints` to `Type::Forall` must NOT alter /// canonical-JSON bytes of any pre-22b.2 polymorphic type. #[test] fn forall_without_constraints_hashes_bit_identical_to_pre_22b2() { let t = Type::Forall { vars: vec!["a".into()], constraints: vec![], body: Box::new(Type::fn_owned( vec![Type::Var { name: "a".into() }], Type::Var { name: "a".into() }, vec![], )), }; let bytes = canonical::to_bytes(&t); let s = std::str::from_utf8(&bytes).unwrap(); assert!( !s.contains("constraints"), "Type::Forall serialised must omit `constraints` when empty; got: {s}" ); } /// pin the /// canonical-form hashes of the two cross-module fixtures migrated by /// `ail migrate-canonical-types` in the canonical-form migration. These hashes are the /// post-migration state. #[test] fn ct4_migrated_fixtures_have_canonical_form_hashes() { let examples = examples_dir(); let ord_mod = ailang_surface::load_module(&examples.join("ordering_match.ail")) .expect("examples/ordering_match.ail loads"); let main_def = ord_mod.defs.iter().find(|d| d.name() == "main").unwrap(); // The hash reflects two successive corpus migrations: // (1) the per-type-print-retirement that moved the body from // `(do io/print_int x)` to `(app print x)`; // (2) the io/print_str byte-faithful-print fix (Gitea #29) that // wrapped the `(app print x)` with an explicit // `(seq ... (do io/print_str "\n"))` newline emission, because // io/print_str no longer adds a trailing newline. assert_eq!( def_hash(main_def), "602d7a6d6ba72bc4", "ordering_match::main canonical hash must match captured post-fputs-swap value" ); let dup_a_mod = ailang_surface::load_module(&examples.join("test_22b1_dup_a.ail")) .expect("examples/test_22b1_dup_a.ail loads"); assert_eq!(dup_a_mod.defs.len(), 1, "test_22b1_dup_a expected to have exactly 1 def (the instance)"); assert_eq!( def_hash(&dup_a_mod.defs[0]), "392c247f07de6517", "test_22b1_dup_a instance hash drifted; expected post-24.2 captured value" ); let dup_classmod_mod = ailang_surface::load_module(&examples.join("test_22b1_dup_classmod.ail")) .expect("examples/test_22b1_dup_classmod.ail loads"); assert_eq!(dup_classmod_mod.defs.len(), 1, "test_22b1_dup_classmod expected to have exactly 1 def (class TShow)"); assert_eq!( def_hash(&dup_classmod_mod.defs[0]), "4ca71c7d8212c96a", "test_22b1_dup_classmod class TShow canonical hash; post-24.2 captured value" ); } /// re-assert that the canonical-form tightening did NOT /// change hashes of intra-module-only fixtures. #[test] fn ct4_unmigrated_fixtures_remain_bit_identical() { let examples = examples_dir(); let sum_mod = ailang_surface::load_module(&examples.join("sum.ail")) .expect("examples/sum.ail loads"); let sum_def = sum_mod.defs.iter().find(|d| d.name() == "sum").unwrap(); assert_eq!(def_hash(sum_def), "19920ec4123d35d6", "sum.sum hash drifted across canonical-form tightening — unexpected"); let list_mod = ailang_surface::load_module(&examples.join("list.ail")) .expect("examples/list.ail loads"); let int_list_def = list_mod.defs.iter().find(|d| d.name() == "IntList").unwrap(); assert_eq!(def_hash(int_list_def), "b082192bd0c99202", "list.IntList hash drifted across canonical-form tightening — unexpected"); }