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 annotated in place.
51    ///
52    /// A `test` block's expressions are *code*, and the three passes that annotate a finished
53    /// program — [`crate::liveness`], [`crate::frames`] and [`crate::fields`] — reach them through
54    /// here. They did not until [`79`](../../../../../docs/79-a-lambda-is-a-frame-report.md): all three
55    /// walked `Program::defs`, a test's clauses are not in it, and so every expression inside a
56    /// `test` block ran on the paths those passes exist to replace.
57    ///
58    /// A clause that names something rather than computing it — `expect place(charge) == server`,
59    /// `expect no net.out` — contributes nothing, because there is no expression to annotate.
60    pub fn cores_mut(&mut self) -> Vec<&mut Core> {
61        let mut out = Vec::new();
62        for clause in &mut self.clauses {
63            match clause {
64                Clause::Given { events, .. } => out.push(events),
65                Clause::When { commands, .. } => out.extend(commands.iter_mut()),
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 assertion that needs no execution — placement, flow, wire compatibility.
87    ///
88    /// §21.2: "These are compile-time queries, not runtime assertions — `beck test` answers them
89    /// without running anything, from the same data `beck explain place` and `beck check
90    /// --wire-compat` already produce."
91    pub fn is_static_only(&self) -> bool {
92        self.clauses.iter().all(|c| match c {
93            Clause::Expect { what, .. } => what.is_static(),
94            _ => false,
95        })
96    }
97}
98
99/// The three names a test's expectations may use. They are plain data — a folded state, the events
100/// a command produced, and the result of the last one — so every backend can hold them.
101#[derive(Clone, Copy, Debug)]
102pub struct Bindings {
103    pub state: VarId,
104    pub events: VarId,
105    pub result: VarId,
106}
107
108#[derive(Clone, Debug)]
109pub enum Clause {
110    /// `given [Added(…)] by "ana"` — the log the state is folded from.
111    Given {
112        events: Core,
113        actor: Option<Arc<str>>,
114        span: Span,
115    },
116    /// `when session("ana") sends Add(…), Toggle(…)` — proposals through the real `validate`.
117    When {
118        actor: Option<Arc<str>>,
119        commands: Vec<Core>,
120        span: Span,
121    },
122    /// `stub net.out(payments.example.com): Declined` — §21.3 rules 2 and 3.
123    Stub {
124        atom: Effect,
125        /// The stubbed definition's parameters, when the stub answers *from* them (rule 3).
126        /// Empty for a plain value (rule 2), which is evaluated once and does not see the call.
127        params: Vec<VarId>,
128        value: Core,
129        span: Span,
130    },
131    Expect {
132        what: Expectation,
133        span: Span,
134    },
135}
136
137#[derive(Clone, Debug)]
138pub enum Expectation {
139    /// `expect <Bool>`, with `state`, `events` and `result` in scope.
140    Holds(Core),
141    /// `expect page(session("bo")) contains "milk"`.
142    PageContains {
143        needle: Core,
144        actor: Option<Arc<str>>,
145    },
146    /// `expect page matches snapshot` / `… matches snapshot "after checkout"`.
147    ///
148    /// The rendered page is compared to a checked-in file rather than to a string in the test, so
149    /// the assertion is the whole page rather than the part somebody remembered to name. `name` is
150    /// `None` when the test's own name keys it — the common case, and the one that keeps the
151    /// assertion one line long.
152    PageMatchesSnapshot {
153        name: Option<Arc<str>>,
154        actor: Option<Arc<str>>,
155    },
156    /// `expect state == fold_of [ … ]`.
157    FoldEquals {
158        events: Core,
159        actor: Option<Arc<str>>,
160    },
161    /// `expect place(charge) == server`.
162    Place { what: Arc<str>, tier: Tier },
163    /// `expect flow(ApiKey) reaches nothing on client`.
164    Flow { ty: Arc<str>, tier: Tier },
165    /// `expect wire_compatible_with "orders.v1.becki"`.
166    WireCompatible { path: Arc<str> },
167    /// `expect no net.out` / `… once` / `… with Charge(amount=2000)` — §21.3 rule 4.
168    Performed { atom: Effect, how: Count },
169}
170
171impl Expectation {
172    pub fn is_static(&self) -> bool {
173        matches!(
174            self,
175            Expectation::Place { .. }
176                | Expectation::Flow { .. }
177                | Expectation::WireCompatible { .. }
178        )
179    }
180}
181
182#[derive(Clone, Debug)]
183pub enum Count {
184    /// `expect no net.out` — nothing left the process.
185    Never,
186    Times(i64),
187    With(Core),
188}
189
190/// Which effect atoms a stub can stand in for.
191///
192/// §21.3: "What is left is the genuinely external: `net.out(host)`, `env`, `external.read/write
193/// (store)`, `fs.read/write(path)`, `cap.*`, `nondet`." Two of that list are handled by the harness rather than
194/// by a stub and are excluded here for reasons worth stating:
195///
196/// * `nondet` — ids and the clock are supplied deterministically by the harness, because §3.7
197///   already makes them data at the edge. A stub would be a second answer to a solved problem.
198/// * `cap.*` — a capability is discharged by the authority chokepoint, and §21.2's whole claim for
199///   `when` is that it "goes through the *real* `validate`, so authorisation is exercised rather
200///   than bypassed". Stubbing a capability would bypass it. An explicit `stub cap.x:` is still
201///   accepted — saying it out loud is the point — but nothing is stubbed automatically.
202/// * `spawn` — not on §21.3's list either, and not external at all: a `parallel:` scope is the
203///   program's own control flow, and standing in for it would delete the children rather than the
204///   boundary they cross. What a test wants stubbed is what a child *does*.
205pub fn is_auto_stubbable(e: &Effect) -> bool {
206    matches!(
207        e,
208        Effect::NetOut(_)
209            | Effect::NetIn
210            | Effect::FsRead(_)
211            | Effect::FsWrite(_)
212            | Effect::Env
213            | Effect::ExternalRead(_)
214            | Effect::ExternalWrite(_)
215    )
216}
217
218/// Whether an atom may be named in a `stub` clause at all.
219pub fn is_stubbable(e: &Effect) -> bool {
220    is_auto_stubbable(e) || matches!(e, Effect::Cap(_))
221}
222
223/// Does a definition *perform* an atom, as opposed to inheriting it from something it calls?
224///
225/// This distinction is the whole difference between a stub that works and one that deletes the
226/// program. An effect row propagates: `validate` calls `charge`, so `validate`'s row contains
227/// `net.out(payments.example.com)` too. Stubbing every definition whose row mentions the atom would
228/// replace `validate` itself — and §21.2's claim that `when` "goes through the *real* `validate`,
229/// so authorisation is exercised rather than bypassed" would be false of every program that talks
230/// to anything.
231///
232/// A definition performs an atom itself when it *declares* it (§3.6's `uses` clause is the
233/// published bound and the only way to introduce a non-primitive effect) or when its own body
234/// applies a primitive that carries it. Everything else in the row arrived from a callee, and the
235/// callee is where the stub belongs.
236pub fn performs_itself(d: &crate::check::Def, atom: &Effect) -> bool {
237    if d.declared_effects.contains(atom) {
238        return true;
239    }
240    // The atoms this body reaches through primitives alone — the global oracle contributes
241    // nothing, so a call to an effectful definition does not count.
242    let mut own = Vec::new();
243    d.body.effects(&|_| Vec::new(), &mut own);
244    own.contains(atom)
245}
246
247#[cfg(test)]
248mod tests {
249    use crate::check_str;
250    use crate::split::tests::TODO;
251
252    fn with(
253        extra: &str,
254    ) -> (
255        crate::check::Program,
256        beck_diag::Diagnostics,
257        beck_diag::SourceMap,
258    ) {
259        check_str("todo.beck", &format!("{TODO}\n{extra}"))
260    }
261
262    #[test]
263    fn a_test_is_typed_against_the_programs_own_event_and_command_types() {
264        let (p, d, m) = with(
265            "test \"an empty todo is rejected\":\n    given []\n    when Add(id=Id(\"1\"), text=\"   \")\n    expect Err(error=BlankText)\n",
266        );
267        assert!(!d.has_errors(), "{}", d.render(&m));
268        assert_eq!(p.tests.len(), 1);
269        assert_eq!(p.tests[0].name.as_ref(), "an empty todo is rejected");
270        assert_eq!(p.tests[0].clauses.len(), 3);
271    }
272
273    #[test]
274    fn a_given_that_is_not_a_log_of_this_programs_events_is_a_type_error() {
275        // The fixture-versus-log distinction, mechanised: a test cannot arrange a state out of
276        // values the program's own stream could never carry.
277        let (_, d, _) = with("test \"x\":\n    given [1, 2, 3]\n");
278        assert!(d.has_errors());
279    }
280
281    #[test]
282    fn a_command_the_union_does_not_declare_is_a_type_error() {
283        let (_, d, _) = with("test \"x\":\n    when Frobnicate(id=Id(\"1\"))\n");
284        assert!(d.has_errors());
285    }
286
287    #[test]
288    fn a_test_that_performs_an_effect_is_refused_by_name() {
289        // §21.2's open question, settled: "a test that performs a real `net.out` is a test that can
290        // fail because somebody else's server is down".
291        let src = format!(
292            "{TODO}\ndef phone_home() -> Bool uses net.out(x.example.com):\n    return True\n\ntest \"x\":\n    expect phone_home()\n"
293        );
294        let (_, d, _) = check_str("todo.beck", &src);
295        assert!(
296            d.iter().any(|x| x.code == "B0700"),
297            "{:?}",
298            d.iter().map(|x| x.code).collect::<Vec<_>>()
299        );
300    }
301
302    #[test]
303    fn a_stub_is_typed_from_the_return_type_of_whatever_performs_the_effect() {
304        let src = format!(
305            "{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"
306        );
307        let (p, d, m) = check_str("todo.beck", &src);
308        assert!(!d.has_errors(), "{}", d.render(&m));
309        assert!(matches!(p.tests[0].clauses[0], super::Clause::Stub { .. }));
310
311        // …and a stub whose value is the wrong type is a type error, with no parameter list
312        // restated anywhere.
313        let src = format!(
314            "{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"
315        );
316        let (_, d, _) = check_str("todo.beck", &src);
317        assert!(d.has_errors());
318    }
319
320    #[test]
321    fn a_stub_can_answer_from_the_call_and_the_arguments_are_in_scope_by_name() {
322        // §21.3 rule 3. The stubbed definition's parameters are bound under their own names, so a
323        // stub is written the way the definition is read — and `match`, `if` and everything else in
324        // the language work inside it without a mock DSL.
325        let src = format!(
326            "{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"
327        );
328        let (p, d, m) = check_str("todo.beck", &src);
329        assert!(!d.has_errors(), "{}", d.render(&m));
330        match &p.tests[0].clauses[0] {
331            super::Clause::Stub { params, .. } => assert_eq!(params.len(), 1),
332            other => panic!("{other:?}"),
333        }
334
335        // …and the body is typechecked against the definition's return type like any other code.
336        let src = format!(
337            "{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"
338        );
339        let (_, d, _) = check_str("todo.beck", &src);
340        assert!(d.has_errors(), "an Int is not a Bool");
341    }
342
343    #[test]
344    fn bare_case_arms_match_on_the_one_argument_there_is() {
345        let src = format!(
346            "{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"
347        );
348        let (p, d, m) = check_str("todo.beck", &src);
349        assert!(!d.has_errors(), "{}", d.render(&m));
350        assert!(matches!(p.tests[0].clauses[0], super::Clause::Stub { .. }));
351
352        // Two arguments and no scrutinee written is a refusal, not a guess.
353        let src = format!(
354            "{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"
355        );
356        let (_, d, _) = check_str("todo.beck", &src);
357        assert!(d.iter().any(|x| x.code == "B0707"));
358    }
359
360    #[test]
361    fn a_stub_that_answers_from_the_call_needs_one_definition_to_take_it_from() {
362        // Two definitions can share a stub *value* — a value looks at nothing. They cannot share a
363        // body, because a body names parameters and there is no reason theirs agree.
364        let two = format!(
365            "{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"
366        );
367        let (_, d, m) = check_str(
368            "todo.beck",
369            &format!("{two}\ntest \"x\":\n    stub net.out(pay.example.com): True\n"),
370        );
371        assert!(!d.has_errors(), "a value still works: {}", d.render(&m));
372
373        let (_, d, _) = check_str(
374            "todo.beck",
375            &format!("{two}\ntest \"x\":\n    stub net.out(pay.example.com):\n        return amount > 1\n"),
376        );
377        assert!(d.iter().any(|x| x.code == "B0707"), "a body cannot");
378    }
379
380    #[test]
381    fn a_stub_body_is_test_code_and_may_not_perform_anything_either() {
382        let src = format!(
383            "{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"
384        );
385        let (_, d, _) = check_str("todo.beck", &src);
386        assert!(d.iter().any(|x| x.code == "B0700"));
387    }
388
389    #[test]
390    fn stubbing_an_effect_nothing_performs_says_so_rather_than_passing_quietly() {
391        let (_, d, _) = with("test \"x\":\n    stub net.out(nobody.example.com): True\n");
392        assert!(d.iter().any(|x| x.code == "B0704"));
393    }
394
395    #[test]
396    fn the_durable_fold_and_the_clock_are_not_things_a_stub_can_replace() {
397        let (_, d, _) = with("test \"x\":\n    stub durable: True\n");
398        assert!(d.iter().any(|x| x.code == "B0703"));
399    }
400
401    #[test]
402    fn a_program_with_no_merge_point_is_told_what_given_would_mean() {
403        let (_, d, _) = check_str(
404            "t.beck",
405            "def f() -> Int:\n    return 1\n\ntest \"x\":\n    given []\n",
406        );
407        assert!(d.iter().any(|x| x.code == "B0706"));
408    }
409
410    #[test]
411    fn a_property_carries_typed_parameters_for_the_generator() {
412        let (p, d, m) = with("property \"any log folds\"(log: list[Event]):\n    given log\n    expect map_len(state.todos) >= 0\n");
413        assert!(!d.has_errors(), "{}", d.render(&m));
414        assert!(p.tests[0].is_property());
415        assert_eq!(p.tests[0].params.len(), 1);
416    }
417
418    #[test]
419    fn the_static_assertions_need_no_execution() {
420        let (p, d, m) = with(
421            "test \"the page is a browser's job\":\n    expect place(page) == client\n    expect flow(Todo) reaches nothing on server\n",
422        );
423        assert!(!d.has_errors(), "{}", d.render(&m));
424        assert!(p.tests[0].is_static_only());
425    }
426}