use std::num::NonZeroU16;
use monospace_core::{Direction, Glyph, Orientation as CoreOrientation, SymbolStyleName, Text};
use monospace_diagram::{
Anchor, Delta, Diagram, Endpoint as DiagramEndpoint, EndpointAt, Position, Reference,
Shape as DiagramShape, ShapeId,
};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Debug)]
pub struct ParseError(serde_json::Error);
impl std::fmt::Display for ParseError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(formatter)
}
}
impl std::error::Error for ParseError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.0)
}
}
pub fn parse(json: &str) -> Result<Diagram, ParseError> {
let description: Description = serde_json::from_str(json).map_err(ParseError)?;
Ok(description.into_diagram())
}
#[derive(Debug)]
pub struct SerializeError(serde_json::Error);
impl std::fmt::Display for SerializeError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(formatter)
}
}
impl std::error::Error for SerializeError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.0)
}
}
pub fn serialize(diagram: &Diagram) -> Result<String, SerializeError> {
let description = Description::from_diagram(diagram);
let mut json = serde_json::to_string_pretty(&description).map_err(SerializeError)?;
json.push('\n');
Ok(json)
}
#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq)]
struct Pos {
x: i16,
y: i16,
}
impl From<Pos> for monospace_core::Pos {
fn from(pos: Pos) -> Self {
monospace_core::Pos { x: pos.x, y: pos.y }
}
}
impl From<monospace_core::Pos> for Pos {
fn from(pos: monospace_core::Pos) -> Self {
Self { x: pos.x, y: pos.y }
}
}
#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq)]
struct Size {
width: u16,
height: u16,
}
impl From<Size> for monospace_core::Size {
fn from(size: Size) -> Self {
monospace_core::Size {
width: size.width,
height: size.height,
}
}
}
impl From<monospace_core::Size> for Size {
fn from(size: monospace_core::Size) -> Self {
Self {
width: size.width,
height: size.height,
}
}
}
#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
enum Orientation {
Horizontal,
Vertical,
}
impl From<Orientation> for CoreOrientation {
fn from(orientation: Orientation) -> Self {
match orientation {
Orientation::Horizontal => CoreOrientation::Horizontal,
Orientation::Vertical => CoreOrientation::Vertical,
}
}
}
impl From<CoreOrientation> for Orientation {
fn from(orientation: CoreOrientation) -> Self {
match orientation {
CoreOrientation::Horizontal => Self::Horizontal,
CoreOrientation::Vertical => Self::Vertical,
}
}
}
#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
enum Leaving {
Up,
Right,
Down,
Left,
}
impl From<Leaving> for Direction {
fn from(leaving: Leaving) -> Self {
match leaving {
Leaving::Up => Direction::Up,
Leaving::Right => Direction::Right,
Leaving::Down => Direction::Down,
Leaving::Left => Direction::Left,
}
}
}
impl From<Direction> for Leaving {
fn from(direction: Direction) -> Self {
match direction {
Direction::Up => Self::Up,
Direction::Right => Self::Right,
Direction::Down => Self::Down,
Direction::Left => Self::Left,
}
}
}
fn serialize_glyph<S>(glyph: &Glyph, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(glyph.as_str())
}
#[allow(clippy::ref_option)]
fn serialize_optional_glyph<S>(glyph: &Option<Glyph>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match glyph {
Some(glyph) => serializer.serialize_str(glyph.as_str()),
None => serializer.serialize_none(),
}
}
#[allow(clippy::ref_option)]
fn serialize_optional_text<S>(text: &Option<Text>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match text {
Some(text) => {
let content = text
.lines()
.iter()
.map(|line| line.iter().map(Glyph::as_str).collect::<String>())
.collect::<Vec<_>>()
.join("\n");
serializer.serialize_str(&content)
}
None => serializer.serialize_none(),
}
}
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_zero(n: &i16) -> bool {
*n == 0
}
fn shape_id_ordinal(id: ShapeId) -> NonZeroU16 {
id.to_string()
.parse::<NonZeroU16>()
.expect("ShapeId displays its NonZeroU16")
}
fn deserialize_glyph<'de, D>(deserializer: D) -> Result<Glyph, D::Error>
where
D: Deserializer<'de>,
{
let text = String::deserialize(deserializer)?;
Glyph::new(&text).ok_or_else(|| {
serde::de::Error::custom(format!("{text:?} is not exactly one grapheme cluster"))
})
}
fn deserialize_optional_glyph<'de, D>(deserializer: D) -> Result<Option<Glyph>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
struct Wrapper(#[serde(deserialize_with = "deserialize_glyph")] Glyph);
Ok(Option::<Wrapper>::deserialize(deserializer)?.map(|wrapper| wrapper.0))
}
fn deserialize_text<'de, D>(deserializer: D) -> Result<Text, D::Error>
where
D: Deserializer<'de>,
{
let content = String::deserialize(deserializer)?;
Text::new(&content).ok_or_else(|| {
serde::de::Error::custom(format!(
"`text` is lines of glyphs: {content:?} holds a control character that is not a \
newline"
))
})
}
fn deserialize_optional_text<'de, D>(deserializer: D) -> Result<Option<Text>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
struct Wrapper(#[serde(deserialize_with = "deserialize_text")] Text);
Ok(Option::<Wrapper>::deserialize(deserializer)?.map(|wrapper| wrapper.0))
}
fn ordinal<'de, D>(field: &'static str, deserializer: D) -> Result<NonZeroU16, D::Error>
where
D: Deserializer<'de>,
{
NonZeroU16::deserialize(deserializer)
.map_err(|error| serde::de::Error::custom(format!("`{field}` is an ordinal: {error}")))
}
fn id<'de, D>(deserializer: D) -> Result<NonZeroU16, D::Error>
where
D: Deserializer<'de>,
{
ordinal("id", deserializer)
}
fn next_id<'de, D>(deserializer: D) -> Result<NonZeroU16, D::Error>
where
D: Deserializer<'de>,
{
ordinal("next_id", deserializer)
}
fn shape<'de, D>(deserializer: D) -> Result<NonZeroU16, D::Error>
where
D: Deserializer<'de>,
{
ordinal("shape", deserializer)
}
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "lowercase")]
enum Terminal {
Glyph {
#[serde(
serialize_with = "serialize_glyph",
deserialize_with = "deserialize_glyph"
)]
glyph: Glyph,
},
Symbol {
style: String,
},
Arm,
}
impl From<Terminal> for monospace_core::Terminal {
fn from(terminal: Terminal) -> Self {
match terminal {
Terminal::Glyph { glyph } => monospace_core::Terminal::Glyph { glyph },
Terminal::Symbol { style } => monospace_core::Terminal::Symbol {
style: SymbolStyleName::from(style.as_str()),
},
Terminal::Arm => monospace_core::Terminal::Arm,
}
}
}
impl From<monospace_core::Terminal> for Terminal {
fn from(terminal: monospace_core::Terminal) -> Self {
match terminal {
monospace_core::Terminal::Glyph { glyph } => Self::Glyph { glyph },
monospace_core::Terminal::Symbol { style } => Self::Symbol { style: style.0 },
monospace_core::Terminal::Arm => Self::Arm,
}
}
}
#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
enum AnchorDescription {
Top,
Right,
Bottom,
Left,
}
impl From<AnchorDescription> for Anchor {
fn from(anchor: AnchorDescription) -> Self {
match anchor {
AnchorDescription::Top => Anchor::Top,
AnchorDescription::Right => Anchor::Right,
AnchorDescription::Bottom => Anchor::Bottom,
AnchorDescription::Left => Anchor::Left,
}
}
}
impl From<Anchor> for AnchorDescription {
fn from(anchor: Anchor) -> Self {
match anchor {
Anchor::Top => Self::Top,
Anchor::Right => Self::Right,
Anchor::Bottom => Self::Bottom,
Anchor::Left => Self::Left,
}
}
}
#[derive(Deserialize, Serialize, Debug, Clone, Copy, Default, PartialEq, Eq)]
struct OffsetDescription {
dx: i16,
dy: i16,
}
impl From<OffsetDescription> for Delta {
fn from(offset: OffsetDescription) -> Self {
Delta {
dx: offset.dx,
dy: offset.dy,
}
}
}
impl From<Delta> for OffsetDescription {
fn from(offset: Delta) -> Self {
Self {
dx: offset.dx,
dy: offset.dy,
}
}
}
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "lowercase")]
enum At {
Point {
x: i16,
y: i16,
},
Reference {
#[serde(deserialize_with = "shape")]
shape: NonZeroU16,
anchor: AnchorDescription,
#[serde(default)]
offset: OffsetDescription,
#[serde(default, skip_serializing_if = "is_zero")]
out: i16,
},
}
impl From<Position> for At {
fn from(position: Position) -> Self {
match position {
Position::Absolute(pos) => Self::Point { x: pos.x, y: pos.y },
Position::Reference(reference) => Self::Reference {
shape: shape_id_ordinal(reference.id),
anchor: reference.anchor.into(),
offset: reference.offset.into(),
out: reference.out,
},
}
}
}
#[derive(Serialize, Debug, Clone, PartialEq, Eq)]
struct Endpoint {
at: At,
#[serde(skip_serializing_if = "Option::is_none")]
leaving: Option<Leaving>,
terminal: Terminal,
}
impl<'de> Deserialize<'de> for Endpoint {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
struct RawEndpoint {
at: At,
#[serde(default)]
leaving: Option<Leaving>,
terminal: Terminal,
}
let raw = RawEndpoint::deserialize(deserializer)?;
match (&raw.at, raw.leaving) {
(At::Point { .. }, None) => Err(serde::de::Error::missing_field("leaving")),
(At::Reference { .. }, Some(_)) => Err(serde::de::Error::custom(
"a reference endpoint derives its leaving direction from its anchor and must not specify `leaving`",
)),
_ => Ok(Self {
at: raw.at,
leaving: raw.leaving,
terminal: raw.terminal,
}),
}
}
}
impl From<DiagramEndpoint> for Endpoint {
fn from(endpoint: DiagramEndpoint) -> Self {
match endpoint.at {
EndpointAt::Absolute { at, leaving } => Self {
at: Position::Absolute(at).into(),
leaving: Some(leaving.into()),
terminal: endpoint.terminal.into(),
},
EndpointAt::Reference(reference) => Self {
at: Position::Reference(reference).into(),
leaving: None,
terminal: endpoint.terminal.into(),
},
}
}
}
impl From<Endpoint> for DiagramEndpoint {
fn from(endpoint: Endpoint) -> Self {
match endpoint.at {
At::Point { x, y } => DiagramEndpoint::absolute(
monospace_core::Pos { x, y },
endpoint
.leaving
.expect("a deserialized point endpoint always has a leaving direction")
.into(),
endpoint.terminal.into(),
),
At::Reference {
shape,
anchor,
offset,
out,
} => DiagramEndpoint::reference(
Reference::new(ShapeId::new(shape), anchor.into(), offset.into(), out),
endpoint.terminal.into(),
),
}
}
}
#[derive(Deserialize, Serialize, Debug, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "lowercase")]
enum ShapeDescription {
Box {
#[serde(deserialize_with = "id")]
id: NonZeroU16,
at: Pos,
size: Size,
stroke: String,
#[serde(
default,
skip_serializing_if = "Option::is_none",
serialize_with = "serialize_optional_glyph",
deserialize_with = "deserialize_optional_glyph"
)]
fill: Option<Glyph>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
serialize_with = "serialize_optional_text",
deserialize_with = "deserialize_optional_text"
)]
text: Option<Text>,
},
Line {
#[serde(deserialize_with = "id")]
id: NonZeroU16,
at: Pos,
len: u16,
orientation: Orientation,
stroke: String,
},
Connector {
#[serde(deserialize_with = "id")]
id: NonZeroU16,
from: Endpoint,
to: Endpoint,
stroke: String,
},
}
impl ShapeDescription {
fn from_shape(id: ShapeId, shape: &DiagramShape) -> Self {
let ordinal = shape_id_ordinal(id);
match shape {
DiagramShape::Box {
at,
size,
stroke,
fill,
text,
} => Self::Box {
id: ordinal,
at: (*at).into(),
size: (*size).into(),
stroke: stroke.0.clone(),
fill: fill.clone(),
text: text.clone(),
},
DiagramShape::Line {
at,
len,
orientation,
stroke,
} => Self::Line {
id: ordinal,
at: (*at).into(),
len: *len,
orientation: (*orientation).into(),
stroke: stroke.0.clone(),
},
DiagramShape::Connector { from, to, stroke } => Self::Connector {
id: ordinal,
from: from.clone().into(),
to: to.clone().into(),
stroke: stroke.0.clone(),
},
}
}
fn id(&self) -> ShapeId {
match self {
ShapeDescription::Box { id, .. }
| ShapeDescription::Line { id, .. }
| ShapeDescription::Connector { id, .. } => ShapeId::new(*id),
}
}
}
impl From<ShapeDescription> for DiagramShape {
fn from(description: ShapeDescription) -> Self {
match description {
ShapeDescription::Box {
at,
size,
stroke,
fill,
text,
..
} => DiagramShape::Box {
at: at.into(),
size: size.into(),
stroke: stroke.as_str().into(),
fill,
text,
},
ShapeDescription::Line {
at,
len,
orientation,
stroke,
..
} => DiagramShape::Line {
at: at.into(),
len,
orientation: orientation.into(),
stroke: stroke.as_str().into(),
},
ShapeDescription::Connector {
from, to, stroke, ..
} => DiagramShape::Connector {
from: from.into(),
to: to.into(),
stroke: stroke.as_str().into(),
},
}
}
}
#[derive(Deserialize, Serialize, Debug, PartialEq, Eq)]
struct Description {
#[serde(deserialize_with = "next_id")]
next_id: NonZeroU16,
shapes: Vec<ShapeDescription>,
}
impl Description {
fn from_diagram(diagram: &Diagram) -> Self {
Self {
next_id: diagram.next_id(),
shapes: diagram
.shapes()
.map(|(id, shape)| ShapeDescription::from_shape(id, shape))
.collect(),
}
}
fn into_diagram(self) -> Diagram {
let mut diagram = Diagram::numbered_from(self.next_id);
for shape in self.shapes {
diagram = diagram.add_under(shape.id(), shape.into());
}
diagram
}
}
#[cfg(test)]
mod tests {
use monospace_core::{Buffer, Direction, GlyphCatalog, Pos, Size, Window};
use monospace_diagram::{Shape as DiagramShape, ShapeId};
use super::{Description, Diagram, ParseError, parse};
fn refusal(json: &str) -> ParseError {
parse(json)
.err()
.expect("a description this format refuses must fail")
}
#[test]
fn parse_hands_back_the_diagram_and_nothing_else() {
let diagram: Diagram = parse(
r#"{
"next_id": 2,
"shapes": [ { "kind": "box", "id": 1, "at": { "x": 0, "y": 0 },
"size": { "width": 4, "height": 3 }, "stroke": "light" } ]
}"#,
)
.expect("a well-formed description");
let drawn = |origin: Pos, size: Size| {
let mut buffer = Buffer::new(Window { origin, size });
diagram.draw(&mut buffer);
monospace_core::render(&buffer, &GlyphCatalog::light(), buffer.window())
};
let small = drawn(
Pos { x: 0, y: 0 },
Size {
width: 4,
height: 3,
},
);
let large = drawn(
Pos { x: 0, y: 0 },
Size {
width: 8,
height: 3,
},
);
assert_eq!(small, "┌──┐\n│ │\n└──┘\n");
assert_ne!(
small, large,
"the window is the caller's, not the description's"
);
}
#[test]
fn a_description_refuses_a_control_character_in_text() {
let json = r#"{
"next_id": 2,
"shapes": [ { "kind": "box", "id": 1, "at": { "x": 0, "y": 0 },
"size": { "width": 4, "height": 3 }, "stroke": "light",
"text": "a\tb" } ]
}"#;
let error = refusal(json).to_string();
assert!(
error.contains("`text`"),
"the refusal names the field the text was written in: {error}"
);
let json = json.replace("a\\tb", "one\\ntwo");
parse(&json).expect("`\\n` is the one control character a text allows");
}
#[test]
fn a_description_reads_a_box_with_text() {
let diagram = parse(
r#"{
"next_id": 2,
"shapes": [ { "kind": "box", "id": 1, "at": { "x": 0, "y": 0 },
"size": { "width": 8, "height": 5 }, "stroke": "light",
"text": "hi" } ]
}"#,
)
.expect("a box with a text is a well-formed description");
let mut buffer = Buffer::new(Window {
origin: Pos { x: 0, y: 0 },
size: Size {
width: 8,
height: 5,
},
});
diagram.draw(&mut buffer);
assert_eq!(
monospace_core::render(&buffer, &GlyphCatalog::light(), buffer.window()),
"┌──────┐\n│ │\n│ hi │\n│ │\n└──────┘\n"
);
}
#[test]
fn a_description_carrying_a_canvas_is_read_and_the_canvas_ignored() {
let json = r#"{
"canvas": { "origin": { "x": 0, "y": 0 }, "size": { "width": 13, "height": 9 } },
"next_id": 2,
"shapes": [ { "kind": "box", "id": 1, "at": { "x": 0, "y": 0 },
"size": { "width": 4, "height": 3 }, "stroke": "light" } ]
}"#;
let diagram: Diagram = parse(json).expect("a description carrying a canvas still reads");
let origin = Pos { x: 0, y: 0 };
let size = Size {
width: 6,
height: 4,
};
let mut buffer = Buffer::new(Window { origin, size });
diagram.draw(&mut buffer);
assert_eq!(
monospace_core::render(&buffer, &GlyphCatalog::light(), buffer.window()),
"┌──┐ \n│ │ \n└──┘ \n \n",
"the `13x9` in the file has no say in it: the picture is the window the caller chose"
);
}
fn render_once(description: Description, origin: Pos, size: Size) -> String {
let diagram = description.into_diagram();
let mut buffer = Buffer::new(Window { origin, size });
diagram.draw(&mut buffer);
monospace_core::render(&buffer, &GlyphCatalog::light(), buffer.window())
}
#[test]
fn an_unrecognized_kind_fails_to_deserialize_and_names_it() {
let json = r#"{
"canvas": { "origin": { "x": 0, "y": 0 }, "size": { "width": 4, "height": 3 } },
"next_id": 1,
"shapes": [ { "kind": "triangle" } ]
}"#;
let error = refusal(json);
assert!(error.to_string().contains("triangle"), "{error}");
}
#[test]
fn a_multi_grapheme_fill_fails_to_deserialize() {
let json = r#"{
"canvas": { "origin": { "x": 0, "y": 0 }, "size": { "width": 4, "height": 3 } },
"next_id": 2,
"shapes": [
{ "kind": "box", "id": 1, "at": { "x": 0, "y": 0 }, "size": { "width": 4, "height": 3 },
"stroke": "light", "fill": "ab" }
]
}"#;
assert!(parse(json).is_err());
}
#[test]
fn a_multi_grapheme_terminal_glyph_fails_to_deserialize() {
let json = r#"{
"canvas": { "origin": { "x": 0, "y": 0 }, "size": { "width": 8, "height": 1 } },
"next_id": 2,
"shapes": [
{ "kind": "connector", "id": 1,
"from": { "at": { "kind": "point", "x": 0, "y": 0 }, "leaving": "right",
"terminal": { "kind": "glyph", "glyph": "ab" } },
"to": { "at": { "kind": "point", "x": 6, "y": 0 }, "leaving": "left",
"terminal": { "kind": "glyph", "glyph": ">" } },
"stroke": "light" }
]
}"#;
assert!(parse(json).is_err());
}
const ARROW: &str = r#""from": { "at": { "kind": "point", "x": 0, "y": 0 }, "leaving": "right", "terminal": TERMINAL },
"to": { "at": { "kind": "point", "x": 6, "y": 0 }, "leaving": "left",
"terminal": { "kind": "glyph", "glyph": ">" } }"#;
const ARROW_WITHOUT_A_TERMINAL: &str = r#""from": { "at": { "kind": "point", "x": 0, "y": 0 }, "leaving": "right" },
"to": { "at": { "kind": "point", "x": 6, "y": 0 }, "leaving": "left",
"terminal": { "kind": "glyph", "glyph": ">" } }"#;
fn description_of(connector: &str) -> String {
format!(
r#"{{
"canvas": {{ "origin": {{ "x": 0, "y": 0 }}, "size": {{ "width": 8, "height": 1 }} }},
"next_id": 2,
"shapes": [
{{ "kind": "connector", "id": 1,
{connector},
"stroke": "light" }}
]
}}"#
)
}
fn error_for(terminal: &str) -> String {
let json = description_of(&ARROW.replace("TERMINAL", terminal));
refusal(&json).to_string()
}
#[test]
fn an_unrecognized_terminal_kind_names_it_and_the_three_that_are_accepted() {
let error = error_for(r#"{ "kind": "dot" }"#);
assert!(
error.starts_with("unknown variant `dot`, expected one of `glyph`, `symbol`, `arm`"),
"{error}"
);
}
#[test]
fn a_symbol_terminal_without_a_style_is_refused_by_name() {
let error = error_for(r#"{ "kind": "symbol" }"#);
assert!(error.starts_with("missing field `style`"), "{error}");
}
#[test]
fn an_unknown_style_name_draws_a_space() {
let json = description_of(
r#""from": { "at": { "kind": "point", "x": 0, "y": 0 }, "leaving": "right",
"terminal": { "kind": "symbol", "style": "nope" } },
"to": { "at": { "kind": "point", "x": 6, "y": 0 }, "leaving": "left",
"terminal": { "kind": "symbol", "style": "solid" } }"#,
);
let diagram = parse(&json).expect("an unknown style is read, not refused");
let mut buffer = Buffer::new(Window {
origin: Pos { x: 0, y: 0 },
size: Size {
width: 7,
height: 1,
},
});
diagram.draw(&mut buffer);
let catalog = GlyphCatalog::union([GlyphCatalog::light(), GlyphCatalog::solid()]);
assert_eq!(
monospace_core::render(&buffer, &catalog, buffer.window()),
" ─────▶\n",
"the unknown name is a space and the held one draws its glyph"
);
}
#[test]
fn a_multi_grapheme_terminal_glyph_is_rejected_with_the_message_the_old_head_produced() {
let error = error_for(r#"{ "kind": "glyph", "glyph": "ab" }"#);
assert!(
error.starts_with("\"ab\" is not exactly one grapheme cluster"),
"{error}"
);
}
#[test]
fn an_omitted_terminal_field_is_refused_by_name() {
let json = description_of(ARROW_WITHOUT_A_TERMINAL);
let error = refusal(&json).to_string();
assert!(error.starts_with("missing field `terminal`"), "{error}");
}
#[test]
fn an_externally_tagged_terminal_is_refused() {
let error = error_for(r#""arm""#);
assert!(
error.starts_with(
r#"invalid type: string "arm", expected internally tagged enum Terminal"#
),
"{error}"
);
}
const ARROW_WITH_AN_AT: &str = r#""from": { "at": AT, "leaving": "right",
"terminal": { "kind": "arm" } },
"to": { "at": { "kind": "point", "x": 6, "y": 0 }, "leaving": "left",
"terminal": { "kind": "glyph", "glyph": ">" } }"#;
const ARROW_WITH_A_REFERENCE: &str = r#""from": { "at": AT,
"terminal": { "kind": "arm" } },
"to": { "at": { "kind": "point", "x": 6, "y": 0 }, "leaving": "left",
"terminal": { "kind": "glyph", "glyph": ">" } }"#;
fn error_for_an_at(at: &str) -> String {
let json = description_of(&ARROW_WITH_AN_AT.replace("AT", at));
refusal(&json).to_string()
}
#[test]
fn an_untagged_at_is_refused_by_name() {
let error = error_for_an_at(r#"{ "x": 0, "y": 0 }"#);
assert!(error.starts_with("missing field `kind`"), "{error}");
}
#[test]
fn an_unknown_at_kind_names_it_and_the_two_that_are_accepted() {
let error = error_for_an_at(r#"{ "kind": "arrows", "x": 0, "y": 0 }"#);
assert!(
error.starts_with("unknown variant `arrows`, expected `point` or `reference`"),
"{error}"
);
}
#[test]
fn a_stray_field_beside_an_at_is_dropped_in_silence() {
let json = description_of(
&ARROW_WITH_AN_AT.replace("AT", r#"{ "kind": "point", "x": 1, "y": 1, "shape": 5 }"#),
);
assert!(
parse(&json).is_ok(),
"a stray field beside a tagged `at` must not refuse the file"
);
}
#[test]
fn a_reference_with_no_offset_is_read_as_a_reference_on_the_side_itself() {
let json = description_of(&ARROW_WITH_A_REFERENCE.replace(
"AT",
r#"{ "kind": "reference", "shape": 1, "anchor": "right" }"#,
));
assert!(
parse(&json).is_ok(),
"a reference without an `offset` must read"
);
}
#[test]
fn an_unknown_key_inside_a_reference_is_dropped_in_silence() {
let json = description_of(&ARROW_WITH_A_REFERENCE.replace(
"AT",
r#"{ "kind": "reference", "shape": 1, "anchor": "right", "along": 2 }"#,
));
assert!(
parse(&json).is_ok(),
"an unknown key beside a reference's own fields must not refuse the file"
);
}
#[test]
fn a_reference_follows_the_identity_and_not_the_place_where_the_entry_is_written() {
let description_with =
|left: u16, right: u16, right_listed_first: bool, connector_names| {
let left_box = format!(
r#"{{ "kind": "box", "id": {left}, "at": {{ "x": 0, "y": 3 }},
"size": {{ "width": 4, "height": 3 }}, "stroke": "light" }}"#
);
let right_box = format!(
r#"{{ "kind": "box", "id": {right}, "at": {{ "x": 11, "y": 3 }},
"size": {{ "width": 4, "height": 3 }}, "stroke": "light" }}"#
);
let boxes = if right_listed_first {
format!("{right_box},\n {left_box}")
} else {
format!("{left_box},\n {right_box}")
};
format!(
r#"{{
"canvas": {{ "origin": {{ "x": 0, "y": 0 }}, "size": {{ "width": 16, "height": 6 }} }},
"next_id": 3,
"shapes": [
{boxes},
{{ "kind": "connector", "id": 3,
"from": {{ "at": {{ "kind": "point", "x": 7, "y": 0 }}, "leaving": "down",
"terminal": {{ "kind": "glyph", "glyph": "▼" }} }},
"to": {{ "at": {{ "kind": "reference", "shape": {connector_names},
"anchor": "top" }},
"terminal": {{ "kind": "arm" }} }},
"stroke": "light" }}
]
}}"#
)
};
let read = |json: String| serde_json::from_str::<Description>(&json).expect("well-formed");
let window = (
Pos { x: 0, y: 0 },
Size {
width: 16,
height: 6,
},
);
let right_listed_first =
render_once(read(description_with(1, 2, true, 2)), window.0, window.1);
let right_listed_second =
render_once(read(description_with(1, 2, false, 2)), window.0, window.1);
assert_eq!(
right_listed_first, right_listed_second,
"a reference naming a shape must not move when the entries are listed in another order"
);
let place_right_first =
render_once(read(description_with(2, 1, true, 2)), window.0, window.1);
let place_right_second =
render_once(read(description_with(1, 2, false, 2)), window.0, window.1);
assert_ne!(
place_right_first, place_right_second,
"a reference naming a place must still follow the place it is written at"
);
}
#[test]
fn a_missing_identity_is_refused_by_name() {
let without_next_id = r#"{
"canvas": { "origin": { "x": 0, "y": 0 }, "size": { "width": 4, "height": 3 } },
"shapes": [ { "kind": "box", "id": 1, "at": { "x": 0, "y": 0 },
"size": { "width": 4, "height": 3 }, "stroke": "light" } ]
}"#;
let without_an_id = r#"{
"canvas": { "origin": { "x": 0, "y": 0 }, "size": { "width": 4, "height": 3 } },
"next_id": 2,
"shapes": [ { "kind": "box", "at": { "x": 0, "y": 0 },
"size": { "width": 4, "height": 3 }, "stroke": "light" } ]
}"#;
for (json, named) in [(without_next_id, "next_id"), (without_an_id, "id")] {
let error = refusal(json);
assert!(
error
.to_string()
.contains(&format!("missing field `{named}`")),
"expected `missing field `{named}``, got: {error}"
);
}
}
#[test]
fn an_identity_written_as_free_text_is_refused_and_names_the_field() {
let a_string_id = r#"{
"canvas": { "origin": { "x": 0, "y": 0 }, "size": { "width": 4, "height": 3 } },
"next_id": 2,
"shapes": [ { "kind": "box", "id": "arrow", "at": { "x": 0, "y": 0 },
"size": { "width": 4, "height": 3 }, "stroke": "light" } ]
}"#;
let a_string_shape = &description_of(&ARROW_WITH_A_REFERENCE.replace(
"AT",
r##"{ "kind": "reference", "shape": "#1", "anchor": "right" }"##,
));
for (json, named, said) in [
(a_string_id, "id", "\"arrow\""),
(a_string_shape, "shape", "\"#1\""),
] {
let error = refusal(json).to_string();
assert!(
error.starts_with(&format!("`{named}` is an ordinal: ")),
"expected `{named}` to be named first, got: {error}"
);
assert!(
error.contains(said),
"expected the message to quote what the file said, `{said}`, got: {error}"
);
}
}
#[test]
fn a_next_id_of_zero_is_refused_and_names_the_field_it_was_refused_on() {
let json = r#"{
"canvas": { "origin": { "x": 0, "y": 0 }, "size": { "width": 4, "height": 3 } },
"next_id": 0,
"shapes": [ { "kind": "box", "id": 1, "at": { "x": 0, "y": 0 },
"size": { "width": 4, "height": 3 }, "stroke": "light" } ]
}"#;
let error = refusal(json).to_string();
assert!(
error.starts_with("`next_id` is an ordinal: "),
"expected `next_id` to be named first, got: {error}"
);
assert!(
error.contains('0'),
"expected the message to quote the zero it was handed, got: {error}"
);
}
#[test]
fn an_ordinal_is_read_where_the_format_names_a_shape() {
let json = r#"{
"canvas": { "origin": { "x": 0, "y": 0 }, "size": { "width": 8, "height": 3 } },
"next_id": 3,
"shapes": [
{ "kind": "box", "id": 1, "at": { "x": 0, "y": 0 },
"size": { "width": 4, "height": 3 }, "stroke": "light" },
{ "kind": "connector", "id": 2,
"from": { "at": { "kind": "reference", "shape": 1, "anchor": "right" },
"terminal": { "kind": "glyph", "glyph": ">" } },
"to": { "at": { "kind": "point", "x": 7, "y": 1 }, "leaving": "left",
"terminal": { "kind": "arm" } },
"stroke": "light" }
]
}"#;
let picture = render_once(
serde_json::from_str::<Description>(json).expect("an ordinal is an ordinal"),
Pos { x: 0, y: 0 },
Size {
width: 8,
height: 3,
},
);
assert!(
picture.contains('>'),
"the connector must draw, so the reference resolved through the ordinal: {picture:?}"
);
}
#[test]
fn converting_a_diagram_to_description_and_back_preserves_the_diagram() {
let json = include_str!("../../monospace/assets/demo.json");
let original = parse(json).expect("demo parses");
let description = Description::from_diagram(&original);
let restored = description.into_diagram();
let second_description = Description::from_diagram(&restored);
assert_eq!(second_description, Description::from_diagram(&original));
}
#[test]
fn serialize_produces_two_space_indented_json_ending_in_newline() {
let json = include_str!("../../monospace/assets/demo.json");
let diagram = parse(json).expect("demo parses");
let serialized = super::serialize(&diagram).expect("serializes");
assert!(
serialized.ends_with('\n'),
"serialized JSON has no trailing newline"
);
assert!(serialized.contains(" \"next_id\": 28,\n \"shapes\": ["));
let read_again = parse(&serialized).expect("reads serialized JSON back");
let description_after = Description::from_diagram(&read_again);
assert_eq!(description_after, Description::from_diagram(&diagram));
}
#[test]
fn serializing_a_reference_preserves_its_out_field_and_omits_it_when_zero() {
let json = r#"{
"canvas": {
"origin": { "x": 0, "y": 0 },
"size": { "width": 10, "height": 5 }
},
"next_id": 4,
"shapes": [
{
"kind": "box",
"id": 1,
"at": { "x": 0, "y": 0 },
"size": { "width": 4, "height": 3 },
"stroke": "light"
},
{
"kind": "connector",
"id": 2,
"from": {
"at": {
"kind": "reference",
"shape": 1,
"anchor": "right",
"offset": { "dx": 2, "dy": -1 },
"out": 1
},
"terminal": { "kind": "arm" }
},
"to": {
"at": { "kind": "point", "x": 8, "y": 1 },
"leaving": "left",
"terminal": { "kind": "arm" }
},
"stroke": "light"
},
{
"kind": "connector",
"id": 3,
"from": {
"at": {
"kind": "reference",
"shape": 1,
"anchor": "bottom",
"offset": { "dx": 1, "dy": 0 }
},
"terminal": { "kind": "arm" }
},
"to": {
"at": { "kind": "point", "x": 2, "y": 4 },
"leaving": "up",
"terminal": { "kind": "arm" }
},
"stroke": "light"
}
]
}"#;
let diagram = parse(json).expect("valid description parses");
let serialized = super::serialize(&diagram).expect("serializes cleanly");
assert!(
serialized.contains("\"out\": 1"),
"serialized output must preserve non-zero out: {serialized}"
);
assert!(
serialized.contains("\"dx\": 2") && serialized.contains("\"dy\": -1"),
"offset must remain separate without folding out into it: {serialized}"
);
let round_tripped = parse(&serialized).expect("serialized JSON parses back");
assert_eq!(
Description::from_diagram(&round_tripped),
Description::from_diagram(&diagram),
"round trip preserves the description structure"
);
let desc = serde_json::from_str::<serde_json::Value>(&serialized)
.expect("serialized JSON is valid JSON");
let shapes = desc["shapes"].as_array().expect("shapes is array");
let conn2_from = &shapes[1]["from"]["at"];
assert_eq!(conn2_from["out"], 1);
assert_eq!(conn2_from["offset"]["dx"], 2);
assert_eq!(conn2_from["offset"]["dy"], -1);
let conn3_from = &shapes[2]["from"]["at"];
assert!(
conn3_from.get("out").is_none(),
"an out of zero is omitted from the serialized reference JSON"
);
assert!(
shapes[1]["from"].get("leaving").is_none(),
"reference endpoint must omit leaving when serialized"
);
assert_eq!(shapes[1]["to"]["leaving"], "left");
}
#[test]
fn a_reference_endpoint_without_leaving_derives_its_leaving_direction_from_the_anchor() {
let json = r#"{
"next_id": 3,
"shapes": [
{ "kind": "box", "id": 1, "at": { "x": 0, "y": 0 },
"size": { "width": 4, "height": 3 }, "stroke": "light" },
{ "kind": "connector", "id": 2,
"from": { "at": { "kind": "reference", "shape": 1, "anchor": "right" },
"terminal": { "kind": "arm" } },
"to": { "at": { "kind": "point", "x": 8, "y": 1 }, "leaving": "left",
"terminal": { "kind": "arm" } },
"stroke": "light" }
]
}"#;
let diagram = parse(json).expect("valid reference endpoint without leaving parses");
let connector = diagram.get(ShapeId::new(std::num::NonZeroU16::new(2).unwrap()));
let Some(DiagramShape::Connector { from, .. }) = connector else {
panic!("expected connector");
};
assert_eq!(from.leaving(), Direction::Right);
}
#[test]
fn a_point_endpoint_without_leaving_is_refused_by_name() {
let json = r#"{
"next_id": 2,
"shapes": [
{ "kind": "connector", "id": 1,
"from": { "at": { "kind": "point", "x": 1, "y": 2 },
"terminal": { "kind": "arm" } },
"to": { "at": { "kind": "point", "x": 8, "y": 2 }, "leaving": "left",
"terminal": { "kind": "arm" } },
"stroke": "light" }
]
}"#;
let error = refusal(json).to_string();
assert!(
error.starts_with("missing field `leaving`"),
"expected missing field `leaving`, got: {error}"
);
}
#[test]
fn a_reference_endpoint_with_leaving_is_rejected() {
let json = r#"{
"next_id": 3,
"shapes": [
{ "kind": "box", "id": 1, "at": { "x": 0, "y": 0 },
"size": { "width": 4, "height": 3 }, "stroke": "light" },
{ "kind": "connector", "id": 2,
"from": { "at": { "kind": "reference", "shape": 1, "anchor": "right" },
"leaving": "right",
"terminal": { "kind": "arm" } },
"to": { "at": { "kind": "point", "x": 8, "y": 1 }, "leaving": "left",
"terminal": { "kind": "arm" } },
"stroke": "light" }
]
}"#;
let error = refusal(json).to_string();
assert!(
error.contains(
"a reference endpoint derives its leaving direction from its anchor and must not specify `leaving`"
),
"expected error refusing leaving on reference endpoint, got: {error}"
);
}
#[test]
fn serializing_a_connector_omits_leaving_on_a_reference_and_includes_it_on_a_point() {
let json = r#"{
"next_id": 3,
"shapes": [
{ "kind": "box", "id": 1, "at": { "x": 0, "y": 0 },
"size": { "width": 4, "height": 3 }, "stroke": "light" },
{ "kind": "connector", "id": 2,
"from": { "at": { "kind": "reference", "shape": 1, "anchor": "right" },
"terminal": { "kind": "arm" } },
"to": { "at": { "kind": "point", "x": 8, "y": 1 }, "leaving": "left",
"terminal": { "kind": "arm" } },
"stroke": "light" }
]
}"#;
let diagram = parse(json).expect("valid description parses");
let serialized = super::serialize(&diagram).expect("serializes cleanly");
let desc = serde_json::from_str::<serde_json::Value>(&serialized).expect("valid json");
let shapes = desc["shapes"].as_array().expect("shapes is array");
let from = &shapes[1]["from"];
let to = &shapes[1]["to"];
assert!(
from.get("leaving").is_none(),
"reference endpoint must omit leaving when serialized: {serialized}"
);
assert_eq!(
to["leaving"], "left",
"point endpoint must include leaving when serialized: {serialized}"
);
}
}