1use facett_core::{FacetCaps, Theme, theme};
24use serde::{Deserialize, Serialize};
25
26#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
29pub enum StationKind {
30 Start,
32 Emitter,
34 PassThrough,
36 Grpc,
38 Terminus,
40 UiClient,
44 CliClient,
48}
49
50impl StationKind {
51 pub fn as_str(self) -> &'static str {
53 match self {
54 StationKind::Start => "start",
55 StationKind::Emitter => "emitter",
56 StationKind::PassThrough => "passthrough",
57 StationKind::Grpc => "grpc",
58 StationKind::Terminus => "terminus",
59 StationKind::UiClient => "ui_client",
60 StationKind::CliClient => "cli_client",
61 }
62 }
63
64 pub fn parse(s: &str) -> Self {
66 match s {
67 "start" => StationKind::Start,
68 "emitter" => StationKind::Emitter,
69 "grpc" => StationKind::Grpc,
70 "terminus" => StationKind::Terminus,
71 "ui_client" => StationKind::UiClient,
72 "cli_client" => StationKind::CliClient,
73 _ => StationKind::PassThrough,
74 }
75 }
76
77 pub fn marker(self) -> Option<&'static str> {
81 match self {
82 StationKind::UiClient => Some("UI"),
83 StationKind::CliClient => Some("CLI"),
84 _ => None,
85 }
86 }
87
88 pub fn is_client_arm(self) -> bool {
91 matches!(self, StationKind::UiClient | StationKind::CliClient)
92 }
93
94 pub fn glyph(self) -> &'static str {
98 match self {
99 StationKind::Start => "▶",
100 StationKind::Emitter => "●",
101 StationKind::PassThrough => "·",
102 StationKind::Grpc => "◆",
103 StationKind::Terminus => "■",
104 StationKind::UiClient => "▲",
105 StationKind::CliClient => "◇",
106 }
107 }
108
109 pub fn gates(self) -> bool {
115 !matches!(self, StationKind::PassThrough | StationKind::UiClient | StationKind::CliClient)
116 }
117
118 pub fn is_station(self) -> bool {
122 !matches!(self, StationKind::PassThrough)
123 }
124}
125
126#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
130pub struct MetroBranch {
131 pub id: String,
133 pub label: String,
135 pub kind: StationKind,
137 pub lit: bool,
141}
142
143impl MetroBranch {
144 pub fn new(id: impl Into<String>, label: impl Into<String>, kind: StationKind, lit: bool) -> Self {
147 Self { id: id.into(), label: label.into(), kind, lit }
148 }
149
150 pub fn marker(&self) -> Option<&'static str> {
153 self.kind.marker()
154 }
155}
156
157#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
159pub struct MetroStation {
160 pub id: String,
162 pub label: String,
164 pub kind: StationKind,
165 pub lit: bool,
168 pub branches: Vec<MetroBranch>,
173}
174
175impl MetroStation {
176 pub fn new(id: impl Into<String>, label: impl Into<String>, kind: StationKind, lit: bool) -> Self {
179 Self {
180 id: id.into(),
181 label: label.into(),
182 kind,
183 lit: if kind == StationKind::PassThrough { true } else { lit },
184 branches: Vec::new(),
185 }
186 }
187
188 pub fn with_branches(
191 id: impl Into<String>,
192 label: impl Into<String>,
193 kind: StationKind,
194 lit: bool,
195 branches: Vec<MetroBranch>,
196 ) -> Self {
197 let mut s = Self::new(id, label, kind, lit);
198 s.branches = branches;
199 s
200 }
201
202 pub fn has_branches(&self) -> bool {
204 !self.branches.is_empty()
205 }
206}
207
208#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
210pub struct MetroLine {
211 pub id: String,
213 pub label: String,
215 pub stations: Vec<MetroStation>,
217}
218
219impl MetroLine {
220 pub fn new(id: impl Into<String>, label: impl Into<String>, stations: Vec<MetroStation>) -> Self {
221 Self { id: id.into(), label: label.into(), stations }
222 }
223
224 pub fn is_green(&self) -> bool {
229 self.stations.iter().filter(|s| s.kind.gates()).all(|s| s.lit)
230 }
231
232 pub fn unlit(&self) -> Vec<&MetroStation> {
235 self.stations.iter().filter(|s| s.kind.gates() && !s.lit).collect()
236 }
237
238 pub fn station_count(&self) -> usize {
240 self.stations.iter().filter(|s| s.kind.is_station()).count()
241 }
242}
243
244#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
246pub struct MetroMap {
247 pub lines: Vec<MetroLine>,
248}
249
250impl MetroMap {
251 pub fn new(lines: Vec<MetroLine>) -> Self {
252 Self { lines }
253 }
254
255 pub fn is_green(&self) -> bool {
258 self.lines.iter().all(|l| l.is_green())
259 }
260
261 pub fn green_count(&self) -> usize {
263 self.lines.iter().filter(|l| l.is_green()).count()
264 }
265}
266
267const ROW_H: f32 = 64.0;
271const LINE_LEFT: f32 = 150.0;
272const STOP_GAP: f32 = 96.0;
273const STATION_R: f32 = 9.0;
274const TICK_R: f32 = 3.0;
275const LINE_W: f32 = 6.0;
276
277#[derive(Clone, Debug, PartialEq)]
281pub enum Msg {
282 SelectLine(String),
284 ClearSelection,
286}
287
288#[derive(Clone, Debug, PartialEq)]
292pub enum Effect {}
293
294#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
300pub struct MetroState {
301 pub map: MetroMap,
303 pub selected: Option<String>,
305}
306
307pub struct MetroView {
311 title: String,
312 state: MetroState,
314}
315
316impl MetroView {
317 pub fn new(title: impl Into<String>) -> Self {
319 Self { title: title.into(), state: MetroState::default() }
320 }
321
322 pub fn set_map(&mut self, map: MetroMap) {
324 self.state.map = map;
325 }
326
327 pub fn set_title(&mut self, title: impl Into<String>) {
330 self.title = title.into();
331 }
332
333 pub fn map(&self) -> &MetroMap {
335 &self.state.map
336 }
337
338 pub fn state(&self) -> &MetroState {
340 &self.state
341 }
342
343 pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
347 match msg {
348 Msg::SelectLine(id) => self.state.selected = Some(id),
349 Msg::ClearSelection => self.state.selected = None,
350 }
351 Vec::new()
352 }
353
354 pub fn select(&mut self, id: Option<String>) {
357 let _ = match id {
358 Some(i) => self.update(Msg::SelectLine(i)),
359 None => self.update(Msg::ClearSelection),
360 };
361 }
362
363 pub fn local() -> Self {
369 let mut v = Self::new("Metro");
370 v.set_map(demo_map());
371 v
372 }
373
374 pub fn remote() -> Self {
378 let mut v = Self::local();
379 v.title = "Metro (remote)".into();
380 v
381 }
382
383 fn stop_color(th: &Theme, line_green: bool, lit: bool) -> egui::Color32 {
386 if !lit {
387 egui::Color32::from_rgb(224, 90, 90)
390 } else if line_green {
391 th.point } else {
393 th.accent }
395 }
396
397 fn line_color(th: &Theme, line_green: bool) -> egui::Color32 {
399 if line_green { th.point } else { th.text_dim }
400 }
401
402 fn arm_color(th: &Theme, kind: StationKind, lit: bool) -> egui::Color32 {
407 let base = match kind {
408 StationKind::CliClient => egui::Color32::from_rgb(214, 160, 70), _ => th.accent, };
411 if lit {
412 base
413 } else {
414 egui::Color32::from_rgba_unmultiplied(base.r(), base.g(), base.b(), 120)
416 }
417 }
418}
419
420const ARM_RISE: f32 = 30.0;
423const ARM_RUN: f32 = 34.0;
425const ARM_R: f32 = 7.0;
427
428pub fn demo_map() -> MetroMap {
433 let line_a = MetroLine::new(
434 "bench_run→Bench.Submit",
435 "Bench Run",
436 vec![
437 MetroStation::new("ui::bench_button", "Run", StationKind::Start, true),
438 MetroStation::new("bench::collect", "collect", StationKind::Emitter, true),
439 MetroStation::new("bench::normalize", "normalize", StationKind::PassThrough, true),
440 MetroStation::new("bench::submit", "submit", StationKind::Emitter, true),
441 MetroStation::with_branches(
443 "grpc::Bench.Submit",
444 "Bench.Submit",
445 StationKind::Grpc,
446 true,
447 vec![
448 MetroBranch::new("caller::ui::Bench.Submit", "viz", StationKind::UiClient, true),
449 MetroBranch::new("caller::cli::Bench.Submit", "nornir", StationKind::CliClient, true),
450 ],
451 ),
452 MetroStation::new("warehouse::bench_runs", "bench_runs", StationKind::Terminus, true),
453 ],
454 );
455 let line_b = MetroLine::new(
456 "docs_export→Docs.Render",
457 "Docs Export",
458 vec![
459 MetroStation::new("ui::docs_button", "Export", StationKind::Start, true),
460 MetroStation::new("docs::gather", "gather", StationKind::Emitter, true),
461 MetroStation::new("docs::render_svg", "render_svg", StationKind::Emitter, false), MetroStation::with_branches(
464 "grpc::Docs.Render",
465 "Docs.Render",
466 StationKind::Grpc,
467 true,
468 vec![MetroBranch::new("caller::cli::Docs.Render", "nornir-mcp", StationKind::CliClient, true)],
469 ),
470 MetroStation::new("warehouse::doc_exports", "doc_exports", StationKind::Terminus, true),
471 ],
472 );
473 MetroMap::new(vec![line_a, line_b])
474}
475
476impl MetroView {
477 pub fn copy_text(&self) -> Option<String> {
480 if self.state.map.lines.is_empty() {
481 return None;
482 }
483 if let Some(id) = self.state.selected.as_deref() {
484 if let Some(l) = self.state.map.lines.iter().find(|l| l.id == id) {
485 return Some(l.label.clone());
486 }
487 }
488 Some(self.state.map.lines.iter().map(|l| l.label.clone()).collect::<Vec<_>>().join("\n"))
489 }
490}
491
492impl facett_core::clip::CopySource for MetroView {
494 fn copy_kinds(&self) -> &[facett_core::clip::ClipKind] {
495 use facett_core::clip::ClipKind;
496 &[ClipKind::Text]
497 }
498 fn copy_payload(&self) -> Option<facett_core::clip::ClipPayload> {
499 self.copy_text().map(facett_core::clip::ClipPayload::Text)
500 }
501}
502
503impl MetroView {
504 pub fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
511 let msgs: Vec<Msg> = Vec::new();
512 let th = theme(ui);
513
514 ui.horizontal_wrapped(|ui| {
516 let total = self.state.map.lines.len();
517 let green = self.state.map.green_count();
518 ui.label(egui::RichText::new(format!("{total} line(s)")).color(th.text).strong());
519 ui.separator();
520 ui.label(
521 egui::RichText::new(format!("✓ {green} green"))
522 .color(if green == total && total > 0 { th.point } else { th.text_dim }),
523 );
524 ui.label(
525 egui::RichText::new(format!("✗ {} red", total - green))
526 .color(if green < total { egui::Color32::from_rgb(224, 90, 90) } else { th.text_dim }),
527 );
528 });
529 ui.separator();
530
531 if self.state.map.lines.is_empty() {
532 ui.label(egui::RichText::new("no lines").color(th.text_dim));
533 #[cfg(feature = "testmatrix")]
534 facett_core::testmatrix::emit(
535 "facett-graphview::MetroView::view",
536 "ui_render",
537 true,
538 "lines=0 drew=empty_hint",
539 );
540 return msgs;
541 }
542
543 let n = self.state.map.lines.len();
545 let (rect, _resp) = ui.allocate_exact_size(
546 egui::vec2(ui.available_width().max(640.0), (ROW_H + ARM_RISE) * n as f32 + 16.0),
549 egui::Sense::hover(),
550 );
551 let painter = ui.painter_at(rect);
552
553 for (li, line) in self.state.map.lines.iter().enumerate() {
554 let green = line.is_green();
555 let y = rect.top() + (ROW_H + ARM_RISE) * li as f32 + ARM_RISE + ROW_H * 0.5;
558 let line_col = MetroView::line_color(&th, green);
559
560 painter.text(
562 egui::pos2(rect.left() + 8.0, y),
563 egui::Align2::LEFT_CENTER,
564 &line.label,
565 egui::FontId::proportional(14.0),
566 if green { th.point } else { th.text },
567 );
568
569 let x0 = rect.left() + LINE_LEFT;
571 let stops = &line.stations;
572 if !stops.is_empty() {
573 let x_last = x0 + STOP_GAP * (stops.len() as f32 - 1.0);
574 painter.line_segment(
575 [egui::pos2(x0, y), egui::pos2(x_last.max(x0), y)],
576 egui::Stroke::new(LINE_W, line_col),
577 );
578 }
579
580 for (si, st) in stops.iter().enumerate() {
582 let x = x0 + STOP_GAP * si as f32;
583 let center = egui::pos2(x, y);
584 if st.kind == StationKind::PassThrough {
585 painter.circle_filled(center, TICK_R, th.text_dim);
587 } else {
588 let fill = MetroView::stop_color(&th, green, st.lit);
589 if st.lit {
590 painter.circle_filled(center, STATION_R, fill);
591 } else {
592 painter.circle_stroke(center, STATION_R, egui::Stroke::new(2.5, fill));
594 }
595 let glyph = st.kind.glyph();
597 if matches!(st.kind, StationKind::Start | StationKind::Grpc | StationKind::Terminus) {
598 painter.text(
599 egui::pos2(x, y - STATION_R - 11.0),
600 egui::Align2::CENTER_CENTER,
601 glyph,
602 egui::FontId::proportional(12.0),
603 th.text,
604 );
605 }
606 }
607 painter.text(
609 egui::pos2(x, y + STATION_R + 9.0),
610 egui::Align2::CENTER_CENTER,
611 &st.label,
612 egui::FontId::proportional(10.0),
613 th.text_dim,
614 );
615
616 if st.has_branches() {
621 let m = st.branches.len();
622 for (bi, br) in st.branches.iter().enumerate() {
623 let frac = if m > 1 { bi as f32 / (m as f32 - 1.0) - 0.5 } else { 0.0 };
625 let ex = x + frac * (ARM_RUN * (m as f32).min(2.0));
626 let ey = y - ARM_RISE;
627 let col = MetroView::arm_color(&th, br.kind, br.lit);
628 painter.line_segment(
630 [center, egui::pos2(ex, ey)],
631 egui::Stroke::new(3.0, col),
632 );
633 let ep = egui::pos2(ex, ey);
635 if br.lit {
636 painter.circle_filled(ep, ARM_R, col);
637 } else {
638 painter.circle_stroke(ep, ARM_R, egui::Stroke::new(2.0, col));
639 }
640 painter.text(
642 egui::pos2(ex, ey - ARM_R - 8.0),
643 egui::Align2::CENTER_CENTER,
644 format!("{} {}", br.kind.glyph(), br.marker().unwrap_or("")),
645 egui::FontId::proportional(10.0),
646 col,
647 );
648 }
649 }
650 }
651 }
652
653 #[cfg(feature = "testmatrix")]
654 facett_core::testmatrix::emit(
655 "facett-graphview::MetroView::view",
656 "ui_render",
657 !self.state.map.lines.is_empty(),
658 &format!("lines={} green={}", self.state.map.lines.len(), self.state.map.green_count()),
659 );
660
661 msgs
662 }
663}
664
665impl facett_core::Elm for MetroView {
667 type Model = MetroState;
668 type Msg = Msg;
669 type Effect = Effect;
670
671 fn title(&self) -> &str {
672 &self.title
673 }
674 fn state(&self) -> &MetroState {
675 &self.state
676 }
677 fn update(&mut self, msg: Msg) -> Vec<Effect> {
678 MetroView::update(self, msg)
679 }
680 fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
681 MetroView::view(self, ui)
682 }
683}
684
685facett_core::impl_facet_via_elm!(MetroView, custom_state_json, {
693 fn state_json(&self) -> serde_json::Value {
694 serde_json::json!({
695 "title": self.title,
696 "lines": self.state.map.lines.len(),
697 "green_lines": self.state.map.green_count(),
698 "green": self.state.map.is_green(),
699 "selected": self.state.selected,
700 "line": self.state.map.lines.iter().map(|l| serde_json::json!({
701 "id": l.id,
702 "label": l.label,
703 "green": l.is_green(),
704 "station_count": l.station_count(),
705 "unlit": l.unlit().iter().map(|s| s.label.clone()).collect::<Vec<_>>(),
706 "stations": l.stations.iter().map(|s| serde_json::json!({
707 "id": s.id,
708 "label": s.label,
709 "kind": s.kind.as_str(),
710 "lit": s.lit,
711 "branches": s.branches.iter().map(|b| serde_json::json!({
714 "id": b.id,
715 "label": b.label,
716 "kind": b.kind.as_str(),
717 "marker": b.marker(),
718 "lit": b.lit,
719 })).collect::<Vec<_>>(),
720 })).collect::<Vec<_>>(),
721 })).collect::<Vec<_>>(),
722 })
723 }
724
725 fn copy(&mut self) -> Option<String> {
726 use facett_core::clip::CopySource as _;
727 self.copy_payload().map(|p| p.as_text())
728 }
729
730 fn selection_json(&self) -> serde_json::Value {
731 match &self.state.selected {
732 Some(id) => serde_json::json!({ "line": id }),
733 None => serde_json::Value::Null,
734 }
735 }
736
737 fn caps(&self) -> FacetCaps {
740 FacetCaps::NONE.themeable().resizable().selectable().copyable()
741 }
742
743 fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
744 Some(self)
745 }
746});
747
748#[cfg(test)]
749mod tests {
750 use super::*;
751 use facett_core::Facet;
754
755 #[test]
756 fn typed_copy_is_selected_line_or_the_line_list() {
757 use facett_core::clip::{ClipKind, CopySource};
758 let mut v = MetroView::new("metro");
759 v.set_map(demo_map());
760 let p = v.copy_payload().expect("populated map copies");
761 assert_eq!(p.kind(), ClipKind::Text);
762 assert_eq!(p.as_text(), "Bench Run\nDocs Export");
763 v.select(Some("bench_run\u{2192}Bench.Submit".into()));
765 assert_eq!(v.copy_payload().unwrap().as_text(), "Bench Run");
766 }
767
768 #[test]
769 fn green_only_when_every_gating_station_lit() {
770 let line = MetroLine::new(
771 "l",
772 "L",
773 vec![
774 MetroStation::new("s", "start", StationKind::Start, true),
775 MetroStation::new("e", "emit", StationKind::Emitter, true),
776 MetroStation::new("p", "pass", StationKind::PassThrough, false), MetroStation::new("g", "grpc", StationKind::Grpc, true),
778 MetroStation::new("t", "wh", StationKind::Terminus, true),
779 ],
780 );
781 assert!(line.is_green(), "all gating stops lit + a pass-through doesn't gate");
782 assert!(line.unlit().is_empty());
783 }
784
785 #[test]
786 fn one_unlit_emitter_makes_line_red() {
787 let line = MetroLine::new(
788 "l",
789 "L",
790 vec![
791 MetroStation::new("s", "start", StationKind::Start, true),
792 MetroStation::new("e", "emit", StationKind::Emitter, false),
793 ],
794 );
795 assert!(!line.is_green());
796 assert_eq!(line.unlit().len(), 1);
797 assert_eq!(line.unlit()[0].label, "emit");
798 }
799
800 #[test]
801 fn passthrough_constructed_lit_and_never_gates() {
802 let p = MetroStation::new("p", "p", StationKind::PassThrough, false);
803 assert!(p.lit, "pass-through is forced lit (nothing to light)");
804 assert!(!p.kind.gates());
805 assert!(!p.kind.is_station());
806 }
807
808 #[test]
809 fn kind_parse_roundtrip() {
810 for k in [
811 StationKind::UiClient,
812 StationKind::CliClient,
813 StationKind::Start,
814 StationKind::Emitter,
815 StationKind::PassThrough,
816 StationKind::Grpc,
817 StationKind::Terminus,
818 ] {
819 assert_eq!(StationKind::parse(k.as_str()), k);
820 }
821 assert_eq!(StationKind::parse("bogus"), StationKind::PassThrough);
822 }
823
824 #[test]
825 fn map_green_iff_all_lines_green() {
826 let m = demo_map();
827 assert_eq!(m.lines.len(), 2);
828 assert!(!m.is_green(), "line B has an unlit emitter");
829 assert_eq!(m.green_count(), 1);
830 }
831
832 #[test]
833 fn local_builds_two_line_demo() {
834 let v = MetroView::local();
835 assert_eq!(v.map().lines.len(), 2);
836 assert!(v.map().lines[0].is_green());
837 assert!(!v.map().lines[1].is_green());
838 }
839
840 #[test]
845 fn set_title_rekeys_view_and_keeps_the_map() {
846 let mut v = MetroView::local();
847 v.set_title("metro");
848 assert_eq!(Facet::title(&v), "metro", "title() reflects the new key");
849 let sj = v.state_json();
850 assert_eq!(sj["title"], "metro", "state_json carries the new title");
851 assert_eq!(sj["lines"].as_u64(), Some(2), "the demo map survived the re-title");
852 assert_eq!(sj["green_lines"].as_u64(), Some(1), "one green / one red line still");
853 }
854
855 #[test]
861 fn caller_arms_never_gate_the_trunk() {
862 assert!(!StationKind::UiClient.gates());
863 assert!(!StationKind::CliClient.gates());
864 assert!(StationKind::UiClient.is_client_arm() && StationKind::CliClient.is_client_arm());
865
866 let line = MetroLine::new(
867 "l",
868 "L",
869 vec![
870 MetroStation::new("s", "start", StationKind::Start, true),
871 MetroStation::with_branches(
872 "g",
873 "grpc",
874 StationKind::Grpc,
875 true,
876 vec![
877 MetroBranch::new("ui", "viz", StationKind::UiClient, false), MetroBranch::new("cli", "nornir", StationKind::CliClient, true),
879 ],
880 ),
881 MetroStation::new("t", "wh", StationKind::Terminus, true),
882 ],
883 );
884 assert!(line.is_green(), "an unlit caller arm does not gate the green trunk");
885 assert!(line.unlit().is_empty(), "arms are not counted as unlit gating stops");
886 }
887
888 #[test]
890 fn client_kinds_carry_the_right_marker() {
891 assert_eq!(StationKind::UiClient.marker(), Some("UI"));
892 assert_eq!(StationKind::CliClient.marker(), Some("CLI"));
893 assert_eq!(StationKind::Grpc.marker(), None);
894 let b = MetroBranch::new("x", "viz", StationKind::UiClient, true);
895 assert_eq!(b.marker(), Some("UI"));
896 }
897
898 #[test]
902 fn state_json_carries_the_branch_topology_and_markers() {
903 let v = MetroView::local(); let sj = v.state_json();
905 let lines = sj["line"].as_array().unwrap();
906
907 let line_a = &lines[0];
909 let grpc_a = line_a["stations"]
910 .as_array()
911 .unwrap()
912 .iter()
913 .find(|s| s["kind"] == "grpc")
914 .expect("line A has a grpc station");
915 let arms_a = grpc_a["branches"].as_array().unwrap();
916 assert_eq!(arms_a.len(), 2, "the verb called by BOTH renders two arms");
917 let markers_a: Vec<&str> = arms_a.iter().map(|b| b["marker"].as_str().unwrap()).collect();
918 assert!(markers_a.contains(&"UI") && markers_a.contains(&"CLI"), "UI+CLI markers: {markers_a:?}");
919 assert!(arms_a.iter().all(|b| b["lit"].as_bool().unwrap()), "both arms lit");
920 assert_eq!(arms_a.iter().find(|b| b["marker"] == "UI").unwrap()["kind"], "ui_client");
921 assert_eq!(arms_a.iter().find(|b| b["marker"] == "CLI").unwrap()["kind"], "cli_client");
922
923 let line_b = &lines[1];
925 let grpc_b = line_b["stations"]
926 .as_array()
927 .unwrap()
928 .iter()
929 .find(|s| s["kind"] == "grpc")
930 .expect("line B has a grpc station");
931 let arms_b = grpc_b["branches"].as_array().unwrap();
932 assert_eq!(arms_b.len(), 1, "the CLI-only verb renders a single arm");
933 assert_eq!(arms_b[0]["marker"], "CLI");
934 assert_eq!(arms_b[0]["kind"], "cli_client");
935
936 let start_a = line_a["stations"].as_array().unwrap().iter().find(|s| s["kind"] == "start").unwrap();
938 assert_eq!(start_a["branches"].as_array().unwrap().len(), 0, "linear stop has no forks");
939 }
940
941 #[test]
944 fn empty_map_is_the_empty_state() {
945 let v = MetroView::new("Metro");
946 let sj = v.state_json();
947 assert_eq!(sj["lines"].as_u64(), Some(0), "no lines on an empty map");
948 assert_eq!(sj["line"].as_array().unwrap().len(), 0);
949 assert!(v.map().lines.is_empty());
950 }
951
952 #[test]
955 fn harness_snapshot_drives_select_and_clear() {
956 use facett_core::harness;
957 let mut v = MetroView::local();
958 let snap = harness::snapshot(&mut v, [Msg::SelectLine("bench_run\u{2192}Bench.Submit".into())]);
960 assert_eq!(snap.selected.as_deref(), Some("bench_run\u{2192}Bench.Submit"), "selection is observable headlessly");
961 assert_eq!(&snap, v.state(), "the snapshot is a clone of the live state");
962 let snap = harness::snapshot(&mut v, [Msg::SelectLine("docs_export\u{2192}Docs.Render".into()), Msg::ClearSelection]);
964 assert_eq!(snap.selected, None, "ClearSelection empties the selection");
965 }
966
967 #[test]
968 fn drive_reports_no_effects_and_state_round_trips() {
969 use facett_core::harness;
970 let mut v = MetroView::local();
971 let effects = harness::drive(&mut v, [Msg::SelectLine("bench_run\u{2192}Bench.Submit".into())]);
973 assert!(effects.is_empty(), "FC-8: metro emits no Effects");
974 let json = serde_json::to_value(v.state()).unwrap();
976 assert_eq!(json["selected"], "bench_run\u{2192}Bench.Submit");
977 let back: MetroState = serde_json::from_value(json).unwrap();
978 assert_eq!(&back, v.state(), "serde(state) -> state round-trips (the whole map survives)");
979 }
980
981 #[test]
985 fn selection_survives_line_reorder_on_stable_id() {
986 use facett_core::harness;
987 let mut v = MetroView::local();
988 let _ = harness::drive(&mut v, [Msg::SelectLine("docs_export\u{2192}Docs.Render".into())]);
989 let mut reordered = demo_map();
991 reordered.lines.reverse();
992 v.set_map(reordered);
993 assert_eq!(v.state().selected.as_deref(), Some("docs_export\u{2192}Docs.Render"));
995 assert!(
996 v.map().lines.iter().any(|l| Some(l.id.as_str()) == v.state().selected.as_deref()),
997 "the selected id still names a real line after the reorder",
998 );
999 assert_eq!(v.copy_text().as_deref(), Some("Docs Export"));
1001 }
1002}