facett-brokkr/tests/graph_editor.rs
use egui::{Event, PointerButton, pos2, vec2};
use egui_kittest::{Harness, kittest::Queryable};
use facett_brokkr::{Mode, Msg, Pipeline, PipelineBuilder, RunKind, Stage, Step};
use facett_core::{Elm, Facet};
fn builder() -> PipelineBuilder {
PipelineBuilder::from_pipeline(
"editor",
Pipeline {
name: "ci".into(),
stages: vec![Stage {
name: "build".into(),
steps: ["a", "b", "c"]
.map(|name| Step {
name: name.into(),
run: RunKind::Variant("test".into()),
..Default::default()
})
.into(),
}],
..Default::default()
},
)
}
#[test]
fn connections_refuse_cycles_and_self_loops_and_disconnect_is_undoable() {
let mut b = builder();
b.update(Msg::Connect {
from: "a".into(),
to: "b".into(),
});
b.update(Msg::Connect {
from: "b".into(),
to: "c".into(),
});
let before = b.pipeline_toml();
for (from, to) in [("c", "a"), ("a", "a"), ("missing", "a")] {
b.update(Msg::Connect {
from: from.into(),
to: to.into(),
});
assert_eq!(b.pipeline_toml(), before);
assert!(b.state_json()["last_refusal"].is_string());
}
assert_eq!(b.state_json()["undo_count"], 2);
b.update(Msg::Disconnect {
from: "a".into(),
to: "b".into(),
});
assert!(b.to_pipeline().stages[0].steps[1].needs.is_empty());
b.update(Msg::Undo);
assert_eq!(b.pipeline_toml(), before);
b.update(Msg::Redo);
assert!(b.to_pipeline().stages[0].steps[1].needs.is_empty());
}
#[test]
fn placement_is_session_state_and_camera_does_not_rebuild_scene() {
let mut b = builder();
let toml = b.pipeline_toml();
let generation = b.scene_generation();
b.update(Msg::Pan(vec2(30.0, -5.0)));
b.update(Msg::Zoom {
factor: 1.3,
anchor: pos2(60.0, 80.0),
});
assert_eq!(b.scene_generation(), generation);
b.update(Msg::MoveNode {
node: "a".into(),
position: [90.0, 130.0],
});
assert_ne!(b.scene_generation(), generation);
assert_eq!(b.node_rect("a").unwrap().center(), pos2(90.0, 130.0));
assert_eq!(b.pipeline_toml(), toml);
assert_eq!(
b.state_json()["layout_overrides"]["a"],
serde_json::json!([90.0, 130.0])
);
b.update(Msg::Undo);
assert_eq!(b.scene_generation(), generation);
b.update(Msg::MoveNode {
node: "a".into(),
position: [f32::INFINITY, 1.0],
});
assert_eq!(b.scene_generation(), generation);
let restored: facett_brokkr::Model =
serde_json::from_value(serde_json::to_value(b.state()).unwrap()).unwrap();
assert_eq!(&restored, b.state());
}
#[test]
fn thousand_seeded_edit_sequences_restore_exact_text_and_placement() {
let mut seed = 37_u64;
for _ in 0..1000 {
let mut b = builder();
b.set_mode(Mode::Text);
let original = format!("# preserve exact input formatting\n{}", b.pipeline_toml());
b.set_text(original.clone());
let baseline = b.state_json()["undo_count"].as_u64().unwrap();
for _ in 0..24 {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
let (from, to) = match (seed >> 8) % 3 {
0 => ("a", "b"),
1 => ("a", "c"),
_ => ("b", "c"),
};
b.update(match seed % 8 {
0 => Msg::Connect {
from: from.into(),
to: to.into(),
},
1 => Msg::Disconnect {
from: from.into(),
to: to.into(),
},
2 => Msg::MoveNode {
node: from.into(),
position: [(seed % 800) as f32, (seed % 300) as f32],
},
3 => Msg::SetName(format!("bygge-åäö-{seed}")),
4 => Msg::AddStage(format!("stage-{seed}")),
5 => Msg::RemoveStage(b.to_pipeline().stages.len().saturating_sub(1).max(1)),
6 => Msg::AddStep {
stage: 0,
name: format!("extra-{seed}"),
run: RunKind::Variant("check".into()),
},
_ => Msg::RemoveStep {
stage: 0,
step: b.to_pipeline().stages[0]
.steps
.len()
.saturating_sub(1)
.max(3),
},
});
}
let edited = b.pipeline_toml();
let placement = b.state_json()["layout_overrides"].clone();
let count = b.state_json()["undo_count"].as_u64().unwrap() - baseline;
for _ in 0..count {
b.update(Msg::Undo);
}
assert_eq!(b.pipeline_toml(), original);
assert_eq!(b.state_json()["layout_overrides"], serde_json::json!({}));
for _ in 0..count {
b.update(Msg::Redo);
}
assert_eq!(b.pipeline_toml(), edited);
assert_eq!(b.state_json()["layout_overrides"], placement);
}
}
#[test]
fn history_is_bounded_and_robot_seam_edits_the_same_model() {
let mut b = builder();
for i in 0..250 {
b.set_name(format!("pipeline{i}"));
}
assert_eq!(b.state_json()["undo_count"], 200);
b.update_json(r#"{"connect":["a","b"]}"#);
assert_eq!(b.to_pipeline().stages[0].steps[1].needs, ["a"]);
b.update_json(r#"{"move":["b",400,120]}"#);
assert_eq!(b.node_rect("b").unwrap().center(), pos2(400.0, 120.0));
b.update_json(r#""undo""#);
assert!(
!b.state_json()["layout_overrides"]
.as_object()
.unwrap()
.contains_key("b")
);
b.update_json(r#""redo""#);
assert_eq!(b.node_rect("b").unwrap().center(), pos2(400.0, 120.0));
b.update_json(r#"{"disconnect":["a","b"]}"#);
assert!(b.to_pipeline().stages[0].steps[1].needs.is_empty());
}
fn button(pos: egui::Pos2, pressed: bool) -> Event {
Event::PointerButton {
pos,
button: PointerButton::Primary,
pressed,
modifiers: Default::default(),
}
}
fn pointer_frame(h: &mut Harness<'_, PipelineBuilder>, events: impl IntoIterator<Item = Event>) {
for event in events {
h.event(event);
}
h.step();
}
fn harness() -> Harness<'static, PipelineBuilder> {
let mut b = builder();
b.set_mode(Mode::Graph);
Harness::builder()
.with_size(vec2(900.0, 600.0))
.build_ui_state(|ui, b: &mut PipelineBuilder| b.ui(ui), b)
}
#[test]
fn real_port_drag_connects_and_node_drag_commits_only_on_release() {
let mut h = harness();
h.run();
assert!(h.query_by_label("Output a").is_some());
assert!(h.query_by_label("Input b").is_some());
let nav = h.state().state().nav;
let start = nav.to_screen(h.state().node_rect("a").unwrap().right_center());
let end = nav.to_screen(h.state().node_rect("b").unwrap().left_center());
pointer_frame(&mut h, [Event::PointerMoved(start)]);
pointer_frame(&mut h, [button(start, true)]);
pointer_frame(&mut h, [Event::PointerMoved(start + vec2(20.0, 0.0))]);
pointer_frame(&mut h, [Event::PointerMoved(end)]);
pointer_frame(&mut h, [button(end, false)]);
assert_eq!(
h.state().to_pipeline().stages[0].steps[1].needs,
["a"],
"drag must edit TOML through needs"
);
h.run();
let nav = h.state().state().nav;
let start = nav.to_screen(h.state().node_rect("a").unwrap().center());
let end = start + vec2(45.0, 30.0);
let generation = h.state().scene_generation();
let text = h.state().pipeline_toml();
pointer_frame(&mut h, [Event::PointerMoved(start)]);
pointer_frame(&mut h, [button(start, true)]);
pointer_frame(&mut h, [Event::PointerMoved(end)]);
assert_eq!(
h.state().scene_generation(),
generation,
"ghost does not rebuild geometry"
);
pointer_frame(&mut h, [button(end, false)]);
assert_ne!(h.state().scene_generation(), generation);
assert_eq!(h.state().pipeline_toml(), text);
assert!((nav.to_screen(h.state().node_rect("a").unwrap().center()) - end).length() < 0.1);
}
#[test]
fn elm_view_is_pure_and_emits_fit_message() {
let b = {
let mut b = builder();
b.set_mode(Mode::Graph);
b
};
let before = b.state().clone();
let mut emitted = Vec::new();
let ctx = egui::Context::default();
ctx.run_ui(egui::RawInput::default(), |ui| {
emitted.extend(Elm::view(&b, ui))
})
.textures_delta
.clear();
assert_eq!(b.state(), &before);
assert!(emitted.iter().any(|m| matches!(m, Msg::Fit(_))));
}
#[test]
fn selecting_edge_and_delete_then_keyboard_undo_redo_updates_document() {
let mut h = harness();
h.state_mut().update(Msg::Connect {
from: "a".into(),
to: "b".into(),
});
h.run();
let nav = h.state().state().nav;
let a = h.state().node_rect("a").unwrap().right_center();
let b = h.state().node_rect("b").unwrap().left_center();
let midpoint = nav.to_screen(a.lerp(b, 0.5));
pointer_frame(&mut h, [Event::PointerMoved(midpoint)]);
pointer_frame(&mut h, [button(midpoint, true)]);
pointer_frame(&mut h, [button(midpoint, false)]);
assert_eq!(
h.state().state_json()["selected_edge"],
serde_json::json!(["a", "b"])
);
h.key_down(egui::Key::Delete);
h.step();
h.key_up(egui::Key::Delete);
h.step();
assert!(h.state().to_pipeline().stages[0].steps[1].needs.is_empty());
h.key_down_modifiers(egui::Modifiers::COMMAND, egui::Key::Z);
h.step();
h.key_up(egui::Key::Z);
h.step();
assert_eq!(h.state().to_pipeline().stages[0].steps[1].needs, ["a"]);
h.key_down_modifiers(
egui::Modifiers::COMMAND | egui::Modifiers::SHIFT,
egui::Key::Z,
);
h.step();
h.key_up(egui::Key::Z);
h.step();
assert!(h.state().to_pipeline().stages[0].steps[1].needs.is_empty());
}
#[test]
fn invalid_in_progress_stage_names_remain_editable() {
let mut b = builder();
b.set_text(
b.pipeline_toml()
.replace("name = \"build\"", "name = \" \""),
);
assert!(!b.validate().valid);
b.set_mode(Mode::Graph);
let mut h = Harness::builder()
.with_size(vec2(800.0, 500.0))
.build_ui_state(|ui, b: &mut PipelineBuilder| b.ui(ui), b);
h.run();
assert!(h.query_by_label("Output a").is_some());
}
#[test]
fn graph_edits_use_the_normalized_names_emitted_to_toml() {
let mut b = builder();
b.set_text(b.pipeline_toml().replace("name = \"b\"", "name = \" b \""));
b.update(Msg::Connect {
from: "a".into(),
to: "b".into(),
});
assert_eq!(b.to_pipeline().stages[0].steps[1].needs, ["a"]);
b.update(Msg::MoveNode {
node: "b".into(),
position: [55.0, 77.0],
});
assert_eq!(b.node_rect("b").unwrap().center(), pos2(55.0, 77.0));
b.update(Msg::SelectNode("b".into()));
assert_eq!(b.state_json()["selected"], "b");
}
#[test]
fn gesture_history_contains_compact_forward_and_inverse_commands() {
let mut b = builder();
b.set_text(format!(
"# {}\n{}",
"original formatting ".repeat(10_000),
b.pipeline_toml()
));
let original = b.pipeline_toml();
b.update(Msg::MoveNode {
node: "a".into(),
position: [17.0, 33.0],
});
let state = serde_json::to_value(b.state()).unwrap();
let entry = state["undo"].as_array().unwrap().last().unwrap();
assert_eq!(entry["forward"].as_array().unwrap().len(), 1);
assert_eq!(entry["forward"][0]["Placement"]["node"], "a");
assert_eq!(
entry["inverse"][0]["Placement"]["position"],
serde_json::Value::Null
);
assert!(
entry.to_string().len() < 250,
"move history must not retain a document checkpoint"
);
b.update(Msg::Connect {
from: "a".into(),
to: "b".into(),
});
let state = serde_json::to_value(b.state()).unwrap();
let entry = state["undo"].as_array().unwrap().last().unwrap();
for direction in ["forward", "inverse"] {
let commands = entry[direction].as_array().unwrap();
assert!(commands.iter().all(|cmd| cmd.get("Document").is_none()));
assert!(
commands.iter().any(|cmd| cmd.get("Step").is_some()),
"dependency edit stores the changed step field"
);
}
b.update(Msg::Undo);
b.update(Msg::Undo);
assert_eq!(b.pipeline_toml(), original);
}
#[test]
fn unicode_text_splices_and_raw_invalid_document_replay_exactly() {
let mut b = builder();
b.set_name("åäö-🚀");
let before = b.pipeline_toml();
b.set_name("åäö-🛰");
let after = b.pipeline_toml();
b.update(Msg::Undo);
assert_eq!(b.pipeline_toml(), before);
b.update(Msg::Redo);
assert_eq!(b.pipeline_toml(), after);
b.set_mode(Mode::Text);
let invalid = "# behoud UTF-8: åäö 🚀\n[pipeline\n";
b.set_text(invalid);
let state = serde_json::to_value(b.state()).unwrap();
assert!(
state["undo"].as_array().unwrap().last().unwrap()["forward"][0]
.get("Document")
.is_some()
);
assert!(state["parse_error"].is_string());
b.update(Msg::Undo);
assert_eq!(b.pipeline_toml(), after);
b.update(Msg::Redo);
assert_eq!(b.pipeline_toml(), invalid);
}
#[test]
fn structural_draft_fields_and_trigger_rows_have_reversible_commands() {
let mut b = builder();
b.set_mode(Mode::Build);
let before = b.pipeline_toml();
let mut pipeline = b.to_pipeline();
pipeline.stages[0].name = "compile-åäö".into();
let step = &mut pipeline.stages[0].steps[0];
step.name = "renamed".into();
step.run = RunKind::Container(facett_brokkr::ContainerRun {
name: "rust:test".into(),
cmd: vec!["cargo".into(), "check".into()],
});
step.artifacts = vec!["target/output".into()];
step.report = Some("junit".into());
pipeline.stages[0].steps[1].needs = vec!["renamed".into()];
let draft = PipelineBuilder::from_pipeline("editor", pipeline);
b.update(Msg::Edit(Box::new(draft.state().clone())));
let edited = b.pipeline_toml();
b.update(Msg::AddTrigger(facett_brokkr::Trigger {
events: vec!["push".into()],
branch: Some("main".into()),
..Default::default()
}));
let with_trigger = b.pipeline_toml();
b.update(Msg::RemoveTrigger(0));
let state = serde_json::to_value(b.state()).unwrap();
for entry in state["undo"].as_array().unwrap() {
for direction in ["forward", "inverse"] {
assert!(
entry[direction]
.as_array()
.unwrap()
.iter()
.all(|c| c.get("Document").is_none())
);
}
}
b.update(Msg::Undo);
assert_eq!(b.pipeline_toml(), with_trigger);
b.update(Msg::Undo);
assert_eq!(b.pipeline_toml(), edited);
b.update(Msg::Undo);
assert_eq!(b.pipeline_toml(), before);
for _ in 0..3 {
b.update(Msg::Redo);
}
assert_eq!(b.pipeline_toml(), edited);
}