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}