Skip to main content

teamcraft/
templates.rs

1//! HTML templates (MiniJinja), embedded into the binary at compile time.
2
3use minijinja::Environment;
4
5use crate::util;
6
7macro_rules! embed {
8    ($($name:literal),* $(,)?) => {
9        &[$(($name, include_str!(concat!("../templates/", $name)))),*]
10    };
11}
12
13/// All templates, keyed by their path below `templates/`.
14pub const TEMPLATES: &[(&str, &str)] = embed![
15    "base.html",
16    "macros.html",
17    "login.html",
18    "error.html",
19    "dashboard.html",
20    "explorer.html",
21    "items/list.html",
22    "items/new.html",
23    "items/label.html",
24    "items/where_used.html",
25    "revisions/show.html",
26    "revisions/edit.html",
27    "revisions/bom.html",
28    "revisions/bom_compare.html",
29    "revisions/mass.html",
30    "materials.html",
31    "revisions/bom_from_references.html",
32    "revisions/save_as.html",
33    "revisions/simulation_compare.html",
34    "changes/list.html",
35    "changes/new.html",
36    "changes/show.html",
37    "requests/list.html",
38    "requests/new.html",
39    "requests/show.html",
40    "projects/list.html",
41    "projects/show.html",
42    "projects/access_review.html",
43    "projects/access_log.html",
44    "projects/season.html",
45    "files/preview.html",
46    "admin/users.html",
47    "admin/user_edit.html",
48    "admin/holdings.html",
49    "admin/audit.html",
50    "admin/attributes.html",
51    "admin/attribute_edit.html",
52    "admin/webhooks.html",
53    "admin/webhook_edit.html",
54    "admin/branding.html",
55    "admin/ai.html",
56    "desktop/authorize.html",
57    "formats.html",
58    "imports/upload.html",
59    "imports/preview.html",
60    "notifications.html",
61    "account.html",
62    "assistant.html",
63];
64
65pub fn environment(instance_name: &str) -> Result<Environment<'static>, minijinja::Error> {
66    let mut env = Environment::new();
67    for (name, source) in TEMPLATES {
68        env.add_template(name, source)?;
69    }
70    env.add_filter("dt", filter_dt);
71    env.add_filter("num", filter_num);
72    env.add_filter("fixed", filter_fixed);
73    env.add_filter("sig", filter_sig);
74    env.add_filter("filesize", filter_filesize);
75    env.add_filter("label", filter_label);
76    env.add_filter("urlencode", |value: String| util::pct_encode(&value));
77    env.add_filter("age", filter_age);
78    env.add_global("instance_name", instance_name.to_string());
79    env.add_global("version", env!("CARGO_PKG_VERSION"));
80    // cache busting of the embedded stylesheets, scripts and logos
81    env.add_global("assets", crate::assets::version());
82    Ok(env)
83}
84
85/// RFC 3339 timestamp → `2026-11-03 14:05 UTC`. (The browser script
86/// rewrites `<time>` elements into the viewer's local time zone.)
87fn filter_dt(value: Option<String>) -> String {
88    match value {
89        Some(v) => match chrono::DateTime::parse_from_rfc3339(&v) {
90            Ok(t) => t
91                .with_timezone(&chrono::Utc)
92                .format("%Y-%m-%d %H:%M UTC")
93                .to_string(),
94            Err(_) => v,
95        },
96        None => "—".to_string(),
97    }
98}
99
100/// RFC 3339 timestamp → its age now (`3 days`, `5 h`, `just now`), e.g.
101/// how long a lock has been held; `—` for none.
102fn filter_age(value: Option<String>) -> String {
103    match value
104        .as_deref()
105        .and_then(|v| chrono::DateTime::parse_from_rfc3339(v).ok())
106    {
107        Some(t) => crate::domain::format_age(
108            (chrono::Utc::now() - t.with_timezone(&chrono::Utc)).num_seconds(),
109        ),
110        None => "—".to_string(),
111    }
112}
113
114/// Number with up to `decimals` (default 3) decimals; `—` for none.
115fn filter_num(value: Option<f64>, decimals: Option<usize>) -> String {
116    match value {
117        Some(v) => util::format_number(v, decimals.unwrap_or(3)),
118        None => "—".to_string(),
119    }
120}
121
122/// A number with exactly `decimals` places (default 2), so a column of
123/// costs or masses lines up at the decimal point; `—` when missing.
124fn filter_fixed(value: Option<f64>, decimals: Option<usize>) -> String {
125    match value {
126        Some(v) => format!("{v:.prec$}", prec = decimals.unwrap_or(2)),
127        None => "—".to_string(),
128    }
129}
130
131/// A physical value with six significant digits (mass properties span
132/// grams to tonnes and 10⁻⁹ to 10⁶ kg·m²); exponent form outside
133/// 10⁻³ … 10⁷; `—` when missing.
134fn filter_sig(value: Option<f64>) -> String {
135    match value {
136        None => "—".to_string(),
137        Some(v) if !v.is_finite() => "—".to_string(),
138        Some(0.0) => "0".to_string(),
139        Some(v) if (1e-3..1e7).contains(&v.abs()) => {
140            let decimals = (5 - v.abs().log10().floor() as i32).clamp(0, 9) as usize;
141            util::format_number(v, decimals)
142        }
143        Some(v) => format!("{v:.5e}"),
144    }
145}
146
147fn filter_filesize(value: i64) -> String {
148    util::format_bytes(value)
149}
150
151/// `in_review` → `In Review`; a few codes have fixed labels (`in_work` → `WIP`).
152fn filter_label(value: Option<String>) -> String {
153    let Some(value) = value else {
154        return String::new();
155    };
156    match value.as_str() {
157        "in_work" => return "WIP".into(),
158        "editor" => return "CAD editor".into(),
159        _ => {}
160    }
161    value
162        .split('_')
163        .filter(|w| !w.is_empty())
164        .map(|w| {
165            let mut c = w.chars();
166            match c.next() {
167                Some(first) => first.to_uppercase().collect::<String>() + c.as_str(),
168                None => String::new(),
169            }
170        })
171        .collect::<Vec<_>>()
172        .join(" ")
173}
174
175#[cfg(test)]
176mod accessibility;
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use serde_json::{json, Value};
182    use std::cell::RefCell;
183
184    thread_local! {
185        /// Every page [`render`] produced on this thread, for the checks that
186        /// run over all of them (`every_rendered_page_is_accessible`).
187        static RENDERED: RefCell<Vec<(String, String)>> = const { RefCell::new(Vec::new()) };
188    }
189
190    const ME: &str = "11111111-1111-4111-8111-111111111111";
191    const OTHER: &str = "22222222-2222-4222-8222-222222222222";
192    const TS: &str = "2026-11-03T14:05:59.123456Z";
193
194    fn me() -> Value {
195        json!({ "id": ME, "username": "max", "display_name": "Max Muster", "initials": "MM", "avatar": 3, "role": "admin", "skin": "arch", "is_admin": true, "can_edit": true })
196    }
197
198    /// The Files card hint of `revisions/show.html` (`kind` as
199    /// `domain::FilesHint::as_str`), shaped like `web::revisions::show` sends it.
200    fn files_hint(kind: &str) -> Value {
201        json!({ "kind": kind, "can_revise": kind == "frozen", "type_hint": "STEP, drawings, <reports>" })
202    }
203
204    fn revision(state: &str, checked_out: bool) -> Value {
205        json!({
206            "id": "33333333-3333-4333-8333-333333333333", "item_id": "44444444-4444-4444-8444-444444444444",
207            "item_number": "PRT-000001", "item_type": "part", "unit": "each", "seq": 2, "code": "B",
208            "state": state, "name": "Bracket <b>", "description": "line 1\nline 2", "material": "AlMg3",
209            "mass_kg": 0.125, "unit_cost": null,
210            "checked_out_by": if checked_out { json!(OTHER) } else { Value::Null },
211            "checked_out_by_name": if checked_out { json!("Erika") } else { Value::Null },
212            "checked_out_at": if checked_out { json!(TS) } else { Value::Null },
213            "created_by": ME, "created_by_name": "Max Muster",
214            "created_at": TS, "updated_at": TS, "released_at": null
215        })
216    }
217
218    fn item() -> Value {
219        json!({
220            "id": "44444444-4444-4444-8444-444444444444", "number": "PRT-000001", "item_type": "part",
221            "unit": "each", "project_id": null, "project_code": "ROCKET", "created_by": ME,
222            "created_by_name": "Max Muster", "created_at": TS,
223            "owner_id": ME, "owner_name": "Max Muster"
224        })
225    }
226
227    fn change(status: &str) -> Value {
228        json!({
229            "id": "55555555-5555-4555-8555-555555555555", "number": "CHG-000001", "title": "Release bracket",
230            "description": "why", "priority": "high", "status": status, "created_by": ME,
231            "created_by_name": "Max Muster", "created_at": TS, "updated_at": TS,
232            "submitted_at": TS, "closed_at": null
233        })
234    }
235
236    fn request(status: &str) -> Value {
237        json!({
238            "id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", "number": "ECR-000001", "title": "Crack <at> the weld",
239            "description": "Problem\nproposal", "reason": "manufacturing", "priority": "high", "status": status,
240            "requested_by": OTHER, "requested_by_name": "Erika", "assignee": ME, "assignee_name": "Max Muster",
241            "assignee_username": "max", "decision_comment": "not now", "decided_by": ME,
242            "decided_by_name": "Max Muster", "decided_at": TS, "due_date": "2026-12-01", "created_at": TS,
243            "updated_at": TS, "closed_at": null, "item_count": 1
244        })
245    }
246
247    fn audit_entry() -> Value {
248        json!({ "id": 1, "at": TS, "user_id": ME, "username": "max", "action": "item.create",
249                "entity_type": "item", "entity_id": "44444444-4444-4444-8444-444444444444",
250                "summary": "Created Part PRT-000001", "details": {} })
251    }
252
253    fn user() -> Value {
254        json!({ "id": OTHER, "username": "erika", "display_name": "Erika", "email": null,
255                "auth_source": "local", "role": "editor", "active": true, "created_at": TS, "last_login_at": null,
256                "skin": "teamcraft" })
257    }
258
259    const ROCKET: &str = "66666666-6666-4666-8666-666666666666";
260
261    fn project() -> Value {
262        json!({ "id": ROCKET, "code": "ROCKET", "name": "Sounding rocket",
263                "description": "Rocketry", "active": true, "created_at": TS,
264                "oidc_group": "teamcraft-rocket", "member_count": 3, "created_by": ME })
265    }
266
267    fn roles() -> Value {
268        json!([{ "value": "viewer", "label": "Viewer" }, { "value": "editor", "label": "CAD editor" },
269               { "value": "admin", "label": "Admin" }])
270    }
271
272    fn page() -> Value {
273        json!({ "page": 1, "pages": 2, "total": 60, "prev": null, "next": 2 })
274    }
275
276    fn with_common(mut data: Value) -> Value {
277        let map = data.as_object_mut().unwrap();
278        map.insert("me".into(), me());
279        map.insert(
280            "flash".into(),
281            json!({ "kind": "success", "message": "Saved <ok>" }),
282        );
283        map.insert("local_login".into(), json!(true));
284        map.insert("skin".into(), json!("arch"));
285        map.insert("skin_scheme".into(), json!("dark"));
286        map.insert(
287            "branding".into(),
288            json!({ "wide": "aaaabbbbcccc", "square": "ddddeeeeffff" }),
289        );
290        data
291    }
292
293    fn render(env: &Environment<'static>, name: &str, data: Value) -> String {
294        let html = env
295            .get_template(name)
296            .unwrap_or_else(|e| panic!("{name}: {e:#}"))
297            .render(&data)
298            .unwrap_or_else(|e| panic!("{name}: {e:#}"));
299        RENDERED.with(|r| r.borrow_mut().push((name.to_string(), html.clone())));
300        html
301    }
302
303    /// Every page of `every_template_renders` – all templates in all the
304    /// states it renders – passes the accessibility checks
305    /// (`templates/accessibility.rs`).
306    #[test]
307    fn every_rendered_page_is_accessible() {
308        RENDERED.with(|r| r.borrow_mut().clear());
309        every_template_renders();
310        let pages = RENDERED.with(|r| r.take());
311        assert!(pages.len() > 60, "rendered pages: {}", pages.len());
312        let mut problems: Vec<String> = pages
313            .iter()
314            .flat_map(|(name, html)| accessibility::check(name, html))
315            .collect();
316        problems.sort();
317        problems.dedup();
318        assert!(
319            problems.is_empty(),
320            "{} accessibility problems:\n{}",
321            problems.len(),
322            problems.join("\n")
323        );
324    }
325
326    #[test]
327    fn every_template_renders() {
328        let env = environment("TeamCraft Test").expect("templates compile");
329        let bom_line = json!({
330            "id": "77777777-7777-4777-8777-777777777777", "parent_revision_id": "33333333-3333-4333-8333-333333333333",
331            "child_item_id": "88888888-8888-4888-8888-888888888888", "child_number": "PRT-000002",
332            "child_type": "part", "child_unit": "each", "find_number": 10, "quantity": 4.0, "notes": "M6",
333            "child_latest_revision_id": "99999999-9999-4999-8999-999999999999", "child_latest_code": "A",
334            "child_latest_state": "released", "child_name": "Bolt"
335        });
336        let version = |n: i32, comment: &str| {
337            json!({ "id": ME, "file_id": OTHER, "version": n, "sha256": "cd".repeat(32), "size_bytes": 1000,
338                    "content_type": "application/pdf", "comment": comment, "uploaded_by": ME,
339                    "uploaded_by_name": "Max", "uploaded_at": TS })
340        };
341        let file = json!({ "id": OTHER, "revision_id": ME, "filename": "nozzle.step", "content_type": "model/step",
342                           "size_bytes": 123456, "sha256": "ab".repeat(32), "uploaded_by": ME,
343                           "uploaded_by_name": "Max", "uploaded_at": TS, "version": 2, "preview": true, "folder": "rover/nozzle",
344                           "format": "step", "format_info": crate::cadformat::info("step"),
345                           "versions": [version(2, "wall 2.5 mm"), version(1, "")] });
346        let native = json!({ "id": ME, "revision_id": ME, "filename": "nozzle.SLDPRT",
347                             "content_type": "application/octet-stream", "size_bytes": 4096,
348                             "sha256": "ef".repeat(32), "uploaded_by": ME, "uploaded_by_name": "Max",
349                             "uploaded_at": TS, "version": 1, "preview": false, "format": "solidworks-part",
350                             "format_info": crate::cadformat::info("solidworks-part"),
351                             "versions": [version(1, "")] });
352        let unknown = json!({ "id": OTHER, "revision_id": ME, "filename": "notes.xyz",
353                              "content_type": "application/x-unknown", "size_bytes": 10,
354                              "sha256": "12".repeat(32), "uploaded_by": ME, "uploaded_by_name": "Max",
355                              "uploaded_at": TS, "version": 1, "preview": false, "format": "",
356                              "format_info": null, "versions": [version(1, "")] });
357        let impact = |resolution: Option<&str>| {
358            json!({ "id": ME, "item_id": ME, "item_number": "ASM-000001", "project_id": ROCKET,
359                    "project_code": "ROCKET", "revision_id": OTHER, "revision_code": "A",
360                    "revision_state": "released", "item_name": "Stage <1>", "source_item_id": OTHER,
361                    "source_number": "PRT-000001", "source_revision_id": ME, "source_code": "B",
362                    "source_name": "Bracket", "event": "released", "relation": "bom",
363                    "detail": "find no. 10", "change_id": ME, "change_number": "CHG-000002",
364                    "created_at": TS, "resolved_at": resolution.map(|_| TS), "resolution": resolution,
365                    "resolved_by_name": resolution.map(|_| "Erika"),
366                    "note": if resolution.is_some() { "fit unchanged <ok>" } else { "" } })
367        };
368        // services::impacts::DependentGroup: two sources on one dependent
369        let impact_group = || {
370            let mut obsolete = impact(None);
371            obsolete["id"] = json!(OTHER);
372            obsolete["source_number"] = json!("PRT-000002");
373            obsolete["event"] = json!("obsolete");
374            obsolete["relation"] = json!("file");
375            obsolete["detail"] = json!("Stage.SLDASM references Nut.SLDPRT");
376            obsolete["change_id"] = json!(null);
377            obsolete["change_number"] = json!(null);
378            json!({ "item_id": ME, "item_number": "ASM-000001", "item_name": "Stage <1>", "project_id": ROCKET,
379                    "project_code": "ROCKET", "revision_id": OTHER, "revision_code": "A",
380                    "revision_state": "released", "latest_at": TS, "impact_ids": format!("{ME},{OTHER}"),
381                    "impacts": [impact(None), obsolete] })
382        };
383        let references = json!({
384            "outgoing": [{ "id": ME, "from_revision_id": ME, "to_item_id": OTHER, "to_number": "PRT-000009",
385                           "to_type": "part", "kind": "drawing", "note": "sheet 2", "to_latest_revision_id": OTHER,
386                           "to_latest_code": "C", "to_latest_state": "released", "to_name": "Nozzle",
387                           "released_to_revision_id": OTHER, "released_to_code": "B", "created_at": TS }],
388            "incoming": [{ "reference_id": ME, "kind": "specification", "note": "", "from_revision_id": OTHER,
389                           "from_item_id": OTHER, "from_number": "DOC-000003", "from_code": "A",
390                           "from_name": "Welding spec", "from_state": "released", "released_to_code": "A" }],
391            "files": [{ "file_id": ME, "filename": "Stage.SLDASM", "format_kind": "assembly", "references": [
392                { "name": "bracket.sldprt", "matches": [{ "item_id": OTHER, "item_number": "PRT-000001",
393                  "item_name": "Bracket", "revision_id": OTHER, "revision_code": "B", "revision_state": "released",
394                  "file_id": OTHER, "filename": "Bracket.SLDPRT", "same_revision": false,
395                  "export_name": false }] },
396                { "name": "nozzle.stp", "matches": [{ "item_id": OTHER, "item_number": "PRT-000002",
397                  "item_name": "Nozzle", "revision_id": OTHER, "revision_code": "A", "revision_state": "in_work",
398                  "file_id": OTHER, "filename": "nozzle_v2.stp", "same_revision": false,
399                  "export_name": true }] },
400                { "name": "Bolt M6.SLDPRT", "matches": [] }] }],
401            "file_referrers": [{ "item_id": OTHER, "item_number": "DOC-000004", "revision_id": OTHER,
402                                 "revision_code": "A", "revision_state": "in_work", "item_name": "Stage drawing",
403                                 "file_id": OTHER, "filename": "Stage.SLDDRW", "referenced": "Stage.SLDASM",
404                                 "export_name": true }],
405            "suggestions": {
406                "bom": [{ "item_number": "PRT-000001", "item_name": "Bracket", "via": "Stage.SLDASM → Bracket.SLDPRT" }],
407                "drawing_of": [{ "item_number": "PRT-000007", "item_name": "Hub", "via": "Hub.SLDDRW → Hub.SLDPRT" }] },
408            "kinds": crate::domain::ReferenceKind::ALL.iter()
409                .map(|k| json!({ "value": k.as_str(), "label": k.label() })).collect::<Vec<_>>(),
410        });
411        let can_all = json!({ "edit": true, "checkout": true, "checkin": true, "cancel_checkout": true,
412                              "revise": true, "obsolete": true, "delete": true, "master": true, "owner": true,
413                              "add_to_change": true, "acknowledge": true });
414
415        let html = render(
416            &env,
417            "dashboard.html",
418            with_common(json!({
419                "stats": { "items": 3, "revisions_in_work": 1, "revisions_in_review": 0, "revisions_released": 2,
420                           "open_changes": 1, "files": 4, "stored_bytes": 2048 },
421                "checkouts": [revision("in_work", true)], "reviews": [change("in_review")],
422                "my_changes": [change("draft")], "activity": [audit_entry()], "impact_groups": [impact_group()],
423                "assigned_requests": [request("in_triage")], "my_requests": [request("new")],
424            })),
425        );
426        assert!(
427            html.contains("Requests assigned to me") && html.contains("due 2026-12-01"),
428            "change requests on the dashboard"
429        );
430        assert!(html.contains("Saved &lt;ok&gt;"), "flash is escaped");
431        assert!(html.contains("2 KiB"));
432        assert!(
433            html.contains("Dependents to check")
434                && html.contains("uses it in its BOM")
435                && html.contains("Stage &lt;1&gt;"),
436            "open impacts on the dashboard"
437        );
438        for expected in [
439            "2 changes to check",
440            "Acknowledge all…",
441            "/revisions/22222222-2222-4222-8222-222222222222/impacts/acknowledge",
442            "BOM line",
443            "CAD file reference",
444            "via <a class=\"mono\" href=\"/changes/",
445            "badge-obsolete",
446            "resolves all 2 as well",
447            "/revise",
448        ] {
449            assert!(html.contains(expected), "grouped impacts show {expected}");
450        }
451        assert_eq!(
452            html.matches("resolves all 2 as well").count(),
453            1,
454            "the revise hint once per group"
455        );
456
457        let entry = json!({ "item_id": ME, "number": "PRT-000001", "item_type": "part", "project_id": ROCKET,
458                            "project_code": "ROCKET", "revision_id": revision("in_work", true)["id"],
459                            "code": "A", "name": "Bracket <b>",
460                            "state": "in_work", "checked_out_by": ME, "checked_out_by_name": "Max Muster",
461                            "checked_out_at": TS, "updated_at": TS, "file_count": 2 });
462        let html = render(
463            &env,
464            "explorer.html",
465            with_common(json!({
466                "folders": [{ "id": ROCKET, "code": "ROCKET", "name": "Sounding rocket", "items": 3, "locked": 1 }],
467                "folder": ROCKET, "entries": [entry], "limit": 500, "q": "",
468                "selected": { "rev": revision("in_work", true), "files": [file.clone()],
469                              "file_groups": [{ "files": [file.clone()], "collapsed": false }],
470                              "can": { "checkout": false, "checkin": true, "cancel_checkout": true } },
471                "back": crate::util::pct_encode(&format!("/vault?folder={ROCKET}&rev={OTHER}")),
472                "max_upload_mb": 512,
473            })),
474        );
475        for expected in [
476            "Sounding rocket",
477            "Bracket &lt;b&gt;",
478            "Check in and release",
479            "/cancel-checkout?back=",
480            "?back=%2Fvault%3Ffolder%3D",
481            "enctype=\"multipart/form-data\"",
482            "class=\"selected\"",
483        ] {
484            assert!(html.contains(expected), "vault explorer shows {expected}");
485        }
486        let html = render(
487            &env,
488            "explorer.html",
489            with_common(
490                json!({ "folders": [], "folder": "mine", "entries": [], "limit": 500, "q": "",
491                                "selected": null, "back": "%2Fvault", "max_upload_mb": 512 }),
492            ),
493        );
494        assert!(html.contains("You have nothing checked out"));
495
496        // Save As and the BOM comparison
497        let html = render(
498            &env,
499            "revisions/save_as.html",
500            with_common(
501                json!({ "rev": revision("released", false), "projects": [project()],
502                                "file_count": 3, "line_count": 2 }),
503            ),
504        );
505        assert!(
506            html.contains("Copy the current files (3)") && html.contains("Copy the BOM (2 lines)")
507        );
508        assert!(html.contains("/save-as") && html.contains("ROCKET"));
509        let diff = |level: usize, change: &str, before: Value, after: Value| {
510            json!({ "level": level, "change": change, "child_item_id": OTHER, "child_number": "PRT-000002",
511                    "child_name": "Bolt <M6>", "unit": "each", "before": before, "after": after })
512        };
513        let values = |find: i32, qty: f64, rev: Value| json!({ "find_number": find, "quantity": qty, "notes": "", "revision": rev });
514        let diffs = json!([
515            diff(1, "added", Value::Null, values(10, 2.0, Value::Null)),
516            diff(1, "removed", values(20, 1.0, Value::Null), Value::Null),
517            diff(
518                1,
519                "changed",
520                values(30, 1.0, Value::Null),
521                values(30, 4.0, Value::Null)
522            )
523        ]);
524        let summary = json!({ "added": 1, "removed": 1, "changed": 1, "unchanged": 0 });
525        let html = render(
526            &env,
527            "revisions/bom_compare.html",
528            with_common(json!({
529                "rev": revision("in_work", false), "before": revision("released", false),
530                "after": revision("in_work", false), "revisions": [revision("in_work", false)],
531                "depth": "1", "rule": "latest", "diffs": diffs, "summary": summary,
532            })),
533        );
534        assert!(html.contains("1 added · 1 removed · 1 changed"));
535        assert!(html.contains("bom-diff-removed") && html.contains("Bolt &lt;M6&gt;"));
536        assert!(
537            !html.contains("<th scope=\"col\">Lvl</th>"),
538            "levels only over all levels"
539        );
540        let deep = render(
541            &env,
542            "revisions/bom_compare.html",
543            with_common(json!({
544                "rev": revision("in_work", false), "before": revision("released", false),
545                "after": revision("in_work", false), "revisions": [revision("in_work", false)],
546                "depth": "all", "rule": "as_released", "summary": summary,
547                "diffs": [diff(1, "changed", values(10, 1.0, json!("A")), values(10, 1.0, json!("B"))),
548                          diff(2, "added", Value::Null, values(10, 2.0, json!("A"))),
549                          diff(9, "removed", values(20, 1.0, Value::Null), Value::Null)],
550            })),
551        );
552        assert!(
553            deep.contains("<th scope=\"col\">Lvl</th>")
554                && deep.contains("indent-2")
555                && deep.contains("indent-deep")
556        );
557        assert!(
558            deep.contains("A → <strong>B</strong>"),
559            "a new child revision"
560        );
561        assert!(deep.contains("value=\"all\" selected"));
562
563        render(
564            &env,
565            "login.html",
566            json!({ "next": "/", "next_encoded": "%2F", "error": "bad", "oidc_name": "Single Sign-On",
567                                           "username": "max", "local_login": true, "flash": null }),
568        );
569        render(
570            &env,
571            "error.html",
572            json!({ "status": 404, "reason": "Not Found", "message": "gone", "flash": null, "local_login": true }),
573        );
574        let html = render(
575            &env,
576            "error.html",
577            json!({ "status": 404, "reason": "Not Found", "message": "gone", "flash": null, "local_login": true,
578                    "branding": { "wide": "aaaabbbbcccc", "square": null } }),
579        );
580        assert!(html.contains("/branding/wide?v=aaaabbbbcccc"));
581        assert!(!html.contains("TeamCraft_Logo_White.svg"));
582
583        let html = render(
584            &env,
585            "items/list.html",
586            with_common(json!({
587                "items": [{ "id": ME, "number": "PRT-000001", "item_type": "part", "unit": "each", "project_code": null,
588                            "created_at": TS, "latest_revision_id": OTHER, "latest_code": "A", "latest_state": "in_work",
589                            "name": "Bracket", "matched_file": "bracket.step", "classification": "purchased",
590                            "attributes": { "vendor": "Bosch" } }],
591                "pagination": page(), "base_query": "q=a%20b",
592                "filter": { "q": "a b", "type": "part", "project": "", "state": "", "system": "creo",
593                            "class": "purchased", "attr": "vendor", "value": "Bo" },
594                "filter_error": "Length: 'abc' is not a number.",
595                "projects": [project()], "types": [{ "value": "part", "label": "Part" }],
596                "states": [{ "value": "in_work", "label": "In Work" }],
597                "systems": crate::cadformat::SYSTEMS,
598                "classifications": [{ "value": "make", "label": "In-house" }, { "value": "purchased", "label": "Purchased" }],
599                "attribute_defs": [{ "key": "vendor", "label": "Vendor", "unit": "", "active": true }],
600            })),
601        );
602        assert!(
603            html.contains("<option value=\"creo\" selected>PTC Creo"),
604            "CAD program filter"
605        );
606        assert!(
607            html.contains("<option value=\"vendor\" selected>Vendor"),
608            "attribute filter"
609        );
610        assert!(html.contains("badge-member\" title=\"Classification\">purchased"));
611        assert!(
612            html.contains("name=\"class\" value=\"purchased\""),
613            "saved searches keep the class"
614        );
615        assert!(html.contains("is not a number"), "filter errors are shown");
616
617        let html = render(
618            &env,
619            "formats.html",
620            with_common(
621                json!({ "systems": crate::cadformat::catalogue(), "previews": [".stl", ".step"] }),
622            ),
623        );
624        for expected in [
625            "SolidWorks assembly",
626            ".sldasm",
627            "NX part",
628            "Creo part",
629            ".catproduct",
630            "JT visualisation",
631            ".x_t",
632            "/items?system=inventor",
633            "by content",
634        ] {
635            assert!(html.contains(expected), "formats page lists {expected}");
636        }
637        render(
638            &env,
639            "items/new.html",
640            with_common(json!({
641                "form": { "item_type": "part", "number": "", "name": "", "description": "", "project_id": "",
642                          "unit": "each", "material": "", "mass_kg": "", "unit_cost": "" },
643                "error": "Name must not be empty.", "projects": [project()], "materials": [],
644                "types": [{ "value": "part", "label": "Part" }], "units": units(),
645            })),
646        );
647        // new simulation based on a revision
648        let html = render(
649            &env,
650            "items/new.html",
651            with_common(json!({
652                "form": { "item_type": "simulation", "number": "", "name": "Simulation – Bracket",
653                          "description": "Analysis.", "project_id": "", "basis": OTHER,
654                          "basis_role": "geometry", "unit": "", "material": "", "mass_kg": "",
655                          "unit_cost": "" },
656                "error": null, "projects": [project()], "materials": [],
657                "types": [{ "value": "simulation", "label": "Simulation" }], "units": units(),
658                "basis": { "revision_id": OTHER, "item_number": "PRT-000002", "code": "A",
659                           "label": "PRT-000002/A", "name": "Bracket <1>" },
660                "basis_roles": [{ "value": "geometry", "label": "Geometry" }],
661                "basis_other_group": true, "number_hint": "SIM-000007",
662            })),
663        );
664        assert!(html.contains("PRT-000002") && html.contains("SIM-000007"));
665        assert!(html.contains("Bracket &lt;1&gt;"));
666        assert!(!html.contains("name=\"mass_kg\""));
667
668        // alternates, supersession and replace in assemblies
669        let linked = |number: &str, restricted: bool| {
670            json!({ "item_id": OTHER, "number": number, "item_type": "part", "name": "Bolt <M6>",
671                    "latest_revision_id": OTHER, "latest_code": "B", "latest_state": "released",
672                    "note": "second <source>", "created_by_name": "Erika", "created_at": TS,
673                    "two_way": true, "restricted": restricted })
674        };
675        let usage = |level: i32, state: &str| {
676            json!({ "level": level, "child_number": "PRT-000001", "line_id": ME, "parent_revision_id": OTHER,
677                    "parent_item_id": ME, "parent_number": "ASM-000001", "parent_code": "A",
678                    "parent_name": "Stage <1>", "parent_state": state, "find_number": 10, "quantity": 2.0,
679                    "released_child_code": null })
680        };
681        let plan_row = |action: &str, label: &str| {
682            json!({ "line_id": ME, "find_number": 10, "quantity": 4.0, "parent_item_id": ME,
683                    "parent_number": "ASM-000003", "parent_revision_id": OTHER, "parent_code": "B",
684                    "parent_name": "Frame <2>", "parent_state": "in_work", "checked_out_by_name": "Max",
685                    "action": action, "action_label": label })
686        };
687        let html = render(
688            &env,
689            "items/label.html",
690            with_common(json!({
691                "rev": revision("released", false),
692                "url": "https://plm.example.org/i/PRT-000001",
693            })),
694        );
695        // (MiniJinja escapes `/` in text as `&#x2f;`)
696        assert!(
697            html.contains("qr.svg") && html.contains("plm.example.org"),
698            "{html}"
699        );
700        for (with_plan, restricted) in [(true, false), (false, true)] {
701            let html = render(
702                &env,
703                "items/where_used.html",
704                with_common(json!({
705                    "item": item(), "latest": revision("released", false), "levels": 25,
706                    "level_options": [{ "value": 1, "label": "1 level" }, { "value": 25, "label": "All levels" }],
707                    "rows": [usage(1, "released"), usage(2, "in_work")], "truncated": with_plan, "hidden": 2,
708                    "successor": linked("PRT-000002", restricted), "alternates": [linked("PRT-000003", false)],
709                    "with": "PRT-000002",
710                    "plan": if with_plan { json!({
711                        "old_item_id": ME, "old_number": "PRT-000001", "new_item_id": OTHER,
712                        "new_number": "PRT-000002", "replaced": 1, "hidden": 1,
713                        "rows": [plan_row("replace", "will be replaced"),
714                                 plan_row("checked_out", "checked out by somebody else")],
715                    }) } else { Value::Null },
716                    "plan_error": if with_plan { Value::Null } else { json!("Item 'X' not <found>.") },
717                    "can_replace": true,
718                })),
719            );
720            assert!(html.contains("Stage &lt;1&gt;"), "names are escaped");
721            assert!(html.contains("+2 assemblies"), "hidden count");
722            assert_eq!(html.contains("Replace in 1 BOM line"), with_plan);
723            assert_eq!(html.contains("(Max)"), with_plan, "who has it checked out");
724            assert_eq!(html.contains("not &lt;found&gt;"), !with_plan);
725            assert_eq!(html.contains("Restricted item"), restricted);
726        }
727
728        for (state, checked_out) in [("in_work", true), ("in_work", false), ("released", false)] {
729            let source = json!({ "file_id": OTHER, "filename": "nozzle.step", "sha256": "cd".repeat(32), "format": "step" });
730            let similar = if checked_out {
731                json!({ "status": "pending", "message": "computing", "source": source,
732                        "signature": null, "ignore_scale": false, "candidates": [] })
733            } else {
734                json!({ "status": "ready", "message": null, "source": source, "ignore_scale": false,
735                        "signature": { "extents": [120.0, 40.0, 8.0], "area": 1.0, "volume": null, "closed": false, "triangles": 12 },
736                        "candidates": [{
737                            "item_id": ME, "item_number": "PRT-000007", "item_type": "part", "project_code": "ROCKET",
738                            "revision_id": OTHER, "revision_code": "B", "state": "released", "name": "Nozzle <i>",
739                            "file_id": OTHER, "filename": "old-nozzle.stl", "identical": false,
740                            "score": 91, "shape": 94, "size": 97, "extents": [118.0, 40.0, 8.0],
741                            "explanation": "extents 118 × 40 × 8 vs 120 × 40 × 8, shape distribution 94 % similar",
742                            "thumbnail_file_id": if state == "released" { json!(ME) } else { json!(null) },
743                        }] })
744            };
745            // a manager looking at an active member's lock: the reason form
746            let mut can_rev = can_all.clone();
747            can_rev["release_with_reason"] = json!(checked_out);
748            let html = render(
749                &env,
750                "revisions/show.html",
751                with_common(json!({
752                    "files_hint": files_hint(if state == "released" { "frozen" } else if checked_out { "upload" } else { "checkout" }),
753                    "rev": revision(state, checked_out), "item": item(), "revisions": [revision(state, checked_out)],
754                    "hidden": { "parents": 2, "incoming": 1, "changes": 1, "simulations": 0 },
755                    "is_latest": false, "latest_id": OTHER, "files": [file.clone(), native.clone(), unknown.clone()],
756                    "file_groups": [{ "files": [file.clone(), unknown.clone()], "collapsed": false },
757                                    { "files": [native.clone()], "collapsed": true, "count": 1, "families": "SolidWorks" }],
758                    "native_without_preview": state == "released", "lines": [bom_line.clone()],
759                    "where_used": [{ "line_id": ME, "parent_revision_id": OTHER, "parent_item_id": ME,
760                                     "parent_number": "ASM-000001", "parent_code": "A", "parent_name": "Assembly",
761                                     "parent_state": "released", "find_number": 10, "quantity": 2.0 }],
762                    "changes": [change("draft")], "open_change": change("draft"), "drafts": [change("draft")],
763                    "history": [audit_entry()], "projects": [project()], "units": units(),
764                    "references": references.clone(), "impact_groups": [impact_group()],
765                    "resolved_impacts": [impact(Some("acknowledged"))],
766                    "open_requests": [request("in_triage")], "similar": similar,
767                    "can": can_rev,
768                    "release_reason": { "min": 10, "max": 500 },
769                    "max_upload_mb": 512, "assistant_enabled": state == "released",
770                    "interchange": {
771                        "alternates": [linked("PRT-000003", false)],
772                        "alternate_for": [linked("PRT-000004", false)],
773                        "successor": linked("PRT-000002", false),
774                        "predecessors": [linked("PRT-000006", false)],
775                        "superseded": { "88888888-8888-4888-8888-888888888888": "PRT-000005" },
776                        "can_edit": checked_out,
777                    },
778                    "lifecycle": {
779                        "states": crate::domain::LifecycleState::ALL,
780                        "next": crate::domain::rules_from(crate::domain::LifecycleState::parse(state).unwrap()),
781                        "rules": crate::domain::LIFECYCLE_RULES,
782                    },
783                    "attribute_rows": [{ "key": "length", "label": "Length", "unit": "mm", "value": "12.5", "active": true },
784                                       { "key": "old", "label": "Old <code>", "unit": "", "value": "x", "active": false }],
785                    "classifications": [{ "value": "make", "label": "In-house" }, { "value": "purchased", "label": "Purchased" }],
786                })),
787            );
788            // preferred CAD program: the other program's file in a <details>
789            let (open, rest) = html
790                .split_once("<details class=\"other-formats\"")
791                .expect("collapsed group of other CAD formats");
792            assert!(open.contains("nozzle.step") && !open.contains(">nozzle.SLDPRT<"));
793            assert!(rest.contains("1 file in other CAD formats (SolidWorks)"));
794            assert!(rest.contains(">nozzle.SLDPRT<"));
795            assert_eq!(
796                html.contains("name=\"reason\" minlength=\"10\" maxlength=\"500\" required"),
797                checked_out,
798                "a manager releases an active member's lock with a reason"
799            );
800            assert!(
801                html.contains("aria-current=\"step\""),
802                "current lifecycle state is marked"
803            );
804            assert!(html.contains("<dd>12.5 mm</dd>") && html.contains("Old &lt;code&gt;"));
805            assert!(
806                html.contains("no longer in use"),
807                "inactive attributes are marked"
808            );
809            assert!(
810                html.contains("name=\"classification\""),
811                "classification in the master data"
812            );
813            for expected in [
814                "is superseded by",
815                "Superseded by PRT-000005",
816                "id=\"interchange\"",
817                "PRT-000003",
818                "may be replaced by",
819                "supersedes",
820                "second &lt;source&gt;",
821                "/where-used",
822            ] {
823                assert!(html.contains(expected), "revision page shows {expected}");
824            }
825            assert_eq!(
826                html.contains("Add alternate"),
827                checked_out,
828                "forms only for editors of the group"
829            );
830            assert!(html.contains("four-eyes principle"), "rules are listed");
831            if checked_out {
832                assert!(
833                    html.contains("Comparing the shape of nozzle.step"),
834                    "similar parts pending"
835                );
836            } else {
837                assert!(
838                    html.contains("PRT-000007/B")
839                        && html.contains("Nozzle &lt;i&gt;")
840                        && html.contains("shape distribution 94 % similar")
841                        && html.contains("value=\"91\""),
842                    "similar parts listed"
843                );
844            }
845            assert!(html.contains("Bracket &lt;b&gt;"), "names are escaped");
846            assert!(
847                html.contains("/files/") && html.contains("/restore"),
848                "rollback offered"
849            );
850            assert!(html.contains("Check in and release"), "check-in form");
851            assert!(
852                html.contains(&format!("/files/{OTHER}/preview"))
853                    && html.contains(&format!("/file-versions/{ME}/preview")),
854                "3D preview of the file and of its versions"
855            );
856            assert!(
857                html.contains("STEP (ISO 10303)")
858                    && html.contains("fmt fmt-exchange")
859                    && html.contains("SolidWorks part")
860                    && html.contains("fmt fmt-part"),
861                "format labels"
862            );
863            for expected in [
864                "Check needed:",
865                "/acknowledge",
866                "Drawing of",
867                "PRT-000009",
868                "released with",
869                "Welding spec",
870                "not in TeamCraft",
871                "Add to BOM",
872                "Hub.SLDDRW",
873                "Stage.SLDDRW references Stage.SLDASM",
874                "fit unchanged &lt;ok&gt;",
875                "Request change…",
876                "1 open change request on",
877                "file=nozzle.step",
878            ] {
879                assert!(html.contains(expected), "revision page shows {expected}");
880            }
881            assert!(
882                html.contains("x-unknown"),
883                "unknown formats show the content type"
884            );
885            assert_eq!(
886                html.contains("No 3D preview for native CAD files"),
887                state == "released",
888                "hint for native files without a neutral export"
889            );
890            // the sidebar: icon and drawer only when the assistant is enabled;
891            // the page declares where the user is (identifiers only)
892            assert_eq!(
893                html.contains("id=\"assistant-drawer\"")
894                    && html.contains("id=\"assistant-toggle\""),
895                state == "released",
896                "assistant sidebar only when enabled"
897            );
898            assert!(
899                html.contains("data-tc-kind=\"revision\"")
900                    && html.contains("data-tc-item=\"PRT-000001\"")
901                    && html.contains("data-tc-revision=\"B\""),
902                "the revision page declares its context"
903            );
904        }
905
906        // Simulations: basis links and results on the simulation, the
907        // simulations based on a part on the part
908        let basis = |out_of_date: Option<&str>| {
909            json!({ "id": ME, "revision_id": OTHER, "role": "geometry", "note": "fillet <r2>",
910                    "basis_item_id": OTHER, "basis_number": "PRT-000001", "basis_type": "part",
911                    "basis_revision_id": OTHER, "basis_code": "A", "basis_state": "released",
912                    "basis_name": "Bracket", "file_version_id": ME, "file_id": OTHER,
913                    "file_name": "bracket.step", "file_version": 2, "file_current_version": 3,
914                    "released_revision_id": ME, "released_code": "B", "created_at": TS,
915                    "out_of_date": out_of_date })
916        };
917        let sim_result = |name: &str, status: &str, upper: Option<f64>| {
918            json!({ "id": ME, "revision_id": OTHER, "position": 1, "name": name, "value": 182.5,
919                    "unit": "MPa", "lower_limit": null, "upper_limit": upper, "load_case": "LC1",
920                    "solver": "CalculiX", "solver_version": "2.21", "note": "", "updated_at": TS,
921                    "status": status })
922        };
923        let basis_roles: Vec<_> = crate::domain::BasisRole::ALL
924            .iter()
925            .map(|r| json!({ "value": r.as_str(), "label": r.label() }))
926            .collect();
927        for (state, checked_out) in [("in_work", false), ("released", false)] {
928            let mut rev = revision(state, checked_out);
929            rev["item_type"] = json!("simulation");
930            rev["item_number"] = json!("SIM-000001");
931            let mut sim_item = item();
932            sim_item["item_type"] = json!("simulation");
933            let editable = state == "in_work";
934            let html = render(
935                &env,
936                "revisions/show.html",
937                with_common(json!({
938                    "files_hint": files_hint(if editable { "upload" } else { "frozen" }),
939                    "rev": rev.clone(), "item": sim_item, "revisions": [rev.clone(), revision("released", false)],
940                    "hidden": { "parents": 0, "incoming": 0, "changes": 0, "simulations": 0 },
941                    "is_latest": true, "latest_id": OTHER, "files": [], "native_without_preview": false,
942                    "lines": [], "where_used": [], "changes": [], "open_change": null, "drafts": [],
943                    "history": [], "projects": [], "units": units(), "references": references.clone(),
944                    "impact_groups": [], "resolved_impacts": [],
945                    "simulation": {
946                        "bases": [basis(Some("PRT-000001/B was released (simulated: A)")), basis(None)],
947                        "results": [sim_result("Max. stress <vM>", "pass", Some(250.0)),
948                                    sim_result("Safety factor", "fail", None),
949                                    sim_result("Mass", "none", None)],
950                        "summary": { "total": 3, "pass": 1, "fail": 1, "without_limits": 1 },
951                        "out_of_date": 1, "roles": basis_roles.clone(),
952                        "views": [{ "id": ME, "filename": "beam <load>.vtu", "version": 2 }],
953                        "images": [{ "id": ME, "filename": "pressure <p>.png", "version": 1, "sha256": "ab" }],
954                        "can_decide": true,
955                        "proposals": [{ "id": OTHER, "revision_id": ME, "name": "Mass flow <p>", "value": 2.25, "unit": "kg/s",
956                                        "lower_limit": null, "upper_limit": 3.0, "load_case": "", "solver": "", "solver_version": "",
957                                        "source_file": "report <1>.pdf", "source_version": 2, "quote": "mass flow 2.25 kg/s",
958                                        "status": "pending", "proposed_by": ME, "proposed_by_name": "Erika", "created_at": TS }],
959                    },
960                    "based_simulations": [],
961                    "can": if editable { can_all.clone() } else { json!({}) }, "max_upload_mb": 512,
962                    "lifecycle": {
963                        "states": crate::domain::LifecycleState::ALL,
964                        "next": crate::domain::rules_from(crate::domain::LifecycleState::parse(state).unwrap()),
965                        "rules": crate::domain::LIFECYCLE_RULES,
966                    },
967                })),
968            );
969            for expected in [
970                "Based on",
971                "B was released (simulated: A)",
972                "bracket.step",
973                "fillet &lt;r2&gt;",
974                "Out of date",
975                "Up to date",
976                "Max. stress &lt;vM&gt;",
977                "badge badge-pass",
978                "badge badge-fail",
979                "1 failed",
980                "no limit",
981                "/simulation/compare",
982                "/simulation/results.csv",
983                "View result in 3D",
984                "beam &lt;load&gt;.vtu v2",
985                "class=\"sim-gallery\"",
986                "Proposed from reports",
987                "Mass flow &lt;p&gt;",
988                "report &lt;1&gt;.pdf",
989                "/simulation-proposals/",
990                "pressure &lt;p&gt;.png",
991            ] {
992                assert!(html.contains(expected), "simulation page shows {expected}");
993            }
994            assert!(!html.contains("id=\"bom\""), "no BOM card for simulations");
995            assert!(!html.contains("id=\"simulations\""));
996            assert_eq!(
997                html.contains("/simulation/results/import") && html.contains("/simulation/bases"),
998                editable,
999                "editing only while checked out"
1000            );
1001        }
1002        let html = render(
1003            &env,
1004            "revisions/show.html",
1005            with_common(json!({
1006                "files_hint": files_hint("hidden"),
1007                "rev": revision("released", false), "item": item(), "revisions": [revision("released", false)],
1008                "hidden": { "parents": 0, "incoming": 0, "changes": 0, "simulations": 1 },
1009                "is_latest": true, "latest_id": OTHER, "files": [], "native_without_preview": false,
1010                "lines": [], "where_used": [], "changes": [], "open_change": null, "drafts": [],
1011                "history": [], "projects": [], "units": units(), "references": references.clone(),
1012                "impact_groups": [], "resolved_impacts": [], "simulation": null,
1013                "based_simulations": [{
1014                    "simulation_item_id": OTHER, "simulation_number": "SIM-000001", "simulation_code": "A",
1015                    "simulation_state": "in_work", "simulation_name": "Bracket – static <LC1>",
1016                    "result_count": 3, "failed_count": 1, "open_impact_id": ME,
1017                    "basis": basis(Some("PRT-000001/B was released (simulated: A)")) }],
1018                "can": {}, "max_upload_mb": 512,
1019                "lifecycle": {
1020                    "states": crate::domain::LifecycleState::ALL,
1021                    "next": crate::domain::rules_from(crate::domain::LifecycleState::Released),
1022                    "rules": crate::domain::LIFECYCLE_RULES,
1023                },
1024            })),
1025        );
1026        for expected in [
1027            "id=\"simulations\"",
1028            "SIM-000001/A",
1029            "Bracket – static &lt;LC1&gt;",
1030            "1 of 3 failed",
1031            "check open",
1032            "Out of date",
1033        ] {
1034            assert!(html.contains(expected), "part page shows {expected}");
1035        }
1036        // As-built: build record and deviations of an as-built revision
1037        let lifecycle = |state: &str| {
1038            json!({
1039                "states": crate::domain::LifecycleState::ALL,
1040                "next": crate::domain::rules_from(crate::domain::LifecycleState::parse(state).unwrap()),
1041                "rules": crate::domain::LIFECYCLE_RULES,
1042            })
1043        };
1044        let kinds: Vec<_> = crate::domain::DeviationKind::ALL
1045            .iter()
1046            .map(|k| json!({ "value": k.as_str(), "label": k.label() }))
1047            .collect();
1048        let dispositions: Vec<_> = crate::domain::Disposition::ALL
1049            .iter()
1050            .map(|d| json!({ "value": d.as_str(), "label": d.label() }))
1051            .collect();
1052        let deviation = |feature: &str, disposition: &str| {
1053            json!({ "id": ME, "revision_id": OTHER, "position": 1, "kind": "dimension",
1054                    "feature": feature, "designed": "12.000 +0.018/0", "built": "12.035 <m>",
1055                    "disposition": disposition, "note": "", "created_by_name": "Erika",
1056                    "created_at": TS, "updated_at": TS })
1057        };
1058        for (state, restricted) in [("in_work", false), ("released", true)] {
1059            let mut rev = revision(state, false);
1060            rev["item_type"] = json!("as_built");
1061            rev["item_number"] = json!("AB-000001");
1062            let mut unit_item = item();
1063            unit_item["item_type"] = json!("as_built");
1064            let editable = state == "in_work";
1065            let html = render(
1066                &env,
1067                "revisions/show.html",
1068                with_common(json!({
1069                    "files_hint": files_hint(if editable { "upload" } else { "frozen" }),
1070                    "rev": rev.clone(), "item": unit_item, "revisions": [rev.clone()],
1071                    "hidden": { "parents": 0, "incoming": 0, "changes": 0, "simulations": 0, "units": 0 },
1072                    "is_latest": true, "latest_id": OTHER, "files": [], "native_without_preview": false,
1073                    "lines": [], "where_used": [], "changes": [], "open_change": null, "drafts": [],
1074                    "history": [], "projects": [], "units": units(), "references": references.clone(),
1075                    "impact_groups": [], "resolved_impacts": [], "simulation": null,
1076                    "based_simulations": [], "built_units": [], "can_be_built_to": false,
1077                    "as_built": {
1078                        "record": {
1079                            "revision_id": OTHER, "design_item_id": ME,
1080                            "design_number": if restricted { "restricted item" } else { "ASM-000003" },
1081                            "design_type": "assembly", "design_revision_id": ME, "design_code": "B",
1082                            "design_state": "released", "design_name": "Gearbox <g>",
1083                            "serial_number": "SN <0042>", "built_on": "2026-11-02", "note": "",
1084                            "updated_by_name": "Erika", "updated_at": TS, "released_code": "C",
1085                            "newer_design": if restricted { Value::Null } else { json!("ASM-000003/C is released (built to: B)") },
1086                            "restricted": restricted,
1087                        },
1088                        "deviations": [deviation("Bore <Ø12>", "open"), deviation("Flange", "scrap")],
1089                        "summary": { "total": 2, "open": 1, "use_as_is": 0, "rework": 0, "repair": 0, "scrap": 1 },
1090                        "kinds": kinds.clone(), "dispositions": dispositions.clone(),
1091                    },
1092                    "can": if editable { can_all.clone() } else { json!({}) }, "max_upload_mb": 512,
1093                    "lifecycle": lifecycle(state),
1094                })),
1095            );
1096            for expected in [
1097                "id=\"as-built\"",
1098                "Built to",
1099                "SN &lt;0042&gt;",
1100                "2026-11-02",
1101                "Deviations from the design",
1102                "1 open",
1103                "Bore &lt;Ø12&gt;",
1104                "12.035 &lt;m&gt;",
1105            ] {
1106                assert!(html.contains(expected), "as-built page shows {expected}");
1107            }
1108            assert_eq!(
1109                html.contains("ASM-000003/B") && html.contains("/bom/compare?with="),
1110                !restricted,
1111                "a hidden design is a placeholder"
1112            );
1113            assert_eq!(
1114                html.contains("/as-built/record") && html.contains("/as-built/deviations"),
1115                editable,
1116                "editing only while checked out"
1117            );
1118            assert!(!html.contains("id=\"built-units\""));
1119        }
1120        // the design side: units built to the item
1121        let html = render(
1122            &env,
1123            "revisions/show.html",
1124            with_common(json!({
1125                "files_hint": files_hint("frozen"),
1126                "rev": revision("released", false), "item": item(), "revisions": [revision("released", false)],
1127                "hidden": { "parents": 0, "incoming": 0, "changes": 0, "simulations": 0, "units": 2 },
1128                "is_latest": true, "latest_id": OTHER, "files": [], "native_without_preview": false,
1129                "lines": [{ "id": ME, "child_number": "PRT-000009", "child_latest_revision_id": OTHER, "child_name": "Screw", "child_code": "A" }], "where_used": [], "changes": [], "open_change": null, "drafts": [],
1130                "history": [], "projects": [{ "id": ME, "code": "ROCKET", "name": "Rocket" }],
1131                "units": units(), "references": references.clone(),
1132                "impact_groups": [], "resolved_impacts": [], "simulation": null, "as_built": null,
1133                "based_simulations": [], "can_be_built_to": true,
1134                "built_units": [{
1135                    "unit_item_id": OTHER, "unit_number": "AB-000001", "unit_revision_id": OTHER,
1136                    "unit_code": "A", "unit_state": "in_work", "unit_name": "Bracket – SN <7>",
1137                    "serial_number": "SN-7", "built_on": null, "design_revision_id": ME,
1138                    "design_code": "A", "deviation_count": 3, "open_count": 2,
1139                    "newer_design": "PRT-000001/B is released (built to: A)" }],
1140                "can": {}, "max_upload_mb": 512,
1141                "lifecycle": lifecycle("released"),
1142            })),
1143        );
1144        for expected in [
1145            "id=\"built-units\"",
1146            "AB-000001/A",
1147            "Bracket – SN &lt;7&gt;",
1148            "Older revision",
1149            "2 open",
1150            "+2 units in groups you cannot see",
1151            "Record a built unit",
1152            "Copy the BOM of B",
1153        ] {
1154            assert!(html.contains(expected), "part page shows {expected}");
1155        }
1156        let diff = |change: &str, before: Value, after: Value, delta: Value| {
1157            json!({ "change": change, "name": "Stress <max>", "load_case": "LC1", "unit": "MPa",
1158                    "before": before, "after": after, "delta": delta,
1159                    "delta_percent": if delta.is_null() { Value::Null } else { json!(-10.0) } })
1160        };
1161        let html = render(
1162            &env,
1163            "revisions/simulation_compare.html",
1164            with_common(json!({
1165                "rev": revision("in_work", false), "before": revision("released", false),
1166                "after": revision("in_work", false), "revisions": [revision("released", false)],
1167                "diffs": [diff("changed", sim_result("Stress", "fail", Some(150.0)),
1168                               sim_result("Stress", "pass", Some(250.0)), json!(-20.0)),
1169                          diff("added", Value::Null, sim_result("Mass", "none", None), Value::Null),
1170                          diff("removed", sim_result("Old", "pass", Some(1e9)), Value::Null, Value::Null)],
1171                "summary": { "added": 1, "removed": 1, "changed": 1, "unchanged": 0 },
1172            })),
1173        );
1174        assert!(html.contains("1 added · 1 removed · 1 changed · 0 unchanged"));
1175        assert!(html.contains("Stress &lt;max&gt;") && html.contains("-10 %"));
1176        assert!(html.contains("badge badge-fail") && html.contains("badge badge-pass"));
1177
1178        // 3D preview: the server delivers the mesh, the page only draws it
1179        let preview = |format: &str, is_current: bool| {
1180            let mut shown = version(1, "first <draft>");
1181            shown["id"] = json!(ME);
1182            with_common(json!({
1183                "file": file.clone(), "shown": shown, "is_current": is_current,
1184                "versions": [version(2, "wall 2.5 mm"), version(1, "first <draft>")],
1185                "rev": revision("released", false),
1186                "format": format, "is_brep": format == "step", "is_points": format == "las",
1187                "is_result": format == "vtk-unstructured",
1188            }))
1189        };
1190        let html = render(
1191            &env,
1192            "files/preview.html",
1193            preview("vtk-unstructured", true),
1194        );
1195        assert!(html.contains("coloured by a result field"), "result hint");
1196        let html = render(&env, "files/preview.html", preview("stl", true));
1197        assert!(html.contains(&format!("data-src=\"/file-versions/{ME}/mesh\"")));
1198        assert!(!html.contains("viewer-stl"), "no STL export of an STL file");
1199        assert!(html.contains("/static/shared/v/") && html.contains("/js/viewer.js\""));
1200        assert!(html.contains("first &lt;draft&gt;"), "comments are escaped");
1201        let html = render(&env, "files/preview.html", preview("step", false));
1202        assert!(html.contains("older version"));
1203        assert!(html.contains("tessellated"), "STEP hint");
1204        assert!(
1205            html.contains("id=\"viewer-stl\"") && html.contains("data-name=\"nozzle.step\""),
1206            "STL export of STEP models"
1207        );
1208        let html = render(&env, "files/preview.html", preview("las", true));
1209        assert!(
1210            !html.contains("viewer-stl"),
1211            "no STL export of a point cloud"
1212        );
1213        render(
1214            &env,
1215            "revisions/edit.html",
1216            with_common(
1217                json!({ "rev": revision("in_work", false), "error": null, "materials": [] }),
1218            ),
1219        );
1220        let html = render(
1221            &env,
1222            "revisions/edit.html",
1223            with_common(json!({
1224                "rev": revision("in_work", false), "error": null,
1225                "materials": [{ "name": "Alu <7075>", "label": "2810 kg/m³" }],
1226                "attribute_fields": [
1227                    { "key": "vendor", "label": "Vendor", "unit": "", "data_type": "choice",
1228                      "choices": ["Bosch", "SKF"], "value": "Old <Ltd>" },
1229                    { "key": "rohs", "label": "RoHS", "unit": "", "data_type": "boolean", "choices": [], "value": "yes" },
1230                    { "key": "length", "label": "Length", "unit": "mm", "data_type": "number", "choices": [], "value": "12.5" },
1231                ],
1232            })),
1233        );
1234        assert!(html.contains("name=\"attr.vendor\"") && html.contains("Length [mm]"));
1235        assert!(
1236            html.contains("<datalist id=\"material-options\"><option value=\"Alu &lt;7075&gt;\">2810 kg&#x2f;m³</option></datalist>")
1237                && html.contains("data-material-hint"),
1238            "material suggestions are escaped"
1239        );
1240        assert!(
1241            html.contains("Old &lt;Ltd&gt; (no longer a choice)"),
1242            "removed choices stay selectable"
1243        );
1244        assert!(html.contains("<option value=\"yes\" selected>"));
1245        render(
1246            &env,
1247            "revisions/edit.html",
1248            with_common(json!({
1249                "rev": revision("in_work", false), "error": "Mass: 'x' is not a number.", "mass_raw": "x", "cost_raw": "", "materials": []
1250            })),
1251        );
1252
1253        let node = json!({
1254            "line_id": ME, "level": 2, "find_number": 10, "quantity": 2.0, "notes": "", "item_id": OTHER,
1255            "item_number": "PRT-000002", "item_type": "part", "unit": "each",
1256            "revision": { "id": OTHER, "code": "A", "state": "released", "name": "Bolt", "mass_kg": 0.01, "unit_cost": 0.2 },
1257            "unit_mass_kg": 0.01, "total_mass_kg": 0.02, "unit_cost_total": 0.2, "total_cost": 0.4,
1258            "mass_complete": true, "cost_complete": false, "cycle": false, "children": []
1259        });
1260        let mut unresolved = node.clone();
1261        unresolved["revision"] = Value::Null;
1262        unresolved["level"] = json!(9);
1263        let child = |status: &str, label: &str| {
1264            json!({ "item_id": OTHER, "item_number": "PRT-000004", "item_name": "Bolt <M6>",
1265                    "quantity": 4.0, "quantity_text": "4", "bom_quantity_text": "2", "quantity_source": "cad_instances", "quantity_label": "CAD instances",
1266                    "via": "A.SLDASM → bolt.SLDPRT", "status": status, "status_label": label,
1267                    "bom_quantity": 2.0, "selected": status == "new" })
1268        };
1269        let from_refs = render(
1270            &env,
1271            "revisions/bom_from_references.html",
1272            with_common(json!({
1273                "rev": revision("in_work", false), "editable": true, "can_checkout": false, "differs": true,
1274                "plan": { "children": [child("new", "new line"), child("differs", "differs")],
1275                          "not_in_teamcraft": [{ "name": "ghost.SLDPRT", "via": "A.SLDASM" }],
1276                          "hidden": 2, "excluded": ["glue.SLDPRT"] },
1277            })),
1278        );
1279        assert!(from_refs.contains("Bolt &lt;M6&gt;") && from_refs.contains("BOM 2, CAD 4"));
1280        assert!(from_refs.contains("Not in TeamCraft") && from_refs.contains("2 hidden item(s)"));
1281        assert!(from_refs.contains("Add the ticked lines"));
1282        let not_out = render(
1283            &env,
1284            "revisions/bom_from_references.html",
1285            with_common(json!({
1286                "rev": revision("in_work", false), "editable": false, "can_checkout": true, "differs": false,
1287                "plan": { "children": [child("new", "new line")], "not_in_teamcraft": [], "hidden": 0, "excluded": [] },
1288            })),
1289        );
1290        assert!(not_out.contains("Check out and create"));
1291        let bom_html = render(
1292            &env,
1293            "revisions/bom.html",
1294            with_common(json!({
1295                "rev": revision("released", false), "rule": "released",
1296                "tree": { "total_mass_kg": 1.5, "total_cost": 10.0, "mass_complete": false, "cost_complete": true,
1297                          "line_count": 2, "unresolved_count": 1, "restricted_lines": 1,
1298                          "rollup_incomplete": true },
1299                "rows": [
1300                    { "level": 2, "indent": "\u{2003}", "has_children": false, "node": node },
1301                    { "level": 9, "indent": "", "has_children": false, "node": unresolved }
1302                ],
1303            })),
1304        );
1305        assert!(
1306            bom_html.contains("/package.zip?rule=released")
1307                && bom_html.contains("Pack and Go (ZIP)"),
1308            "Pack and Go with the selected rule"
1309        );
1310
1311        render(
1312            &env,
1313            "changes/list.html",
1314            with_common(json!({
1315                "changes": [change("in_review")], "status": "in_review",
1316                "statuses": [{ "value": "draft", "label": "Draft" }], "pagination": page(),
1317            })),
1318        );
1319        render(
1320            &env,
1321            "changes/new.html",
1322            with_common(json!({
1323                "form": { "title": "", "description": "", "priority": "normal", "revision_id": OTHER },
1324                "error": null, "priorities": ["low", "normal"], "revision": revision("in_work", false),
1325            })),
1326        );
1327        for status in ["draft", "in_review", "rejected", "released"] {
1328            let html = render(
1329                &env,
1330                "changes/show.html",
1331                with_common(json!({
1332                    "change": change(status), "status_label": "Draft",
1333                    "hidden_dependents": 1,
1334                    "implements": [request("accepted")],
1335                    "items": [{ "revision_id": OTHER, "item_id": ME, "item_number": "PRT-000001", "code": "A",
1336                                "name": "Bracket", "state": "in_work", "checked_out_by_name": "Erika" }],
1337                    "reviews": [{ "reviewer_id": OTHER, "reviewer_username": "erika", "reviewer_name": "Erika",
1338                                  "decision": "approved", "comment": "ok", "decided_at": TS, "absent": null,
1339                                  "sign_off_method": "password", "sign_off_meaning": "approved as <owner>" },
1340                                 { "reviewer_id": ME, "reviewer_username": "gone", "reviewer_name": "Gone <G>",
1341                                  "decision": "pending", "comment": "", "decided_at": null, "absent": "deactivated" }],
1342                    "replacements": [user()], "review_round": 2,
1343                    "earlier_rounds": [{ "round": 1, "reviewer_id": OTHER, "reviewer_username": "erika", "reviewer_name": "Erika",
1344                                         "decision": "rejected", "comment": "<too thin>", "decided_at": TS, "closed_at": TS }],
1345                    "candidates": [user()], "history": [audit_entry()], "priorities": ["low", "normal"],
1346                    "suggested": [{ "user_id": OTHER, "username": "max", "display_name": "Max <M>", "role": "editor",
1347                                    "score": 7, "reasons": ["edited PRT-000001 3 times in the last 90 days",
1348                                                            "manager of ROVER"] },
1349                                  { "user_id": ME, "username": "erika", "display_name": "Erika", "role": "editor",
1350                                    "score": 5, "cannot_review": true, "reasons": ["edited PRT-000001 once in the last 90 days"] }],
1351                    "impact": [{ "root_item_id": ME, "level": 1, "item_id": OTHER, "item_number": "ASM-000001",
1352                                 "item_name": "Stage", "revision_id": OTHER, "revision_code": "A",
1353                                 "revision_state": "released", "relation": "bom", "via_number": "PRT-000001",
1354                                 "in_change": false }],
1355                    "can": { "modify": true, "edit_text": true, "submit": true, "withdraw": true, "reopen": true,
1356                             "cancel": true, "review": true, "replace_reviewer": true, "take_over": true },
1357                    "sign_off": { "reauth": "release", "rejections_too": false, "sso": false,
1358                                  "reauthenticated": false, "window_minutes": 10 },
1359                })),
1360            );
1361            assert!(
1362                html.contains("name=\"password\"") && html.contains("name=\"meaning\""),
1363                "{status}: a fresh sign-in is asked for"
1364            );
1365            assert!(
1366                html.contains("signed off with password")
1367                    && html.contains("approved as &lt;owner&gt;"),
1368                "{status}: the sign-off of a decision"
1369            );
1370            assert!(
1371                html.contains("Earlier rounds")
1372                    && html.contains("Cannot decide: deactivated")
1373                    && html.contains("&lt;too thin&gt;")
1374                    && html.contains("/reviewers/"),
1375                "{status}: rounds and absent reviewers"
1376            );
1377            assert!(
1378                html.contains("/take-over\""),
1379                "{status}: a manager takes over a deactivated author's change"
1380            );
1381            if status == "in_review" {
1382                let mut ctx = json!({
1383                    "change": change(status), "status_label": "In review",
1384                    "reviews": [{ "reviewer_id": ME, "reviewer_username": "gone", "reviewer_name": "Gone <G>",
1385                                  "decision": "pending", "comment": "", "decided_at": null, "absent": "deactivated" }],
1386                    "replacements": [user()], "review_round": 1, "earlier_rounds": [],
1387                    "items": [], "candidates": [], "history": [], "priorities": ["low", "normal"],
1388                    "suggested": [], "impact": [],
1389                    "can": { "modify": false, "review": false, "replace_reviewer": true },
1390                });
1391                let html = render(&env, "changes/show.html", with_common(ctx.take()));
1392                assert!(
1393                    html.contains("/replace") && html.contains("Substitute for"),
1394                    "the replace form of an absent reviewer"
1395                );
1396            }
1397            assert!(
1398                html.contains("Impact analysis") && html.contains("uses PRT-000001 in its BOM"),
1399                "impact analysis on the change page"
1400            );
1401            assert!(
1402                html.contains("Implements") && html.contains("ECR-000001"),
1403                "the change page names the request it implements"
1404            );
1405            assert!(
1406                html.contains("Suggested reviewers")
1407                    && html.contains("Add Max &lt;M&gt;")
1408                    && html.contains("name=\"username\" value=\"max\"")
1409                    && html.contains(
1410                        "edited PRT-000001 3 times in the last 90 days; manager of ROVER"
1411                    ),
1412                "suggested reviewers with their reasons"
1413            );
1414        }
1415
1416        // change requests (ECR)
1417        let html = render(
1418            &env,
1419            "requests/list.html",
1420            with_common(json!({
1421                "requests": [request("new"), request("implemented")],
1422                "filter": { "status": "open", "scope": "mine", "item": "PRT-000001" },
1423                "statuses": crate::domain::RequestStatus::ALL.iter()
1424                    .map(|s| json!({ "value": s.as_str(), "label": s.label() })).collect::<Vec<_>>(),
1425                "base_query": "status=open&scope=mine&item=PRT-000001", "pagination": page(),
1426            })),
1427        );
1428        assert!(html.contains("ECR-000001") && html.contains("Crack &lt;at&gt; the weld"));
1429        assert!(html.contains("value=\"mine\" selected") && html.contains("In Triage"));
1430        let reasons = json!(crate::domain::RequestReason::ALL
1431            .iter()
1432            .map(|r| json!({ "value": r.as_str(), "label": r.label() }))
1433            .collect::<Vec<_>>());
1434        let html = render(
1435            &env,
1436            "requests/new.html",
1437            with_common(json!({
1438                "form": { "title": "", "description": "Found in nozzle.step:\n\n", "reason": "defect",
1439                          "priority": "normal", "due_date": "", "item_number": "", "revision_id": OTHER,
1440                          "note": "File: nozzle.step" },
1441                "error": "Title must not be empty.", "priorities": ["low", "normal"], "reasons": reasons,
1442                "revision": revision("released", false),
1443            })),
1444        );
1445        assert!(html.contains("PRT-000001/B") && html.contains("File: nozzle.step"));
1446        assert!(html.contains("value=\"defect\" selected") && html.contains("Raise request"));
1447        let request_item = json!({
1448            "item_id": ME, "item_number": "PRT-000001", "item_type": "part", "project_id": ROCKET,
1449            "project_code": "ROCKET", "revision_id": OTHER, "revision_code": "B", "revision_state": "released",
1450            "name": "Bracket <b>", "latest_revision_id": OTHER, "latest_code": "C", "latest_state": "in_work",
1451            "note": "weld seam" });
1452        let attachment = |ct: &str, name: &str| {
1453            json!({ "id": OTHER, "request_id": ME, "filename": name, "content_type": ct, "size_bytes": 2048,
1454                    "sha256": "ab".repeat(32), "uploaded_by": ME, "uploaded_by_name": "Max", "uploaded_at": TS })
1455        };
1456        let can_all = json!({ "edit": true, "start_triage": true, "decide": true, "assign": true, "reopen": true,
1457                              "withdraw": true, "implement": true, "comment": true, "attach": true });
1458        for status in [
1459            "new",
1460            "in_triage",
1461            "accepted",
1462            "rejected",
1463            "deferred",
1464            "implemented",
1465            "withdrawn",
1466        ] {
1467            let html = render(
1468                &env,
1469                "requests/show.html",
1470                with_common(json!({
1471                    "request": request(status), "status_label": "New", "reason_label": "Defect / error",
1472                    "hidden_changes": 1,
1473                    "items": [request_item.clone()], "in_work": [request_item.clone()],
1474                    "comments": [{ "id": OTHER, "author_id": OTHER, "author_name": "Erika",
1475                                   "body": "Seen on <three> parts", "created_at": TS }],
1476                    "attachments": [attachment("image/png", "crack.png"), attachment("application/pdf", "report.pdf")],
1477                    "images": [attachment("image/png", "crack.png")],
1478                    "changes": [change("draft")], "open_changes": [change("draft")], "candidates": [user()],
1479                    "history": [audit_entry()], "watching": false, "can": can_all.clone(),
1480                    "priorities": ["low", "normal"], "reasons": reasons.clone(), "max_attachment_mb": 10,
1481                })),
1482            );
1483            for expected in [
1484                "Crack &lt;at&gt; the weld",
1485                "Seen on &lt;three&gt; parts",
1486                "Create change order",
1487                "PRT-000001/C",
1488                "Link an open change order",
1489                "name=\"decision\" value=\"accept\"",
1490                "/request-attachments/",
1491                "?inline=1",
1492                "enctype=\"multipart/form-data\"",
1493                "CHG-000001",
1494                "weld seam",
1495            ] {
1496                assert!(
1497                    html.contains(expected),
1498                    "request page ({status}) shows {expected}"
1499                );
1500            }
1501            // deferred → deferred is no transition: no Defer button then
1502            assert_eq!(
1503                html.contains("name=\"decision\" value=\"defer\""),
1504                status != "deferred",
1505                "request page ({status}): Defer button"
1506            );
1507        }
1508
1509        let html = render(
1510            &env,
1511            "projects/list.html",
1512            with_common(json!({
1513                "projects": [project()], "my_projects": [ROCKET], "review_due": [],
1514                "manager_gaps": [
1515                    { "project_id": ROCKET, "code": "ROCKET", "name": "Sounding <rocket>", "kind": "none",
1516                      "active_managers": 0, "last_active_at": null },
1517                    { "project_id": OTHER, "code": "ROVER", "name": "Rover", "kind": "stale",
1518                      "active_managers": 2, "last_active_at": TS },
1519                ],
1520                "manager_gap_ids": [ROCKET, OTHER], "stale_days": 90,
1521            })),
1522        );
1523        assert!(html.contains("Groups without an active manager"));
1524        assert!(html.contains("Sounding &lt;rocket&gt; –") && html.contains("no active manager"));
1525        assert!(html.contains("2 active managers, none seen for 90 days"));
1526        assert!(html.contains("href=\"#manager-gaps\""), "the row is marked");
1527        let html = render(
1528            &env,
1529            "projects/list.html",
1530            with_common(json!({
1531                "projects": [project()], "my_projects": [ROCKET], "review_due": [],
1532                "manager_gaps": [], "manager_gap_ids": [], "stale_days": 90,
1533            })),
1534        );
1535        assert!(!html.contains("manager-gaps"));
1536        let html = render(
1537            &env,
1538            "projects/access_review.html",
1539            with_common(json!({
1540                "project_id": OTHER, "max_note": 1000,
1541                "report": { "project_code": "ROVER", "project_name": "Rover <x>", "generated_at": TS,
1542                    "admin_read": "with_reason", "administrators_outside": 2, "stale_days": 90,
1543                    "members": [{ "username": "erika", "display_name": "Erika", "role": "editor", "manager": true,
1544                                  "source": "oidc", "active": true, "added_at": TS, "last_login_at": null, "stale": true,
1545                                  "conditions_met": false }],
1546                    "conditions": { "required_claims": [{ "claim": "site", "values": ["a", "b"] }],
1547                                    "allowed_networks": [], "max_signin_age_hours": null },
1548                    "elevations": [{ "id": OTHER, "project_id": OTHER, "user_id": OTHER, "username": "root", "reason": "audit <b>",
1549                                    "started_at": TS, "expires_at": TS, "ended_at": null, "active": false }],
1550                    "reviews": [{ "id": OTHER, "username": "root", "note": "ok", "reviewed_at": TS,
1551                                  "admin_read": "none", "member_count": 1 }],
1552                    "schedule": { "interval_days": 90, "last_review_at": TS, "next_review_at": TS, "due": true } },
1553            })),
1554        );
1555        assert!(html.contains("no sign-in for 90 days") && html.contains("audit &lt;b&gt;"));
1556        assert!(html.contains("review of this group is due") && html.contains("every 90 days"));
1557        assert!(html.contains("is one of a, b") && html.contains("not met"));
1558        let entry = json!({ "id": OTHER, "at": TS, "user_id": OTHER, "username": "erika",
1559                            "channel": "assistant", "kind": "view", "object_type": "item",
1560                            "object_id": OTHER, "label": "PRT-1 <x>", "client_address": "192.0.2.4",
1561                            "detail": "get_bom" });
1562        let html = render(
1563            &env,
1564            "projects/access_log.html",
1565            with_common(json!({
1566                "project": project(), "entries": [entry],
1567                "query": { "user": "erika", "kind": "view", "channel": "assistant", "object": null,
1568                           "since": "2026-11-01", "until": null, "limit": null, "before": null },
1569                "next_link": "/projects/x/access-log?user=erika&before=y", "first_page_link": null,
1570                "filtered": true, "read_log": true, "error": null, "collapse_minutes": 5,
1571                "kinds": [{ "value": "view", "label": "View" }],
1572                "channels": [{ "value": "assistant", "label": "Assistant" }],
1573            })),
1574        );
1575        assert!(html.contains("PRT-1 &lt;x&gt;") && html.contains("get_bom"));
1576        assert!(html.contains("Older entries") && !html.contains("Newest entries"));
1577        assert!(html.contains("value=\"assistant\" selected"));
1578        let html = render(
1579            &env,
1580            "projects/access_log.html",
1581            with_common(json!({
1582                "project": project(), "entries": [], "query": {}, "next_link": null,
1583                "first_page_link": "/projects/x/access-log", "filtered": false, "read_log": false,
1584                "error": "since must be a date", "collapse_minutes": 5, "kinds": [], "channels": [],
1585            })),
1586        );
1587        assert!(
1588            html.contains("the log of this group is off") && html.contains("since must be a date")
1589        );
1590        let html = render(
1591            &env,
1592            "projects/show.html",
1593            with_common(json!({
1594                "project": project(),
1595                "members": [{ "user_id": OTHER, "username": "erika", "display_name": "Erika", "role": "editor",
1596                              "active": true, "source": "oidc", "added_at": TS, "manager": true, "checkouts": 0 },
1597                            { "user_id": "33333333-3333-4333-8333-333333333333", "username": "lena", "display_name": "Lena <gone>", "role": "editor",
1598                              "active": false, "source": "oidc", "added_at": TS, "manager": false, "checkouts": 40 }],
1599                "former_holders": [{ "user_id": OTHER, "username": "tom", "display_name": "Tom <left>",
1600                                     "active": true, "checkouts": 3 }],
1601                "candidates": [user()], "is_member": true, "can_edit": true, "oidc_enabled": true,
1602                "can_manage": true,
1603                "result_templates": [{ "id": OTHER, "project_id": OTHER, "name": "Pressure <drop>", "file_pattern": "*.log",
1604                                       "method": "regex", "pattern": "dp:\\s*([0-9.]+)", "csv_column": "", "csv_key_column": "",
1605                                       "csv_key_value": "", "pick": "last", "unit": "Pa", "lower_limit": null,
1606                                       "upper_limit": 500.0, "load_case": "", "solver": "", "enabled": true }],
1607                "numbering": { "scheme": "{PROJECT}-{TYPE}-{YYYY}-{SEQ:4}", "is_default": false,
1608                               "next": [{ "item_type": "part", "label": "Part", "number": "ROCKET-PRT-2026-0001" }] },
1609                "scheme_input": "{PROJECT}-{TYPE}-{YYYY}-{SEQ:4}", "previewing": true,
1610                "scheme_error": "Invalid numbering scheme 'x': <bad>", "default_scheme": "{TYPE}-{SEQ:6}",
1611            })),
1612        );
1613        assert!(html.contains("CAD editor"));
1614        assert!(html.contains("via IdP group"));
1615        assert!(html.contains("Pressure &lt;drop&gt;") && html.contains("/result-templates/"));
1616        assert!(html.contains("Add template"), "managers add templates");
1617        assert!(html.contains("manager"), "project managers are marked");
1618        assert!(html.contains("Add member"), "managers add members");
1619        assert!(html.contains("/demote\""), "managers demote managers");
1620        assert!(
1621            html.contains("/season\""),
1622            "managers find the season handover"
1623        );
1624        let season = render(
1625            &env,
1626            "projects/season.html",
1627            with_common(json!({
1628                "project": project(),
1629                "members": [
1630                    { "user_id": OTHER, "username": "erika", "display_name": "Erika", "role": "editor",
1631                      "active": true, "source": "oidc", "manager": true, "checkouts": 0,
1632                      "last_active_at": TS, "expires_at": null, "expired": false },
1633                    { "user_id": ME, "username": "lena", "display_name": "Lena <gone>", "role": "editor",
1634                      "active": false, "source": "manual", "manager": false, "checkouts": 40,
1635                      "last_active_at": null, "expires_at": "2026-07-31", "expired": true },
1636                ],
1637                "chosen": [ME], "inactive_since": "2026-08-01",
1638                "release_reason": { "min": 10, "max": 500 }, "max_members": 500,
1639            })),
1640        );
1641        assert!(season.contains("Lena &lt;gone&gt;") && season.contains("expired"));
1642        assert!(season.contains(&format!("name=\"release\" value=\"{ME}\"")));
1643        assert!(
1644            season.contains(&format!(
1645                "name=\"remove\" value=\"{ME}\" aria-label=\"Remove lena\" checked"
1646            )),
1647            "a member whose membership expired is ticked"
1648        );
1649        assert!(
1650            !season.contains(&format!(
1651                "name=\"remove\" value=\"{OTHER}\" aria-label=\"Remove erika\" checked"
1652            )),
1653            "an active member is not"
1654        );
1655        assert!(season.contains("value=\"2026-08-01\""));
1656        assert!(
1657            html.contains("Release 40 check-outs") && html.contains("Lena &lt;gone&gt;"),
1658            "a deactivated member's check-outs can be released"
1659        );
1660        assert!(
1661            html.contains("/members/33333333-3333-4333-8333-333333333333/remove\""),
1662            "a deactivated member's IdP membership can be removed"
1663        );
1664        assert!(
1665            !html.contains(&format!("/members/{OTHER}/remove\"")),
1666            "an active member's IdP membership cannot"
1667        );
1668        assert!(
1669            html.contains("Former members still holding") && html.contains("Release 3 check-outs"),
1670            "former holders are listed"
1671        );
1672        assert!(
1673            html.contains("ROCKET-PRT-2026-0001"),
1674            "next numbers are shown"
1675        );
1676        assert!(html.contains("preview – not saved"));
1677        assert!(html.contains("value=\"{PROJECT}-{TYPE}-{YYYY}-{SEQ:4}\""));
1678        assert!(html.contains("&lt;bad&gt;"), "scheme errors are escaped");
1679        assert!(html.contains("/numbering\""), "managers edit the scheme");
1680        let html = render(
1681            &env,
1682            "projects/show.html",
1683            with_common(json!({
1684                "project": project(), "members": [], "candidates": [], "is_member": true,
1685                "can_edit": true, "oidc_enabled": false, "can_manage": true, "readable": true,
1686                "result_templates": [],
1687                "policy": { "admin_read": "implicit", "access": { "read_log": true, "log_retention_days": 90,
1688                    "review_interval_days": 120,
1689                    "interfaces": { "api_tokens": true, "mcp": false, "assistant": true, "webhooks": true, "packages": true } },
1690                    "conditions": { "required_claims": [{ "claim": "site", "values": ["main-office"] }],
1691                                    "allowed_networks": ["10.0.0.0/8", "192.168.1.0/24"], "max_signin_age_hours": 12 } },
1692                "review_schedule": { "interval_days": 120, "last_review_at": null, "next_review_at": TS, "due": true },
1693                "claim_lines": "site = main-office, <x>", "my_address": "10.1.2.3",
1694                "admin_modes": [{ "value": "implicit", "label": "Implicit" }],
1695                "interface_rows": [{ "name": "mcp", "label": "MCP <server>", "allowed": false }],
1696                "access_entries": [{ "id": OTHER, "at": TS, "username": "erika", "channel": "api", "kind": "download",
1697                    "label": "<b>secret.step</b>", "client_address": "10.0.0.1", "detail": "" }],
1698                "numbering": { "scheme": "{TYPE}-{SEQ:6}", "is_default": true, "next": [] },
1699                "scheme_input": "", "previewing": false, "scheme_error": null, "default_scheme": "{TYPE}-{SEQ:6}",
1700            })),
1701        );
1702        assert!(
1703            html.contains("/access\""),
1704            "managers edit the interfaces and the log"
1705        );
1706        assert!(html.contains("MCP &lt;server&gt;"));
1707        assert!(
1708            html.contains("&lt;b&gt;secret.step"),
1709            "log labels are escaped"
1710        );
1711        assert!(html.contains("access review due") && html.contains("value=\"120\""));
1712        assert!(html.contains("/access-log\""), "the whole log is linked");
1713        assert!(html.contains("/conditions\"") && html.contains("main-office, &lt;x&gt;"));
1714        assert!(html.contains("10.1.2.3") && html.contains("value=\"12\""));
1715        let html = render(
1716            &env,
1717            "admin/users.html",
1718            with_common(json!({
1719                "users": [user()], "roles": roles(),
1720                "oidc": { "admin_group": "a", "editor_group": "e", "viewer_group": null },
1721                "idle_sso_days": 90,
1722                "idle_sso": [{
1723                    "user_id": ME, "username": "lena", "display_name": "Lena <L>",
1724                    "last_login_at": null, "last_active_at": TS, "idp_logout_at": TS,
1725                    "memberships": 2, "tokens": 1, "checkouts": 40,
1726                }],
1727            })),
1728        );
1729        assert!(html.contains("SSO users without sign-in") && html.contains("90 days"));
1730        assert!(html.contains("Lena &lt;L&gt;"), "names are escaped");
1731        assert!(html.contains(&format!("/users/{ME}/holdings\"")));
1732        assert!(html.contains(">never<") && html.contains(">40<"));
1733        let html = render(
1734            &env,
1735            "admin/user_edit.html",
1736            with_common(json!({
1737                "user": user(), "roles": roles(), "oidc_enabled": false, "memberships": [project()],
1738                "handover_targets": [user()], "held_checkouts": 40, "sole_manager_of": ["CHASSIS"],
1739                "tokens": [{ "id": ME, "name": "ci", "token_prefix": "tc_pat_abc123", "created_at": TS,
1740                             "last_used_at": null, "expires_at": TS, "scope": "full", "idle_days": null },
1741                           { "id": ME, "name": "desktop", "token_prefix": "tc_pat_def456", "created_at": TS,
1742                             "last_used_at": TS, "expires_at": TS, "scope": "read_only", "idle_days": 30 }],
1743            })),
1744        );
1745        assert!(html.contains("/offboard\"") && html.contains("only active manager of CHASSIS"));
1746        assert!(html.contains("name=\"confirm_no_manager\"") && html.contains("API tokens (2)"));
1747        let html = render(
1748            &env,
1749            "admin/holdings.html",
1750            with_common(json!({
1751                "nothing": false,
1752                "h": {
1753                    "user_id": OTHER, "username": "lena", "display_name": "Lena <left>", "active": false,
1754                    "last_login_at": null, "scope": "all",
1755                    "checkouts": { "total": 40, "shown": [{ "revision_id": OTHER, "item_number": "PRT-000007",
1756                        "code": "B", "name": "Upright <x>", "project_code": "CHASSIS", "checked_out_at": TS }] },
1757                    "hidden_checkouts": [{ "project_code": "NTK", "count": 2 }],
1758                    "authored_changes": { "total": 1, "shown": [{ "id": OTHER, "number": "CO-0031",
1759                        "title": "Stiffer upright", "status": "rejected" }] },
1760                    "pending_reviews": { "total": 0, "shown": [] },
1761                    "assigned_requests": { "total": 1, "shown": [{ "id": OTHER, "number": "CR-0004",
1762                        "title": "Crack", "status": "in_triage" }] },
1763                    "raised_requests": { "total": 0, "shown": [] },
1764                    "owned_items": { "total": 1, "shown": [{ "id": OTHER, "number": "PRT-000007",
1765                        "name": "Upright", "project_code": null }] },
1766                    "memberships": [{ "project_id": OTHER, "project_code": "CHASSIS", "project_name": "Chassis",
1767                        "source": "oidc", "manager": true, "only_active_manager": true }],
1768                    "tokens": [{ "id": ME, "name": "desktop", "token_prefix": "tc_pat_def456", "created_at": TS,
1769                        "last_used_at": null, "expires_at": TS, "scope": "full", "idle_days": 30 }],
1770                },
1771            })),
1772        );
1773        assert!(html.contains("Lena &lt;left&gt;") && html.contains("Upright &lt;x&gt;"));
1774        assert!(html.contains("The first 1 of 40") && html.contains("the only active manager"));
1775        assert!(html.contains("2 check-outs in") && html.contains("CO-0031"));
1776        let html = render(
1777            &env,
1778            "admin/holdings.html",
1779            with_common(json!({
1780                "nothing": true,
1781                "h": {
1782                    "user_id": OTHER, "username": "tom", "display_name": "Tom", "active": true,
1783                    "last_login_at": TS, "scope": "managed",
1784                    "checkouts": { "total": 0, "shown": [] }, "hidden_checkouts": [],
1785                    "authored_changes": { "total": 0, "shown": [] },
1786                    "pending_reviews": { "total": 0, "shown": [] },
1787                    "assigned_requests": { "total": 0, "shown": [] },
1788                    "raised_requests": { "total": 0, "shown": [] },
1789                    "owned_items": { "total": 0, "shown": [] },
1790                    "memberships": [], "tokens": null,
1791                },
1792            })),
1793        );
1794        assert!(html.contains("holds nothing that blocks") && !html.contains("API tokens"));
1795        assert!(html.contains("Only what lies in the project groups you manage"));
1796        render(
1797            &env,
1798            "admin/audit.html",
1799            with_common(json!({
1800                "entries": [audit_entry()], "filter": { "entity_type": "item", "username": null, "action": null },
1801                "base_query": "entity_type=item", "pagination": page(), "per_page": 50,
1802            })),
1803        );
1804        // mass properties: a full report, editable and read-only settings
1805        let mass_line = |source: &str, warnings: Value| {
1806            json!({ "level": 1, "find_number": 10, "item_number": "PRT-000002",
1807                    "revision_id": OTHER, "revision_code": "A", "name": "Fin <x>", "quantity": 4.0,
1808                    "factor": 1.0, "source": source, "source_label": source,
1809                    "detail": "fin.stl (mm) × 2810 kg/m³ (Aluminium 7075-T6)",
1810                    "unit_mass_kg": 0.0425, "total_mass_kg": 0.17, "cg_m": [1.2, 0.0, 1e-9],
1811                    "warnings": warnings })
1812        };
1813        let report = json!({
1814            "rule": "latest", "root_revision_id": ME, "root_label": "ASM-000001/A", "root_name": "Vehicle",
1815            "root_source": "assembly", "root_detail": "", "reference_m": [0.0, 0.0, 0.0],
1816            "mass_kg": 12.5, "placed_mass_kg": 12.4, "unplaced_mass_kg": 0.1,
1817            "cg_m": [1.234567, 0.0001, -0.002], "inertia_cg": [0.1, 5.2, 5.3, 1e-6, 0.0, -2e-4],
1818            "inertia_ref": [0.1, 24.0, 24.1, 1e-6, 0.0, -2e-4], "principal_moments": [5.3, 5.2, 0.1],
1819            "lines": [mass_line("computed", json!([])), mass_line("missing", json!(["no density <x>"])),
1820                      mass_line("restricted", json!([]))],
1821            "line_count": 3, "restricted_lines": 1, "missing_lines": 1, "complete": false,
1822            "warnings": ["1 restricted line left out."], "lines_with_warnings": 1,
1823        });
1824        let geometry_file = json!({ "id": OTHER, "revision_id": ME, "filename": "fin.stl", "sha256": "ab",
1825                                    "format": "stl", "length_unit": null, "instances": 4 });
1826        for (editable, measured) in [(true, true), (false, false)] {
1827            let settings = json!({
1828                "settings": { "revision_id": ME, "density_kg_m3": if measured { json!(2810.0) } else { Value::Null },
1829                              "geometry_file_id": OTHER,
1830                              "measured_mass_kg": if measured { json!(1.5) } else { Value::Null },
1831                              "measured_cg_m": if measured { json!([1.0, 0.0, 0.0]) } else { Value::Null },
1832                              "measured_inertia_kg_m2": null, "measured_note": "scale <A>" },
1833                "files": [geometry_file.clone()], "geometry_file_id": OTHER,
1834                "material": if measured { json!({ "id": OTHER, "name": "AlMg3", "density_kg_m3": 2660.0,
1835                                                  "notes": "", "created_at": TS, "updated_at": TS }) } else { Value::Null },
1836            });
1837            let html = render(
1838                &env,
1839                "revisions/mass.html",
1840                with_common(json!({
1841                    "rev": revision("in_work", editable), "rule": "latest", "report": report,
1842                    "reference": "0,0,0", "reference_error": if editable { Value::Null } else { json!("bad point") },
1843                    "settings": settings, "chosen": geometry_file, "chosen_unit": "mm",
1844                    "units": [{ "value": "mm" }, { "value": "m" }, { "value": "in" }],
1845                    "measured_cg": if measured { json!(["1", "0", "0"]) } else { json!([]) },
1846                    "measured_inertia": [], "editable": editable, "is_admin": false,
1847                })),
1848            );
1849            assert!(html.contains("Fin &lt;x&gt;") && html.contains("no density &lt;x&gt;"));
1850            assert!(html.contains("1.23457") && html.contains("mass.csv?rule=latest"));
1851            assert_eq!(html.contains("Save mass settings"), editable);
1852        }
1853        let empty_report = json!({
1854            "rule": "latest", "root_revision_id": ME, "root_label": "PRT-000001/B", "root_name": "Bracket",
1855            "root_source": "missing", "root_detail": "", "reference_m": [0.0, 0.0, 0.0],
1856            "mass_kg": 0.0, "placed_mass_kg": 0.0, "unplaced_mass_kg": 0.0, "cg_m": null,
1857            "inertia_cg": null, "inertia_ref": null, "principal_moments": null, "lines": [],
1858            "line_count": 0, "restricted_lines": 0, "missing_lines": 0, "complete": false,
1859            "warnings": [], "lines_with_warnings": 0,
1860        });
1861        let html = render(
1862            &env,
1863            "revisions/mass.html",
1864            with_common(json!({
1865                "rev": revision("released", false), "rule": "released", "report": empty_report,
1866                "reference": "0,0,0", "reference_error": null,
1867                "settings": { "settings": { "revision_id": ME, "density_kg_m3": null, "geometry_file_id": null,
1868                                            "measured_mass_kg": null, "measured_cg_m": null,
1869                                            "measured_inertia_kg_m2": null, "measured_note": "" },
1870                              "files": [], "geometry_file_id": null, "material": null },
1871                "chosen": null, "chosen_unit": null, "units": [], "measured_cg": [], "measured_inertia": [],
1872                "editable": false, "is_admin": false,
1873            })),
1874        );
1875        assert!(html.contains("No placed mass"));
1876        // files of several parts: the warning, the one-click choice (only
1877        // when editable) and the grouped selector
1878        for editable in [true, false] {
1879            let candidate = |id: &str, name: &str, likely: bool| json!({ "file_id": id, "filename": name, "format": "jt", "likely": likely, "used": false });
1880            let geometry = json!({
1881                "choice": "ambiguous", "file": null, "file_id": null, "other_formats": 0,
1882                "models": 2, "files": 3, "likely_file": "asm <1>.jt", "likely_file_id": OTHER,
1883                "candidates": [
1884                    { "model": "asm <1>", "files": [candidate(OTHER, "asm <1>.jt", true)] },
1885                    { "model": "fin", "files": [candidate(ME, "fin.jt", false)] },
1886                ],
1887            });
1888            let html = render(
1889                &env,
1890                "revisions/mass.html",
1891                with_common(json!({
1892                    "rev": revision("in_work", editable), "rule": "latest", "report": empty_report,
1893                    "reference": "0,0,0", "reference_error": null,
1894                    "settings": { "settings": { "revision_id": ME, "density_kg_m3": null, "geometry_file_id": null,
1895                                                "measured_mass_kg": null, "measured_cg_m": null,
1896                                                "measured_inertia_kg_m2": null, "measured_note": "" },
1897                                  "files": [], "geometry_file_id": null, "geometry": geometry,
1898                                  "material": null },
1899                    "chosen": null, "chosen_unit": null, "units": [], "measured_cg": [], "measured_inertia": [],
1900                    "editable": editable, "is_admin": false,
1901                })),
1902            );
1903            assert!(html.contains("Geometry ambiguous") && html.contains("asm &lt;1&gt;.jt"));
1904            assert_eq!(html.contains("/mass/geometry"), editable);
1905            assert_eq!(html.contains("<optgroup label=\"fin\">"), editable);
1906        }
1907        for is_admin in [true, false] {
1908            let html = render(
1909                &env,
1910                "materials.html",
1911                with_common(json!({
1912                    "materials": [{ "id": OTHER, "name": "Aluminium <7075>", "density_kg_m3": 2810.0,
1913                                    "notes": "handbook", "created_at": TS, "updated_at": TS }],
1914                    "is_admin": is_admin,
1915                })),
1916            );
1917            assert!(html.contains("Aluminium &lt;7075&gt;"));
1918            assert_eq!(html.contains("Add material"), is_admin);
1919        }
1920        render(
1921            &env,
1922            "materials.html",
1923            with_common(json!({ "materials": [], "is_admin": false })),
1924        );
1925        let attr_def = json!({
1926            "id": ME, "key": "vendor", "label": "Vendor <Lieferant>", "data_type": "choice",
1927            "unit": "", "choices": ["Bosch", "SKF"], "item_types": ["part"], "position": 10,
1928            "active": true, "created_at": TS,
1929        });
1930        let html = render(
1931            &env,
1932            "admin/attributes.html",
1933            with_common(json!({
1934                "rows": [{ "def": attr_def, "type_label": "Choice list" }],
1935                "data_types": [{ "value": "text", "label": "Text" }],
1936                "item_types": [{ "value": "part", "label": "Part", "checked": false }],
1937                "next_position": 20,
1938            })),
1939        );
1940        assert!(html.contains("Vendor &lt;Lieferant&gt;") && html.contains("2 choices"));
1941        let html = render(
1942            &env,
1943            "admin/attribute_edit.html",
1944            with_common(json!({
1945                "def": attr_def, "type_label": "Choice list", "choices_text": "Bosch\nSKF",
1946                "item_types": [{ "value": "part", "label": "Part", "checked": true }], "in_use": 3,
1947            })),
1948        );
1949        assert!(html.contains("3 revisions") && html.contains("Bosch\nSKF"));
1950        assert!(html.contains("value=\"part\" checked"));
1951        let webhook = json!({
1952            "id": ME, "name": "ERP <export>", "url": "https://erp.example.org/in?token=x",
1953            "secret_hint": "tc_whsec_…a1b2", "events": ["revision.released", "change.released"],
1954            "project_id": OTHER, "project_code": "ROVER", "active": true, "created_by": ME,
1955            "created_by_name": "Max", "created_at": TS, "updated_at": TS, "secret_rotated_at": TS,
1956            "pending": 2, "failed_recently": 1, "last_status": "failed", "last_delivery_at": TS,
1957            "host": "erp.example.org",
1958        });
1959        let events = json!(crate::domain::WebhookEvent::ALL
1960            .iter()
1961            .map(|e| json!({ "value": e.as_str(), "label": e.label(),
1962                             "on": *e == crate::domain::WebhookEvent::ChangeReleased }))
1963            .collect::<Vec<_>>());
1964        let projects = json!([{ "id": OTHER, "code": "ROVER", "name": "Planetary rover" }]);
1965        let html = render(
1966            &env,
1967            "admin/webhooks.html",
1968            with_common(json!({
1969                "webhooks": [webhook.clone()], "events": events.clone(), "projects": projects.clone(),
1970                "targets": { "allowed_hosts": [], "allow_private": false },
1971            })),
1972        );
1973        for expected in [
1974            "ERP &lt;export&gt;",
1975            "badge badge-failed",
1976            "1 failed in 7 days",
1977            "value=\"simulation.result_added\"",
1978            "TEAMCRAFT_WEBHOOK_ALLOWED_HOSTS",
1979        ] {
1980            assert!(html.contains(expected), "webhook list shows {expected}");
1981        }
1982        assert!(!html.contains("token=x"), "the list shows the host only");
1983        let delivery = |status: &str, attempts: i32, code: Option<i32>, error: &str| {
1984            json!({
1985                "id": OTHER, "webhook_id": ME, "event_id": ME, "event": "change.released",
1986                "payload": {}, "payload_text": "{\n  \"event\": \"change.released\" <x>\n}",
1987                "status": status, "attempts": attempts, "next_attempt_at": TS,
1988                "last_status_code": code, "last_error": error, "created_at": TS, "updated_at": TS,
1989                "delivered_at": if status == "sent" { json!(TS) } else { json!(null) },
1990            })
1991        };
1992        let html = render(
1993            &env,
1994            "admin/branding.html",
1995            with_common(json!({
1996                "slots": [
1997                    { "kind": "wide", "label": "Wide logo", "custom": "aaaabbbbcccc", "hint": "h" },
1998                    { "kind": "square", "label": "Square logo", "custom": null, "hint": "h" },
1999                ],
2000                "max_kib": 512, "max_pixels": 4096,
2001            })),
2002        );
2003        assert!(
2004            html.contains("/branding/wide?v=aaaabbbbcccc"),
2005            "custom wide logo in the header"
2006        );
2007        assert!(
2008            html.contains("/admin/branding/wide/reset"),
2009            "reset offered for a custom logo"
2010        );
2011        assert!(
2012            !html.contains("/admin/branding/square/reset"),
2013            "no reset for the built-in logo"
2014        );
2015        // Admin → AI in every mode: provider and model shown, the key never
2016        let company_ai = crate::config::AssistantConfig {
2017            provider: crate::config::AssistantProvider::Anthropic,
2018            base_url: "https://api.anthropic.com/v1".into(),
2019            api_key: Some("sk-ant-never-shown".into()),
2020            model: "claude-opus-5-5".into(),
2021            max_steps: 8,
2022            max_tokens: 4096,
2023            request_timeout: std::time::Duration::from_secs(60),
2024            run_timeout: std::time::Duration::from_secs(180),
2025            max_concurrent: 4,
2026            retention_days: 30,
2027            internal_urls: Vec::new(),
2028        };
2029        use crate::config::LocalAgents;
2030        // the notice, and what the desktop app's Agent view runs on
2031        for (agents, assistant, says, desktop) in [
2032            (
2033                LocalAgents::Off,
2034                Some(&company_ai),
2035                "Company AI only",
2036                "company AI (no local option)",
2037            ),
2038            (
2039                LocalAgents::Off,
2040                None,
2041                "users have no AI in TeamCraft",
2042                "a notice that AI is switched off",
2043            ),
2044            (
2045                LocalAgents::Allowed,
2046                Some(&company_ai),
2047                "TEAMCRAFT_LOCAL_AGENTS=off",
2048                "the user chooses: company AI or local agent",
2049            ),
2050            (
2051                LocalAgents::Allowed,
2052                None,
2053                "No assistant is configured",
2054                "<dd>local agent</dd>",
2055            ),
2056        ] {
2057            let html = render(
2058                &env,
2059                "admin/ai.html",
2060                with_common(crate::services::assistant::admin_overview(
2061                    agents, assistant,
2062                )),
2063            );
2064            assert!(html.contains(says), "{agents:?}: {says}");
2065            assert!(html.contains(desktop), "{agents:?}: {desktop}");
2066            assert!(!html.contains("sk-ant-never-shown"), "the key leaked");
2067            if assistant.is_some() {
2068                assert!(html.contains("claude-opus-5-5"), "model shown");
2069                assert!(html.contains("api.anthropic.com"), "host shown");
2070                assert!(html.contains("set (not shown)"));
2071            }
2072        }
2073        let html = render(
2074            &env,
2075            "admin/webhook_edit.html",
2076            with_common(json!({
2077                "webhook": webhook, "host": "erp.example.org", "secret": "tc_whsec_0123",
2078                "events": events, "projects": projects, "max_attempts": 8, "retention_days": 30,
2079                "targets": { "allowed_hosts": ["erp.example.org"], "allow_private": false },
2080                "deliveries": [
2081                    delivery("sent", 1, Some(200), ""),
2082                    delivery("pending", 2, Some(503), "HTTP 503"),
2083                    delivery("failed", 8, None, "connection failed: refused"),
2084                ],
2085            })),
2086        );
2087        for expected in [
2088            "tc_whsec_0123",
2089            "data-copy=\"webhook-secret\"",
2090            "tc_whsec_…a1b2",
2091            "badge badge-retrying",
2092            "badge badge-failed",
2093            "connection failed: refused",
2094            "&lt;x&gt;",
2095            "/deliveries/",
2096            "value=\"change.released\" checked",
2097            "selected>ROVER",
2098        ] {
2099            assert!(html.contains(expected), "webhook page shows {expected}");
2100        }
2101        assert!(
2102            !html.contains("HTTP 503 – HTTP 503"),
2103            "the status code once"
2104        );
2105        let html = render(
2106            &env,
2107            "desktop/authorize.html",
2108            with_common(json!({
2109                "req": { "port": 53682, "state": "abcdefghijklmnop", "challenge": "c".repeat(43),
2110                         "device": "Leo's <MacBook>" },
2111                "token_days": 30,
2112            })),
2113        );
2114        assert!(
2115            html.contains("Leo&#x27;s &lt;MacBook&gt;"),
2116            "device name is escaped"
2117        );
2118        assert!(html.contains("name=\"decision\" value=\"allow\""));
2119        let html = render(
2120            &env,
2121            "account.html",
2122            with_common(json!({
2123                "user": user(),
2124                "tokens": [{ "id": ME, "name": "assistant", "token_prefix": "tc_pat_abc123", "created_at": TS,
2125                             "last_used_at": null, "expires_at": null, "scope": "mcp_only", "idle_days": null }],
2126                "new_token": { "name": "ci", "value": "tc_pat_secret", "scope": "read_only" },
2127                "api_base": "http://localhost:8080/api/v1",
2128                "skins": crate::domain::SKINS,
2129                "cad_families": crate::cadformat::families().iter()
2130                    .map(|s| json!({ "id": s.id, "short": s.short, "name": s.name }))
2131                    .collect::<Vec<_>>(),
2132                "notify": {
2133                    "settings": { "email": true, "email_address": "max@example.org", "mattermost": false,
2134                                  "mattermost_username": null, "muted": ["item_update"] },
2135                    "kinds": crate::domain::NotificationKind::MUTABLE.iter().map(|k| json!({
2136                        "value": k.as_str(), "label": k.label(), "on": *k != crate::domain::NotificationKind::ItemUpdate
2137                    })).collect::<Vec<_>>(),
2138                    "email_enabled": true, "mattermost_enabled": false, "mattermost_direct": false,
2139                },
2140                "unread_notifications": 3,
2141            })),
2142        );
2143        assert!(html.contains("tc_pat_secret"));
2144        assert!(
2145            html.contains("action=\"/account/preferred-cad\"")
2146                && html.contains("value=\"nx\"")
2147                && html.contains("Siemens NX"),
2148            "preferred CAD program choice"
2149        );
2150        assert!(
2151            html.contains("“ci” (read-only)")
2152                && html.contains("MCP only")
2153                && html.contains("name=\"scope\""),
2154            "token scopes"
2155        );
2156        assert!(
2157            html.contains("value=\"review_requested\" checked")
2158                && html.contains("value=\"item_update\" >")
2159                && html.contains("max@example.org")
2160                && html.contains("Mattermost is not configured"),
2161            "notification settings"
2162        );
2163        assert!(
2164            html.contains("class=\"bell has-unread\"")
2165                && html.contains("<span class=\"bell-count\">3</span>"),
2166            "bell with the unread count"
2167        );
2168        let notes = render(
2169            &env,
2170            "notifications.html",
2171            with_common(json!({
2172                "notifications": [
2173                    { "id": ME, "kind": "review_requested", "title": "Review requested: CHG-000004 <x>",
2174                      "body": "Erika asks you to review PRT-000003/B.", "link": "/changes/1", "subject_type": "change",
2175                      "subject_ref": "CHG-000004", "actor_name": "Erika", "created_at": TS, "read_at": null,
2176                      "email_state": "sent", "chat_state": "failed", "chat_error": "Mattermost user \"x\" not found" },
2177                    { "id": OTHER, "kind": "impact", "title": "Check needed: ASM-000001", "body": "",
2178                      "link": "/revisions/1", "subject_type": "revision", "subject_ref": "ASM-000001/A",
2179                      "actor_name": "Rolf", "created_at": TS, "read_at": TS, "email_state": "none", "chat_state": "none" }],
2180                "watched": [{ "entity_type": "item", "entity_id": ME, "number": "PRT-000003", "name": "Bracket",
2181                              "state": "released", "link": "/revisions/1", "revision_code": "B" },
2182                            { "entity_type": "change", "entity_id": OTHER, "number": "CHG-000004", "name": "Wall",
2183                              "state": "in_review", "link": "/changes/1", "revision_code": null }],
2184                "email_enabled": true, "mattermost_enabled": true, "unread_notifications": 1,
2185            })),
2186        );
2187        for expected in [
2188            "Review requested: CHG-000004 &lt;x&gt;",
2189            "class=\"unread\"",
2190            "e-mailed",
2191            "Mattermost failed",
2192            &format!("/notifications/{ME}/open"),
2193            "Mark all as read",
2194            &format!("/items/{ME}/watch"),
2195            &format!("/changes/{OTHER}/watch"),
2196        ] {
2197            assert!(
2198                notes.contains(expected),
2199                "notifications page shows {expected}"
2200            );
2201        }
2202
2203        let upload = render(
2204            &env,
2205            "imports/upload.html",
2206            with_common(json!({
2207                "target": null,
2208                "projects": [{ "id": ME, "code": "ROCKET", "name": "Rocket <stage>" }],
2209                "max_mib": 20,
2210            })),
2211        );
2212        assert!(
2213            upload.contains("enctype=\"multipart/form-data\"")
2214                && upload.contains("Rocket &lt;stage&gt;")
2215                && upload.contains("name=\"match_existing_sent\"")
2216                && upload.contains("name=\"project_id\""),
2217            "import upload form"
2218        );
2219        let into = render(
2220            &env,
2221            "imports/upload.html",
2222            with_common(json!({
2223                "target": { "id": ME, "item_number": "ASM-000001", "code": "A", "name": "Pump",
2224                            "project_code": "ROCKET" },
2225                "projects": [], "max_mib": 20,
2226            })),
2227        );
2228        assert!(
2229            into.contains(&format!("name=\"into\" value=\"{ME}\""))
2230                && !into.contains("name=\"project_id\""),
2231            "import into a checked-out revision"
2232        );
2233        let preview = render(
2234            &env,
2235            "imports/preview.html",
2236            with_common(json!({
2237                "id": OTHER,
2238                "plan": {
2239                    "filename": "pump <bom>.csv", "target": null, "root_label": "Pump",
2240                    "root_number": "ASM-PUMP", "root_name": "Pump", "project_code": "ROCKET",
2241                    "new_items": 2, "existing_items": 1, "lines": [{}, {}, {}],
2242                    "warnings": ["Row 4: <screw> appears again"],
2243                    "columns": [{ "header": "Part Number", "field": "number", "label": "Part number" }],
2244                    "attribute_columns": [{ "header": "Lieferant", "key": "vendor", "label": "Vendor" }],
2245                    "unused_columns": ["Cost <Center>"],
2246                },
2247                "rows": [
2248                    { "row": 2, "level": 1, "indent": "", "find_number": 1, "file_number": "HOUSING",
2249                      "number": "HOUSING", "existing": null, "type": "Assembly", "name": "Housing",
2250                      "quantity": 1.0, "unit": "each", "material": "AlMg3", "mass_kg": 0.8 },
2251                    { "row": 3, "level": 2, "indent": "indent-2", "find_number": 10,
2252                      "file_number": "ISO 4762 M6", "number": null, "existing": null, "type": "Part",
2253                      "name": "Screw", "quantity": 6.0, "unit": "each", "material": "", "mass_kg": null,
2254                      "classification": "Standard part", "attributes": [{ "key": "vendor", "label": "Vendor", "value": "Würth <GmbH>" }] },
2255                    { "row": 5, "level": 1, "indent": "", "find_number": 2, "file_number": "PRT-000001",
2256                      "number": "PRT-000001", "existing": "PRT-000001/B", "type": "Part",
2257                      "name": "Bracket", "quantity": 2.0, "unit": "each", "material": "", "mass_kg": null },
2258                ],
2259            })),
2260        );
2261        for expected in [
2262            "pump &lt;bom&gt;.csv",
2263            "<span class=\"mono\">ASM-PUMP</span>",
2264            &format!("/import/{OTHER}/run"),
2265            &format!("/import/{OTHER}/discard"),
2266            "Row 4: &lt;screw&gt; appears again",
2267            "indent-2",
2268            "new assembly",
2269            "generated",
2270            "PRT-000001&#x2f;B",
2271            "badge-released\">existing",
2272            "Attribute “Vendor”",
2273            "Cost &lt;Center&gt;",
2274            "standard part</span>",
2275            "Vendor: Würth &lt;GmbH&gt;",
2276            "<td>Part number</td>",
2277        ] {
2278            assert!(
2279                preview.contains(expected),
2280                "import preview shows {expected}"
2281            );
2282        }
2283        assert!(html.contains("<html lang=\"en\" data-skin=\"arch\">"));
2284        assert!(
2285            html.contains("data-skin=\"ubuntu\""),
2286            "skin picker lists every skin"
2287        );
2288        assert!(
2289            html.contains("skin-card selected"),
2290            "current skin is marked"
2291        );
2292
2293        // the assistant: dashboard quick action, change briefing, answer page
2294        let dashboard = |enabled: bool| {
2295            let mut data = with_common(json!({
2296                "stats": { "items": 0, "revisions_in_work": 0, "revisions_in_review": 0, "revisions_released": 0,
2297                           "open_changes": 0, "files": 0, "stored_bytes": 0 },
2298                "checkouts": [], "reviews": [], "my_changes": [], "activity": [], "impact_groups": [],
2299            }));
2300            data["assistant_enabled"] = json!(enabled);
2301            render(&env, "dashboard.html", data)
2302        };
2303        let html = dashboard(true);
2304        for expected in [
2305            "id=\"assistant-toggle\"",
2306            "aria-controls=\"assistant-drawer\"",
2307            "id=\"assistant-drawer\"",
2308            "data-module=\"/static/shared/v/",
2309            "/js/assistant.js\"",
2310            "/static/shared/css/assistant.css?v=",
2311            "data-can-edit=\"1\"",
2312            "data-tc-kind=\"dashboard\"",
2313        ] {
2314            assert!(html.contains(expected), "the sidebar markup has {expected}");
2315        }
2316        let disabled = dashboard(false);
2317        assert!(
2318            !disabled.contains("assistant-drawer")
2319                && !disabled.contains("assistant-toggle")
2320                && !disabled.contains("assistant.css"),
2321            "hidden when disabled"
2322        );
2323        assert!(
2324            disabled.contains("data-tc-kind=\"dashboard\""),
2325            "the page context is declared either way"
2326        );
2327
2328        let mut data = with_common(json!({
2329            "change": change("in_review"), "status_label": "In review", "items": [], "reviews": [],
2330            "candidates": [], "history": [], "priorities": ["low"], "impact": [], "hidden_dependents": 0,
2331            "can": { "modify": false, "edit_text": false, "submit": false, "withdraw": false, "reopen": false,
2332                     "cancel": false, "review": true },
2333        }));
2334        data["assistant_enabled"] = json!(true);
2335        let html = render(&env, "changes/show.html", data);
2336        assert!(
2337            html.contains("data-tc-kind=\"change\"")
2338                && html.contains("data-tc-change=\"CHG-000001\""),
2339            "a change order declares its number"
2340        );
2341
2342        let answer = crate::assistant::render_markdown("## Scope\n- PRT-000001 <b>");
2343        // "Open 3D preview" links: checked by open_links, label escaped
2344        let links = crate::assistant::open_links(
2345            &[
2346                json!({ "url": "http://localhost:8080/files/0b9c3a3e-5d1f-4c9a-9e8b-1234567890ab/preview",
2347                        "item": "PRT-000001", "revision": "A", "file": "<script>x</script>.stl" }),
2348                json!({ "url": "https://evil.example/files/0b9c3a3e-5d1f-4c9a-9e8b-1234567890ab/preview",
2349                        "item": "PRT-000002", "revision": "A", "file": "evil.stl" }),
2350            ],
2351            "http://localhost:8080",
2352        );
2353        assert_eq!(links.len(), 1);
2354        let html = render(
2355            &env,
2356            "assistant.html",
2357            with_common(json!({
2358                "actions": [["review_brief", "Brief me for this review"]],
2359                "form": { "question": "Is it <ready>?", "item": "", "revision": "", "change": "CHG-000001" },
2360                "back": "/changes/x", "error": null,
2361                "run": { "label": "Brief me for this review", "html": answer, "text": "x", "truncated": true,
2362                         "tools": [{ "name": "get_change", "ok": true }, { "name": "bom_health", "ok": false }],
2363                         "steps": 3, "model": "claude-opus-5-5", "allow_drafts": false,
2364                         "links": links },
2365            })),
2366        );
2367        assert!(
2368            !html.contains("<script>x") && !html.contains("evil"),
2369            "the link is escaped and only to this server"
2370        );
2371        for expected in [
2372            "<a class=\"button small view-3d\" href=\"&#x2f;files&#x2f;0b9c3a3e-5d1f-4c9a-9e8b-1234567890ab&#x2f;preview\">Open 3D preview: PRT-000001&#x2f;A · &lt;script&gt;x&lt;&#x2f;script&gt;.stl</a>",
2373            "<h3>Scope</h3>",
2374            "<a href=\"/i/PRT-000001\">PRT-000001</a> &lt;b&gt;",
2375            "Is it &lt;ready&gt;?",
2376            "get_change",
2377            "badge badge-rejected",
2378            "3 steps",
2379            "read-only",
2380            "cut off",
2381            "changes&#x2f;x\" data-back>Back</a>",
2382        ] {
2383            assert!(html.contains(expected), "assistant page shows {expected}");
2384        }
2385        let html = render(
2386            &env,
2387            "assistant.html",
2388            with_common(json!({
2389                "actions": [["attention", "What needs my attention"]],
2390                "form": { "question": "", "item": "", "revision": "", "change": "" },
2391                "back": "/", "error": "Type a question first.", "run": null,
2392            })),
2393        );
2394        assert!(
2395            html.contains("Type a question first.") && html.contains("What needs my attention")
2396        );
2397    }
2398
2399    fn units() -> Value {
2400        json!(crate::domain::COMMON_UNITS)
2401    }
2402
2403    /// Every link to an embedded asset carries the asset version, so browsers
2404    /// never keep an outdated stylesheet, script or logo after an update:
2405    /// `?v=` or, for shared scripts, the versioned path (src/assets.rs).
2406    #[test]
2407    fn static_links_carry_the_asset_version() {
2408        for (name, source) in TEMPLATES {
2409            for (i, _) in source.match_indices("\"/static/") {
2410                let link = &source[i + 1..];
2411                let link = &link[..link.find('"').unwrap_or(link.len())];
2412                let versioned = link.starts_with("/static/shared/v/{{ assets }}/js/");
2413                assert!(
2414                    link.contains("?v={{ assets }}") || versioned,
2415                    "{name}: {link} has no ?v={{{{ assets }}}}"
2416                );
2417            }
2418        }
2419    }
2420
2421    #[test]
2422    fn filters() {
2423        assert_eq!(
2424            filter_dt(Some("2026-11-03T14:05:59.123Z".into())),
2425            "2026-11-03 14:05 UTC"
2426        );
2427        assert_eq!(filter_dt(None), "—");
2428        assert_eq!(filter_num(Some(1.23456), None), "1.235");
2429        assert_eq!(filter_num(Some(2.0), Some(2)), "2");
2430        assert_eq!(filter_num(None, None), "—");
2431        assert_eq!(filter_sig(Some(12.345678)), "12.3457");
2432        assert_eq!(filter_sig(Some(0.0425)), "0.0425");
2433        assert_eq!(filter_sig(Some(-1234.5)), "-1234.5");
2434        assert_eq!(filter_sig(Some(2.5e-7)), "2.50000e-7");
2435        assert_eq!(filter_sig(Some(0.0)), "0");
2436        assert_eq!(filter_sig(Some(f64::NAN)), "—");
2437        assert_eq!(filter_sig(None), "—");
2438        assert_eq!(filter_fixed(Some(41.0), None), "41.00");
2439        assert_eq!(filter_fixed(Some(0.2), Some(3)), "0.200");
2440        assert_eq!(filter_fixed(None, Some(3)), "—");
2441        assert_eq!(filter_label(Some("in_work".into())), "WIP");
2442        assert_eq!(filter_label(Some("in_review".into())), "In Review");
2443        assert_eq!(filter_label(Some("released".into())), "Released");
2444    }
2445}