Skip to content

Commit 2e269ef

Browse files
authored
Merge pull request #25 from DDecoene/feature/e2e-parity-commands
test+fix: parity-line e2e coverage, and fix #4 built-ins unreachable via REPL
2 parents bea5f94 + b26a3a4 commit 2e269ef

5 files changed

Lines changed: 183 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ Versions follow [Semantic Versioning](https://semver.org/) — minor bump per su
1111

1212
> Parity milestone. Accumulates on `release/v1.0.0`; tagged when it merges to `main`.
1313
14+
### Fixed
15+
- The #4 built-ins (`ROUND`, `MOD`, `MAX`, `MIN`, `TIME`, `YEAR`, `MONTH`, `DAY`,
16+
shipped in 0.8.0) were implemented in `Builtins.ts` but never registered in the
17+
parser's `BUILTIN_FUNCTIONS` whitelist, so calling them from the REPL failed with
18+
`Unknown command: (`. They are now registered and reachable. The unit tests
19+
passed only because they called the implementation directly — caught by adding
20+
Playwright e2e coverage for the parity commands.
21+
1422
### Added
1523
- **`SUM` / `AVERAGE` commands** (#3) — `SUM <field> [FOR <cond>]` and
1624
`AVERAGE <field> [FOR <cond>]` aggregate a numeric field over the current table,

‎CLAUDE.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ Complete these steps **in order** — do not skip or reorder:
245245

246246
1. **Branch correctly** — work sits on a `feature/<name>` branched off the milestone's `release/vX.Y.Z`; the PR is based on that release branch, **not** `main` (see Git conventions → GitFlow). Confirm the issue is assigned to the matching milestone.
247247
2. `npm test` passes — all tests green
248+
- **Every user-facing command/feature gets a Playwright e2e case, not just a vitest unit/integration test.** A REPL command needs at least one `tests/*.spec.ts` case that types it and asserts the rendered terminal/UI result; browser-only behavior (downloads, uploads, grid, wizards) must be exercised in a real browser. Unit coverage alone is not "done".
248249
3. `package.json` version = the milestone's version (set on the `release/vX.Y.Z` branch); patch bumps for hotfixes
249250
4. `CHANGELOG.md` — add entry (Added / Fixed / Changed sections) under the milestone version heading
250251
5. `README.md` — command tables and feature list reflect what was built

‎src/interpreter/Parser.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ const BUILTIN_FUNCTIONS = new Set([
66
'SUBSTR','LEN','TRIM','LTRIM','UPPER','LOWER','AT','STR','VAL',
77
'INT','ABS','SPACE','REPLICATE','DATE','DTOC','CTOD',
88
'EOF','BOF','FOUND','RECNO','RECCOUNT',
9+
// #4 (PR #17, @kas2804) — implemented in Builtins.ts; must be whitelisted here
10+
// too or the parser won't recognise the call.
11+
'ROUND','MOD','MAX','MIN','TIME','YEAR','MONTH','DAY',
912
]);
1013

1114
// ── AST Node Types ──────────────────────────────────────────────────────────

‎tests/BuiltinsParse.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { Session } from '../server/Session';
3+
import type { ServerMessage } from '../src/shared/types.js';
4+
5+
// Exercises built-in functions THROUGH the parser+executor (not callStateless
6+
// directly), the way the REPL actually does — catches the parser-whitelist gap
7+
// where an implemented builtin isn't registered in Parser.BUILTIN_FUNCTIONS.
8+
async function evalPrint(expr: string): Promise<string> {
9+
const sent: ServerMessage[] = [];
10+
const session = new Session((m) => sent.push(m));
11+
await session.handleMessage({ type: 'command', text: `? ${expr}` });
12+
return (sent.filter(m => m.type === 'output') as Extract<ServerMessage, { type: 'output' }>[])
13+
.flatMap(m => m.lines.map(l => l.text))
14+
.join('\n');
15+
}
16+
17+
describe('built-in functions reachable through the REPL parser', () => {
18+
it('ROUND', async () => { expect(await evalPrint('ROUND(3.14159, 2)')).toContain('3.14'); });
19+
it('MOD', async () => { expect(await evalPrint('MOD(17, 5)')).toContain('2'); });
20+
it('MAX', async () => { expect(await evalPrint('MAX(3, 9)')).toContain('9'); });
21+
it('MIN', async () => { expect(await evalPrint('MIN(3, 9)')).toContain('3'); });
22+
it('TIME', async () => { expect(await evalPrint('TIME()')).toMatch(/\d\d:\d\d:\d\d/); });
23+
it('YEAR', async () => { expect(await evalPrint('YEAR(CTOD("12/25/2026"))')).toContain('2026'); });
24+
it('MONTH', async () => { expect(await evalPrint('MONTH(CTOD("12/25/2026"))')).toContain('12'); });
25+
it('DAY', async () => { expect(await evalPrint('DAY(CTOD("12/25/2026"))')).toContain('25'); });
26+
});

‎tests/parity-commands.spec.ts‎

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { test, expect, Page } from '@playwright/test';
2+
3+
async function cmd(page: Page, command: string, waitMs = 800): Promise<void> {
4+
const input = page.locator('#terminal-input');
5+
await input.fill(command);
6+
await input.press('Enter');
7+
await page.waitForTimeout(waitMs);
8+
}
9+
10+
async function waitForOutput(page: Page, text: string, timeout = 5000): Promise<void> {
11+
await expect(page.locator('#terminal-output')).toContainText(text, { timeout, ignoreCase: true });
12+
}
13+
14+
async function boot(page: Page, dbName: string): Promise<void> {
15+
await page.goto('/');
16+
await waitForOutput(page, 'Connected.', 8000);
17+
await cmd(page, `USE DATABASE ${dbName}`);
18+
await waitForOutput(page, 'Opened database', 3000);
19+
}
20+
21+
test.describe('Parity commands e2e', () => {
22+
23+
test('1. ? / ?? print expressions', async ({ page }) => {
24+
await boot(page, `e2e_parity_print_${Date.now()}`);
25+
26+
await cmd(page, '? 2 + 2');
27+
await waitForOutput(page, '4', 3000);
28+
29+
await cmd(page, '? UPPER("hi")');
30+
await waitForOutput(page, 'HI', 3000);
31+
32+
await cmd(page, '? "hello"');
33+
await waitForOutput(page, 'hello', 3000);
34+
});
35+
36+
// #4 (PR #17, @kas2804) built-ins — exercised end-to-end through the REPL.
37+
test('2. Built-in functions via ?', async ({ page }) => {
38+
await boot(page, `e2e_parity_builtins_${Date.now()}`);
39+
40+
await cmd(page, '? ROUND(3.14159, 2)');
41+
await waitForOutput(page, '3.14', 3000);
42+
43+
await cmd(page, '? MOD(17, 5)');
44+
await waitForOutput(page, '2', 3000);
45+
46+
await cmd(page, '? MAX(3, 9)');
47+
await waitForOutput(page, '9', 3000);
48+
49+
await cmd(page, '? MIN(3, 9)');
50+
await waitForOutput(page, '3', 3000);
51+
52+
await cmd(page, '? YEAR(CTOD("12/25/2026"))');
53+
await waitForOutput(page, '2026', 3000);
54+
55+
await cmd(page, '? MONTH(CTOD("12/25/2026"))');
56+
await waitForOutput(page, '12', 3000);
57+
58+
await cmd(page, '? DAY(CTOD("12/25/2026"))');
59+
await waitForOutput(page, '25', 3000);
60+
61+
// TIME() -> HH:MM:SS
62+
await cmd(page, '? TIME()');
63+
await expect(page.locator('#terminal-output')).toContainText(/\d\d:\d\d:\d\d/, { timeout: 3000 });
64+
});
65+
66+
test('3. SUM and AVERAGE', async ({ page }) => {
67+
const db = `e2e_parity_sum_${Date.now()}`;
68+
await boot(page, db);
69+
70+
await cmd(page, 'DROP TABLE orders');
71+
await page.waitForTimeout(500);
72+
73+
await cmd(page, 'CREATE TABLE orders (amount INTEGER, country CHARACTER)');
74+
await waitForOutput(page, 'created', 3000);
75+
76+
await cmd(page, 'USE orders');
77+
await waitForOutput(page, 'orders', 2000);
78+
79+
await cmd(page, 'APPEND RECORD');
80+
await cmd(page, 'REPLACE amount WITH 100, country WITH "BE"');
81+
await waitForOutput(page, 'replaced', 2000);
82+
83+
await cmd(page, 'APPEND RECORD');
84+
await cmd(page, 'REPLACE amount WITH 250, country WITH "NL"');
85+
await waitForOutput(page, 'replaced', 2000);
86+
87+
await cmd(page, 'APPEND RECORD');
88+
await cmd(page, 'REPLACE amount WITH 900, country WITH "BE"');
89+
await waitForOutput(page, 'replaced', 2000);
90+
91+
await cmd(page, 'SUM amount');
92+
await waitForOutput(page, '1250', 3000);
93+
94+
await cmd(page, 'AVERAGE amount FOR country == "BE"');
95+
await waitForOutput(page, '500', 3000);
96+
});
97+
98+
test('4. SORT ON ... TO creates sorted copy', async ({ page }) => {
99+
const db = `e2e_parity_sort_${Date.now()}`;
100+
await boot(page, db);
101+
102+
await cmd(page, 'DROP TABLE sortbase');
103+
await page.waitForTimeout(300);
104+
await cmd(page, 'DROP TABLE sorted_t');
105+
await page.waitForTimeout(300);
106+
107+
await cmd(page, 'CREATE TABLE sortbase (amount INTEGER)');
108+
await waitForOutput(page, 'created', 3000);
109+
110+
await cmd(page, 'USE sortbase');
111+
await waitForOutput(page, 'sortbase', 2000);
112+
113+
await cmd(page, 'APPEND RECORD');
114+
await cmd(page, 'REPLACE amount WITH 900');
115+
await waitForOutput(page, 'replaced', 2000);
116+
117+
await cmd(page, 'APPEND RECORD');
118+
await cmd(page, 'REPLACE amount WITH 100');
119+
await waitForOutput(page, 'replaced', 2000);
120+
121+
await cmd(page, 'APPEND RECORD');
122+
await cmd(page, 'REPLACE amount WITH 500');
123+
await waitForOutput(page, 'replaced', 2000);
124+
125+
await cmd(page, 'SORT ON amount TO sorted_t');
126+
await waitForOutput(page, 'sorted', 3000);
127+
128+
await cmd(page, 'USE sorted_t');
129+
await waitForOutput(page, 'sorted_t', 2000);
130+
131+
await cmd(page, 'LIST');
132+
await page.waitForTimeout(1000);
133+
134+
const text = await page.locator('#terminal-output').textContent() ?? '';
135+
const i100 = text.lastIndexOf('100');
136+
const i500 = text.lastIndexOf('500');
137+
const i900 = text.lastIndexOf('900');
138+
expect(i100).toBeGreaterThan(-1);
139+
expect(i500).toBeGreaterThan(-1);
140+
expect(i900).toBeGreaterThan(-1);
141+
expect(i100).toBeLessThan(i500);
142+
expect(i500).toBeLessThan(i900);
143+
});
144+
145+
});

0 commit comments

Comments
 (0)