beck_rt/
testing.rs

1//! The `beck test` runner — `docs/21-tests-in-beck-and-proof.md` §21.2 and §21.3, executed.
2//!
3//! # Why the runner lives in the runtime crate
4//!
5//! §21.2: "the test runs the same `Roles` the runtime drives, with the tiers co-located. What it
6//! proves is what the boundary *means*." That is not a figure of speech here — a test's `when`
7//! goes through the same `validate` [`crate::Runtime`] calls on a websocket frame, its `given` goes
8//! through the same fold the sequencer drives, and `expect page(session("bo"))` renders through the
9//! same view the server diffs. There is no second execution path to keep in agreement, which is the
10//! only reason the cross-boundary test in §21.2 is three lines instead of a docker-compose file.
11//!
12//! # What makes a test here unable to flake
13//!
14//! Three things, none of them a convention:
15//!
16//! * **The log is the state**, so there is nothing to arrange and nothing to tear down.
17//! * **Time and identity are data.** The envelope's `at` is the sequence position and its `actor`
18//!   is written in the test, so two runs produce the same state bit for bit.
19//! * **Effects are stubbed**, and the *complete list* of what was stubbed is the effect row, which
20//!   the compiler already computed. §21.3 rule 1: "'any value' is the default, so it needs no
21//!   expression" — and rule 1's price is that the default must say what it did, which
22//!   [`Report`] does.
23
24use std::collections::BTreeMap;
25use std::sync::{Arc, Mutex};
26
27use beck_core::backend::{Backend, Callable, ExecError, Interceptor};
28use beck_core::core::{Core, CoreKind, VarId};
29use beck_core::testing::{Clause, Count, Expectation, TestDef};
30use beck_core::{digest, Effect, Placed, Tier, Ty, Value};
31
32use crate::log::{Envelope, Instant};
33use crate::program::Runtime;
34
35/// How many inputs a `property` block is run with when nothing says otherwise.
36pub const DEFAULT_RUNS: u64 = 100;
37
38/// The actor a test speaks as when it does not name one.
39pub const DEFAULT_ACTOR: &str = "test";
40
41#[derive(Clone, Debug, Default)]
42pub struct Options {
43    /// Only run tests whose name contains this.
44    pub filter: Option<String>,
45    /// Inputs per `property` block.
46    pub runs: u64,
47    /// Where a `expect wire_compatible_with "…"` path is resolved from, and where `snapshots/`
48    /// lives for `expect page matches snapshot`.
49    pub base_dir: std::path::PathBuf,
50    /// `beck test --update`: write what the page renders to instead of comparing against it.
51    ///
52    /// Off by default and never inferred. A snapshot that updates itself when it disagrees is not
53    /// an assertion — §21.2's stated risk is snapshot rot and its stated mitigation is reviewing
54    /// the diff, which only exists if writing is something a person asked for.
55    pub update_snapshots: bool,
56}
57
58impl Options {
59    pub fn runs(&self) -> u64 {
60        if self.runs == 0 {
61            DEFAULT_RUNS
62        } else {
63            self.runs
64        }
65    }
66}
67
68#[derive(Clone, Debug)]
69pub struct Report {
70    pub cases: Vec<Case>,
71}
72
73impl Report {
74    pub fn passed(&self) -> usize {
75        self.cases.iter().filter(|c| c.outcome.is_pass()).count()
76    }
77    pub fn failed(&self) -> usize {
78        self.cases
79            .iter()
80            .filter(|c| matches!(c.outcome, Outcome::Failed { .. }))
81            .count()
82    }
83    pub fn skipped(&self) -> usize {
84        self.cases
85            .iter()
86            .filter(|c| matches!(c.outcome, Outcome::Skipped(_)))
87            .count()
88    }
89    pub fn ok(&self) -> bool {
90        self.failed() == 0
91    }
92}
93
94#[derive(Clone, Debug)]
95pub struct Case {
96    pub name: Arc<str>,
97    pub outcome: Outcome,
98    /// What §21.3 rule 1's hidden default did, so it is not hidden: every definition that was
99    /// stubbed, the atom that caused it, the value it returned and how often it was called.
100    pub stubbed: Vec<Stubbed>,
101    /// Inputs actually run, for a `property`.
102    pub runs: u64,
103}
104
105#[derive(Clone, Debug)]
106pub enum Outcome {
107    Passed,
108    Failed {
109        why: String,
110    },
111    /// Not run, and why — never silently counted as a pass.
112    Skipped(String),
113}
114
115impl Outcome {
116    pub fn is_pass(&self) -> bool {
117        matches!(self, Outcome::Passed)
118    }
119}
120
121#[derive(Clone, Debug)]
122pub struct Stubbed {
123    pub def: Arc<str>,
124    pub atom: String,
125    /// What it answered. `None` for a stub that answers from the call (§21.3 rule 3) and was never
126    /// called — there is no single value to name, and inventing one for the report would be the
127    /// same mistake the stub itself exists to avoid. `None` too for a stub that *failed*, which
128    /// [`Stubbed::raised`] names instead.
129    pub returned: Option<Value>,
130    /// What it raised, for a stub that answers by failing. §21.3 rule 1's obligation is to say
131    /// what the default did, and "returned nothing" is not what a raise did.
132    pub raised: Option<String>,
133    pub calls: usize,
134    /// True when the test named it, false when §21.3 rule 1 supplied it.
135    pub explicit: bool,
136    /// True when the stub is a body over the call's arguments rather than a fixed value.
137    pub from_the_call: bool,
138}
139
140// ---------------------------------------------------------------------------------------------
141// The stub table
142// ---------------------------------------------------------------------------------------------
143
144/// What a stub answers with.
145///
146/// §21.3 rule 2 is a value: "no parameter list, because parameters are not how the stub is
147/// selected". Rule 3 is a *body* over the stubbed definition's parameters, so that "matching by
148/// value uses the language's own `match`, and there is no mock DSL". The second is prepared through
149/// [`Backend::function`] like any other code, and by the *base* backend — a stub is test code, and
150/// stubbing a stub would be a loop.
151enum Answer {
152    Value(Value),
153    /// `stub net.out(pay.example.com): raise Declined(...)` — the peer is down, and the branch a
154    /// program writes for that is the branch this makes reachable (`docs/22` §22.6). It is
155    /// evaluated once, like any other value, and the failure it produced is replayed at every
156    /// interception: a stub is a *value* for an effect and a failure is one of the values a
157    /// definition that declares `raises(E)` can answer with.
158    Fails(ExecError),
159    FromTheCall(Callable),
160}
161
162/// One entry per definition a stub stands in for. The unit is a *definition* because that is what
163/// gets called; the *identity* is the effect atom, which is what the test names.
164struct Entry {
165    atom: Effect,
166    answer: Answer,
167    explicit: bool,
168    /// Set when the generator refused to invent a return value. §21.3 rule 5: "it can refuse, with
169    /// a diagnostic, for a type with no inhabitant it can construct". The refusal has to reach the
170    /// person, so it is carried to the point of use and reported as a failure of the test that
171    /// reached it — not swallowed, and not turned into a real call.
172    refused: Option<String>,
173}
174
175/// One recorded performance of an effect: what was called, with what, and what it answered.
176struct Call {
177    def: Arc<str>,
178    args: Vec<Value>,
179    /// `None` when this call answered by failing — see [`Call::raised`].
180    returned: Option<Value>,
181    raised: Option<String>,
182}
183
184#[derive(Default)]
185struct Recorder {
186    entries: BTreeMap<Arc<str>, Entry>,
187    calls: Mutex<Vec<Call>>,
188    /// Anything that went wrong *inside* the stub machinery, reported as a failure of the test that
189    /// reached it rather than swallowed.
190    problems: Mutex<Vec<String>>,
191}
192
193impl Interceptor for Recorder {
194    fn intercept(&self, name: &str, args: &[Value]) -> Option<Result<Value, ExecError>> {
195        let e = self.entries.get(name)?;
196        if let Some(why) = &e.refused {
197            let atom = e.atom.name();
198            self.problems.lock().expect("stub log").push(format!(
199                "the generator cannot invent a return value for `{name}` ({atom}): {why}\n  \
200                 write the stub out: `stub {atom}: <value>`"
201            ));
202        }
203        // A `raise` is an answer and anything else is a fault. The difference is the value the
204        // failure carries: the checker has already held a stub's raise to what the definition it
205        // stands in for declares (`B0708`), so one that arrives here is a failure the program was
206        // compiled against and unwinds like the real body's; one that arrives without a raised
207        // value is the stub itself going wrong, and belongs in the report rather than in the
208        // program.
209        let answered: Result<Value, ExecError> = match &e.answer {
210            Answer::Value(v) => Ok(v.clone()),
211            Answer::Fails(err) => Err(err.clone()),
212            Answer::FromTheCall(f) => match f(args.to_vec()) {
213                Ok(v) => Ok(v),
214                Err(err) if err.raised.is_some() => Err(err),
215                Err(err) => {
216                    self.problems
217                        .lock()
218                        .expect("stub log")
219                        .push(format!("the stub for `{}` failed: {err}", e.atom.name()));
220                    Ok(Value::Unit)
221                }
222            },
223        };
224        self.calls.lock().expect("stub log").push(Call {
225            def: Arc::from(name),
226            args: args.to_vec(),
227            returned: answered.as_ref().ok().cloned(),
228            raised: answered.as_ref().err().map(|e| e.message.clone()),
229        });
230        Some(answered)
231    }
232}
233
234impl Recorder {
235    fn count(&self, atom: &Effect) -> usize {
236        self.calls
237            .lock()
238            .expect("stub log")
239            .iter()
240            .filter(|c| self.entries.get(&c.def).map(|e| &e.atom) == Some(atom))
241            .count()
242    }
243
244    fn called_with(&self, atom: &Effect, wanted: &Value) -> bool {
245        let want = digest(wanted);
246        self.calls
247            .lock()
248            .expect("stub log")
249            .iter()
250            .filter(|c| self.entries.get(&c.def).map(|e| &e.atom) == Some(atom))
251            .any(|c| c.args.iter().any(|a| digest(a) == want))
252    }
253
254    fn report(&self) -> Vec<Stubbed> {
255        let calls = self.calls.lock().expect("stub log");
256        let mut out: Vec<Stubbed> = self
257            .entries
258            .iter()
259            .map(|(def, e)| {
260                let mine: Vec<&Call> = calls.iter().filter(|c| c.def == *def).collect();
261                Stubbed {
262                    def: def.clone(),
263                    atom: e.atom.name(),
264                    returned: match &e.answer {
265                        Answer::Value(v) => Some(v.clone()),
266                        Answer::Fails(_) => None,
267                        // The last answer it gave, which is the only honest single value a stub
268                        // that varies with the call has.
269                        Answer::FromTheCall(_) => mine.last().and_then(|c| c.returned.clone()),
270                    },
271                    raised: match &e.answer {
272                        Answer::Fails(err) => Some(err.message.clone()),
273                        Answer::Value(_) => None,
274                        Answer::FromTheCall(_) => mine.last().and_then(|c| c.raised.clone()),
275                    },
276                    calls: mine.len(),
277                    explicit: e.explicit,
278                    from_the_call: matches!(e.answer, Answer::FromTheCall(_)),
279                }
280            })
281            .collect();
282        out.sort_by(|a, b| a.def.cmp(&b.def));
283        out
284    }
285}
286
287// ---------------------------------------------------------------------------------------------
288// Running
289// ---------------------------------------------------------------------------------------------
290
291/// Run every `test` and `property` block in a compiled program.
292///
293/// On a thread with as much host stack as the backend says it needs
294/// ([`Backend::stack_bytes`]), because a test is the most likely place for a program to recurse
295/// further than its author expected and the answer to that has to be a failing case rather than a
296/// dead process. A backend that needs nothing gets no thread.
297pub fn run(placed: &Placed, backend: Arc<dyn Backend>, opts: &Options) -> Report {
298    match backend.stack_bytes() {
299        0 => run_here(placed, backend, opts),
300        bytes => std::thread::scope(|scope| {
301            std::thread::Builder::new()
302                .stack_size(bytes)
303                .name("beck-test".into())
304                .spawn_scoped(scope, || run_here(placed, backend, opts))
305                .expect("a thread for the tests")
306                .join()
307                .unwrap_or_else(|panic| std::panic::resume_unwind(panic))
308        }),
309    }
310}
311
312fn run_here(placed: &Placed, backend: Arc<dyn Backend>, opts: &Options) -> Report {
313    let mut cases = Vec::new();
314    for t in &placed.program.tests {
315        if let Some(f) = &opts.filter {
316            if !t.name.contains(f.as_str()) {
317                continue;
318            }
319        }
320        cases.push(run_one(placed, backend.clone(), t, opts));
321    }
322    Report { cases }
323}
324
325/// Which of a test's clauses need an *application*, and are therefore not available to a library.
326///
327/// A library has no merge point, so it has no log to fold, no `validate` to propose through and no
328/// page to render. Its `Placed` carries placeholder roles ([`beck_core::split::Placed::library`]),
329/// and running one of those would report a pass for a test that asserted nothing — which is worse
330/// than the refusal it replaced. So the clause is named and refused.
331///
332/// Everything else works: `expect <Bool>` over the module's own definitions, `property` blocks and
333/// their generated inputs, `stub`, and the static expectations. That is the whole of a unit test for
334/// a domain module, and it is what docs/22 §22.6 said was missing "for exactly the modules that most
335/// want unit tests".
336fn needs_an_application(t: &TestDef) -> Option<&'static str> {
337    for clause in &t.clauses {
338        match clause {
339            Clause::Given { .. } => return Some("`given` folds a log, and a library has none"),
340            Clause::When { .. } => {
341                return Some("`when` proposes a command through `validate`, and a library has none")
342            }
343            Clause::Expect { what, .. } => match what {
344                Expectation::PageContains { .. } | Expectation::PageMatchesSnapshot { .. } => {
345                    return Some("`page` is the view of an application, and a library has none")
346                }
347                Expectation::FoldEquals { .. } => {
348                    return Some("`fold_of` folds a log, and a library has none")
349                }
350                _ => {}
351            },
352            Clause::Stub { .. } => {}
353        }
354    }
355    None
356}
357
358fn run_one(placed: &Placed, backend: Arc<dyn Backend>, t: &TestDef, opts: &Options) -> Case {
359    if !placed.is_application() {
360        if let Some(why) = needs_an_application(t) {
361            return Case {
362                name: t.name.clone(),
363                outcome: Outcome::Failed {
364                    why: format!(
365                        "{why}. This module has no merge point, so it is a library: add \
366                         `proposals: Stream[Proposal] = merge_clients()` and a `durable` fold to \
367                         make it an application, or write this test over the module's own \
368                         definitions"
369                    ),
370                },
371                stubbed: Vec::new(),
372                runs: 0,
373            };
374        }
375    }
376    let stubs = match build_stubs(placed, &backend, t) {
377        Ok(s) => s,
378        Err(why) => {
379            return Case {
380                name: t.name.clone(),
381                outcome: Outcome::Failed { why },
382                stubbed: Vec::new(),
383                runs: 0,
384            }
385        }
386    };
387
388    // A test that only asks compile-time questions needs no execution at all — §21.2: "`beck test`
389    // answers them without running anything".
390    if t.is_static_only() {
391        let outcome = match static_only(placed, t, opts) {
392            Ok(()) => Outcome::Passed,
393            Err(why) => Outcome::Failed { why },
394        };
395        return Case {
396            name: t.name.clone(),
397            outcome,
398            stubbed: Vec::new(),
399            runs: 0,
400        };
401    }
402
403    let recorder = Arc::new(stubs);
404    let needs_stubs = !recorder.entries.is_empty();
405    let exec: Arc<dyn Backend> = if needs_stubs {
406        match backend.intercepting(recorder.clone()) {
407            Some(b) => b,
408            None => {
409                return Case {
410                    name: t.name.clone(),
411                    outcome: Outcome::Skipped(format!(
412                        "the `{}` backend cannot install stubs, and this test's subject performs \
413                         effects that must not run for real",
414                        backend.name()
415                    )),
416                    stubbed: Vec::new(),
417                    runs: 0,
418                }
419            }
420        }
421    } else {
422        backend.clone()
423    };
424
425    let runtime = match Runtime::new(placed.clone(), exec) {
426        Ok(r) => r,
427        Err(e) => {
428            return Case {
429                name: t.name.clone(),
430                outcome: Outcome::Failed {
431                    why: format!("preparing the program: {e}"),
432                },
433                stubbed: Vec::new(),
434                runs: 0,
435            }
436        }
437    };
438
439    let runs = if t.is_property() { opts.runs() } else { 1 };
440    let mut ran = 0;
441    for run in 0..runs {
442        ran += 1;
443        let inputs = match generate(placed, t, run) {
444            Ok(v) => v,
445            Err(why) => {
446                return Case {
447                    name: t.name.clone(),
448                    outcome: Outcome::Failed { why },
449                    stubbed: recorder.report(),
450                    runs: ran,
451                }
452            }
453        };
454        if let Err(why) = execute(placed, &runtime, &recorder, t, &inputs, opts) {
455            // §21.3 rule 5's shrinking: report the smallest input that still fails, because the
456            // point of a generated counterexample is that a person can read it.
457            let (inputs, why) = shrink_failure(placed, &runtime, &recorder, t, inputs, why, opts);
458            let shown = describe_inputs(t, &inputs);
459            return Case {
460                name: t.name.clone(),
461                outcome: Outcome::Failed {
462                    why: if shown.is_empty() {
463                        why
464                    } else {
465                        format!("{why}\n  with {shown}")
466                    },
467                },
468                stubbed: recorder.report(),
469                runs: ran,
470            };
471        }
472    }
473
474    let problems = recorder.problems.lock().expect("stub log").clone();
475    if !problems.is_empty() {
476        return Case {
477            name: t.name.clone(),
478            outcome: Outcome::Failed {
479                why: problems.join("\n"),
480            },
481            stubbed: recorder.report(),
482            runs: ran,
483        };
484    }
485
486    Case {
487        name: t.name.clone(),
488        outcome: Outcome::Passed,
489        stubbed: recorder.report(),
490        runs: ran,
491    }
492}
493
494/// §21.3 rules 1 and 2: everything is stubbed by default, and naming an effect overrides it.
495fn build_stubs(
496    placed: &Placed,
497    backend: &Arc<dyn Backend>,
498    t: &TestDef,
499) -> Result<Recorder, String> {
500    let program = &placed.program;
501    // The stubs the test named. A plain value is evaluated once, here; a body over the call's
502    // arguments (§21.3 rule 3) is prepared as a function and called per interception.
503    let mut explicit: BTreeMap<Effect, Answer> = BTreeMap::new();
504    for c in &t.clauses {
505        if let Clause::Stub {
506            atom,
507            params,
508            value,
509            ..
510        } = c
511        {
512            let answer = if params.is_empty() {
513                match backend.constant(value) {
514                    Ok(v) => Answer::Value(v),
515                    // A stub whose body is `raise …` produced its answer by failing, and that is
516                    // the answer. Anything else went wrong while evaluating it.
517                    Err(e) if e.raised.is_some() => Answer::Fails(e),
518                    Err(e) => {
519                        return Err(format!("evaluating the stub for `{}`: {e}", atom.name()))
520                    }
521                }
522            } else {
523                let lam = Core {
524                    kind: CoreKind::Lam {
525                        params: params.clone().into(),
526                        body: std::sync::Arc::new(value.clone()),
527                    },
528                    ty: Ty::fun(Vec::new(), value.ty.clone()),
529                    tier: Tier::Any,
530                    span: value.span,
531                    last_use: false,
532                    order: beck_core::fields::UNORDERED,
533                    locals: 0,
534                };
535                Answer::FromTheCall(
536                    backend
537                        .function(&lam)
538                        .map_err(|e| format!("preparing the stub for `{}`: {e}", atom.name()))?,
539                )
540            };
541            explicit.insert(atom.clone(), answer);
542        }
543    }
544
545    let mut entries = BTreeMap::new();
546    for (name, def) in &program.defs {
547        // The first atom this definition *performs* — not the first its row mentions. A row
548        // propagates to callers, so matching on the row would stub `validate` itself and the test
549        // would exercise nothing. See `beck_core::testing::performs_itself`.
550        //
551        // A definition performing two atoms is stubbed by whichever the test named, and by the
552        // first otherwise, which is why the report prints the atom beside the definition rather
553        // than leaving it to be guessed.
554        let atom = def
555            .effects
556            .iter()
557            .filter(|e| beck_core::testing::performs_itself(def, e))
558            .find(|e| explicit.contains_key(e))
559            .or_else(|| {
560                def.effects
561                    .iter()
562                    .filter(|e| beck_core::testing::performs_itself(def, e))
563                    .find(|e| beck_core::testing::is_auto_stubbable(e))
564            });
565        let Some(atom) = atom.cloned() else { continue };
566        let (answer, refused, is_explicit) = match explicit.get(&atom) {
567            Some(Answer::Value(v)) => (Answer::Value(v.clone()), None, true),
568            Some(Answer::Fails(e)) => (Answer::Fails(e.clone()), None, true),
569            Some(Answer::FromTheCall(f)) => (Answer::FromTheCall(f.clone()), None, true),
570            None => match beck_core::gen::canonical(&def.ret, &program.types) {
571                Ok(v) => (Answer::Value(v), None, false),
572                Err(e) => (Answer::Value(Value::Unit), Some(e.to_string()), false),
573            },
574        };
575        entries.insert(
576            name.clone(),
577            Entry {
578                atom,
579                answer,
580                explicit: is_explicit,
581                refused,
582            },
583        );
584    }
585
586    // An explicit stub for an atom nothing performs is a compile error (B0704), so anything left
587    // here is a real entry.
588    Ok(Recorder {
589        entries,
590        ..Default::default()
591    })
592}
593
594/// A `property`'s inputs for one run. A `test` has none, and the vector is empty.
595fn generate(placed: &Placed, t: &TestDef, run: u64) -> Result<Vec<Value>, String> {
596    let mut rng = beck_core::gen::Rng::seeded(&t.name, run);
597    let mut out = Vec::with_capacity(t.params.len());
598    for (_, name, ty) in &t.params {
599        let v = beck_core::gen::arbitrary(ty, &placed.program.types, &mut rng)
600            .map_err(|e| format!("generating `{name}`: {e}"))?;
601        out.push(v);
602    }
603    Ok(out)
604}
605
606fn describe_inputs(t: &TestDef, inputs: &[Value]) -> String {
607    t.params
608        .iter()
609        .zip(inputs)
610        .map(|((_, n, _), v)| format!("{n} = {}", v.display()))
611        .collect::<Vec<_>>()
612        .join(", ")
613}
614
615/// Shrink a failing `property` input to the smallest one that still fails.
616///
617/// Greedy and terminating: every candidate is strictly smaller by [`beck_core::gen::size`], so the
618/// loop makes progress and stops. A `test` block has no inputs and this returns immediately.
619fn shrink_failure(
620    placed: &Placed,
621    runtime: &Runtime,
622    recorder: &Arc<Recorder>,
623    t: &TestDef,
624    inputs: Vec<Value>,
625    why: String,
626    opts: &Options,
627) -> (Vec<Value>, String) {
628    let mut best = inputs;
629    let mut why = why;
630    let mut improved = true;
631    while improved {
632        improved = false;
633        for i in 0..best.len() {
634            for candidate in beck_core::gen::shrink(&best[i]) {
635                let mut next = best.clone();
636                next[i] = candidate;
637                if let Err(w) = execute(placed, runtime, recorder, t, &next, opts) {
638                    best = next;
639                    why = w;
640                    improved = true;
641                    break;
642                }
643            }
644        }
645    }
646    (best, why)
647}
648
649/// Compare a rendered page against its checked-in snapshot, or write one.
650///
651/// `docs/21` §21.2 asked for this and named both the risk and the mitigation: "the risk is snapshot
652/// rot, and the mitigation is the same one — review the diff." Three things follow from taking that
653/// seriously rather than quoting it.
654///
655/// **A missing snapshot is a failure, not a silent write.** The first run of a new assertion has to
656/// tell somebody it recorded nothing, or a test that has never compared anything reads as a test
657/// that passes. It fails, and says which flag writes it.
658///
659/// **Writing is only ever `--update`.** A snapshot that rewrites itself when it disagrees asserts
660/// nothing at all.
661///
662/// **The diff is in the failure.** A message that says two pages differ, without saying where, sends
663/// the reader to a file comparison the harness could have done — so the first differing line is
664/// named, with both sides.
665fn snapshot(
666    opts: &Options,
667    test: &str,
668    name: Option<&str>,
669    actor: &str,
670    rendered: &str,
671) -> Result<(), String> {
672    let dir = opts.base_dir.join("snapshots");
673    let path = dir.join(format!("{}.html", snapshot_key(test, name, actor)));
674
675    if opts.update_snapshots {
676        std::fs::create_dir_all(&dir).map_err(|e| format!("creating {}: {e}", dir.display()))?;
677        std::fs::write(&path, rendered).map_err(|e| format!("writing {}: {e}", path.display()))?;
678        return Ok(());
679    }
680
681    let Ok(want) = std::fs::read_to_string(&path) else {
682        return Err(format!(
683            "no snapshot recorded at {}\n  \
684             run `beck test --update` to write it, then review the file like any other diff",
685            path.display()
686        ));
687    };
688    if want == rendered {
689        return Ok(());
690    }
691    Err(format!(
692        "the page `{actor}` sees does not match {}\n{}",
693        path.display(),
694        first_difference(&want, rendered)
695    ))
696}
697
698/// Sixty characters either side of `at`, with `…` where the line was cut.
699fn window(line: &str, at: usize) -> String {
700    const EITHER_SIDE: usize = 60;
701    let floor = |i: usize| {
702        (0..=i)
703            .rev()
704            .find(|&i| line.is_char_boundary(i))
705            .unwrap_or(0)
706    };
707    let ceil = |i: usize| {
708        (i..=line.len())
709            .find(|&i| line.is_char_boundary(i))
710            .unwrap_or(line.len())
711    };
712    let start = floor(at.saturating_sub(EITHER_SIDE));
713    let end = ceil((at + EITHER_SIDE).min(line.len()));
714    format!(
715        "{}{}{}",
716        if start > 0 { "…" } else { "" },
717        &line[start..end],
718        if end < line.len() { "…" } else { "" }
719    )
720}
721
722/// A file name that is stable, readable, and cannot collide by accident.
723///
724/// The actor is part of the key because one test may assert two people's pages, and the two are
725/// different snapshots of the same test. Everything outside `[A-Za-z0-9_-]` becomes `-`, so a test
726/// called `"a user's page"` is a file somebody can open on any platform.
727fn snapshot_key(test: &str, name: Option<&str>, actor: &str) -> String {
728    let slug = |s: &str| -> String {
729        let mut out = String::new();
730        let mut dash = false;
731        for c in s.chars() {
732            if c.is_ascii_alphanumeric() || c == '_' {
733                out.push(c);
734                dash = false;
735            } else if !dash && !out.is_empty() {
736                out.push('-');
737                dash = true;
738            }
739        }
740        out.trim_end_matches('-').to_string()
741    };
742    format!("{}@{}", slug(name.unwrap_or(test)), slug(actor))
743}
744
745/// The first line that differs, with both sides — so a failure is readable without a second tool.
746///
747/// A rendered page is frequently **one very long line**, so eliding both sides from the start shows
748/// two identical prefixes and hides the difference — which is the failure mode this whole message
749/// exists to avoid (§4.5: an error message is a product surface). The window is therefore centred
750/// on the first differing *character* rather than on the start of the line.
751fn first_difference(want: &str, got: &str) -> String {
752    for (i, (a, b)) in want.lines().zip(got.lines()).enumerate() {
753        if a != b {
754            let at = a
755                .char_indices()
756                .zip(b.char_indices())
757                .find(|((_, x), (_, y))| x != y)
758                .map(|((i, _), _)| i)
759                .unwrap_or_else(|| a.len().min(b.len()));
760            return format!(
761                "  line {}, column {}:\n    snapshot: {}\n    rendered: {}",
762                i + 1,
763                a[..at].chars().count() + 1,
764                window(a, at),
765                window(b, at)
766            );
767        }
768    }
769    let (w, g) = (want.lines().count(), got.lines().count());
770    if w == g {
771        // Equal line-by-line and unequal overall: a trailing newline, which is exactly the
772        // difference a reader would stare past.
773        return "  the lines are identical and the files are not — a trailing newline differs"
774            .to_string();
775    }
776    format!("  the snapshot has {w} lines and the page rendered {g}")
777}
778
779/// One pass through a test's clauses.
780fn execute(
781    placed: &Placed,
782    runtime: &Runtime,
783    recorder: &Arc<Recorder>,
784    t: &TestDef,
785    inputs: &[Value],
786    opts: &Options,
787) -> Result<(), String> {
788    let program = &placed.program;
789    let mut state = runtime
790        .initial_state()
791        .map_err(|e| format!("evaluating the initial state: {e}"))?;
792    let mut events: Vec<Value> = Vec::new();
793    let mut result = Value::ok(Value::list(Vec::new()));
794    let mut seq: u64 = 0;
795    let mut actor: Arc<str> = Arc::from(DEFAULT_ACTOR);
796
797    for clause in &t.clauses {
798        match clause {
799            Clause::Given {
800                events: code,
801                actor: who,
802                ..
803            } => {
804                let who = who.clone().unwrap_or_else(|| actor.clone());
805                let log = eval(runtime, t, code, &state, &events, &result, inputs)?;
806                let log = log
807                    .as_list()
808                    .map(|xs| xs.to_vec())
809                    .ok_or_else(|| "`given` did not produce a list of events".to_string())?;
810                for e in log {
811                    seq += 1;
812                    state = fold(runtime, &state, seq, &who, e)?;
813                }
814            }
815            Clause::When {
816                actor: who,
817                route,
818                commands,
819                ..
820            } => {
821                if let Some(w) = who {
822                    actor = w.clone();
823                }
824                let from = at(&actor, route);
825                for cmd in commands {
826                    let c = eval(runtime, t, cmd, &state, &events, &result, inputs)?;
827                    let proposal = runtime.proposal(&from, c);
828                    let out = runtime
829                        .decide(&state, &proposal)
830                        .map_err(|e| format!("`validate` failed: {e}"))?;
831                    result = out.clone();
832                    // Only an `Ok` reaches the log — the chokepoint is the chokepoint.
833                    if out.variant() == Some("Ok") {
834                        let produced = out
835                            .field("value")
836                            .and_then(|v| v.as_list())
837                            .map(|xs| xs.to_vec())
838                            .unwrap_or_default();
839                        for e in produced {
840                            events.push(e.clone());
841                            seq += 1;
842                            state = fold(runtime, &state, seq, &actor, e)?;
843                        }
844                    }
845                }
846            }
847            Clause::Stub { .. } => {}
848            Clause::Expect { what, .. } => match what {
849                Expectation::Holds(code) => {
850                    let v = eval(runtime, t, code, &state, &events, &result, inputs)?;
851                    if v.as_bool() != Some(true) {
852                        // §4.5: an error message is a product surface. "expected true, got false"
853                        // is what a boolean assertion can say and no more, so a comparison — which
854                        // is what almost every expectation is — reports both sides instead.
855                        if let CoreKind::Prim {
856                            op: beck_core::Prim::Eq,
857                            args,
858                        } = &code.kind
859                        {
860                            if args.len() == 2 {
861                                let l =
862                                    eval(runtime, t, &args[0], &state, &events, &result, inputs)?;
863                                let r =
864                                    eval(runtime, t, &args[1], &state, &events, &result, inputs)?;
865                                return Err(format!(
866                                    "these are not equal\n     is: {}\n  wanted: {}",
867                                    elide(&l.display()),
868                                    elide(&r.display())
869                                ));
870                            }
871                        }
872                        return Err(format!("expected true, got {}", v.display()));
873                    }
874                }
875                Expectation::PageContains {
876                    needle,
877                    actor: who,
878                    route,
879                } => {
880                    let n = eval(runtime, t, needle, &state, &events, &result, inputs)?;
881                    let n = n.as_str().unwrap_or_default().to_string();
882                    let who = who.clone().unwrap_or_else(|| actor.clone());
883                    let seen = at(&who, route);
884                    let page = runtime
885                        .view(&state, &seen)
886                        .map_err(|e| format!("rendering the page for `{who}`: {e}"))?;
887                    let rendered = page.render();
888                    if !rendered.contains(&n) {
889                        return Err(format!(
890                            "the page `{who}` sees does not contain {n:?}\n  page: {}",
891                            elide(&rendered)
892                        ));
893                    }
894                }
895                Expectation::PageMatchesSnapshot {
896                    name,
897                    actor: who,
898                    route,
899                } => {
900                    let who = who.clone().unwrap_or_else(|| actor.clone());
901                    let seen = at(&who, route);
902                    let page = runtime
903                        .view(&state, &seen)
904                        .map_err(|e| format!("rendering the page for `{who}`: {e}"))?;
905                    snapshot(opts, &t.name, name.as_deref(), &who, &page.render())?;
906                }
907                Expectation::FoldEquals {
908                    events: code,
909                    actor: who,
910                } => {
911                    let log = eval(runtime, t, code, &state, &events, &result, inputs)?;
912                    let log = log
913                        .as_list()
914                        .cloned()
915                        .ok_or_else(|| "`fold_of` did not get a list of events".to_string())?;
916                    let who = who.clone().unwrap_or_else(|| Arc::from(DEFAULT_ACTOR));
917                    let mut other = runtime
918                        .initial_state()
919                        .map_err(|e| format!("evaluating the initial state: {e}"))?;
920                    for (i, e) in log.into_iter().enumerate() {
921                        other = fold(runtime, &other, i as u64 + 1, &who, e)?;
922                    }
923                    if digest(&state) != digest(&other) {
924                        return Err(format!(
925                            "the state is not the fold of that log\n     is: {}\n  wanted: {}",
926                            elide(&state.display()),
927                            elide(&other.display())
928                        ));
929                    }
930                }
931                Expectation::Performed { atom, how } => match how {
932                    Count::Never => {
933                        let n = recorder.count(atom);
934                        if n != 0 {
935                            return Err(format!(
936                                "`{}` was performed {n} time(s), and the test says it is not",
937                                atom.name()
938                            ));
939                        }
940                    }
941                    Count::Times(k) => {
942                        let n = recorder.count(atom) as i64;
943                        if n != *k {
944                            return Err(format!(
945                                "`{}` was performed {n} time(s), not {k}",
946                                atom.name()
947                            ));
948                        }
949                    }
950                    Count::With(code) => {
951                        let want = eval(runtime, t, code, &state, &events, &result, inputs)?;
952                        if !recorder.called_with(atom, &want) {
953                            return Err(format!(
954                                "`{}` was never performed with {}",
955                                atom.name(),
956                                want.display()
957                            ));
958                        }
959                    }
960                },
961                Expectation::Place { .. }
962                | Expectation::Flow { .. }
963                | Expectation::WireCompatible { .. } => {
964                    check_static(placed, what, opts)?;
965                }
966            },
967        }
968    }
969    let _ = program;
970    Ok(())
971}
972
973fn static_only(placed: &Placed, t: &TestDef, opts: &Options) -> Result<(), String> {
974    for c in &t.clauses {
975        if let Clause::Expect { what, .. } = c {
976            check_static(placed, what, opts)?;
977        }
978    }
979    Ok(())
980}
981
982/// The assertions answered from the compiler's own data.
983fn check_static(placed: &Placed, what: &Expectation, opts: &Options) -> Result<(), String> {
984    match what {
985        Expectation::Place {
986            what: name, tier, ..
987        } => {
988            let actual = placed
989                .program
990                .defs
991                .get(name.as_ref())
992                .map(|d| d.tier)
993                .or_else(|| {
994                    placed
995                        .program
996                        .signals
997                        .iter()
998                        .find(|s| s.name == *name)
999                        .map(|s| s.tier)
1000                })
1001                .ok_or_else(|| format!("`{name}` is not a definition or a signal"))?;
1002            if actual != *tier {
1003                return Err(format!(
1004                    "`{name}` is placed on `{}`, not `{}`",
1005                    actual.name(),
1006                    tier.name()
1007                ));
1008            }
1009            Ok(())
1010        }
1011        Expectation::Flow { ty, tier } => {
1012            // An unplaced-pure definition is compiled to whichever tier calls it (§3.3), so it
1013            // reaches *every* tier. Counting it as reaching only `any` would make this assertion
1014            // pass for the most dangerous case there is.
1015            let reached: Vec<String> = beck_core::secure::flow(&placed.program, ty)
1016                .into_iter()
1017                .filter(|r| r.tier == *tier || r.tier == Tier::Any)
1018                .map(|r| format!("{} ({})", r.what, r.tier.name()))
1019                .collect();
1020            if !reached.is_empty() {
1021                return Err(format!(
1022                    "`{ty}` reaches {} on `{}`",
1023                    reached.join(", "),
1024                    tier.name()
1025                ));
1026            }
1027            Ok(())
1028        }
1029        Expectation::WireCompatible { path } => {
1030            let file = opts.base_dir.join(path.as_ref());
1031            let src = std::fs::read_to_string(&file)
1032                .map_err(|e| format!("reading `{}`: {e}", file.display()))?;
1033            let mut diags = beck_diag::Diagnostics::new();
1034            let mut map = beck_diag::SourceMap::new();
1035            let previous =
1036                beck_core::Interface::parse(&placed.program.name, &src, &mut map, &mut diags);
1037            if diags.has_errors() {
1038                return Err(format!("`{}` is not a readable interface", file.display()));
1039            }
1040            let current = beck_core::Interface::of(&placed.program);
1041            let changes = beck_core::compare(&previous, &current);
1042            if beck_core::is_breaking(&changes) {
1043                let why: Vec<String> = changes
1044                    .iter()
1045                    .filter(|c| c.severity == beck_core::compat::Severity::Breaking)
1046                    .map(|c| format!("{}: {}", c.what, c.because))
1047                    .collect();
1048                return Err(format!(
1049                    "not wire-compatible with `{path}`\n  {}",
1050                    why.join("\n  ")
1051                ));
1052            }
1053            Ok(())
1054        }
1055        _ => Ok(()),
1056    }
1057}
1058
1059fn fold(
1060    runtime: &Runtime,
1061    state: &Value,
1062    seq: u64,
1063    actor: &str,
1064    event: Value,
1065) -> Result<Value, String> {
1066    // `at` is the sequence position, not a clock. §3.7 makes time data on the envelope, so a test
1067    // that reads `env.at` reads something reproducible instead of something that moves.
1068    let env = Envelope {
1069        seq,
1070        at: Instant(seq as i64),
1071        actor: actor.to_string(),
1072        body: event.clone(),
1073    };
1074    runtime
1075        .fold(state, &env, event)
1076        .map_err(|e| format!("folding at seq {seq}: {e}"))
1077}
1078
1079/// Evaluate a clause expression with `state`, `events`, `result` and a property's parameters bound.
1080///
1081/// The expression is wrapped as a lambda and prepared through [`Backend::function`], so this goes
1082/// through the same seam the runtime's roles do rather than reaching into a particular evaluator.
1083///
1084/// The wrapper is built here rather than by the compiler, which is why it has to ask for its own
1085/// `locals`: the pass that sizes a frame runs over the program and this lambda does not exist
1086/// until now. Without it every `let` inside a `test` block allocated a scope of its own, which is
1087/// the cost `docs/70` removed everywhere else.
1088fn eval(
1089    runtime: &Runtime,
1090    t: &TestDef,
1091    code: &Core,
1092    state: &Value,
1093    events: &[Value],
1094    result: &Value,
1095    inputs: &[Value],
1096) -> Result<Value, String> {
1097    let mut params: Vec<VarId> = vec![t.bindings.state, t.bindings.events, t.bindings.result];
1098    params.extend(t.params.iter().map(|(id, _, _)| *id));
1099    let lam = Core {
1100        kind: CoreKind::Lam {
1101            params: params.into(),
1102            body: std::sync::Arc::new(code.clone()),
1103        },
1104        ty: Ty::fun(Vec::new(), code.ty.clone()),
1105        tier: Tier::Any,
1106        span: code.span,
1107        last_use: false,
1108        order: beck_core::fields::UNORDERED,
1109        locals: beck_core::frames::locals_of(code),
1110    };
1111    let f = runtime
1112        .prepare(&lam)
1113        .map_err(|e| format!("preparing an expectation: {e}"))?;
1114    let mut args = vec![state.clone(), Value::list(events.to_vec()), result.clone()];
1115    args.extend(inputs.iter().cloned());
1116    f(args).map_err(|e| e.to_string())
1117}
1118
1119fn elide(s: &str) -> String {
1120    if s.chars().count() <= 200 {
1121        s.to_string()
1122    } else {
1123        let head: String = s.chars().take(200).collect();
1124        format!("{head}…")
1125    }
1126}
1127
1128/// The console form, so `beck test` and a harness print the same thing.
1129pub fn render(report: &Report, verbose: bool) -> String {
1130    let mut out = String::new();
1131    for c in &report.cases {
1132        let mark = match &c.outcome {
1133            Outcome::Passed => "ok".to_string(),
1134            Outcome::Failed { .. } => "FAILED".to_string(),
1135            Outcome::Skipped(_) => "skipped".to_string(),
1136        };
1137        let runs = if c.runs > 1 {
1138            format!(" ({} inputs)", c.runs)
1139        } else {
1140            String::new()
1141        };
1142        out.push_str(&format!("test {:?} … {mark}{runs}\n", c.name));
1143        match &c.outcome {
1144            Outcome::Failed { why } => {
1145                for line in why.lines() {
1146                    out.push_str(&format!("  {line}\n"));
1147                }
1148            }
1149            Outcome::Skipped(why) => out.push_str(&format!("  {why}\n")),
1150            Outcome::Passed => {}
1151        }
1152        // §21.3: "which is the thing a hidden default must always do: say what it did."
1153        let shown: Vec<&Stubbed> = c
1154            .stubbed
1155            .iter()
1156            .filter(|s| verbose || s.calls > 0)
1157            .collect();
1158        if !shown.is_empty() && (verbose || matches!(c.outcome, Outcome::Failed { .. })) {
1159            out.push_str("  stubbed:\n");
1160            for s in shown {
1161                let how = match (s.explicit, s.from_the_call) {
1162                    (true, true) => "named, from the call",
1163                    (true, false) => "named",
1164                    (false, _) => "automatically",
1165                };
1166                let answered = match (&s.returned, &s.raised) {
1167                    (Some(v), _) => v.display(),
1168                    (None, Some(why)) => why.clone(),
1169                    (None, None) => "—".into(),
1170                };
1171                out.push_str(&format!(
1172                    "    {:<24} by `{}`  → {answered}   called {}× ({how})\n",
1173                    s.atom, s.def, s.calls
1174                ));
1175            }
1176        }
1177    }
1178    out.push_str(&format!(
1179        "\n{} passed, {} failed, {} skipped\n",
1180        report.passed(),
1181        report.failed(),
1182        report.skipped()
1183    ));
1184    out
1185}
1186
1187/// A test's viewer: the actor it named, at the route it named.
1188///
1189/// `None` is the application's root rather than an empty string, for the reason
1190/// [`beck_core::edge::ROOT`] exists — a program matching on `session.path` should not have to
1191/// spell "the test did not say" and "the test said the root" as two different pages.
1192fn at(actor: &Arc<str>, route: &Option<Arc<str>>) -> crate::program::At<Arc<str>> {
1193    crate::program::At {
1194        who: actor.clone(),
1195        path: route
1196            .clone()
1197            .unwrap_or_else(|| Arc::from(beck_core::edge::ROOT)),
1198    }
1199}