aboutsummaryrefslogtreecommitdiffstats
path: root/prototypes/workbench/cli.test.mjs
blob: f355d2dee1933b3798ef35a3c5a48d0933d9f52f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRuntime } from './web/runtime.mjs';

const root = fileURLToPath(new URL('.', import.meta.url));
function run(input, file) {
  const result = spawnSync(process.execPath, [join(root, 'cli.mjs'), ...(file ? [file] : [])], {
    input, encoding: 'utf8', cwd: root,
  });
  if (result.error) throw result.error;
  return { ...result, snapshots: result.stdout.split('\n').filter(line => line.startsWith('{')).map(line => JSON.parse(line)) };
}

test('terminal starts empty and produces the same charge trace as the shared runtime', async () => {
  assert.deepEqual(run('show\nquit\n').snapshots[0].states, {});
  const file = join(root, 'examples/charge.pure');
  const result = run('down jump\nframe 30\nup jump\nframe 1\nshow\nquit\n', file);
  assert.equal(result.status, 0, result.stderr);
  const runtime = await createRuntime(await readFile(join(root, 'web/core.wasm')));
  runtime.apply(await readFile(file, 'utf8'));
  runtime.down('jump', 'terminal'); runtime.frame(30); runtime.up('jump', 'terminal'); runtime.frame();
  // JSON has no undefined-valued optional fields; compare the same wire form.
  assert.deepEqual(result.snapshots.at(-1), JSON.parse(JSON.stringify(runtime.snapshot())));
  assert(result.snapshots.at(-1).states.y < 240);
});

test('invalid terminal commands do not alter state and fail piped execution', () => {
  const result = run('show\nframe 0\nframe -1\nframe 1.5\nframe 1e2\nframe 601\nshow extra\ndown unknown\nshow\nquit\n', join(root, 'examples/counter.pure'));
  assert.equal(result.status, 1);
  assert.deepEqual(result.snapshots[0], result.snapshots[1]);
  assert.equal(result.stderr.trim().split('\n').length, 7);
});

test('terminal replays authored pointer movement and cancellation through the shared runtime', async () => {
  const file = join(root, 'examples/direct.pure');
  const result = run('point drag down 90 170\npoint drag move 360 220\npoint drag up 410 240\npoint drag down 410 240\npoint drag move 500 260\npoint drag cancel 500 260\nshow\nquit\n', file);
  assert.equal(result.status, 0, result.stderr);
  const runtime = await createRuntime(await readFile(join(root, 'web/core.wasm')));
  runtime.apply(await readFile(file, 'utf8'));
  for (const [phase, x, y] of [['down',90,170],['move',360,220],['up',410,240],['down',410,240],['move',500,260],['cancel',500,260]])
    runtime.point('drag', phase, x, y, 'terminal');
  assert.deepEqual(result.snapshots.at(-1), JSON.parse(JSON.stringify(runtime.snapshot())));
  assert.equal(result.snapshots.at(-1).states.x, 350);
  assert.equal(result.snapshots.at(-1).states.y, 210);
  assert.deepEqual(result.snapshots.at(-1).held, []);
});

test('file load preserves by name, rejected load preserves active work, and save never overwrites', async t => {
  const directory = await mkdtemp(join(tmpdir(), 'pure-author-cli-'));
  t.after(() => rm(directory, { recursive: true, force: true }));
  const first = join(directory, 'first.pure'), second = join(directory, 'second with space.pure');
  const invalid = join(directory, 'invalid.pure'), saved = join(directory, 'saved.pure');
  await writeFile(first, 'state n = 7');
  await writeFile(second, 'state n = 100\nstate added = 9');
  await writeFile(invalid, 'state n = broken');
  const result = run(`load ${second}\nload ${invalid}\nshow\nsave ${saved}\nreset\nsave ${saved}\nquit\n`, first);
  assert.equal(result.status, 1);
  assert.deepEqual(result.snapshots[0].states, { n: 7, added: 9 });
  assert.deepEqual(result.snapshots[1], result.snapshots[0]);
  assert.deepEqual(result.snapshots.at(-1).states, { n: 100, added: 9 });
  assert.equal(await readFile(saved, 'utf8'), await readFile(second, 'utf8'));
  assert.match(result.stderr, /EEXIST/);
});

test('terminal exposes arithmetic rollback and retains failure through matching release', async t => {
  const directory = await mkdtemp(join(tmpdir(), 'pure-author-error-'));
  t.after(() => rm(directory, { recursive: true, force: true }));
  const file = join(directory, 'error.pure');
  await writeFile(file, 'state n = 7\ninput tap = Space\non tap.down { n = 1 / 0 }');
  const result = run('down tap\nup tap\nshow\nreset\nquit\n', file);
  assert.equal(result.status, 1);
  for (const snapshot of result.snapshots.slice(0, 3)) {
    assert.equal(snapshot.states.n, 7); assert.equal(snapshot.lastError, 4);
    assert.equal(snapshot.errorLine, 3);
  }
  assert.equal(result.snapshots[3].lastError, 0);
});

test('terminal configures and discards evaluation records and recovers from a display limit', () => {
  const result = run('limits 32 8 2048\nshow\ncosts\nforget all\nlimits 32 8 0\nlimits 32 8 2048\nshow\nquit\n', join(root, 'examples/counter.pure'));
  assert.equal(result.status, 1); // The explicit zero-budget display failed.
  assert.equal(result.stderr, '');
  const [configured, warm, costs, forgotten, limited, recovered, final] = result.snapshots;
  assert.deepEqual(warm.views, configured.views);
  assert(costs.evaluation.cacheHits > 0);
  assert.deepEqual(forgotten.states, configured.states);
  assert.deepEqual(limited.views, []);
  assert.equal(limited.lastError, 101);
  assert.deepEqual(recovered, final);
  assert.deepEqual(recovered.views, configured.views);
  assert.deepEqual(recovered.states, configured.states);
});

test('terminal saves and restores placement independently of reset and preserves a browser draft', async t => {
  const directory = await mkdtemp(join(tmpdir(), 'pure-layout-cli-'));
  t.after(() => rm(directory, {recursive:true,force:true}));
  const saved=join(directory,'layout.json'), copied=join(directory,'copy.json');
  const first=run(`point drag down 90 170\npoint drag move 360 220\npoint drag up 90 170\nsave-project ${saved}\nreset\nrestore ${saved}\nshow\nquit\n`,join(root,'examples/direct.pure'));
  assert.equal(first.status,0,first.stderr);assert.equal(first.snapshots.at(-1).states.x,350);
  const document=JSON.parse(await readFile(saved,'utf8'));document.draft='state incomplete =';
  await writeFile(saved,JSON.stringify(document));
  const second=run(`save-project ${copied}\nreset\nshow\nquit\n`,saved);
  assert.equal(second.status,0,second.stderr);assert.equal(second.snapshots.at(-1).states.x,80);
  const copy=JSON.parse(await readFile(copied,'utf8'));assert.equal(copy.draft,document.draft);assert.deepEqual(copy.states,document.states);
  const rejected=run(`save-project ${saved}\nquit\n`,saved);
  assert.equal(rejected.status,1);assert.match(rejected.stderr,/EEXIST/);
});

test('invalid restoration leaves terminal code and state unchanged', async t => {
  const directory=await mkdtemp(join(tmpdir(),'pure-invalid-layout-'));
  t.after(()=>rm(directory,{recursive:true,force:true}));
  const file=join(directory,'bad.json');
  await writeFile(file,JSON.stringify({format:'pure-workbench',version:2,source:'state x=1',states:[]}));
  const result=run(`show\nrestore ${file}\nshow\nquit\n`,join(root,'examples/counter.pure'));
  assert.equal(result.status,1);assert.deepEqual(result.snapshots[0],result.snapshots[1]);
});