Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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);
}