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    /// How long the case ran, in milliseconds, as its module measured it (#2267
157    /// stage 2). Never part of the JSON document, which stays deterministic; the
158    /// `ndjson` stream's `case` event carries it, last.
159    #[serde(skip)]
160    pub duration_ms: Option<f64>,
161}
162
163#[derive(Debug, PartialEq, Serialize)]
164pub struct Location {
165    pub path: String,
166    pub line: u32,
167    pub col: u32,
168}
169
170/// #2272: the declaration a violated invariant or transition names. `kind` is the
171/// declaration's own kind, `"invariant"` or `"transition"`, which the name alone
172/// cannot say (an invariant and a transition may share one).
173#[derive(Debug, PartialEq, Serialize)]
174pub struct Violation {
175    pub kind: String,
176    pub agent: String,
177    pub name: String,
178    pub location: Option<Location>,
179}
180
181/// #2272: a step of a driven history that an agent refused. `index` is the step's
182/// position in the shrunk sequence the case reports (from 0), `call` the handler it
183/// called, and the rest names the declaration that refused it, as [`Violation`] does.
184#[derive(Debug, PartialEq, Serialize)]
185pub struct Refusal {
186    pub index: u32,
187    pub call: String,
188    pub kind: String,
189    pub agent: String,
190    pub name: String,
191    pub location: Option<Location>,
192}
193
194#[derive(Debug, PartialEq, Serialize)]
195pub struct TestError {
196    pub kind: String,
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub message: Option<String>,
199    /// `bynkc` diagnostic lines (`path:line:col: severity[category]: message`),
200    /// for `kind == "compile"`. Empty (and omitted) otherwise.
201    #[serde(skip_serializing_if = "Vec::is_empty")]
202    pub diagnostics: Vec<String>,
203    /// Captured stderr from a crashed run, for `kind == "runtime"`.
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub stderr: Option<String>,
206}
207
208impl TestRun {
209    /// A normal run with no suites (no tests, or `--no-run`).
210    pub fn empty() -> Self {
211        TestRun {
212            passed: 0,
213            failed: 0,
214            skipped: 0,
215            suites: Some(Vec::new()),
216            error: None,
217            coverage: None,
218            transport: None,
219        }
220    }
221
222    /// Attach a coverage block (`--coverage`, issue #854). A report with no
223    /// attributed line is dropped rather than serialising an empty block, so a
224    /// coverage run over a project with nothing measurable reads like a normal
225    /// run.
226    pub fn with_coverage(mut self, report: &crate::coverage::CoverageReport) -> Self {
227        if !report.is_empty() {
228            self.coverage = Some(Coverage::from(report));
229        }
230        self
231    }
232
233    /// v0.67: a **discovery** document (`--no-run --format json`) — the suites and
234    /// cases the compile retained, listed without running. `passed`/`failed` are
235    /// 0 and each case carries `outcome: "discovered"` (see [`Case`]), so the
236    /// "every case has an outcome" invariant holds and no consumer mistakes a
237    /// listed case for a result. Reconciles against a later run document: same
238    /// suite `name`/`kind`, same case `name`s.
239    pub fn discovered(suites: Vec<Suite>) -> Self {
240        TestRun {
241            passed: 0,
242            failed: 0,
243            skipped: 0,
244            suites: Some(suites),
245            error: None,
246            coverage: None,
247            transport: None,
248        }
249    }
250
251    /// The document for a run that could not start or complete outside the
252    /// compile step (the runner couldn't be launched, `tsc` rejected the
253    /// emitted TS, or the runner died). No suites — use [`ParsedRun::into_document`]
254    /// when a partial suite prefix was observed.
255    pub fn runtime_error(message: impl Into<String>, stderr: Option<String>) -> Self {
256        TestRun {
257            passed: 0,
258            failed: 0,
259            skipped: 0,
260            suites: None,
261            error: Some(TestError {
262                kind: "runtime".to_string(),
263                message: Some(message.into()),
264                diagnostics: Vec::new(),
265                stderr: stderr.filter(|s| !s.trim().is_empty()),
266            }),
267            coverage: None,
268            transport: None,
269        }
270    }
271
272    /// The document for a project that never compiled.
273    pub fn compile_error(diagnostics: Vec<String>) -> Self {
274        TestRun {
275            passed: 0,
276            failed: 0,
277            skipped: 0,
278            suites: None,
279            error: Some(TestError {
280                kind: "compile".to_string(),
281                message: None,
282                diagnostics,
283                stderr: None,
284            }),
285            coverage: None,
286            transport: None,
287        }
288    }
289
290    /// Attach each case's tier from the compile's case table, matched by suite
291    /// and case name (the names the runner reports are the ones discovery lists).
292    /// A case the table does not hold keeps whatever tier it has.
293    pub fn with_tiers(mut self, discovered: &[bynk_emit::project::DiscoveredSuite]) -> Self {
294        for suite in self.suites.iter_mut().flatten() {
295            let Some(known) = discovered.iter().find(|d| d.name == suite.name) else {
296                continue;
297            };
298            for case in &mut suite.cases {
299                if let Some(tier) = known
300                    .cases
301                    .iter()
302                    .find(|c| c.name == case.name)
303                    .and_then(|c| c.tier)
304                {
305                    case.tier = Some(tier.to_string());
306                }
307            }
308        }
309        self
310    }
311
312    /// #2138: fold a remote run into the document — its `transport`, and each
313    /// skipped case as an `outcome: "skipped"` case whose `message` is why,
314    /// in its suite (a new `integration` suite when none of that name ran).
315    pub fn with_remote(mut self, transport: String, skipped: &[(String, String, String)]) -> Self {
316        self.transport = Some(transport);
317        if skipped.is_empty() {
318            return self;
319        }
320        self.skipped = u32::try_from(skipped.len()).unwrap_or(u32::MAX);
321        let suites = self.suites.get_or_insert_with(Vec::new);
322        for (suite, case, reason) in skipped {
323            let case = Case {
324                name: case.clone(),
325                outcome: "skipped".to_string(),
326                message: Some(reason.clone()),
327                location: None,
328                retries: None,
329                // A remote run skips only `system` cases.
330                tier: Some("system".to_string()),
331                kind: None,
332                seed: None,
333                counterexample: None,
334                violation: None,
335                refusals: None,
336                duration_ms: None,
337            };
338            match suites.iter_mut().find(|s| &s.name == suite) {
339                Some(s) => s.cases.push(case),
340                None => suites.push(Suite {
341                    name: suite.clone(),
342                    kind: "integration".to_string(),
343                    cases: vec![case],
344                }),
345            }
346        }
347        self
348    }
349
350    /// Render to a pretty JSON string (trailing newline). Serde emits struct
351    /// fields in declaration order regardless of `preserve_order`.
352    pub fn render(&self) -> String {
353        let mut s = serde_json::to_string_pretty(self).expect("TestRun serialises");
354        s.push('\n');
355        s
356    }
357}
358
359/// The `--suite` and `--case` values a run selected nothing for (#2273), as
360/// `run-end` reports them: present only when a filter was given and a value
361/// went unmatched. A filter that selects nothing is an error, not a vacuous pass.
362#[derive(Debug, Default, Clone, PartialEq, Eq)]
363pub struct Unmatched {
364    pub suites: Vec<String>,
365    pub cases: Vec<String>,
366}
367
368impl Unmatched {
369    pub fn is_empty(&self) -> bool {
370        self.suites.is_empty() && self.cases.is_empty()
371    }
372
373    /// One line per unmatched value, without the `<program> test:` prefix.
374    pub fn messages(&self) -> Vec<String> {
375        let suites = self
376            .suites
377            .iter()
378            .map(|s| format!("no suite named --suite {s:?}"));
379        let cases = self
380            .cases
381            .iter()
382            .map(|c| format!("no case matched --case {c:?}"));
383        suites.chain(cases).collect()
384    }
385
386    /// Print the error for each unmatched value to stderr, after the run's
387    /// usual output, so a consumer still reads the document.
388    pub fn report(&self, program: &str) {
389        for message in self.messages() {
390            eprintln!("{program} test: {message}");
391        }
392    }
393}
394
395/// The outcome of parsing the runner's NDJSON stream: the suites observed, the
396/// running tallies, and whether a `run-end` event was seen (a missing `run-end`
397/// means the runner died mid-stream — a crashed/incomplete run).
398#[derive(Debug, Default, PartialEq)]
399pub struct ParsedRun {
400    pub passed: u32,
401    pub failed: u32,
402    pub suites: Vec<Suite>,
403    pub complete: bool,
404    /// What a `--suite`/`--case` filter selected nothing for (#2273).
405    pub unmatched: Unmatched,
406}
407
408impl ParsedRun {
409    /// Fold this parsed stream into the final document. `node_ok` is whether the
410    /// runner process exited zero; `stderr` is its captured stderr (used only
411    /// for a crashed run). A stream with no `run-end` — or a non-zero exit with
412    /// no completion — becomes a `runtime` error carrying the observed prefix.
413    pub fn into_document(self, stderr: &str) -> TestRun {
414        if self.complete {
415            TestRun {
416                passed: self.passed,
417                failed: self.failed,
418                skipped: 0,
419                suites: Some(self.suites),
420                error: None,
421                coverage: None,
422                transport: None,
423            }
424        } else {
425            let trimmed = stderr.trim();
426            TestRun {
427                passed: self.passed,
428                failed: self.failed,
429                skipped: 0,
430                suites: Some(self.suites),
431                error: Some(TestError {
432                    kind: "runtime".to_string(),
433                    message: Some("the test runner exited before completing".to_string()),
434                    diagnostics: Vec::new(),
435                    stderr: (!trimmed.is_empty()).then(|| trimmed.to_string()),
436                }),
437                coverage: None,
438                transport: None,
439            }
440        }
441    }
442}
443
444/// Parse the runner's NDJSON stdout into a [`ParsedRun`]. Unparseable or
445/// unrecognised lines are skipped (the stream is an internal protocol; a
446/// stray line should never abort the whole report).
447pub fn parse_ndjson(stdout: &str) -> ParsedRun {
448    let mut run = ParsedRun::default();
449    for line in stdout.lines() {
450        let line = line.trim();
451        if line.is_empty() {
452            continue;
453        }
454        let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
455            continue;
456        };
457        match value.get("type").and_then(|t| t.as_str()) {
458            Some("suite-begin") => {
459                run.suites.push(Suite {
460                    name: str_field(&value, "name"),
461                    kind: str_field(&value, "kind"),
462                    cases: Vec::new(),
463                });
464            }
465            Some("case") => {
466                let case = case_from_event(&value);
467                if case.outcome == "pass" {
468                    run.passed += 1;
469                } else {
470                    run.failed += 1;
471                }
472                if let Some(suite) = run.suites.last_mut() {
473                    suite.cases.push(case);
474                }
475            }
476            Some("run-end") => {
477                run.complete = true;
478                run.unmatched = unmatched_from_event(&value);
479            }
480            _ => {}
481        }
482    }
483    run
484}
485
486/// The [`Case`] a runner `case` event describes, without its tier (which comes
487/// from the compile's case table). Shared by the document parser and the stream.
488pub(crate) fn case_from_event(value: &serde_json::Value) -> Case {
489    let message = value
490        .get("message")
491        .and_then(|m| m.as_str())
492        .map(str::to_string);
493    let location = value
494        .get("location")
495        .and_then(|l| l.as_str())
496        .and_then(parse_location);
497    let retries = value
498        .get("retries")
499        .and_then(serde_json::Value::as_u64)
500        .filter(|n| *n > 0)
501        .map(|n| u32::try_from(n).unwrap_or(u32::MAX));
502    let violation = value.get("violation").map(violation_from_json);
503    let refusals = refusals_from_json(value.get("refusals"));
504    Case {
505        name: str_field(value, "name"),
506        outcome: str_field(value, "outcome"),
507        message,
508        location,
509        retries,
510        tier: None,
511        kind: str_opt(value, "kind"),
512        seed: str_opt(value, "seed"),
513        counterexample: str_opt(value, "counterexample"),
514        violation,
515        refusals,
516        duration_ms: value.get("durationMs").and_then(serde_json::Value::as_f64),
517    }
518}
519
520/// A location as the ndjson event carries it (`path:line:col`) or the JSON document
521/// does (the driver serialises it from [`Location`], as an object).
522fn location_from_json(value: &serde_json::Value) -> Option<Location> {
523    value.get("location").and_then(|l| match l {
524        serde_json::Value::String(s) => parse_location(s),
525        serde_json::Value::Object(_) => Some(Location {
526            path: str_field(l, "path"),
527            line: l
528                .get("line")
529                .and_then(serde_json::Value::as_u64)
530                .unwrap_or(0) as u32,
531            col: l
532                .get("col")
533                .and_then(serde_json::Value::as_u64)
534                .unwrap_or(0) as u32,
535        }),
536        _ => None,
537    })
538}
539
540/// #2272: a runner `violation` object, as the case event and the document carry it.
541pub(crate) fn violation_from_json(value: &serde_json::Value) -> Violation {
542    Violation {
543        kind: str_field(value, "kind"),
544        agent: str_field(value, "agent"),
545        name: str_field(value, "name"),
546        location: location_from_json(value),
547    }
548}
549
550/// #2272: a runner `refusals` array, as the case event and the document carry it. A
551/// malformed entry is dropped, and an array left empty is absent.
552pub(crate) fn refusals_from_json(value: Option<&serde_json::Value>) -> Option<Vec<Refusal>> {
553    let refusals: Vec<Refusal> = value?
554        .as_array()?
555        .iter()
556        .filter_map(|r| {
557            Some(Refusal {
558                index: u32::try_from(r.get("index")?.as_u64()?).ok()?,
559                call: r.get("call")?.as_str()?.to_string(),
560                kind: r.get("kind")?.as_str()?.to_string(),
561                agent: r.get("agent")?.as_str()?.to_string(),
562                name: r.get("name")?.as_str()?.to_string(),
563                location: location_from_json(r),
564            })
565        })
566        .collect();
567    (!refusals.is_empty()).then_some(refusals)
568}
569
570/// The values a runner `run-end` event reports as selecting nothing (#2273).
571pub(crate) fn unmatched_from_event(value: &serde_json::Value) -> Unmatched {
572    let Some(unmatched) = value.get("unmatched") else {
573        return Unmatched::default();
574    };
575    let strings = |key: &str| -> Vec<String> {
576        unmatched
577            .get(key)
578            .and_then(|v| v.as_array())
579            .map(|a| {
580                a.iter()
581                    .filter_map(|v| v.as_str())
582                    .map(str::to_string)
583                    .collect()
584            })
585            .unwrap_or_default()
586    };
587    Unmatched {
588        suites: strings("suites"),
589        cases: strings("cases"),
590    }
591}
592
593fn str_opt(value: &serde_json::Value, key: &str) -> Option<String> {
594    value.get(key).and_then(|v| v.as_str()).map(str::to_string)
595}
596
597fn str_field(value: &serde_json::Value, key: &str) -> String {
598    value
599        .get(key)
600        .and_then(|v| v.as_str())
601        .unwrap_or_default()
602        .to_string()
603}
604
605/// Split a `path:line:col` location string into structured fields. Returns
606/// `None` for anything that isn't that shape (e.g. the `"unknown"` fallback a
607/// non-assertion throw carries), so such a failure keeps its message but offers
608/// no click-through. Splits from the right, so a path containing `:` is safe.
609fn parse_location(s: &str) -> Option<Location> {
610    let (rest, col) = s.rsplit_once(':')?;
611    let (path, line) = rest.rsplit_once(':')?;
612    let line: u32 = line.parse().ok()?;
613    let col: u32 = col.parse().ok()?;
614    if path.is_empty() {
615        return None;
616    }
617    Some(Location {
618        path: path.to_string(),
619        line,
620        col,
621    })
622}
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627
628    fn known(
629        suite: &str,
630        cases: &[(&str, Option<&'static str>)],
631    ) -> bynk_emit::project::DiscoveredSuite {
632        bynk_emit::project::DiscoveredSuite {
633            name: suite.to_string(),
634            kind: "unit",
635            cases: cases
636                .iter()
637                .map(|(name, tier)| bynk_emit::project::DiscoveredCase {
638                    name: (*name).to_string(),
639                    location: None,
640                    tier: *tier,
641                    generative: None,
642                })
643                .collect(),
644            skipped: Vec::new(),
645            envs: Vec::new(),
646        }
647    }
648
649    /// A case's measured duration is read from the runner's event, and is not part of
650    /// the document: the JSON document stays deterministic (#2267 stage 2).
651    #[test]
652    fn a_cases_duration_is_read_but_never_written_to_the_document() {
653        let ndjson = concat!(
654            r#"{"type":"suite-begin","name":"s","kind":"unit","tests":1}"#,
655            "\n",
656            r#"{"type":"case","suite":"s","name":"c","outcome":"pass","durationMs":1.5}"#,
657            "\n",
658            r#"{"type":"suite-end","name":"s"}"#,
659            "\n",
660            r#"{"type":"run-end","passed":1,"failed":0}"#,
661            "\n",
662        );
663        let parsed = parse_ndjson(ndjson);
664        assert_eq!(parsed.suites[0].cases[0].duration_ms, Some(1.5));
665        let rendered = parsed.into_document("").render();
666        assert!(!rendered.contains("durationMs"), "{rendered}");
667    }
668
669    /// `run-end` names the `--suite`/`--case` values that selected nothing, when
670    /// the runner reports any (#2273); without the field there are none.
671    #[test]
672    fn run_end_reports_the_values_a_filter_selected_nothing_for() {
673        let ended = parse_ndjson(concat!(
674            r#"{"type":"run-begin","suites":0}"#,
675            "\n",
676            r#"{"type":"run-end","passed":0,"failed":0,"unmatched":{"suites":["nope"],"cases":["typo","other"]}}"#,
677            "\n",
678        ));
679        assert!(ended.complete);
680        assert_eq!(ended.unmatched.suites, vec!["nope".to_string()]);
681        assert_eq!(
682            ended.unmatched.cases,
683            vec!["typo".to_string(), "other".to_string()]
684        );
685        assert_eq!(
686            ended.unmatched.messages(),
687            vec![
688                "no suite named --suite \"nope\"".to_string(),
689                "no case matched --case \"typo\"".to_string(),
690                "no case matched --case \"other\"".to_string(),
691            ]
692        );
693
694        let plain = parse_ndjson(r#"{"type":"run-end","passed":1,"failed":0}"#);
695        assert!(plain.unmatched.is_empty());
696    }
697
698    /// A run document built from the runner's NDJSON takes each case's tier from
699    /// the compile's case table, by suite and case name: the runner's names are
700    /// discovery's, and a case the table does not hold (or holds without a tier)
701    /// keeps none.
702    #[test]
703    fn a_run_takes_each_cases_tier_from_the_case_table() {
704        let ndjson = concat!(
705            r#"{"type":"suite-begin","name":"shop","kind":"unit"}"#,
706            "\n",
707            r#"{"type":"case","name":"wired","outcome":"pass"}"#,
708            "\n",
709            r#"{"type":"case","name":"plain","outcome":"fail","message":"no"}"#,
710            "\n",
711            r#"{"type":"case","name":"a property","outcome":"pass"}"#,
712            "\n",
713            r#"{"type":"case","name":"unlisted","outcome":"pass"}"#,
714            "\n",
715            r#"{"type":"suite-begin","name":"other","kind":"unit"}"#,
716            "\n",
717            r#"{"type":"case","name":"wired","outcome":"pass"}"#,
718            "\n",
719            r#"{"type":"run-end"}"#,
720            "\n",
721        );
722        let table = [
723            known(
724                "shop",
725                &[
726                    ("wired", Some("system")),
727                    ("plain", Some("unit")),
728                    ("a property", None),
729                ],
730            ),
731            known("other", &[("wired", Some("integration"))]),
732        ];
733        let doc = parse_ndjson(ndjson).into_document("").with_tiers(&table);
734        let suites = doc.suites.unwrap();
735        let tiers = |i: usize| -> Vec<Option<String>> {
736            suites[i].cases.iter().map(|c| c.tier.clone()).collect()
737        };
738        assert_eq!(
739            tiers(0),
740            [Some("system".into()), Some("unit".into()), None, None]
741        );
742        assert_eq!(
743            tiers(1),
744            [Some("integration".into())],
745            "the same name in another suite"
746        );
747    }
748
749    /// #2104: a file's coverage carries both line lists, `covered_lines` last,
750    /// so the block's existing field order (the contract) is unchanged.
751    #[test]
752    fn file_coverage_carries_covered_and_uncovered_lines() {
753        let report = crate::coverage::CoverageReport {
754            files: vec![crate::coverage::FileCoverage {
755                path: "src/demo/m.bynk".into(),
756                covered: 2,
757                total: 5,
758                uncovered: vec![7, 8, 9],
759                covered_lines: vec![3, 4],
760            }],
761        };
762        assert_eq!(
763            serde_json::to_string(&Coverage::from(&report)).unwrap(),
764            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]}]}"#
765        );
766    }
767
768    #[test]
769    fn refusals_read_in_either_shape_and_drop_what_is_malformed() {
770        let refusal = |location: &str| {
771            format!(
772                r#"{{"index":1,"call":"Spend","kind":"transition","agent":"Wallet","name":"never_decreases","location":{location}}}"#
773            )
774        };
775        let as_event = refusals_from_json(
776            serde_json::from_str(&format!(
777                r#"[{}]"#,
778                refusal(r#""src/demo/wallet.bynk:18:14""#)
779            ))
780            .ok()
781            .as_ref(),
782        )
783        .unwrap();
784        let as_document = refusals_from_json(
785            serde_json::from_str(&format!(
786                r#"[{}]"#,
787                refusal(r#"{"path":"src/demo/wallet.bynk","line":18,"col":14}"#)
788            ))
789            .ok()
790            .as_ref(),
791        )
792        .unwrap();
793        assert_eq!(as_event, as_document);
794        assert_eq!(as_event[0].index, 1);
795        assert_eq!(as_event[0].kind, "transition");
796        assert_eq!(as_event[0].location.as_ref().unwrap().line, 18);
797
798        // A refusal missing a field is dropped, and an array left empty is absent.
799        let malformed: serde_json::Value =
800            serde_json::from_str(r#"[{"index":0,"call":"Spend","kind":"invariant"}]"#).unwrap();
801        assert_eq!(refusals_from_json(Some(&malformed)), None);
802        assert_eq!(refusals_from_json(Some(&serde_json::json!([]))), None);
803        assert_eq!(refusals_from_json(Some(&serde_json::json!("x"))), None);
804        assert_eq!(refusals_from_json(None), None);
805    }
806
807    #[test]
808    fn a_violation_reads_its_location_in_either_shape() {
809        // The ndjson event carries `path:line:col`; the JSON document, which `merge`
810        // reads back, carries the `Location` object. Both name the same place (#2272).
811        let as_event = serde_json::json!({
812            "kind": "transition", "agent": "Meter", "name": "same",
813            "location": "src/demo/m.bynk:28:14"
814        });
815        let as_document = serde_json::json!({
816            "kind": "transition", "agent": "Meter", "name": "same",
817            "location": { "path": "src/demo/m.bynk", "line": 28, "col": 14 }
818        });
819        let expected = Some(Location {
820            path: "src/demo/m.bynk".to_string(),
821            line: 28,
822            col: 14,
823        });
824        assert_eq!(violation_from_json(&as_event).location, expected);
825        assert_eq!(violation_from_json(&as_document).location, expected);
826    }
827}