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}