Monorepo for Aesthetic.Computer aesthetic.computer
at main 563 lines 19 kB view raw
1// KidLisp: Spec 24.05.03.22.45 2// A test runner for `kidlisp`. 3 4const tests = ["addition", "subtraction", "timing-highlight", "complex-timing"]; 5 6import fs from "fs/promises"; 7import path from "path"; 8import { fileURLToPath } from "url"; 9import { 10 parse, 11 evaluate, 12 KidLisp, 13 isPromptInKidlispMode, 14 encodeKidlispForUrl, 15 decodeKidlispFromUrl, 16} from "../system/public/aesthetic.computer/lib/kidlisp.mjs"; 17 18const __filename = fileURLToPath(import.meta.url); 19const __dirname = path.dirname(__filename); 20 21// ANSI color codes for terminal output 22const colors = { 23 reset: "\x1b[0m", 24 bright: "\x1b[1m", 25 red: "\x1b[31m", 26 green: "\x1b[32m", 27 yellow: "\x1b[33m", 28 blue: "\x1b[34m", 29 magenta: "\x1b[35m", 30 cyan: "\x1b[36m", 31 white: "\x1b[37m", 32 orange: "\x1b[38;5;208m", 33 lime: "\x1b[38;5;10m", 34 purple: "\x1b[38;5;93m", 35 olive: "\x1b[38;5;58m", 36 gray: "\x1b[90m", 37}; 38 39function isRgbEscape(text) { 40 return /^\d{1,3},\d{1,3},\d{1,3}(,\d{1,3})?$/.test(text); 41} 42 43function rgbEscapeToAnsi(text) { 44 const parts = text.split(",").map((p) => Number.parseInt(p.trim(), 10)); 45 if (parts.length < 3) return ""; 46 const [r, g, b] = parts; 47 if ([r, g, b].some((n) => Number.isNaN(n))) return ""; 48 return `\x1b[38;2;${Math.max(0, Math.min(255, r))};${Math.max(0, Math.min(255, g))};${Math.max(0, Math.min(255, b))}m`; 49} 50 51// Convert kidlisp color escapes (e.g. \yellow\ or \255,0,0,255\) to ANSI. 52function kidlispColoredToAnsi(coloredText) { 53 if (!coloredText) return ""; 54 55 let output = ""; 56 let currentPos = 0; 57 58 const colorRegex = /\\([^\\]+)\\/g; 59 let match; 60 61 while ((match = colorRegex.exec(coloredText)) !== null) { 62 output += coloredText.substring(currentPos, match.index); 63 64 const colorName = match[1]; 65 if (colors[colorName]) { 66 output += colors[colorName]; 67 } else if (isRgbEscape(colorName)) { 68 output += rgbEscapeToAnsi(colorName); 69 } 70 71 currentPos = match.index + match[0].length; 72 } 73 74 output += coloredText.substring(currentPos); 75 output += colors.reset; 76 return output; 77} 78 79function printColoredOutput(coloredText) { 80 if (!coloredText) { 81 console.log("(no colored output)"); 82 return; 83 } 84 console.log(kidlispColoredToAnsi(coloredText)); 85} 86 87function printSyntaxHighlightedKidlisp(source) { 88 const lisp = new KidLisp(); 89 lisp.initializeSyntaxHighlighting(source); 90 const colored = lisp.buildColoredKidlispString(); 91 printColoredOutput(colored); 92} 93 94describe("🤖 Kid Lisp", () => { 95 let pieces = {}; 96 97 beforeAll(async () => { 98 // Clear the terminal 99 console.clear(); 100 101 // List of all pieces you want to preload 102 const loadPromises = tests.map((name) => 103 load(name).then((data) => { 104 if (data) { 105 console.log(`✅ Loaded test: ${name} - ${data.desc}`); 106 console.log("📝 Source code (syntax highlighted):"); 107 printSyntaxHighlightedKidlisp(data.src); 108 pieces[name] = data; 109 } else { 110 throw new Error(`Failed to load file: ${name}`); 111 } 112 }), 113 ); 114 115 try { 116 console.log("🧒 Loading kidlisp tests..."); 117 await Promise.all(loadPromises); 118 console.log(`🎯 All ${tests.length} tests loaded successfully`); 119 } catch (error) { 120 console.error("🔴 Error during test setup:", error); 121 throw error; // This will fail the test suite 122 } 123 }); 124 125 afterAll(() => { 126 // Print timestamp after all tests complete 127 const now = new Date(); 128 const timestamp = now.toLocaleString('en-US', { 129 timeZone: 'America/Los_Angeles', 130 weekday: 'long', 131 year: 'numeric', 132 month: 'long', 133 day: 'numeric', 134 hour: '2-digit', 135 minute: '2-digit', 136 second: '2-digit', 137 timeZoneName: 'short' 138 }); 139 console.log(`\n⏰ Tests completed at: ${timestamp}`); 140 141 // List all items in the KidLisp globalEnv (colorized) 142 console.log("\n"); 143 printColoredOutput("\\bright\\\\cyan\\📚 Kid Lisp Library:\\reset\\"); 144 const lisp = new KidLisp(); 145 const globalEnv = lisp.getGlobalEnv(); 146 const envKeys = Object.keys(globalEnv).sort(); 147 148 // Categorize the functions 149 const categories = { 150 'math': ['+', '-', '*', '/', 'max'], 151 'logic': ['>', '<', '=', 'if', 'not'], 152 'control': ['def', 'later', 'die', 'now', 'range'], 153 'graphics': ['resolution', 'wipe', 'ink', 'line', 'box', 'write', 'wiggle'], 154 'system': ['tap', 'draw', 'net', 'source', 'width', 'height'], 155 'audio': ['overtone'], 156 'utility': ['len'] 157 }; 158 159 const categoryColors = { 160 math: "yellow", 161 logic: "lime", 162 control: "orange", 163 graphics: "cyan", 164 system: "purple", 165 audio: "magenta", 166 utility: "olive", 167 other: "gray", 168 }; 169 170 const isRgbArray = (v) => 171 Array.isArray(v) && 172 (v.length === 3 || v.length === 4) && 173 v.slice(0, 3).every((n) => Number.isFinite(n)); 174 175 const clamp255 = (n) => Math.max(0, Math.min(255, Math.round(n))); 176 177 const formatLibraryItem = (item, fallbackColor) => { 178 const value = globalEnv[item]; 179 180 // If this identifier is a color constant in the env, show it in that exact RGB. 181 if (isRgbArray(value)) { 182 const r = clamp255(value[0]); 183 const g = clamp255(value[1]); 184 const b = clamp255(value[2]); 185 return `\\${r},${g},${b},255\\${item}\\reset\\`; 186 } 187 188 // Otherwise colorize by category. 189 const color = fallbackColor || "white"; 190 return `\\${color}\\${item}\\reset\\`; 191 }; 192 193 const joinStyled = (items, fallbackColor) => { 194 const sep = "\\gray\\, \\reset\\"; 195 return items.map((i) => formatLibraryItem(i, fallbackColor)).join(sep); 196 }; 197 198 // Display by categories as tag clouds (colorized) 199 Object.entries(categories).forEach(([category, categoryItems]) => { 200 const availableItems = categoryItems.filter((item) => envKeys.includes(item)); 201 if (availableItems.length > 0) { 202 const color = categoryColors[category] || "white"; 203 printColoredOutput( 204 `\\${color}\\${category}\\reset\\: ${joinStyled(availableItems, color)}`, 205 ); 206 } 207 }); 208 209 // Display any uncategorized items 210 const categorizedItems = Object.values(categories).flat(); 211 const uncategorized = envKeys.filter(key => !categorizedItems.includes(key)); 212 213 if (uncategorized.length > 0) { 214 printColoredOutput( 215 `\\${categoryColors.other}\\other\\reset\\: ${joinStyled(uncategorized, categoryColors.other)}`, 216 ); 217 } 218 219 // --- Syntax-highlighted preview (uses kidlisp.mjs highlighter) --- 220 // This makes the library dump reflect exactly what the client highlighter does. 221 const snippetForItem = (item, category) => { 222 // Operators and math-ish tokens 223 if (["+", "-", "*", "/", "%", "max", "min", "mod"].includes(item)) { 224 return `(${item} 1 2)`; 225 } 226 227 // Control forms 228 if (item === "def") return "(def x 1)"; 229 if (item === "later") return "(later 1 (write \"HI\"))"; 230 if (item === "die") return "(die)"; 231 if (item === "now") return "(now)"; 232 if (item === "range") return "(range 5)"; 233 234 // Logic 235 if ([">", "<", "="] .includes(item)) return `(${item} 2 1)`; 236 if (item === "if") return "(if yes (write \"Y\") (write \"N\"))"; 237 if (item === "not") return "(not yes)"; 238 239 // Drawing/system-ish 240 if (item === "resolution") return "(resolution 64 64)"; 241 if (item === "wipe") return "(wipe black)"; 242 if (item === "ink") return "(ink white)"; 243 if (item === "line") return "(line 0 0 width height)"; 244 if (item === "box") return "(box 1 1 10 10)"; 245 if (item === "write") return "(write \"HELLO\")"; 246 if (item === "wiggle") return "(wiggle 0.1)"; 247 if (item === "tap") return "(tap (write \"TAP\"))"; 248 if (item === "draw") return "(draw (line 0 0 width height))"; 249 if (item === "net") return "(net)"; 250 if (item === "source") return "(source)"; 251 if (item === "width") return "width"; 252 if (item === "height") return "height"; 253 254 // Audio 255 if (item === "overtone") return "(overtone 220)"; 256 257 // Utility 258 if (item === "len") return "(len \"abc\")"; 259 260 // Colors/constants 261 const value = globalEnv[item]; 262 if (isRgbArray(value)) { 263 return `(ink ${item})`; 264 } 265 266 // Fallback: present it as a call so it gets tokenized 267 // (works for identifiers like resetSpin, smoothspin, ready?, etc.) 268 if (typeof item === "string" && item.length > 0) { 269 return `(${item})`; 270 } 271 272 // Shouldn't happen, but keep it safe. 273 return "; (unknown)"; 274 }; 275 276 const buildLibraryPreviewSource = () => { 277 const lines = []; 278 lines.push("; kidlisp library — syntax highlight preview"); 279 280 Object.entries(categories).forEach(([category, categoryItems]) => { 281 const availableItems = categoryItems.filter((item) => envKeys.includes(item)); 282 if (availableItems.length === 0) return; 283 284 lines.push(""); 285 lines.push(`; ${category}`); 286 availableItems.forEach((item) => { 287 lines.push(snippetForItem(item, category)); 288 }); 289 }); 290 291 if (uncategorized.length > 0) { 292 lines.push(""); 293 lines.push(`; other (${uncategorized.length})`); 294 uncategorized.forEach((item) => { 295 lines.push(snippetForItem(item, "other")); 296 }); 297 } 298 299 return lines.join("\n"); 300 }; 301 302 printColoredOutput("\\bright\\\\cyan\\\\n📚 Kid Lisp Library (syntax preview)\\reset\\"); 303 printSyntaxHighlightedKidlisp(buildLibraryPreviewSource()); 304 }); 305 306 it("Add numbers", () => { 307 console.log("🧮 Running addition test..."); 308 console.log(`📄 Test source: ${pieces.addition.src}`); 309 310 const parsed = parse(pieces.addition.src); 311 console.log(`🔍 Parsed AST:`, parsed); 312 313 const result = evaluate(parsed); 314 console.log(`🎯 Evaluation result: ${result}`); 315 console.log(`✅ Expected: 6, Got: ${result}`); 316 317 expect(result).toEqual(6); 318 }); 319 320 it("Subtract numbers", () => { 321 console.log("➖ Running subtraction test..."); 322 console.log(`📄 Test source: ${pieces.subtraction.src}`); 323 324 const parsed = parse(pieces.subtraction.src); 325 console.log(`🔍 Parsed AST:`, parsed); 326 327 const result = evaluate(parsed); 328 console.log(`🎯 Evaluation result: ${result}`); 329 console.log(`✅ Expected: 3, Got: ${result}`); 330 331 expect(result).toEqual(3); 332 }); 333 334 it("Parse kidlisp functions without parentheses", () => { 335 console.log("🔧 Testing parser with unparenthesized functions..."); 336 337 const testSource = `wipe green 338ink red`; 339 console.log(`📄 Original source:\n${testSource}`); 340 341 const parsed = parse(testSource); 342 console.log(`📦 Parsed AST:`, parsed); 343 344 const expected = [ 345 ['wipe', 'green'], 346 ['ink', 'red'] 347 ]; 348 349 expect(parsed).toEqual(expected); 350 }); 351 352 it("Detect kidlisp mode with newlines", () => { 353 console.log("🔍 Testing kidlisp mode detection..."); 354 355 const testCases = [ 356 { input: "(+ 1 2)", expected: true, desc: "traditional parentheses" }, 357 { input: "; comment", expected: true, desc: "comment" }, 358 { input: "line red\nink blue", expected: true, desc: "newline with functions" }, 359 { input: "hello world", expected: false, desc: "plain text" }, 360 { input: "just\ntext", expected: false, desc: "newline without functions" } 361 ]; 362 363 testCases.forEach(({ input, expected, desc }) => { 364 const result = isPromptInKidlispMode(input); 365 console.log(` ${desc}: "${input.replace(/\n/g, '\\n')}" -> ${result} (expected: ${expected})`); 366 expect(result).toEqual(expected); 367 }); 368 }); 369 370 it("Preserve newlines in URL encoding/decoding", () => { 371 console.log("🔗 Testing URL encoding/decoding with newlines..."); 372 373 const testSource = `line 10 20 30 40 374ink red 375box 5 5 10 10`; 376 377 console.log(`📄 Original source:\n${testSource}`); 378 379 const encoded = encodeKidlispForUrl(testSource); 380 console.log(`🔒 Encoded: ${encoded}`); 381 382 const decoded = decodeKidlispFromUrl(encoded); 383 console.log(`🔓 Decoded:\n${decoded}`); 384 385 expect(decoded).toEqual(testSource); 386 }); 387 388 it("Test timing expression syntax highlighting", () => { 389 console.log("🎨 Running timing expression syntax highlighting test..."); 390 console.log("📄 Test source (syntax highlighted):"); 391 printSyntaxHighlightedKidlisp(pieces["timing-highlight"].src); 392 393 const lisp = new KidLisp(); 394 395 // Initialize syntax highlighting 396 lisp.initializeSyntaxHighlighting(pieces["timing-highlight"].src); 397 398 // Mock API for evaluation 399 const mockApi = { 400 write: (...args) => console.log('Write:', args.join(' ')), 401 clock: { 402 time: () => new Date(), 403 }, 404 }; 405 406 // Parse and evaluate to trigger AST tagging 407 const parsed = lisp.parse(pieces["timing-highlight"].src); 408 console.log("📊 AST structure:", JSON.stringify(parsed, null, 2)); 409 410 // Test initial highlighting (before evaluation) 411 const initialHighlight = lisp.buildColoredKidlispString(); 412 console.log("🎨 Initial highlighting:", initialHighlight); 413 414 // Evaluate to trigger timing logic 415 const result = lisp.evaluate(parsed, mockApi); 416 console.log("⚡ Evaluation result:", result); 417 418 // Test highlighting after evaluation 419 const finalHighlight = lisp.buildColoredKidlispString(); 420 console.log("🎨 Final highlighting:", finalHighlight); 421 422 // Test with colored terminal output 423 console.log("\n🌈 COLORED TERMINAL OUTPUT:"); 424 printColoredOutput(finalHighlight); 425 426 expect(finalHighlight).toBeDefined(); 427 expect(finalHighlight.length).toBeGreaterThan(0); 428 }); 429 430 it("Test complex timing expression highlighting", () => { 431 console.log("🎨 Running complex timing expression highlighting test..."); 432 console.log("📄 Test source (syntax highlighted):"); 433 printSyntaxHighlightedKidlisp(pieces["complex-timing"].src); 434 435 const lisp = new KidLisp(); 436 437 // Initialize syntax highlighting for multi-line source 438 lisp.initializeSyntaxHighlighting(pieces["complex-timing"].src); 439 440 // Mock API for evaluation 441 const mockApi = { 442 beige: () => console.log('Beige called'), 443 ink: (...args) => console.log('Ink:', args.join(' ')), 444 write: (...args) => console.log('Write:', args.join(' ')), 445 scroll: (...args) => console.log('Scroll:', args.join(' ')), 446 blur: (...args) => console.log('Blur:', args.join(' ')), 447 zoom: (...args) => console.log('Zoom:', args.join(' ')), 448 rainbow: () => [255, 0, 0], 449 '?': (...args) => args[Math.floor(Math.random() * args.length)], 450 clock: { 451 time: () => new Date() // Provide the missing clock API 452 } 453 }; 454 455 // Parse and evaluate 456 const parsed = lisp.parse(pieces["complex-timing"].src); 457 console.log("📊 Complex AST structure (first 3 expressions):", 458 JSON.stringify(parsed.slice(0, 3), null, 2)); 459 460 // Test highlighting before evaluation 461 const beforeHighlight = lisp.buildColoredKidlispString(); 462 console.log("🎨 Before evaluation:", beforeHighlight); 463 464 // Evaluate to trigger timing logic 465 const result = lisp.evaluate(parsed, mockApi); 466 console.log("⚡ Complex evaluation result:", result); 467 468 // Test highlighting after evaluation 469 const afterHighlight = lisp.buildColoredKidlispString(); 470 console.log("🎨 After evaluation:", afterHighlight); 471 472 // Test with colored terminal output 473 console.log("\n🌈 COMPLEX COLORED TERMINAL OUTPUT:"); 474 printColoredOutput(afterHighlight); 475 476 expect(afterHighlight).toBeDefined(); 477 expect(afterHighlight.length).toBeGreaterThan(0); 478 expect(afterHighlight).toContain('\\'); // Should contain color codes 479 }); 480 481 it("Tiny timing tokens use fast pulsed blink windows", () => { 482 const lisp = new KidLisp(); 483 484 // Sub-second timing should pulse quickly instead of staying continuously "on". 485 const tinySecondStart = lisp.getTimingEditBlinkState("0.01s", 0); 486 const tinySecondLater = lisp.getTimingEditBlinkState("0.01s", 40); 487 expect(tinySecondStart.isBlinking).toBeTrue(); 488 expect(tinySecondLater.isBlinking).toBeFalse(); 489 490 // Sub-frame timing gets the same fast pulse treatment. 491 const tinyFrameStart = lisp.getTimingEditBlinkState("1f", 0); 492 const tinyFrameLater = lisp.getTimingEditBlinkState("1f", 40); 493 expect(tinyFrameStart.isBlinking).toBeTrue(); 494 expect(tinyFrameLater.isBlinking).toBeFalse(); 495 496 // Longer timers keep a broader blink window. 497 const normalStart = lisp.getTimingEditBlinkState("1.5s", 0); 498 const normalLater = lisp.getTimingEditBlinkState("1.5s", 300); 499 expect(normalStart.isBlinking).toBeTrue(); 500 expect(normalLater.isBlinking).toBeFalse(); 501 }); 502 503 it("Auto-close incomplete expressions", () => { 504 console.log("🔧 Testing auto-closing of incomplete expressions..."); 505 506 const testCases = [ 507 { 508 input: "(+ 1 2", 509 expected: [["+"," ","1", 2]], 510 desc: "incomplete addition" 511 }, 512 { 513 input: "(line 10 20", 514 expected: [["line", 10, 20]], 515 desc: "incomplete function call" 516 }, 517 { 518 input: "(+ (- 5 2", 519 expected: [["+", ["-", 5, 2]]], 520 desc: "nested incomplete expression" 521 }, 522 { 523 input: "(def x (+ 1", 524 expected: [["def", "x", ["+", 1]]], 525 desc: "incomplete definition" 526 } 527 ]; 528 529 testCases.forEach(({ input, expected, desc }) => { 530 console.log(` Testing ${desc}: "${input}"`); 531 try { 532 const parsed = parse(input); 533 console.log(` Parsed successfully:`, parsed); 534 expect(Array.isArray(parsed)).toBe(true); 535 expect(parsed.length).toBeGreaterThan(0); 536 } catch (error) { 537 fail(` Failed to parse incomplete expression: ${error.message}`); 538 } 539 }); 540 }); 541}); 542 543async function load(name) { 544 const filePath = path.resolve( 545 __dirname, 546 "..", 547 "system", 548 "public", 549 "aesthetic.computer", 550 "disks", 551 `${name}.lisp`, 552 ); 553 try { 554 console.log(`📂 Loading test file: ${filePath}`); 555 const src = await fs.readFile(filePath, "utf8"); 556 const desc = src.split("\n")[0].replace(/^;\s*/, ""); 557 console.log(`📝 File loaded - Description: ${desc}`); 558 return { desc, src }; 559 } catch (error) { 560 console.error(`🔴 Error setting up \`kidlisp\` tests for ${name}:`, error); 561 return null; 562 } 563}