delvewright-dsl 0.39.0

Staged JSON DSL types and schemas for Delvewright adventure-map campaigns — the format the delvec compiler reads.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
//! DSL v0.13 (spec-0049 §3): the layout graph and the geometry brief.
//!
//! # How these tests are kept falsifiable
//!
//! The trap this file is written against is the one the metrics round walked
//! into: a fixture the code computes agrees with the code by construction, so
//! perturbing the rule leaves every assertion green. Graph algorithms are where
//! that hides best — a reachability test whose fixture is generated by the
//! traversal it validates cannot disagree with it.
//!
//! So every fixture here is **hand-written JSON with a hand-written expected
//! answer**. [`GREEN`] is a four-place map somebody drew; the comment above it
//! states, by hand, which places are reachable and which connections close a
//! loop. Nothing in this file calls [`delvewright_dsl::Closure`] to decide what
//! it should assert.
//!
//! And every red is a **one-field perturbation of that same green document**,
//! so each assertion below is half of a red→green pair rather than a document
//! written to fail. If a check stopped working, its perturbation would go green
//! and the test would say so — which is the property a fixture built by the code
//! under test cannot have.

mod common;

use delvewright_dsl::{RawCampaign, check_campaign};
use serde_json::{Value, json};
use std::sync::LazyLock;

/// The green map, drawn by hand:
///
/// ```text
///                   ┌──── vision ─────────────────┐
///   porch ── walk ── hall ══ barred (quest) ══ vault
///     │                │                        │
///     └── drop ──▶ cellar ┘ (stair)             │
///     ◀───────────── walk, one way, shortcut ───┘
/// ```
///
/// Stated by hand, so the assertions below are not the closure agreeing with
/// itself:
///
/// * **every place is reachable.** `porch` is the entry; `hall` over a plain
///   walk; `cellar` over the stair from `hall`; `vault` once the quest completes,
///   which it does once `porch` and `hall` are both stood in, because that is
///   where both of its beats are.
/// * **`edge/vault-shortcut` closes a loop.** Take it away and `vault` is still
///   reached through the barred door, so the mark is earned.
/// * **neither one-way connection strands.** The drop lands in `cellar`, whose
///   stair climbs back to `hall`; the shortcut lands in `porch`. Both are on the
///   critical path or reach it.
/// * **the critical path is `porch → hall → vault`,** two steps, and it visits
///   both beat-bound places.
static GREEN: LazyLock<String> = LazyLock::new(|| {
    common::at_dsl_version(
        r#"{
  "campaign_id": "hello-world",
  "dsl_version": "%dsl_version%",
  "stage": "layout-graph",
  "content": {
    "nodes": [
      { "id": "node/porch", "intent": "threshold" },
      { "id": "node/hall", "intent": "hub", "note": "where the keeper stands" },
      { "id": "node/cellar", "intent": "cache" },
      { "id": "node/vault", "intent": "goal-chamber" }
    ],
    "edges": [
      { "id": "edge/porch-hall", "class": "walk", "a": "node/porch", "b": "node/hall" },
      { "id": "edge/hall-vault", "class": "barred", "a": "node/hall", "b": "node/vault",
        "opens_from": "a", "gating": { "quest": "quest/open-the-door" } },
      { "id": "edge/cellar-stair", "class": "stair", "a": "node/hall", "b": "node/cellar" },
      { "id": "edge/cellar-drop", "class": "drop", "a": "node/porch", "b": "node/cellar",
        "falls": "a-to-b" },
      { "id": "edge/vault-shortcut", "class": "walk", "a": "node/vault", "b": "node/porch",
        "one_way": "a-to-b", "shortcut": true },
      { "id": "edge/vault-sightline", "class": "vision", "a": "node/porch", "b": "node/vault" }
    ],
    "entry": "node/porch",
    "goal": "node/vault",
    "critical_path": ["node/porch", "node/hall", "node/vault"],
    "beats": [
      { "quest": "quest/open-the-door", "objective": "obj/talk", "node": "node/porch" },
      { "quest": "quest/open-the-door", "objective": "obj/exit", "node": "node/hall" }
    ]
  }
}"#,
    )
});

static BRIEF: LazyLock<String> = LazyLock::new(|| {
    common::at_dsl_version(
        r#"{
  "campaign_id": "hello-world",
  "dsl_version": "%dsl_version%",
  "stage": "geometry-brief",
  "content": {
    "facts": [
      { "id": "fact/hall-span", "value": 16.0, "unit": "blocks", "note": "The hall is sixteen across." }
    ]
  }
}"#,
    )
});

/// The green graph with one field changed. The patch is the whole test: what it
/// touches is what the assertion is about.
fn graph_with(patch: impl Fn(&mut Value)) -> String {
    let mut v: Value = serde_json::from_str(GREEN.as_str()).expect("the green graph parses");
    patch(&mut v);
    serde_json::to_string(&v).expect("re-serialize")
}

fn campaign(graph: Option<String>, brief: Option<String>) -> RawCampaign {
    RawCampaign {
        layout_graph: graph,
        site_plan: None,
        detail_plan: None,
        design: None,
        geometry_brief: brief,
        ..common::valid_raw()
    }
}

fn codes_of(raw: &RawCampaign) -> Vec<String> {
    check_campaign(raw).into_iter().map(|d| d.code).collect()
}

fn validate(graph: Option<String>) -> Vec<String> {
    codes_of(&campaign(graph, Some(BRIEF.to_string())))
}

/// The reachability battery alone, over the same documents. Reached through
/// [`delvewright_dsl::layout::reachability`], so that a test can isolate the
/// closure's own verdict from the rest of the validation battery `validate`
/// above runs. Both are the same tier now: `layout::check` is the only caller
/// of `reachability`, and the compiler side of that binding is
/// `crates/delvec/tests/layout_graph.rs`.
fn reachability(graph: Option<String>) -> Vec<String> {
    let raw = campaign(graph, Some(BRIEF.to_string()));
    let c = delvewright_dsl::parse_campaign(&raw).expect("the fixture parses");
    delvewright_dsl::layout::reachability(&c)
        .into_iter()
        .map(|d| d.code)
        .collect()
}

fn nodes(v: &mut Value) -> &mut Vec<Value> {
    v["content"]["nodes"].as_array_mut().expect("nodes")
}

fn edges(v: &mut Value) -> &mut Vec<Value> {
    v["content"]["edges"].as_array_mut().expect("edges")
}

// ---------------------------------------------------------------------------
// The green baseline, and the binding it reports
// ---------------------------------------------------------------------------

/// The map above is accepted, and the only lines it draws are the two advisories
/// this version always draws: the pacing projection, which refuses nothing, and
/// the notice that it rests on uncalibrated standards.
#[test]
fn the_hand_drawn_map_is_accepted() {
    let got = validate(Some(GREEN.to_string()));
    assert_eq!(
        got,
        vec!["DW0822".to_string(), "DW0813".to_string()],
        "the green map should raise nothing but the two advisories"
    );
    assert!(reachability(Some(GREEN.to_string())).is_empty());
}

/// A campaign that carries neither document is untouched — no check runs, and
/// the binding says so.
#[test]
fn a_campaign_with_no_map_documents_binds_nothing() {
    assert!(codes_of(&campaign(None, None)).is_empty());
    let raw = campaign(None, None);
    let c = delvewright_dsl::parse_campaign(&raw).expect("parses");
    let b = delvewright_dsl::LayoutBinding::of(&c);
    assert_eq!(b, delvewright_dsl::LayoutBinding::default());
    assert!(delvewright_dsl::layout::reachability(&c).is_empty());
}

/// The binding count, stated by hand from the document above and compared
/// against what the ledger computes. Written out rather than derived, because a
/// count the code produced and the code checked would agree however wrong it
/// was.
#[test]
fn the_binding_ledger_counts_what_the_document_holds() {
    let raw = campaign(Some(GREEN.to_string()), Some(BRIEF.to_string()));
    let c = delvewright_dsl::parse_campaign(&raw).expect("parses");
    let b = delvewright_dsl::LayoutBinding::of(&c);
    assert_eq!(b.nodes, 4);
    assert_eq!(b.edges, 6);
    assert_eq!(b.traversal_edges, 5, "five of the six carry a body");
    assert_eq!(b.one_way_edges, 2, "the drop and the shortcut");
    assert_eq!(b.shortcut_edges, 1);
    assert_eq!(b.gated_edges, 1, "only the barred door demands anything");
    assert_eq!(b.beats, 2);
    assert_eq!(b.spine_beats, 2, "the one quest is the finale");
    assert_eq!(b.path_steps, 2);
    assert_eq!(b.brief_facts, 1);
    assert!(b.line().contains("4 node(s)"));
    assert!(b.line().contains("1 fact(s)"));
}

// ---------------------------------------------------------------------------
// The document's shape
// ---------------------------------------------------------------------------

// ---------------------------------------------------------------------------
// DW0814 — the graph is not a graph
// ---------------------------------------------------------------------------

#[test]
fn dw0814_a_duplicate_node_id() {
    let g = graph_with(|v| {
        let first = nodes(v)[0].clone();
        nodes(v).push(first);
    });
    assert!(validate(Some(g)).contains(&"DW0814".to_string()));
}

#[test]
fn dw0814_a_duplicate_edge_id() {
    let g = graph_with(|v| {
        let first = edges(v)[0].clone();
        edges(v).push(first);
    });
    assert!(validate(Some(g)).contains(&"DW0814".to_string()));
}

#[test]
fn dw0814_an_edge_end_naming_no_place() {
    let g = graph_with(|v| edges(v)[0]["b"] = json!("node/nowhere"));
    assert!(validate(Some(g)).contains(&"DW0814".to_string()));
}

/// For every class, including the ones where a self-loop looks harmless.
#[test]
fn dw0814_a_self_loop_at_every_class() {
    for i in 0..6 {
        let g = graph_with(|v| {
            let a = edges(v)[i]["a"].clone();
            edges(v)[i]["b"] = a;
        });
        let got = validate(Some(g));
        assert!(
            got.contains(&"DW0814".to_string()),
            "edge {i} looped on itself and was accepted: {got:?}"
        );
    }
}

#[test]
fn dw0814_an_entry_or_goal_that_is_not_a_place() {
    let g = graph_with(|v| v["content"]["entry"] = json!("node/nowhere"));
    assert!(validate(Some(g)).contains(&"DW0814".to_string()));
    let g = graph_with(|v| v["content"]["goal"] = json!("node/nowhere"));
    assert!(validate(Some(g)).contains(&"DW0814".to_string()));
}

#[test]
fn dw0814_a_critical_path_step_or_beat_naming_no_place() {
    let g = graph_with(|v| v["content"]["critical_path"][1] = json!("node/nowhere"));
    assert!(validate(Some(g)).contains(&"DW0814".to_string()));
    let g = graph_with(|v| v["content"]["beats"][0]["node"] = json!("node/nowhere"));
    assert!(validate(Some(g)).contains(&"DW0814".to_string()));
}

#[test]
fn dw0814_a_place_with_no_intent() {
    let g = graph_with(|v| nodes(v)[0]["intent"] = json!("   "));
    assert!(validate(Some(g)).contains(&"DW0814".to_string()));
}

/// Ids are ordinary ids, so a malformed one is the ordinary `DW0110` rather than
/// a rule of this stage's own.
#[test]
fn dw0110_malformed_ids_are_the_ordinary_refusal() {
    let g = graph_with(|v| nodes(v)[0]["id"] = json!("porch"));
    assert!(validate(Some(g)).contains(&"DW0110".to_string()));
    let g = graph_with(|v| edges(v)[0]["id"] = json!("Edge/Porch"));
    assert!(validate(Some(g)).contains(&"DW0110".to_string()));

    let bad_brief = BRIEF.replace("fact/hall-span", "hall-span");
    let got = codes_of(&campaign(Some(GREEN.to_string()), Some(bad_brief)));
    assert!(got.contains(&"DW0110".to_string()));
}

#[test]
fn dw0111_two_brief_facts_of_one_name() {
    let mut v: Value = serde_json::from_str(BRIEF.as_str()).expect("parses");
    let first = v["content"]["facts"][0].clone();
    v["content"]["facts"]
        .as_array_mut()
        .expect("facts")
        .push(first);
    let got = codes_of(&campaign(
        Some(GREEN.to_string()),
        Some(serde_json::to_string(&v).expect("re-serialize")),
    ));
    assert!(got.contains(&"DW0111".to_string()));
}

/// A field the edge's class does not read is refused by the schema itself, with
/// no rule of this stage's own: the class is the serde tag, so `opens_from` on a
/// walk is an unknown field.
#[test]
fn dw0100_a_field_the_class_does_not_read() {
    let g = graph_with(|v| edges(v)[0]["opens_from"] = json!("a"));
    assert!(validate(Some(g)).contains(&"DW0100".to_string()));
    let g = graph_with(|v| edges(v)[0]["falls"] = json!("a-to-b"));
    assert!(validate(Some(g)).contains(&"DW0100".to_string()));
    // And the other way: a drop must say which way it falls.
    let g = graph_with(|v| {
        edges(v)[3].as_object_mut().expect("object").remove("falls");
    });
    assert!(validate(Some(g)).contains(&"DW0100".to_string()));
}

/// The provisional notice has a document-side binding: the graph's checks read
/// the pacing coefficient through one run-scoped ledger, so `DW0813` names
/// exactly that.
#[test]
fn dw0813_names_the_standards_this_graph_rested_on() {
    let raw = campaign(Some(GREEN.to_string()), Some(BRIEF.to_string()));
    let notice = check_campaign(&raw)
        .into_iter()
        .find(|d| d.code == "DW0813")
        .expect("the graph reads building metrics, so the notice is owed");
    assert!(
        notice.message.contains("pacing.route-blocks-per-minute"),
        "the coefficient is named: {}",
        notice.message
    );
    // Falsifiable in the direction that matters: no other standard was read.
    assert!(!notice.message.contains("opening."), "{}", notice.message);
}

// ---------------------------------------------------------------------------
// DW0818 — the graph and the mission agree, in both directions
// ---------------------------------------------------------------------------

#[test]
fn dw0818_a_beat_naming_a_quest_or_objective_that_does_not_exist() {
    let g = graph_with(|v| v["content"]["beats"][0]["quest"] = json!("quest/nowhere"));
    assert!(validate(Some(g)).contains(&"DW0818".to_string()));
    let g = graph_with(|v| v["content"]["beats"][0]["objective"] = json!("obj/nowhere"));
    assert!(validate(Some(g)).contains(&"DW0818".to_string()));
}

/// A graph whose beats name quests **stage 5 has not been written yet** is not
/// a graph mistake. It is the ordinary state of a campaign between the plan and
/// the quests, `DW0150` is the one diagnostic that names it, and every beat in
/// the graph says the same thing — so none of them is the finding. The message
/// says so and points at that code, instead of reporting a fault per beat in a
/// document the author finished two steps ago.
///
/// The second half is the discriminator: with stage 5 written, a beat naming a
/// quest that is not in it is a real mistake and must not be excused.
#[test]
fn dw0818_an_unwritten_stage_five_points_at_dw0150_instead_of_blaming_the_graph() {
    let raw = RawCampaign {
        quests: common::patch_doc(&common::read_valid("quests.json"), |v| {
            v["content"] = json!({ "quests": [] });
        }),
        ..campaign(Some(GREEN.to_string()), Some(BRIEF.to_string()))
    };
    let diags = check_campaign(&raw);
    // Two beats and one gated way borrow a quest name from a stage 5 that is
    // not written: all three carry the clause, because they are the same
    // borrowing and a clause on only the one that was noticed would be the
    // narrow binding.
    let borrowed: Vec<_> = diags
        .iter()
        .filter(|d| d.code == "DW0818" && d.path.ends_with("/quest"))
        .collect();
    assert_eq!(
        borrowed.len(),
        3,
        "two beats and one gated connection: {diags:#?}"
    );
    for d in &borrowed {
        assert!(d.message.contains("DW0150"), "{}", d.message);
        assert!(
            d.message.contains("Stage 5 declares no quests at all"),
            "{}",
            d.message
        );
    }

    let g = graph_with(|v| v["content"]["beats"][0]["quest"] = json!("quest/nowhere"));
    let d = check_campaign(&campaign(Some(g), Some(BRIEF.to_string())))
        .into_iter()
        .find(|d| d.code == "DW0818")
        .expect("a beat naming a quest stage 5 does not have is still DW0818");
    assert!(!d.message.contains("DW0150"), "{}", d.message);
}

#[test]
fn dw0818_gating_on_state_the_campaign_never_produces() {
    let g = graph_with(|v| edges(v)[1]["gating"] = json!({ "flags": ["flag/never-set"] }));
    assert!(validate(Some(g)).contains(&"DW0818".to_string()));
    let g = graph_with(|v| edges(v)[1]["gating"] = json!({ "quest": "quest/nowhere" }));
    assert!(validate(Some(g)).contains(&"DW0818".to_string()));
}

/// The stage-3 half of the opener obligation: a barred way must say what opens
/// it, and `DW0818` then holds that name to something the campaign produces.
#[test]
fn dw0818_a_barred_way_that_says_nothing_about_what_opens_it() {
    let g = graph_with(|v| edges(v)[1]["gating"] = json!({}));
    assert!(validate(Some(g)).contains(&"DW0818".to_string()));
}

/// **The ordering tooth**, and the direction that makes *graph before mission*
/// representable without being silently green: a graph may be written at any
/// time, but not in ignorance of a mission that exists.
#[test]
fn dw0818_the_reverse_direction_every_beat_has_a_place() {
    let g = graph_with(|v| {
        v["content"]["beats"]
            .as_array_mut()
            .expect("beats")
            .remove(1);
    });
    let got = validate(Some(g));
    assert!(got.contains(&"DW0818".to_string()), "{got:?}");

    // The whole mission dropped — the *graph before mission* document §7 calls
    // representable. It is representable, and it is refused the moment the
    // mission it ignores is there to be ignored: one refusal per unbound beat.
    let g = graph_with(|v| v["content"]["beats"] = json!([]));
    let got = validate(Some(g));
    assert_eq!(
        got.iter().filter(|c| *c == "DW0818").count(),
        2,
        "one refusal per objective the graph left placeless: {got:?}"
    );
}

#[test]
fn dw0818_a_beat_bound_to_two_places() {
    let g = graph_with(|v| {
        let mut second = v["content"]["beats"][0].clone();
        second["node"] = json!("node/cellar");
        v["content"]["beats"]
            .as_array_mut()
            .expect("beats")
            .push(second);
    });
    assert!(validate(Some(g)).contains(&"DW0818".to_string()));
}

// ---------------------------------------------------------------------------
// DW0820 — a shortcut closes a loop
// ---------------------------------------------------------------------------

/// Hand-stated, and the first draft of this test got it wrong in the direction
/// worth recording: marking `edge/cellar-stair` a shortcut in the green map is
/// **not** a fault, because `cellar` also has the drop into it, so the stair
/// really does lie on a loop. Cut the drop first and the stair becomes the only
/// way between `hall` and `cellar` — then the mark is marking a corridor.
#[test]
fn dw0820_a_shortcut_that_is_the_only_way_between_its_places() {
    let earned = graph_with(|v| edges(v)[2]["shortcut"] = json!(true));
    assert!(
        !validate(Some(earned)).contains(&"DW0820".to_string()),
        "the stair lies on a loop while the drop is there"
    );
    let g = graph_with(|v| {
        edges(v).remove(3); // the drop into `cellar`
        edges(v)[2]["shortcut"] = json!(true);
    });
    let got = validate(Some(g));
    assert!(got.contains(&"DW0820".to_string()), "{got:?}");
}

/// The other half of the pair: remove the long way round and the mark that WAS
/// earned stops being earned. Without this, the check could be reporting on the
/// mark rather than on the loop.
#[test]
fn dw0820_the_same_mark_reds_once_its_loop_is_taken_away() {
    assert!(!validate(Some(GREEN.to_string())).contains(&"DW0820".to_string()));
    let g = graph_with(|v| {
        // Delete the barred door: `vault` now hangs off its own shortcut alone.
        edges(v).remove(1);
    });
    let got = validate(Some(g));
    assert!(got.contains(&"DW0820".to_string()), "{got:?}");
}

/// Direction-blind on purpose: the shortcut runs one way and the loop it closes
/// is spatial, so a two-way long-way-round is not required to be walkable back.
#[test]
fn dw0820_reads_the_loop_spatially() {
    let g = graph_with(|v| edges(v)[1]["one_way"] = json!("a-to-b"));
    assert!(!validate(Some(g)).contains(&"DW0820".to_string()));
}

// ---------------------------------------------------------------------------
// DW0822 — the pacing projection, and its lack of a threshold
// ---------------------------------------------------------------------------

/// A graph with no site plan has no boxes, so every leg of `porch → hall →
/// vault` is unprojected and the projection says so rather than inventing a
/// number.
#[test]
fn dw0822_projects_the_route_and_refuses_nothing() {
    let raw = campaign(Some(GREEN.to_string()), Some(BRIEF.to_string()));
    let d = check_campaign(&raw)
        .into_iter()
        .find(|d| d.code == "DW0822")
        .expect("the projection is printed on every graph");
    assert_eq!(d.severity, delvewright_dsl::Severity::Warning);
    assert!(d.message.contains("0 place(s)"), "{}", d.message);
    assert!(d.message.contains("2 step(s)"));
    assert!(d.message.contains("3 leg(s) UNPROJECTED"), "{}", d.message);
    assert!(
        d.message
            .contains("carries no threshold and refuses nothing"),
        "{}",
        d.message
    );
    // One line, with something to read the figure AGAINST: the reasoning for why
    // there is no threshold lives in the `DW0822` catalog row, not in every
    // run's output.
    assert!(d.message.contains("`target_minutes`"), "{}", d.message);
    // It refuses nothing: the green document is green.
    assert!(
        !check_campaign(&raw)
            .iter()
            .any(|d| d.severity == delvewright_dsl::Severity::Error)
    );
}

// ---------------------------------------------------------------------------
// DW0816 / DW0817 / DW0819 — the analysis tier
// ---------------------------------------------------------------------------

/// Hand-stated: cut the stair and nothing else reaches `cellar`, because the
/// only other way in is the drop, which falls INTO it.
#[test]
fn dw0816_a_place_the_closure_never_reaches() {
    let g = graph_with(|v| {
        edges(v).remove(3); // the drop
        edges(v).remove(2); // the stair
    });
    let got = reachability(Some(g));
    assert_eq!(
        got,
        vec!["DW0816".to_string()],
        "exactly one place is cut off"
    );
}

/// The gating is what the closure has to honour. `vault` sits behind a quest
/// whose beats are at `porch` and `hall`; move one of those beats behind the
/// door and the quest can never complete, so the door never opens.
#[test]
fn dw0816_a_place_behind_state_nothing_reachable_grants() {
    let g = graph_with(|v| v["content"]["beats"][1]["node"] = json!("node/vault"));
    let got = reachability(Some(g));
    assert!(got.contains(&"DW0816".to_string()), "{got:?}");
}

#[test]
fn dw0817_a_path_that_does_not_start_where_a_body_starts() {
    let g = graph_with(|v| v["content"]["critical_path"] = json!(["node/hall", "node/vault"]));
    assert!(reachability(Some(g)).contains(&"DW0817".to_string()));
}

#[test]
fn dw0817_a_step_no_connection_makes() {
    let g = graph_with(|v| {
        v["content"]["critical_path"] =
            json!(["node/porch", "node/cellar", "node/hall", "node/vault"]);
    });
    // `porch → cellar` is a drop and IS legal; `cellar → hall` climbs the stair
    // and is legal too. What is not is the reverse of the drop:
    let g2 = graph_with(|v| {
        v["content"]["critical_path"] = json!(["node/porch", "node/vault"]);
    });
    assert!(!reachability(Some(g)).contains(&"DW0817".to_string()));
    assert!(reachability(Some(g2)).contains(&"DW0817".to_string()));
}

/// Quest-legal order, checked stepwise: the door opens on a quest whose second
/// beat is in `hall`, so a path that skips `hall` reaches the door too early.
#[test]
fn dw0817_a_step_through_a_gate_that_is_not_open_yet() {
    let g = graph_with(|v| {
        // Give the path a way to reach `vault` without visiting `hall`: the
        // shortcut, run the other way.
        edges(v)[4]["one_way"] = json!("b-to-a");
        v["content"]["critical_path"] = json!(["node/porch", "node/vault"]);
    });
    let got = reachability(Some(g));
    assert!(got.contains(&"DW0817".to_string()), "{got:?}");
}

/// A mandatory beat the path walks past. `cellar` is deliberately beat-free in
/// the green map, so moving a beat there is the smallest possible perturbation.
#[test]
fn dw0817_a_mandatory_beat_the_path_never_visits() {
    let g = graph_with(|v| v["content"]["beats"][1]["node"] = json!("node/cellar"));
    let got = reachability(Some(g));
    assert!(got.contains(&"DW0817".to_string()), "{got:?}");
}

/// Hand-stated: take the stair away and `cellar` is a place a body falls into
/// and cannot leave.
#[test]
fn dw0819_a_one_way_drop_into_a_place_with_no_way_out() {
    let g = graph_with(|v| {
        edges(v).remove(2); // the stair back up
    });
    let got = reachability(Some(g));
    assert!(got.contains(&"DW0819".to_string()), "{got:?}");
    // …and `DW0816` does NOT fire, because the drop still reaches it. The two
    // rules are about different things and this is where that is visible.
    assert!(!got.contains(&"DW0816".to_string()), "{got:?}");
}

/// The strand is judged under what a body can be holding when it arrives, not
/// under everything the campaign ever grants: seal the way out behind state the
/// falling body cannot have and the drop strands even though the graph as a
/// whole is connected.
#[test]
fn dw0819_reads_what_the_body_can_be_holding() {
    let g = graph_with(|v| {
        edges(v)[2]["gating"] = json!({ "quest": "quest/open-the-door" });
    });
    let got = reachability(Some(g));
    assert!(got.contains(&"DW0819".to_string()), "{got:?}");
}

/// spec-0083 §7: **the closure crosses a gated `carry` once its gating is
/// granted, and not before** — over a two-node graph drawn by hand: `jetty` is
/// the entry, `far-shore` is reached only by the carry, and the carry demands
/// `flag/boarded`. Granted at the jetty, the far shore is reached; granted
/// nowhere, it is not.
#[test]
fn dw0816_the_closure_crosses_a_gated_carry_once_granted_and_not_before() {
    use delvewright_dsl::layout::{Closure, Grant, Grants, LayoutGraphContent};
    let graph: LayoutGraphContent = serde_json::from_value(json!({
        "nodes": [
            { "id": "node/jetty", "intent": "jetty" },
            { "id": "node/far-shore", "intent": "landing" }
        ],
        "edges": [
            { "id": "edge/strait", "class": "carry", "a": "node/jetty", "b": "node/far-shore",
              "one_way": "a-to-b", "gating": { "flags": ["flag/boarded"] } }
        ],
        "entry": "node/jetty",
        "goal": "node/far-shore",
        "critical_path": ["node/jetty", "node/far-shore"]
    }))
    .expect("the two-node graph parses");
    let mut granted = Grants::default();
    granted.by_node.insert(
        "node/jetty".to_string(),
        [Grant::Flag("flag/boarded".to_string())]
            .into_iter()
            .collect(),
    );
    let c = Closure::run(&graph, &granted);
    assert!(c.reached.contains("node/far-shore"), "{:?}", c.reached);
    let c = Closure::run(&graph, &Grants::default());
    assert!(!c.reached.contains("node/far-shore"), "{:?}", c.reached);
    // And the carry is one-way: from the far shore, nothing carries back.
    let mut back = graph.clone();
    back.entry = delvewright_dsl::ids::NodeId("node/far-shore".to_string());
    let mut granted_far = Grants::default();
    granted_far.by_node.insert(
        "node/far-shore".to_string(),
        [Grant::Flag("flag/boarded".to_string())]
            .into_iter()
            .collect(),
    );
    let c = Closure::run(&back, &granted_far);
    assert!(!c.reached.contains("node/jetty"), "{:?}", c.reached);
}

/// spec-0083 §7: a `carry` edge with an empty `gating` is live from world load,
/// a hole in the graph's own claim — refused like an ungated barred way.
#[test]
fn dw0818_a_carry_with_an_empty_gating() {
    let g = graph_with(|v| {
        edges(v)[1] = json!({ "id": "edge/hall-vault", "class": "carry", "a": "node/hall",
                              "b": "node/vault", "gating": {} });
    });
    assert!(validate(Some(g)).contains(&"DW0818".to_string()));
}