1use minijinja::Environment;
4
5use crate::util;
6
7macro_rules! embed {
8 ($($name:literal),* $(,)?) => {
9 &[$(($name, include_str!(concat!("../templates/", $name)))),*]
10 };
11}
12
13pub 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 env.add_global("assets", crate::assets::version());
82 Ok(env)
83}
84
85fn 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
100fn 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
114fn 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
122fn 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
131fn 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
151fn 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 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 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 #[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 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 <ok>"), "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 <1>"),
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 <b>",
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 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 <M6>"));
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 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 <1>"));
666 assert!(!html.contains("name=\"mass_kg\""));
667
668 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 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 <1>"), "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 <found>"), !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 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 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 <code>"));
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 <source>",
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 <i>")
840 && html.contains("shape distribution 94 % similar")
841 && html.contains("value=\"91\""),
842 "similar parts listed"
843 );
844 }
845 assert!(html.contains("Bracket <b>"), "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 <ok>",
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 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 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 <r2>",
974 "Out of date",
975 "Up to date",
976 "Max. stress <vM>",
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 <load>.vtu v2",
985 "class=\"sim-gallery\"",
986 "Proposed from reports",
987 "Mass flow <p>",
988 "report <1>.pdf",
989 "/simulation-proposals/",
990 "pressure <p>.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 <LC1>",
1030 "1 of 3 failed",
1031 "check open",
1032 "Out of date",
1033 ] {
1034 assert!(html.contains(expected), "part page shows {expected}");
1035 }
1036 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 <0042>",
1100 "2026-11-02",
1101 "Deviations from the design",
1102 "1 open",
1103 "Bore <Ø12>",
1104 "12.035 <m>",
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 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 <7>",
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 <max>") && html.contains("-10 %"));
1176 assert!(html.contains("badge badge-fail") && html.contains("badge badge-pass"));
1177
1178 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 <draft>"), "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 <7075>\">2810 kg/m³</option></datalist>")
1237 && html.contains("data-material-hint"),
1238 "material suggestions are escaped"
1239 );
1240 assert!(
1241 html.contains("Old <Ltd> (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 <M6>") && 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 <owner>"),
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("<too thin>")
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 <M>")
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 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 <at> 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 <at> the weld",
1485 "Seen on <three> 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 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 <rocket> –") && 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 <b>"));
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 <x>") && 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 <drop>") && 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 <gone>") && 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 <gone>"),
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("<bad>"), "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 <server>"));
1707 assert!(
1708 html.contains("<b>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, <x>"));
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 <L>"), "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 <left>") && html.contains("Upright <x>"));
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 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 <x>") && html.contains("no density <x>"));
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 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 <1>.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 <7075>"));
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 <Lieferant>") && 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 <export>",
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 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 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 "<x>",
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's <MacBook>"),
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 <x>",
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 <stage>")
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 <bom>.csv",
2263 "<span class=\"mono\">ASM-PUMP</span>",
2264 &format!("/import/{OTHER}/run"),
2265 &format!("/import/{OTHER}/discard"),
2266 "Row 4: <screw> appears again",
2267 "indent-2",
2268 "new assembly",
2269 "generated",
2270 "PRT-000001/B",
2271 "badge-released\">existing",
2272 "Attribute “Vendor”",
2273 "Cost <Center>",
2274 "standard part</span>",
2275 "Vendor: Würth <GmbH>",
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 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 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=\"/files/0b9c3a3e-5d1f-4c9a-9e8b-1234567890ab/preview\">Open 3D preview: PRT-000001/A · <script>x</script>.stl</a>",
2373 "<h3>Scope</h3>",
2374 "<a href=\"/i/PRT-000001\">PRT-000001</a> <b>",
2375 "Is it <ready>?",
2376 "get_change",
2377 "badge badge-rejected",
2378 "3 steps",
2379 "read-only",
2380 "cut off",
2381 "changes/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 #[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}