1
0

run.sh 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. #!/usr/bin/env bash
  2. # Self-test for isometric-ops scripts.
  3. #
  4. # Offline-deterministic (no network, no live registry calls — check-iso-facts
  5. # is exercised only in --offline mode). Builds throwaway tile fixtures via
  6. # `uv run` + Pillow, asserts documented exit codes and key output of each
  7. # script, then cleans up. Resolves paths relative to itself so it works both
  8. # in the repo and once installed to ~/.claude/skills/isometric-ops/.
  9. #
  10. # The companion iso-studio app was extracted to its own repository
  11. # (github.com/0xDarkMatter/iso-studio) and carries its own tests/run.sh.
  12. #
  13. # Usage: bash tests/run.sh
  14. # Exit: 0 all pass (including graceful skips), 1 one or more failures
  15. #
  16. # Skips gracefully (with a message, not a failure) when uv is unavailable —
  17. # see the tile-validate/sheet-pack sections below.
  18. set -uo pipefail
  19. HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  20. SKILL="$(dirname "$HERE")"
  21. SCRIPTS="$SKILL/scripts"
  22. # Pick a python that actually executes — skips the Windows Store `python3`
  23. # stub (an app-execution alias that exits non-zero non-interactively).
  24. PYTHON=""
  25. for c in python python3 py; do
  26. if command -v "$c" >/dev/null 2>&1 && "$c" -c "" >/dev/null 2>&1; then PYTHON="$c"; break; fi
  27. done
  28. [[ -z "$PYTHON" ]] && { echo "no working python found" >&2; exit 1; }
  29. SB="$(mktemp -d)"; trap 'rm -rf "$SB"' EXIT
  30. PASS=0; FAIL=0
  31. ok() { PASS=$((PASS+1)); printf ' PASS %s\n' "$1"; }
  32. no() { FAIL=$((FAIL+1)); printf ' FAIL %s\n' "$1"; }
  33. expect_exit() { [[ "$2" == "$3" ]] && ok "$1 (exit $3)" || no "$1 (want $2 got $3)"; }
  34. expect_has() { case "$3" in *"$2"*) ok "$1";; *) no "$1 (missing '$2')";; esac; }
  35. echo "=== isometric-ops self-test ==="
  36. # ── --help + EXAMPLES for every script ─────────────────────────────────────
  37. echo "-- --help / EXAMPLES contract --"
  38. for s in iso-math tile-validate sheet-pack check-iso-facts; do
  39. out="$("$PYTHON" "$SCRIPTS/$s.py" --help 2>&1)"; rc=$?
  40. expect_exit "$s.py --help" 0 "$rc"
  41. case "$out" in
  42. *EXAMPLES*|*Examples*) ok "$s.py --help mentions EXAMPLES" ;;
  43. *) no "$s.py --help missing EXAMPLES section" ;;
  44. esac
  45. done
  46. # ── iso-math.py: constants ──────────────────────────────────────────────────
  47. echo "-- iso-math.py constants --"
  48. true_out="$("$PYTHON" "$SCRIPTS/iso-math.py" constants --projection true 2>&1)"; rc=$?
  49. expect_exit "constants --projection true" 0 "$rc"
  50. # grep-tolerant of formatting: the script prints decimal fractions (0.8165,
  51. # 0.86603, 0.57735) rather than the brief's percent notation (81.65%,
  52. # 86.602%, 57.735%) -- match on the digit runs common to both forms.
  53. for v in 35.264 8165 86603 57735 1.22474 54.7356 30.0 120.0; do
  54. expect_has "true-iso constants contain $v" "$v" "$true_out"
  55. done
  56. dim_out="$("$PYTHON" "$SCRIPTS/iso-math.py" constants --projection dimetric21 2>&1)"; rc=$?
  57. expect_exit "constants --projection dimetric21" 0 "$rc"
  58. expect_has "dimetric constants contain 26.565" "26.565" "$dim_out"
  59. expect_has "dimetric constants mention 2:1 tile aspect" "2:1" "$dim_out"
  60. pix_out="$("$PYTHON" "$SCRIPTS/iso-math.py" constants --projection pixel 2>&1)"; rc=$?
  61. expect_exit "constants --projection pixel" 0 "$rc"
  62. expect_has "pixel constants contain 22.6 (obelisk stated)" "22.6" "$pix_out"
  63. expect_has "pixel constants contain 26.565 (geometric arctan)" "26.565" "$pix_out"
  64. json_out="$("$PYTHON" "$SCRIPTS/iso-math.py" constants --projection true --json 2>&1)"; rc=$?
  65. expect_exit "constants --json" 0 "$rc"
  66. expect_has "constants --json parses" '"data"' "$json_out"
  67. # ── iso-math.py: to-screen / to-tile round-trips at 3 distinct points ──────
  68. echo "-- iso-math.py to-screen/to-tile round-trips --"
  69. roundtrip() { # tx ty tile_w tile_h
  70. local tx="$1" ty="$2" tw="$3" th="$4"
  71. local sxy sx sy tjson rtx rty
  72. sxy="$("$PYTHON" "$SCRIPTS/iso-math.py" to-screen "$tx" "$ty" --tile-w "$tw" --tile-h "$th" 2>&1)"
  73. read -r sx sy <<<"$sxy"
  74. tjson="$("$PYTHON" "$SCRIPTS/iso-math.py" to-tile "$sx" "$sy" --tile-w "$tw" --tile-h "$th" --json 2>&1)"
  75. rtx="$("$PYTHON" -c "import json,sys; d=json.loads(sys.argv[1]); print(d['data']['tileRounded']['x'])" "$tjson" 2>/dev/null)"
  76. rty="$("$PYTHON" -c "import json,sys; d=json.loads(sys.argv[1]); print(d['data']['tileRounded']['y'])" "$tjson" 2>/dev/null)"
  77. if [[ "$rtx" == "$tx" && "$rty" == "$ty" ]]; then
  78. ok "round-trip ($tx,$ty) tileW=$tw tileH=$th -> screen($sx,$sy) -> tile($rtx,$rty)"
  79. else
  80. no "round-trip ($tx,$ty) tileW=$tw tileH=$th -> got tile($rtx,$rty) via screen($sx,$sy)"
  81. fi
  82. }
  83. roundtrip 3 5 64 32
  84. roundtrip -4 7 128 64
  85. roundtrip 0 0 32 16
  86. # elevation-aware to-screen (z / --elev-step) sanity: higher z should move screenY up (smaller)
  87. z0="$("$PYTHON" "$SCRIPTS/iso-math.py" to-screen 3 5 0 --tile-w 64 --tile-h 32 --elev-step 16 2>&1)"
  88. z2="$("$PYTHON" "$SCRIPTS/iso-math.py" to-screen 3 5 2 --tile-w 64 --tile-h 32 --elev-step 16 2>&1)"
  89. z0y="$(awk '{print $2}' <<<"$z0")"; z2y="$(awk '{print $2}' <<<"$z2")"
  90. if "$PYTHON" -c "import sys; sys.exit(0 if float('$z2y') < float('$z0y') else 1)" 2>/dev/null; then
  91. ok "elevation raises screen position (z=2 screenY < z=0 screenY)"
  92. else
  93. no "elevation did not raise screen position ($z0y vs $z2y)"
  94. fi
  95. # ── iso-math.py: grid-svg emits parseable SVG with expected line count ─────
  96. echo "-- iso-math.py grid-svg --"
  97. svg="$("$PYTHON" "$SCRIPTS/iso-math.py" grid-svg --projection dimetric21 --tile-w 64 --extent 4 2>/dev/null)"
  98. case "$svg" in "<svg"*|*"<svg "*) ok "grid-svg starts with <svg" ;; *) no "grid-svg did not start with <svg" ;; esac
  99. case "$svg" in *"</svg>") ok "grid-svg ends with </svg>" ;; *) no "grid-svg did not end with </svg>" ;; esac
  100. line_count="$(grep -o '<line ' <<<"$svg" | wc -l | tr -d ' ')"
  101. # extent N dimetric grid: N+1 lines per axis direction, two axis directions -> 2*(N+1)
  102. expected=$(( (4 + 1) * 2 ))
  103. if [[ "$line_count" == "$expected" ]]; then
  104. ok "grid-svg line count == $expected for extent=4"
  105. else
  106. no "grid-svg line count $line_count != expected $expected"
  107. fi
  108. "$PYTHON" -c "
  109. import sys, xml.etree.ElementTree as ET
  110. try:
  111. ET.fromstring(sys.stdin.read())
  112. sys.exit(0)
  113. except Exception as e:
  114. print(e, file=sys.stderr); sys.exit(1)
  115. " <<<"$svg" >/dev/null 2>&1
  116. expect_exit "grid-svg is well-formed XML" 0 $?
  117. svg_true="$("$PYTHON" "$SCRIPTS/iso-math.py" grid-svg --projection true --tile-w 128 --extent 3 2>/dev/null)"
  118. expect_has "true-iso grid-svg is an <svg>" "<svg" "$svg_true"
  119. # ── iso-math.py: transforms --target recipes ────────────────────────────────
  120. echo "-- iso-math.py transforms --"
  121. css3d="$("$PYTHON" "$SCRIPTS/iso-math.py" transforms --target css-3d 2>&1)"; rc=$?
  122. expect_exit "transforms --target css-3d" 0 "$rc"
  123. expect_has "css-3d contains rotateX(54.7356deg)" "54.7356" "$css3d"
  124. expect_has "css-3d contains rotateZ(-45deg)" "rotateZ(-45deg)" "$css3d"
  125. expect_has "css-3d contains scale3d(1.22474" "1.22474" "$css3d"
  126. illus="$("$PYTHON" "$SCRIPTS/iso-math.py" transforms --target illustrator 2>&1)"; rc=$?
  127. expect_exit "transforms --target illustrator" 0 "$rc"
  128. expect_has "illustrator recipe contains 0.86603 (SSR)" "0.86603" "$illus"
  129. expect_has "illustrator recipe notes the SRC-B 86.062 typo" "86.062" "$illus"
  130. expect_has "illustrator recipe has +30/-30 shear-rotate figures" "30" "$illus"
  131. for tgt in css-top css-left css-right svg-top svg-left svg-right figma; do
  132. out="$("$PYTHON" "$SCRIPTS/iso-math.py" transforms --target "$tgt" 2>&1)"; rc=$?
  133. expect_exit "transforms --target $tgt" 0 "$rc"
  134. done
  135. figma_out="$("$PYTHON" "$SCRIPTS/iso-math.py" transforms --target figma --json 2>&1)"; rc=$?
  136. expect_exit "transforms --target figma --json" 0 "$rc"
  137. expect_has "figma recipe json parses" '"data"' "$figma_out"
  138. # ── tile-validate.py: fixtures generated at test time via uv run + Pillow ─
  139. echo "-- tile-validate.py (fixture-driven) --"
  140. if command -v uv >/dev/null 2>&1; then
  141. cat > "$SB/gen_tiles.py" <<'PYEOF'
  142. # /// script
  143. # requires-python = ">=3.11"
  144. # dependencies = ["pillow>=10.0"]
  145. # ///
  146. import sys
  147. from PIL import Image
  148. outdir = sys.argv[1]
  149. # GOOD: correct 2:1 tile (64x32), clean alpha diamond, transparent margin, no
  150. # halo/bleed, feet centered.
  151. w, h = 64, 32
  152. img = Image.new("RGBA", (w, h), (0, 0, 0, 0))
  153. px = img.load()
  154. cx, cy = w / 2, h / 2
  155. for y in range(h):
  156. for x in range(w):
  157. nx = abs(x - cx) / (w / 2 - 4)
  158. ny = abs(y - cy) / (h / 2 - 4)
  159. if nx + ny <= 1.0:
  160. px[x, y] = (120, 140, 160, 255)
  161. img.save(outdir + "/good_tile.png")
  162. # BAD: wrong ratio (48x48, not a 64x32 multiple) + alpha-halo fringe + edge bleed.
  163. w2, h2 = 48, 48
  164. img2 = Image.new("RGBA", (w2, h2), (0, 0, 0, 0))
  165. px2 = img2.load()
  166. for y in range(h2):
  167. for x in range(w2):
  168. px2[x, y] = (200, 80, 80, 255) # opaque all the way to the border -> bleed
  169. for y in range(h2):
  170. for x in range(w2):
  171. d = ((x - w2 / 2) ** 2 + (y - h2 / 2) ** 2) ** 0.5
  172. if d > w2 / 2 - 6:
  173. px2[x, y] = (200, 80, 80, 80) # semi-transparent ring -> halo
  174. img2.save(outdir + "/bad_tile.png")
  175. PYEOF
  176. uv run "$SB/gen_tiles.py" "$SB" >/dev/null 2>"$SB/gen.err"
  177. if [[ -f "$SB/good_tile.png" && -f "$SB/bad_tile.png" ]]; then
  178. out="$(uv run "$SCRIPTS/tile-validate.py" --tile-w 64 --tile-h 32 "$SB/good_tile.png" 2>&1)"; rc=$?
  179. expect_exit "good fixture -> 0" 0 "$rc"
  180. out="$(uv run "$SCRIPTS/tile-validate.py" --tile-w 64 --tile-h 32 "$SB/bad_tile.png" 2>&1)"; rc=$?
  181. expect_exit "bad fixture -> 10" 10 "$rc"
  182. expect_has "bad fixture flags dimension" "dimension" "$out"
  183. expect_has "bad fixture flags halo" "halo" "$out"
  184. expect_has "bad fixture flags bleed" "bleed" "$out"
  185. jout="$(uv run "$SCRIPTS/tile-validate.py" --tile-w 64 --tile-h 32 --json "$SB/good_tile.png" "$SB/bad_tile.png" 2>/dev/null)"
  186. expect_has "tile-validate --json parses" '"data"' "$jout"
  187. else
  188. no "tile fixture generation did not produce expected files (see $SB/gen.err)"
  189. fi
  190. else
  191. echo " SKIP uv not found — tile-validate.py fixture tests skipped"
  192. fi
  193. # ── sheet-pack.py: pack fixtures, assert atlas JSON schema ─────────────────
  194. echo "-- sheet-pack.py (fixture-driven) --"
  195. if command -v uv >/dev/null 2>&1 && [[ -f "$SB/good_tile.png" && -f "$SB/bad_tile.png" ]]; then
  196. mkdir -p "$SB/packdir"
  197. cp "$SB/good_tile.png" "$SB/packdir/"
  198. cp "$SB/bad_tile.png" "$SB/packdir/"
  199. out="$(uv run "$SCRIPTS/sheet-pack.py" "$SB/packdir" --out "$SB/atlas" --force --json 2>&1)"; rc=$?
  200. expect_exit "sheet-pack on fixtures -> 0" 0 "$rc"
  201. if [[ -f "$SB/atlas.json" && -f "$SB/atlas.png" ]]; then
  202. ok "sheet-pack wrote atlas.png + atlas.json"
  203. "$PYTHON" - "$SB/atlas.json" <<'PYEOF' >"$SB/atlas_check.out" 2>&1
  204. import json, sys
  205. with open(sys.argv[1]) as f:
  206. atlas = json.load(f)
  207. assert atlas["meta"]["schema"] == "claude-mods.isometric-ops.sheet-pack/v1", "bad schema"
  208. frames = atlas["frames"]
  209. assert "good_tile" in frames and "bad_tile" in frames, "missing frame names"
  210. for name, fr in frames.items():
  211. for k in ("frame", "sourceSize"):
  212. assert k in fr, f"{name} missing {k}"
  213. for k in ("x", "y", "w", "h"):
  214. assert k in fr["frame"], f"{name} frame missing {k}"
  215. for k in ("w", "h"):
  216. assert k in fr["sourceSize"], f"{name} sourceSize missing {k}"
  217. assert "sourceW" in fr and "sourceH" in fr, f"{name} missing flat sourceW/sourceH"
  218. print("ok")
  219. PYEOF
  220. if [[ "$(cat "$SB/atlas_check.out")" == "ok" ]]; then
  221. ok "atlas.json parses and frame fields (frame.x/y/w/h, sourceSize, sourceW/sourceH) present"
  222. else
  223. no "atlas.json schema check failed: $(cat "$SB/atlas_check.out")"
  224. fi
  225. else
  226. no "sheet-pack did not write atlas.png/atlas.json"
  227. fi
  228. else
  229. echo " SKIP uv (or tile fixtures) not available — sheet-pack.py fixture tests skipped"
  230. fi
  231. # ── check-iso-facts.py ───────────────────────────────────────────────────────
  232. echo "-- check-iso-facts.py --"
  233. "$PYTHON" "$SCRIPTS/check-iso-facts.py" --offline >/dev/null 2>&1
  234. expect_exit "--offline -> 0" 0 $?
  235. jout="$("$PYTHON" "$SCRIPTS/check-iso-facts.py" --offline --json 2>/dev/null)"
  236. expect_has "--offline --json parses" '"data"' "$jout"
  237. # ── summary ────────────────────────────────────────────────────────────────
  238. echo "=== $PASS passed, $FAIL failed ==="
  239. if [[ "$PASS" -eq 0 && "$FAIL" -eq 0 ]]; then
  240. echo " SKIP no assertions ran on this platform"
  241. exit 0
  242. fi
  243. [[ "$FAIL" -eq 0 ]] || exit 1