git-ents.gitmain
⌘K
foforge
eval.rs426 lines · 15.4 KB · rusthistorycomment on this file
1//! Evaluation integration tests on a synthetic repo: the
2//! staged-pipeline and fan-in composition idioms handled incrementally
3//! (the Phase 3 → 4 gate criterion), the exclusion idiom including a
4//! subtrahend shrink, the work set, and the generation-number read
5//! bound of `query.incremental`.
6
7#![expect(
8 clippy::expect_used,
9 clippy::indexing_slicing,
10 reason = "test code: fixture indexing panics are test failures"
11)]
12
13use std::collections::BTreeSet;
14
15use ents_model::Status;
16use ents_query::{Evaluator, Query, Transition};
17use ents_testutil::{
18 CountingFind, MemRefStore, ObjectStore, advance_ref, empty_tree, record_result,
19};
20use gix_hash::ObjectId;
21use gix_ref_store::RefStoreRead as _;
22use rstest::rstest;
23
24fn parse(input: &str) -> Query {
25 input.parse().expect("valid query in test")
26}
27
28fn short(oid: ObjectId) -> String {
29 oid.to_string().get(..12).expect("40 hex chars").to_owned()
30}
31
32fn set(oids: &[ObjectId]) -> BTreeSet<ObjectId> {
33 oids.iter().copied().collect()
34}
35
36/// The transition a fixture mutation just performed, reconstructed from
37/// the refname and its old/new tips.
38fn transition(name: &str, old: Option<ObjectId>, new: Option<ObjectId>) -> Transition {
39 Transition {
40 name: name.try_into().expect("valid refname"),
41 old,
42 new,
43 }
44}
45
46/// Record a result and return the transition that landed it.
47fn record(
48 refs: &MemRefStore,
49 objects: &ObjectStore,
50 effect: &str,
51 tested: ObjectId,
52 status: Status,
53 seconds: i64,
54) -> Transition {
55 let short = short(tested);
56 let tip = record_result(refs, objects, effect, &short, status, None, seconds);
57 transition(
58 &format!("refs/meta/results/{effect}/{short}"),
59 None,
60 Some(tip),
61 )
62}
63
64// ---------------------------------------------------------------------
65// The staged-pipeline idiom, both transition directions.
66// ---------------------------------------------------------------------
67
68#[rstest]
69// @relation(query.incremental, query.results, scope=function, role=Verifies)
70fn staged_pipeline_fires_when_the_result_lands() {
71 let refs = MemRefStore::default();
72 let objects = ObjectStore::default();
73 let commits = advance_ref(&refs, &objects, "refs/heads/main", 3, 100);
74 let trigger = parse("rev(refs/heads/main) & results(unit, pass)");
75 let evaluator = Evaluator::new(&refs, &objects);
76
77 // Advancing main alone enters nothing: no unit results yet.
78 let advance = transition("refs/heads/main", None, Some(commits[2]));
79 assert!(
80 evaluator
81 .entry_set(&trigger, &advance)
82 .expect("evaluates")
83 .is_empty()
84 );
85
86 // A passing unit result for the middle commit: exactly it enters.
87 let landed = record(&refs, &objects, "unit", commits[1], Status::Pass, 300);
88 assert_eq!(
89 evaluator.entry_set(&trigger, &landed).expect("evaluates"),
90 set(&[commits[1]])
91 );
92
93 // A failing unit result for the tip enters nothing.
94 let failed = record(&refs, &objects, "unit", commits[2], Status::Fail, 310);
95 assert!(
96 evaluator
97 .entry_set(&trigger, &failed)
98 .expect("evaluates")
99 .is_empty()
100 );
101}
102
103#[rstest]
104// @relation(query.incremental, scope=function, role=Verifies)
105fn staged_pipeline_fires_when_the_rev_side_arrives_second() {
106 let refs = MemRefStore::default();
107 let objects = ObjectStore::default();
108 // The commit exists on a side branch, its result is already
109 // recorded, and only then does main advance to include it.
110 let commits = advance_ref(&refs, &objects, "refs/heads/dev", 2, 100);
111 record(&refs, &objects, "unit", commits[1], Status::Pass, 200);
112
113 let trigger = parse("rev(refs/heads/main) & results(unit, pass)");
114 let evaluator = Evaluator::new(&refs, &objects);
115
116 refs.set_str("refs/heads/main", commits[1]);
117 let advance = transition("refs/heads/main", None, Some(commits[1]));
118 // Both dev commits enter rev(main); only the tested one has a pass.
119 assert_eq!(
120 evaluator.entry_set(&trigger, &advance).expect("evaluates"),
121 set(&[commits[1]])
122 );
123}
124
125// ---------------------------------------------------------------------
126// The fan-in idiom: fires when the last prerequisite lands, in
127// whichever order the underlying refs moved.
128// ---------------------------------------------------------------------
129
130#[rstest]
131#[case::a_then_b(true)]
132#[case::b_then_a(false)]
133// @relation(query.incremental, query.results, scope=function, role=Verifies)
134fn fan_in_fires_once_when_the_last_prerequisite_lands(#[case] a_first: bool) {
135 let refs = MemRefStore::default();
136 let objects = ObjectStore::default();
137 let commits = advance_ref(&refs, &objects, "refs/heads/main", 1, 100);
138 let tested = commits[0];
139 let trigger = parse("results(unit, pass) & results(integ, pass)");
140 let evaluator = Evaluator::new(&refs, &objects);
141
142 let (first, second) = if a_first {
143 ("unit", "integ")
144 } else {
145 ("integ", "unit")
146 };
147
148 let first_landing = record(&refs, &objects, first, tested, Status::Pass, 200);
149 assert!(
150 evaluator
151 .entry_set(&trigger, &first_landing)
152 .expect("evaluates")
153 .is_empty(),
154 "one prerequisite alone must not fire"
155 );
156
157 let second_landing = record(&refs, &objects, second, tested, Status::Pass, 210);
158 assert_eq!(
159 evaluator
160 .entry_set(&trigger, &second_landing)
161 .expect("evaluates"),
162 set(&[tested]),
163 "the last prerequisite fires the fan-in"
164 );
165}
166
167// ---------------------------------------------------------------------
168// The exclusion idiom, including entry via a shrinking subtrahend.
169// ---------------------------------------------------------------------
170
171#[rstest]
172// @relation(query.set-ops, query.incremental, scope=function, role=Verifies)
173fn exclusion_skips_wip_branches_and_reenters_on_subtrahend_shrink() {
174 let refs = MemRefStore::default();
175 let objects = ObjectStore::default();
176 let main = advance_ref(&refs, &objects, "refs/heads/main", 2, 100);
177 let query = parse("rev(refs/heads/*) - rev(refs/heads/wip/*)");
178 let evaluator = Evaluator::new(&refs, &objects);
179
180 // A wip branch on top of main: its new commit enters both sides at
181 // once, so it never enters the difference.
182 let wip = advance_ref(&refs, &objects, "refs/heads/wip/x", 1, 200);
183 let wip_advance = transition("refs/heads/wip/x", None, Some(wip[0]));
184 assert!(
185 evaluator
186 .entry_set(&query, &wip_advance)
187 .expect("evaluates")
188 .is_empty()
189 );
190
191 // But main's own commits are in the difference.
192 assert_eq!(
193 evaluator.eval(&query).expect("evaluates"),
194 set(&[main[0], main[1]])
195 );
196
197 // Deleting the wip branch shrinks the subtrahend: its commit is
198 // still reachable from... nothing. Re-point wip at main's tip
199 // first, so main's tip is temporarily excluded, then delete.
200 refs.set_str("refs/heads/wip/x", main[1]);
201 let repoint = transition("refs/heads/wip/x", Some(wip[0]), Some(main[1]));
202 assert!(
203 evaluator
204 .entry_set(&query, &repoint)
205 .expect("evaluates")
206 .is_empty(),
207 "pointing wip at existing commits only removes from the difference"
208 );
209 assert_eq!(evaluator.eval(&query).expect("evaluates"), BTreeSet::new());
210
211 let wip_name: gix::refs::FullName = "refs/heads/wip/x".try_into().expect("valid");
212 refs.remove(wip_name.as_ref());
213 let deletion = transition("refs/heads/wip/x", Some(main[1]), None);
214 // The subtrahend shrank: main's commits re-enter the difference.
215 assert_eq!(
216 evaluator.entry_set(&query, &deletion).expect("evaluates"),
217 set(&[main[0], main[1]])
218 );
219}
220
221// ---------------------------------------------------------------------
222// The work set: trigger − results(self, any).
223// ---------------------------------------------------------------------
224
225#[rstest]
226// @relation(query.workset, scope=function, role=Verifies)
227fn work_set_subtracts_any_recorded_status_by_refname_scan() {
228 let refs = MemRefStore::default();
229 let objects = ObjectStore::default();
230 let first = advance_ref(&refs, &objects, "refs/heads/main", 1, 100);
231 let trigger = parse("rev(refs/heads/main)");
232 let evaluator = Evaluator::new(&refs, &objects);
233
234 // Advance by two; an *error* result for the first new commit is
235 // already recorded (a terminal infrastructure verdict counts as
236 // recorded — the obligation is discharged, `query.workset`).
237 let new = advance_ref(&refs, &objects, "refs/heads/main", 2, 200);
238 record(&refs, &objects, "unit", new[0], Status::Error, 250);
239
240 let advance = transition("refs/heads/main", Some(first[0]), Some(new[1]));
241 assert_eq!(
242 evaluator.entry_set(&trigger, &advance).expect("evaluates"),
243 set(&[new[0], new[1]]),
244 "the trigger itself knows nothing about results"
245 );
246 assert_eq!(
247 evaluator
248 .work_set("unit", &trigger, &advance)
249 .expect("evaluates"),
250 set(&[new[1]]),
251 "the effect's own results ref is the sole materialization marker"
252 );
253}
254
255#[rstest]
256// @relation(query.workset, query.monotone, scope=function, role=Verifies)
257fn outstanding_reconstructs_the_obligation_set_from_repository_state() {
258 let refs = MemRefStore::default();
259 let objects = ObjectStore::default();
260 let commits = advance_ref(&refs, &objects, "refs/heads/main", 3, 100);
261 record(&refs, &objects, "unit", commits[0], Status::Pass, 200);
262 record(&refs, &objects, "unit", commits[2], Status::Fail, 210);
263
264 let trigger = parse("rev(refs/heads/main)");
265 let evaluator = Evaluator::new(&refs, &objects);
266 // No pipeline state anywhere: the outstanding set falls out of ref
267 // state alone — exactly what a boot-time reconciliation scan needs.
268 assert_eq!(
269 evaluator.outstanding("unit", &trigger).expect("evaluates"),
270 set(&[commits[1]])
271 );
272}
273
274// ---------------------------------------------------------------------
275// meta() entry, and monotone non-retraction.
276// ---------------------------------------------------------------------
277
278#[rstest]
279// @relation(query.meta, query.monotone, scope=function, role=Verifies)
280fn meta_tips_enter_and_old_tips_are_not_retracted() {
281 let refs = MemRefStore::default();
282 let objects = ObjectStore::default();
283 let query = parse("meta(refs/meta/issues/*)");
284 let evaluator = Evaluator::new(&refs, &objects);
285
286 let tip1 = record_result(&refs, &objects, "unused", "seed", Status::Pass, None, 90);
287 let _ = tip1; // keep the results namespace non-empty and irrelevant
288
289 let tree = empty_tree(&objects);
290 let write = |parents: Vec<ObjectId>, seconds: i64| {
291 ents_testutil::write_commit(
292 &objects,
293 &ents_testutil::CommitSpec {
294 tree,
295 parents,
296 message: format!("issue mutation at {seconds}"),
297 seconds,
298 },
299 None,
300 )
301 };
302 let first = write(vec![], 100);
303 refs.set_str("refs/meta/issues/7", first);
304 let created = transition("refs/meta/issues/7", None, Some(first));
305 assert_eq!(
306 evaluator.entry_set(&query, &created).expect("evaluates"),
307 set(&[first])
308 );
309
310 let second = write(vec![first], 110);
311 refs.set_str("refs/meta/issues/7", second);
312 let advanced = transition("refs/meta/issues/7", Some(first), Some(second));
313 // Only the new tip enters; the old tip leaving the set retracts
314 // nothing — it simply stops being a tip.
315 assert_eq!(
316 evaluator.entry_set(&query, &advanced).expect("evaluates"),
317 set(&[second])
318 );
319}
320
321#[rstest]
322// @relation(query.monotone, scope=function, role=Verifies)
323fn a_force_push_shrinks_the_set_without_reentering_survivors() {
324 let refs = MemRefStore::default();
325 let objects = ObjectStore::default();
326 let commits = advance_ref(&refs, &objects, "refs/heads/main", 3, 100);
327 let query = parse("rev(refs/heads/main)");
328 let evaluator = Evaluator::new(&refs, &objects);
329
330 // Force main back to its first commit: the set shrinks, nothing
331 // enters, nothing is retracted.
332 refs.set_str("refs/heads/main", commits[0]);
333 let force = transition("refs/heads/main", Some(commits[2]), Some(commits[0]));
334 assert!(
335 evaluator
336 .entry_set(&query, &force)
337 .expect("evaluates")
338 .is_empty()
339 );
340
341 // Re-advancing over the same commits re-enters them: whether they
342 // fire again is the work set's job, not the entry set's — results
343 // already written keep them discharged.
344 refs.set_str("refs/heads/main", commits[2]);
345 let restore = transition("refs/heads/main", Some(commits[0]), Some(commits[2]));
346 assert_eq!(
347 evaluator.entry_set(&query, &restore).expect("evaluates"),
348 set(&[commits[1], commits[2]])
349 );
350}
351
352// ---------------------------------------------------------------------
353// The generation-number bound (`query.incremental`).
354// ---------------------------------------------------------------------
355
356#[rstest]
357// @relation(query.incremental, scope=function, role=Verifies)
358fn entry_after_a_one_commit_advance_reads_a_bounded_frontier() {
359 const HISTORY: usize = 300;
360 const READ_BUDGET: usize = 25;
361
362 let refs = MemRefStore::default();
363 let objects = ObjectStore::default();
364 let commits = advance_ref(&refs, &objects, "refs/heads/main", HISTORY, 1_000);
365 let tip = *commits.last().expect("non-empty");
366
367 let counting = CountingFind::new(&objects);
368 let evaluator = Evaluator::new(&refs, &counting);
369 let query = parse("rev(refs/heads/main) | results(unit, pass)");
370
371 // Warm the evaluator the way a long-lived receive process is warm:
372 // one reconciliation pass caches commit structure for the history.
373 let full = evaluator.eval(&query).expect("evaluates");
374 assert_eq!(full.len(), HISTORY);
375 let warm_reads = counting.reads();
376 assert!(warm_reads >= HISTORY, "the warm-up walk pays for history");
377
378 // One commit lands. The entry set must be computed from the
379 // frontier, not by re-walking three hundred commits.
380 let new = advance_ref(&refs, &objects, "refs/heads/main", 1, 2_000);
381 counting.reset();
382 let entered = evaluator
383 .entry_set(
384 &query,
385 &transition("refs/heads/main", Some(tip), Some(new[0])),
386 )
387 .expect("evaluates");
388 assert_eq!(entered, set(&[new[0]]));
389 assert!(
390 counting.reads() <= READ_BUDGET,
391 "a one-commit advance read {} objects; the frontier bound allows {}",
392 counting.reads(),
393 READ_BUDGET
394 );
395}
396
397#[rstest]
398// @relation(query.footprint, query.incremental, scope=function, role=Verifies)
399fn transitions_outside_the_footprint_are_free() {
400 let refs = MemRefStore::default();
401 let objects = ObjectStore::default();
402 let commits = advance_ref(&refs, &objects, "refs/heads/main", 2, 100);
403 advance_ref(&refs, &objects, "refs/heads/dev", 2, 200);
404
405 let counting = CountingFind::new(&objects);
406 let evaluator = Evaluator::new(&refs, &counting);
407 let query = parse("rev(refs/heads/main)");
408
409 let dev_tip = refs
410 .get("refs/heads/dev".try_into().expect("valid"))
411 .ok()
412 .flatten();
413 let unrelated = transition("refs/heads/dev", None, dev_tip);
414 assert!(
415 evaluator
416 .entry_set(&query, &unrelated)
417 .expect("evaluates")
418 .is_empty()
419 );
420 assert_eq!(
421 counting.reads(),
422 0,
423 "a non-matching footprint must short-circuit before any object read"
424 );
425 let _ = commits;
426}