beck_core/
testing.rs

1//! `test` and `property` blocks — `docs/21-tests-in-beck-and-proof.md` §21.2 and §21.3, checked.
2//!
3//! This module holds the *checked* shape of a test. The runner is elsewhere (`beck-rt`), because
4//! running one means driving the same `Roles` the runtime drives; what belongs here is the part
5//! that is a language feature: a clause is typed against the program's own `Event`, `Command` and
6//! state types, and an assertion about placement is answered from the compiler's own data without
7//! running anything.
8//!
9//! # Why a test is clauses rather than statements
10//!
11//! §21.2: "A test names a log, an input, and an expectation. The log is the state, because state is
12//! a fold — there is no fixture, no factory and no `setUp`." Each of the three is a clause, so the
13//! checker knows which is which: `given` is a `list[Event]` and goes through the *real*
14//! `apply_event`, `when` is a `Command` and goes through the *real* `validate`. A test therefore
15//! cannot construct a state the program could not reach, which is the property a factory cannot
16//! offer.
17//!
18//! # The row of a test is empty, and that is checked
19//!
20//! §21.2's open question — "Do test blocks have effect rows? They must not" — is settled here as an
21//! error, `B0700`. An expression inside a test performs nothing: a test that could perform
22//! `net.out` is a test that fails when somebody else's server is down. The *subject's* effects are
23//! a different matter, and §21.3's answer is that they are stubbed — see [`Clause::Stub`] and the
24//! auto-stubbing in the runner.
25
26use std::sync::Arc;
27
28use beck_diag::Span;
29
30use crate::core::{Core, VarId};
31use crate::ty::{Effect, Tier, Ty};
32
33/// A checked `test` or `property` block.
34#[derive(Clone, Debug)]
35pub struct TestDef {
36    pub name: Arc<str>,
37    /// Non-empty for a `property`: the inputs a generator supplies (§21.3 rule 5).
38    pub params: Vec<(VarId, Arc<str>, Ty)>,
39    pub clauses: Vec<Clause>,
40    /// The variables `state`, `events` and `result` are bound to while the expectations run.
41    pub bindings: Bindings,
42    pub span: Span,
43}
44
45impl TestDef {
46    pub fn is_property(&self) -> bool {
47        !self.params.is_empty()
48    }
49
50    /// Every expression this test evaluates, to be read rather than rewritten.
51    ///
52    /// [`Editor::references`](crate::editor::Editor::references) is what wants them: a name used
53    /// only inside a `test` block is used, and an editor that did not look here would report it as
54    /// unreferenced and rename it into a program that no longer compiles.
55    ///
56    /// This and [`cores_mut`](TestDef::cores_mut) have to stay the same list — the failure this
57    /// file has already had once is a pass that walked `Program::defs` and missed every expression
58    /// in a `test` block — so `tests::the_two_walks_agree` counts them
59    /// against each other.
60    pub fn cores(&self) -> Vec<&Core> {
61        let mut out = Vec::new();
62        for clause in &self.clauses {
63            match clause {
64                Clause::Given { events, .. } => out.push(events),
65                Clause::When { commands, .. } => out.extend(commands.iter()),
66                Clause::Stub { value, .. } => out.push(value),
67                Clause::Expect { what, .. } => match what {
68                    Expectation::Holds(c) => out.push(c),
69                    Expectation::PageContains { needle, .. } => out.push(needle),
70                    Expectation::FoldEquals { events, .. } => out.push(events),
71                    Expectation::Performed {
72                        how: Count::With(c),
73                        ..
74                    } => out.push(c),
75                    Expectation::PageMatchesSnapshot { .. }
76                    | Expectation::Place { .. }
77                    | Expectation::Flow { .. }
78                    | Expectation::WireCompatible { .. }
79                    | Expectation::Performed { .. } => {}
80                },
81            }
82        }
83        out
84    }
85
86    /// Every expression this test evaluates, to be annotated in place.
87    ///
88    /// A `test` block's expressions are *code*, and the three passes that annotate a finished
89    /// program — [`crate::liveness`], [`crate::frames`] and [`crate::fields`] — reach them through
90    /// here. They did not until [`70`](../../../../../docs/70-the-evaluator-gets-fast-report.md): all three
91    /// walked `Program::defs`, a test's clauses are not in it, and so every expression inside a
92    /// `test` block ran on the paths those passes exist to replace.
93    ///
94    /// A clause that names something rather than computing it — `expect place(charge) == server`,
95    /// `expect no net.out` — contributes nothing, because there is no expression to annotate.
96    pub fn cores_mut(&mut self) -> Vec<&mut Core> {
97        let mut out = Vec::new();
98        for clause in &mut self.clauses {
99            match clause {
100                Clause::Given { events, .. } => out.push(events),
101                Clause::When { commands, .. } => out.extend(commands.iter_mut()),
102                Clause::Stub { value, .. } => out.push(value),
103                Clause::Expect { what, .. } => match what {
104                    Expectation::Holds(c) => out.push(c),
105                    Expectation::PageContains { needle, .. } => out.push(needle),
106                    Expectation::FoldEquals { events, .. } => out.push(events),
107                    Expectation::Performed {
108                        how: Count::With(c),
109                        ..
110                    } => out.push(c),
111                    Expectation::PageMatchesSnapshot { .. }
112                    | Expectation::Place { .. }
113                    | Expectation::Flow { .. }
114                    | Expectation::WireCompatible { .. }
115                    | Expectation::Performed { .. } => {}
116                },
117            }
118        }
119        out
120    }
121
122    /// Every clause's span, so a caller can ask what part of the file a clause covers.
123    pub fn clause_spans(&self) -> impl Iterator<Item = Span> + '_ {
124        self.clauses.iter().map(|c| c.span())
125    }
126
127    /// Every assertion that needs no execution — placement, flow, wire compatibility.
128    ///
129    /// §21.2: "These are compile-time queries, not runtime assertions — `beck test` answers them
130    /// without running anything, from the same data `beck explain place` and `beck check
131    /// --wire-compat` already produce."
132    pub fn is_static_only(&self) -> bool {
133        self.clauses.iter().all(|c| match c {
134            Clause::Expect { what, .. } => what.is_static(),
135            _ => false,
136        })
137    }
138}
139
140/// The three names a test's expectations may use. They are plain data — a folded state, the events
141/// a command produced, and the result of the last one — so every backend can hold them.
142#[derive(Clone, Copy, Debug)]
143pub struct Bindings {
144    pub state: VarId,
145    pub events: VarId,
146    pub result: VarId,
147}
148
149impl Clause {
150    /// The part of the source this clause covers.
151    pub fn span(&self) -> Span {
152        match self {
153            Clause::Given { span, .. }
154            | Clause::When { span, .. }
155            | Clause::Stub { span, .. }
156            | Clause::Expect { span, .. } => *span,
157        }
158    }
159}
160
161#[derive(Clone, Debug)]
162pub enum Clause {
163    /// `given [Added(…)] by "ana"` — the log the state is folded from.
164    Given {
165        events: Core,
166        actor: Option<Arc<str>>,
167        span: Span,
168    },
169    /// `when session("ana") sends Add(…), Toggle(…)` — proposals through the real `validate`.
170    When {
171        actor: Option<Arc<str>>,
172        /// `when session("ana", "/done") sends …` — the route the proposal was made from, which is
173        /// what a `Proposal`'s own session carries. `None` is the application's root.
174        route: Option<Arc<str>>,
175        commands: Vec<Core>,
176        span: Span,
177    },
178    /// `stub net.out(payments.example.com): Declined` — §21.3 rules 2 and 3.
179    Stub {
180        atom: Effect,
181        /// The stubbed definition's parameters, when the stub answers *from* them (rule 3).
182        /// Empty for a plain value (rule 2), which is evaluated once and does not see the call.
183        params: Vec<VarId>,
184        value: Core,
185        span: Span,
186    },
187    Expect {
188        what: Expectation,
189        span: Span,
190    },
191}
192
193#[derive(Clone, Debug)]
194pub enum Expectation {
195    /// `expect <Bool>`, with `state`, `events` and `result` in scope.
196    Holds(Core),
197    /// `expect page(session("bo")) contains "milk"`, and `session("bo", "/done")` for a route.
198    PageContains {
199        needle: Core,
200        actor: Option<Arc<str>>,
201        route: Option<Arc<str>>,
202    },
203    /// `expect page matches snapshot` / `… matches snapshot "after checkout"`.
204    ///
205    /// The rendered page is compared to a checked-in file rather than to a string in the test, so
206    /// the assertion is the whole page rather than the part somebody remembered to name. `name` is
207    /// `None` when the test's own name keys it — the common case, and the one that keeps the
208    /// assertion one line long.
209    PageMatchesSnapshot {
210        name: Option<Arc<str>>,
211        actor: Option<Arc<str>>,
212        route: Option<Arc<str>>,
213    },
214    /// `expect state == fold_of [ … ]`.
215    FoldEquals {
216        events: Core,
217        actor: Option<Arc<str>>,
218    },
219    /// `expect place(charge) == server`.
220    Place {
221        what: Arc<str>,
222        /// Where `charge` is written. The name is a **reference** to a definition, resolved
223        /// against the placement table rather than evaluated, so there is no `Core` node carrying
224        /// its position — and an editor renaming that definition has to edit this too
225        /// ([`crate::editor::Editor::occurrences`]).
226        what_span: Span,
227        tier: Tier,
228    },
229    /// `expect flow(ApiKey) reaches nothing on client`.
230    Flow { ty: Arc<str>, tier: Tier },
231    /// `expect wire_compatible_with "orders.v1.becki"`.
232    WireCompatible { path: Arc<str> },
233    /// `expect no net.out` / `… once` / `… with Charge(amount=2000)` — §21.3 rule 4.
234    Performed { atom: Effect, how: Count },
235}
236
237impl Expectation {
238    pub fn is_static(&self) -> bool {
239        matches!(
240            self,
241            Expectation::Place { .. }
242                | Expectation::Flow { .. }
243                | Expectation::WireCompatible { .. }
244        )
245    }
246}
247
248#[derive(Clone, Debug)]
249pub enum Count {
250    /// `expect no net.out` — nothing left the process.
251    Never,
252    Times(i64),
253    With(Core),
254}
255
256/// Which effect atoms a stub can stand in for.
257///
258/// §21.3: "What is left is the genuinely external: `net.out(host)`, `env`, `external.read/write
259/// (store)`, `fs.read/write(path)`, `cap.*`, `nondet`." Two of that list are handled by the harness rather than
260/// by a stub and are excluded here for reasons worth stating:
261///
262/// * `nondet` — ids and the clock are supplied deterministically by the harness, because §3.7
263///   already makes them data at the edge. A stub would be a second answer to a solved problem.
264/// * `cap.*` — a capability is discharged by the authority chokepoint, and §21.2's whole claim for
265///   `when` is that it "goes through the *real* `validate`, so authorisation is exercised rather
266///   than bypassed". Stubbing a capability would bypass it. An explicit `stub cap.x:` is still
267///   accepted — saying it out loud is the point — but nothing is stubbed automatically.
268/// * `spawn` — not on §21.3's list either, and not external at all: a `parallel:` scope is the
269///   program's own control flow, and standing in for it would delete the children rather than the
270///   boundary they cross. What a test wants stubbed is what a child *does*.
271pub fn is_auto_stubbable(e: &Effect) -> bool {
272    matches!(
273        e,
274        Effect::NetOut(_)
275            | Effect::NetIn
276            | Effect::FsRead(_)
277            | Effect::FsWrite(_)
278            | Effect::Env
279            | Effect::ExternalRead(_)
280            | Effect::ExternalWrite(_)
281    )
282}
283
284/// Whether an atom may be named in a `stub` clause at all.
285pub fn is_stubbable(e: &Effect) -> bool {
286    is_auto_stubbable(e) || matches!(e, Effect::Cap(_))
287}
288
289/// Does a definition *perform* an atom, as opposed to inheriting it from something it calls?
290///
291/// This distinction is the whole difference between a stub that works and one that deletes the
292/// program. An effect row propagates: `validate` calls `charge`, so `validate`'s row contains
293/// `net.out(payments.example.com)` too. Stubbing every definition whose row mentions the atom would
294/// replace `validate` itself — and §21.2's claim that `when` "goes through the *real* `validate`,
295/// so authorisation is exercised rather than bypassed" would be false of every program that talks
296/// to anything.
297///
298/// A definition performs an atom itself when it *declares* it (§3.6's `uses` clause is the
299/// published bound and the only way to introduce a non-primitive effect) or when its own body
300/// applies a primitive that carries it. Everything else in the row arrived from a callee, and the
301/// callee is where the stub belongs.
302pub fn performs_itself(d: &crate::check::Def, atom: &Effect) -> bool {
303    if d.declared_effects.contains(atom) {
304        return true;
305    }
306    // The atoms this body reaches through primitives alone — the global oracle contributes
307    // nothing, so a call to an effectful definition does not count.
308    let mut own = Vec::new();
309    d.body.effects(&|_| Vec::new(), &mut own);
310    own.contains(atom)
311}
312
313#[cfg(test)]
314mod tests {
315    use crate::check_str;
316    use crate::split::tests::TODO;
317
318    fn with(
319        extra: &str,
320    ) -> (
321        crate::check::Program,
322        beck_diag::Diagnostics,
323        beck_diag::SourceMap,
324    ) {
325        check_str("todo.beck", &format!("{TODO}\n{extra}"))
326    }
327
328    #[test]
329    fn a_test_is_typed_against_the_programs_own_event_and_command_types() {
330        let (p, d, m) = with(
331            "test \"an empty todo is rejected\":\n    given []\n    when Add(id=Id(\"1\"), text=\"   \")\n    expect Err(error=BlankText)\n",
332        );
333        assert!(!d.has_errors(), "{}", d.render(&m));
334        assert_eq!(p.tests.len(), 1);
335        assert_eq!(p.tests[0].name.as_ref(), "an empty todo is rejected");
336        assert_eq!(p.tests[0].clauses.len(), 3);
337    }
338
339    #[test]
340    fn a_given_that_is_not_a_log_of_this_programs_events_is_a_type_error() {
341        // The fixture-versus-log distinction, mechanised: a test cannot arrange a state out of
342        // values the program's own stream could never carry.
343        let (_, d, _) = with("test \"x\":\n    given [1, 2, 3]\n");
344        assert!(d.has_errors());
345    }
346
347    #[test]
348    fn a_command_the_union_does_not_declare_is_a_type_error() {
349        let (_, d, _) = with("test \"x\":\n    when Frobnicate(id=Id(\"1\"))\n");
350        assert!(d.has_errors());
351    }
352
353    #[test]
354    fn a_test_that_performs_an_effect_is_refused_by_name() {
355        // §21.2's open question, settled: "a test that performs a real `net.out` is a test that can
356        // fail because somebody else's server is down".
357        let src = format!(
358            "{TODO}\ndef phone_home() -> Bool uses net.out(x.example.com):\n    return True\n\ntest \"x\":\n    expect phone_home()\n"
359        );
360        let (_, d, _) = check_str("todo.beck", &src);
361        assert!(
362            d.iter().any(|x| x.code == "B0700"),
363            "{:?}",
364            d.iter().map(|x| x.code).collect::<Vec<_>>()
365        );
366    }
367
368    #[test]
369    fn a_stub_is_typed_from_the_return_type_of_whatever_performs_the_effect() {
370        let src = format!(
371            "{TODO}\ndef charge() -> Bool uses net.out(pay.example.com):\n    return True\n\ntest \"x\":\n    stub net.out(pay.example.com): False\n"
372        );
373        let (p, d, m) = check_str("todo.beck", &src);
374        assert!(!d.has_errors(), "{}", d.render(&m));
375        assert!(matches!(p.tests[0].clauses[0], super::Clause::Stub { .. }));
376
377        // …and a stub whose value is the wrong type is a type error, with no parameter list
378        // restated anywhere.
379        let src = format!(
380            "{TODO}\ndef charge() -> Bool uses net.out(pay.example.com):\n    return True\n\ntest \"x\":\n    stub net.out(pay.example.com): 3\n"
381        );
382        let (_, d, _) = check_str("todo.beck", &src);
383        assert!(d.has_errors());
384    }
385
386    #[test]
387    fn a_stub_can_answer_from_the_call_and_the_arguments_are_in_scope_by_name() {
388        // §21.3 rule 3. The stubbed definition's parameters are bound under their own names, so a
389        // stub is written the way the definition is read — and `match`, `if` and everything else in
390        // the language work inside it without a mock DSL.
391        let src = format!(
392            "{TODO}\ndef charge(amount: Int) -> Bool uses net.out(pay.example.com):\n    return True\n\ntest \"x\":\n    stub net.out(pay.example.com):\n        return amount > 10\n"
393        );
394        let (p, d, m) = check_str("todo.beck", &src);
395        assert!(!d.has_errors(), "{}", d.render(&m));
396        match &p.tests[0].clauses[0] {
397            super::Clause::Stub { params, .. } => assert_eq!(params.len(), 1),
398            other => panic!("{other:?}"),
399        }
400
401        // …and the body is typechecked against the definition's return type like any other code.
402        let src = format!(
403            "{TODO}\ndef charge(amount: Int) -> Bool uses net.out(pay.example.com):\n    return True\n\ntest \"x\":\n    stub net.out(pay.example.com):\n        return amount\n"
404        );
405        let (_, d, _) = check_str("todo.beck", &src);
406        assert!(d.has_errors(), "an Int is not a Bool");
407    }
408
409    #[test]
410    fn bare_case_arms_match_on_the_one_argument_there_is() {
411        let src = format!(
412            "{TODO}\ndef charge(amount: Int) -> Bool uses net.out(pay.example.com):\n    return True\n\ntest \"x\":\n    stub net.out(pay.example.com):\n        case 1:\n            return True\n        case _:\n            return False\n"
413        );
414        let (p, d, m) = check_str("todo.beck", &src);
415        assert!(!d.has_errors(), "{}", d.render(&m));
416        assert!(matches!(p.tests[0].clauses[0], super::Clause::Stub { .. }));
417
418        // Two arguments and no scrutinee written is a refusal, not a guess.
419        let src = format!(
420            "{TODO}\ndef charge(amount: Int, tries: Int) -> Bool uses net.out(pay.example.com):\n    return True\n\ntest \"x\":\n    stub net.out(pay.example.com):\n        case 1:\n            return True\n        case _:\n            return False\n"
421        );
422        let (_, d, _) = check_str("todo.beck", &src);
423        assert!(d.iter().any(|x| x.code == "B0707"));
424    }
425
426    #[test]
427    fn a_stub_that_answers_from_the_call_needs_one_definition_to_take_it_from() {
428        // Two definitions can share a stub *value* — a value looks at nothing. They cannot share a
429        // body, because a body names parameters and there is no reason theirs agree.
430        let two = format!(
431            "{TODO}\ndef charge(amount: Int) -> Bool uses net.out(pay.example.com):\n    return True\n\ndef refund(amount: Int) -> Bool uses net.out(pay.example.com):\n    return True\n"
432        );
433        let (_, d, m) = check_str(
434            "todo.beck",
435            &format!("{two}\ntest \"x\":\n    stub net.out(pay.example.com): True\n"),
436        );
437        assert!(!d.has_errors(), "a value still works: {}", d.render(&m));
438
439        let (_, d, _) = check_str(
440            "todo.beck",
441            &format!("{two}\ntest \"x\":\n    stub net.out(pay.example.com):\n        return amount > 1\n"),
442        );
443        assert!(d.iter().any(|x| x.code == "B0707"), "a body cannot");
444    }
445
446    #[test]
447    fn a_stub_body_is_test_code_and_may_not_perform_anything_either() {
448        let src = format!(
449            "{TODO}\ndef charge(amount: Int) -> Bool uses net.out(pay.example.com):\n    return True\n\ndef ping() -> Bool uses net.out(other.example.com):\n    return True\n\ntest \"x\":\n    stub net.out(pay.example.com):\n        return ping()\n"
450        );
451        let (_, d, _) = check_str("todo.beck", &src);
452        assert!(d.iter().any(|x| x.code == "B0700"));
453    }
454
455    #[test]
456    fn stubbing_an_effect_nothing_performs_says_so_rather_than_passing_quietly() {
457        let (_, d, _) = with("test \"x\":\n    stub net.out(nobody.example.com): True\n");
458        assert!(d.iter().any(|x| x.code == "B0704"));
459    }
460
461    #[test]
462    fn the_durable_fold_and_the_clock_are_not_things_a_stub_can_replace() {
463        let (_, d, _) = with("test \"x\":\n    stub durable: True\n");
464        assert!(d.iter().any(|x| x.code == "B0703"));
465    }
466
467    #[test]
468    fn a_program_with_no_merge_point_is_told_what_given_would_mean() {
469        let (_, d, _) = check_str(
470            "t.beck",
471            "def f() -> Int:\n    return 1\n\ntest \"x\":\n    given []\n",
472        );
473        assert!(d.iter().any(|x| x.code == "B0706"));
474    }
475
476    #[test]
477    fn a_property_carries_typed_parameters_for_the_generator() {
478        let (p, d, m) = with("property \"any log folds\"(log: list[Event]):\n    given log\n    expect map_len(state.todos) >= 0\n");
479        assert!(!d.has_errors(), "{}", d.render(&m));
480        assert!(p.tests[0].is_property());
481        assert_eq!(p.tests[0].params.len(), 1);
482    }
483
484    #[test]
485    fn the_two_walks_agree() {
486        // `cores` and `cores_mut` are the same list written twice, and a clause added to one and
487        // not the other is invisible until something downstream skips an expression. The fixture
488        // carries one of every clause that holds an expression, so a new variant that only reaches
489        // the mutable walk changes these counts.
490        let (mut p, d, m) = with(
491            "test \"every clause that carries an expression\":\n    \
492             given []\n    \
493             when Add(id=Id(\"1\"), text=\"milk\")\n    \
494             expect map_len(state.todos) == 1\n    \
495             expect state == fold_of []\n",
496        );
497        assert!(!d.has_errors(), "{}", d.render(&m));
498        let test = &mut p.tests[0];
499        let read = test.cores().len();
500        assert!(read >= 4, "the fixture exercises four clauses, got {read}");
501        assert_eq!(read, test.cores_mut().len());
502    }
503
504    #[test]
505    fn the_static_assertions_need_no_execution() {
506        let (p, d, m) = with(
507            "test \"the page is a browser's job\":\n    expect place(page) == client\n    expect flow(Todo) reaches nothing on server\n",
508        );
509        assert!(!d.has_errors(), "{}", d.render(&m));
510        assert!(p.tests[0].is_static_only());
511    }
512}