Skip to main content

cupel_testing/
chain.rs

1use cupel::SelectionReport;
2use cupel::analytics;
3use cupel::diagnostics::{ExcludedItem, ExclusionReason, IncludedItem};
4use cupel::model::{ContextBudget, ContextKind};
5
6/// A fluent assertion chain for inspecting a [`SelectionReport`].
7///
8/// Obtain an instance via [`SelectionReportAssertions::should()`](crate::SelectionReportAssertions::should).
9/// Assertion methods are chained on this struct and each return `&mut Self` so
10/// multiple checks can be composed in a single expression.
11pub struct SelectionReportAssertionChain<'a> {
12    pub(crate) report: &'a SelectionReport,
13}
14
15impl<'a> SelectionReportAssertionChain<'a> {
16    pub(crate) fn new(report: &'a SelectionReport) -> Self {
17        Self { report }
18    }
19
20    // ── Pattern 1: Inclusion ────────────────────────────────────────────────
21
22    /// Asserts that at least one included item has the given `kind`.
23    pub fn include_item_with_kind(&mut self, kind: ContextKind) -> &mut Self {
24        let included = &self.report.included;
25        if !included.iter().any(|i| i.item.kind() == &kind) {
26            let kinds: Vec<_> = included
27                .iter()
28                .map(|i| i.item.kind().as_str().to_string())
29                .collect::<std::collections::HashSet<_>>()
30                .into_iter()
31                .collect();
32            let kinds_str = kinds.join(", ");
33            panic!(
34                "include_item_with_kind({kind}) failed: Included contained 0 items with Kind={kind}. \
35                 Included had {count} items with kinds: [{kinds_str}].",
36                kind = kind,
37                count = included.len(),
38            );
39        }
40        self
41    }
42
43    // ── Pattern 2: Inclusion ────────────────────────────────────────────────
44
45    /// Asserts that at least one included item satisfies `predicate`.
46    pub fn include_item_matching(
47        &mut self,
48        predicate: impl Fn(&IncludedItem) -> bool,
49    ) -> &mut Self {
50        let included = &self.report.included;
51        if !included.iter().any(predicate) {
52            panic!(
53                "include_item_matching failed: no item in Included matched the predicate. \
54                 Included had {count} items.",
55                count = included.len(),
56            );
57        }
58        self
59    }
60
61    // ── Pattern 3: Inclusion ────────────────────────────────────────────────
62
63    /// Asserts that exactly `n` included items have the given `kind`.
64    pub fn include_exact_n_items_with_kind(&mut self, kind: ContextKind, n: usize) -> &mut Self {
65        let included = &self.report.included;
66        let actual = included.iter().filter(|i| i.item.kind() == &kind).count();
67        if actual != n {
68            panic!(
69                "include_exact_n_items_with_kind({kind}, {n}) failed: expected {n} items with Kind={kind} in Included, \
70                 but found {actual}. Included had {count} items total.",
71                kind = kind,
72                n = n,
73                actual = actual,
74                count = included.len(),
75            );
76        }
77        self
78    }
79
80    // ── Pattern 4: Exclusion ────────────────────────────────────────────────
81
82    /// Asserts that at least one excluded item carries the given `reason` variant.
83    pub fn exclude_item_with_reason(&mut self, reason: ExclusionReason) -> &mut Self {
84        let excluded = &self.report.excluded;
85        let found = excluded
86            .iter()
87            .any(|e| std::mem::discriminant(&e.reason) == std::mem::discriminant(&reason));
88        if !found {
89            let reasons: Vec<_> = excluded
90                .iter()
91                .map(|e| format!("{:?}", e.reason))
92                .collect::<std::collections::HashSet<_>>()
93                .into_iter()
94                .collect();
95            let reasons_str = reasons.join(", ");
96            panic!(
97                "exclude_item_with_reason({reason:?}) failed: no excluded item had reason {reason:?}. \
98                 Excluded had {count} items with reasons: [{reasons_str}].",
99                reason = reason,
100                count = excluded.len(),
101            );
102        }
103        self
104    }
105
106    // ── Pattern 5: Exclusion ────────────────────────────────────────────────
107
108    /// Asserts that at least one excluded item satisfies `predicate` and has the given `reason` variant.
109    pub fn exclude_item_matching_with_reason(
110        &mut self,
111        predicate: impl Fn(&ExcludedItem) -> bool,
112        reason: ExclusionReason,
113    ) -> &mut Self {
114        let excluded = &self.report.excluded;
115        let predicate_matches: Vec<_> = excluded.iter().filter(|e| predicate(e)).collect();
116        let found = predicate_matches
117            .iter()
118            .any(|e| std::mem::discriminant(&e.reason) == std::mem::discriminant(&reason));
119        if !found {
120            let actual_reasons: Vec<_> = predicate_matches
121                .iter()
122                .map(|e| format!("{:?}", e.reason))
123                .collect::<std::collections::HashSet<_>>()
124                .into_iter()
125                .collect();
126            let actual_reasons_str = actual_reasons.join(", ");
127            panic!(
128                "exclude_item_matching_with_reason(reason={reason:?}) failed: predicate matched {count} \
129                 excluded item(s) but none had reason {reason:?}. Matched items had reasons: [{actual_reasons_str}].",
130                reason = reason,
131                count = predicate_matches.len(),
132            );
133        }
134        self
135    }
136
137    // ── Pattern 6: Exclusion ────────────────────────────────────────────────
138
139    /// Asserts that an excluded item matching `predicate` was excluded due to
140    /// `BudgetExceeded` with exactly the given `expected_item_tokens` and
141    /// `expected_available_tokens`.
142    pub fn have_excluded_item_with_budget_details(
143        &mut self,
144        predicate: impl Fn(&ExcludedItem) -> bool,
145        expected_item_tokens: i64,
146        expected_available_tokens: i64,
147    ) -> &mut Self {
148        let excluded = &self.report.excluded;
149        // Find the first predicate-matching BudgetExceeded item.
150        let budget_match = excluded
151            .iter()
152            .find(|e| predicate(e) && matches!(e.reason, ExclusionReason::BudgetExceeded { .. }));
153        match budget_match {
154            Some(e) => {
155                if let ExclusionReason::BudgetExceeded {
156                    item_tokens: ait,
157                    available_tokens: aat,
158                } = e.reason
159                {
160                    if ait != expected_item_tokens || aat != expected_available_tokens {
161                        panic!(
162                            "have_excluded_item_with_budget_details failed: expected BudgetExceeded \
163                             with item_tokens={eIT}, available_tokens={eAT}, \
164                             but found item_tokens={aIT}, available_tokens={aAT}.",
165                            eIT = expected_item_tokens,
166                            eAT = expected_available_tokens,
167                            aIT = ait,
168                            aAT = aat,
169                        );
170                    }
171                }
172            }
173            None => {
174                panic!(
175                    "have_excluded_item_with_budget_details failed: expected BudgetExceeded \
176                     with item_tokens={eIT}, available_tokens={eAT}, \
177                     but no matching item had reason BudgetExceeded.",
178                    eIT = expected_item_tokens,
179                    eAT = expected_available_tokens,
180                );
181            }
182        }
183        self
184    }
185
186    // ── Pattern 7: Exclusion ────────────────────────────────────────────────
187
188    /// Asserts that no excluded item has the given `kind`.
189    pub fn have_no_exclusions_for_kind(&mut self, kind: ContextKind) -> &mut Self {
190        let excluded = &self.report.excluded;
191        let matching: Vec<_> = excluded.iter().filter(|e| e.item.kind() == &kind).collect();
192        if !matching.is_empty() {
193            let first = &matching[0];
194            panic!(
195                "have_no_exclusions_for_kind({kind}) failed: found {count} excluded item(s) with Kind={kind}. \
196                 First: score={score:.4}, reason={reason:?}.",
197                kind = kind,
198                count = matching.len(),
199                score = first.score,
200                reason = first.reason,
201            );
202        }
203        self
204    }
205
206    // ── Pattern 8: Aggregate ────────────────────────────────────────────────
207
208    /// Asserts that the excluded list has at least `n` items.
209    pub fn have_at_least_n_exclusions(&mut self, n: usize) -> &mut Self {
210        let actual = self.report.excluded.len();
211        if actual < n {
212            panic!(
213                "have_at_least_n_exclusions({n}) failed: expected at least {n} excluded items, \
214                 but Excluded had {actual}.",
215            );
216        }
217        self
218    }
219
220    // ── Pattern 9: Aggregate ────────────────────────────────────────────────
221
222    /// Asserts that the excluded list is sorted in non-increasing score order.
223    pub fn excluded_items_are_sorted_by_score_descending(&mut self) -> &mut Self {
224        let excluded = &self.report.excluded;
225        for i in 0..excluded.len().saturating_sub(1) {
226            let si_prev = excluded[i].score;
227            let si = excluded[i + 1].score;
228            if si > si_prev {
229                panic!(
230                    "excluded_items_are_sorted_by_score_descending failed: item at index {next} \
231                     (score={si:.6}) is higher than item at index {i} (score={si_prev:.6}). \
232                     Expected non-increasing scores.",
233                    next = i + 1,
234                    i = i,
235                    si = si,
236                    si_prev = si_prev,
237                );
238            }
239        }
240        self
241    }
242
243    // ── Pattern 10: Budget ──────────────────────────────────────────────────
244
245    /// Asserts that `sum(included tokens) / budget.max_tokens() >= threshold`.
246    pub fn have_budget_utilization_above(
247        &mut self,
248        threshold: f64,
249        budget: &ContextBudget,
250    ) -> &mut Self {
251        let actual = analytics::budget_utilization(self.report, budget);
252        if actual < threshold {
253            let included_tokens: i64 = self.report.included.iter().map(|i| i.item.tokens()).sum();
254            let max_tokens = budget.max_tokens();
255            panic!(
256                "have_budget_utilization_above({threshold}) failed: computed utilization was \
257                 {actual:.6} (includedTokens={included_tokens}, budget.MaxTokens={max_tokens}).",
258            );
259        }
260        self
261    }
262
263    // ── Pattern 11: Coverage ────────────────────────────────────────────────
264
265    /// Asserts that the included list contains at least `n` distinct `ContextKind` values.
266    pub fn have_kind_coverage_count(&mut self, n: usize) -> &mut Self {
267        let actual = analytics::kind_diversity(self.report);
268        if actual < n {
269            let kinds: Vec<_> = self
270                .report
271                .included
272                .iter()
273                .map(|i| i.item.kind().as_str().to_string())
274                .collect::<std::collections::HashSet<_>>()
275                .into_iter()
276                .collect();
277            let actual_kinds = kinds.join(", ");
278            panic!(
279                "have_kind_coverage_count({n}) failed: expected at least {n} distinct ContextKind \
280                 values in Included, but found {actual}: [{actual_kinds}].",
281            );
282        }
283        self
284    }
285
286    // ── Pattern 12: Ordering ────────────────────────────────────────────────
287
288    /// Asserts that an included item matching `predicate` is at position 0 or position `count−1`.
289    pub fn place_item_at_edge(&mut self, predicate: impl Fn(&IncludedItem) -> bool) -> &mut Self {
290        let included = &self.report.included;
291        let count = included.len();
292
293        // Find first matching item and its index.
294        let found = included
295            .iter()
296            .enumerate()
297            .find(|(_, item)| predicate(item));
298
299        match found {
300            None => {
301                panic!("place_item_at_edge failed: no item in Included matched the predicate.");
302            }
303            Some((idx, _)) => {
304                let last = count.saturating_sub(1);
305                if idx != 0 && idx != last {
306                    panic!(
307                        "place_item_at_edge failed: item matching predicate was at index {idx} \
308                         (not at edge). Edge positions: 0 and {last}. Included had {count} items.",
309                    );
310                }
311            }
312        }
313        self
314    }
315
316    // ── Pattern 13: Ordering ────────────────────────────────────────────────
317
318    /// Asserts that the top-`n` scored included items occupy the `n` outermost edge positions.
319    ///
320    /// Edge position mapping: 0, count−1, 1, count−2, … (alternating inward from both ends).
321    /// `n = 0` always passes. `n > included.len()` panics with a count mismatch message.
322    /// Uses index-based approach — no `HashSet<&IncludedItem>` (f64 prevents `Hash`).
323    pub fn place_top_n_scored_at_edges(&mut self, n: usize) -> &mut Self {
324        if n == 0 {
325            return self;
326        }
327        let count = self.report.included.len();
328        if n > count {
329            panic!(
330                "place_top_n_scored_at_edges({n}) failed: n={n} exceeds Included count={count}.",
331            );
332        }
333
334        // Collect (score, original_index) pairs and sort by score descending.
335        let mut scored: Vec<(f64, usize)> = self
336            .report
337            .included
338            .iter()
339            .enumerate()
340            .map(|(i, item)| (item.score, i))
341            .collect();
342        scored.sort_by(|a, b| b.0.total_cmp(&a.0));
343
344        // Top-N entries and the minimum score among them.
345        let top_n = &scored[..n];
346        let min_top_score = top_n.iter().map(|(s, _)| *s).fold(f64::INFINITY, f64::min);
347
348        // Build the expected edge positions: 0, count-1, 1, count-2, …
349        let mut edge_positions: Vec<usize> = Vec::with_capacity(n);
350        let mut lo = 0usize;
351        let mut hi = count - 1;
352        while edge_positions.len() < n {
353            edge_positions.push(lo);
354            if lo != hi && edge_positions.len() < n {
355                edge_positions.push(hi);
356            }
357            lo += 1;
358            hi = hi.saturating_sub(1);
359        }
360
361        let edge_set: std::collections::HashSet<usize> = edge_positions.iter().copied().collect();
362
363        // Count top-N items not at expected edge positions.
364        let mut fail_count = 0usize;
365        for &(score, idx) in top_n {
366            // Only count items that are clearly in the top-N (score >= min_top_score).
367            if score >= min_top_score && !edge_set.contains(&idx) {
368                fail_count += 1;
369            }
370        }
371
372        if fail_count > 0 {
373            let top_items: Vec<_> = top_n
374                .iter()
375                .map(|&(score, idx)| {
376                    let kind = self.report.included[idx].item.kind().as_str().to_string();
377                    format!("(kind={kind}, score={score:.6}, idx={idx})")
378                })
379                .collect();
380            let top_items_str = top_items.join(", ");
381            let edge_pos_str = edge_positions
382                .iter()
383                .map(|p| p.to_string())
384                .collect::<Vec<_>>()
385                .join(", ");
386            panic!(
387                "place_top_n_scored_at_edges({n}) failed: {fail_count} of the top-{n} scored items \
388                 were not at expected edge positions. Top-{n} items (by score): [{top_items_str}]. \
389                 Expected edge positions: [{edge_pos_str}].",
390            );
391        }
392        self
393    }
394}