From bdc28c4e832924d81984c2b5da72b369bced6901 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Sun, 2 Feb 2025 20:06:07 -0800 Subject: [PATCH] feat(parse_transition): first version of parse_transition, with tests --- .busted | 3 + spec/helper.lua | 2 + spec/parse_transition_spec.moon | 66 ++++++++++++++++++++ spec/string_spec.moon | 56 +++++++++++++++++ spec/validators_spec.moon | 104 ++++++++++++++++++++++++++++++++ src/util/debug.moon | 24 ++++++++ src/util/parse_transition.moon | 41 +++++++++++++ src/util/string.moon | 18 ++++++ src/util/validators.moon | 28 +++++++++ 9 files changed, 342 insertions(+) create mode 100644 .busted create mode 100644 spec/helper.lua create mode 100644 spec/parse_transition_spec.moon create mode 100644 spec/string_spec.moon create mode 100644 spec/validators_spec.moon create mode 100644 src/util/debug.moon create mode 100644 src/util/parse_transition.moon create mode 100644 src/util/string.moon create mode 100644 src/util/validators.moon diff --git a/.busted b/.busted new file mode 100644 index 0000000..74a6e56 --- /dev/null +++ b/.busted @@ -0,0 +1,3 @@ +return { + preload = { "moonscript" } +} diff --git a/spec/helper.lua b/spec/helper.lua new file mode 100644 index 0000000..85e3bb0 --- /dev/null +++ b/spec/helper.lua @@ -0,0 +1,2 @@ +-- supports requiring .moon files directly +require "moonscript".register! diff --git a/spec/parse_transition_spec.moon b/spec/parse_transition_spec.moon new file mode 100644 index 0000000..19d1599 --- /dev/null +++ b/spec/parse_transition_spec.moon @@ -0,0 +1,66 @@ +require "src.util.parse_transition" + +describe "parse_transition", -> + + -- ### Valid Cases ### + it "parses a valid 'on' transition", -> + result = parse_transition "stateA on eventX" + assert.same {target: "stateA", event: "eventX"}, result + + it "parses a valid 'after' transition", -> + result = parse_transition "stateB after 3.14" + assert.same {target: "stateB", after: 3.14}, result + + it "parses a valid 'every' transition", -> + result = parse_transition "stateC every 42" + assert.same {target: "stateC", every: 42}, result + + -- ### Basic Error Cases ### + it "errors when input is not a string", -> + assert.has_error (-> parse_transition 123), "Transition must be a string" + + it "errors when input does not have exactly three words", -> + assert.has_error (-> parse_transition "two words"), "Transition must consist of three words" + + it "errors when transition type is unknown", -> + assert.has_error (-> parse_transition "stateD unknown value"), "Unknown transition type" + + it "errors when number conversion fails for 'after' transition", -> + assert.has_error (-> parse_transition "stateE after not_a_number"), "Invalid time value in 'after' transition type" + + it "errors when number conversion fails for 'every' transition", -> + assert.has_error (-> parse_transition "stateF every not_a_number"), "Invalid time value in 'every' transition type" + + -- ### Additional Tests for Nil and Bad Numbers ### + it "errors when nil is passed as transition", -> + assert.has_error (-> parse_transition nil), "Transition must be a string" + + it "errors for a negative number in an 'after' transition", -> + -- Assuming negative times are invalid. + assert.has_error (-> parse_transition "stateNeg after -5"), "Invalid time value in 'after' transition type" + + it "errors for a negative number in an 'every' transition", -> + assert.has_error (-> parse_transition "stateNeg every -5"), "Invalid time value in 'every' transition type" + + it "errors for zero in an 'after' transition", -> + -- Zero is considered invalid. + assert.has_error (-> parse_transition "stateZero after 0"), "Invalid time value in 'after' transition type" + + it "errors for zero in an 'every' transition", -> + assert.has_error (-> parse_transition "stateZero every 0"), "Invalid time value in 'every' transition type" + + it "errors when the state name contains a space", -> + -- e.g. "state A" is not a valid Lua identifier. + assert.has_error (-> parse_transition "state A on eventX"), "Transition must consist of three words" + + it "errors when the event name contains a space", -> + -- e.g. "event X" is not a valid Lua identifier. + assert.has_error (-> parse_transition "stateA on event X"), "Transition must consist of three words" + + it "errors when the state name starts with a number", -> + -- Lua identifiers cannot start with a digit. + assert.has_error (-> parse_transition "1state on eventX"), "Invalid state name" + + it "errors when the event name contains an invalid character", -> + -- For example, a dash is not allowed. + assert.has_error (-> parse_transition "stateA on event-X"), "Invalid event name" diff --git a/spec/string_spec.moon b/spec/string_spec.moon new file mode 100644 index 0000000..70706a1 --- /dev/null +++ b/spec/string_spec.moon @@ -0,0 +1,56 @@ +require "src.util.string" + +describe "string.split", -> + it "returns an empty table when given an empty string", -> + result = split "" + assert.is_table result + assert.are.equal 0, #result + + it "returns an empty table when given a string containing only whitespace", -> + result = split " \t \n" + assert.is_table result + assert.are.equal 0, #result + + it "splits a single word correctly", -> + result = split "hello" + assert.are.same { "hello" }, result + + it "splits two words separated by a single space", -> + result = split "hello world" + assert.are.same { "hello", "world" }, result + + it "handles multiple spaces between words", -> + result = split "hello world" + assert.are.same { "hello", "world" }, result + + it "handles leading and trailing whitespace", -> + result = split " hello world " + assert.are.same { "hello", "world" }, result + + it "handles tabs and newline characters as whitespace", -> + result = split "hello\tworld\nLua" + assert.are.same { "hello", "world", "Lua" }, result + + it "treats punctuation as part of the word", -> + result = split "hello, world!" + assert.are.same { "hello,", "world!" }, result + + it "splits a string with various whitespace characters", -> + result = split " one \t two\n three \n\t four" + assert.are.same { "one", "two", "three", "four" }, result + + it "returns a new table on each call", -> + result1 = split "a b c" + result2 = split "a b c" + assert.not_equal result1, result2 + + it "errors when the input is not a string", -> + assert.has_error => split nil + assert.has_error => split 123 + + it "does not modify the input string", -> + s = " immutable test " + original = s + split s + assert.are.equal original, s + diff --git a/spec/validators_spec.moon b/spec/validators_spec.moon new file mode 100644 index 0000000..c180ceb --- /dev/null +++ b/spec/validators_spec.moon @@ -0,0 +1,104 @@ +require "src.util.validators" + +describe "validators", -> + describe "is_valid_timer", -> + it "returns falsy when passed nil", -> + result = is_valid_timer nil + -- nil is falsy in Lua. + assert.falsy result + + it "returns false when passed false", -> + result = is_valid_timer false + assert.is_false result + + it "returns false when passed 0", -> + result = is_valid_timer 0 + assert.is_false result + + it "returns false when passed a negative number", -> + result = is_valid_timer -1 + assert.is_false result + + it "returns true when passed a positive integer", -> + result = is_valid_timer 1 + assert.is_true result + + it "returns true when passed a positive decimal", -> + result = is_valid_timer 3.14 + assert.is_true result + + it "errors when passed a non-number (string)", -> + assert.has_error => is_valid_timer "10" + + it "errors when passed a non-number (boolean true)", -> + assert.has_error => is_valid_timer true + + it "errors when passed a non-number (table)", -> + assert.has_error => is_valid_timer { foo: "bar" } + + describe "is_valid_symbol", -> + -- When the input is not a string, it should immediately return false. + it "returns false when passed nil", -> + result = is_valid_symbol nil + assert.is_false result + + it "returns false when passed a number", -> + result = is_valid_symbol 123 + assert.is_false result + + it "returns false when passed a table", -> + result = is_valid_symbol { } + assert.is_false result + + it "returns false when passed a boolean", -> + result = is_valid_symbol false + assert.is_false result + + -- When the input is a string: + it "returns false for an empty string", -> + result = is_valid_symbol "" + assert.is_false result + + it "returns true for a single lowercase letter", -> + result = is_valid_symbol "a" + assert.is_true result + + it "returns true for a single uppercase letter", -> + result = is_valid_symbol "Z" + assert.is_true result + + it "returns true for a string of only letters", -> + result = is_valid_symbol "HelloWorld" + assert.is_true result + + it "returns true for a string with mixed case letters", -> + result = is_valid_symbol "AbCdEf" + assert.is_true result + + it "returns false for a string with numbers", -> + result = is_valid_symbol "abc123" + assert.is_false result + + it "returns false for a string with punctuation", -> + result = is_valid_symbol "hello!" + assert.is_false result + + it "returns false for a string with an underscore", -> + result = is_valid_symbol "a_b" + assert.is_false result + + it "returns false for a string containing whitespace", -> + result = is_valid_symbol "abc def" + assert.is_false result + + it "returns false for a string with leading/trailing whitespace", -> + result = is_valid_symbol " abc " + assert.is_false result + + it "returns false for a string with non-ASCII letters", -> + result = is_valid_symbol "éclair" + assert.is_false result + + it "returns false for a string with a hyphen", -> + result = is_valid_symbol "A-B" + assert.is_false result diff --git a/src/util/debug.moon b/src/util/debug.moon new file mode 100644 index 0000000..ecb942e --- /dev/null +++ b/src/util/debug.moon @@ -0,0 +1,24 @@ +--- +-- Recursively prints the contents of a table in a human-readable format. +-- +-- This function traverses the provided table `tbl` and prints each key-value +-- pair to the standard output. For nested tables, it calls itself recursively, +-- increasing the indentation level to visually represent the table hierarchy. +-- +-- @param tbl table: The table whose contents are to be printed. +-- @param indent number: (Optional) The current indentation level. Defaults to 0. +-- @return nil +export print_table = (tbl, indent) -> + -- Use the “?=” operator to give a default value. + unless indent + indent = 0 + -- Omit the extra “do” after the loop header. + for k, v in pairs tbl + formatting = string.rep(" ", indent) .. k .. ": " + if type(v) == "table" + print formatting + print_table v, indent + 1 + elseif type(v) == "boolean" + print formatting .. tostring(v) + else + print formatting .. v diff --git a/src/util/parse_transition.moon b/src/util/parse_transition.moon new file mode 100644 index 0000000..f6a75d1 --- /dev/null +++ b/src/util/parse_transition.moon @@ -0,0 +1,41 @@ +require "src.util.string" +require "src.util.validators" + +--- +-- parse_transition +-- Parses a transition string into a table with: +-- • target: the target state name, +-- • type: "after" | "every" | "on", +-- • time: number (if timer-based), +-- • event: string (if event-based). +export parse_transition = (trans) -> + unless type(trans) == "string" + error "Transition must be a string" + + words = split trans + unless #words == 3 + error "Transition must consist of three words" + + unless is_valid_symbol words[1] + error "Invalid state name" + + switch words[2] + when "on" + unless is_valid_symbol words[3] + error "Invalid event name" + {target: words[1], event: words[3]} + + when "after" + num = tonumber words[3] + unless is_valid_timer num + error "Invalid time value in 'after' transition type" + {target: words[1], after: num} + + when "every" + num = tonumber words[3] + unless is_valid_timer num + error "Invalid time value in 'every' transition type" + {target: words[1], every: num} + + else + error "Unknown transition type" diff --git a/src/util/string.moon b/src/util/string.moon new file mode 100644 index 0000000..d27776e --- /dev/null +++ b/src/util/string.moon @@ -0,0 +1,18 @@ +--- +-- Splits the input string into an array of non-whitespace substrings. +-- +-- This function uses Lua's pattern matching to iterate over the provided +-- string `s` and collects each contiguous sequence of non-whitespace +-- characters into a new table. If the input is not a string, the function +-- raises an error. +-- +-- @param s string: The string to be split. Must be a valid string. +-- @return table: An array of substrings obtained by splitting `s` on whitespace. +-- @raise error if the input parameter `s` is not a string. +export split = (s) -> + unless type(s) == "string" + error "split() requires a string parameter" + chunks = {} + for substr in string.gmatch s, '%S+' + table.insert chunks, substr + chunks diff --git a/src/util/validators.moon b/src/util/validators.moon new file mode 100644 index 0000000..d4855df --- /dev/null +++ b/src/util/validators.moon @@ -0,0 +1,28 @@ +--- +-- Determines if the provided timer value is valid. +-- +-- A valid timer is defined as a number greater than zero. +-- +-- @param x number: The timer value to validate. +-- @return boolean: Returns `true` if `x` is a positive number; otherwise, returns `false` or `nil`. +export is_valid_timer = (x) -> + x and x > 0 + +--- +-- Checks whether the given string is a valid symbol. +-- +-- A valid symbol is defined as a non-empty string that contains only +-- alphabetic characters (A-Z and a-z). If the input is not a string, +-- the function returns `false`. +-- +-- @param s string: The string to be validated as a symbol. +-- @return boolean: Returns `true` if `s` contains only letters; otherwise, returns `false`. +export is_valid_symbol = (s) -> + -- Return false immediately if s is not a string. + unless type(s) == "string" + return false + -- Use Lua's pattern matching to ensure the string only contains letters. + if string.match s, "^[A-Za-z]+$" + true + else + false