⎇ 4d50bfc5879839a835e26ffda3bbc7ae3b927cc7
crates/modules/nakui/core/tests/graph.rs
Historia
Quién tocó qué
crudo
chore: absorbe nakui (ERP matemático) en modules/nakui
1//! ManifestGraph: cycle detection on `depends_on`, data-flow indexes for
2//! `reads`/`writes`, and the `affected_by` query that powers dirty-marking.
3
4use std::path::{Path, PathBuf};
5
6use nakui_core::executor::Executor;
7use nakui_core::graph::{DirtyTracker, GraphError, ManifestGraph};
8use nakui_core::manifest::{
9 ConserveRule, Invariants, Manifest, MorphismInput, MorphismSpec,
10};
11
12fn workspace_root() -> PathBuf {
13 Path::new(env!("CARGO_MANIFEST_DIR"))
14 .parent()
15 .expect("workspace root above core/")
16 .to_path_buf()
17}
18
19fn module(name: &str) -> PathBuf {
20 workspace_root().join("modules").join(name)
21}
22
23fn morphism(name: &str, depends_on: Vec<String>) -> MorphismSpec {
24 MorphismSpec {
25 name: name.into(),
26 inputs: vec![MorphismInput {
27 role: "caja".into(),
28 entity: "Caja".into(),
29 }],
30 reads: vec!["caja.saldo".into()],
31 writes: vec!["caja.saldo".into()],
32 invariants: Invariants::default(),
33 depends_on,
34 script: "morphisms/register_cash_move.rhai".into(),
35 }
36}
37
38fn manifest_with(morphisms: Vec<MorphismSpec>) -> Manifest {
39 Manifest {
40 module: "graph_test".into(),
41 schemas: vec![],
42 morphisms,
43 }
44}
45
46#[test]
47fn detects_two_node_cycle() {
48 let m = manifest_with(vec![
49 morphism("a", vec!["b".into()]),
50 morphism("b", vec!["a".into()]),
51 ]);
52 match ManifestGraph::build(&m) {
53 Err(GraphError::Cycle(names)) => {
54 assert!(names.contains(&"a".to_string()));
55 assert!(names.contains(&"b".to_string()));
56 }
57 other => panic!("expected Cycle, got {:?}", other),
58 }
59}
60
61#[test]
62fn detects_self_loop() {
63 let m = manifest_with(vec![morphism("loop", vec!["loop".into()])]);
64 match ManifestGraph::build(&m) {
65 Err(GraphError::Cycle(names)) => {
66 assert_eq!(names, vec!["loop".to_string()]);
67 }
68 other => panic!("expected Cycle, got {:?}", other),
69 }
70}
71
72#[test]
73fn detects_three_node_cycle() {
74 let m = manifest_with(vec![
75 morphism("a", vec!["b".into()]),
76 morphism("b", vec!["c".into()]),
77 morphism("c", vec!["a".into()]),
78 ]);
79 match ManifestGraph::build(&m) {
80 Err(GraphError::Cycle(names)) => {
81 assert_eq!(names.len(), 3);
82 }
83 other => panic!("expected Cycle, got {:?}", other),
84 }
85}
86
87#[test]
88fn topological_order_respects_explicit_dependencies() {
89 // a <- b <- c (c depends on b depends on a)
90 let m = manifest_with(vec![
91 morphism("a", vec![]),
92 morphism("b", vec!["a".into()]),
93 morphism("c", vec!["b".into()]),
94 ]);
95 let g = ManifestGraph::build(&m).expect("acyclic");
96 let order = g.topological_order();
97 let pos = |n: &str| order.iter().position(|x| x == n).unwrap();
98 assert!(pos("a") < pos("b"));
99 assert!(pos("b") < pos("c"));
100}
101
102#[test]
103fn unknown_depends_on_target_errors() {
104 let m = manifest_with(vec![morphism("a", vec!["ghost".into()])]);
105 match ManifestGraph::build(&m) {
106 Err(GraphError::UnknownMorphism(name)) => assert_eq!(name, "ghost"),
107 other => panic!("expected UnknownMorphism, got {:?}", other),
108 }
109}
110
111#[test]
112fn treasury_data_flow_indexes_match_manifest() {
113 let exec = Executor::load_module(module("treasury")).expect("load");
114 let g = &exec.graph;
115
116 // Both register_cash_move and transfer_between_cajas write Caja.saldo.
117 let mut writers: Vec<&str> = g.writers_of("Caja.saldo").iter().map(|s| s.as_str()).collect();
118 writers.sort();
119 assert_eq!(writers, vec!["register_cash_move", "transfer_between_cajas"]);
120
121 // Both read Caja.saldo too.
122 let mut readers: Vec<&str> = g.readers_of("Caja.saldo").iter().map(|s| s.as_str()).collect();
123 readers.sort();
124 assert_eq!(readers, vec!["register_cash_move", "transfer_between_cajas"]);
125
126 // Movimiento is written only by register_cash_move.
127 assert_eq!(
128 g.writers_of("Movimiento"),
129 &["register_cash_move".to_string()]
130 );
131
132 // Transferencia is written only by transfer_between_cajas.
133 assert_eq!(
134 g.writers_of("Transferencia"),
135 &["transfer_between_cajas".to_string()]
136 );
137
138 // Nothing in treasury reads Movimiento or Transferencia.
139 assert!(g.readers_of("Movimiento").is_empty());
140 assert!(g.readers_of("Transferencia").is_empty());
141}
142
143#[test]
144fn affected_by_excludes_self_and_finds_overlap() {
145 // A simple two-morphism manifest where one writes what the other reads.
146 let m = manifest_with(vec![
147 MorphismSpec {
148 name: "writer".into(),
149 inputs: vec![MorphismInput {
150 role: "caja".into(),
151 entity: "Caja".into(),
152 }],
153 reads: vec![],
154 writes: vec!["caja.saldo".into()],
155 invariants: Invariants::default(),
156 depends_on: vec![],
157 script: "morphisms/register_cash_move.rhai".into(),
158 },
159 MorphismSpec {
160 name: "reader".into(),
161 inputs: vec![MorphismInput {
162 role: "caja".into(),
163 entity: "Caja".into(),
164 }],
165 reads: vec!["caja.saldo".into()],
166 writes: vec![],
167 invariants: Invariants::default(),
168 depends_on: vec![],
169 script: "morphisms/register_cash_move.rhai".into(),
170 },
171 MorphismSpec {
172 name: "self_loop".into(),
173 inputs: vec![MorphismInput {
174 role: "caja".into(),
175 entity: "Caja".into(),
176 }],
177 reads: vec!["caja.saldo".into()],
178 writes: vec!["caja.saldo".into()],
179 invariants: Invariants::default(),
180 depends_on: vec![],
181 script: "morphisms/register_cash_move.rhai".into(),
182 },
183 ]);
184 let g = ManifestGraph::build(&m).expect("acyclic");
185
186 let mut affected = g.affected_by("writer");
187 affected.sort();
188 // writer writes Caja.saldo; readers are reader + self_loop, but
189 // self_loop is "writer"? no, self_loop is a separate morphism here,
190 // and it does read Caja.saldo so it's affected by writer.
191 assert_eq!(affected, vec!["reader", "self_loop"]);
192
193 // self_loop writes its own field but should not list itself.
194 let affected_self = g.affected_by("self_loop");
195 assert_eq!(affected_self, vec!["reader"]);
196}
197
198#[test]
199fn cross_module_graph_canonicalizes_to_entity_tokens() {
200 // sales/vender uses role "stock" (entity Stock) and role "caja" (entity Caja).
201 // Reads and writes should canonicalize to "Stock.cantidad" and "Caja.saldo".
202 let exec = Executor::load_module(module("sales")).expect("load sales");
203 let g = &exec.graph;
204
205 assert_eq!(g.writers_of("Stock.cantidad"), &["vender".to_string()]);
206 assert_eq!(g.writers_of("Caja.saldo"), &["vender".to_string()]);
207 assert_eq!(g.writers_of("Venta"), &["vender".to_string()]);
208
209 let reads = g.morphism_reads("vender");
210 assert!(reads.contains(&"Stock.cantidad".to_string()));
211 assert!(reads.contains(&"Caja.saldo".to_string()));
212 assert!(reads.contains(&"Caja.currency".to_string()));
213}
214
215#[test]
216fn executor_load_module_rejects_cyclic_manifest() {
217 // Synthesize a tempdir with a cyclic manifest and confirm Executor
218 // surfaces ExecError::Graph rather than running.
219 let tmp = std::env::temp_dir().join(format!("nakui_cycle_{}", uuid::Uuid::new_v4()));
220 std::fs::create_dir_all(tmp.join("morphisms")).unwrap();
221 std::fs::write(
222 tmp.join("schema.k"),
223 "schema Caja:\n saldo: int\n check:\n saldo >= 0\n",
224 )
225 .unwrap();
226 std::fs::write(tmp.join("morphisms/op.rhai"), "[]").unwrap();
227 std::fs::write(
228 tmp.join("nsmc.json"),
229 r#"{
230 "module": "cycle",
231 "morphisms": [
232 {"name": "a", "inputs": [{"role":"caja","entity":"Caja"}],
233 "reads": [], "writes": ["caja.saldo"], "depends_on": ["b"],
234 "script": "morphisms/op.rhai"},
235 {"name": "b", "inputs": [{"role":"caja","entity":"Caja"}],
236 "reads": [], "writes": ["caja.saldo"], "depends_on": ["a"],
237 "script": "morphisms/op.rhai"}
238 ]
239 }"#,
240 )
241 .unwrap();
242
243 let err = match Executor::load_module(&tmp) {
244 Ok(_) => panic!("must fail with cycle"),
245 Err(e) => e,
246 };
247 let msg = err.to_string();
248 assert!(msg.contains("graph") || msg.contains("cycle"),
249 "expected graph diagnostic, got `{}`", msg);
250
251 let _ = std::fs::remove_dir_all(&tmp);
252}
253
254#[test]
255fn dirty_tracker_marks_after_treasury_morphism() {
256 let exec = Executor::load_module(module("treasury")).expect("load");
257 let mut tracker = DirtyTracker::new();
258
259 // register_cash_move writes Caja.saldo + Movimiento. Both are read by
260 // transfer_between_cajas (Caja.saldo) but Movimiento is read by no one.
261 tracker.mark_dirty_after("register_cash_move", &exec.graph);
262
263 let dirty = tracker.dirty();
264 assert!(
265 dirty.contains(&"transfer_between_cajas".to_string()),
266 "transfer_between_cajas reads Caja.saldo, must be dirty after deposit; got {:?}",
267 dirty
268 );
269 assert!(
270 !tracker.is_dirty("register_cash_move"),
271 "self should not be marked dirty by its own write"
272 );
273}
274
275#[test]
276fn dirty_tracker_clear_works() {
277 let exec = Executor::load_module(module("treasury")).expect("load");
278 let mut tracker = DirtyTracker::new();
279 tracker.mark_dirty_after("transfer_between_cajas", &exec.graph);
280 let count_before = tracker.len();
281 assert!(count_before > 0);
282
283 let first = tracker.dirty().into_iter().next().unwrap();
284 tracker.clear(&first);
285 assert!(!tracker.is_dirty(&first));
286 assert_eq!(tracker.len(), count_before - 1);
287}
288
289#[test]
290fn dirty_tracker_accumulates_across_morphisms() {
291 // Manifest with three morphisms where each writes what the next reads.
292 // After running A then B, both readers should be marked.
293 let m = manifest_with(vec![
294 MorphismSpec {
295 name: "writer_a".into(),
296 inputs: vec![MorphismInput {
297 role: "caja".into(),
298 entity: "Caja".into(),
299 }],
300 reads: vec![],
301 writes: vec!["caja.saldo".into()],
302 invariants: Invariants::default(),
303 depends_on: vec![],
304 script: "morphisms/register_cash_move.rhai".into(),
305 },
306 MorphismSpec {
307 name: "writer_b".into(),
308 inputs: vec![MorphismInput {
309 role: "caja".into(),
310 entity: "Caja".into(),
311 }],
312 reads: vec![],
313 writes: vec!["Movimiento".into()],
314 invariants: Invariants::default(),
315 depends_on: vec![],
316 script: "morphisms/register_cash_move.rhai".into(),
317 },
318 MorphismSpec {
319 name: "reader_caja".into(),
320 inputs: vec![MorphismInput {
321 role: "caja".into(),
322 entity: "Caja".into(),
323 }],
324 reads: vec!["caja.saldo".into()],
325 writes: vec![],
326 invariants: Invariants::default(),
327 depends_on: vec![],
328 script: "morphisms/register_cash_move.rhai".into(),
329 },
330 MorphismSpec {
331 name: "reader_mov".into(),
332 inputs: vec![MorphismInput {
333 role: "caja".into(),
334 entity: "Caja".into(),
335 }],
336 reads: vec!["Movimiento".into()],
337 writes: vec![],
338 invariants: Invariants::default(),
339 depends_on: vec![],
340 script: "morphisms/register_cash_move.rhai".into(),
341 },
342 ]);
343 let g = ManifestGraph::build(&m).unwrap();
344 let mut tracker = DirtyTracker::new();
345
346 tracker.mark_dirty_after("writer_a", &g);
347 assert!(tracker.is_dirty("reader_caja"));
348 assert!(!tracker.is_dirty("reader_mov"));
349
350 tracker.mark_dirty_after("writer_b", &g);
351 assert!(tracker.is_dirty("reader_caja"));
352 assert!(tracker.is_dirty("reader_mov"));
353
354 assert_eq!(tracker.len(), 2);
355}