use std::num::NonZeroU16;
use monospace_core::{
Buffer, Direction, Glyph, GlyphCatalog, Orientation, Pos, Size, Stroke, Terminal, Text, Window,
render,
};
use monospace_diagram::{
Anchor, Delta, Diagram, End, Endpoint, EndpointAt, Reference, Shape, ShapeId,
};
use monospace_editing::{Command, Session};
const EXAMPLE: Size = Size {
width: 8,
height: 3,
};
const FIXTURE: Size = Size {
width: 12,
height: 6,
};
const ORIGIN: Pos = Pos { x: 0, y: 0 };
const AS_WRITTEN: &str = "┌──┐ \n│ │ \n└──┘ \n";
const FOUR_RIGHT: &str = " ┌──┐\n │ │\n └──┘\n";
const ONE_RIGHT: &str = " ┌──┐ \n │ │ \n └──┘ \n";
const SIX_RIGHT: &str = " ┌─\n │ \n └─\n";
fn id(ordinal: u16) -> ShapeId {
ShapeId::new(NonZeroU16::new(ordinal).expect("no test names zero"))
}
fn small_box(at: Pos) -> Shape {
Shape::Box {
at,
size: Size {
width: 4,
height: 3,
},
stroke: Stroke::from("light"),
fill: None,
text: None,
}
}
fn smallest_box(at: Pos) -> Shape {
Shape::Box {
at,
size: Size {
width: 2,
height: 2,
},
stroke: Stroke::from("light"),
fill: None,
text: None,
}
}
fn one_box(at: Pos) -> Diagram {
Diagram::new().add(small_box(at)).0
}
fn picture_in(diagram: &Diagram, size: Size) -> String {
let mut buffer = Buffer::new(Window {
origin: ORIGIN,
size,
});
diagram.draw(&mut buffer);
render(&buffer, &GlyphCatalog::light(), buffer.window())
}
fn picture(session: &Session) -> String {
picture_in(session.diagram(), EXAMPLE)
}
fn example_picture(diagram: &Diagram) -> String {
picture_in(diagram, EXAMPLE)
}
fn after(command: &Command) -> String {
let session = Session::new(the_fixture()).perform(command.clone());
picture_in(session.diagram(), FIXTURE)
}
fn after_operating(on_diagram: impl FnOnce(&Diagram) -> Diagram) -> String {
let diagram = the_fixture();
picture_in(&on_diagram(&diagram), FIXTURE)
}
fn the_fixture() -> Diagram {
let mut diagram = Diagram::new();
diagram = diagram.add(small_box(ORIGIN)).0;
diagram = diagram
.add(Shape::Box {
at: Pos { x: 2, y: 0 },
size: Size {
width: 4,
height: 3,
},
stroke: Stroke::from("heavy"),
fill: None,
text: None,
})
.0;
diagram = diagram
.add(Shape::Connector {
from: Endpoint::absolute(Pos { x: 0, y: 4 }, Direction::Right, Terminal::Arm),
to: Endpoint::absolute(
Pos { x: 8, y: 4 },
Direction::Left,
Terminal::Glyph {
glyph: Glyph::new("▶").expect("\"▶\" is one glyph"),
},
),
stroke: Stroke::from("light"),
})
.0;
diagram
}
fn an_extra_box() -> Shape {
Shape::Box {
at: Pos { x: 6, y: 5 },
size: Size {
width: 2,
height: 1,
},
stroke: Stroke::from("light"),
fill: None,
text: None,
}
}
#[test]
fn a_session_owns_the_diagram_it_was_created_over() {
let the_callers_own = one_box(ORIGIN);
let session = Session::new(the_callers_own.clone());
assert_eq!(
picture(&session),
example_picture(&the_callers_own),
"a session over a diagram draws that diagram"
);
let theirs = the_callers_own.forward(id(1)).remove(id(1));
assert_ne!(
picture_in(&theirs, EXAMPLE),
example_picture(&the_callers_own),
"the caller's own copy did change"
);
assert_eq!(
picture(&session),
example_picture(&the_callers_own),
"and the session, which was given the diagram by value, did not follow it"
);
}
#[test]
fn the_diagram_changes_only_by_a_command_performed_on_it() {
let given_nothing = Session::new(one_box(ORIGIN));
let given = given_nothing.perform(Command::Move {
id: id(1),
by: Delta { dx: 4, dy: 0 },
});
assert_eq!(
picture(&given_nothing),
AS_WRITTEN,
"the session the command was not given still holds what it was created over"
);
assert_eq!(
picture(&given),
FOUR_RIGHT,
"and the one it was given moved by four"
);
}
#[test]
fn no_session_method_changes_the_session_it_was_given() {
let _: fn(Diagram) -> Session = Session::new;
let _: for<'a> fn(&'a Session) -> &'a Diagram = Session::diagram;
let _: fn(&Session) -> bool = Session::can_undo;
let _: fn(&Session) -> bool = Session::can_redo;
let _: fn(&Session) -> Session = Session::undo;
let _: fn(&Session) -> Session = Session::redo;
let _: fn(&Session, Command) -> Session = Session::perform;
let _: fn(&Session, Shape) -> (Session, ShapeId) = Session::add;
}
#[test]
#[expect(
clippy::too_many_lines,
reason = "the contract test covers each command in the set against its operation"
)]
fn each_command_becomes_one_of_the_diagrams_seven_operations() {
let two_down = Delta { dx: 0, dy: 2 };
assert_eq!(
after(&Command::Move {
id: id(1),
by: two_down,
}),
after_operating(|diagram| {
let moved = diagram.get(id(1)).map(|shape| shape.displaced_by(two_down));
if let Some(moved) = moved {
diagram.replace(id(1), moved)
} else {
diagram.clone()
}
}),
"`Move` is `replace` with the figure displaced"
);
assert_eq!(
after(&Command::Resize {
id: id(1),
by: two_down,
}),
after_operating(|diagram| {
let resized = diagram.get(id(1)).map(|shape| shape.resized_by(two_down));
if let Some(resized) = resized {
diagram.replace(id(1), resized)
} else {
diagram.clone()
}
}),
"`Resize` is `replace` with the figure resized"
);
assert_eq!(
after(&Command::SetStroke {
id: id(1),
stroke: Stroke::from("double"),
}),
after_operating(|diagram| {
let updated = diagram
.get(id(1))
.map(|shape| shape.with_stroke(Stroke::from("double")));
if let Some(updated) = updated {
diagram.replace(id(1), updated)
} else {
diagram.clone()
}
}),
"`SetStroke` is `replace` with the figure's stroke changed"
);
assert_eq!(
after(&Command::SetText {
id: id(1),
text: Some(Text::new("hi").unwrap()),
}),
after_operating(|diagram| {
let updated = diagram
.get(id(1))
.map(|shape| shape.with_text(Some(Text::new("hi").unwrap())));
if let Some(updated) = updated {
diagram.replace(id(1), updated)
} else {
diagram.clone()
}
}),
"`SetText` is `replace` with the figure's text changed"
);
assert_eq!(
after(&Command::InvertTerminals { id: id(3) }),
after_operating(|diagram| {
let updated = diagram.get(id(3)).map(Shape::with_inverted_terminals);
if let Some(updated) = updated {
diagram.replace(id(3), updated)
} else {
diagram.clone()
}
}),
"`InvertTerminals` is `replace` with the figure's terminals swapped"
);
assert_eq!(
after(&Command::RotateLine { id: id(1) }),
after_operating(|diagram| {
let updated = diagram.get(id(1)).map(Shape::with_rotated_orientation);
if let Some(updated) = updated {
diagram.replace(id(1), updated)
} else {
diagram.clone()
}
}),
"`RotateLine` is `replace` with the line's orientation toggled"
);
assert_eq!(
after(&Command::MoveEndpoint {
id: id(3),
end: End::From,
by: Delta { dx: 1, dy: 0 },
out: 0,
}),
after_operating(|diagram| {
let updated = diagram
.get(id(3))
.map(|shape| shape.with_endpoint_moved(End::From, Delta { dx: 1, dy: 0 }, 0));
if let Some(updated) = updated {
diagram.replace(id(3), updated)
} else {
diagram.clone()
}
}),
"`MoveEndpoint` is `replace` with the endpoint moved"
);
assert_eq!(
after(&Command::ResetEndpoint {
id: id(3),
end: End::From,
}),
after_operating(|diagram| {
let updated = diagram
.get(id(3))
.map(|shape| shape.with_endpoint_reset(End::From));
if let Some(updated) = updated {
diagram.replace(id(3), updated)
} else {
diagram.clone()
}
}),
"`ResetEndpoint` is `replace` with the endpoint reset"
);
assert_eq!(
after(&Command::SetEndpointTerminal {
id: id(3),
end: End::From,
terminal: Terminal::Glyph {
glyph: Glyph::new("◆").unwrap(),
},
}),
after_operating(|diagram| {
let updated = diagram.get(id(3)).map(|shape| {
shape.with_endpoint_terminal(
End::From,
Terminal::Glyph {
glyph: Glyph::new("◆").unwrap(),
},
)
});
if let Some(updated) = updated {
diagram.replace(id(3), updated)
} else {
diagram.clone()
}
}),
"`SetEndpointTerminal` is `replace` with the endpoint terminal set"
);
assert_eq!(
after(&Command::DetachEndpoint {
id: id(3),
end: End::From,
}),
after_operating(|diagram| {
let updated = diagram
.get(id(3))
.map(|shape| shape.with_endpoint_detached(End::From, diagram));
if let Some(updated) = updated {
diagram.replace(id(3), updated)
} else {
diagram.clone()
}
}),
"`DetachEndpoint` is `replace` with the endpoint detached"
);
assert_eq!(
after(&Command::SetEndpointLeaving {
id: id(3),
end: End::From,
leaving: Direction::Up,
}),
after_operating(|diagram| {
let updated = diagram
.get(id(3))
.map(|shape| shape.with_endpoint_leaving(End::From, Direction::Up));
if let Some(updated) = updated {
diagram.replace(id(3), updated)
} else {
diagram.clone()
}
}),
"`SetEndpointLeaving` is `replace` with the endpoint leaving direction set"
);
assert_eq!(
after(&Command::Hang {
id: id(3),
end: End::From,
target: id(1),
anchor: Anchor::Bottom,
}),
after_operating(|diagram| {
if let Some(shape) = diagram.get(id(3)).cloned() {
diagram.replace(
id(3),
shape.with_endpoint_attached(
End::From,
Reference::new(id(1), Anchor::Bottom, Delta::ZERO, 0),
diagram,
),
)
} else {
diagram.clone()
}
}),
"`Hang` is `replace` with one end of the connector attached"
);
assert_eq!(
after(&Command::Forward { id: id(1) }),
after_operating(|diagram| diagram.forward(id(1))),
"`Forward` is `forward`"
);
assert_eq!(
after(&Command::Backward { id: id(2) }),
after_operating(|diagram| diagram.backward(id(2))),
"`Backward` is `backward`"
);
assert_eq!(
after(&Command::ToFront { id: id(1) }),
after_operating(|diagram| diagram.to_front(id(1))),
"`ToFront` is `to_front`"
);
assert_eq!(
after(&Command::ToBack { id: id(2) }),
after_operating(|diagram| diagram.to_back(id(2))),
"`ToBack` is `to_back`"
);
assert_eq!(
after(&Command::Add {
shape: an_extra_box(),
}),
after_operating(|diagram| diagram.add(an_extra_box()).0),
"`Add` is `add`"
);
assert_eq!(
after(&Command::Remove { id: id(2) }),
after_operating(|diagram| diagram.remove(id(2))),
"`Remove` is `remove`"
);
}
#[test]
fn no_command_reaches_the_diagram_by_another_way() {
let mut session = Session::new(the_fixture());
for command in [
Command::Forward { id: id(1) },
Command::Backward { id: id(2) },
Command::ToFront { id: id(1) },
Command::ToBack { id: id(2) },
Command::Move {
id: id(1),
by: Delta { dx: 0, dy: 2 },
},
Command::Hang {
id: id(3),
end: End::From,
target: id(1),
anchor: Anchor::Bottom,
},
Command::MoveEndpoint {
id: id(3),
end: End::From,
by: Delta { dx: 1, dy: 0 },
out: 0,
},
Command::ResetEndpoint {
id: id(3),
end: End::From,
},
Command::Forward { id: id(7) },
Command::Move {
id: id(7),
by: Delta { dx: 1, dy: 1 },
},
Command::Resize {
id: id(7),
by: Delta { dx: 2, dy: 1 },
},
Command::Remove { id: id(2) },
Command::Remove { id: id(7) },
] {
session = session.perform(command.clone());
}
let diagram = session.diagram();
assert!(
diagram.get(id(2)).is_none(),
"`Remove` took the figure it named out"
);
assert!(diagram.get(id(1)).is_some(), "the first box is still there");
assert!(
diagram.get(id(3)).is_some(),
"the connector is still there, whatever `Hang` did to one end of it"
);
let (copy, issued) = diagram.clone().add(an_extra_box());
assert_eq!(
issued,
id(4),
"no command reached `add`, so the next ordinal is the one the fixture issued"
);
assert!(
copy.get(issued).is_some(),
"the copy holds the figure that was added to it"
);
assert!(
diagram.get(issued).is_none(),
"and the diagram the session hands out does not"
);
}
#[test]
fn a_command_naming_nothing_records_no_step() {
let mut session = Session::new(one_box(ORIGIN));
let as_written = picture(&session);
session = session.perform(Command::Move {
id: id(7),
by: Delta { dx: 1, dy: 0 },
});
assert_eq!(
picture(&session),
as_written,
"a command naming a figure the diagram does not hold changes nothing"
);
assert!(!session.can_undo(), "and records no step");
assert!(
!session.can_redo(),
"and leaves nothing ahead of a position that did not move"
);
}
#[test]
fn two_commands_are_two_steps_and_are_never_merged() {
let mut session = Session::new(one_box(ORIGIN));
let mut forwards = vec![picture(&session)];
for _ in 0..3 {
session = session.perform(Command::Move {
id: id(1),
by: Delta { dx: 2, dy: 0 },
});
forwards.push(picture(&session));
}
let mut backwards = Vec::new();
while session.can_undo() {
session = session.undo();
backwards.push(picture(&session));
}
let expected: Vec<String> = forwards[..3].iter().rev().cloned().collect();
assert_eq!(
backwards, expected,
"three commands left three steps, and giving them back one at a time walks the pictures \
back in the order they were made"
);
}
#[test]
fn undo_restores_the_diagram_one_step_back() {
let mut session = Session::new(one_box(ORIGIN));
session = session.perform(Command::Move {
id: id(1),
by: Delta { dx: 4, dy: 0 },
});
assert_eq!(
picture(&session),
FOUR_RIGHT,
"the figure moved four to the right"
);
assert!(session.can_undo(), "which left a step behind it");
session = session.undo();
assert_eq!(
picture(&session),
AS_WRITTEN,
"and the undo brought it back"
);
assert!(!session.can_undo(), "with nothing behind it now");
}
#[test]
fn redo_restores_the_diagram_the_step_ahead_holds() {
let mut session = Session::new(one_box(ORIGIN));
session = session.perform(Command::Move {
id: id(1),
by: Delta { dx: 4, dy: 0 },
});
session = session.undo();
session = session.redo();
assert_eq!(
picture(&session),
FOUR_RIGHT,
"the redo brought the move back"
);
assert!(!session.can_redo(), "with nothing ahead of it now");
assert_eq!(
picture(&session),
FOUR_RIGHT,
"and it is the picture the move made, not the one the undo left"
);
}
#[test]
fn undo_with_nothing_behind_changes_nothing() {
let mut session = Session::new(one_box(ORIGIN));
let as_written = picture(&session);
session = session.undo();
assert_eq!(picture(&session), as_written, "nothing was given back");
assert!(!session.can_undo(), "and nothing is behind the position");
}
#[test]
fn redo_with_nothing_ahead_changes_nothing() {
let mut session = Session::new(one_box(ORIGIN));
session = session.perform(Command::Move {
id: id(1),
by: Delta { dx: 4, dy: 0 },
});
let as_left = picture(&session);
session = session.redo();
assert_eq!(picture(&session), as_left, "nothing was carried out again");
assert!(!session.can_redo(), "and nothing is ahead of the position");
}
#[test]
fn a_step_holds_the_diagram_the_command_left() {
let session = Session::new(one_box(ORIGIN));
let after_one = session.perform(Command::Move {
id: id(1),
by: Delta { dx: 4, dy: 0 },
});
let after_two = after_one.perform(Command::Move {
id: id(1),
by: Delta { dx: 2, dy: 0 },
});
assert_eq!(
picture(&session),
AS_WRITTEN,
"the session the commands were not given is the one it was created over"
);
assert_eq!(
picture(&after_two),
SIX_RIGHT,
"and both commands were carried out"
);
assert_eq!(
picture(&after_two.undo()),
FOUR_RIGHT,
"one give back is the state the first command left, which is the step behind the position"
);
assert_eq!(
picture(&after_two.redo()),
SIX_RIGHT,
"and the step ahead is the state the second left"
);
assert_eq!(
picture(&after_two.undo().undo()),
AS_WRITTEN,
"giving both back reaches the diagram as it was rather than the one in between twice"
);
}
#[test]
fn the_history_is_a_line_a_position_cannot_disagree_with() {
let start = Session::new(one_box(ORIGIN));
let after_one = start.perform(Command::Move {
id: id(1),
by: Delta { dx: 4, dy: 0 },
});
let after_two = after_one.perform(Command::Move {
id: id(1),
by: Delta { dx: 2, dy: 0 },
});
assert!(
after_two.can_undo() && !after_two.can_redo(),
"at the newest position there is something behind and nothing ahead"
);
let mut backwards = vec![picture(&after_two)];
let mut behind = after_two.clone();
while behind.can_undo() {
behind = behind.undo();
backwards.push(picture(&behind));
}
assert_eq!(
backwards,
vec![SIX_RIGHT, FOUR_RIGHT, AS_WRITTEN],
"walking back passes through every state in the order it was made, the other way round"
);
assert!(
!behind.can_undo(),
"and at the oldest there is nothing behind"
);
assert!(
behind.can_redo(),
"while everything that was walked over is ahead, which is the same position read twice"
);
assert_eq!(
picture(&behind.undo()),
AS_WRITTEN,
"and asking again at the oldest end answers with the session as it stands"
);
let mut forwards = vec![picture(&behind)];
let mut ahead = behind;
while ahead.can_redo() {
ahead = ahead.redo();
forwards.push(picture(&ahead));
}
assert_eq!(
forwards,
vec![AS_WRITTEN, FOUR_RIGHT, SIX_RIGHT],
"walking forward retraces the same pictures in the other order"
);
assert!(
!ahead.can_redo(),
"and at the newest end there is nothing ahead"
);
assert_eq!(
picture(&ahead.redo()),
SIX_RIGHT,
"and asking again at the newest end answers with the session as it stands"
);
}
#[test]
fn a_command_with_steps_ahead_discards_them() {
let mut session = Session::new(one_box(ORIGIN));
session = session.perform(Command::Move {
id: id(1),
by: Delta { dx: 4, dy: 0 },
});
session = session.perform(Command::Move {
id: id(1),
by: Delta { dx: -1, dy: 0 },
});
session = session.undo();
assert_eq!(
picture(&session),
FOUR_RIGHT,
"one give back is the state the first move left"
);
session = session.perform(Command::Move {
id: id(1),
by: Delta { dx: 2, dy: 0 },
});
assert_eq!(
picture(&session),
SIX_RIGHT,
"the new command was carried out from where the session stood"
);
assert!(
!session.can_redo(),
"the step that was ahead of the position was discarded"
);
session = session.undo();
assert_eq!(
picture(&session),
FOUR_RIGHT,
"one back is the state the first move left"
);
assert_ne!(
picture(&session),
ONE_RIGHT,
"and not the state the discarded step held, which stood one cell nearer"
);
session = session.undo();
assert_eq!(
picture(&session),
AS_WRITTEN,
"and the second back is the diagram the session began with"
);
}
#[test]
fn whether_undo_is_available_is_answerable_without_carrying_it_out() {
let mut session = Session::new(one_box(ORIGIN));
let as_written = picture(&session);
assert!(
(session.can_undo(), session.can_redo()) == (false, false),
"a session with nothing behind it has nothing to give back and nothing to carry out"
);
session = session.perform(Command::Move {
id: id(1),
by: Delta { dx: 4, dy: 0 },
});
assert!(
(session.can_undo(), session.can_redo()) == (true, false),
"after one command there is a step behind and none ahead"
);
assert!(
session.can_undo(),
"asked again, and the picture is untouched by being asked"
);
session = session.undo();
assert!(
(session.can_undo(), session.can_redo()) == (false, true),
"at the oldest position it is the other way round"
);
assert!(session.can_redo());
assert_eq!(
picture(&session),
as_written,
"and every question left the picture where it was"
);
}
#[test]
fn drawing_what_a_session_holds_needs_no_copy() {
let diagram = one_box(ORIGIN);
let mut as_created = Buffer::new(Window {
origin: ORIGIN,
size: EXAMPLE,
});
diagram.draw(&mut as_created);
let session = Session::new(diagram);
let mut borrowed = Buffer::new(Window {
origin: ORIGIN,
size: EXAMPLE,
});
session.diagram().draw(&mut borrowed);
assert_eq!(
render(&borrowed, &GlyphCatalog::light(), borrowed.window()),
render(&as_created, &GlyphCatalog::light(), as_created.window()),
"a drawing of what a session holds is a drawing of the diagram it was created over"
);
assert_eq!(
render(&borrowed, &GlyphCatalog::light(), borrowed.window()),
AS_WRITTEN,
"and it is the picture that diagram draws"
);
let theirs = session.diagram().clone();
let mut session = session;
session = session.perform(Command::Move {
id: id(1),
by: Delta { dx: 4, dy: 0 },
});
assert_eq!(
picture_in(&theirs, EXAMPLE),
AS_WRITTEN,
"the copy did not move with the session's diagram"
);
assert_eq!(picture(&session), FOUR_RIGHT, "which did");
}
#[test]
fn a_resize_command_changes_the_size() {
let session = Session::new(the_fixture()).perform(Command::Resize {
id: id(1),
by: Delta { dx: 2, dy: 1 },
});
let Shape::Box { size, .. } = session.diagram().get(id(1)).expect("box is still there") else {
panic!("expected box");
};
assert_eq!(
*size,
Size {
width: 6,
height: 4
}
);
}
#[test]
fn a_resize_naming_nothing_leaves_the_diagram_untouched_and_records_no_step() {
let session = Session::new(the_fixture()).perform(Command::Resize {
id: id(999),
by: Delta { dx: 2, dy: 1 },
});
assert_eq!(
picture_in(session.diagram(), FIXTURE),
picture_in(&the_fixture(), FIXTURE)
);
assert!(!session.can_undo(), "and no step was recorded");
}
#[test]
fn a_resize_at_the_smallest_size_records_no_step() {
let diagram = Diagram::new().add(smallest_box(ORIGIN)).0;
let session = Session::new(diagram);
let before = picture_in(session.diagram(), EXAMPLE);
let shrunk = session.perform(Command::Resize {
id: id(1),
by: Delta { dx: -1, dy: -1 },
});
assert_eq!(
picture_in(shrunk.diagram(), EXAMPLE),
before,
"shrinking a 2×2 box changes nothing"
);
assert!(!shrunk.can_undo(), "and records no step");
let grown = shrunk.perform(Command::Resize {
id: id(1),
by: Delta { dx: 1, dy: 0 },
});
assert!(
grown.can_undo(),
"but growing it again records its step as any other command"
);
}
#[test]
fn hanging_an_end_where_it_already_hangs_records_no_step() {
let session = Session::new(the_fixture());
let initial_pic = picture_in(session.diagram(), FIXTURE);
let hung = session.perform(Command::Hang {
id: id(3),
end: End::From,
target: id(1),
anchor: Anchor::Bottom,
});
assert!(hung.can_undo(), "hanging an end records its step");
let hung_again = hung.perform(Command::Hang {
id: id(3),
end: End::From,
target: id(1),
anchor: Anchor::Bottom,
});
assert_eq!(
picture_in(hung_again.diagram(), FIXTURE),
picture_in(hung.diagram(), FIXTURE),
"hanging it where it already hangs changes nothing"
);
let undone = hung_again.undo();
assert_eq!(
picture_in(undone.diagram(), FIXTURE),
initial_pic,
"so a single undo returns to the initial state"
);
}
#[test]
fn attaching_the_to_end_attaches_to_the_target() {
let session = Session::new(the_fixture());
let initial_pic = picture_in(session.diagram(), FIXTURE);
let attached = session.perform(Command::Hang {
id: id(3),
end: End::To,
target: id(2),
anchor: Anchor::Bottom,
});
assert!(attached.can_undo(), "attaching the to end records its step");
assert_ne!(
picture_in(attached.diagram(), FIXTURE),
initial_pic,
"attaching the to end changes the diagram"
);
let Shape::Connector { to, from, .. } = attached.diagram().get(id(3)).unwrap() else {
panic!("id(3) must be a connector");
};
assert_eq!(
to.at,
EndpointAt::Reference(Reference::new(id(2), Anchor::Bottom, Delta::ZERO, 0))
);
assert!(matches!(from.at, EndpointAt::Absolute { .. }));
let undone = attached.undo();
assert_eq!(
picture_in(undone.diagram(), FIXTURE),
initial_pic,
"undo returns to the initial state"
);
}
#[test]
fn attaching_to_a_missing_target_records_no_step() {
let session = Session::new(the_fixture());
let initial_pic = picture_in(session.diagram(), FIXTURE);
let attempted = session.perform(Command::Hang {
id: id(3),
end: End::From,
target: id(99),
anchor: Anchor::Bottom,
});
assert!(
!attempted.can_undo(),
"attaching to a missing target records no step"
);
assert_eq!(
picture_in(attempted.diagram(), FIXTURE),
initial_pic,
"attaching to a missing target leaves diagram unchanged"
);
}
#[test]
fn attaching_to_a_connector_records_no_step() {
let session = Session::new(the_fixture());
let initial_pic = picture_in(session.diagram(), FIXTURE);
let attempted = session.perform(Command::Hang {
id: id(3),
end: End::From,
target: id(3),
anchor: Anchor::Top,
});
assert!(
!attempted.can_undo(),
"attaching to a connector records no step"
);
assert_eq!(
picture_in(attempted.diagram(), FIXTURE),
initial_pic,
"attaching to a connector leaves diagram unchanged"
);
}
#[test]
fn attaching_an_already_attached_end_records_no_step() {
let session = Session::new(the_fixture());
let hung = session.perform(Command::Hang {
id: id(3),
end: End::From,
target: id(1),
anchor: Anchor::Bottom,
});
assert!(hung.can_undo());
let hung_again = hung.perform(Command::Hang {
id: id(3),
end: End::From,
target: id(2),
anchor: Anchor::Top,
});
assert_eq!(
hung_again.diagram().get(id(3)),
hung.diagram().get(id(3)),
"attaching an already attached end leaves shape unchanged"
);
assert_eq!(
picture_in(hung_again.diagram(), FIXTURE),
picture_in(hung.diagram(), FIXTURE)
);
let undone = hung_again.undo();
assert_eq!(
picture_in(undone.diagram(), FIXTURE),
picture_in(session.diagram(), FIXTURE),
"only the first attach was recorded"
);
}
#[test]
fn a_forward_of_the_front_most_figure_records_no_step() {
let session = Session::new(the_fixture());
let initial_pic = picture_in(session.diagram(), FIXTURE);
let no_op = session.perform(Command::Forward { id: id(3) });
assert_eq!(
picture_in(no_op.diagram(), FIXTURE),
initial_pic,
"forwarding the front-most figure changes nothing"
);
assert!(!no_op.can_undo(), "and records no step");
let moved = session.perform(Command::Forward { id: id(1) });
assert!(
moved.can_undo(),
"forwarding an obscured figure records a step"
);
}
#[test]
fn a_remove_naming_nothing_records_no_step() {
let session = Session::new(one_box(ORIGIN));
let initial_pic = picture(&session);
let after_remove = session.perform(Command::Remove { id: id(9) });
assert_eq!(picture(&after_remove), initial_pic);
assert!(!after_remove.can_undo());
}
#[test]
fn a_move_by_nothing_records_no_step() {
let session = Session::new(one_box(ORIGIN));
let initial_pic = picture(&session);
let after_move = session.perform(Command::Move {
id: id(1),
by: Delta { dx: 0, dy: 0 },
});
assert_eq!(picture(&after_move), initial_pic);
assert!(!after_move.can_undo());
}
#[test]
fn a_command_that_changes_nothing_leaves_what_was_ahead() {
let session = Session::new(one_box(ORIGIN));
let moved = session.perform(Command::Move {
id: id(1),
by: Delta { dx: 2, dy: 0 },
});
let moved_pic = picture(&moved);
let undone = moved.undo();
assert!(undone.can_redo());
let no_op = undone.perform(Command::Move {
id: id(9),
by: Delta { dx: 1, dy: 0 },
});
assert!(
no_op.can_redo(),
"a command that changed nothing preserves what was ahead"
);
let redone = no_op.redo();
assert_eq!(
picture(&redone),
moved_pic,
"and redo restores the state that was ahead"
);
}
#[test]
fn two_resizes_are_two_steps() {
let session = Session::new(one_box(ORIGIN));
let after_one = session.perform(Command::Resize {
id: id(1),
by: Delta { dx: 1, dy: 0 },
});
let after_two = after_one.perform(Command::Resize {
id: id(1),
by: Delta { dx: 0, dy: 1 },
});
let Shape::Box { size: size_two, .. } = after_two.diagram().get(id(1)).expect("box") else {
panic!("expected box");
};
assert_eq!(
*size_two,
Size {
width: 5,
height: 4
}
);
let undone = after_two.undo();
let Shape::Box { size: size_one, .. } = undone.diagram().get(id(1)).expect("box") else {
panic!("expected box");
};
assert_eq!(
*size_one,
Size {
width: 5,
height: 3
}
);
let undone_twice = undone.undo();
let Shape::Box {
size: size_orig, ..
} = undone_twice.diagram().get(id(1)).expect("box")
else {
panic!("expected box");
};
assert_eq!(
*size_orig,
Size {
width: 4,
height: 3
}
);
}
#[test]
fn setting_a_figures_stroke_records_one_step() {
let session = Session::new(one_box(ORIGIN));
let updated = session.perform(Command::SetStroke {
id: id(1),
stroke: Stroke::from("double"),
});
assert!(
updated.can_undo(),
"setting a different stroke records a step"
);
let Shape::Box { stroke, .. } = updated.diagram().get(id(1)).expect("box") else {
panic!("expected box");
};
assert_eq!(*stroke, Stroke::from("double"));
let undone = updated.undo();
let Shape::Box {
stroke: orig_stroke,
..
} = undone.diagram().get(id(1)).expect("box")
else {
panic!("expected box");
};
assert_eq!(*orig_stroke, Stroke::from("light"));
}
#[test]
fn setting_the_stroke_a_figure_already_has_records_no_step() {
let session = Session::new(one_box(ORIGIN));
let moved = session.perform(Command::Move {
id: id(1),
by: Delta { dx: 1, dy: 0 },
});
let moved_pic = picture(&moved);
let undone = moved.undo();
assert!(undone.can_redo());
let no_op = undone.perform(Command::SetStroke {
id: id(1),
stroke: Stroke::from("light"),
});
assert!(
no_op.can_redo(),
"setting the stroke the figure already has preserves what was ahead"
);
let redone = no_op.redo();
assert_eq!(
picture(&redone),
moved_pic,
"and redo restores the state that was ahead"
);
}
#[test]
fn setting_the_stroke_of_a_figure_the_diagram_does_not_hold_changes_nothing() {
let session = Session::new(one_box(ORIGIN));
let initial_pic = picture(&session);
let no_op = session.perform(Command::SetStroke {
id: id(999),
stroke: Stroke::from("heavy"),
});
assert_eq!(picture(&no_op), initial_pic);
assert!(!no_op.can_undo(), "and no step was recorded");
}
#[test]
fn a_set_text_command_changes_only_a_box() {
let text = Text::new("hi").expect("\"hi\" holds no control character");
assert_eq!(
after(&Command::SetText {
id: id(1),
text: Some(text.clone()),
}),
after_operating(|diagram| {
let updated = diagram
.get(id(1))
.map(|shape| shape.with_text(Some(text.clone())));
if let Some(updated) = updated {
diagram.replace(id(1), updated)
} else {
diagram.clone()
}
}),
"`SetText` is `replace` with the figure's text changed"
);
}
#[test]
fn an_invert_terminals_command_changes_only_a_connector() {
assert_eq!(
after(&Command::InvertTerminals { id: id(3) }),
after_operating(|diagram| {
let updated = diagram.get(id(3)).map(Shape::with_inverted_terminals);
if let Some(updated) = updated {
diagram.replace(id(3), updated)
} else {
diagram.clone()
}
}),
"`InvertTerminals` is `replace` with the figure's terminals swapped"
);
}
#[test]
fn setting_the_same_text_records_no_step() {
let text = Text::new("hi").expect("\"hi\" holds no control character");
let with_text = Session::new(one_box(ORIGIN)).perform(Command::SetText {
id: id(1),
text: Some(text.clone()),
});
let twice = with_text.perform(Command::SetText {
id: id(1),
text: Some(text),
});
let back = twice.undo();
let Shape::Box { text: restored, .. } = back.diagram().get(id(1)).expect("box") else {
panic!("expected box");
};
assert_eq!(
*restored, None,
"the second command recorded a step, so one undo stayed on the same text"
);
}
#[test]
fn clearing_a_text_is_one_step() {
let text = Text::new("hi").expect("\"hi\" holds no control character");
let with_text = Session::new(one_box(ORIGIN)).perform(Command::SetText {
id: id(1),
text: Some(text.clone()),
});
let cleared = with_text.perform(Command::SetText {
id: id(1),
text: None,
});
assert!(cleared.can_undo(), "clearing the text recorded no step");
let Shape::Box { text: now, .. } = cleared.diagram().get(id(1)).expect("box") else {
panic!("expected box");
};
assert_eq!(*now, None);
let undone = cleared.undo();
let Shape::Box { text: restored, .. } = undone.diagram().get(id(1)).expect("box") else {
panic!("expected box");
};
assert_eq!(*restored, Some(text), "one undo did not give the text back");
}
#[test]
fn the_moves_that_change_nothing_record_no_step() {
let session = Session::new(the_fixture());
let initial_pic = picture_in(session.diagram(), FIXTURE);
for command in [
Command::Backward { id: id(1) },
Command::ToFront { id: id(3) },
Command::ToBack { id: id(1) },
] {
let no_op = session.perform(command.clone());
assert_eq!(
picture_in(no_op.diagram(), FIXTURE),
initial_pic,
"{command:?} changed the picture"
);
assert!(!no_op.can_undo(), "{command:?} recorded a step");
}
}
#[test]
fn each_absolute_move_is_one_step_that_undo_reverses() {
for command in [
Command::ToFront { id: id(1) },
Command::ToBack { id: id(2) },
] {
let session = Session::new(the_fixture());
let before = picture_in(session.diagram(), FIXTURE);
let moved = session.perform(command.clone());
assert!(moved.can_undo(), "{command:?} recorded no step");
assert!(!moved.can_redo(), "{command:?} recorded more than one step");
assert_eq!(
picture_in(moved.undo().diagram(), FIXTURE),
before,
"one undo did not reverse {command:?}"
);
}
}
#[test]
fn inverting_terminals_swaps_terminals_and_records_one_step() {
let session = Session::new(the_fixture());
let before_pic = picture_in(session.diagram(), FIXTURE);
let inverted = session.perform(Command::InvertTerminals { id: id(3) });
assert!(inverted.can_undo(), "inverting terminals recorded no step");
assert!(
!inverted.can_redo(),
"inverting terminals recorded more than one step"
);
let Shape::Connector { from, to, .. } = inverted.diagram().get(id(3)).expect("connector")
else {
panic!("expected connector");
};
assert_eq!(
from.terminal,
Terminal::Glyph {
glyph: Glyph::new("▶").expect("glyph"),
}
);
assert_eq!(to.terminal, Terminal::Arm);
let after_pic = picture_in(inverted.diagram(), FIXTURE);
assert_ne!(
before_pic, after_pic,
"picture should change after terminal swap"
);
let undone = inverted.undo();
assert_eq!(
picture_in(undone.diagram(), FIXTURE),
before_pic,
"single undo should restore initial terminals"
);
}
#[test]
fn inverting_identical_terminals_records_no_step() {
let mut diagram = Diagram::new();
diagram = diagram
.add(Shape::Connector {
from: Endpoint::absolute(Pos { x: 0, y: 0 }, Direction::Right, Terminal::Arm),
to: Endpoint::absolute(Pos { x: 5, y: 0 }, Direction::Left, Terminal::Arm),
stroke: Stroke::from("light"),
})
.0;
let session = Session::new(diagram);
let initial_pic = picture_in(session.diagram(), EXAMPLE);
let no_op = session.perform(Command::InvertTerminals { id: id(1) });
assert_eq!(
picture_in(no_op.diagram(), EXAMPLE),
initial_pic,
"identical terminals should not change picture"
);
assert!(
!no_op.can_undo(),
"identical terminals should record no step (#285)"
);
}
#[test]
fn inverting_terminals_on_a_box_or_nonexistent_shape_records_no_step() {
let session = Session::new(the_fixture());
let initial_pic = picture_in(session.diagram(), FIXTURE);
let no_op_box = session.perform(Command::InvertTerminals { id: id(1) });
assert_eq!(picture_in(no_op_box.diagram(), FIXTURE), initial_pic);
assert!(
!no_op_box.can_undo(),
"inverting terminals on a box should record no step"
);
let no_op_missing = session.perform(Command::InvertTerminals { id: id(99) });
assert_eq!(picture_in(no_op_missing.diagram(), FIXTURE), initial_pic);
assert!(
!no_op_missing.can_undo(),
"inverting terminals on nonexistent shape should record no step"
);
}
#[test]
fn rotating_a_line_toggles_its_orientation_and_records_one_step() {
let mut diagram = Diagram::new();
diagram = diagram
.add(Shape::Line {
at: Pos { x: 1, y: 1 },
len: 5,
orientation: Orientation::Vertical,
stroke: Stroke::from("light"),
})
.0;
let session = Session::new(diagram);
let initial_pic = picture_in(session.diagram(), FIXTURE);
let rotated = session.perform(Command::RotateLine { id: id(1) });
assert!(rotated.can_undo(), "rotating a line should record one step");
assert!(!rotated.can_redo());
let Shape::Line {
orientation,
at,
len,
stroke,
} = rotated.diagram().get(id(1)).expect("line")
else {
panic!("expected line");
};
assert_eq!(*orientation, Orientation::Horizontal);
assert_eq!(*at, Pos { x: 1, y: 1 });
assert_eq!(*len, 5);
assert_eq!(*stroke, Stroke::from("light"));
let rotated_pic = picture_in(rotated.diagram(), FIXTURE);
assert_ne!(initial_pic, rotated_pic);
let undone = rotated.undo();
assert_eq!(picture_in(undone.diagram(), FIXTURE), initial_pic);
}
#[test]
fn rotating_a_box_connector_or_nonexistent_shape_records_no_step() {
let session = Session::new(the_fixture());
let initial_pic = picture_in(session.diagram(), FIXTURE);
let no_op_box = session.perform(Command::RotateLine { id: id(1) });
assert_eq!(picture_in(no_op_box.diagram(), FIXTURE), initial_pic);
assert!(
!no_op_box.can_undo(),
"rotating a box should record no step"
);
let no_op_conn = session.perform(Command::RotateLine { id: id(3) });
assert_eq!(picture_in(no_op_conn.diagram(), FIXTURE), initial_pic);
assert!(
!no_op_conn.can_undo(),
"rotating a connector should record no step"
);
let no_op_missing = session.perform(Command::RotateLine { id: id(99) });
assert_eq!(picture_in(no_op_missing.diagram(), FIXTURE), initial_pic);
assert!(
!no_op_missing.can_undo(),
"rotating nonexistent shape should record no step"
);
}
#[test]
fn moving_an_endpoint_records_one_step_and_zero_delta_records_none() {
let mut diagram = Diagram::new();
diagram = diagram.add(small_box(ORIGIN)).0; diagram = diagram
.add(Shape::Connector {
from: Endpoint::reference(
Reference::new(id(1), Anchor::Top, Delta::ZERO, 0),
Terminal::Arm,
),
to: Endpoint::absolute(Pos { x: 10, y: 0 }, Direction::Down, Terminal::Arm),
stroke: Stroke::from("light"),
})
.0;
let session = Session::new(diagram);
assert!(!session.can_undo());
let moved_x = session.perform(Command::MoveEndpoint {
id: id(2),
end: End::From,
by: Delta { dx: 1, dy: 0 },
out: 0,
});
assert!(
moved_x.can_undo(),
"moving endpoint horizontally records a step"
);
let moved_xy = moved_x.perform(Command::MoveEndpoint {
id: id(2),
end: End::From,
by: Delta { dx: 0, dy: 2 },
out: 0,
});
assert!(
moved_xy.can_undo(),
"moving endpoint vertically records a step"
);
let Shape::Connector { from, .. } = moved_xy.diagram().get(id(2)).unwrap() else {
panic!()
};
let monospace_diagram::EndpointAt::Reference(ref r) = from.at else {
panic!()
};
assert_eq!(r.offset, Delta { dx: 1, dy: 2 });
let pic_before = picture_in(moved_xy.diagram(), FIXTURE);
let no_op = moved_xy.perform(Command::MoveEndpoint {
id: id(2),
end: End::From,
by: Delta::ZERO,
out: 0,
});
assert_eq!(picture_in(no_op.diagram(), FIXTURE), pic_before);
let undone = no_op.undo();
let Shape::Connector { from, .. } = undone.diagram().get(id(2)).unwrap() else {
panic!()
};
let monospace_diagram::EndpointAt::Reference(ref r) = from.at else {
panic!()
};
assert_eq!(
r.offset,
Delta { dx: 1, dy: 0 },
"zero delta move was a no-op so undo stepped back to moved_x"
);
}
#[test]
fn adjusting_endpoint_out_via_move_endpoint_records_one_step_including_negative_out() {
let mut diagram = Diagram::new();
diagram = diagram.add(small_box(ORIGIN)).0; diagram = diagram
.add(Shape::Connector {
from: Endpoint::reference(
Reference::new(id(1), Anchor::Top, Delta::ZERO, 0),
Terminal::Arm,
),
to: Endpoint::absolute(Pos { x: 10, y: 0 }, Direction::Down, Terminal::Arm),
stroke: Stroke::from("light"),
})
.0;
let session = Session::new(diagram);
let out_plus = session.perform(Command::MoveEndpoint {
id: id(2),
end: End::From,
by: Delta::ZERO,
out: 1,
});
assert!(out_plus.can_undo(), "adjusting out records a step");
let Shape::Connector { from, .. } = out_plus.diagram().get(id(2)).unwrap() else {
panic!()
};
let monospace_diagram::EndpointAt::Reference(ref r) = from.at else {
panic!()
};
assert_eq!(r.out, 1);
let out_neg = out_plus.perform(Command::MoveEndpoint {
id: id(2),
end: End::From,
by: Delta::ZERO,
out: -2,
});
assert!(out_neg.can_undo(), "negative out records a step");
let Shape::Connector { from, .. } = out_neg.diagram().get(id(2)).unwrap() else {
panic!()
};
let monospace_diagram::EndpointAt::Reference(ref r) = from.at else {
panic!()
};
assert_eq!(r.out, -1);
}
#[test]
fn resetting_an_endpoint_clears_offset_and_out_in_one_step_and_zero_endpoint_records_none() {
let mut diagram = Diagram::new();
diagram = diagram.add(small_box(ORIGIN)).0; diagram = diagram
.add(Shape::Connector {
from: Endpoint::reference(
Reference::new(id(1), Anchor::Top, Delta { dx: 1, dy: 0 }, 2),
Terminal::Arm,
),
to: Endpoint::absolute(Pos { x: 10, y: 0 }, Direction::Down, Terminal::Arm),
stroke: Stroke::from("light"),
})
.0;
let session = Session::new(diagram);
assert!(!session.can_undo());
let reset = session.perform(Command::ResetEndpoint {
id: id(2),
end: End::From,
});
assert!(
reset.can_undo(),
"resetting non-zero endpoint records a step"
);
let Shape::Connector { from, .. } = reset.diagram().get(id(2)).unwrap() else {
panic!()
};
let monospace_diagram::EndpointAt::Reference(ref r) = from.at else {
panic!()
};
assert_eq!(r.offset, Delta::ZERO);
assert_eq!(r.out, 0);
let no_op = reset.perform(Command::ResetEndpoint {
id: id(2),
end: End::From,
});
let undone = no_op.undo();
let Shape::Connector { from, .. } = undone.diagram().get(id(2)).unwrap() else {
panic!()
};
let monospace_diagram::EndpointAt::Reference(ref r) = from.at else {
panic!()
};
assert_eq!(r.offset, Delta { dx: 1, dy: 0 });
assert_eq!(r.out, 2);
}
#[test]
fn setting_endpoint_terminal_records_one_step_and_identical_terminal_records_none() {
let mut diagram = Diagram::new();
diagram = diagram.add(small_box(ORIGIN)).0; diagram = diagram
.add(Shape::Connector {
from: Endpoint::reference(
Reference::new(id(1), Anchor::Top, Delta { dx: 1, dy: 0 }, 2),
Terminal::Arm,
),
to: Endpoint::absolute(Pos { x: 10, y: 0 }, Direction::Down, Terminal::Arm),
stroke: Stroke::from("light"),
})
.0;
let session = Session::new(diagram);
assert!(!session.can_undo());
let glyph_terminal = Terminal::Glyph {
glyph: Glyph::new("◆").unwrap(),
};
let updated = session.perform(Command::SetEndpointTerminal {
id: id(2),
end: End::From,
terminal: glyph_terminal.clone(),
});
assert!(
updated.can_undo(),
"setting different terminal records a step"
);
let Shape::Connector { from, to, .. } = updated.diagram().get(id(2)).unwrap() else {
panic!()
};
assert_eq!(from.terminal, glyph_terminal);
assert_eq!(to.terminal, Terminal::Arm);
let no_op = updated.perform(Command::SetEndpointTerminal {
id: id(2),
end: End::From,
terminal: glyph_terminal,
});
let undone = no_op.undo();
let Shape::Connector { from, .. } = undone.diagram().get(id(2)).unwrap() else {
panic!()
};
assert_eq!(from.terminal, Terminal::Arm);
}
#[test]
fn setting_endpoint_terminal_on_non_connector_records_none() {
let mut diagram = Diagram::new();
diagram = diagram.add(small_box(ORIGIN)).0;
let session = Session::new(diagram);
assert!(!session.can_undo());
let no_op = session.perform(Command::SetEndpointTerminal {
id: id(1),
end: End::From,
terminal: Terminal::Glyph {
glyph: Glyph::new("◆").unwrap(),
},
});
assert!(
!no_op.can_undo(),
"setting endpoint terminal on non-connector records no step"
);
}
#[test]
fn detaching_attached_endpoint_records_one_step_and_already_detached_records_none() {
use monospace_diagram::EndpointAt;
let mut diagram = Diagram::new();
diagram = diagram.add(small_box(ORIGIN)).0; diagram = diagram
.add(Shape::Connector {
from: Endpoint::reference(
Reference::new(id(1), Anchor::Top, Delta { dx: 1, dy: 0 }, 2),
Terminal::Arm,
),
to: Endpoint::absolute(Pos { x: 10, y: 0 }, Direction::Down, Terminal::Arm),
stroke: Stroke::from("light"),
})
.0;
let session = Session::new(diagram);
assert!(!session.can_undo());
let detached_session = session.perform(Command::DetachEndpoint {
id: id(2),
end: End::From,
});
assert!(
detached_session.can_undo(),
"detaching attached endpoint records a step"
);
let Shape::Connector { from, to, .. } = detached_session.diagram().get(id(2)).unwrap() else {
panic!()
};
assert!(
matches!(from.at, EndpointAt::Absolute { .. }),
"targeted endpoint becomes Absolute"
);
assert_eq!(
to.at,
EndpointAt::Absolute {
at: Pos { x: 10, y: 0 },
leaving: Direction::Down
}
);
let no_op_from = detached_session.perform(Command::DetachEndpoint {
id: id(2),
end: End::From,
});
assert_eq!(
no_op_from.diagram().get(id(2)),
detached_session.diagram().get(id(2)),
"detaching already-detached endpoint does not modify shape"
);
let no_op_to = no_op_from.perform(Command::DetachEndpoint {
id: id(2),
end: End::To,
});
assert_eq!(
no_op_to.diagram().get(id(2)),
detached_session.diagram().get(id(2)),
"detaching already-detached endpoint does not modify shape"
);
let undone = no_op_to.undo();
let Shape::Connector {
from: undone_from, ..
} = undone.diagram().get(id(2)).unwrap()
else {
panic!()
};
assert!(
matches!(undone_from.at, EndpointAt::Reference(_)),
"undo restores reference endpoint"
);
assert!(!undone.can_undo(), "only one step was recorded");
}
#[test]
fn setting_endpoint_leaving_records_one_step_and_identical_or_attached_records_none() {
use monospace_diagram::EndpointAt;
let mut diagram = Diagram::new();
diagram = diagram.add(small_box(ORIGIN)).0; diagram = diagram
.add(Shape::Connector {
from: Endpoint::absolute(Pos { x: 5, y: 5 }, Direction::Right, Terminal::Arm),
to: Endpoint::reference(
Reference::new(id(1), Anchor::Top, Delta::ZERO, 0),
Terminal::Arm,
),
stroke: Stroke::from("light"),
})
.0;
let session = Session::new(diagram);
assert!(!session.can_undo());
let updated = session.perform(Command::SetEndpointLeaving {
id: id(2),
end: End::From,
leaving: Direction::Down,
});
assert!(
updated.can_undo(),
"setting different leaving direction records a step"
);
let Shape::Connector { from, to, .. } = updated.diagram().get(id(2)).unwrap() else {
panic!()
};
assert_eq!(
from.at,
EndpointAt::Absolute {
at: Pos { x: 5, y: 5 },
leaving: Direction::Down
}
);
assert!(
matches!(to.at, EndpointAt::Reference(_)),
"attached endpoint remains unchanged"
);
let no_op_identical = updated.perform(Command::SetEndpointLeaving {
id: id(2),
end: End::From,
leaving: Direction::Down,
});
assert_eq!(
no_op_identical.diagram().get(id(2)),
updated.diagram().get(id(2)),
"setting identical leaving records no step"
);
let no_op_attached = no_op_identical.perform(Command::SetEndpointLeaving {
id: id(2),
end: End::To,
leaving: Direction::Left,
});
assert_eq!(
no_op_attached.diagram().get(id(2)),
updated.diagram().get(id(2)),
"setting leaving on attached endpoint records no step"
);
let undone = no_op_attached.undo();
let Shape::Connector {
from: undone_from, ..
} = undone.diagram().get(id(2)).unwrap()
else {
panic!()
};
assert_eq!(
undone_from.at,
EndpointAt::Absolute {
at: Pos { x: 5, y: 5 },
leaving: Direction::Right
}
);
assert!(!undone.can_undo(), "only one step was recorded");
}
#[test]
fn adding_a_shape_via_session_add_assigns_id_records_one_step_and_undo_redo_walk_it() {
let session = Session::new(Diagram::new());
assert!(!session.can_undo());
assert!(!session.can_redo());
assert_eq!(session.diagram().shape_count(), 0);
let (added, assigned_id) = session.add(small_box(ORIGIN));
assert_eq!(assigned_id, id(1));
assert_eq!(added.diagram().shape_count(), 1);
assert!(added.diagram().get(assigned_id).is_some());
assert!(added.can_undo());
assert!(!added.can_redo());
assert_eq!(picture(&added), AS_WRITTEN);
let undone = added.undo();
assert_eq!(undone.diagram().shape_count(), 0);
assert!(!undone.can_undo());
assert!(undone.can_redo());
let redone = undone.redo();
assert_eq!(redone.diagram().shape_count(), 1);
assert_eq!(picture(&redone), AS_WRITTEN);
assert_eq!(
redone.diagram().get(assigned_id),
added.diagram().get(assigned_id)
);
}
#[test]
fn adding_a_shape_truncates_redo_history_when_called_after_undo() {
let session = Session::new(Diagram::new());
let (s1, id1) = session.add(small_box(ORIGIN));
let s2 = s1.perform(Command::Move {
id: id1,
by: Delta { dx: 4, dy: 0 },
});
assert_eq!(picture(&s2), FOUR_RIGHT);
let s_undone = s2.undo();
assert_eq!(picture(&s_undone), AS_WRITTEN);
assert!(s_undone.can_redo());
let (s_branched, id2) = s_undone.add(an_extra_box());
assert_eq!(id2, id(2));
assert!(!s_branched.can_redo(), "redo history is truncated");
assert!(s_branched.can_undo());
assert_eq!(s_branched.diagram().shape_count(), 2);
}
#[test]
fn performing_command_add_via_session_perform_records_step_and_adds_shape() {
let session = Session::new(Diagram::new());
let added = session.perform(Command::Add {
shape: small_box(ORIGIN),
});
assert_eq!(added.diagram().shape_count(), 1);
assert_eq!(picture(&added), AS_WRITTEN);
assert!(added.can_undo());
let undone = added.undo();
assert_eq!(undone.diagram().shape_count(), 0);
}
#[test]
fn affected_by_undo_and_redo_name_the_figure_each_step_touched() {
let fixture = the_fixture();
for (command, expected) in commands_that_name_their_figure() {
holds_the_figure(&fixture, &command, expected);
}
let line = Diagram::new()
.add(Shape::Line {
at: ORIGIN,
len: 4,
orientation: Orientation::Horizontal,
stroke: Stroke::from("light"),
})
.0;
let rotated = Session::new(line).perform(Command::RotateLine { id: id(1) });
assert_eq!(
rotated.affected_by_undo(),
Some(id(1)),
"`RotateLine` named another figure"
);
let hung = Session::new(fixture.clone()).perform(Command::Hang {
id: id(3),
end: End::From,
target: id(1),
anchor: Anchor::Right,
});
let detached = hung.perform(Command::DetachEndpoint {
id: id(3),
end: End::From,
});
assert_eq!(
detached.affected_by_undo(),
Some(id(3)),
"`DetachEndpoint` named another figure"
);
let moved = Session::new(fixture).perform(Command::MoveEndpoint {
id: id(3),
end: End::From,
by: Delta { dx: 1, dy: 0 },
out: 2,
});
let reset = moved.perform(Command::ResetEndpoint {
id: id(3),
end: End::From,
});
assert_eq!(
reset.affected_by_undo(),
Some(id(3)),
"`ResetEndpoint` named another figure"
);
}
fn commands_that_name_their_figure() -> [(Command, ShapeId); 14] {
let text = Text::new("hi").expect("\"hi\" holds no control character");
[
(
Command::Move {
id: id(1),
by: Delta { dx: 1, dy: 0 },
},
id(1),
),
(
Command::Resize {
id: id(1),
by: Delta { dx: 1, dy: 0 },
},
id(1),
),
(
Command::SetStroke {
id: id(2),
stroke: Stroke::from("double"),
},
id(2),
),
(
Command::SetText {
id: id(1),
text: Some(text),
},
id(1),
),
(Command::InvertTerminals { id: id(3) }, id(3)),
(
Command::MoveEndpoint {
id: id(3),
end: End::From,
by: Delta { dx: 1, dy: 1 },
out: 2,
},
id(3),
),
(
Command::SetEndpointTerminal {
id: id(3),
end: End::To,
terminal: Terminal::Arm,
},
id(3),
),
(
Command::SetEndpointLeaving {
id: id(3),
end: End::To,
leaving: Direction::Up,
},
id(3),
),
(
Command::Hang {
id: id(3),
end: End::From,
target: id(1),
anchor: Anchor::Right,
},
id(3),
),
(Command::Forward { id: id(1) }, id(1)),
(Command::Backward { id: id(3) }, id(3)),
(Command::ToFront { id: id(1) }, id(1)),
(Command::ToBack { id: id(2) }, id(2)),
(Command::Remove { id: id(2) }, id(2)),
]
}
fn holds_the_figure(diagram: &Diagram, command: &Command, expected: ShapeId) {
let session = Session::new(diagram.clone());
assert_eq!(
session.affected_by_undo(),
None,
"a session with no history named a figure behind it"
);
assert_eq!(
session.affected_by_redo(),
None,
"a session with no history named a figure ahead of it"
);
let walked = session.perform(command.clone());
assert_eq!(
walked.affected_by_undo(),
Some(expected),
"{command:?} recorded the wrong figure"
);
assert_eq!(
walked.affected_by_redo(),
None,
"{command:?} left a step ahead"
);
let given_back = walked.undo();
assert_eq!(
given_back.affected_by_undo(),
None,
"{command:?} left a second step behind"
);
assert_eq!(
given_back.affected_by_redo(),
Some(expected),
"{command:?} lost its figure when it was walked back"
);
}
#[test]
fn the_affected_figure_of_an_add_is_the_one_the_diagram_assigned() {
let (added, assigned) = Session::new(Diagram::new()).add(small_box(ORIGIN));
assert_eq!(assigned, id(1), "the diagram assigned another identity");
assert_eq!(
added.affected_by_undo(),
Some(assigned),
"`add` recorded another figure"
);
let performed = Session::new(Diagram::new()).perform(Command::Add {
shape: small_box(ORIGIN),
});
assert_eq!(
performed.affected_by_undo(),
Some(assigned),
"`perform(Add)` recorded another figure than `add`"
);
let second = added.add(small_box(Pos { x: 2, y: 0 })).0;
assert_eq!(
second.affected_by_undo(),
Some(id(2)),
"the second add recorded the first identity"
);
assert_eq!(
second.undo().affected_by_redo(),
Some(id(2)),
"walking the second add back lost its figure"
);
}