Monorepo for Aesthetic.Computer
aesthetic.computer
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}