Skip to main content

bynk_driver/
test_json.rs

1//! v0.59: the `bynkc test --format json` result model, plus the parser that
2//! folds the runner's NDJSON event stream into it.
3//!
4//! The generated `tests/main.ts` runner emits one JSON event per line when
5//! `BYNK_TEST_FORMAT=ndjson` (an **internal** protocol — proposal v0.59,
6//! Decision 2); `run_test` captures that stream and renders the single pinned
7//! **document** below. The document is built from `#[derive(Serialize)]` structs
8//! in **declaration order** — field order *is* the contract (the discipline
9//! `bynk/src/report.rs` calls out); we never use `serde_json::json!`, so the
10//! `preserve_order` feature some workspace crates enable can't reorder it.
11//!
12//! There are three terminal states, distinguished by the consumer on the
13//! presence/`kind` of `error`:
14//! - **normal** — `suites` present, no `error` (may have `failed > 0`);
15//! - **compile** — the project never compiled: no `suites`, `error.kind ==
16//!   "compile"` carrying the `bynkc` diagnostic lines;
17//! - **runtime** — the runner started then died before `run-end`: the observed
18//!   `suites` prefix *and* `error.kind == "runtime"` with the captured stderr.
19
20use serde::Serialize;
21
22/// The pinned `bynkc test --format json` document.
23#[derive(Debug, PartialEq, Serialize)]
24pub struct TestRun {
25    pub passed: u32,
26    pub failed: u32,
27    /// #2138: the cases a remote run skipped (`--skip-unsupported`). Absent
28    /// when zero, so every other document's layout is unchanged.
29    #[serde(skip_serializing_if = "is_zero")]
30    pub skipped: u32,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub suites: Option<Vec<Suite>>,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub error: Option<TestError>,
35    /// #854: the optional coverage block, present only for a `--coverage` run
36    /// that produced attributable lines. Last field, so every existing
37    /// document's byte layout is unchanged when it is absent.
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub coverage: Option<Coverage>,
40    /// #2138: where the `system` suites ran — `"remote:<target>"` under
41    /// `--against`. Absent for an in-process run. Last field, so every
42    /// existing document's layout is unchanged.
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub transport: Option<String>,
45}
46
47fn is_zero(n: &u32) -> bool {
48    *n == 0
49}
50
51/// The `coverage` block: whole-run totals plus one entry per measured `.bynk`
52/// file, keyed by project-relative path. Attributed to `.bynk` source lines, not
53/// emitted `.ts`/`.js` (issue #854); generated glue with no source origin is
54/// out-of-scope, never counted.
55#[derive(Debug, PartialEq, Serialize)]
56pub struct Coverage {
57    /// Covered executable lines across every measured file.
58    pub covered: u32,
59    /// Total executable lines across every measured file.
60    pub lines: u32,
61    /// Whole-run percentage (0–100), rounded.
62    pub percent: u32,
63    pub files: Vec<FileCoverage>,
64}
65
66/// One measured `.bynk` file's line coverage.
67#[derive(Debug, PartialEq, Serialize)]
68pub struct FileCoverage {
69    pub path: String,
70    pub covered: u32,
71    pub lines: u32,
72    pub percent: u32,
73    /// Uncovered executable lines, 1-based and ascending.
74    pub uncovered: Vec<u32>,
75    /// #2104: covered executable lines, 1-based and ascending. With
76    /// `uncovered`, every executable line, so an editor can mark both. Named
77    /// `covered_lines` because `covered` is already the count. Additive and
78    /// last, so a consumer that predates it is unaffected.
79    pub covered_lines: Vec<u32>,
80}
81
82impl From<&crate::coverage::CoverageReport> for Coverage {
83    fn from(r: &crate::coverage::CoverageReport) -> Self {
84        Coverage {
85            covered: r.total_covered(),
86            lines: r.total_lines(),
87            percent: r.total_percent(),
88            files: r
89                .files
90                .iter()
91                .map(|f| FileCoverage {
92                    path: f.path.clone(),
93                    covered: f.covered,
94                    lines: f.total,
95                    percent: crate::coverage::percent(f.covered, f.total),
96                    uncovered: f.uncovered.clone(),
97                    covered_lines: f.covered_lines.clone(),
98                })
99                .collect(),
100        }
101    }
102}
103
104#[derive(Debug, PartialEq, Serialize)]
105pub struct Suite {
106    pub name: String,
107    pub kind: String,
108    pub cases: Vec<Case>,
109}
110
111#[derive(Debug, PartialEq, Serialize)]
112pub struct Case {
113    pub name: String,
114    /// `"pass"` / `"fail"` from a run, `"skipped"` for a case a remote run
115    /// skipped (#2138), or `"discovered"` in a `--no-run`
116    /// discovery document (the case was listed, not executed).
117    pub outcome: String,
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub message: Option<String>,
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub location: Option<Location>,
122    /// #2138 (track §3.6): how often `--settle` retried the case's `expect`s
123    /// in a remote run. Absent when it wasn't retried.
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub retries: Option<u32>,
126    /// The case's effective tier, `"unit"`, `"integration"` or `"system"`
127    /// (ADR 0153), attached from the compile's case table: the suite `kind`
128    /// follows the module name and cannot say it. Appended last and omitted when
129    /// absent (a property has none), so a document without it keeps its layout.
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub tier: Option<String>,
132    /// A failed generative case: `"property"` or `"history"`. Set with `seed`,
133    /// whatever the failure was, because a draw depends on the seed even when the
134    /// body threw something that is not an assertion. Follows `tier`; see the ADR
135    /// for appending optional `Case` fields.
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub kind: Option<String>,
138    /// The run's root seed, as the `0x…` text `--seed` accepts. With `--case`, it
139    /// reproduces the failed case's stream.
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub seed: Option<String>,
142    /// The shrunk counterexample as the runner rendered it (`p = 73`, or the
143    /// shrunk sequence of a history), when shrinking produced one.
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub counterexample: Option<String>,
146    /// #2272: the invariant or transition a violated case named, when a violation
147    /// failed it. Appended after `counterexample`; absent otherwise.
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub violation: Option<Violation>,
150    /// #2272: the steps of a failed history that an agent refused, each with the
151    /// declaration that refused it. A refusal is data on the step, not a failure of
152    /// its own, so `violation` does not say it. Appended after `violation`; absent
153    /// when no step was refused.
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub refusals: Option<Vec<Refusal>>,
156    /// #2461: where the case came from, `"doc"` for a doc-comment example marked
157    /// `bynk test`. Appended after `refusals` and absent for every other case, so a
158    /// document without doc examples keeps its layout; a reader treats a missing
159    /// `origin` as an ordinary `case`.
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub origin: Option<String>,
162    /// How long the case ran, in milliseconds, as its module measured it (#2267
163    /// stage 2). Never part of the JSON document, which stays deterministic; the
164    /// `ndjson` stream's `case` event carries it, last.
165    #[serde(skip)]
166    pub duration_ms: Option<f64>,
167}
168
169#[derive(Debug, PartialEq, Serialize)]
170pub struct Location {
171    pub path: String,
172    pub line: u32,
173    pub col: u32,
174}
175
176/// #2272: the declaration a violated invariant or transition names. `kind` is the
177/// declaration's own kind, `"invariant"` or `"transition"`, which the name alone
178/// cannot say (an invariant and a transition may share one).
179#[derive(Debug, PartialEq, Serialize)]
180pub struct Violation {
181    pub kind: String,
182    pub agent: String,
183    pub name: String,
184    pub location: Option<Location>,
185}
186
187/// #2272: a step of a driven history that an agent refused. `index` is the step's
188/// position in the shrunk sequence the case reports (from 0), `call` the handler it
189/// called, and the rest names the declaration that refused it, as [`Violation`] does.
190#[derive(Debug, PartialEq, Serialize)]
191pub struct Refusal {
192    pub index: u32,
193    pub call: String,
194    pub kind: String,
195    pub agent: String,
196    pub name: String,
197    pub location: Option<Location>,
198}
199
200#[derive(Debug, PartialEq, Serialize)]
201pub struct TestError {
202    pub kind: String,
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub message: Option<String>,
205    /// `bynkc` diagnostic lines (`path:line:col: severity[category]: message`),
206    /// for `kind == "compile"`. Empty (and omitted) otherwise.
207    #[serde(skip_serializing_if = "Vec::is_empty")]
208    pub diagnostics: Vec<String>,
209    /// Captured stderr from a crashed run, for `kind == "runtime"`.
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub stderr: Option<String>,
212}
213
214impl TestRun {
215    /// A normal run with no suites (no tests, or `--no-run`).
216    pub fn empty() -> Self {
217        TestRun {
218            passed: 0,
219            failed: 0,
220            skipped: 0,
221            suites: Some(Vec::new()),
222            error: None,
223            coverage: None,
224            transport: None,
225        }
226    }
227
228    /// Attach a coverage block (`--coverage`, issue #854). A report with no
229    /// attributed line is dropped rather than serialising an empty block, so a
230    /// coverage run over a project with nothing measurable reads like a normal
231    /// run.
232    pub fn with_coverage(mut self, report: &crate::coverage::CoverageReport) -> Self {
233        if !report.is_empty() {
234            self.coverage = Some(Coverage::from(report));
235        }
236        self
237    }
238
239    /// v0.67: a **discovery** document (`--no-run --format json`) — the suites and
240    /// cases the compile retained, listed without running. `passed`/`failed` are
241    /// 0 and each case carries `outcome: "discovered"` (see [`Case`]), so the
242    /// "every case has an outcome" invariant holds and no consumer mistakes a
243    /// listed case for a result. Reconciles against a later run document: same
244    /// suite `name`/`kind`, same case `name`s.
245    pub fn discovered(suites: Vec<Suite>) -> Self {
246        TestRun {
247            passed: 0,
248            failed: 0,
249            skipped: 0,
250            suites: Some(suites),
251            error: None,
252            coverage: None,
253            transport: None,
254        }
255    }
256
257    /// The document for a run that could not start or complete outside the
258    /// compile step (the runner couldn't be launched, `tsc` rejected the
259    /// emitted TS, or the runner died). No suites — use [`ParsedRun::into_document`]
260    /// when a partial suite prefix was observed.
261    pub fn runtime_error(message: impl Into<String>, stderr: Option<String>) -> Self {
262        TestRun {
263            passed: 0,
264            failed: 0,
265            skipped: 0,
266            suites: None,
267            error: Some(TestError {
268                kind: "runtime".to_string(),
269                message: Some(message.into()),
270                diagnostics: Vec::new(),
271                stderr: stderr.filter(|s| !s.trim().is_empty()),
272            }),
273            coverage: None,
274            transport: None,
275        }
276    }
277
278    /// The document for a project that never compiled.
279    pub fn compile_error(diagnostics: Vec<String>) -> Self {
280        TestRun {
281            passed: 0,
282            failed: 0,
283            skipped: 0,
284            suites: None,
285            error: Some(TestError {
286                kind: "compile".to_string(),
287                message: None,
288                diagnostics,
289                stderr: None,
290            }),
291            coverage: None,
292            transport: None,
293        }
294    }
295
296    /// Attach each case's tier from the compile's case table, matched by suite
297    /// and case name (the names the runner reports are the ones discovery lists).
298    /// A case the table does not hold keeps whatever tier it has.
299    pub fn with_tiers(mut self, discovered: &[bynk_emit::project::DiscoveredSuite]) -> Self {
300        for suite in self.suites.iter_mut().flatten() {
301            let Some(known) = discovered.iter().find(|d| d.name == suite.name) else {
302                continue;
303            };
304            for case in &mut suite.cases {
305                let known_case = known.cases.iter().find(|c| c.name == case.name);
306                if let Some(tier) = known_case.and_then(|c| c.tier) {
307                    case.tier = Some(tier.to_string());
308                }
309                if known_case.is_some_and(|c| c.doc) {
310                    case.origin = Some("doc".to_string());
311                }
312            }
313        }
314        self
315    }
316
317    /// #2138: fold a remote run into the document — its `transport`, and each
318    /// skipped case as an `outcome: "skipped"` case whose `message` is why,
319    /// in its suite (a new `integration` suite when none of that name ran).
320    pub fn with_remote(mut self, transport: String, skipped: &[(String, String, String)]) -> Self {
321        self.transport = Some(transport);
322        if skipped.is_empty() {
323            return self;
324        }
325        self.skipped = u32::try_from(skipped.len()).unwrap_or(u32::MAX);
326        let suites = self.suites.get_or_insert_with(Vec::new);
327        for (suite, case, reason) in skipped {
328            let case = Case {
329                name: case.clone(),
330                outcome: "skipped".to_string(),
331                message: Some(reason.clone()),
332                location: None,
333                retries: None,
334                // A remote run skips only `system` cases.
335                tier: Some("system".to_string()),
336                kind: None,
337                seed: None,
338                counterexample: None,
339                violation: None,
340                refusals: None,
341                origin: None,
342                duration_ms: None,
343            };
344            match suites.iter_mut().find(|s| &s.name == suite) {
345                Some(s) => s.cases.push(case),
346                None => suites.push(Suite {
347                    name: suite.clone(),
348                    kind: "integration".to_string(),
349                    cases: vec![case],
350                }),
351            }
352        }
353        self
354    }
355
356    /// Render to a pretty JSON string (trailing newline). Serde emits struct
357    /// fields in declaration order regardless of `preserve_order`.
358    pub fn render(&self) -> String {
359        let mut s = serde_json::to_string_pretty(self).expect("TestRun serialises");
360        s.push('\n');
361        s
362    }
363}
364
365/// The `--suite` and `--case` values a run selected nothing for (#2273), as
366/// `run-end` reports them: present only when a filter was given and a value
367/// went unmatched. A filter that selects nothing is an error, not a vacuous pass.
368#[derive(Debug, Default, Clone, PartialEq, Eq)]
369pub struct Unmatched {
370    pub suites: Vec<String>,
371    pub cases: Vec<String>,
372}
373
374impl Unmatched {
375    pub fn is_empty(&self) -> bool {
376        self.suites.is_empty() && self.cases.is_empty()
377    }
378
379    /// One line per unmatched value, without the `<program> test:` prefix.
380    pub fn messages(&self) -> Vec<String> {
381        let suites = self
382            .suites
383            .iter()
384            .map(|s| format!("no suite named --suite {s:?}"));
385        let cases = self
386            .cases
387            .iter()
388            .map(|c| format!("no case matched --case {c:?}"));
389        suites.chain(cases).collect()
390    }
391
392    /// Print the error for each unmatched value to stderr, after the run's
393    /// usual output, so a consumer still reads the document.
394    pub fn report(&self, program: &str) {
395        for message in self.messages() {
396            eprintln!("{program} test: {message}");
397        }
398    }
399}
400
401/// The outcome of parsing the runner's NDJSON stream: the suites observed, the
402/// running tallies, and whether a `run-end` event was seen (a missing `run-end`
403/// means the runner died mid-stream — a crashed/incomplete run).
404#[derive(Debug, Default, PartialEq)]
405pub struct ParsedRun {
406    pub passed: u32,
407    pub failed: u32,
408    pub suites: Vec<Suite>,
409    pub complete: bool,
410    /// What a `--suite`/`--case` filter selected nothing for (#2273).
411    pub unmatched: Unmatched,
412}
413
414impl ParsedRun {
415    /// Fold this parsed stream into the final document. `node_ok` is whether the
416    /// runner process exited zero; `stderr` is its captured stderr (used only
417    /// for a crashed run). A stream with no `run-end` — or a non-zero exit with
418    /// no completion — becomes a `runtime` error carrying the observed prefix.
419    pub fn into_document(self, stderr: &str) -> TestRun {
420        if self.complete {
421            TestRun {
422                passed: self.passed,
423                failed: self.failed,
424                skipped: 0,
425                suites: Some(self.suites),
426                error: None,
427                coverage: None,
428                transport: None,
429            }
430        } else {
431            let trimmed = stderr.trim();
432            TestRun {
433                passed: self.passed,
434                failed: self.failed,
435                skipped: 0,
436                suites: Some(self.suites),
437                error: Some(TestError {
438                    kind: "runtime".to_string(),
439                    message: Some("the test runner exited before completing".to_string()),
440                    diagnostics: Vec::new(),
441                    stderr: (!trimmed.is_empty()).then(|| trimmed.to_string()),
442                }),
443                coverage: None,
444                transport: None,
445            }
446        }
447    }
448}
449
450/// Parse the runner's NDJSON stdout into a [`ParsedRun`]. Unparseable or
451/// unrecognised lines are skipped (the stream is an internal protocol; a
452/// stray line should never abort the whole report).
453pub fn parse_ndjson(stdout: &str) -> ParsedRun {
454    let mut run = ParsedRun::default();
455    for line in stdout.lines() {
456        let line = line.trim();
457        if line.is_empty() {
458            continue;
459        }
460        let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
461            continue;
462        };
463        match value.get("type").and_then(|t| t.as_str()) {
464            Some("suite-begin") => {
465                run.suites.push(Suite {
466                    name: str_field(&value, "name"),
467                    kind: str_field(&value, "kind"),
468                    cases: Vec::new(),
469                });
470            }
471            Some("case") => {
472                let case = case_from_event(&value);
473                if case.outcome == "pass" {
474                    run.passed += 1;
475                } else {
476                    run.failed += 1;
477                }
478                if let Some(suite) = run.suites.last_mut() {
479                    suite.cases.push(case);
480                }
481            }
482            Some("run-end") => {
483                run.complete = true;
484                run.unmatched = unmatched_from_event(&value);
485            }
486            _ => {}
487        }
488    }
489    run
490}
491
492/// The [`Case`] a runner `case` event describes, without its tier (which comes
493/// from the compile's case table). Shared by the document parser and the stream.
494pub(crate) fn case_from_event(value: &serde_json::Value) -> Case {
495    let message = value
496        .get("message")
497        .and_then(|m| m.as_str())
498        .map(str::to_string);
499    let location = value
500        .get("location")
501        .and_then(|l| l.as_str())
502        .and_then(parse_location);
503    let retries = value
504        .get("retries")
505        .and_then(serde_json::Value::as_u64)
506        .filter(|n| *n > 0)
507        .map(|n| u32::try_from(n).unwrap_or(u32::MAX));
508    let violation = value.get("violation").map(violation_from_json);
509    let refusals = refusals_from_json(value.get("refusals"));
510    Case {
511        name: str_field(value, "name"),
512        outcome: str_field(value, "outcome"),
513        message,
514        location,
515        retries,
516        tier: None,
517        kind: str_opt(value, "kind"),
518        seed: str_opt(value, "seed"),
519        counterexample: str_opt(value, "counterexample"),
520        violation,
521        refusals,
522        origin: None,
523        duration_ms: value.get("durationMs").and_then(serde_json::Value::as_f64),
524    }
525}
526
527/// A location as the ndjson event carries it (`path:line:col`) or the JSON document
528/// does (the driver serialises it from [`Location`], as an object).
529fn location_from_json(value: &serde_json::Value) -> Option<Location> {
530    value.get("location").and_then(|l| match l {
531        serde_json::Value::String(s) => parse_location(s),
532        serde_json::Value::Object(_) => Some(Location {
533            path: str_field(l, "path"),
534            line: l
535                .get("line")
536                .and_then(serde_json::Value::as_u64)
537                .unwrap_or(0) as u32,
538            col: l
539                .get("col")
540                .and_then(serde_json::Value::as_u64)
541                .unwrap_or(0) as u32,
542        }),
543        _ => None,
544    })
545}
546
547/// #2272: a runner `violation` object, as the case event and the document carry it.
548pub(crate) fn violation_from_json(value: &serde_json::Value) -> Violation {
549    Violation {
550        kind: str_field(value, "kind"),
551        agent: str_field(value, "agent"),
552        name: str_field(value, "name"),
553        location: location_from_json(value),
554    }
555}
556
557/// #2272: a runner `refusals` array, as the case event and the document carry it. A
558/// malformed entry is dropped, and an array left empty is absent.
559pub(crate) fn refusals_from_json(value: Option<&serde_json::Value>) -> Option<Vec<Refusal>> {
560    let refusals: Vec<Refusal> = value?
561        .as_array()?
562        .iter()
563        .filter_map(|r| {
564            Some(Refusal {
565                index: u32::try_from(r.get("index")?.as_u64()?).ok()?,
566                call: r.get("call")?.as_str()?.to_string(),
567                kind: r.get("kind")?.as_str()?.to_string(),
568                agent: r.get("agent")?.as_str()?.to_string(),
569                name: r.get("name")?.as_str()?.to_string(),
570                location: location_from_json(r),
571            })
572        })
573        .collect();
574    (!refusals.is_empty()).then_some(refusals)
575}
576
577/// The values a runner `run-end` event reports as selecting nothing (#2273).
578pub(crate) fn unmatched_from_event(value: &serde_json::Value) -> Unmatched {
579    let Some(unmatched) = value.get("unmatched") else {
580        return Unmatched::default();
581    };
582    let strings = |key: &str| -> Vec<String> {
583        unmatched
584            .get(key)
585            .and_then(|v| v.as_array())
586            .map(|a| {
587                a.iter()
588                    .filter_map(|v| v.as_str())
589                    .map(str::to_string)
590                    .collect()
591            })
592            .unwrap_or_default()
593    };
594    Unmatched {
595        suites: strings("suites"),
596        cases: strings("cases"),
597    }
598}
599
600fn str_opt(value: &serde_json::Value, key: &str) -> Option<String> {
601    value.get(key).and_then(|v| v.as_str()).map(str::to_string)
602}
603
604fn str_field(value: &serde_json::Value, key: &str) -> String {
605    value
606        .get(key)
607        .and_then(|v| v.as_str())
608        .unwrap_or_default()
609        .to_string()
610}
611
612/// Split a `path:line:col` location string into structured fields. Returns
613/// `None` for anything that isn't that shape (e.g. the `"unknown"` fallback a
614/// non-assertion throw carries), so such a failure keeps its message but offers
615/// no click-through. Splits from the right, so a path containing `:` is safe.
616fn parse_location(s: &str) -> Option<Location> {
617    let (rest, col) = s.rsplit_once(':')?;
618    let (path, line) = rest.rsplit_once(':')?;
619    let line: u32 = line.parse().ok()?;
620    let col: u32 = col.parse().ok()?;
621    if path.is_empty() {
622        return None;
623    }
624    Some(Location {
625        path: path.to_string(),
626        line,
627        col,
628    })
629}
630
631#[cfg(test)]
632mod tests {
633    use super::*;
634
635    fn known(
636        suite: &str,
637        cases: &[(&str, Option<&'static str>)],
638    ) -> bynk_emit::project::DiscoveredSuite {
639        bynk_emit::project::DiscoveredSuite {
640            name: suite.to_string(),
641            kind: "unit",
642            cases: cases
643                .iter()
644                .map(|(name, tier)| bynk_emit::project::DiscoveredCase {
645                    name: (*name).to_string(),
646                    location: None,
647                    tier: *tier,
648                    generative: None,
649                    doc: false,
650                })
651                .collect(),
652            skipped: Vec::new(),
653            envs: Vec::new(),
654        }
655    }
656
657    /// A case's measured duration is read from the runner's event, and is not part of
658    /// the document: the JSON document stays deterministic (#2267 stage 2).
659    #[test]
660    fn a_cases_duration_is_read_but_never_written_to_the_document() {
661        let ndjson = concat!(
662            r#"{"type":"suite-begin","name":"s","kind":"unit","tests":1}"#,
663            "\n",
664            r#"{"type":"case","suite":"s","name":"c","outcome":"pass","durationMs":1.5}"#,
665            "\n",
666            r#"{"type":"suite-end","name":"s"}"#,
667            "\n",
668            r#"{"type":"run-end","passed":1,"failed":0}"#,
669            "\n",
670        );
671        let parsed = parse_ndjson(ndjson);
672        assert_eq!(parsed.suites[0].cases[0].duration_ms, Some(1.5));
673        let rendered = parsed.into_document("").render();
674        assert!(!rendered.contains("durationMs"), "{rendered}");
675    }
676
677    /// `run-end` names the `--suite`/`--case` values that selected nothing, when
678    /// the runner reports any (#2273); without the field there are none.
679    #[test]
680    fn run_end_reports_the_values_a_filter_selected_nothing_for() {
681        let ended = parse_ndjson(concat!(
682            r#"{"type":"run-begin","suites":0}"#,
683            "\n",
684            r#"{"type":"run-end","passed":0,"failed":0,"unmatched":{"suites":["nope"],"cases":["typo","other"]}}"#,
685            "\n",
686        ));
687        assert!(ended.complete);
688        assert_eq!(ended.unmatched.suites, vec!["nope".to_string()]);
689        assert_eq!(
690            ended.unmatched.cases,
691            vec!["typo".to_string(), "other".to_string()]
692        );
693        assert_eq!(
694            ended.unmatched.messages(),
695            vec![
696                "no suite named --suite \"nope\"".to_string(),
697                "no case matched --case \"typo\"".to_string(),
698                "no case matched --case \"other\"".to_string(),
699            ]
700        );
701
702        let plain = parse_ndjson(r#"{"type":"run-end","passed":1,"failed":0}"#);
703        assert!(plain.unmatched.is_empty());
704    }
705
706    /// A run document built from the runner's NDJSON takes each case's tier from
707    /// the compile's case table, by suite and case name: the runner's names are
708    /// discovery's, and a case the table does not hold (or holds without a tier)
709    /// keeps none.
710    #[test]
711    fn a_run_takes_each_cases_tier_from_the_case_table() {
712        let ndjson = concat!(
713            r#"{"type":"suite-begin","name":"shop","kind":"unit"}"#,
714            "\n",
715            r#"{"type":"case","name":"wired","outcome":"pass"}"#,
716            "\n",
717            r#"{"type":"case","name":"plain","outcome":"fail","message":"no"}"#,
718            "\n",
719            r#"{"type":"case","name":"a property","outcome":"pass"}"#,
720            "\n",
721            r#"{"type":"case","name":"unlisted","outcome":"pass"}"#,
722            "\n",
723            r#"{"type":"suite-begin","name":"other","kind":"unit"}"#,
724            "\n",
725            r#"{"type":"case","name":"wired","outcome":"pass"}"#,
726            "\n",
727            r#"{"type":"run-end"}"#,
728            "\n",
729        );
730        let table = [
731            known(
732                "shop",
733                &[
734                    ("wired", Some("system")),
735                    ("plain", Some("unit")),
736                    ("a property", None),
737                ],
738            ),
739            known("other", &[("wired", Some("integration"))]),
740        ];
741        let doc = parse_ndjson(ndjson).into_document("").with_tiers(&table);
742        let suites = doc.suites.unwrap();
743        let tiers = |i: usize| -> Vec<Option<String>> {
744            suites[i].cases.iter().map(|c| c.tier.clone()).collect()
745        };
746        assert_eq!(
747            tiers(0),
748            [Some("system".into()), Some("unit".into()), None, None]
749        );
750        assert_eq!(
751            tiers(1),
752            [Some("integration".into())],
753            "the same name in another suite"
754        );
755    }
756
757    /// #2104: a file's coverage carries both line lists, `covered_lines` last,
758    /// so the block's existing field order (the contract) is unchanged.
759    #[test]
760    fn file_coverage_carries_covered_and_uncovered_lines() {
761        let report = crate::coverage::CoverageReport {
762            files: vec![crate::coverage::FileCoverage {
763                path: "src/demo/m.bynk".into(),
764                covered: 2,
765                total: 5,
766                uncovered: vec![7, 8, 9],
767                covered_lines: vec![3, 4],
768            }],
769        };
770        assert_eq!(
771            serde_json::to_string(&Coverage::from(&report)).unwrap(),
772            r#"{"covered":2,"lines":5,"percent":40,"files":[{"path":"src/demo/m.bynk","covered":2,"lines":5,"percent":40,"uncovered":[7,8,9],"covered_lines":[3,4]}]}"#
773        );
774    }
775
776    #[test]
777    fn refusals_read_in_either_shape_and_drop_what_is_malformed() {
778        let refusal = |location: &str| {
779            format!(
780                r#"{{"index":1,"call":"Spend","kind":"transition","agent":"Wallet","name":"never_decreases","location":{location}}}"#
781            )
782        };
783        let as_event = refusals_from_json(
784            serde_json::from_str(&format!(
785                r#"[{}]"#,
786                refusal(r#""src/demo/wallet.bynk:18:14""#)
787            ))
788            .ok()
789            .as_ref(),
790        )
791        .unwrap();
792        let as_document = refusals_from_json(
793            serde_json::from_str(&format!(
794                r#"[{}]"#,
795                refusal(r#"{"path":"src/demo/wallet.bynk","line":18,"col":14}"#)
796            ))
797            .ok()
798            .as_ref(),
799        )
800        .unwrap();
801        assert_eq!(as_event, as_document);
802        assert_eq!(as_event[0].index, 1);
803        assert_eq!(as_event[0].kind, "transition");
804        assert_eq!(as_event[0].location.as_ref().unwrap().line, 18);
805
806        // A refusal missing a field is dropped, and an array left empty is absent.
807        let malformed: serde_json::Value =
808            serde_json::from_str(r#"[{"index":0,"call":"Spend","kind":"invariant"}]"#).unwrap();
809        assert_eq!(refusals_from_json(Some(&malformed)), None);
810        assert_eq!(refusals_from_json(Some(&serde_json::json!([]))), None);
811        assert_eq!(refusals_from_json(Some(&serde_json::json!("x"))), None);
812        assert_eq!(refusals_from_json(None), None);
813    }
814
815    #[test]
816    fn a_violation_reads_its_location_in_either_shape() {
817        // The ndjson event carries `path:line:col`; the JSON document, which `merge`
818        // reads back, carries the `Location` object. Both name the same place (#2272).
819        let as_event = serde_json::json!({
820            "kind": "transition", "agent": "Meter", "name": "same",
821            "location": "src/demo/m.bynk:28:14"
822        });
823        let as_document = serde_json::json!({
824            "kind": "transition", "agent": "Meter", "name": "same",
825            "location": { "path": "src/demo/m.bynk", "line": 28, "col": 14 }
826        });
827        let expected = Some(Location {
828            path: "src/demo/m.bynk".to_string(),
829            line: 28,
830            col: 14,
831        });
832        assert_eq!(violation_from_json(&as_event).location, expected);
833        assert_eq!(violation_from_json(&as_document).location, expected);
834    }
835
836    #[test]
837    fn a_doc_example_case_is_marked_and_no_other_is() {
838        let table = vec![bynk_emit::project::DiscoveredSuite {
839            name: "demo.m".to_string(),
840            kind: "unit",
841            cases: vec![
842                bynk_emit::project::DiscoveredCase {
843                    name: "doc example m.bynk:4".to_string(),
844                    location: None,
845                    tier: Some("unit"),
846                    generative: None,
847                    doc: true,
848                },
849                bynk_emit::project::DiscoveredCase {
850                    name: "real".to_string(),
851                    location: None,
852                    tier: Some("unit"),
853                    generative: None,
854                    doc: false,
855                },
856            ],
857            skipped: Vec::new(),
858            envs: Vec::new(),
859        }];
860        let ndjson = concat!(
861            r#"{"type":"suite-begin","name":"demo.m","kind":"unit","tests":2}"#,
862            "\n",
863            r#"{"type":"case","suite":"demo.m","name":"doc example m.bynk:4","outcome":"pass"}"#,
864            "\n",
865            r#"{"type":"case","suite":"demo.m","name":"real","outcome":"pass"}"#,
866            "\n",
867            r#"{"type":"suite-end","name":"demo.m"}"#,
868            "\n",
869            r#"{"type":"run-end","passed":2,"failed":0}"#,
870            "\n",
871        );
872        let doc = parse_ndjson(ndjson).into_document("").with_tiers(&table);
873        let rendered = doc.render();
874        // Appended after the other optional fields, and only on the doc case.
875        assert_eq!(rendered.matches("\"origin\"").count(), 1, "{rendered}");
876        assert!(rendered.contains("\"origin\": \"doc\""), "{rendered}");
877        let suites = doc.suites.as_ref().unwrap();
878        assert_eq!(suites[0].cases[0].origin.as_deref(), Some("doc"));
879        assert_eq!(suites[0].cases[1].origin, None);
880    }
881}