summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorArnaud Bailly <arnaud@pankzsoft.com>2026-03-22 10:54:07 +0100
committerArnaud Bailly <arnaud@pankzsoft.com>2026-03-22 10:54:07 +0100
commitafb965ca75a106063c36195a1c180baebe610115 (patch)
treefa06f42d2ef0b2c3c0b117a1bc8ddfed2a0184c5
parent91ae5eb9e16b00603c3dfa5ec123c64dd140765d (diff)
downloadlambda-nantes-afb965ca75a106063c36195a1c180baebe610115.tar.gz
Add integration test for all challenges
-rw-r--r--lambda-calcul/haskell/challenges.json124
-rw-r--r--lambda-calcul/haskell/fourmolu.yaml18
-rw-r--r--lambda-calcul/haskell/minilang.cabal4
-rw-r--r--lambda-calcul/haskell/src/Minilang/App.hs8
-rwxr-xr-xlambda-calcul/haskell/test.sh45
-rw-r--r--lambda-calcul/haskell/test/Minilang/IntegrationSpec.hs106
6 files changed, 226 insertions, 79 deletions
diff --git a/lambda-calcul/haskell/challenges.json b/lambda-calcul/haskell/challenges.json
index 544bf0a..119115b 100644
--- a/lambda-calcul/haskell/challenges.json
+++ b/lambda-calcul/haskell/challenges.json
@@ -109,198 +109,258 @@
},
{
"level": 18,
+ "description": "Integer literals are native values in this interpreter — they are already fully reduced and need no evaluation. What does the integer 42 reduce to?",
+ "expr": "42",
+ "answer": "42"
+ },
+ {
+ "level": 19,
+ "description": "Zero is also a valid integer literal that self-evaluates. What does 0 reduce to?",
+ "expr": "0",
+ "answer": "0"
+ },
+ {
+ "level": 20,
+ "description": "Negative integers are written with a minus sign immediately preceding the digits, with no space: -7 is a single integer literal, not the identifier - applied to 7. What does -7 reduce to?",
+ "expr": "-7",
+ "answer": "-7"
+ },
+ {
+ "level": 21,
+ "description": "Apply the identity function to an integer. ((lam (x) x) 99) beta-reduces by substituting 99 for x. What is the result?",
+ "expr": "((lam (x) x) 99)",
+ "answer": "99"
+ },
+ {
+ "level": 22,
+ "description": "A two-argument constant function applied to an integer and a free variable. (lam (x y) y) ignores its first argument and returns the second. What does ((lam (x y) y) 42 z) reduce to?",
+ "expr": "((lam (x y) y) 42 z)",
+ "answer": "z"
+ },
+ {
+ "level": 23,
+ "description": "A let-binding wraps an integer in a local name. (let ((n 5)) n) desugars to ((lam (n) n) 5) and reduces to 5. What is the result?",
+ "expr": "(let ((n 5)) n)",
+ "answer": "5"
+ },
+ {
+ "level": 24,
+ "description": "An integer passed as argument to a free variable f produces a stuck neutral term — f has no definition, so (f 42) cannot reduce further. What does (f 42) reduce to?",
+ "expr": "(f 42)",
+ "answer": "(f 42)"
+ },
+ {
+ "level": 25,
+ "description": "An integer in function position applied to an argument cannot reduce — 42 is not a lambda. The result is a stuck neutral application. What does (42 x) reduce to?",
+ "expr": "(42 x)",
+ "answer": "(42 x)"
+ },
+ {
+ "level": 26,
+ "description": "Chained neutral applications. (42 a) is already stuck; applying that to b gives a left-associative neutral term. Remember that (f a b) pretty-prints as (f a b). What does ((42 a) b) reduce to?",
+ "expr": "((42 a) b)",
+ "answer": "(42 a b)"
+ },
+ {
+ "level": 27,
+ "description": "A def declaration binds an integer to a name globally. Evaluating n afterwards should return the integer. What does the following program produce?",
+ "expr": "(def n -3)\nn",
+ "answer": "-3"
+ },
+ {
+ "level": 28,
"description": "Introduce And. And a b = a False b: if a is True it selects its second argument (b); if a is False it selects its first argument (False). Evaluate (And True True).",
"expr": "(def False (lam (f t) f))\n(def True (lam (f t) t))\n(def Or (lam (a b) (a b True)))\n(def And (lam (a b) (a False b)))\n(And True True)",
"answer": "(lam (f t) t)"
},
{
- "level": 19,
+ "level": 29,
"description": "Combine Or, And and Not. Evaluate (And (Or False True) (Not False)). Both sub-expressions must reduce to True for And to return True.",
"expr": "(def False (lam (f t) f))\n(def True (lam (f t) t))\n(def Not (lam (b) (b True False)))\n(def Or (lam (a b) (a b True)))\n(def And (lam (a b) (a False b)))\n(And (Or False True) (Not False))",
"answer": "(lam (f t) t)"
},
{
- "level": 20,
+ "level": 30,
"description": "Xor (exclusive or) is True when exactly one argument is True. It can be defined as And (Or a b) (Not (And a b)). Evaluate (Xor True False).",
"expr": "(def False (lam (f t) f))\n(def True (lam (f t) t))\n(def Not (lam (b) (b True False)))\n(def And (lam (a b) (a False b)))\n(def Or (lam (a b) (a b True)))\n(def Xor (lam (a b) (And (Or a b) (Not (And a b)))))\n(Xor True False)",
"answer": "(lam (f t) t)"
},
{
- "level": 21,
+ "level": 31,
"description": "Evaluate (Xor False False). Two identical inputs to Xor should always yield False.",
"expr": "(def False (lam (f t) f))\n(def True (lam (f t) t))\n(def Not (lam (b) (b True False)))\n(def And (lam (a b) (a False b)))\n(def Or (lam (a b) (a b True)))\n(def Xor (lam (a b) (And (Or a b) (Not (And a b)))))\n(Xor False False)",
"answer": "(lam (f t) f)"
},
{
- "level": 22,
+ "level": 32,
"description": "Church-encoded if-then-else. Because our True selects its second argument and False selects its first, ite must swap the branches: ite c t e = c e t. Evaluate (ite True a b) — which branch is returned?",
"expr": "(def False (lam (f t) f))\n(def True (lam (f t) t))\n(def Not (lam (b) (b True False)))\n(def And (lam (a b) (a False b)))\n(def Or (lam (a b) (a b True)))\n(def ite (lam (c t e) (c e t)))\n(ite True a b)",
"answer": "a"
},
{
- "level": 23,
+ "level": 33,
"description": "Evaluate (ite False a b). When the condition is False, the else-branch should be returned.",
"expr": "(def False (lam (f t) f))\n(def True (lam (f t) t))\n(def Not (lam (b) (b True False)))\n(def And (lam (a b) (a False b)))\n(def Or (lam (a b) (a b True)))\n(def ite (lam (c t e) (c e t)))\n(ite False a b)",
"answer": "b"
},
{
- "level": 24,
+ "level": 34,
"description": "Church-encoded pairs. pair x y = lam f -> f x y stores two values by passing them to a selector function. fst p = p (lam x y -> x) retrieves the first. Evaluate (fst (pair a b)).",
"expr": "(def pair (lam (x y f) (f x y)))\n(def fst (lam (p) (p (lam (x y) x))))\n(def snd (lam (p) (p (lam (x y) y))))\n(fst (pair a b))",
"answer": "a"
},
{
- "level": 25,
+ "level": 35,
"description": "snd p = p (lam x y -> y) retrieves the second element of a Church pair. Evaluate (snd (pair a b)).",
"expr": "(def pair (lam (x y f) (f x y)))\n(def fst (lam (p) (p (lam (x y) x))))\n(def snd (lam (p) (p (lam (x y) y))))\n(snd (pair a b))",
"answer": "b"
},
{
- "level": 26,
+ "level": 36,
"description": "Pairs can be nested. (pair a (pair b c)) is a pair whose second element is itself a pair. Evaluate (snd (snd (pair a (pair b c)))).",
"expr": "(def pair (lam (x y f) (f x y)))\n(def fst (lam (p) (p (lam (x y) x))))\n(def snd (lam (p) (p (lam (x y) y))))\n(snd (snd (pair a (pair b c))))",
"answer": "c"
},
{
- "level": 27,
+ "level": 37,
"description": "swap exchanges the two elements of a pair: swap p = pair (snd p) (fst p). Evaluate (fst (swap (pair a b))). After swapping, fst should return what was previously snd.",
"expr": "(def pair (lam (x y f) (f x y)))\n(def fst (lam (p) (p (lam (x y) x))))\n(def snd (lam (p) (p (lam (x y) y))))\n(def swap (lam (p) (pair (snd p) (fst p))))\n(fst (swap (pair a b)))",
"answer": "b"
},
{
- "level": 28,
+ "level": 38,
"description": "Church numerals encode natural numbers as higher-order functions: n applies its first argument (the successor step) to its second argument (the base) exactly n times. zero = lam s z -> z applies s zero times. Evaluate (zero f x).",
"expr": "(def zero (lam (s z) z))\n(zero f x)",
"answer": "x"
},
{
- "level": 29,
+ "level": 39,
"description": "two = lam s z -> s (s z) applies s twice. Evaluate (two f x) — you should see f applied to x twice.",
"expr": "(def two (lam (s z) (s (s z))))\n(two f x)",
"answer": "(f (f x))"
},
{
- "level": 30,
+ "level": 40,
"description": "three = lam s z -> s (s (s z)) applies s three times. Evaluate (three f x).",
"expr": "(def three (lam (s z) (s (s (s z)))))\n(three f x)",
"answer": "(f (f (f x)))"
},
{
- "level": 31,
+ "level": 41,
"description": "isZero tests whether a Church numeral is zero. isZero n = n (lam x -> False) True: if n > 0 the step function (lam x -> False) is applied at least once, discarding its argument and returning False; if n = 0 the base True is returned. Evaluate (isZero zero).",
"expr": "(def False (lam (f t) f))\n(def True (lam (f t) t))\n(def zero (lam (s z) z))\n(def isZero (lam (n) (n (lam (x) False) True)))\n(isZero zero)",
"answer": "(lam (f t) t)"
},
{
- "level": 32,
+ "level": 42,
"description": "Evaluate (isZero three). Three is not zero, so the step function fires and the result is False.",
"expr": "(def False (lam (f t) f))\n(def True (lam (f t) t))\n(def three (lam (s z) (s (s (s z)))))\n(def isZero (lam (n) (n (lam (x) False) True)))\n(isZero three)",
"answer": "(lam (f t) f)"
},
{
- "level": 33,
+ "level": 43,
"description": "The I combinator (identity): I x = x. It is the simplest combinator and the building block of the SKI calculus. Evaluate (I a).",
"expr": "(def I (lam (x) x))\n(I a)",
"answer": "a"
},
{
- "level": 34,
+ "level": 44,
"description": "The K combinator (constant): K x y = x. It ignores its second argument and always returns its first. Evaluate (K a b).",
"expr": "(def K (lam (x y) x))\n(K a b)",
"answer": "a"
},
{
- "level": 35,
+ "level": 45,
"description": "The S combinator (substitution): S f g x = f x (g x). It distributes its third argument over two functions and applies the results. Evaluate (S f g x) — f and g are free variables here.",
"expr": "(def S (lam (f g x) ((f x) (g x))))\n(S f g x)",
"answer": "(f x (g x))"
},
{
- "level": 36,
+ "level": 46,
"description": "SKI completeness: S K K encodes the identity function. S K K x = K x (K x) = x. Evaluate (S K K a) and confirm it equals a.",
"expr": "(def S (lam (f g x) ((f x) (g x))))\n(def K (lam (x y) x))\n(S K K a)",
"answer": "a"
},
{
- "level": 37,
+ "level": 47,
"description": "The B combinator (composition): B f g x = f (g x). It is function composition. Evaluate (B f g x) — you should see f applied to the result of g applied to x.",
"expr": "(def B (lam (f g x) (f (g x))))\n(B f g x)",
"answer": "(f (g x))"
},
{
- "level": 38,
+ "level": 48,
"description": "The C combinator (flip): C f x y = f y x. It flips the order of the last two arguments of a function. Evaluate (C f a b).",
"expr": "(def C (lam (f x y) (f y x)))\n(C f a b)",
"answer": "(f b a)"
},
{
- "level": 39,
+ "level": 49,
"description": "The W combinator (duplication): W f x = f x x. It applies f to x twice, duplicating the argument. Evaluate (W f a).",
"expr": "(def W (lam (f x) (f x x)))\n(W f a)",
"answer": "(f a a)"
},
{
- "level": 40,
+ "level": 50,
"description": "Scott-encoded lists. nil = lam c n -> n (the empty list). cons h t = lam c n -> c h t stores head and tail directly. head l = l (lam h t -> h) nil extracts the first element. Evaluate (head (cons a nil)).",
"expr": "(def nil (lam (c n) n))\n(def cons (lam (h t c n) (c h t)))\n(def head (lam (l) (l (lam (h t) h) nil)))\n(head (cons a nil))",
"answer": "a"
},
{
- "level": 41,
+ "level": 51,
"description": "tail l = l (lam h t -> t) nil retrieves the rest of a Scott-encoded list. Evaluate (head (tail (cons a (cons b nil)))) — the head of the tail of a two-element list is its second element.",
"expr": "(def nil (lam (c n) n))\n(def cons (lam (h t c n) (c h t)))\n(def head (lam (l) (l (lam (h t) h) nil)))\n(def tail (lam (l) (l (lam (h t) t) nil)))\n(head (tail (cons a (cons b nil))))",
"answer": "b"
},
{
- "level": 42,
+ "level": 52,
"description": "isNil tests whether a Scott-encoded list is empty. isNil l = l (lam h t -> False) True: a non-empty list immediately calls its cons branch returning False; nil returns its base True. Evaluate (isNil nil).",
"expr": "(def False (lam (f t) f))\n(def True (lam (f t) t))\n(def nil (lam (c n) n))\n(def cons (lam (h t c n) (c h t)))\n(def isNil (lam (l) (l (lam (h t) False) True)))\n(isNil nil)",
"answer": "(lam (f t) t)"
},
{
- "level": 43,
+ "level": 53,
"description": "Evaluate (isNil (cons a nil)). A non-empty list is not nil, so the result should be False.",
"expr": "(def False (lam (f t) f))\n(def True (lam (f t) t))\n(def nil (lam (c n) n))\n(def cons (lam (h t c n) (c h t)))\n(def isNil (lam (l) (l (lam (h t) False) True)))\n(isNil (cons a nil))",
"answer": "(lam (f t) f)"
},
{
- "level": 44,
+ "level": 54,
"description": "Church-encoded sum types (Either). Left x = lam l r -> l x tags a value as Left; Right x = lam l r -> r x tags it as Right. either f g e = e f g eliminates the sum by applying the matching handler. Evaluate (either f g (Left a)).",
"expr": "(def Left (lam (x l r) (l x)))\n(def Right (lam (x l r) (r x)))\n(def either (lam (f g e) (e f g)))\n(either f g (Left a))",
"answer": "(f a)"
},
{
- "level": 45,
+ "level": 55,
"description": "Evaluate (either f g (Right a)). The Right handler g should be applied to a.",
"expr": "(def Left (lam (x l r) (l x)))\n(def Right (lam (x l r) (r x)))\n(def either (lam (f g e) (e f g)))\n(either f g (Right a))",
"answer": "(g a)"
},
{
- "level": 46,
+ "level": 56,
"description": "Church-encoded option (Maybe). None = lam n s -> n is the absent case. Some x = lam n s -> s x wraps a value. fromOption default opt = opt default (lam x -> x) returns the wrapped value or the default. Evaluate (fromOption a None).",
"expr": "(def None (lam (n s) n))\n(def Some (lam (x n s) (s x)))\n(def fromOption (lam (default opt) (opt default (lam (x) x))))\n(fromOption a None)",
"answer": "a"
},
{
- "level": 47,
+ "level": 57,
"description": "Evaluate (fromOption a (Some b)). The option contains b, so the default a is ignored and b is returned.",
"expr": "(def None (lam (n s) n))\n(def Some (lam (x n s) (s x)))\n(def fromOption (lam (default opt) (opt default (lam (x) x))))\n(fromOption a (Some b))",
"answer": "b"
},
{
- "level": 48,
+ "level": 58,
"description": "mapOption applies a function inside an Option without unwrapping it. mapOption f opt = opt None (lam x -> Some (f x)): None maps to None; Some x maps to Some (f x). Evaluate (mapOption f (Some a)) and give its fully reduced form.",
"expr": "(def None (lam (n s) n))\n(def Some (lam (x n s) (s x)))\n(def mapOption (lam (f opt) (opt None (lam (x) (Some (f x))))))\n(mapOption f (Some a))",
"answer": "(lam (n s) (s (f a)))"
},
{
- "level": 49,
+ "level": 59,
"description": "The Kleene predecessor uses a pair-stepping trick to compute pred n = n - 1 (with pred zero = zero). Starting from (pair zero zero), each step shifts: (pair (snd p)) (succ (snd p)). After n steps fst of the pair is n-1. Evaluate (pred zero) — it should return zero.",
"expr": "(def zero (lam (s z) z))\n(def one (lam (s z) (s z)))\n(def pair (lam (x y f) (f x y)))\n(def fst (lam (p) (p (lam (x y) x))))\n(def snd (lam (p) (p (lam (x y) y))))\n(def succ (lam (n s z) (s ((n s) z))))\n(def pred (lam (n) (fst (n (lam (p) (pair (snd p) (succ (snd p)))) (pair zero zero)))))\n(pred zero)",
"answer": "(lam (s z) z)"
},
{
- "level": 50,
+ "level": 60,
"description": "Evaluate (pred one) using the same Kleene predecessor. After one pair-step starting from (pair zero zero) the fst is still zero. Confirm that the predecessor of one is zero.",
"expr": "(def zero (lam (s z) z))\n(def one (lam (s z) (s z)))\n(def pair (lam (x y f) (f x y)))\n(def fst (lam (p) (p (lam (x y) x))))\n(def snd (lam (p) (p (lam (x y) y))))\n(def succ (lam (n s z) (s ((n s) z))))\n(def pred (lam (n) (fst (n (lam (p) (pair (snd p) (succ (snd p)))) (pair zero zero)))))\n(pred one)",
"answer": "(lam (s z) z)"
diff --git a/lambda-calcul/haskell/fourmolu.yaml b/lambda-calcul/haskell/fourmolu.yaml
new file mode 100644
index 0000000..a2cc914
--- /dev/null
+++ b/lambda-calcul/haskell/fourmolu.yaml
@@ -0,0 +1,18 @@
+indentation: 2
+comma-style: leading # for lists, tuples etc. - can also be 'trailing'
+record-brace-space: false # rec {x = 1} vs. rec{x = 1}
+indent-wheres: false # 'false' means save space by only half-indenting the 'where' keyword
+diff-friendly-import-export: true # 'false' uses Ormolu-style lists
+respectful: true # don't be too opinionated about newlines etc.
+haddock-style: single-line # '--' vs. '{-'
+newlines-between-decls: 1 # number of newlines between top-level declarations
+single-constraint-parens: never # whether or not to put braces around single constraints: https://fourmolu.github.io/config/single-constraint-parens/
+fixities:
+ - infixr 0 $
+ - infixr 1 &
+ - infixl 3 <|>
+ - infixr 2 ||
+ - infixr 3 &&
+ - infixl 1 <&>
+ - infixl 4 <$>
+ - infixl 4 <*>
diff --git a/lambda-calcul/haskell/minilang.cabal b/lambda-calcul/haskell/minilang.cabal
index 200e5c5..25f3346 100644
--- a/lambda-calcul/haskell/minilang.cabal
+++ b/lambda-calcul/haskell/minilang.cabal
@@ -96,6 +96,7 @@ test-suite minilang-test
main-is: Spec.hs
other-modules:
Minilang.IOSpec
+ Minilang.IntegrationSpec
Minilang.Lambda.InferSpec
Minilang.Lambda.DeBruijnSpec
Minilang.Lambda.EvalSpec
@@ -134,10 +135,13 @@ test-suite minilang-test
, hspec
, hspec-discover
, hspec-wai
+ , http-client
, http-types
, minilang
, mtl
, random
+ , servant
+ , servant-client
, stm
, text
, unix
diff --git a/lambda-calcul/haskell/src/Minilang/App.hs b/lambda-calcul/haskell/src/Minilang/App.hs
index cce48f7..3373b75 100644
--- a/lambda-calcul/haskell/src/Minilang/App.hs
+++ b/lambda-calcul/haskell/src/Minilang/App.hs
@@ -22,7 +22,7 @@ import Minilang.API (Challenge (..), LambdaAPI, Score (..), Token (..), User (..
import Minilang.Lambda.Parser (pretty)
import Minilang.REPL (withInputs)
import Minilang.REPL.Purer (PurerEnv (..))
-import Minilang.REPL.Types (In (..), Out (..), initialREPL)
+import Minilang.REPL.Types (Command (..), Content (..), In (..), Out (..), initialREPL)
import Network.Wai.Application.Static (defaultFileServerSettings, staticApp)
import Servant (ServerT, err400, err500, errBody, (:<|>) (..), err404)
import Servant.Server (Application, Handler (..), Tagged (..))
@@ -147,7 +147,7 @@ postAnswer Nothing _ = liftIO $ throwM $ err400 {errBody = "Missing token"}
postAnswer (Just token) expected = do
checkUserToken token
Challenge {expr} <- getCurrentChallenge token
- let PurerEnv {outputs} = withInputs initialREPL [In expr]
+ let PurerEnv {outputs} = withInputs initialREPL [Com (Load (Data expr))]
result = evaluateOutputs outputs expected
updateUserLastTest token result
case result of
@@ -171,6 +171,10 @@ evaluateOutputs outputs expected =
if pretty ast /= expected
then TestFailed "Incorrect result"
else TestSucceeded
+ [Bye] ->
+ if expected == ""
+ then TestSucceeded
+ else TestFailed "Incorrect result"
[Bye, Msg msg] -> TestFailed msg
_ -> TestErrored $ pack $ show outputs
diff --git a/lambda-calcul/haskell/test.sh b/lambda-calcul/haskell/test.sh
deleted file mode 100755
index 6bb9dc4..0000000
--- a/lambda-calcul/haskell/test.sh
+++ /dev/null
@@ -1,45 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-HOST=${1:-https://lambda.pankzsoft.net}
-
-post () {
- url=$1
- data=$2
-
- curl -s -X POST -H 'content-type: application/json' "$url" -d "$data"
-}
-
-get () {
- url=$1
-
- curl -s -X GET -H 'content-type: application/json' "$url"
-}
-
-ALICE=$(post $HOST/api/register '"alice"' | jq -r)
-echo "registered alice with token '$ALICE'"
-sleep 2
-
-for i in $(seq 1 14); do
- GRADE=$(get "$HOST/api/score?token=$ALICE" | jq -r .grade)
- echo "alice's grade is $GRADE"
- sleep 1
-
- CHAL=$(get "$HOST/api/challenge?token=$ALICE" | jq -r .expr)
- echo "alice's challenge is '$CHAL'"
- sleep 1
-
- ANSWER=$(echo "$CHAL" | cabal run)
- echo "alice's answer is '$ANSWER'"
-
- RESULT=$(post "$HOST/api/challenge?token=$ALICE" "\"$ANSWER\"")
-
- if [ "$RESULT" != "[]" ]; then
- echo "Failed challenge: $RESULT"
- fi
-done
-
-SCORE=$(curl -s -X DELETE -H 'content-type: application/json' "$HOST/api/register?token=$ALICE")
-
-echo "Unregistered alice: $SCORE"
diff --git a/lambda-calcul/haskell/test/Minilang/IntegrationSpec.hs b/lambda-calcul/haskell/test/Minilang/IntegrationSpec.hs
new file mode 100644
index 0000000..fa87cef
--- /dev/null
+++ b/lambda-calcul/haskell/test/Minilang/IntegrationSpec.hs
@@ -0,0 +1,106 @@
+{-# LANGUAGE LambdaCase #-}
+
+module Minilang.IntegrationSpec where
+
+import Control.Exception (bracket)
+import Data.Aeson (eitherDecodeFileStrict)
+import Data.Proxy (Proxy (..))
+import Data.Text (Text)
+import Minilang.API
+import Minilang.Server (Server (..), startServer, stopServer)
+import Network.HTTP.Client (defaultManagerSettings, newManager)
+import Servant.API ((:<|>) (..))
+import Servant.Client hiding (mkClientEnv)
+import qualified Servant.Client as SC
+import Test.Hspec
+
+-- ---------------------------------------------------------------------------
+-- Servant client functions derived from the API type
+-- ---------------------------------------------------------------------------
+
+registerClient :: User -> ClientM Token
+unregisterClient :: Maybe Token -> ClientM Score
+leaderboardClient :: ClientM [Score]
+scoreClient :: Maybe Token -> ClientM Score
+challengeClient :: Maybe Token -> ClientM Challenge
+answerClient :: Maybe Token -> Text -> ClientM ()
+
+registerClient
+ :<|> unregisterClient
+ :<|> leaderboardClient
+ :<|> scoreClient
+ :<|> challengeClient
+ :<|> answerClient
+ :<|> _ =
+ client (Proxy :: Proxy LambdaAPI)
+
+-- ---------------------------------------------------------------------------
+-- Helpers
+-- ---------------------------------------------------------------------------
+
+-- | Build a 'ClientEnv' pointing at localhost on the given port.
+mkEnv :: Int -> IO ClientEnv
+mkEnv port = do
+ mgr <- newManager defaultManagerSettings
+ pure $ SC.mkClientEnv mgr (BaseUrl Http "127.0.0.1" port "")
+
+-- | Run a servant client action, failing the test on a Left.
+runC :: ClientEnv -> ClientM a -> IO a
+runC env action =
+ runClientM action env >>= \case
+ Left err -> fail $ "Servant client error: " <> show err
+ Right v -> pure v
+
+-- ---------------------------------------------------------------------------
+-- Spec
+-- ---------------------------------------------------------------------------
+
+spec :: Spec
+spec =
+ around withServer $
+ describe "Full contest integration" $ do
+ it "Alice can solve all challenges and reach the maximum grade" $
+ \Server{serverPort} -> do
+ -- Load the same challenges.json the server loaded so we know the
+ -- expected final grade.
+ challenges <-
+ eitherDecodeFileStrict "challenges.json" >>= \case
+ Left err -> fail $ "Could not load challenges.json: " <> err
+ Right cs -> pure (cs :: [Challenge])
+
+ let maxGrade = length challenges
+
+ env <- mkEnv serverPort
+
+ -- Register Alice
+ token <- runC env $ registerClient (User "alice")
+
+ -- Solve every challenge in order
+ mapM_ (solveChallenge env token) challenges
+
+ -- Verify final grade equals number of challenges
+ Score{grade} <- runC env $ scoreClient (Just token)
+ grade `shouldBe` maxGrade
+
+ -- Clean up
+ _ <- runC env $ unregisterClient (Just token)
+ pure ()
+
+-- | Fetch the current challenge for the token, post the known answer, and
+-- assert the server accepted it (200 OK / no exception).
+solveChallenge :: ClientEnv -> Token -> Challenge -> IO ()
+solveChallenge env token Challenge{level, answer} = do
+ -- Confirm the server gives us the challenge we expect at this grade
+ Challenge{level = serverLevel} <- runC env $ challengeClient (Just token)
+ serverLevel `shouldBe` level
+ -- Post the correct answer
+ runC env $ answerClient (Just token) answer
+
+-- ---------------------------------------------------------------------------
+-- Server lifecycle
+-- ---------------------------------------------------------------------------
+
+-- | Spawn the real server (which itself reads challenges.json and picks a
+-- free port) and tear it down after the test.
+withServer :: (Server -> IO ()) -> IO ()
+withServer = bracket (startServer 0) stopServer