⎇ d3649bfd1a80153652c2a028315369bb4b184024
crates/modules/nakui/core/tests/manifest_validation.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
refactor(nakui-core): KCL → Nickel — kcl_wrapper reemplazado por evaluación in-process
1//! Manifest::validate covers the contract between authors (humans or AI)
2//! and Nakui. Each test inline-builds a manifest with one specific defect
3//! and asserts the right diagnostic fires.
4//!
5//! Most tests point at `modules/treasury/` so the schema/script paths
6//! resolve. Two tests need a synthetic tempdir to express their defect
7//! (missing schema file, duplicate schema across files).
8
9use std::fs;
10use std::path::{Path, PathBuf};
11
12use nakui_core::executor::Executor;
13use nakui_core::manifest::{
14 ConserveRule, Invariants, Manifest, MorphismInput, MorphismSpec, ValidationError,
15};
16use uuid::Uuid;
17
18fn workspace_root() -> PathBuf {
19 Path::new(env!("CARGO_MANIFEST_DIR"))
20 .parent()
21 .expect("workspace root above core/")
22 .to_path_buf()
23}
24
25fn treasury_dir() -> PathBuf {
26 workspace_root().join("modules/treasury")
27}
28
29fn caja_input() -> MorphismInput {
30 MorphismInput {
31 role: "caja".into(),
32 entity: "Caja".into(),
33 }
34}
35
36fn baseline_morphism() -> MorphismSpec {
37 MorphismSpec {
38 name: "test_op".into(),
39 inputs: vec![caja_input()],
40 reads: vec!["caja.saldo".into()],
41 writes: vec!["caja.saldo".into()],
42 invariants: Invariants::default(),
43 depends_on: vec![],
44 script: "morphisms/register_cash_move.rhai".into(),
45 }
46}
47
48fn baseline_manifest() -> Manifest {
49 Manifest {
50 module: "test".into(),
51 schemas: vec![],
52 morphisms: vec![baseline_morphism()],
53 }
54}
55
56#[test]
57fn production_modules_validate_clean() {
58 for name in ["treasury", "inventory", "sales"] {
59 let dir = workspace_root().join("modules").join(name);
60 let manifest = Manifest::load(&dir.join("nsmc.json"))
61 .unwrap_or_else(|e| panic!("load {}: {}", name, e));
62 manifest
63 .validate(&dir)
64 .unwrap_or_else(|e| panic!("validate {}: {}", name, e));
65 }
66}
67
68#[test]
69fn rejects_duplicate_morphism_name() {
70 let mut m = baseline_manifest();
71 m.morphisms.push(baseline_morphism()); // same name as the first
72 match m.validate(&treasury_dir()) {
73 Err(ValidationError::DuplicateMorphism(name)) => assert_eq!(name, "test_op"),
74 other => panic!("expected DuplicateMorphism, got {:?}", other),
75 }
76}
77
78#[test]
79fn rejects_duplicate_role_within_morphism() {
80 let mut m = baseline_manifest();
81 m.morphisms[0].inputs.push(caja_input()); // same role twice
82 match m.validate(&treasury_dir()) {
83 Err(ValidationError::DuplicateRole { morphism, role }) => {
84 assert_eq!(morphism, "test_op");
85 assert_eq!(role, "caja");
86 }
87 other => panic!("expected DuplicateRole, got {:?}", other),
88 }
89}
90
91#[test]
92fn rejects_input_unknown_entity() {
93 let mut m = baseline_manifest();
94 m.morphisms[0].inputs[0].entity = "Banana".into();
95 match m.validate(&treasury_dir()) {
96 Err(ValidationError::InputUnknownEntity {
97 morphism,
98 entity,
99 known,
100 }) => {
101 assert_eq!(morphism, "test_op");
102 assert_eq!(entity, "Banana");
103 assert!(known.contains(&"Caja".to_string()));
104 }
105 other => panic!("expected InputUnknownEntity, got {:?}", other),
106 }
107}
108
109#[test]
110fn rejects_writes_unknown_role() {
111 let mut m = baseline_manifest();
112 m.morphisms[0].writes = vec!["ghost.saldo".into()];
113 match m.validate(&treasury_dir()) {
114 Err(ValidationError::WritesUnknownRole {
115 morphism,
116 token,
117 role,
118 ..
119 }) => {
120 assert_eq!(morphism, "test_op");
121 assert_eq!(token, "ghost.saldo");
122 assert_eq!(role, "ghost");
123 }
124 other => panic!("expected WritesUnknownRole, got {:?}", other),
125 }
126}
127
128#[test]
129fn rejects_writes_unknown_entity() {
130 let mut m = baseline_manifest();
131 m.morphisms[0].writes = vec!["BananaSplit".into()];
132 match m.validate(&treasury_dir()) {
133 Err(ValidationError::WritesUnknownEntity { morphism, token }) => {
134 assert_eq!(morphism, "test_op");
135 assert_eq!(token, "BananaSplit");
136 }
137 other => panic!("expected WritesUnknownEntity, got {:?}", other),
138 }
139}
140
141#[test]
142fn rejects_conserve_unknown_entity() {
143 let mut m = baseline_manifest();
144 m.morphisms[0].invariants.conserve = vec![ConserveRule {
145 entity: "Banana".into(),
146 field: "x".into(),
147 group_by: None,
148 }];
149 match m.validate(&treasury_dir()) {
150 Err(ValidationError::ConserveUnknownEntity { morphism, entity }) => {
151 assert_eq!(morphism, "test_op");
152 assert_eq!(entity, "Banana");
153 }
154 other => panic!("expected ConserveUnknownEntity, got {:?}", other),
155 }
156}
157
158#[test]
159fn rejects_depends_on_unknown_morphism() {
160 let mut m = baseline_manifest();
161 m.morphisms[0].depends_on = vec!["ghost_morphism".into()];
162 match m.validate(&treasury_dir()) {
163 Err(ValidationError::DependsOnUnknown { morphism, dep }) => {
164 assert_eq!(morphism, "test_op");
165 assert_eq!(dep, "ghost_morphism");
166 }
167 other => panic!("expected DependsOnUnknown, got {:?}", other),
168 }
169}
170
171#[test]
172fn rejects_missing_script() {
173 let mut m = baseline_manifest();
174 m.morphisms[0].script = "morphisms/ghost.rhai".into();
175 match m.validate(&treasury_dir()) {
176 Err(ValidationError::ScriptMissing { morphism, script, .. }) => {
177 assert_eq!(morphism, "test_op");
178 assert_eq!(script, "morphisms/ghost.rhai");
179 }
180 other => panic!("expected ScriptMissing, got {:?}", other),
181 }
182}
183
184#[test]
185fn rejects_missing_schema_file() {
186 let mut m = baseline_manifest();
187 m.schemas = vec!["nonexistent.k".into()];
188 match m.validate(&treasury_dir()) {
189 Err(ValidationError::SchemaFileMissing { path, .. }) => {
190 assert_eq!(path, "nonexistent.k");
191 }
192 other => panic!("expected SchemaFileMissing, got {:?}", other),
193 }
194}
195
196#[test]
197fn rejects_duplicate_schema_across_files() {
198 // Synthesize a tempdir with two .ncl files that both declare
199 // `Caja` en el record top-level.
200 let tmp = std::env::temp_dir().join(format!("nakui_dup_{}", Uuid::new_v4()));
201 fs::create_dir_all(&tmp).unwrap();
202 fs::create_dir_all(tmp.join("morphisms")).unwrap();
203 fs::write(
204 tmp.join("a.ncl"),
205 "{\n Caja = { saldo | Number },\n}\n",
206 )
207 .unwrap();
208 fs::write(
209 tmp.join("b.ncl"),
210 "{\n Caja = { monto | Number },\n}\n",
211 )
212 .unwrap();
213 fs::write(tmp.join("morphisms/op.rhai"), "[]").unwrap();
214
215 let m = Manifest {
216 module: "dup".into(),
217 schemas: vec!["a.ncl".into(), "b.ncl".into()],
218 morphisms: vec![MorphismSpec {
219 name: "op".into(),
220 inputs: vec![MorphismInput {
221 role: "caja".into(),
222 entity: "Caja".into(),
223 }],
224 reads: vec![],
225 writes: vec![],
226 invariants: Invariants::default(),
227 depends_on: vec![],
228 script: "morphisms/op.rhai".into(),
229 }],
230 };
231
232 match m.validate(&tmp) {
233 Err(ValidationError::DuplicateSchema { name, files }) => {
234 assert_eq!(name, "Caja");
235 assert!(files.contains(&"a.ncl".to_string()));
236 assert!(files.contains(&"b.ncl".to_string()));
237 }
238 other => panic!("expected DuplicateSchema, got {:?}", other),
239 }
240
241 let _ = fs::remove_dir_all(&tmp);
242}
243
244#[test]
245fn executor_load_module_runs_validation() {
246 // Synthesize a module dir whose manifest references a missing script —
247 // load_module must surface ManifestValidation, not a runtime kernel error.
248 let tmp = std::env::temp_dir().join(format!("nakui_bad_{}", Uuid::new_v4()));
249 fs::create_dir_all(&tmp).unwrap();
250 fs::write(
251 tmp.join("schema.ncl"),
252 "{\n Caja = {\n saldo | std.contract.from_predicate (fun n => std.is_number n && n >= 0),\n },\n}\n",
253 )
254 .unwrap();
255 fs::write(
256 tmp.join("nsmc.json"),
257 r#"{
258 "module": "bad",
259 "morphisms": [{
260 "name": "op",
261 "inputs": [{"role": "caja", "entity": "Caja"}],
262 "reads": [],
263 "writes": ["caja.saldo"],
264 "depends_on": [],
265 "script": "morphisms/missing.rhai"
266 }]
267 }"#,
268 )
269 .unwrap();
270
271 let err = match Executor::load_module(&tmp) {
272 Ok(_) => panic!("must fail validation"),
273 Err(e) => e,
274 };
275 let msg = err.to_string();
276 assert!(
277 msg.contains("validation") && msg.contains("missing.rhai"),
278 "expected validation diagnostic naming the missing script, got `{}`",
279 msg
280 );
281
282 let _ = fs::remove_dir_all(&tmp);
283}