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