Skip to main content

thread_conformance/
lib.rs

1//! # The Thread conformance suite
2//!
3//! A standard is only real if it can be checked *without* the reference browser.
4//! This crate is that check: point it at a corpus of worlds (a directory of
5//! `world.json`s) and it reports, browser-independently, whether they honour the
6//! spec — manifests validate, worlds are enterable, links are well-formed, and the
7//! constellation hangs together. It's the artifact that lets a third party prove
8//! their worlds (or their own engine's output) conform, with zero contact.
9//!
10//! Clauses are either **Error** (a real conformance violation — fails the suite)
11//! or **Warn** (a quality signal that doesn't break interop — reported, but the
12//! suite still passes). [`run`] returns a [`Report`]; [`Report::passed`] is true
13//! when no Error clause failed.
14
15use std::path::{Path, PathBuf};
16
17use infinite_manifest::{Locator, StructuredId, WorldManifest};
18
19pub mod relay;
20pub mod rendezvous;
21
22/// Whether a failed clause breaks conformance or is merely a quality signal.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Severity {
25    /// A real spec violation — fails the suite.
26    Error,
27    /// A quality signal — reported, but interop still holds.
28    Warn,
29}
30
31/// The outcome of one conformance clause.
32#[derive(Debug, Clone)]
33pub struct Clause {
34    pub name: &'static str,
35    pub severity: Severity,
36    pub pass: bool,
37    /// Human-readable specifics (offending worlds, external links, orphans…).
38    pub notes: Vec<String>,
39}
40
41/// The full conformance report over a corpus.
42#[derive(Debug, Clone)]
43pub struct Report {
44    pub clauses: Vec<Clause>,
45    /// How many worlds loaded and validated.
46    pub worlds: usize,
47}
48
49impl Report {
50    /// The suite passes when no **Error**-severity clause failed. Failed **Warn**
51    /// clauses are reported but do not break conformance.
52    pub fn passed(&self) -> bool {
53        self.clauses
54            .iter()
55            .all(|c| c.pass || c.severity == Severity::Warn)
56    }
57}
58
59/// A world that loaded and validated.
60pub struct World {
61    /// The world's key in the corpus — its directory path under the root, which is
62    /// exactly what a Locator's path (or host) resolves to.
63    pub name: String,
64    pub manifest: WorldManifest,
65    /// Where it was loaded from, when it came off a disk. `None` for a world
66    /// fetched over the wire — the clauses that read the tree skip those rather
67    /// than guess at what a remote host has beside its manifest.
68    pub dir: Option<PathBuf>,
69}
70
71/// A world that failed to load or validate.
72pub struct LoadError {
73    pub name: String,
74    pub error: String,
75}
76
77/// A loaded corpus: the worlds that validated, plus the ones that didn't.
78pub struct Corpus {
79    pub worlds: Vec<World>,
80    pub load_errors: Vec<LoadError>,
81}
82
83/// Load every world under `root` (one per immediate subdirectory — a
84/// `world.json`, or a `world.thread` markup source when only that exists),
85/// parsing and validating each through the reference manifest implementation.
86pub fn load_corpus(root: &Path) -> Corpus {
87    let mut worlds = Vec::new();
88    let mut load_errors = Vec::new();
89
90    let mut entries: Vec<PathBuf> = match std::fs::read_dir(root) {
91        Ok(rd) => rd.flatten().map(|e| e.path()).collect(),
92        Err(e) => {
93            load_errors.push(LoadError {
94                name: root.display().to_string(),
95                error: e.to_string(),
96            });
97            return Corpus {
98                worlds,
99                load_errors,
100            };
101        }
102    };
103    entries.sort();
104
105    for dir in entries {
106        let mut wj = dir.join("world.json");
107        if !wj.exists() {
108            wj = dir.join("world.thread");
109            if !wj.exists() {
110                continue;
111            }
112        }
113        let name = dir
114            .file_name()
115            .map(|n| n.to_string_lossy().into_owned())
116            .unwrap_or_default();
117        match std::fs::read_to_string(&wj) {
118            Ok(text) => match WorldManifest::from_text(&text) {
119                Ok(manifest) => {
120                    worlds.push(World { name, manifest, dir: Some(dir.clone()) })
121                }
122                Err(e) => load_errors.push(LoadError {
123                    name,
124                    error: e.to_string(),
125                }),
126            },
127            Err(e) => load_errors.push(LoadError {
128                name,
129                error: e.to_string(),
130            }),
131        }
132    }
133    Corpus {
134        worlds,
135        load_errors,
136    }
137}
138
139/// Build a one-world corpus from a single manifest's text (the live-host path:
140/// fetch a `thread://host` and run the spec clauses on what it served). `name` is
141/// the world's corpus key (its Locator path/host).
142pub fn single_corpus(name: &str, manifest_text: &str) -> Corpus {
143    match WorldManifest::from_text(manifest_text) {
144        Ok(manifest) => Corpus {
145            worlds: vec![World {
146                dir: None,
147                name: name.to_string(),
148                manifest,
149            }],
150            load_errors: Vec::new(),
151        },
152        Err(e) => Corpus {
153            worlds: Vec::new(),
154            load_errors: vec![LoadError {
155                name: name.to_string(),
156                error: e.to_string(),
157            }],
158        },
159    }
160}
161
162/// The transport contract a live host must satisfy so *any* browser — including a
163/// cross-origin web one — can walk it. Pure over the response facts, so it's
164/// testable without a network. `reachable` is whether the GET completed with a 2xx.
165pub fn transport_clauses(
166    reachable: bool,
167    status: u16,
168    content_type: &str,
169    cors: &str,
170) -> Vec<Clause> {
171    vec![
172        Clause {
173            name: "reachable over HTTPS",
174            severity: Severity::Error,
175            pass: reachable,
176            notes: if reachable {
177                vec![]
178            } else {
179                vec![format!("HTTP {status}")]
180            },
181        },
182        Clause {
183            name: "serves any-origin CORS",
184            severity: Severity::Error,
185            pass: cors == "*",
186            notes: if cors == "*" {
187                vec![]
188            } else if cors.is_empty() {
189                vec!["missing Access-Control-Allow-Origin (a web browser can't fetch it)".into()]
190            } else {
191                vec![format!("Access-Control-Allow-Origin: {cors} (not '*')")]
192            },
193        },
194        Clause {
195            name: "declares JSON content-type",
196            severity: Severity::Warn,
197            pass: content_type.contains("json"),
198            notes: if content_type.contains("json") {
199                vec![]
200            } else {
201                vec![format!(
202                    "Content-Type: '{content_type}' (expected application/json)"
203                )]
204            },
205        },
206    ]
207}
208
209/// Whether a slice of clauses passes (no failed Error clause).
210pub fn clauses_pass(clauses: &[Clause]) -> bool {
211    clauses
212        .iter()
213        .all(|c| c.pass || c.severity == Severity::Warn)
214}
215
216/// The corpus key a Locator resolves to — its path, or its host when path-less
217/// (mirrors the local resolver: `thread://host/path` → `<root>/<path>`).
218fn dest_key(loc: &Locator) -> &str {
219    if loc.path.is_empty() {
220        &loc.host
221    } else {
222        &loc.path
223    }
224}
225
226/// Run the full suite over a loaded corpus.
227pub fn run(corpus: &Corpus) -> Report {
228    let mut clauses = Vec::new();
229    let names: std::collections::HashSet<&str> =
230        corpus.worlds.iter().map(|w| w.name.as_str()).collect();
231
232    // C1 — every world manifest validates. (Error)
233    clauses.push(Clause {
234        name: "world manifests validate",
235        severity: Severity::Error,
236        pass: corpus.load_errors.is_empty(),
237        notes: corpus
238            .load_errors
239            .iter()
240            .map(|e| format!("{}: {}", e.name, e.error))
241            .collect(),
242    });
243
244    // C2 — every world declares at least one spawn (an arrival point). (Error)
245    let no_spawn: Vec<String> = corpus
246        .worlds
247        .iter()
248        .filter(|w| w.manifest.spawns.is_empty())
249        .map(|w| w.name.clone())
250        .collect();
251    clauses.push(Clause {
252        name: "worlds declare a spawn",
253        severity: Severity::Error,
254        pass: no_spawn.is_empty(),
255        notes: no_spawn.iter().map(|n| format!("{n}: no spawns")).collect(),
256    });
257
258    // C3 — every portal destination is a well-formed Locator. (Error)
259    let mut bad_locators = Vec::new();
260    for w in &corpus.worlds {
261        for p in &w.manifest.portals {
262            if Locator::parse(&p.to).is_none() {
263                bad_locators.push(format!("{}: portal '{}' → '{}'", w.name, p.id, p.to));
264            }
265        }
266    }
267    clauses.push(Clause {
268        name: "portal destinations are valid Locators",
269        severity: Severity::Error,
270        pass: bad_locators.is_empty(),
271        notes: bad_locators,
272    });
273
274    // C4 — the constellation is connected: from an entry world, every other world
275    // is reachable via internal veils. External links (to worlds hosted elsewhere)
276    // are fine and simply aren't traversed. Orphans are a quality signal. (Warn)
277    let (entry, orphans, internal, external) = analyze_graph(&corpus.worlds, &names);
278    let mut notes = vec![format!(
279        "entry '{}' · {} internal link(s) · {} external link(s)",
280        entry.as_deref().unwrap_or("—"),
281        internal,
282        external
283    )];
284    notes.extend(
285        orphans
286            .iter()
287            .map(|o| format!("unreachable from entry: {o}")),
288    );
289    clauses.push(Clause {
290        name: "constellation is connected",
291        severity: Severity::Warn,
292        pass: orphans.is_empty(),
293        notes,
294    });
295
296    // C5 — veils carry a human label (so a browser can name the doorway). (Warn)
297    let mut unlabeled = Vec::new();
298    for w in &corpus.worlds {
299        for p in &w.manifest.portals {
300            if p.label.trim().is_empty() {
301                unlabeled.push(format!("{}: portal '{}' has no label", w.name, p.id));
302            }
303        }
304    }
305    clauses.push(Clause {
306        name: "veils carry labels",
307        severity: Severity::Warn,
308        pass: unlabeled.is_empty(),
309        notes: unlabeled,
310    });
311
312    // C8 — a world's own files are actually there. A **relative** asset URI
313    // names something the author was supposed to ship beside the manifest; if
314    // it is missing the room loads perfectly and is missing its pillars, with
315    // nothing to complain about, because the manifest alone is still valid.
316    // (That is not hypothetical: a publisher that staged only the top level of
317    // a room directory would have put exactly that on a live domain.)
318    //
319    // Error, not Warn — unlike a dead veil (C6), which links out to a host that
320    // may come back and is the web's normal weather, this is content inside the
321    // author's own tree, under their control, missing every single time the
322    // world loads. Absolute URIs are links out and are not checked here.
323    //
324    // **`wasm` is exempt**, and not as a convenience: behaviour-abi §5 says a
325    // browser MAY ignore a behavior it cannot sandbox, so a world whose module
326    // is absent degrades to exactly what the spec promises — the room is whole,
327    // the pedestal simply doesn't do its trick. A missing mesh has no such
328    // clause behind it and leaves a hole where the columns were.
329    let mut missing = Vec::new();
330    for w in &corpus.worlds {
331        let Some(dir) = &w.dir else { continue };
332        for a in &w.manifest.assets {
333            let uri = a.uri.trim();
334            if uri.is_empty() || uri.contains("://") || uri.starts_with("//") {
335                continue;
336            }
337            if matches!(a.kind, infinite_manifest::AssetKind::Wasm) {
338                continue;
339            }
340            let rel = uri.split(['?', '#']).next().unwrap_or(uri);
341            if !dir.join(rel).exists() {
342                missing.push(format!("{}: asset '{}' → {rel} is not there", w.name, a.id));
343            }
344        }
345    }
346    clauses.push(Clause {
347        name: "declared files are present",
348        severity: Severity::Error,
349        pass: missing.is_empty(),
350        notes: missing,
351    });
352
353    // C7 — geometry rests on the ground rather than sinking through it. A
354    // centre-origin primitive whose `position.y` is less than half its
355    // `scale.y` has its lower half below y = 0: the table is through the
356    // floor, the pillar is buried to the knee. Nobody files that as a bug —
357    // the room merely looks slightly strange — which is exactly why it wants
358    // a clause. (Warn: deliberately half-buried geometry is legitimate.)
359    //
360    // The check is deliberately narrow. It applies ONLY to the builtins whose
361    // origin is their centre and whose extent is exactly `scale`: `cube`,
362    // `sphere`, `cylinder`, `capsule`. Not `plane` (flat, its y-scale means
363    // nothing), not carved shapes or glTF assets — a model from a store is
364    // usually **base**-origin, so the same arithmetic would condemn every
365    // correctly-placed one of them.
366    //
367    // The capsule was excluded here until 0.2.1, because it was three units
368    // tall by construction and so its `scale` did not mean what every other
369    // primitive's did. It is one metre now, which removed the exception rather
370    // than documenting it.
371    let mut sunk = Vec::new();
372    for w in &corpus.worlds {
373        let centre_origin: std::collections::HashSet<StructuredId> = w
374            .manifest
375            .prefabs
376            .iter()
377            .filter(|p| {
378                matches!(
379                    p.mesh.builtin.as_deref(),
380                    Some("cube" | "sphere" | "cylinder" | "capsule")
381                )
382            })
383            .map(|p| p.id)
384            .collect();
385        for pl in &w.manifest.placements {
386            if !centre_origin.contains(&pl.prefab) {
387                continue;
388            }
389            // Panels and lights hover by design; so does anything the author
390            // marked non-solid (trim, banners, decals).
391            if pl.solid == Some(false) || pl.text.is_some() || pl.light.is_some() {
392                continue;
393            }
394            let bottom = pl.position[1] - pl.scale[1].abs() / 2.0;
395            if bottom < -GROUND_TOLERANCE {
396                sunk.push(format!(
397                    "{}: '{}' sits {:.2} m below the ground plane",
398                    w.name,
399                    if pl.name.is_empty() {
400                        "(unnamed)"
401                    } else {
402                        &pl.name
403                    },
404                    -bottom
405                ));
406            }
407        }
408    }
409    let shown: Vec<String> = sunk.iter().take(12).cloned().collect();
410    let mut notes = shown;
411    if sunk.len() > notes.len() {
412        notes.push(format!("… and {} more", sunk.len() - notes.len()));
413    }
414    clauses.push(Clause {
415        name: "geometry rests on the ground plane",
416        severity: Severity::Warn,
417        pass: sunk.is_empty(),
418        notes,
419    });
420
421    Report {
422        clauses,
423        worlds: corpus.worlds.len(),
424    }
425}
426
427/// How far a piece may sit below y = 0 before it counts as sunk. Five
428/// centimetres: enough for a floor slab bedded into the ground, far short of a
429/// table through it.
430const GROUND_TOLERANCE: f32 = 0.05;
431
432/// Pick an entry world, BFS the internal veil graph, and return
433/// `(entry, orphans, internal_link_count, external_link_count)`.
434fn analyze_graph(
435    worlds: &[World],
436    names: &std::collections::HashSet<&str>,
437) -> (Option<String>, Vec<String>, usize, usize) {
438    if worlds.is_empty() {
439        return (None, Vec::new(), 0, 0);
440    }
441
442    // Entry: prefer a world named "nexus"; else the hub with the most veils.
443    let entry = worlds
444        .iter()
445        .find(|w| w.name == "nexus")
446        .or_else(|| worlds.iter().max_by_key(|w| w.manifest.portals.len()))
447        .map(|w| w.name.clone());
448
449    // Adjacency over internal links; tally internal vs external.
450    let mut internal = 0usize;
451    let mut external = 0usize;
452    let mut adj: std::collections::HashMap<&str, Vec<String>> = std::collections::HashMap::new();
453    for w in worlds {
454        for p in &w.manifest.portals {
455            match Locator::parse(&p.to) {
456                Some(loc) if names.contains(dest_key(&loc)) => {
457                    internal += 1;
458                    adj.entry(w.name.as_str())
459                        .or_default()
460                        .push(dest_key(&loc).to_string());
461                }
462                _ => external += 1,
463            }
464        }
465    }
466
467    // BFS from the entry.
468    let mut seen = std::collections::HashSet::new();
469    if let Some(start) = &entry {
470        let mut queue = vec![start.clone()];
471        seen.insert(start.clone());
472        while let Some(cur) = queue.pop() {
473            if let Some(nexts) = adj.get(cur.as_str()) {
474                for n in nexts {
475                    if seen.insert(n.clone()) {
476                        queue.push(n.clone());
477                    }
478                }
479            }
480        }
481    }
482
483    let mut orphans: Vec<String> = worlds
484        .iter()
485        .map(|w| w.name.clone())
486        .filter(|n| !seen.contains(n))
487        .collect();
488    orphans.sort();
489    (entry, orphans, internal, external)
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495
496    fn corpus_from(worlds: Vec<(&str, WorldManifest)>) -> Corpus {
497        Corpus {
498            worlds: worlds
499                .into_iter()
500                .map(|(n, m)| World {
501                    name: n.into(),
502                    manifest: m,
503                    dir: None,
504                })
505                .collect(),
506            load_errors: Vec::new(),
507        }
508    }
509
510    fn world(id: &str, spawns: bool, portals: &[(&str, &str, &str)]) -> WorldManifest {
511        let spawn_json = if spawns {
512            r#""spawns": [{ "name": "entry", "position": [0,0,0] }],"#
513        } else {
514            ""
515        };
516        let portal_json: Vec<String> = portals
517            .iter()
518            .map(|(pid, to, label)| {
519                format!(
520                    r#"{{ "id": "{pid}", "position": [0,0,0], "to": "{to}", "label": "{label}" }}"#
521                )
522            })
523            .collect();
524        let text = format!(
525            r#"{{ "thread": "thread/0.1", "world": {{ "id": "{id}", "title": "{id}" }},
526                {spawn_json} "portals": [{}] }}"#,
527            portal_json.join(",")
528        );
529        WorldManifest::from_json(&text).unwrap()
530    }
531
532    #[test]
533    fn a_connected_labeled_corpus_passes_cleanly() {
534        let c = corpus_from(vec![
535            (
536                "nexus",
537                world("nexus", true, &[("to-a", "thread://a.io/alpha", "Alpha")]),
538            ),
539            (
540                "alpha",
541                world("alpha", true, &[("to-n", "thread://x.io/nexus", "Nexus")]),
542            ),
543        ]);
544        let r = run(&c);
545        assert!(r.passed(), "no error clauses should fail");
546        assert!(
547            r.clauses.iter().all(|cl| cl.pass),
548            "and no warnings either: {:?}",
549            r.clauses
550        );
551    }
552
553    #[test]
554    fn a_missing_spawn_is_an_error_and_fails_the_suite() {
555        let c = corpus_from(vec![("alpha", world("alpha", false, &[]))]);
556        let r = run(&c);
557        assert!(!r.passed());
558        let spawn_clause = r
559            .clauses
560            .iter()
561            .find(|c| c.name == "worlds declare a spawn")
562            .unwrap();
563        assert!(!spawn_clause.pass);
564        assert_eq!(spawn_clause.severity, Severity::Error);
565    }
566
567    #[test]
568    fn an_orphan_world_warns_but_still_passes() {
569        // 'lonely' is in the corpus but nothing links to it.
570        let c = corpus_from(vec![
571            (
572                "nexus",
573                world("nexus", true, &[("to-a", "thread://a.io/alpha", "Alpha")]),
574            ),
575            ("alpha", world("alpha", true, &[])),
576            ("lonely", world("lonely", true, &[])),
577        ]);
578        let r = run(&c);
579        assert!(
580            r.passed(),
581            "an orphan is a warning, not a conformance failure"
582        );
583        let conn = r
584            .clauses
585            .iter()
586            .find(|c| c.name == "constellation is connected")
587            .unwrap();
588        assert!(!conn.pass);
589        assert_eq!(conn.severity, Severity::Warn);
590        assert!(conn.notes.iter().any(|n| n.contains("lonely")));
591    }
592
593    /// A corpus sitting beside this crate must have zero **Error**-severity
594    /// failures. In the reference implementation's own tree that is the hosted
595    /// constellation, and this is the suite guarding it; published on its own,
596    /// the crate travels without those worlds, so the test reports that it had
597    /// nothing to check rather than failing for the absence of files that were
598    /// never its own. (Warn clauses like orphan worlds are allowed; this
599    /// asserts real conformance, not full connectivity.)
600    #[test]
601    fn the_shipped_worlds_corpus_is_conformant() {
602        let root = format!("{}/../../worlds", env!("CARGO_MANIFEST_DIR"));
603        let root = std::path::Path::new(&root);
604        if !root.is_dir() {
605            eprintln!("no corpus beside this crate — nothing to guard here");
606            return;
607        }
608        let corpus = load_corpus(root);
609        let report = run(&corpus);
610        assert!(
611            report.worlds >= 8,
612            "a corpus is present but nearly empty — found {}",
613            report.worlds
614        );
615        for c in &report.clauses {
616            if c.severity == Severity::Error {
617                assert!(c.pass, "error clause '{}' failed: {:?}", c.name, c.notes);
618            }
619        }
620        assert!(report.passed());
621    }
622
623    #[test]
624    fn transport_contract_flags_missing_cors_as_error() {
625        let ok = transport_clauses(true, 200, "application/json", "*");
626        assert!(clauses_pass(&ok));
627
628        let no_cors = transport_clauses(true, 200, "application/json", "");
629        assert!(
630            !clauses_pass(&no_cors),
631            "missing CORS breaks cross-origin browsers"
632        );
633        let cors_clause = no_cors
634            .iter()
635            .find(|c| c.name == "serves any-origin CORS")
636            .unwrap();
637        assert_eq!(cors_clause.severity, Severity::Error);
638
639        // Wrong content-type is only a warning — the manifest still parses.
640        let text_plain = transport_clauses(true, 200, "text/plain", "*");
641        assert!(clauses_pass(&text_plain));
642        assert!(
643            !text_plain
644                .iter()
645                .find(|c| c.name == "declares JSON content-type")
646                .unwrap()
647                .pass
648        );
649    }
650
651    #[test]
652    fn single_corpus_runs_the_spec_clauses_on_one_fetched_world() {
653        let text = r#"{ "thread": "thread/0.1", "world": { "id": "w", "title": "Live" },
654            "spawns": [{ "name": "entry", "position": [0,0,0] }],
655            "portals": [{ "id": "p", "position": [0,0,0], "to": "thread://x.io/y", "label": "Y" }] }"#;
656        let corpus = single_corpus("myhost.com", text);
657        let report = run(&corpus);
658        assert_eq!(report.worlds, 1);
659        assert!(report.passed());
660
661        // A malformed live manifest surfaces as a C1 error.
662        let bad = single_corpus("myhost.com", "{ nope }");
663        assert!(!run(&bad).passed());
664    }
665
666    #[test]
667    fn external_links_are_counted_not_penalized() {
668        let c = corpus_from(vec![(
669            "nexus",
670            world(
671                "nexus",
672                true,
673                &[("out", "thread://someone-elses-host.com/room", "Elsewhere")],
674            ),
675        )]);
676        let r = run(&c);
677        assert!(r.passed());
678        let conn = r
679            .clauses
680            .iter()
681            .find(|c| c.name == "constellation is connected")
682            .unwrap();
683        assert!(conn.notes.iter().any(|n| n.contains("1 external link")));
684    }
685
686    /// C7. The clause has to be narrow or it is worse than nothing: a
687    /// base-origin glTF at y = 0 is *correct*, and condemning it would train
688    /// authors to ignore the report. So only centre-origin builtins count.
689    #[test]
690    fn sunk_geometry_is_reported_and_base_origin_models_are_left_alone() {
691        let m: WorldManifest = serde_json::from_str(
692            r#"{ "thread": "thread/0.1", "world": { "id": "w", "title": "W" },
693                 "spawns": [{ "name": "entry", "position": [0,0,0] }],
694                 "assets": [{ "id": "urn", "uri": "https://q/x.glb", "kind": "gltf" }],
695                 "prefabs": [
696                   { "id": 60930001, "mesh": { "builtin": "cylinder" } },
697                   { "id": 60930002, "mesh": { "builtin": "plane" } },
698                   { "id": 60930003, "mesh": { "asset": "urn" } }
699                 ],
700                 "placements": [
701                   { "prefab": 60930001, "name": "buried post",
702                     "position": [0,0.5,0], "scale": [0.3,1.4,0.3] },
703                   { "prefab": 60930001, "name": "standing post",
704                     "position": [2,0.7,0], "scale": [0.3,1.4,0.3] },
705                   { "prefab": 60930001, "name": "half-sunk boulder",
706                     "position": [4,0.2,0], "scale": [1,1,1], "solid": false },
707                   { "prefab": 60930002, "name": "floor",
708                     "position": [0,0,0], "scale": [30,1,30] },
709                   { "prefab": 60930003, "name": "a bought column",
710                     "position": [6,0,0], "scale": [1,1,1] }
711                 ] }"#,
712        )
713        .expect("manifest");
714        let report = run(&corpus_from(vec![("w", m)]));
715        let c = report
716            .clauses
717            .iter()
718            .find(|c| c.name == "geometry rests on the ground plane")
719            .expect("the clause runs");
720        assert_eq!(
721            c.severity,
722            Severity::Warn,
723            "half-buried geometry is legitimate"
724        );
725        assert!(!c.pass, "the buried post is reported");
726        assert_eq!(c.notes.len(), 1, "and nothing else is: {:?}", c.notes);
727        assert!(c.notes[0].contains("buried post"), "{:?}", c.notes);
728        assert!(report.passed(), "a Warn clause never breaks conformance");
729    }
730
731    /// C8. The failure it exists for: a room published without the directory
732    /// its models live in. Every other check passes — the manifest is valid,
733    /// the spawn is there, the veils are labelled — and the visitor arrives in
734    /// a hall with no columns.
735    #[test]
736    fn a_world_missing_its_own_files_fails_and_links_out_are_left_alone() {
737        let dir = std::env::temp_dir().join("thread-c8-test");
738        let _ = std::fs::remove_dir_all(&dir);
739        std::fs::create_dir_all(dir.join("models")).expect("tmp");
740        std::fs::write(dir.join("models/here.glb"), b"glb").expect("write");
741        let m: WorldManifest = serde_json::from_str(
742            r#"{ "thread": "thread/0.1", "world": { "id": "w", "title": "W" },
743                 "spawns": [{ "name": "e", "position": [0,0,0] }],
744                 "assets": [
745                   { "id": "a", "uri": "models/here.glb", "kind": "gltf" },
746                   { "id": "b", "uri": "models/gone.glb", "kind": "gltf" },
747                   { "id": "c", "uri": "https://cdn.example/x.glb", "kind": "gltf" },
748                   { "id": "d", "uri": "behaviors/absent.wasm", "kind": "wasm" }
749                 ] }"#,
750        )
751        .expect("manifest");
752        let corpus = Corpus {
753            worlds: vec![World { name: "w".into(), manifest: m, dir: Some(dir.clone()) }],
754            load_errors: Vec::new(),
755        };
756        let report = run(&corpus);
757        let c = report.clauses.iter().find(|c| c.name == "declared files are present").unwrap();
758        assert_eq!(c.severity, Severity::Error);
759        assert!(!c.pass);
760        assert_eq!(
761            c.notes.len(),
762            1,
763            "only the missing local file a browser must draw — not the link out, \
764             and not the wasm the spec lets a browser ignore: {:?}",
765            c.notes
766        );
767        assert!(c.notes[0].contains("gone.glb"), "{:?}", c.notes);
768        assert!(!report.passed(), "a room without its models is not conformant");
769
770        // A world fetched over the wire has no tree to inspect — skipped, not failed.
771        let fetched = single_corpus("host", &serde_json::to_string(&corpus.worlds[0].manifest).unwrap());
772        let c = run(&fetched)
773            .clauses
774            .iter()
775            .find(|c| c.name == "declared files are present")
776            .cloned()
777            .unwrap();
778        assert!(c.pass, "nothing to check without a directory: {:?}", c.notes);
779        let _ = std::fs::remove_dir_all(&dir);
780    }
781}