Skip to main content

speccoder_core/
oracle.rs

1//! The oracle at review: per requirement, the visible
2//! acceptance tests the run would use — written on the person's request,
3//! red-checked on the current tree, shaped by the person and frozen with
4//! the approval — and the review comments anchored to what the person
5//! reads. These are the shapes every interface shows; none of them has a
6//! field that could hold a held-out test's path or content.
7
8use serde::{Deserialize, Serialize};
9
10use crate::actions::Via;
11
12/// How a run started from the review asks for a person.
13#[derive(
14    Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
15)]
16#[serde(rename_all = "snake_case")]
17pub enum RunMode {
18    /// *Ask on Risky Calls*: a call the policy asks about is asked inline,
19    /// as a run from the interface does.
20    #[default]
21    Ask,
22    /// *Autopilot*: nothing asks; a call the policy would ask about and no
23    /// ticked grant covers is refused for the run and listed afterwards.
24    Autopilot,
25}
26
27impl RunMode {
28    /// Its recorded name.
29    #[must_use]
30    pub const fn as_str(self) -> &'static str {
31        match self {
32            Self::Ask => "ask",
33            Self::Autopilot => "autopilot",
34        }
35    }
36}
37
38/// What a change to a requirement's tests does.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
40#[serde(rename_all = "snake_case")]
41pub enum OracleOp {
42    /// Write the tests (the author calls, the comparison, the red check,
43    /// the held-out set).
44    Write,
45    /// Drop one test (the input is the reason, required).
46    Reject,
47    /// Write one test again (the input is the person's comment).
48    Again,
49    /// Replace one test's content with the person's (the input).
50    Edit,
51    /// Say which reading of an ambiguous scenario is meant.
52    Reading,
53}
54
55/// One of the two readings of an ambiguous scenario.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
57pub enum Reading {
58    /// The first author's.
59    A,
60    /// The second author's.
61    B,
62}
63
64impl Reading {
65    /// `A` or `B`.
66    #[must_use]
67    pub const fn as_str(self) -> &'static str {
68        match self {
69            Self::A => "A",
70            Self::B => "B",
71        }
72    }
73}
74
75/// What an author states it asserts for one scenario.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
77pub struct Expectation {
78    /// The scenario's id (`US1-2`).
79    pub scenario: String,
80    /// The situation, in the spec's terms.
81    pub given: String,
82    /// The expected outcome it asserts.
83    pub expect: String,
84}
85
86/// A scenario an author found untestable, and why.
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
88pub struct Untestable {
89    /// The scenario's id.
90    pub scenario: String,
91    /// Why.
92    pub why: String,
93}
94
95/// One reading of a scenario: what one author expects.
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
97pub struct ReadingText {
98    /// The situation.
99    pub given: String,
100    /// The outcome (`untestable: …` when the author found none).
101    pub expect: String,
102}
103
104/// The person's choice on an ambiguous scenario.
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
106pub struct Resolution {
107    /// The reading meant.
108    pub reading: Reading,
109    /// Who chose.
110    pub by: String,
111    /// Unix seconds.
112    pub at: u64,
113}
114
115/// Two authors read a scenario differently.
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
117pub struct Ambiguity {
118    /// The scenario.
119    pub scenario: String,
120    /// The first author's reading (its tests are the ones kept).
121    pub a: ReadingText,
122    /// The second author's.
123    pub b: ReadingText,
124    /// The person's choice; `None` while it is open.
125    pub resolution: Option<Resolution>,
126}
127
128impl Ambiguity {
129    /// Still open: nothing approves or freezes past it.
130    #[must_use]
131    pub const fn open(&self) -> bool {
132        self.resolution.is_none()
133    }
134}
135
136/// Who wrote a shown test.
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
138#[serde(rename_all = "snake_case")]
139pub enum AuthoredBy {
140    /// The acceptance author.
141    Model,
142    /// The person (an edit).
143    Person,
144}
145
146/// One visible test as the review shows it.
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
148pub struct OracleTest {
149    /// Project-relative path.
150    pub path: String,
151    /// Its content (a visible test: the implementer reads it too).
152    pub content: String,
153    /// The scenarios it cites (never empty).
154    pub scenarios: Vec<String>,
155    /// Its red class on the current tree (`assertion`, `absent_symbol`,
156    /// `passed`, `build_error`, `flaky`, …).
157    pub red_class: String,
158    /// Red: it can be frozen.
159    pub red: bool,
160    /// Who wrote it.
161    pub authored_by: AuthoredBy,
162    /// The person who edited it.
163    pub edited_by: Option<String>,
164}
165
166/// A test the author wrote that is not part of the set.
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
168pub struct OracleDiscarded {
169    /// Its path as given.
170    pub path: String,
171    /// `cites_no_scenario`, `not_a_new_test_file`, `exists`.
172    pub why: String,
173}
174
175/// A test the person rejected.
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
177pub struct OracleRejected {
178    /// Its path.
179    pub path: String,
180    /// The person's reason (told to every later author call).
181    pub reason: String,
182    /// Who.
183    pub by: String,
184    /// Unix seconds.
185    pub at: u64,
186}
187
188/// The held-out set of a requirement as anyone may see it: a count, a
189/// state and a digest — never a path or a line of it.
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
191pub struct HeldOutSummary {
192    /// `kept`, `absent` or `off` (`[acceptance] held_out = false`).
193    pub state: String,
194    /// Tests kept.
195    pub count: usize,
196    /// The sealed manifest's digest, when kept.
197    pub manifest_sha256: Option<String>,
198    /// Why none was kept (`not_red`, `none_generated`, …).
199    pub reason: Option<String>,
200    /// Why each discarded test was (`not_red`, `unknown_scenario`, …);
201    /// never a held-out path.
202    #[serde(default, skip_serializing_if = "Vec::is_empty")]
203    pub discarded: Vec<String>,
204}
205
206/// Where a requirement's oracle is.
207#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
208#[serde(rename_all = "snake_case")]
209pub enum OracleState {
210    /// No tests written yet.
211    None,
212    /// Written; some are red.
213    Written,
214    /// Written; none is red, so none can be frozen.
215    NotRed,
216    /// The author call failed.
217    Failed,
218    /// Two readings disagree: the person chooses or rewords.
219    Ambiguous,
220    /// Frozen with the approval, on the current wording.
221    Frozen,
222    /// Frozen on an earlier wording: the run writes tests anew.
223    Lapsed,
224    /// `review.json` names a frozen set this machine does not have.
225    FrozenElsewhere,
226}
227
228impl OracleState {
229    /// Its recorded name.
230    #[must_use]
231    pub const fn as_str(self) -> &'static str {
232        match self {
233            Self::None => "none",
234            Self::Written => "written",
235            Self::NotRed => "not_red",
236            Self::Failed => "failed",
237            Self::Ambiguous => "ambiguous",
238            Self::Frozen => "frozen",
239            Self::Lapsed => "lapsed",
240            Self::FrozenElsewhere => "frozen_elsewhere",
241        }
242    }
243}
244
245/// One requirement's oracle in the review: everything the Tests card shows.
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
247pub struct OracleView {
248    /// The requirement (`US1`).
249    pub req: String,
250    /// Its title.
251    pub title: String,
252    /// Its scenarios: id and words.
253    pub scenarios: Vec<crate::spec::ScenarioRef>,
254    /// Where it is.
255    pub state: OracleState,
256    /// For `lapsed`: the wording it was frozen on.
257    pub frozen_on: Option<String>,
258    /// Who froze it, when frozen.
259    pub frozen_by: Option<String>,
260    /// When (Unix seconds).
261    pub frozen_at: Option<u64>,
262    /// The kept tests.
263    pub tests: Vec<OracleTest>,
264    /// The tests not kept, and why.
265    pub discarded: Vec<OracleDiscarded>,
266    /// The tests the person rejected.
267    pub rejected: Vec<OracleRejected>,
268    /// Scenarios no red kept test covers.
269    pub uncovered: Vec<String>,
270    /// Scenarios an author found untestable, with why.
271    pub untestable: Vec<Untestable>,
272    /// Readings that disagree.
273    pub ambiguities: Vec<Ambiguity>,
274    /// The held-out set: count and digest only.
275    pub held_out: HeldOutSummary,
276    /// `[acceptance] min_strength` (0–100), `None` when none is set.
277    pub strength_target: Option<u8>,
278    /// The author calls *Write Tests* makes (`[review] oracle_samples`).
279    pub calls: u8,
280    /// With one call there is nothing to compare.
281    pub ambiguity_check: bool,
282    /// The visible set's manifest digest, when tests are written.
283    pub manifest_sha256: Option<String>,
284    /// Why writing failed, or why it cannot be written here.
285    pub error: Option<String>,
286}
287
288impl OracleView {
289    /// The open ambiguities.
290    pub fn open_ambiguities(&self) -> impl Iterator<Item = &Ambiguity> {
291        self.ambiguities.iter().filter(|a| a.open())
292    }
293
294    /// What approval freezes for it, as the sheet says it.
295    #[must_use]
296    pub fn freezes(&self) -> String {
297        let red = self.tests.iter().filter(|t| t.red).count();
298        if let Some(a) = self.open_ambiguities().next() {
299            return format!("blocked: two readings of {}", a.scenario);
300        }
301        match self.state {
302            OracleState::Frozen => {
303                format!("{red} {} frozen", if red == 1 { "test" } else { "tests" })
304            }
305            _ if red == 0 => "tests written at run time".to_owned(),
306            _ => {
307                let mut parts = vec![format!("{red} {}", if red == 1 { "test" } else { "tests" })];
308                if self.held_out.state == "kept" {
309                    parts.push(format!("{} held out", self.held_out.count));
310                }
311                if let Some(t) = self.strength_target {
312                    parts.push(format!("target {t}%"));
313                }
314                parts.join(" · ")
315            }
316        }
317    }
318}
319
320/// The record of an approval that froze the oracle (in `review.json`).
321#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
322pub struct ReviewApproval {
323    /// SHA-256 over the approved decision lines and the frozen oracle
324    /// entries: what a tick that reused a frozen set names.
325    pub sha256: String,
326    /// The run's mode.
327    pub mode: RunMode,
328    /// Who approved.
329    pub by: String,
330    /// Through what.
331    pub via: Via,
332    /// Unix seconds.
333    pub at: u64,
334}
335
336/// A grant an autopilot run may hold, offered on the approve sheet: one
337/// object-bound grant a person decided earlier for this feature.
338#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
339pub struct OfferedGrant {
340    /// Its grant id: what `--grant` and the sheet's tick name.
341    pub id: String,
342    /// `write`, `command`, `network` or `mcp`.
343    pub class: String,
344    /// The object in full (`run: cargo run --example demo · argv #3f…`).
345    pub object: String,
346}
347
348/// What the approve sheet shows besides the requirements: who can approve
349/// and run now, and what autopilot would hold.
350#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
351pub struct ApproveSheet {
352    /// The approval digest *Approve and Run* would record now (decision
353    /// lines and frozen manifests): the click sends it back and is refused
354    /// when it no longer matches. `None`: it cannot be computed here.
355    #[serde(default)]
356    pub sha256: Option<String>,
357    /// Decision lines the approval records.
358    pub approves: Vec<String>,
359    /// Why *Approve and Run* is disabled now (each a line naming its
360    /// cause); empty: it can run.
361    pub blocked: Vec<String>,
362    /// Why autopilot is disabled (no enforced sandbox); `None`: it can run.
363    pub autopilot_blocked: Option<String>,
364    /// The sandbox line (`Seatbelt, network off`).
365    pub sandbox: Option<String>,
366    /// The grants autopilot may hold, none ticked by default.
367    pub grants: Vec<OfferedGrant>,
368}
369
370/// One call an autopilot run refused instead of asking.
371#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
372pub struct AutopilotDenial {
373    /// The task.
374    pub task: String,
375    /// The model turn.
376    pub turn: u32,
377    /// `write`, `command`, `network`, `mcp` (empty: none resolved).
378    pub class: String,
379    /// What it reached (`src/a.rs`, `cargo add x`).
380    pub object: String,
381    /// `autopilot: not asked — listed for you`.
382    pub reason: String,
383}
384
385/// What an anchored comment points at.
386#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
387#[serde(tag = "kind", rename_all = "snake_case")]
388pub enum CommentAnchor {
389    /// A passage of a feature document.
390    Passage {
391        /// Project-relative path.
392        path: String,
393        /// First line (1-based).
394        start_line: usize,
395        /// Last line.
396        end_line: usize,
397        /// The words selected.
398        quote: String,
399        /// `sha256:<hex>` of the quote (filled when the draft is stored).
400        #[serde(default)]
401        quote_sha256: String,
402        /// Its section's heading path (`Requirements › Functional`).
403        #[serde(default)]
404        section: String,
405    },
406    /// A line of the spec's changes since the last commit.
407    DiffLine {
408        /// Project-relative path.
409        path: String,
410        /// `old` or `new`.
411        side: String,
412        /// The line (1-based) on that side.
413        line: usize,
414        /// The line's text.
415        quote: String,
416    },
417    /// A line of a shown visible test.
418    TestLine {
419        /// The requirement.
420        req: String,
421        /// The test's path.
422        path: String,
423        /// The line (1-based).
424        line: usize,
425        /// The line's text.
426        quote: String,
427    },
428    /// A finding the review shows.
429    Finding {
430        /// `analyze` or `review`.
431        source: String,
432        /// Its id.
433        id: String,
434        /// Its text.
435        text: String,
436    },
437}
438
439impl CommentAnchor {
440    /// The quote the comment is about.
441    #[must_use]
442    pub fn quote(&self) -> &str {
443        match self {
444            Self::Passage { quote, .. }
445            | Self::DiffLine { quote, .. }
446            | Self::TestLine { quote, .. } => quote,
447            Self::Finding { text, .. } => text,
448        }
449    }
450
451    /// Where it is, in a few words (`spec.md:40-42`, `US1 tests/a.rs:3`).
452    #[must_use]
453    pub fn place(&self) -> String {
454        match self {
455            Self::Passage {
456                path,
457                start_line,
458                end_line,
459                ..
460            } if start_line == end_line => format!("{path}:{start_line}"),
461            Self::Passage {
462                path,
463                start_line,
464                end_line,
465                ..
466            } => format!("{path}:{start_line}-{end_line}"),
467            Self::DiffLine {
468                path, side, line, ..
469            } => format!("{path}:{line} ({side})"),
470            Self::TestLine {
471                req, path, line, ..
472            } => format!("{req} {path}:{line}"),
473            Self::Finding { source, id, .. } => format!("{source} {id}"),
474        }
475    }
476}
477
478/// Where a draft comment is.
479#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
480#[serde(rename_all = "snake_case")]
481pub enum DraftState {
482    /// In the tray.
483    Draft,
484    /// Its quote is no longer where it was anchored: sent only if kept.
485    Outdated,
486    /// Sent in a revision turn.
487    Sent,
488}
489
490/// One comment in the tray.
491#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
492pub struct CommentDraft {
493    /// `c1`, `c2`, …
494    pub id: String,
495    /// What it is about.
496    pub anchor: CommentAnchor,
497    /// The person's words.
498    pub text: String,
499    /// Who.
500    pub by: String,
501    /// Unix seconds.
502    pub at: u64,
503    /// Where it is.
504    pub state: DraftState,
505    /// The person kept it although its quote moved.
506    #[serde(default)]
507    pub kept: bool,
508}
509
510/// What a revision turn did.
511#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
512pub struct RevisionView {
513    /// `t1`, `t2`, …
514    pub id: String,
515    /// The comments it was sent with.
516    pub comments: Vec<String>,
517    /// The sections they touch.
518    pub sections: Vec<String>,
519    /// The format's revising skill.
520    pub skill: String,
521    /// Sections it changed.
522    pub changed: Vec<String>,
523    /// Of those, the ones no comment touched.
524    pub outside: Vec<String>,
525    /// `pending` (the person keeps or undoes it), `kept`, `undone`.
526    pub outcome: String,
527    /// Files it changed (project-relative).
528    pub files: Vec<String>,
529    /// Requirements whose tests were asked again from test comments.
530    #[serde(default)]
531    pub asked_again: Vec<String>,
532    /// Documents changed while it ran by something other than the skill
533    /// (the person's own edit): *Undo Revision* never overwrites them.
534    #[serde(default)]
535    pub beside: Vec<String>,
536    /// While it waits to be kept or undone: each changed section, before
537    /// and after, for the word diff.
538    #[serde(default)]
539    pub diffs: Vec<SectionDiff>,
540}
541
542/// A bug's confirmed reproductions as a bug unit's Tests tab shows them,
543/// read only: a bug's tests are written and confirmed by its own pipeline.
544#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
545pub struct BugTests {
546    /// The bug's task (`T001`).
547    pub task: String,
548    /// Its title.
549    pub title: String,
550    /// The person's confirmation of the reproductions.
551    pub confirmation: crate::bug::Confirmation,
552    /// The reproductions, in the order confirmed.
553    pub reproductions: Vec<BugTest>,
554}
555
556/// One confirmed reproduction.
557#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
558pub struct BugTest {
559    /// The statement it reproduces (`E1`) or guards (`U1`).
560    pub statement: String,
561    /// Project-relative test file.
562    pub file: String,
563    /// The test's name.
564    pub test: String,
565    /// Its class on the untouched code (`assertion`).
566    pub red_class: String,
567    /// Its content as confirmed; `None` when the kept copy is missing or no
568    /// longer matches its digest.
569    pub content: Option<String>,
570}
571
572/// One section a revision changed, before and after (each cut to 4000
573/// characters).
574#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
575pub struct SectionDiff {
576    /// The document (project-relative).
577    pub path: String,
578    /// Its heading path (`Spec › Requirements`; empty before the first).
579    pub heading: String,
580    /// The section's text before the revision (empty: it was added).
581    pub before: String,
582    /// After it (empty: it was removed).
583    pub after: String,
584    /// No comment touched it.
585    pub outside: bool,
586}
587
588/// Result of `speccoder review comments`: the tray and the last turn.
589#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
590pub struct CommentsView {
591    /// The feature.
592    pub feature: String,
593    /// Every draft not yet sent, in order.
594    pub drafts: Vec<CommentDraft>,
595    /// The last revision turn.
596    pub turn: Option<RevisionView>,
597    /// The skill a send runs (`speckit-clarify`), when the format has one.
598    pub revise_with: Option<String>,
599}
600
601/// One line comment of the Code view.
602#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
603pub struct CodeComment {
604    /// Project-relative path.
605    pub path: String,
606    /// The line (1-based).
607    pub line: usize,
608    /// The code on that line.
609    pub quote: String,
610    /// The person's words.
611    pub text: String,
612}
613
614impl CodeComment {
615    /// The nudge line: `src/a.rs:12 "let x = …" — text`.
616    #[must_use]
617    pub fn nudge_line(&self) -> String {
618        let quote: String = self.quote.trim().chars().take(80).collect();
619        format!(
620            "{}:{} \"{}\" — {}",
621            self.path,
622            self.line,
623            quote,
624            self.text.trim()
625        )
626    }
627}
628
629/// The deterministic ambiguity rule: words compared after trimming,
630/// collapsing whitespace and lowercasing.
631#[must_use]
632pub fn normalise(text: &str) -> String {
633    text.split_whitespace()
634        .collect::<Vec<_>>()
635        .join(" ")
636        .to_lowercase()
637}
638
639/// Where two authors' stated expectations disagree: for a scenario, the
640/// same given (normalised) with a different expected outcome; or one
641/// covers it and the other declares it untestable. Different givens are
642/// different examples, not a disagreement. In scenario order of `a`.
643#[must_use]
644pub fn compare(
645    a: &[Expectation],
646    a_untestable: &[Untestable],
647    b: &[Expectation],
648    b_untestable: &[Untestable],
649) -> Vec<Ambiguity> {
650    let mut out: Vec<Ambiguity> = Vec::new();
651    let mut push = |scenario: &str, ra: ReadingText, rb: ReadingText| {
652        if !out.iter().any(|x| x.scenario == scenario) {
653            out.push(Ambiguity {
654                scenario: scenario.to_owned(),
655                a: ra,
656                b: rb,
657                resolution: None,
658            });
659        }
660    };
661    let reading = |e: &Expectation| ReadingText {
662        given: e.given.clone(),
663        expect: e.expect.clone(),
664    };
665    let untestable = |u: &Untestable| ReadingText {
666        given: String::new(),
667        expect: format!("untestable: {}", u.why),
668    };
669    for ea in a {
670        for eb in b.iter().filter(|eb| eb.scenario == ea.scenario) {
671            if normalise(&ea.given) == normalise(&eb.given)
672                && normalise(&ea.expect) != normalise(&eb.expect)
673            {
674                push(&ea.scenario, reading(ea), reading(eb));
675            }
676        }
677        if let Some(u) = b_untestable.iter().find(|u| u.scenario == ea.scenario) {
678            push(&ea.scenario, reading(ea), untestable(u));
679        }
680    }
681    for eb in b {
682        if let Some(u) = a_untestable.iter().find(|u| u.scenario == eb.scenario) {
683            push(&eb.scenario, untestable(u), reading(eb));
684        }
685    }
686    out
687}
688
689#[cfg(test)]
690mod tests {
691    use proptest::prelude::*;
692
693    use super::*;
694
695    fn e(s: &str, given: &str, expect: &str) -> Expectation {
696        Expectation {
697            scenario: s.to_owned(),
698            given: given.to_owned(),
699            expect: expect.to_owned(),
700        }
701    }
702
703    #[test]
704    fn the_same_given_with_another_outcome_is_ambiguous() {
705        let a = [
706            e("US1-1", "name Ada", "Hello, Ada!"),
707            e("US1-2", "\"\"", "Hello!"),
708        ];
709        let b = [
710            e("US1-1", " Name   ada ", "hello, ada!"),
711            e("US1-2", "\"\"", "an error"),
712        ];
713        let found = compare(&a, &[], &b, &[]);
714        assert_eq!(found.len(), 1);
715        assert_eq!(found[0].scenario, "US1-2");
716        assert_eq!(found[0].a.expect, "Hello!");
717        assert_eq!(found[0].b.expect, "an error");
718        assert!(found[0].open());
719    }
720
721    #[test]
722    fn different_givens_are_different_examples() {
723        let a = [e("US1-1", "name Ada", "Hello, Ada!")];
724        let b = [e("US1-1", "name Bob", "Hello, Bob!")];
725        assert!(compare(&a, &[], &b, &[]).is_empty());
726    }
727
728    #[test]
729    fn covered_by_one_and_untestable_for_the_other_is_ambiguous() {
730        let a = [e("US1-3", "offline", "an error")];
731        let u = [Untestable {
732            scenario: "US1-3".to_owned(),
733            why: "needs a network".to_owned(),
734        }];
735        let found = compare(&a, &[], &[], &u);
736        assert_eq!(found.len(), 1);
737        assert!(found[0].b.expect.starts_with("untestable"));
738        let back = compare(&[], &u, &a, &[]);
739        assert_eq!(back.len(), 1);
740        assert!(back[0].a.expect.starts_with("untestable"));
741    }
742
743    proptest! {
744        #[test]
745        fn agreeing_authors_raise_nothing(given in "[a-zA-Z ]{1,20}", expect in "[a-zA-Z ]{1,20}", pad in " {0,3}") {
746            let a = [e("S", &given, &expect)];
747            let b = [e("S", &format!("{pad}{}{pad}", given.to_uppercase()), &expect.to_lowercase())];
748            prop_assert!(compare(&a, &[], &b, &[]).is_empty());
749        }
750
751        #[test]
752        fn the_rule_is_symmetric_in_whether_it_fires(g in "[a-c]{1,3}", x in "[a-c]{1,3}", y in "[a-c]{1,3}") {
753            let a = [e("S", &g, &x)];
754            let b = [e("S", &g, &y)];
755            prop_assert_eq!(compare(&a, &[], &b, &[]).len(), compare(&b, &[], &a, &[]).len());
756            prop_assert_eq!(compare(&a, &[], &b, &[]).is_empty(), normalise(&x) == normalise(&y));
757        }
758    }
759
760    #[test]
761    fn the_sheet_says_what_approval_freezes() {
762        let mut v = OracleView {
763            req: "US1".to_owned(),
764            title: "Greet".to_owned(),
765            scenarios: Vec::new(),
766            state: OracleState::Written,
767            frozen_on: None,
768            frozen_by: None,
769            frozen_at: None,
770            tests: vec![OracleTest {
771                path: "tests/a.rs".to_owned(),
772                content: String::new(),
773                scenarios: vec!["US1-1".to_owned()],
774                red_class: "assertion".to_owned(),
775                red: true,
776                authored_by: AuthoredBy::Model,
777                edited_by: None,
778            }],
779            discarded: Vec::new(),
780            rejected: Vec::new(),
781            uncovered: Vec::new(),
782            untestable: Vec::new(),
783            ambiguities: Vec::new(),
784            held_out: HeldOutSummary {
785                state: "kept".to_owned(),
786                count: 2,
787                manifest_sha256: None,
788                reason: None,
789                discarded: Vec::new(),
790            },
791            strength_target: Some(80),
792            calls: 2,
793            ambiguity_check: true,
794            manifest_sha256: None,
795            error: None,
796        };
797        assert_eq!(v.freezes(), "1 test · 2 held out · target 80%");
798        v.tests[0].red = false;
799        assert_eq!(v.freezes(), "tests written at run time");
800        let line = CodeComment {
801            path: "src/a.rs".to_owned(),
802            line: 12,
803            quote: "  let x = 1;".to_owned(),
804            text: " use a constant ".to_owned(),
805        };
806        assert_eq!(
807            line.nudge_line(),
808            "src/a.rs:12 \"let x = 1;\" — use a constant"
809        );
810    }
811}