http-nu 0.17.2

The surprisingly performant, Nushell-scriptable, cross.stream-powered, Datastar-ready HTTP server that fits in your back pocket.
Documentation
use std/assert

# Import the pure pieces of the 2048 module: roll, slide-tiles,
# apply-move, tiles-equal, initial-state, impulses-to-states, ... The
# store-touching helpers (frames-to-states, list-games, ...) aren't
# exercised here -- those are covered by the browser e2e.
const script_dir = path self | path dirname
use ($script_dir | path join ".." "tfe" "game.nu") *
use ($script_dir | path join ".." "tfe" "sse.nu") *
use ($script_dir | path join ".." "notes" "pages.nu") split-md

# A fixed game id so every test is deterministic.
const GID = "test-game-aaaa"

# --- roll: deterministic hash -> seeds --------------------------------------

# Regression: `into int --radix 16` errors on hex strings starting with "0b"
# (interpreted as a binary literal prefix), and similarly for "0x" / "0o".
# `game-249` happens to produce a hash starting with "0b" -- roll must still
# return clean integers, not error.
let r = roll "game-249" {tiles: [] next_id: 1 score: 0 game_over: false} "h"
assert (($r.idx | describe) == "int") "roll returns int idx even on 0b-prefixed hash"
assert ($r.value >= 0 and $r.value <= 9) "roll value in 0..9"

# Same inputs always yield the same outputs.
let r2 = roll "game-249" {tiles: [] next_id: 1 score: 0 game_over: false} "h"
assert ($r.idx == $r2.idx and $r.value == $r2.value) "roll is deterministic"

# --- pure game logic --------------------------------------------------------

# initial-state seeds two tiles deterministically from the game_id.
let s0 = initial-state $GID
assert (($s0.tiles | length) == 2) "initial state has 2 tiles"
assert ($s0.score == 0) "initial score 0"
assert (not $s0.game_over) "initial not game over"
assert ($s0.next_id >= 3) "next_id advanced past two spawned tiles"

# Calling initial-state with the same game_id reproduces the same board.
let s0_again = initial-state $GID
assert (tiles-equal $s0.tiles $s0_again.tiles) "initial-state is deterministic per game_id"

# Different game_ids generally produce different starts.
let s0_other = initial-state "other-game-bbbb"
assert (not (tiles-equal $s0.tiles $s0_other.tiles)) "different game_id -> different initial tiles"

# slide-row-tiles collapses adjacent equal values and accumulates score.
let row = [
  {id: 1 r: 0 c: 0 value: 2}
  {id: 2 r: 0 c: 1 value: 2}
  {id: 3 r: 0 c: 2 value: 4}
  {id: 4 r: 0 c: 3 value: 4}
] | slide-row-tiles 0
assert (($row.tiles | length) == 2) "two pairs merge into two tiles"
assert ($row.score == 12) "score is 4 + 8 = 12"
assert (($row.tiles | get 0 | get c) == 0) "first merged tile at column 0"
assert (($row.tiles | get 1 | get c) == 1) "second merged tile at column 1"

# slide-row-tiles preserves leading tile id on merge.
let merge_ids = [
  {id: 10 r: 0 c: 0 value: 2}
  {id: 11 r: 0 c: 1 value: 2}
] | slide-row-tiles 0
assert ((($merge_ids.tiles | get 0).id) == 10) "merge keeps leading tile id"

# apply-move shifts everything left when called with 'h'.
let pre = {
  tiles: [{id: 1 r: 0 c: 3 value: 4}]
  next_id: 2 score: 0 game_over: false
}
let post = $pre | apply-move "h" $GID
let moved = $post.tiles | where id == 1 | first
assert ($moved.c == 0) "single tile moves to column 0 on left"
assert (($post.tiles | length) == 2) "spawn-tile added a second tile after the move"

# Same input twice produces the same output -- the roll is deterministic.
let post_again = $pre | apply-move "h" $GID
assert (tiles-equal $post.tiles $post_again.tiles) "apply-move is deterministic for (state, dir, game_id)"

# apply-move is identity (same tiles) when the move doesn't change the board.
let locked = {
  tiles: [{id: 1 r: 0 c: 0 value: 2}]
  next_id: 2 score: 0 game_over: false
}
let noop = $locked | apply-move "h" $GID
assert (tiles-equal $locked.tiles $noop.tiles) "no-op move returns same tile list"
assert ($locked.score == $noop.score) "no-op move keeps score"

# tiles-equal is order-independent (sort-by id).
let a = [{id: 1 r: 0 c: 0 value: 2} {id: 2 r: 1 c: 1 value: 4}]
let b = [{id: 2 r: 1 c: 1 value: 4} {id: 1 r: 0 c: 0 value: 2}]
assert (tiles-equal $a $b) "tiles-equal ignores order"
let c = [{id: 1 r: 0 c: 0 value: 2} {id: 2 r: 1 c: 1 value: 8}]
assert (not (tiles-equal $a $c)) "tiles-equal detects value diff"

# A row of 2s slides right and merges; rightmost cell holds the merged 4.
let diag = {
  tiles: [
    {id: 1 r: 0 c: 0 value: 2}
    {id: 2 r: 0 c: 1 value: 2}
    {id: 3 r: 0 c: 2 value: 2}
    {id: 4 r: 0 c: 3 value: 2}
  ]
  next_id: 5 score: 0 game_over: false
}
let after_l = $diag | apply-move "l" $GID
let rightmost = $after_l.tiles | where r == 0 and c == 3 | first
assert ($rightmost.value == 4) "right-side merge yields 4 on slide right"

# --- filter-for-player ------------------------------------------------------

let frames = [
  {topic: "player.alice.games"}        # alice's index -- keep
  {topic: "game.move.abc"}             # any game move topic -- keep
  {topic: "game.move.xyz"}             # any game move topic -- keep
  {topic: "xs.threshold"}              # threshold marker -- keep
  {topic: "player.bob.games"}          # bob's index -- drop
  {topic: "templates.html"}            # unrelated -- drop
  {topic: "game.snapshot.abc"}         # not a move topic -- drop
  {topic: "abc.move"}                  # missing game.move. prefix -- drop
]
let kept = $frames | filter-for-player "player.alice.games" | get topic
let expected = ["player.alice.games" "game.move.abc" "game.move.xyz" "xs.threshold"]
assert ($kept == $expected) $"filter kept ($kept), expected ($expected)"

# --- impulses-to-states stack discipline ------------------------------------

# A known starting state so we can reason about the stack precisely.
let known = {
  tiles: [
    {id: 1 r: 0 c: 1 value: 2}
    {id: 2 r: 0 c: 2 value: 2}
  ]
  next_id: 3 score: 0 game_over: false
}
let GAMES_TOPIC = $"player.test-pid.games"
let MOVE_TOPIC = $"game.move.($GID)"
let init = {stack: [$known] mode: "game" game_id: $GID games_topic: $GAMES_TOPIC}

# Helper: feed a list of frames through impulses-to-states and return the
# emitted records as a list. Each frame emits one or more records.
def drive [frames: list, initial: record]: nothing -> list {
  $frames | impulses-to-states $initial
}

# 0a. Regression: when a fresh game's snapshot-actor hasn't caught up
#     yet, frames-to-states' accumulator state is still null when the
#     xs.threshold marker arrives. threshold-gate forwards a {state:
#     null, threshold: false} record. The terminal output stage used to
#     deref $state.tiles on this and crash; it must now skip the
#     record (the placeholder already on the page stays put until the
#     next snapshot lands).
let r_null = [{state: null threshold: false}] | states-to-wc-signals
assert (($r_null | length) == 0) "null-state record is dropped, not rendered"

# 0. Empty log: only an xs.threshold marker, no prior frames. The pipeline
#    must produce ONE state record (the placeholder), not crash. Regression
#    for prod incident where threshold-gate emitted null and downstream
#    errored on `$s.state`.
let placeholder_init = {
  stack: [{tiles: [] next_id: 1 score: 0 game_over: false}]
  mode: "game" game_id: "" games_topic: "player.test-pid.games"
}
let r0 = [{topic: "xs.threshold"}] | impulses-to-states $placeholder_init | threshold-gate-states | take 2
# Threshold emits: (1) the gated state record, (2) a {signals: {replayMs}}
# debug record. The state record comes first and has zero tiles.
let r0_state = $r0 | where {|i| ($i | get state? | default null) != null} | first
assert (($r0_state.state.tiles | length) == 0) "empty-log state has zero tiles"
assert ((($r0_state | get threshold?) | default false) == false) "threshold flag stripped before downstream"
let r0_signals = $r0 | where {|i| ('signals' in $i)} | first
assert (($r0_signals.signals | get replayMs? | default null) != null) "threshold emits replayMs signals"

# 2. xs.threshold marker passes the current top of stack through.
let r2 = drive [{topic: "xs.threshold"}] $init
let r2_first = $r2 | first
assert (($r2_first | get threshold) == true) "threshold marker carries threshold=true"
assert (tiles-equal $r2_first.state.tiles $known.tiles) "threshold emits current top"

# 3. A move that changes the board emits one state.
let r3 = drive [{topic: "game.move.test-game-aaaa" meta: {intent: "h"}}] $init
let r3_state = $r3 | first | get state
let changed = not (tiles-equal $r3_state.tiles $known.tiles)
assert $changed "left move on known state changes the board"
assert (($r3 | first | get direction) == "h") "move record carries direction"

# 4. Undo after a real move restores the pre-move state.
let move_then_undo = [
  {topic: "game.move.test-game-aaaa" meta: {intent: "h"}}
  {topic: "game.move.test-game-aaaa" meta: {kind: "undo"}}
  {topic: "xs.threshold"}
]
let r4 = drive $move_then_undo $init
let r4_final = $r4 | where threshold == true | first
assert (tiles-equal $r4_final.state.tiles $known.tiles) "undo restores the prior state"

# 5. Undo + same direction reproduces the original spawn (the whole point
#    of game_id-based deterministic rolls).
let move_undo_redo = [
  {topic: "game.move.test-game-aaaa" meta: {intent: "h"}}
  {topic: "game.move.test-game-aaaa" meta: {kind: "undo"}}
  {topic: "game.move.test-game-aaaa" meta: {intent: "h"}}
  {topic: "xs.threshold"}
]
let r5 = drive $move_undo_redo $init
let r5_final = $r5 | where threshold == true | first
let r3_final_again = drive [{topic: "game.move.test-game-aaaa" meta: {intent: "h"}} {topic: "xs.threshold"}] $init | where threshold == true | first
assert (tiles-equal $r5_final.state.tiles $r3_final_again.state.tiles) "undo + redo same dir = same board"

# 6. Undo at the bottom of the stack is a no-op echo (state unchanged).
let r6 = drive [{topic: "game.move.test-game-aaaa" meta: {kind: "undo"}}] $init
assert (tiles-equal ($r6 | first | get state | get tiles) $known.tiles) "undo at bottom echoes current"

# 7. A no-op move does NOT push, so a subsequent undo finds the stack at
#    its bottom and echoes.
let edge = {
  tiles: [{id: 1 r: 0 c: 0 value: 2}]
  next_id: 2 score: 0 game_over: false
}
let init_edge = {stack: [$edge] mode: "game" game_id: $GID games_topic: $GAMES_TOPIC}
let r7 = drive [
  {topic: "game.move.test-game-aaaa" meta: {intent: "h"}}
  {topic: "game.move.test-game-aaaa" meta: {kind: "undo"}}
  {topic: "xs.threshold"}
] $init_edge
let r7_final = $r7 | where threshold == true | first
assert (tiles-equal $r7_final.state.tiles $edge.tiles) "no-op move + undo round-trips"

# 8. Legacy slam-X intents (no longer supported) fall through to the noop
#    echo arm: state unchanged, no stack push.
let pair = {
  tiles: [
    {id: 1 r: 0 c: 0 value: 2}
    {id: 2 r: 0 c: 1 value: 2}
  ]
  next_id: 3 score: 0 game_over: false
}
let init_pair = {stack: [$pair] mode: "game" game_id: $GID games_topic: $GAMES_TOPIC}
let r8 = drive [{topic: "game.move.test-game-aaaa" meta: {intent: "slam-h"}}] $init_pair
assert (tiles-equal ($r8 | first | get state | get tiles) $pair.tiles) "legacy slam-X is a no-op echo"

# 9. A frame on the player's games_topic starts a new game: stack resets,
#    game_id updates, and the emitted state is the fresh initial-state.
let r9 = drive [{topic: $GAMES_TOPIC id: "new-game-zzzz" meta: {}}] $init
let r9_state = $r9 | first | get state
let expected9 = (initial-state "new-game-zzzz") | get tiles
assert (tiles-equal $r9_state.tiles $expected9) "new game frame resets to initial-state(new id)"

# 10. After a game switch, OLD game's move frames are dropped: state stays
#     at the new game's initial board (the old game's "h" intent has no
#     effect because impulses-to-states only matches `game.<current>.move`).
let r10 = drive [
  {topic: $GAMES_TOPIC id: "new-game-zzzz" meta: {}}
  {topic: $"game.move.($GID)" meta: {intent: "h"}}    # old game -- dropped
  {topic: "xs.threshold"}
] $init
let r10_final = $r10 | where threshold == true | first
let new_init_tiles = (initial-state "new-game-zzzz") | get tiles
assert (tiles-equal $r10_final.state.tiles $new_init_tiles) "stale game's move is dropped after switch"

# --- frames-to-states ------------------------------------------------------
#
# Contract: snapshot frames yield one state record carrying boardState and
# req_id; threshold and pre-converted events flow through. Move frames are
# DROPPED -- every move ack now flows through a snapshot (state-changing as
# durable, no-op as an ephemeral one carrying just the req_id), so the SSE
# handler no longer follows `game.move.<id>` and the pipeline no longer
# echoes them.

let MOVE_TOPIC2 = $"game.move.($GID)"
let SNAP_TOPIC  = $"game.snapshot.($GID)"

# Helper: pump a list of frames through frames-to-states and return the
# emitted records as a list.
def drive-frames [frames: list]: nothing -> list {
  $frames | frames-to-states
}

# 11. Move frames are dropped (no record emitted). The ack rides the
#     snapshot that the actor emits in response.
let r11 = drive-frames [
  {id: "m1" topic: $MOVE_TOPIC2 meta: {intent: "h" req_id: "probe-42"}}
  {id: "m2" topic: $MOVE_TOPIC2 meta: {kind: "undo" req_id: "undo-7"}}
]
assert (($r11 | length) == 0) "move frames are dropped by frames-to-states"

# 12. A snapshot frame emits one state record carrying its meta.req_id and
#     the snapshot's state -- both state-changing (durable) and no-op
#     (ephemeral) snapshots flow through the same path.
let snap_state = {tiles: [{id: 1 r: 0 c: 0 value: 4}] next_id: 2 score: 4 game_over: false ghosts: []}
let r12 = drive-frames [{id: "s1" topic: $SNAP_TOPIC meta: {state: $snap_state req_id: "probe-42" intent: "h"}}]
assert (($r12 | length) == 1) "snapshot emits one record"
assert (tiles-equal ($r12 | first | get state | get tiles) $snap_state.tiles) "snapshot state propagates"
assert (($r12 | first | get req_id) == "probe-42") "snapshot carries req_id"

# 13. Pre-converted SSE event records (e.g. from pulse-keepalive) flow
#     through unchanged so downstream stages can dispatch by `event`.
let evt = {event: "datastar-patch-signals" data: ["signals {}"]}
let r13 = drive-frames [$evt]
assert (($r13 | first) == $evt) "event records pass through unchanged"

# --- notes/split-md --------------------------------------------------------
#
# Regression: bare `open` on a .md file parses it into a structured
# table (the commonmark AST), so `split row` then errored with
# "Input type not supported". Fix is `open --raw | decode utf-8`.
# Test loads the actual content file used by the notes sub-site.

let notes_md = $script_dir | path join ".." "notes" "content" "what-is-2048.md"
let sections = split-md $notes_md
assert (($sections | length) >= 1) "split-md returns at least one section"
let slugs = $sections | get slug
assert ("the-rules" in $slugs) "the-rules section present"
assert ("backstory" in $slugs) "backstory section present"
assert ("in-nushell" in $slugs) "in-nushell section present"
let rules = $sections | where slug == "the-rules" | first
assert ($rules.title == "The Rules") "title preserved verbatim"
assert (($rules.body | str length) > 0) "body is non-empty"

# --- move-authorized: actor's owner gate -----------------------------------
#
# The snapshot-actor consults this on every move frame before applying.
# HTTP-layer rejects unauthenticated /move; this is the second-line
# gate that also covers CLI appends, replays, or third-party frames.

let alice_move = {meta: {user_id: "alice" intent: "h"}}
let mallory_move = {meta: {user_id: "mallory" intent: "h"}}
let anon_move = {meta: {intent: "h"}}

assert (move-authorized $alice_move "alice") "owner's move passes"
assert (not (move-authorized $mallory_move "alice")) "stranger's move rejected"
assert (not (move-authorized $anon_move "alice")) "anonymous move on owned game rejected"

# Open ownership (empty owner_id) accepts anyone -- covers games
# created before the field existed; covered by the migration path.
assert (move-authorized $alice_move "") "no-owner game accepts any user"
assert (move-authorized $anon_move "")  "no-owner game accepts anonymous"

# Smoke check: serve.nu (and its sourced sub-sites) parse + evaluate up
# to the route closure that the file returns. Catches paren/bracket
# imbalance and other syntax errors that the unit tests above wouldn't
# trip because they only import the pure pieces. The closure itself is
# discarded -- exercising routes needs a running server.
source ($script_dir | path join ".." "serve.nu")

print "examples/2048/test.nu: all assertions passed"