From 5114853404b9c170d84b99f6b3f3259508b16dc4 Mon Sep 17 00:00:00 2001
From: keiravillekode <keiravillekode@gmail.com>
Date: Fri, 27 Dec 2024 04:54:25 +1100
Subject: [PATCH] Add say exercise (#328)

---
 bin/generate                                 |   2 +-
 config.json                                  |   8 +
 exercises/practice/say/.docs/instructions.md |  48 ++++++
 exercises/practice/say/.meta/config.json     |  19 +++
 exercises/practice/say/.meta/example.sml     |  59 +++++++
 exercises/practice/say/.meta/tests.toml      |  67 ++++++++
 exercises/practice/say/say.sml               |   2 +
 exercises/practice/say/test.sml              |  67 ++++++++
 exercises/practice/say/testlib.sml           | 160 +++++++++++++++++++
 9 files changed, 431 insertions(+), 1 deletion(-)
 create mode 100644 exercises/practice/say/.docs/instructions.md
 create mode 100644 exercises/practice/say/.meta/config.json
 create mode 100644 exercises/practice/say/.meta/example.sml
 create mode 100644 exercises/practice/say/.meta/tests.toml
 create mode 100644 exercises/practice/say/say.sml
 create mode 100644 exercises/practice/say/test.sml
 create mode 100644 exercises/practice/say/testlib.sml

diff --git a/bin/generate b/bin/generate
index 094e7176..71bd8ad8 100755
--- a/bin/generate
+++ b/bin/generate
@@ -355,7 +355,7 @@ def generate(exercise, flags, problem_specs_source, force=False):
         write(path / ('%s.sml' % exercise), content)
 
     if flags & EXAMPLE:
-        write(path / 'example.sml', content)
+        write(path / '.meta/example.sml', content)
 
     shutil.copyfile(
         (root / 'lib/testlib.sml').as_posix(),
diff --git a/config.json b/config.json
index 9accbdc5..36eb59e2 100644
--- a/config.json
+++ b/config.json
@@ -617,6 +617,14 @@
         "prerequisites": [],
         "difficulty": 5
       },
+      {
+        "slug": "say",
+        "name": "Say",
+        "uuid": "f3bcc2f2-9752-4fb4-81f0-a790c0c2b3e6",
+        "practices": [],
+        "prerequisites": [],
+        "difficulty": 5
+      },
       {
         "slug": "transpose",
         "name": "Transpose",
diff --git a/exercises/practice/say/.docs/instructions.md b/exercises/practice/say/.docs/instructions.md
new file mode 100644
index 00000000..ad3d3477
--- /dev/null
+++ b/exercises/practice/say/.docs/instructions.md
@@ -0,0 +1,48 @@
+# Instructions
+
+Given a number from 0 to 999,999,999,999, spell out that number in English.
+
+## Step 1
+
+Handle the basic case of 0 through 99.
+
+If the input to the program is `22`, then the output should be `'twenty-two'`.
+
+Your program should complain loudly if given a number outside the blessed range.
+
+Some good test cases for this program are:
+
+- 0
+- 14
+- 50
+- 98
+- -1
+- 100
+
+### Extension
+
+If you're on a Mac, shell out to Mac OS X's `say` program to talk out loud.
+If you're on Linux or Windows, eSpeakNG may be available with the command `espeak`.
+
+## Step 2
+
+Implement breaking a number up into chunks of thousands.
+
+So `1234567890` should yield a list like 1, 234, 567, and 890, while the far simpler `1000` should yield just 1 and 0.
+
+## Step 3
+
+Now handle inserting the appropriate scale word between those chunks.
+
+So `1234567890` should yield `'1 billion 234 million 567 thousand 890'`
+
+The program must also report any values that are out of range.
+It's fine to stop at "trillion".
+
+## Step 4
+
+Put it all together to get nothing but plain English.
+
+`12345` should give `twelve thousand three hundred forty-five`.
+
+The program must also report any values that are out of range.
diff --git a/exercises/practice/say/.meta/config.json b/exercises/practice/say/.meta/config.json
new file mode 100644
index 00000000..69b00005
--- /dev/null
+++ b/exercises/practice/say/.meta/config.json
@@ -0,0 +1,19 @@
+{
+  "authors": [
+    "keiravillekode"
+  ],
+  "files": {
+    "solution": [
+      "say.sml"
+    ],
+    "test": [
+      "test.sml"
+    ],
+    "example": [
+      ".meta/example.sml"
+    ]
+  },
+  "blurb": "Given a number from 0 to 999,999,999,999, spell out that number in English.",
+  "source": "A variation on the JavaRanch CattleDrive, Assignment 4",
+  "source_url": "https://coderanch.com/wiki/718804"
+}
diff --git a/exercises/practice/say/.meta/example.sml b/exercises/practice/say/.meta/example.sml
new file mode 100644
index 00000000..aa31cd4b
--- /dev/null
+++ b/exercises/practice/say/.meta/example.sml
@@ -0,0 +1,59 @@
+val unitNames = Array.fromList [
+  "zero",
+  "one",
+  "two",
+  "three",
+  "four",
+  "five",
+  "six",
+  "seven",
+  "eight",
+  "nine",
+  "ten",
+  "eleven",
+  "twelve",
+  "thirteen",
+  "fourteen",
+  "fifteen",
+  "sixteen",
+  "seventeen",
+  "eighteen",
+  "nineteen"
+]
+
+val decadeNames = Array.fromList [
+  "zero",
+  "ten",
+  "twenty",
+  "thirty",
+  "forty",
+  "fifty",
+  "sixty",
+  "seventy",
+  "eighty",
+  "ninety"
+]
+
+fun words (number: int): string list =
+  if number >= 1000000000
+  then (words (number div 1000000000)) @ ("billion" :: (words (number mod 1000000000)))
+  else if number >= 1000000
+  then (words (number div 1000000)) @ ("million" :: (words (number mod 1000000)))
+  else if number >= 1000
+  then (words (number div 1000)) @ ("thousand" :: (words (number mod 1000)))
+  else if number >= 100
+  then (words (number div 100)) @ ("hundred" :: (words (number mod 100)))
+  else if number = 0
+  then []
+  else if number < 20
+  then [Array.sub (unitNames, number)]
+  else if (number mod 10) = 0
+  then [Array.sub (decadeNames, number div 10)]
+  else [Array.sub (decadeNames, number div 10) ^ "-" ^ Array.sub (unitNames, number mod 10)]
+
+fun say (number: int): string =
+  if (number < 0) orelse (number > 999999999999)
+  then raise Fail "input out of range"
+  else if number = 0
+  then "zero"
+  else String.concatWith " " (words number)
diff --git a/exercises/practice/say/.meta/tests.toml b/exercises/practice/say/.meta/tests.toml
new file mode 100644
index 00000000..a5532e9e
--- /dev/null
+++ b/exercises/practice/say/.meta/tests.toml
@@ -0,0 +1,67 @@
+# This is an auto-generated file.
+#
+# Regenerating this file via `configlet sync` will:
+# - Recreate every `description` key/value pair
+# - Recreate every `reimplements` key/value pair, where they exist in problem-specifications
+# - Remove any `include = true` key/value pair (an omitted `include` key implies inclusion)
+# - Preserve any other key/value pair
+#
+# As user-added comments (using the # character) will be removed when this file
+# is regenerated, comments can be added via a `comment` key.
+
+[5d22a120-ba0c-428c-bd25-8682235d83e8]
+description = "zero"
+
+[9b5eed77-dbf6-439d-b920-3f7eb58928f6]
+description = "one"
+
+[7c499be1-612e-4096-a5e1-43b2f719406d]
+description = "fourteen"
+
+[f541dd8e-f070-4329-92b4-b7ce2fcf06b4]
+description = "twenty"
+
+[d78601eb-4a84-4bfa-bf0e-665aeb8abe94]
+description = "twenty-two"
+
+[f010d4ca-12c9-44e9-803a-27789841adb1]
+description = "thirty"
+
+[738ce12d-ee5c-4dfb-ad26-534753a98327]
+description = "ninety-nine"
+
+[e417d452-129e-4056-bd5b-6eb1df334dce]
+description = "one hundred"
+
+[d6924f30-80ba-4597-acf6-ea3f16269da8]
+description = "one hundred twenty-three"
+
+[2f061132-54bc-4fd4-b5df-0a3b778959b9]
+description = "two hundred"
+
+[feed6627-5387-4d38-9692-87c0dbc55c33]
+description = "nine hundred ninety-nine"
+
+[3d83da89-a372-46d3-b10d-de0c792432b3]
+description = "one thousand"
+
+[865af898-1d5b-495f-8ff0-2f06d3c73709]
+description = "one thousand two hundred thirty-four"
+
+[b6a3f442-266e-47a3-835d-7f8a35f6cf7f]
+description = "one million"
+
+[2cea9303-e77e-4212-b8ff-c39f1978fc70]
+description = "one million two thousand three hundred forty-five"
+
+[3e240eeb-f564-4b80-9421-db123f66a38f]
+description = "one billion"
+
+[9a43fed1-c875-4710-8286-5065d73b8a9e]
+description = "a big number"
+
+[49a6a17b-084e-423e-994d-a87c0ecc05ef]
+description = "numbers below zero are out of range"
+
+[4d6492eb-5853-4d16-9d34-b0f61b261fd9]
+description = "numbers above 999,999,999,999 are out of range"
diff --git a/exercises/practice/say/say.sml b/exercises/practice/say/say.sml
new file mode 100644
index 00000000..cd97ce0d
--- /dev/null
+++ b/exercises/practice/say/say.sml
@@ -0,0 +1,2 @@
+fun say (number: int): string =
+  raise Fail "'say' is not implemented"
\ No newline at end of file
diff --git a/exercises/practice/say/test.sml b/exercises/practice/say/test.sml
new file mode 100644
index 00000000..b5958695
--- /dev/null
+++ b/exercises/practice/say/test.sml
@@ -0,0 +1,67 @@
+use "testlib.sml";
+use "say.sml";
+
+infixr |>
+fun x |> f = f x
+
+val testsuite =
+  describe "say" [
+    test "zero"
+      (fn _ => say 0 |> Expect.equalTo "zero"),
+
+    test "one"
+      (fn _ => say 1 |> Expect.equalTo "one"),
+
+    test "fourteen"
+      (fn _ => say 14 |> Expect.equalTo "fourteen"),
+
+    test "twenty"
+      (fn _ => say 20 |> Expect.equalTo "twenty"),
+
+    test "twenty-two"
+      (fn _ => say 22 |> Expect.equalTo "twenty-two"),
+
+    test "thirty"
+      (fn _ => say 30 |> Expect.equalTo "thirty"),
+
+    test "ninety-nine"
+      (fn _ => say 99 |> Expect.equalTo "ninety-nine"),
+
+    test "one hundred"
+      (fn _ => say 100 |> Expect.equalTo "one hundred"),
+
+    test "one hundred twenty-three"
+      (fn _ => say 123 |> Expect.equalTo "one hundred twenty-three"),
+
+    test "two hundred"
+      (fn _ => say 200 |> Expect.equalTo "two hundred"),
+
+    test "nine hundred ninety-nine"
+      (fn _ => say 999 |> Expect.equalTo "nine hundred ninety-nine"),
+
+    test "one thousand"
+      (fn _ => say 1000 |> Expect.equalTo "one thousand"),
+
+    test "one thousand two hundred thirty-four"
+      (fn _ => say 1234 |> Expect.equalTo "one thousand two hundred thirty-four"),
+
+    test "one million"
+      (fn _ => say 1000000 |> Expect.equalTo "one million"),
+
+    test "one million two thousand three hundred forty-five"
+      (fn _ => say 1002345 |> Expect.equalTo "one million two thousand three hundred forty-five"),
+
+    test "one billion"
+      (fn _ => say 1000000000 |> Expect.equalTo "one billion"),
+
+    test "a big number"
+      (fn _ => say 987654321123 |> Expect.equalTo "nine hundred eighty-seven billion six hundred fifty-four million three hundred twenty-one thousand one hundred twenty-three"),
+
+    test "numbers below zero are out of range"
+      (fn _ => (fn _ => say ~1) |> Expect.error (Fail "input out of range")),
+
+    test "numbers above 999,999,999,999 are out of range"
+      (fn _ => (fn _ => say 1000000000000) |> Expect.error (Fail "input out of range"))
+  ]
+
+val _ = Test.run testsuite
\ No newline at end of file
diff --git a/exercises/practice/say/testlib.sml b/exercises/practice/say/testlib.sml
new file mode 100644
index 00000000..0c8370c0
--- /dev/null
+++ b/exercises/practice/say/testlib.sml
@@ -0,0 +1,160 @@
+structure Expect =
+struct
+  datatype expectation = Pass | Fail of string * string
+
+  local
+    fun failEq b a =
+      Fail ("Expected: " ^ b, "Got: " ^ a)
+    
+    fun failExn b a =
+      Fail ("Expected: " ^ b, "Raised: " ^ a)
+
+    fun exnName (e: exn): string = General.exnName e
+  in
+    fun truthy a =
+      if a
+      then Pass
+      else failEq "true" "false"
+
+    fun falsy a =
+      if a
+      then failEq "false" "true"
+      else Pass
+
+    fun equalTo b a = 
+      if a = b
+      then Pass
+      else failEq (PolyML.makestring b) (PolyML.makestring a)
+
+    fun nearTo delta b a =
+      if Real.abs (a - b) <= delta * Real.abs a orelse
+         Real.abs (a - b) <= delta * Real.abs b
+      then Pass
+      else failEq (Real.toString b ^ " +/- " ^ Real.toString delta) (Real.toString a)
+
+    fun anyError f =
+      (
+        f ();
+        failExn "an exception" "Nothing"
+      ) handle _ => Pass
+
+    fun error e f =
+      (
+        f ();
+        failExn (exnName e) "Nothing"
+      ) handle e' => if exnMessage e' = exnMessage e
+                     then Pass
+                     else failExn (exnMessage e) (exnMessage e')
+  end
+end
+
+structure TermColor =
+struct
+  datatype color = Red | Green | Yellow | Normal
+
+  fun f Red    = "\027[31m"
+    | f Green  = "\027[32m"
+    | f Yellow = "\027[33m"
+    | f Normal = "\027[0m"
+
+  fun colorize color s = (f color) ^ s ^ (f Normal)
+
+  val redit = colorize Red
+
+  val greenit = colorize Green
+
+  val yellowit = colorize Yellow
+end
+
+structure Test =
+struct
+  datatype testnode = TestGroup of string * testnode list
+                    | Test of string * (unit -> Expect.expectation)
+
+  local
+    datatype evaluation = Success of string
+                        | Failure of string * string * string
+                        | Error of string * string
+
+    fun indent n s = (implode (List.tabulate (n, fn _ => #" "))) ^ s
+
+    fun fmt indentlvl ev =
+      let
+        val check = TermColor.greenit "\226\156\148 " (* ✔ *)
+        val cross = TermColor.redit "\226\156\150 "   (* ✖ *)
+        val indentlvl = indentlvl * 2
+      in
+      case ev of
+        Success descr => indent indentlvl (check ^ descr)
+      | Failure (descr, exp, got) =>
+          String.concatWith "\n" [indent indentlvl (cross ^ descr),
+                                  indent (indentlvl + 2) exp,
+                                  indent (indentlvl + 2) got]
+      | Error (descr, reason) =>
+          String.concatWith "\n" [indent indentlvl (cross ^ descr),
+                                  indent (indentlvl + 2) (TermColor.redit reason)]
+      end
+
+    fun eval (TestGroup _) = raise Fail "Only a 'Test' can be evaluated"
+      | eval (Test (descr, thunk)) =
+          (
+            case thunk () of
+              Expect.Pass   => ((1, 0, 0), Success descr)
+            | Expect.Fail (s, s') => ((0, 1, 0), Failure (descr, s, s'))
+          )
+          handle e => ((0, 0, 1), Error (descr, "Unexpected error: " ^ exnMessage e))
+
+    fun flatten depth testnode =
+      let
+        fun sum (x, y, z) (a, b, c) = (x + a, y + b, z + c)
+
+        fun aux (t, (counter, acc)) =
+          let
+            val (counter', texts) = flatten (depth + 1) t
+          in
+            (sum counter' counter, texts :: acc)
+          end
+      in
+        case testnode of
+          TestGroup (descr, ts) =>
+            let
+              val (counter, texts) = foldr aux ((0, 0, 0), []) ts
+            in
+              (counter, (indent (depth * 2) descr) :: List.concat texts)
+            end
+        | Test _ =>
+            let
+              val (counter, evaluation) = eval testnode
+            in
+              (counter, [fmt depth evaluation])
+            end
+      end
+    
+    fun println s = print (s ^ "\n")
+  in
+    fun run suite =
+      let
+        val ((succeeded, failed, errored), texts) = flatten 0 suite
+
+        val summary = String.concatWith ", " [
+          TermColor.greenit ((Int.toString succeeded) ^ " passed"),
+          TermColor.redit ((Int.toString failed) ^ " failed"),
+          TermColor.redit ((Int.toString errored) ^ " errored"),
+          (Int.toString (succeeded + failed + errored)) ^ " total"
+        ]
+        
+        val status = if failed = 0 andalso errored = 0
+                     then OS.Process.success
+                     else OS.Process.failure
+
+      in
+        List.app println texts;
+        println "";
+        println ("Tests: " ^ summary);
+        OS.Process.exit status
+      end
+  end
+end
+
+fun describe description tests = Test.TestGroup (description, tests)
+fun test description thunk = Test.Test (description, thunk)