From ad8ac3120ec762d16df4faa3c345c13bca4cda8d Mon Sep 17 00:00:00 2001 From: Eric Willigers Date: Fri, 19 Jan 2024 20:40:40 +1100 Subject: [PATCH] Add luhn exercise (#269) --- config.json | 11 ++ exercises/practice/luhn/.docs/instructions.md | 64 +++++++ exercises/practice/luhn/.meta/config.json | 19 +++ exercises/practice/luhn/.meta/example.sml | 21 +++ exercises/practice/luhn/.meta/tests.toml | 76 +++++++++ exercises/practice/luhn/luhn.sml | 2 + exercises/practice/luhn/test.sml | 78 +++++++++ exercises/practice/luhn/testlib.sml | 160 ++++++++++++++++++ 8 files changed, 431 insertions(+) create mode 100644 exercises/practice/luhn/.docs/instructions.md create mode 100644 exercises/practice/luhn/.meta/config.json create mode 100644 exercises/practice/luhn/.meta/example.sml create mode 100644 exercises/practice/luhn/.meta/tests.toml create mode 100644 exercises/practice/luhn/luhn.sml create mode 100644 exercises/practice/luhn/test.sml create mode 100644 exercises/practice/luhn/testlib.sml diff --git a/config.json b/config.json index 5287919..0e7fb22 100644 --- a/config.json +++ b/config.json @@ -333,6 +333,17 @@ "strings" ] }, + { + "slug": "luhn", + "name": "Luhn", + "uuid": "18e982ac-3e1e-497b-bfa0-04209223bb30", + "practices": [], + "prerequisites": [], + "difficulty": 4, + "topics": [ + "strings" + ] + }, { "slug": "pig-latin", "name": "Pig Latin", diff --git a/exercises/practice/luhn/.docs/instructions.md b/exercises/practice/luhn/.docs/instructions.md new file mode 100644 index 0000000..8cbe791 --- /dev/null +++ b/exercises/practice/luhn/.docs/instructions.md @@ -0,0 +1,64 @@ +# Instructions + +Given a number determine whether or not it is valid per the Luhn formula. + +The [Luhn algorithm][luhn] is a simple checksum formula used to validate a variety of identification numbers, such as credit card numbers and Canadian Social Insurance Numbers. + +The task is to check if a given string is valid. + +## Validating a Number + +Strings of length 1 or less are not valid. +Spaces are allowed in the input, but they should be stripped before checking. +All other non-digit characters are disallowed. + +### Example 1: valid credit card number + +```text +4539 3195 0343 6467 +``` + +The first step of the Luhn algorithm is to double every second digit, starting from the right. +We will be doubling + +```text +4_3_ 3_9_ 0_4_ 6_6_ +``` + +If doubling the number results in a number greater than 9 then subtract 9 from the product. +The results of our doubling: + +```text +8569 6195 0383 3437 +``` + +Then sum all of the digits: + +```text +8+5+6+9+6+1+9+5+0+3+8+3+3+4+3+7 = 80 +``` + +If the sum is evenly divisible by 10, then the number is valid. +This number is valid! + +### Example 2: invalid credit card number + +```text +8273 1232 7352 0569 +``` + +Double the second digits, starting from the right + +```text +7253 2262 5312 0539 +``` + +Sum the digits + +```text +7+2+5+3+2+2+6+2+5+3+1+2+0+5+3+9 = 57 +``` + +57 is not evenly divisible by 10, so this number is not valid. + +[luhn]: https://en.wikipedia.org/wiki/Luhn_algorithm diff --git a/exercises/practice/luhn/.meta/config.json b/exercises/practice/luhn/.meta/config.json new file mode 100644 index 0000000..f95bfde --- /dev/null +++ b/exercises/practice/luhn/.meta/config.json @@ -0,0 +1,19 @@ +{ + "authors": [ + "keiravillekode" + ], + "files": { + "solution": [ + "luhn.sml" + ], + "test": [ + "test.sml" + ], + "example": [ + ".meta/example.sml" + ] + }, + "blurb": "Given a number determine whether or not it is valid per the Luhn formula.", + "source": "The Luhn Algorithm on Wikipedia", + "source_url": "https://en.wikipedia.org/wiki/Luhn_algorithm" +} diff --git a/exercises/practice/luhn/.meta/example.sml b/exercises/practice/luhn/.meta/example.sml new file mode 100644 index 0000000..d0d40ad --- /dev/null +++ b/exercises/practice/luhn/.meta/example.sml @@ -0,0 +1,21 @@ +local + fun contribution (digit: char, count: int): int = + let + val number = ord digit - ord #"0" + in + if count mod 2 = 0 then number + else if 2 * number > 9 then 2 * number - 9 + else 2 * number + end + + fun recurse (count: int) (total: int) (l: char list): bool = + case l of + nil => count > 1 andalso total mod 10 = 0 + | #" " :: rest => recurse count total rest + | first :: rest => + if Char.isDigit first then recurse (count + 1) (total + contribution (first, count)) rest + else false +in + val valid: string -> bool = + recurse 0 0 o rev o explode +end diff --git a/exercises/practice/luhn/.meta/tests.toml b/exercises/practice/luhn/.meta/tests.toml new file mode 100644 index 0000000..c0be0c4 --- /dev/null +++ b/exercises/practice/luhn/.meta/tests.toml @@ -0,0 +1,76 @@ +# 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. + +[792a7082-feb7-48c7-b88b-bbfec160865e] +description = "single digit strings can not be valid" + +[698a7924-64d4-4d89-8daa-32e1aadc271e] +description = "a single zero is invalid" + +[73c2f62b-9b10-4c9f-9a04-83cee7367965] +description = "a simple valid SIN that remains valid if reversed" + +[9369092e-b095-439f-948d-498bd076be11] +description = "a simple valid SIN that becomes invalid if reversed" + +[8f9f2350-1faf-4008-ba84-85cbb93ffeca] +description = "a valid Canadian SIN" + +[1cdcf269-6560-44fc-91f6-5819a7548737] +description = "invalid Canadian SIN" + +[656c48c1-34e8-4e60-9a5a-aad8a367810a] +description = "invalid credit card" + +[20e67fad-2121-43ed-99a8-14b5b856adb9] +description = "invalid long number with an even remainder" + +[7e7c9fc1-d994-457c-811e-d390d52fba5e] +description = "invalid long number with a remainder divisible by 5" + +[ad2a0c5f-84ed-4e5b-95da-6011d6f4f0aa] +description = "valid number with an even number of digits" + +[ef081c06-a41f-4761-8492-385e13c8202d] +description = "valid number with an odd number of spaces" + +[bef66f64-6100-4cbb-8f94-4c9713c5e5b2] +description = "valid strings with a non-digit added at the end become invalid" + +[2177e225-9ce7-40f6-b55d-fa420e62938e] +description = "valid strings with punctuation included become invalid" + +[ebf04f27-9698-45e1-9afe-7e0851d0fe8d] +description = "valid strings with symbols included become invalid" + +[08195c5e-ce7f-422c-a5eb-3e45fece68ba] +description = "single zero with space is invalid" + +[12e63a3c-f866-4a79-8c14-b359fc386091] +description = "more than a single zero is valid" + +[ab56fa80-5de8-4735-8a4a-14dae588663e] +description = "input digit 9 is correctly converted to output digit 9" + +[b9887ee8-8337-46c5-bc45-3bcab51bc36f] +description = "very long input is valid" + +[8a7c0e24-85ea-4154-9cf1-c2db90eabc08] +description = "valid luhn with an odd number of digits and non zero first digit" + +[39a06a5a-5bad-4e0f-b215-b042d46209b1] +description = "using ascii value for non-doubled non-digit isn't allowed" + +[f94cf191-a62f-4868-bc72-7253114aa157] +description = "using ascii value for doubled non-digit isn't allowed" + +[8b72ad26-c8be-49a2-b99c-bcc3bf631b33] +description = "non-numeric, non-space char in the middle with a sum that's divisible by 10 isn't allowed" diff --git a/exercises/practice/luhn/luhn.sml b/exercises/practice/luhn/luhn.sml new file mode 100644 index 0000000..813f7e0 --- /dev/null +++ b/exercises/practice/luhn/luhn.sml @@ -0,0 +1,2 @@ +fun valid (value: string): bool = + raise Fail "'valid' is not implemented" diff --git a/exercises/practice/luhn/test.sml b/exercises/practice/luhn/test.sml new file mode 100644 index 0000000..18f236b --- /dev/null +++ b/exercises/practice/luhn/test.sml @@ -0,0 +1,78 @@ +(* version 1.0.0 *) + +use "testlib.sml"; +use "luhn.sml"; + +infixr |> +fun x |> f = f x + +val testsuite = + describe "luhn" [ + test "single digit strings can not be valid" + (fn _ => valid "1" |> Expect.falsy), + + test "a single zero is invalid" + (fn _ => valid "0" |> Expect.falsy), + + test "a simple valid SIN that remains valid if reversed" + (fn _ => valid "059" |> Expect.truthy), + + test "a simple valid SIN that becomes invalid if reversed" + (fn _ => valid "59" |> Expect.truthy), + + test "a valid Canadian SIN" + (fn _ => valid "055 444 285" |> Expect.truthy), + + test "invalid Canadian SIN" + (fn _ => valid "055 444 286" |> Expect.falsy), + + test "invalid credit card" + (fn _ => valid "8273 1232 7352 0569" |> Expect.falsy), + + test "invalid long number with an even remainder" + (fn _ => valid "1 2345 6789 1234 5678 9012" |> Expect.falsy), + + test "invalid long number with a remainder divisible by 5" + (fn _ => valid "1 2345 6789 1234 5678 9013" |> Expect.falsy), + + test "valid number with an even number of digits" + (fn _ => valid "095 245 88" |> Expect.truthy), + + test "valid number with an odd number of spaces" + (fn _ => valid "234 567 891 234" |> Expect.truthy), + + test "valid strings with a non-digit added at the end become invalid" + (fn _ => valid "059a" |> Expect.falsy), + + test "valid strings with punctuation included become invalid" + (fn _ => valid "055-444-285" |> Expect.falsy), + + test "valid strings with symbols included become invalid" + (fn _ => valid "055# 444$ 285" |> Expect.falsy), + + test "single zero with space is invalid" + (fn _ => valid " 0" |> Expect.falsy), + + test "more than a single zero is valid" + (fn _ => valid "0000 0" |> Expect.truthy), + + test "input digit 9 is correctly converted to output digit 9" + (fn _ => valid "091" |> Expect.truthy), + + test "very long input is valid" + (fn _ => valid "9999999999 9999999999 9999999999 9999999999" |> Expect.truthy), + + test "valid luhn with an odd number of digits and non zero first digit" + (fn _ => valid "109" |> Expect.truthy), + + test "using ascii value for non-doubled non-digit isn't allowed" + (fn _ => valid "055b 444 285" |> Expect.falsy), + + test "using ascii value for doubled non-digit isn't allowed" + (fn _ => valid ":9" |> Expect.falsy), + + test "non-numeric, non-space char in the middle with a sum that's divisible by 10 isn't allowed" + (fn _ => valid "59%59" |> Expect.falsy) + ] + +val _ = Test.run testsuite diff --git a/exercises/practice/luhn/testlib.sml b/exercises/practice/luhn/testlib.sml new file mode 100644 index 0000000..0c8370c --- /dev/null +++ b/exercises/practice/luhn/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)