aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/lib.rs1354
1 files changed, 1354 insertions, 0 deletions
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..cb003f2
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,1354 @@
+/// A parsed wardley map.
+#[derive(Debug)]
+pub struct Map {
+ pub components: Vec<Component>,
+ pub dependencies: Vec<Dependency>,
+ pub notes: Vec<Note>,
+ pub stages: Vec<StageData>,
+ pub groups: Vec<Group>,
+ pub inertias: Vec<Inertia>,
+ pub evolutions: Vec<Evolution>,
+}
+
+/// Any of the potential shapes in a component.
+#[derive(Debug, PartialEq)]
+pub enum Shape {
+ Circle,
+ X,
+ Square,
+ Triangle,
+}
+
+/// The potential stages of evolution.
+#[derive(Debug, PartialEq)]
+pub enum Stage {
+ I,
+ Ii,
+ Iii,
+ Iv,
+}
+
+/// A component in the map.
+#[derive(Debug, PartialEq)]
+pub struct Component {
+ pub label: String,
+ pub coordinates: (f64, f64),
+ pub shape: Shape,
+}
+
+/// A dependency between two components.
+#[derive(Debug, PartialEq)]
+pub struct Dependency {
+ pub from: String,
+ pub to: String,
+ pub is_directed: bool,
+}
+
+/// A note.
+#[derive(Debug, PartialEq)]
+pub struct Note {
+ pub text: String,
+ pub coordinates: (f64, f64),
+}
+
+/// An override for the width of an evolution stage.
+#[derive(Debug, PartialEq)]
+pub struct StageData {
+ pub stage: Stage,
+ pub value: f64,
+}
+
+/// A group of components.
+#[derive(Debug, PartialEq)]
+pub struct Group {
+ pub components: Vec<String>,
+}
+
+/// Inertia associated with a component.
+#[derive(Debug, PartialEq)]
+pub struct Inertia {
+ pub component: String,
+}
+
+/// Evolution associated with a component.
+#[derive(Debug, PartialEq)]
+pub struct Evolution {
+ pub component: String,
+ pub value: f64,
+}
+
+/// Parses a wmap source string.
+#[must_use]
+pub fn parse(source: &str) -> Map {
+ // Estimate capacity based on input size
+ let estimated_lines = source.len() / 30;
+
+ let mut components = Vec::with_capacity(estimated_lines);
+ let mut dependencies = Vec::with_capacity(estimated_lines);
+ let mut notes = Vec::with_capacity(estimated_lines / 10);
+ let mut stages = Vec::with_capacity(4);
+ let mut groups = Vec::with_capacity(estimated_lines / 20);
+ let mut inertias = Vec::with_capacity(estimated_lines / 20);
+ let mut evolutions = Vec::with_capacity(estimated_lines / 20);
+
+ let bytes = source.as_bytes();
+ let mut i = 0;
+ let len = bytes.len();
+
+ while i < len {
+ // Skip line separators
+ while i < len && (bytes[i] == b'\n' || bytes[i] == b'\r') {
+ i += 1;
+ }
+
+ let line_start = i;
+
+ // Find line end
+ while i < len && bytes[i] != b'\n' && bytes[i] != b'\r' {
+ i += 1;
+ }
+
+ let line = source[line_start..i].trim();
+
+ if line.is_empty() {
+ continue;
+ }
+
+ // At this point the line is trimmed.
+ let bytes = line.as_bytes();
+ if bytes[0] == b'[' {
+ parse_keyword_line(
+ line,
+ &mut stages,
+ &mut notes,
+ &mut groups,
+ &mut inertias,
+ &mut evolutions,
+ );
+ } else if has_dependency(bytes) {
+ if let Some(dep) = parse_dependency(line) {
+ dependencies.push(dep);
+ }
+ } else if let Some(comp) = parse_component(line) {
+ components.push(comp);
+ }
+ }
+
+ Map {
+ components,
+ dependencies,
+ notes,
+ stages,
+ groups,
+ inertias,
+ evolutions,
+ }
+}
+
+/// Checks if the line has the -- or -> of a dependency.
+#[inline]
+fn has_dependency(bytes: &[u8]) -> bool {
+ for i in 0..bytes.len() {
+ if bytes[i] == b'(' {
+ return false;
+ }
+ if bytes[i] == b'-' && i + 1 < bytes.len() {
+ let next = bytes[i + 1];
+ if next == b'>' || next == b'-' {
+ return true;
+ }
+ }
+ }
+ false
+}
+
+/// Parses a Keyword line.
+/// IMPORTANT: This function assumes the line has been trimmed, and confirmed
+/// to start with [.
+fn parse_keyword_line(
+ line: &str,
+ stages: &mut Vec<StageData>,
+ notes: &mut Vec<Note>,
+ groups: &mut Vec<Group>,
+ inertias: &mut Vec<Inertia>,
+ evolutions: &mut Vec<Evolution>,
+) {
+ let bytes = line.as_bytes();
+ let mut i = 0;
+
+ // Skip the first [
+ i += 1;
+
+ let keyword_start = i;
+ while i < bytes.len() && bytes[i] != b']' {
+ i += 1;
+ }
+ if i >= bytes.len() {
+ return;
+ }
+
+ let keyword = &line[keyword_start..i];
+ i += 1;
+
+ i = skip_whitespace(bytes, i);
+ let rest = &line[i..];
+
+ if keyword.eq_ignore_ascii_case("i")
+ || keyword.eq_ignore_ascii_case("ii")
+ || keyword.eq_ignore_ascii_case("iii")
+ || keyword.eq_ignore_ascii_case("iv")
+ {
+ stages.extend(parse_stage(keyword, rest));
+ } else if keyword.eq_ignore_ascii_case("note") {
+ notes.extend(parse_note(rest));
+ } else if keyword.eq_ignore_ascii_case("group") {
+ groups.extend(parse_group(rest));
+ } else if keyword.eq_ignore_ascii_case("inertia") {
+ let component = rest.trim();
+ if !component.is_empty() {
+ inertias.push(Inertia {
+ component: component.to_string(),
+ });
+ }
+ } else if keyword.eq_ignore_ascii_case("evolution") {
+ evolutions.extend(parse_evolution(rest));
+ }
+}
+
+/// Parses the stage of evolution.
+/// IMPORTANT: Assumes we already checked for i, ii, iii, or iv.
+fn parse_stage(stage_str: &str, rest: &str) -> Option<StageData> {
+ let value = rest.trim().parse::<f64>().ok()?;
+ let stage = if stage_str.eq_ignore_ascii_case("i") {
+ Stage::I
+ } else if stage_str.eq_ignore_ascii_case("ii") {
+ Stage::Ii
+ } else if stage_str.eq_ignore_ascii_case("iii") {
+ Stage::Iii
+ } else {
+ Stage::Iv
+ };
+ Some(StageData { stage, value })
+}
+
+/// Parses a note with a position.
+fn parse_note(rest: &str) -> Option<Note> {
+ let bytes = rest.as_bytes();
+ let mut i = skip_whitespace(bytes, 0);
+
+ if i >= bytes.len() || bytes[i] != b'(' {
+ return None;
+ }
+ i += 1;
+
+ let (x, y, end_i) = parse_coordinates(rest, i)?;
+ i = end_i;
+
+ i = skip_whitespace(bytes, i);
+ let text = rest[i..].trim().to_string();
+
+ Some(Note {
+ text,
+ coordinates: (x, y),
+ })
+}
+
+/// Parses a comma separated group of nodes.
+fn parse_group(rest: &str) -> Option<Group> {
+ let components: Vec<String> = rest
+ .split(',')
+ .map(str::trim)
+ .filter(|s| !s.is_empty())
+ .map(std::string::ToString::to_string)
+ .collect();
+
+ if components.is_empty() {
+ None
+ } else {
+ Some(Group { components })
+ }
+}
+
+/// Parses the evolution of a node
+fn parse_evolution(rest: &str) -> Option<Evolution> {
+ let bytes = rest.as_bytes();
+ let mut position_of_sign = None;
+ let mut sign = 1.0;
+
+ for (i, byte) in bytes.iter().enumerate() {
+ if byte == &b'+' || byte == &b'-' {
+ position_of_sign = Some(i);
+ sign = if byte == &b'-' { -1.0 } else { 1.0 };
+ break;
+ }
+ }
+
+ let i = position_of_sign?;
+ let component = rest[..i].trim().to_string();
+ if component.is_empty() {
+ return None;
+ }
+
+ let value_str = rest[i + 1..].trim();
+ let value = value_str.parse::<f64>().ok()?;
+
+ Some(Evolution {
+ component,
+ value: sign * value,
+ })
+}
+
+/// Parses a dependency.
+/// IMPORTANT: This function assumes we confirmed this string has a -- or ->
+fn parse_dependency(line: &str) -> Option<Dependency> {
+ let index_of_arrow = line.find("->");
+ let index_of_dash = line.find("--");
+
+ let index = index_of_arrow.or(index_of_dash)?;
+ let from = line[..index].trim();
+ let to = line[index + 2..].trim();
+
+ if from.is_empty() || to.is_empty() {
+ return None;
+ }
+
+ Some(Dependency {
+ from: from.to_string(),
+ to: to.to_string(),
+ is_directed: index_of_arrow.is_some(),
+ })
+}
+
+/// Parses a component with coordinates, and optionally a cusotm shape.
+fn parse_component(line: &str) -> Option<Component> {
+ let bytes = line.as_bytes();
+ let index_of_parenthesis = line.find('(')?;
+ let label = line[..index_of_parenthesis].trim().to_string();
+ if label.is_empty() {
+ return None;
+ }
+
+ let (x, y, end_i) = parse_coordinates(line, index_of_parenthesis + 1)?;
+
+ let mut i = skip_whitespace(bytes, end_i);
+ let shape = if i < bytes.len() && bytes[i] == b'[' {
+ i += 1;
+ let shape_start = i;
+ while i < bytes.len() && bytes[i] != b']' {
+ i += 1;
+ }
+ if i > shape_start && i < bytes.len() {
+ let shape_label = line[shape_start..i].trim();
+ parse_shape(shape_label)
+ } else {
+ Shape::Circle
+ }
+ } else {
+ Shape::Circle
+ };
+
+ Some(Component {
+ label,
+ coordinates: (x, y),
+ shape,
+ })
+}
+
+/// Parses the name of a shape.
+#[inline]
+fn parse_shape(shape: &str) -> Shape {
+ if shape.eq_ignore_ascii_case("x") {
+ Shape::X
+ } else if shape.eq_ignore_ascii_case("square") {
+ Shape::Square
+ } else if shape.eq_ignore_ascii_case("triangle") {
+ Shape::Triangle
+ } else {
+ Shape::Circle
+ }
+}
+
+/// Parses a pair of comma separated coordinates.
+fn parse_coordinates(line: &str, start: usize) -> Option<(f64, f64, usize)> {
+ let bytes = line.as_bytes();
+ let mut i = skip_whitespace(bytes, start);
+
+ // Parse x
+ let x_start = i;
+ while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {
+ i += 1;
+ }
+ let x = line[x_start..i].parse::<f64>().ok()?;
+
+ // Expect comma
+ i = skip_whitespace(bytes, i);
+ if i >= bytes.len() || bytes[i] != b',' {
+ return None;
+ }
+ i += 1;
+
+ // Parse y
+ i = skip_whitespace(bytes, i);
+ let y_start = i;
+ while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {
+ i += 1;
+ }
+ let y = line[y_start..i].parse::<f64>().ok()?;
+
+ // Expect closing paren
+ i = skip_whitespace(bytes, i);
+ if i >= bytes.len() || bytes[i] != b')' {
+ return None;
+ }
+ i += 1;
+
+ Some((x, y, i))
+}
+
+// Parsing Helpers /////////////////////////////////////////////////////////////
+
+/// Advances the iterator while there's only whitespace.
+#[inline]
+fn skip_whitespace(bytes: &[u8], mut i: usize) -> usize {
+ while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
+ i += 1;
+ }
+ i
+}
+
+// Tests ///////////////////////////////////////////////////////////////////////
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /* Components */
+
+ #[test]
+ fn should_parse_a_basic_component() {
+ let source = "Dominoes (0.9, 0.5)";
+ let result = parse(source);
+ assert_eq!(result.components.len(), 1);
+ assert_eq!(
+ result.components.first().unwrap(),
+ &Component {
+ label: "Dominoes".to_string(),
+ coordinates: (0.9, 0.5),
+ shape: Shape::Circle
+ }
+ );
+ }
+
+ #[test]
+ fn should_parse_a_component_with_a_shape() {
+ let source = "Database (0.33, 0.81) [Square]";
+ let result = parse(source);
+ assert_eq!(result.components.len(), 1);
+ assert_eq!(
+ result.components.first().unwrap(),
+ &Component {
+ label: "Database".to_string(),
+ coordinates: (0.33, 0.81),
+ shape: Shape::Square
+ }
+ );
+ }
+
+ #[test]
+ fn should_parse_a_component_with_x_shape() {
+ let source = "API (0.5, 0.6) [x]";
+ let result = parse(source);
+ assert_eq!(result.components.len(), 1);
+ assert_eq!(
+ result.components.first().unwrap(),
+ &Component {
+ label: "API".to_string(),
+ coordinates: (0.5, 0.6),
+ shape: Shape::X
+ }
+ );
+ }
+
+ #[test]
+ fn should_parse_a_component_with_triangle_shape() {
+ let source = "Service (0.4, 0.7) [Triangle]";
+ let result = parse(source);
+ assert_eq!(result.components.len(), 1);
+ assert_eq!(
+ result.components.first().unwrap(),
+ &Component {
+ label: "Service".to_string(),
+ coordinates: (0.4, 0.7),
+ shape: Shape::Triangle
+ }
+ );
+ }
+
+ #[test]
+ fn should_parse_a_component_with_circle_shape() {
+ let source = "Process (0.2, 0.9) [CIRCLE]";
+ let result = parse(source);
+ assert_eq!(result.components.len(), 1);
+ assert_eq!(
+ result.components.first().unwrap(),
+ &Component {
+ label: "Process".to_string(),
+ coordinates: (0.2, 0.9),
+ shape: Shape::Circle
+ }
+ );
+ }
+
+ #[test]
+ fn should_default_shape_to_circle_if_invalid() {
+ let source = "Figures (0.12, 0.711) [";
+ let result = parse(source);
+ assert_eq!(result.components.len(), 1);
+ assert_eq!(
+ result.components.first().unwrap(),
+ &Component {
+ label: "Figures".to_string(),
+ coordinates: (0.12, 0.711),
+ shape: Shape::Circle
+ }
+ );
+ }
+
+ #[test]
+ fn should_handle_decimal_numbers() {
+ let source = "Item (.5, .123)";
+ let result = parse(source);
+ assert_eq!(result.components.len(), 1);
+ assert_eq!(
+ result.components.first().unwrap(),
+ &Component {
+ label: "Item".to_string(),
+ coordinates: (0.5, 0.123),
+ shape: Shape::Circle
+ }
+ );
+ }
+
+ #[test]
+ fn should_handle_inteers_as_coordinates() {
+ let source = "Item (1, 0)";
+ let result = parse(source);
+ assert_eq!(result.components.len(), 1);
+ assert_eq!(
+ result.components.first().unwrap(),
+ &Component {
+ label: "Item".to_string(),
+ coordinates: (1.0, 0.0),
+ shape: Shape::Circle
+ }
+ );
+ }
+
+ #[test]
+ fn should_preserve_case_in_component_labels() {
+ let source = "RubberGaskets (0.5, 0.5)";
+ let result = parse(source);
+ assert_eq!(result.components.len(), 1);
+ assert_eq!(
+ result.components.first().unwrap(),
+ &Component {
+ label: "RubberGaskets".to_string(),
+ coordinates: (0.5, 0.5),
+ shape: Shape::Circle
+ }
+ );
+ }
+
+ #[test]
+ fn should_handle_component_labels_with_spaces() {
+ let source = "Big Lawnmowers (0.5, 0.5)";
+ let result = parse(source);
+ assert_eq!(result.components.len(), 1);
+ assert_eq!(
+ result.components.first().unwrap(),
+ &Component {
+ label: "Big Lawnmowers".to_string(),
+ coordinates: (0.5, 0.5),
+ shape: Shape::Circle
+ }
+ );
+ }
+
+ #[test]
+ fn should_handle_a_whitespace_around_tokens() {
+ let source = " Outer Space ( 0.9 , 0.5 ) [ Square ] ";
+ let result = parse(source);
+ assert_eq!(result.components.len(), 1);
+ assert_eq!(
+ result.components.first().unwrap(),
+ &Component {
+ label: "Outer Space".to_string(),
+ coordinates: (0.9, 0.5),
+ shape: Shape::Square
+ }
+ );
+ }
+
+ /* Dependencies */
+
+ #[test]
+ fn should_parse_undirected_dependency() {
+ let source = "Smoke -- Fire";
+ let result = parse(source);
+ assert_eq!(result.dependencies.len(), 1);
+ assert_eq!(
+ result.dependencies.first().unwrap(),
+ &Dependency {
+ from: "Smoke".to_string(),
+ to: "Fire".to_string(),
+ is_directed: false
+ }
+ );
+ }
+
+ #[test]
+ fn should_parse_directed_dependency() {
+ let source = "Cool Thing -> Different Cool Thing";
+ let result = parse(source);
+ assert_eq!(result.dependencies.len(), 1);
+ assert_eq!(
+ result.dependencies.first().unwrap(),
+ &Dependency {
+ from: "Cool Thing".to_string(),
+ to: "Different Cool Thing".to_string(),
+ is_directed: true
+ }
+ );
+ }
+
+ #[test]
+ fn should_preserve_case_in_dependency_labels() {
+ let source = "SaRcAsM -> Seriousness.";
+ let result = parse(source);
+ assert_eq!(result.dependencies.len(), 1);
+ assert_eq!(
+ result.dependencies.first().unwrap(),
+ &Dependency {
+ from: "SaRcAsM".to_string(),
+ to: "Seriousness.".to_string(),
+ is_directed: true
+ }
+ );
+ }
+
+ #[test]
+ fn should_ignore_whitespace_in_dependencies() {
+ let source = " loose --TIGHT";
+ let result = parse(source);
+ assert_eq!(result.dependencies.len(), 1);
+ assert_eq!(
+ result.dependencies.first().unwrap(),
+ &Dependency {
+ from: "loose".to_string(),
+ to: "TIGHT".to_string(),
+ is_directed: false
+ }
+ );
+ }
+
+ /* Notes */
+
+ #[test]
+ fn should_parse_a_note() {
+ let source = "[Note] (0.5, 0.5) This is a note";
+ let result = parse(source);
+ assert_eq!(result.notes.len(), 1);
+ assert_eq!(
+ result.notes.first().unwrap(),
+ &Note {
+ text: "This is a note".to_string(),
+ coordinates: (0.5, 0.5)
+ }
+ );
+ }
+
+ #[test]
+ fn should_handle_case_insensitive_note_keyword() {
+ let source = "[note] (0.1, 0.2) Text";
+ let result = parse(source);
+ assert_eq!(result.notes.len(), 1);
+ assert_eq!(
+ result.notes.first().unwrap(),
+ &Note {
+ text: "Text".to_string(),
+ coordinates: (0.1, 0.2)
+ }
+ );
+ }
+
+ #[test]
+ fn should_preserve_case_in_note_text() {
+ let source = "[NOTE] (0.77, 0.19) Important NOTE";
+ let result = parse(source);
+ assert_eq!(result.notes.len(), 1);
+ assert_eq!(
+ result.notes.first().unwrap(),
+ &Note {
+ text: "Important NOTE".to_string(),
+ coordinates: (0.77, 0.19)
+ }
+ );
+ }
+
+ #[test]
+ fn should_handle_empty_note_text() {
+ let source = "[Note] (0.5, 0.5) ";
+ let result = parse(source);
+ assert_eq!(result.notes.len(), 1);
+ assert_eq!(
+ result.notes.first().unwrap(),
+ &Note {
+ text: "".to_string(),
+ coordinates: (0.5, 0.5)
+ }
+ );
+ }
+
+ /* Stages */
+
+ #[test]
+ fn should_parse_stage_i() {
+ let source = "[I] 0.25";
+ let result = parse(source);
+ assert_eq!(result.stages.len(), 1);
+ assert_eq!(
+ result.stages.first().unwrap(),
+ &StageData {
+ stage: Stage::I,
+ value: 0.25
+ }
+ );
+ }
+
+ #[test]
+ fn should_parse_stage_ii() {
+ let source = "[ii] 0.5";
+ let result = parse(source);
+ assert_eq!(result.stages.len(), 1);
+ assert_eq!(
+ result.stages.first().unwrap(),
+ &StageData {
+ stage: Stage::Ii,
+ value: 0.5
+ }
+ );
+ }
+
+ #[test]
+ fn should_parse_stage_iii() {
+ let source = "[III] 0.75";
+ let result = parse(source);
+ assert_eq!(result.stages.len(), 1);
+ assert_eq!(
+ result.stages.first().unwrap(),
+ &StageData {
+ stage: Stage::Iii,
+ value: 0.75
+ }
+ );
+ }
+
+ #[test]
+ fn should_parse_stage_iv() {
+ let source = "[Iv] 1.0";
+ let result = parse(source);
+ assert_eq!(result.stages.len(), 1);
+ assert_eq!(
+ result.stages.first().unwrap(),
+ &StageData {
+ stage: Stage::Iv,
+ value: 1.0
+ }
+ );
+ }
+
+ #[test]
+ fn should_handle_decimal_values() {
+ let source = "[i] .333";
+ let result = parse(source);
+ assert_eq!(result.stages.len(), 1);
+ assert_eq!(
+ result.stages.first().unwrap(),
+ &StageData {
+ stage: Stage::I,
+ value: 0.333
+ }
+ );
+ }
+
+ /* Groups */
+
+ #[test]
+ fn should_parse_a_group_with_a_single_component() {
+ let source = "[Group] Lonely";
+ let result = parse(source);
+ assert_eq!(result.groups.len(), 1);
+ assert_eq!(
+ result.groups.first().unwrap().components,
+ vec!["Lonely".to_string()]
+ );
+ }
+
+ #[test]
+ fn should_parse_a_group_with_multiple_components() {
+ let source = "[GrouP] Huey, Dewey, Louie";
+ let result = parse(source);
+ assert_eq!(result.groups.len(), 1);
+ assert_eq!(
+ result.groups.first().unwrap().components,
+ vec!["Huey".to_string(), "Dewey".to_string(), "Louie".to_string()]
+ );
+ }
+
+ #[test]
+ fn should_handle_case_insensitive_group_keyword() {
+ let source = "[group] Salt, Pepper";
+ let result = parse(source);
+ assert_eq!(result.groups.len(), 1);
+ assert_eq!(
+ result.groups.first().unwrap().components,
+ vec!["Salt".to_string(), "Pepper".to_string()]
+ );
+ }
+
+ #[test]
+ fn should_preserve_case_in_group_component_label() {
+ let source = "[GROUP] XML, LaTeX";
+ let result = parse(source);
+ assert_eq!(result.groups.len(), 1);
+ assert_eq!(
+ result.groups.first().unwrap().components,
+ vec!["XML".to_string(), "LaTeX".to_string()]
+ );
+ }
+
+ #[test]
+ fn should_handle_whitespace_around_commas() {
+ let source = "[Group] sky, is large, emperor";
+ let result = parse(source);
+ assert_eq!(result.groups.len(), 1);
+ assert_eq!(
+ result.groups.first().unwrap().components,
+ vec![
+ "sky".to_string(),
+ "is large".to_string(),
+ "emperor".to_string()
+ ]
+ );
+ }
+
+ /* Inertia */
+ #[test]
+ fn should_parse_inertia() {
+ let source = "[Inertia] Print Media";
+ let result = parse(source);
+ assert_eq!(result.inertias.len(), 1);
+ assert_eq!(result.inertias.first().unwrap().component, "Print Media");
+ }
+
+ #[test]
+ fn should_handle_case_insensitive_inertia_keyword() {
+ let source = "[inertia] Yes";
+ let result = parse(source);
+ assert_eq!(result.inertias.len(), 1);
+ assert_eq!(result.inertias.first().unwrap().component, "Yes");
+ }
+
+ #[test]
+ fn should_preserve_case_in_inertia_component_label() {
+ let source = "[INERTIA] SpOnGeBoB";
+ let result = parse(source);
+ assert_eq!(result.inertias.len(), 1);
+ assert_eq!(result.inertias.first().unwrap().component, "SpOnGeBoB");
+ }
+
+ /* Evolution */
+ #[test]
+ fn should_parse_positive_evolution() {
+ let source = "[Evolution] Nanomachines + 0.5";
+ let result = parse(source);
+ assert_eq!(result.evolutions.len(), 1);
+ assert_eq!(
+ result.evolutions.first().unwrap(),
+ &Evolution {
+ component: "Nanomachines".to_string(),
+ value: 0.5
+ }
+ );
+ }
+
+ #[test]
+ fn should_parse_negative_evolution() {
+ let source = "[Evolution] FOXDIE - 0.3";
+ let result = parse(source);
+ assert_eq!(result.evolutions.len(), 1);
+ assert_eq!(
+ result.evolutions.first().unwrap(),
+ &Evolution {
+ component: "FOXDIE".to_string(),
+ value: -0.3
+ }
+ );
+ }
+
+ #[test]
+ fn should_handle_case_insensitive_evolution_keyword() {
+ let source = "[Evolution] tamales + 0.73";
+ let result = parse(source);
+ assert_eq!(result.evolutions.len(), 1);
+ assert_eq!(
+ result.evolutions.first().unwrap(),
+ &Evolution {
+ component: "tamales".to_string(),
+ value: 0.73
+ }
+ );
+ }
+
+ #[test]
+ fn should_preserve_case_in_evolution_component_label() {
+ let source = "[EVOLUTION] iMac - 0.2";
+ let result = parse(source);
+ assert_eq!(result.evolutions.len(), 1);
+ assert_eq!(
+ result.evolutions.first().unwrap(),
+ &Evolution {
+ component: "iMac".to_string(),
+ value: -0.2
+ }
+ );
+ }
+
+ #[test]
+ fn should_handle_decimal_evolution_values() {
+ let source = "[Evolution] Numerals + .75";
+ let result = parse(source);
+ assert_eq!(result.evolutions.len(), 1);
+ assert_eq!(
+ result.evolutions.first().unwrap(),
+ &Evolution {
+ component: "Numerals".to_string(),
+ value: 0.75
+ }
+ );
+ }
+
+ /* Multiple Entities */
+ #[test]
+ fn should_parse_multiple_entities() {
+ let source = "Water (0.9, 0.5)
+Bucket (0.8, 0.4) [x]
+Water -> Bucket";
+ let result = parse(source);
+ assert_eq!(result.components.len(), 2);
+ assert_eq!(
+ result.components.first().unwrap(),
+ &Component {
+ label: "Water".to_string(),
+ coordinates: (0.9, 0.5),
+ shape: Shape::Circle
+ }
+ );
+ assert_eq!(
+ result.components.get(1).unwrap(),
+ &Component {
+ label: "Bucket".to_string(),
+ coordinates: (0.8, 0.4),
+ shape: Shape::X
+ }
+ );
+ assert_eq!(result.dependencies.len(), 1);
+ assert_eq!(
+ result.dependencies.first().unwrap(),
+ &Dependency {
+ from: "Water".to_string(),
+ to: "Bucket".to_string(),
+ is_directed: true
+ }
+ );
+ }
+
+ #[test]
+ fn should_handle_unix_line_endings() {
+ let source = "Penguins (0.9, 0.5)\nI Use Arch BTW (0.8, 0.4) [x]";
+ let result = parse(source);
+ assert_eq!(result.components.len(), 2);
+ assert_eq!(
+ result.components.first().unwrap(),
+ &Component {
+ label: "Penguins".to_string(),
+ coordinates: (0.9, 0.5),
+ shape: Shape::Circle
+ }
+ );
+ assert_eq!(
+ result.components.get(1).unwrap(),
+ &Component {
+ label: "I Use Arch BTW".to_string(),
+ coordinates: (0.8, 0.4),
+ shape: Shape::X
+ }
+ );
+ }
+
+ #[test]
+ fn should_handle_windows_line_endings() {
+ let source = "Clippy (0.9, 0.5)\r\nSpace Cadet (0.8, 0.4) [x]";
+ let result = parse(source);
+ assert_eq!(result.components.len(), 2);
+ assert_eq!(
+ result.components.first().unwrap(),
+ &Component {
+ label: "Clippy".to_string(),
+ coordinates: (0.9, 0.5),
+ shape: Shape::Circle
+ }
+ );
+ assert_eq!(
+ result.components.get(1).unwrap(),
+ &Component {
+ label: "Space Cadet".to_string(),
+ coordinates: (0.8, 0.4),
+ shape: Shape::X
+ }
+ );
+ }
+
+ #[test]
+ fn should_handle_mac_line_endings() {
+ let source = "SE/30 (0.9, 0.5)\rPerforma (0.8, 0.4) [x]";
+ let result = parse(source);
+ assert_eq!(result.components.len(), 2);
+ assert_eq!(
+ result.components.first().unwrap(),
+ &Component {
+ label: "SE/30".to_string(),
+ coordinates: (0.9, 0.5),
+ shape: Shape::Circle
+ }
+ );
+ assert_eq!(
+ result.components.get(1).unwrap(),
+ &Component {
+ label: "Performa".to_string(),
+ coordinates: (0.8, 0.4),
+ shape: Shape::X
+ }
+ );
+ }
+
+ #[test]
+ fn should_ignore_empty_lines() {
+ let source = "The Void (0.9, 0.5)
+
+The Abyss (0.8, 0.4)";
+ let result = parse(source);
+ assert_eq!(result.components.len(), 2);
+ assert_eq!(
+ result.components.first().unwrap(),
+ &Component {
+ label: "The Void".to_string(),
+ coordinates: (0.9, 0.5),
+ shape: Shape::Circle
+ }
+ );
+ assert_eq!(
+ result.components.get(1).unwrap(),
+ &Component {
+ label: "The Abyss".to_string(),
+ coordinates: (0.8, 0.4),
+ shape: Shape::Circle
+ }
+ );
+ }
+
+ #[test]
+ fn should_handle_incomplete_notes() {
+ let source = "[Note]";
+ let result = parse(source);
+ assert_eq!(result.components.len(), 0);
+ }
+
+ #[test]
+ fn should_handle_incomplete_groups() {
+ let source = "[Group]";
+ let result = parse(source);
+ assert_eq!(result.components.len(), 0);
+ }
+
+ #[test]
+ fn should_handle_incomplete_nodes_with_no_coordinates() {
+ let source = "I am a node? (";
+ let result = parse(source);
+ assert_eq!(result.components.len(), 0);
+ }
+
+ #[test]
+ fn should_handle_incomplete_nodes_with_incomplete_coordinates() {
+ let source = "I am a node? (12";
+ let result = parse(source);
+ assert_eq!(result.components.len(), 0);
+ }
+
+ #[test]
+ fn should_handle_incomplete_nodes_with_missing_y_coordinates() {
+ let source = "I am a node? (12,";
+ let result = parse(source);
+ assert_eq!(result.components.len(), 0);
+ }
+
+ #[test]
+ fn should_handle_incomplete_nodes_with_unclosed_coordinates() {
+ let source = "I am a node? (12,21";
+ let result = parse(source);
+ assert_eq!(result.components.len(), 0);
+ }
+
+ #[test]
+ fn should_handle_incomplete_evolution_without_value() {
+ let source = "[Evolution] I accidentally a +";
+ let result = parse(source);
+ assert_eq!(result.components.len(), 0);
+ }
+
+ #[test]
+ fn should_handle_incomplete_evolution_without_component() {
+ let source = "[Evolution]+";
+ let result = parse(source);
+ assert_eq!(result.components.len(), 0);
+ }
+ #[test]
+ fn should_handle_just_a_poarenthesis() {
+ let source = " (";
+ let result = parse(source);
+ assert_eq!(result.components.len(), 0);
+ }
+
+ #[test]
+ fn should_ignore_invalid_lines() {
+ let source = "Hello (0.9, 0.5)
+Oh No
+It's OK (0.8, 0.4)
+[ijole] NO IT ISN'T
+A--
+B->
+(
+Hello -> It's OK
+[";
+ let result = parse(source);
+ assert_eq!(result.components.len(), 2);
+ assert_eq!(
+ result.components.first().unwrap(),
+ &Component {
+ label: "Hello".to_string(),
+ coordinates: (0.9, 0.5),
+ shape: Shape::Circle
+ }
+ );
+ assert_eq!(
+ result.components.get(1).unwrap(),
+ &Component {
+ label: "It's OK".to_string(),
+ coordinates: (0.8, 0.4),
+ shape: Shape::Circle
+ }
+ );
+ assert_eq!(result.dependencies.len(), 1);
+ assert_eq!(
+ result.dependencies.first().unwrap(),
+ &Dependency {
+ from: "Hello".to_string(),
+ to: "It's OK".to_string(),
+ is_directed: true
+ }
+ );
+ }
+
+ /* Edge Cases */
+ #[test]
+ fn should_handle_empty_input() {
+ let source = "";
+ let result = parse(source);
+ assert_eq!(result.components.len(), 0);
+ }
+
+ #[test]
+ fn should_handle_only_whitespace() {
+ let source = " \n \n ";
+ let result = parse(source);
+ assert_eq!(result.components.len(), 0);
+ }
+
+ #[test]
+ fn only_line_breaks() {
+ let source = "\n";
+ let result = parse(source);
+ assert_eq!(result.components.len(), 0);
+ }
+
+ #[test]
+ fn should_handle_only_invalid_lines() {
+ let source = "invalid\nstill invalid";
+ let result = parse(source);
+ assert_eq!(result.components.len(), 0);
+ }
+
+ #[test]
+ fn should_handle_complex_component_labels() {
+ let source = "My Sü'per Complex Label 123 (0.5, 0.5)";
+ let result = parse(source);
+ assert_eq!(result.components.len(), 1);
+ assert_eq!(
+ result.components.first().unwrap(),
+ &Component {
+ label: "My Sü'per Complex Label 123".to_string(),
+ coordinates: (0.5, 0.5),
+ shape: Shape::Circle
+ }
+ );
+ }
+
+ /* Comprehensive Example */
+ #[test]
+ fn should_parse_a_complete_wardley_map() {
+ let source = "[I] 0.25
+[II] 0.5
+[III] 0.75
+[IV] 1.0
+
+Tea (0.9, 0.5) [Circle]
+Cup (0.8, 0.4)
+Kettle (0.7, 0.3) [Square]
+Hot Water (0.6, 0.2)
+
+Tea -> Cup
+Cup -> Kettle
+Kettle -> Hot Water
+
+[Note] (0.5, 0.5) This is our tea supply chain
+
+[Group] Tea, Cup, Kettle
+[Inertia] Kettle
+[Evolution] Tea + 0.1";
+
+ let result = parse(source);
+
+ // 4 stages + 4 components + 3 dependencies + 1 note + 1 group + 1 inertia + 1 evolution
+ assert_eq!(result.stages.len(), 4);
+ assert_eq!(result.components.len(), 4);
+ assert_eq!(result.dependencies.len(), 3);
+ assert_eq!(result.notes.len(), 1);
+ assert_eq!(result.groups.len(), 1);
+ assert_eq!(result.inertias.len(), 1);
+ assert_eq!(result.evolutions.len(), 1);
+
+ assert_eq!(
+ result.stages.first().unwrap(),
+ &StageData {
+ stage: Stage::I,
+ value: 0.25
+ }
+ );
+ assert_eq!(
+ result.stages.get(1).unwrap(),
+ &StageData {
+ stage: Stage::Ii,
+ value: 0.5
+ }
+ );
+ assert_eq!(
+ result.stages.get(2).unwrap(),
+ &StageData {
+ stage: Stage::Iii,
+ value: 0.75
+ }
+ );
+ assert_eq!(
+ result.stages.get(3).unwrap(),
+ &StageData {
+ stage: Stage::Iv,
+ value: 1.0
+ }
+ );
+ assert_eq!(
+ result.components.first().unwrap(),
+ &Component {
+ label: "Tea".to_string(),
+ coordinates: (0.9, 0.5),
+ shape: Shape::Circle
+ }
+ );
+ assert_eq!(
+ result.components.get(1).unwrap(),
+ &Component {
+ label: "Cup".to_string(),
+ coordinates: (0.8, 0.4),
+ shape: Shape::Circle
+ }
+ );
+ assert_eq!(
+ result.components.get(2).unwrap(),
+ &Component {
+ label: "Kettle".to_string(),
+ coordinates: (0.7, 0.3),
+ shape: Shape::Square
+ }
+ );
+ assert_eq!(
+ result.components.get(3).unwrap(),
+ &Component {
+ label: "Hot Water".to_string(),
+ coordinates: (0.6, 0.2),
+ shape: Shape::Circle
+ }
+ );
+ assert_eq!(
+ result.dependencies.first().unwrap(),
+ &Dependency {
+ from: "Tea".to_string(),
+ to: "Cup".to_string(),
+ is_directed: true
+ }
+ );
+ assert_eq!(
+ result.dependencies.get(1).unwrap(),
+ &Dependency {
+ from: "Cup".to_string(),
+ to: "Kettle".to_string(),
+ is_directed: true
+ }
+ );
+ assert_eq!(
+ result.dependencies.get(2).unwrap(),
+ &Dependency {
+ from: "Kettle".to_string(),
+ to: "Hot Water".to_string(),
+ is_directed: true
+ }
+ );
+ assert_eq!(
+ result.notes.first().unwrap(),
+ &Note {
+ text: "This is our tea supply chain".to_string(),
+ coordinates: (0.5, 0.5)
+ }
+ );
+ assert_eq!(
+ result.groups.first().unwrap().components,
+ vec!["Tea".to_string(), "Cup".to_string(), "Kettle".to_string()]
+ );
+ assert_eq!(result.inertias.first().unwrap().component, "Kettle");
+ assert_eq!(
+ result.evolutions.first().unwrap(),
+ &Evolution {
+ component: "Tea".to_string(),
+ value: 0.1
+ }
+ );
+ }
+}