diff --git a/.gitignore b/.gitignore index 54f651c..b48f0ec 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ webbase-screenshot.png playwright-report/ test-results/ out/ +coverage/ diff --git a/CHANGELOG.md b/CHANGELOG.md index ebf29dd..204ffe4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,92 @@ Versions follow [Semantic Versioning](https://semver.org/) — minor bump per su --- +## [1.2.0] — 2026-07-09 — TIME columns, WEEK()/DATEADD(), BROWSE cell validation, Overtime demo + +### Added +- `TIME` column type — `CREATE TABLE ... (col TIME)` / `TIME(n)` for a minute-granularity + qualifier (e.g. `TIME(15)` for quarter-hour increments). Stores canonical `HH:MM`, + validated on `REPLACE ... WITH` (rejects malformed or off-granularity values — + no silent coercion), and `LIST STRUCTURE` prints the declared type instead of the + raw SQLite storage class. (#43) +- `WEEK(date)` built-in — ISO-8601 week number (1–53): Monday-start weeks, week 1 is the + week containing the year's first Thursday. Early-January dates correctly report the + previous year's week 52/53, and late-December dates week 1 of the next year. Accepts + ISO `YYYY-MM-DD` or `MM/DD/YY`; invalid input returns 0. (#44) +- `DATEADD(date, n)` built-in — the ISO date `n` days later (`n` may be negative). Computed + in UTC so month, year and leap-day boundaries are exact (`2024-02-28` + 1 = `2024-02-29`, + `2023-02-28` + 1 = `2023-03-01`). Accepts ISO `YYYY-MM-DD` or `MM/DD/YY` and composes with + `CTOD()`; invalid or impossible input returns `''`. W3Script previously had no date + arithmetic at all. (#52) +- `BROWSE` now validates each cell edit against its column's declared type before + committing. An invalid edit keeps the cell in edit mode, outlines it in red and shows + why (`HH:MM`, `multiple of 15`, `at most 2 decimal place(s)`, `not a real date`, …); + the error clears as soon as the value becomes valid. `DATE`, `TIME`/`TIME(n)`, + `NUM(p,s)`, `INT` and `LOGICAL` are checked; `CHAR`/`MEMO` stay unconstrained. The rules + live in `src/shared/cellValidation.ts` and run on both the client (instant feedback) and + the server (`grid-edit` is now validated authoritatively — previously it wrote straight + to SQLite with no check at all). (#45) +- `NUM(p,s)` is now a genuinely supported qualifier — the precision and scale are parsed, + recorded, and enforced on grid edits (`NUM(8,2)` accepts `123456.78`, rejects `1.234`). + Previously the scale silently corrupted the schema; see Fixed. The Assistant's **New table** + wizard accepts a width (`8`) or a precision,scale pair (`8,2`). (#45, #50) +- `LIST STRUCTURE` prints the **declared** type of every column (`CHAR(10)`, `NUM(8,2)`, + `DATE`, `TIME(15)`, `LOGICAL`, `INT`) rather than SQLite's storage class (`TEXT`/`REAL`/ + `INTEGER`). Declared types are recorded per `(database, table, column)` in + `server/ColumnMetaStore.ts`. (#45) +- `demos/overtime.prg` — an Overtime Tracker example app, and the showcase for this + release's engine work: `TIME(15)` columns validated per-cell as you type in `BROWSE`, + `WEEK()` for the ISO week number, and `DATEADD()` to walk a week's Monday through Friday. + Employees have their own weekly schedule, so standard hours are a real per-employee sum + rather than a flat 40; overtime is banked per week and drawn down as leave, with the + balance computed live from the source rows (no running-total field to drift when a week + is re-edited). Seeds a grouped report (`demos/reports/overtimebyemp.json`) and is + reachable from the splash screen, `HELP`, and the Assistant (Programs → Run Overtime + demo). (#46) + +### Fixed +- `CREATE TABLE t (price NUM(8,2))` silently created a **phantom column named `2`** of + type `)`: the parser read the precision, then treated the scale as the next column + definition. The shipped `PRODUCTS`, `DEALS` and `SALES` demo tables all carried this + stray column. `NUM(p,s)` now parses correctly. Tables created before this fix keep the + stray column until recreated. (#45) +- Column type metadata is now scoped per database. Two databases holding same-named + tables previously shared (and overwrote) one another's declared column types, so a + `TIME(15)` column in one database could be validated against another database's + `CHAR(20)` declaration of the same name. (#45) +- `CREATE TABLE` now **rejects a malformed column list** instead of silently inventing + columns from tokens it doesn't understand. `CREATE TABLE t (a CHAR(10) b INT)` (missing + comma), `(a)` (no type), `(a NUM(8,2,9))` and an unclosed paren all now raise a parse + error naming the offending column, and create nothing. This permissiveness was the root + cause of the phantom-column bug above. (#50) +- **Index metadata is now scoped per database.** `indexes`/`active_indexes` were keyed by + table name alone, so opening `PEOPLE` in one database silently activated an index defined + on a *different* database's `PEOPLE` — pointing the record order at a column that need not + even exist there, and breaking `BROWSE`/`LIST`. On first run, existing index definitions + are adopted into the one database that owns the table; definitions whose owner is ambiguous + (same table name in two databases) or missing are dropped and must be recreated with + `INDEX ON`. The underlying SQLite indexes are untouched. (#50) +- A bare `INPUT "prompt" TO ` typed at the REPL silently discarded the value: the + submitted form was only applied when a continuation existed, which is never the case for + a single statement. Values a form collects are now always stored. (#50) +- The `BROWSE` cell-validation message was invisible. Grid cells set `overflow: hidden` + (for the ellipsis on long values), which clipped the absolutely-positioned error tooltip + away entirely — an invalid edit showed a red border and no reason. The e2e tests missed + it because `toContainText`/`toBeVisible` do not account for clipping by an ancestor's + overflow; the assertions now use `toBeInViewport()`, which does. (#46) + +### Changed +- Removed the `input-request` / `input-response` WebSocket message types. They were declared + in the protocol but never sent or handled by anything — `INPUT` collects its value through + `form-open` / `form-submit`. (#50) +- New `npm run coverage` (vitest + v8, reporting only, no thresholds), so modules no test ever + executes stop hiding. (#50) +- Regenerated every `docs/screenshots/*.png` and the README `demo.gif` against v1.2.0, and + added `screenshot-grid-validation.png` showing `BROWSE` rejecting an off-quarter + `TIME(15)` edit. (#46) + +--- + ## [1.1.1] — 2026-07-05 — Docs refresh ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index c504659..841a324 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,8 @@ server/ SessionManager.ts Tracks all active sessions; broadcast() fans data-changed to peers viewing a mutated table ServerDatabaseBridge.ts IDatabaseBridge impl wrapping better-sqlite3 ProgramStore.ts .prg program storage in data/system.sqlite3 - IndexStore.ts Index metadata + active index in data/system.sqlite3 + IndexStore.ts Index metadata + active index per (db, table) in data/system.sqlite3 + ColumnMetaStore.ts Declared column types per (db, table, column) in data/system.sqlite3 — SQLite affinity can't distinguish TIME/DATE/CHAR, LOGICAL/INT, or recover NUM(p,s) ReportStore.ts Report definition storage in data/system.sqlite3 (reports table) ReportRunner.ts ASCII and HTML report rendering, group breaks, subtotals, grand totals DemoSeeder.ts Seeds demos/*.prg into the program store and demos/reports/*.json into the report store at startup (demos win) @@ -67,7 +68,7 @@ src/ Terminal.ts REPL UI — command history, multi-line block accumulation ui/ - Grid.ts BROWSE spreadsheet — inline cell editing, keyboard nav + Grid.ts BROWSE spreadsheet — inline cell editing with per-column type validation, keyboard nav FormLayout.ts @ SAY GET form engine — character-cell coordinates ProgramEditor.ts .prg source editor UI ReportPreview.ts iframe-based HTML report preview panel (Esc to close, Ctrl+P to print) @@ -80,7 +81,8 @@ src/ WsClient.ts Browser WebSocket client — sends commands, receives messages shared/ - types.ts Shared TS types (IDatabaseBridge, IIndexStore, WS message shapes) + types.ts Shared TS types (IDatabaseBridge, IIndexStore, IColumnMetaStore, WS message shapes) + cellValidation.ts Declared-type cell validation, shared by Grid.ts (inline UX) and Session (authoritative) main.ts Boot: connect WS → wire terminal/grid/form/editor @@ -91,8 +93,9 @@ data/ demos/ *.prg Demo programs — single source of truth; seeded into the program store on every server start (overwrites store copies). - crm.prg + INVENTORY.prg are usable example apps. + crm.prg, INVENTORY.prg + overtime.prg are usable example apps. reports/*.json Demo report definitions — seeded into the report store at startup + (dealsbystage, lowstock, overtimebyemp) .devcontainer/ devcontainer.json GitHub Codespaces config — auto npm install + npm run dev @@ -109,6 +112,14 @@ tests/ ServerDatabaseBridge.test.ts ProgramStore.test.ts AlterTable.test.ts ALTER TABLE + MODIFY STRUCTURE integration tests + TimeType.test.ts TIME / TIME(n) columns — creation, structure, write validation + ColumnMeta.test.ts NUM(p,s) parsing, declared types in LIST STRUCTURE, grid-open columnTypes, server-side grid-edit validation + ColumnMetaStore.test.ts Per-(db,table,column) type metadata + legacy-schema migration + CellValidation.test.ts Shared per-type cell validation rules + CreateTableParse.test.ts Strict CREATE TABLE grammar — malformed column lists must throw + DemoSchemas.test.ts Golden column lists for every table the demos create + GridMessages.test.ts grid-edit / grid-delete / grid-new-row / grid-refresh + INPUT form round-trip + IndexStoreMigration.test.ts Adopting pre-#50 unscoped index rows into their owning database Print.test.ts `?` / `??` print command Aggregate.test.ts `SUM` / `AVERAGE` Builtins.test.ts / BuiltinsParse.test.ts built-in functions (direct + through the parser) @@ -166,6 +177,38 @@ WebBase-III supports **unlimited work areas** (no DOS 10-area limit). Cross-area > Column ops that can invalidate an index (DROP, RENAME, ALTER type) drop all of the table's indexes and warn to rebuild with `INDEX ON`. +#### Column types + +`CREATE TABLE`/`ALTER TABLE ADD`/`ALTER TABLE ALTER` accept: + +| Type | Aliases | Storage | +|---|---|---| +| `CHAR(n)` | `CHARACTER`, `VARCHAR`, `STRING`, `MEMO` | `TEXT` | +| `NUM` | `NUMERIC`, `FLOAT`, `DOUBLE`, `DECIMAL` | `REAL` | +| `INT` | `INTEGER` | `INTEGER` | +| `LOGICAL` | `BOOLEAN` | `INTEGER` | +| `DATE` | | `TEXT` | +| `TIME` / `TIME(n)` | | `TEXT` | + +`TIME` stores `HH:MM` (24-hour). The optional `TIME(n)` qualifier (only via `CREATE TABLE` — not carried through `ALTER TABLE`) requires minutes to be a multiple of `n`, e.g. `TIME(15)` only accepts `:00`/`:15`/`:30`/`:45`. `APPEND RECORD` leaves new fields `NULL` (unvalidated); `REPLACE ... WITH` rejects a malformed or off-granularity `TIME` value with `** Error: ...` and does not write it. `LIST STRUCTURE` prints the declared type (`TIME`, `NUM(8,2)`, `TIME(15)`) rather than the raw SQLite storage class. + +Declared types are recorded per `(database, table, column)` in `server/ColumnMetaStore.ts`, because SQLite only keeps a storage affinity: `TIME`/`DATE`/`CHAR` are all `TEXT`, `LOGICAL`/`INT` are both `INTEGER`, and a `NUM(p,s)` qualifier is lost entirely. + +#### Cell validation (`BROWSE`) + +`src/shared/cellValidation.ts` holds the rules and runs on **both** sides: `Grid.ts` checks before commit (an invalid edit keeps the cell in edit mode, outlined red, with the reason shown; the error clears as the value becomes valid), and `Session`'s `grid-edit` handler re-checks authoritatively before writing — a WS message can reach the server without passing through the grid. + +| Type | Accepted | +|---|---| +| `DATE` | `YYYY-MM-DD`, a real calendar date (rejects `2023-02-29`) | +| `TIME` / `TIME(n)` | `HH:MM`; minutes a multiple of `n` when set | +| `NUM(p,s)` | numeric; ≤ `s` decimals, ≤ `p - s` integer digits | +| `INT` | a whole number | +| `LOGICAL` | `.T.`/`.F.`/`.TRUE.`/`.FALSE.`/`T`/`F`/`TRUE`/`FALSE`/`1`/`0` | +| `CHAR` / `MEMO` | anything (length is not enforced) | + +An empty value is always allowed (clears the cell). Columns with no recorded declared type are unconstrained. `REPLACE` enforces only `TIME` — widening it would change the semantics of existing programs. + ### Indexing & search | Command | What it does | |---|---| @@ -206,6 +249,26 @@ WebBase-III supports **unlimited work areas** (no DOS 10-area limit). Cross-area | `@ r,c SAY "text" GET ` | Define a form field | | `READ` | Display form and wait for submit | +### Built-in functions + +Implemented in `src/interpreter/Builtins.ts` (stateless) and `Executor.ts` (stateful: +`EOF()`, `BOF()`, `FOUND()`, `RECNO()`, `RECCOUNT()`). + +> **Adding a built-in:** implementing it in `Builtins.ts` is not enough — it must also be +> added to `BUILTIN_FUNCTIONS` in `src/interpreter/Parser.ts` or the parser rejects the +> call (`Unknown command: (`). This is how the #4 built-ins shipped broken. Always cover a +> new built-in in **both** `tests/Builtins.test.ts` (direct) and `tests/BuiltinsParse.test.ts` +> (through the parser), plus a Playwright case. + +Strings: `SUBSTR`, `LEN`, `TRIM`, `LTRIM`, `UPPER`, `LOWER`, `AT`, `STR`, `VAL`, `SPACE`, `REPLICATE`. +Numbers: `INT`, `ABS`, `ROUND`, `MOD`, `MAX`, `MIN`. +Dates/times: `DATE()`, `TIME()`, `DTOC`, `CTOD`, `YEAR`, `MONTH`, `DAY`, `WEEK`, `DATEADD`. + +| Function | What it returns | +|---|---| +| `WEEK(date)` | ISO-8601 week number (1–53). Monday-start weeks; week 1 is the week containing the year's first Thursday, so early-January dates can return 52/53 (belonging to the previous year's last week) and late-December dates can return 1. Accepts ISO `YYYY-MM-DD` or `MM/DD/YY`; invalid input → 0. | +| `DATEADD(date, n)` | The ISO date `n` days later (`n` may be negative), as `YYYY-MM-DD`. Computed in UTC, so month/year/leap-day boundaries are exact. Accepts ISO `YYYY-MM-DD` or `MM/DD/YY`; invalid or impossible input → `''`. | + ### Control flow | Command | What it does | |---|---| @@ -233,12 +296,12 @@ WebBase-III supports **unlimited work areas** (no DOS 10-area limit). Cross-area **v1.0.0 — dBASE III parity: complete ✅.** All sub-projects below shipped, plus the closing parity commands: `?`/`??` print (#2), `SUM`/`AVERAGE` (#3), the extra built-ins (#4), `SORT ON … TO` (#8), and `COPY TO`/`APPEND FROM` CSV (#5). -Beyond-parity work (e.g. live multiuser propagation, #11) lands on the `release/v1.1.0` -line. +Beyond-parity work lands on the milestone's own `release/vX.Y.Z` line. 1. ~~Indexing & Search~~ — `INDEX ON`, `SET INDEX TO`, `SEEK`, `FIND`, `REINDEX`, `LIST INDEXES` ✅ 2. ~~Language Completeness~~ — `DO CASE/ENDCASE`, built-in functions (`EOF()`, `BOF()`, `FOUND()`, `RECNO()`, `RECCOUNT()`, `SUBSTR()`, `STR()`, `AT()`, `UPPER()`, `LOWER()`, `ROUND()`, `MOD()`, `MAX()`, `MIN()`, `TIME()`, `YEAR()`, `MONTH()`, `DAY()`, and more) ✅ - `ROUND`/`MOD`/`MAX`/`MIN`/`TIME`/`YEAR`/`MONTH`/`DAY` contributed by [@kas2804](https://github.com/kas2804) in PR #17 (#4). 🙏 + - `WEEK()` (#44) and `DATEADD()` (#52) added in v1.2.0. 3. ~~Multi-Work-Area~~ — unlimited `SELECT `, `SET RELATION TO`, `alias.field` notation ✅ 4. ~~Report & Label Engine~~ — `REPORT FORM`, group breaks, subtotals, HTML preview ✅ 5. ~~The Assistant~~ — sidebar GUI, wizards, catalog protocol ✅ @@ -251,6 +314,19 @@ line. so other sessions BROWSE-ing that table refresh automatically (#11) ✅ - ~~JOIN to materialize a combined table~~ — `JOIN WITH TO FOR [FIELDS ]`, snapshot table via SQLite join (#10) ✅ +### Beyond parity (v1.2.0) + +- ~~`TIME` column type~~ — `TIME`/`TIME(n)` columns storing `HH:MM`, with a minute-granularity + qualifier validated on write; declared types tracked in `server/ColumnMetaStore.ts` (#43) ✅ +- ~~`WEEK()` built-in~~ — ISO-8601 week number (#44) ✅ +- ~~BROWSE per-cell validation~~ — grid rejects invalid edits per column type, validated on both client and server via `src/shared/cellValidation.ts` (#45) ✅ +- ~~Test hardening~~ — strict `CREATE TABLE` grammar, golden demo schemas, coverage for every + grid WS message, per-database index/column metadata scoping, `npm run coverage` (#50) ✅ +- ~~`demos/overtime.prg`~~ — overtime tracker: per-employee weekly schedules, `TIME(15)` + timesheets edited in the validated grid, `WEEK()`/`DATEADD()`, live overtime balance, + grouped report, CSV export (#46) ✅ +- ~~`DATEADD()` built-in~~ — day arithmetic; W3Script had none (#52) ✅ + ## Boolean literals Both styles accepted: `TRUE`/`FALSE` and `.T.`/`.TRUE.`/`.F.`/`.FALSE.` (dBASE III style). Output always uses `.T.`/`.F.`. Logical operators likewise: `NOT`/`.NOT.`, `AND`/`.AND.`, `OR`/`.OR.`. @@ -258,11 +334,42 @@ Both styles accepted: `TRUE`/`FALSE` and `.T.`/`.TRUE.`/`.F.`/`.FALSE.` (dBASE I ## Testing ```bash -npm test # Vitest unit + integration (265 tests) +npm test # Vitest unit + integration (379 tests) +npm run coverage # Vitest + v8 coverage report (reporting only, no thresholds) npx playwright test # E2E browser tests — requires dev server on :5173/:3000 ``` -Playwright suites (73 tests): `tests/integration.spec.ts` (20 tests — full REPL scenario), `tests/assistant.spec.ts` (20 tests — sidebar, wizards, report designer, MODIFY STRUCTURE round-trip, program run, CSV/SORT/SUM-AVERAGE/REINDEX/PACK actions, demo launchers), `tests/inventory.spec.ts` (8 tests — INVENTORY.prg menu + valuation/low-stock report/sort/CSV/JOIN), `tests/crm.spec.ts` (6 tests — CRM demo menu, pipeline summary, sort, report, CSV, JOIN), `tests/multiarea.spec.ts` (4 tests — multi-work-area, relations, alias.field), `tests/parity-commands.spec.ts` (4 tests — `?`/`??`, built-in functions, `SUM`/`AVERAGE`, `SORT ON … TO`), `tests/demos.spec.ts` (4 tests — demo program + report seeding), `tests/copycsv.spec.ts` (2 tests — COPY TO download + APPEND FROM upload), `tests/splash.spec.ts` (2 tests — version banner + demo discoverability), `tests/join.spec.ts` (1 test — JOIN materialization), `tests/propagation.spec.ts` (1 test — live multiuser refresh), `tests/program-side-effects.spec.ts` (1 test — CSV/report side-effects fire from inside a program block). +Playwright suites (94 tests): `tests/assistant.spec.ts` (23 tests — sidebar, wizards, report designer, MODIFY STRUCTURE round-trip, `TIME(15)` column + REPLACE validation, `NUM(p,s)` wizard, Browse-action grid validation, program run, CSV/SORT/SUM-AVERAGE/REINDEX/PACK actions, demo launchers), `tests/integration.spec.ts` (20 tests — full REPL scenario), `tests/overtime.spec.ts` (9 tests — overtime.prg: menu, seeding, DATEADD week prep, TIME(15) grid rejection, recalculation, live balance, quarter-hour leave check, report, CSV), `tests/inventory.spec.ts` (8 tests — INVENTORY.prg menu + valuation/low-stock report/sort/CSV/JOIN), `tests/crm.spec.ts` (6 tests — CRM demo menu, pipeline summary, sort, report, CSV, JOIN), `tests/parity-commands.spec.ts` (6 tests — `?`/`??`, built-in functions, `WEEK()`, `DATEADD()`, `SUM`/`AVERAGE`, `SORT ON … TO`), `tests/multiarea.spec.ts` (4 tests — multi-work-area, relations, alias.field), `tests/demos.spec.ts` (4 tests — demo program + report seeding), `tests/grid-validation.spec.ts` (3 tests — BROWSE per-cell validation: TIME(15), NUM(p,s)/DATE, Esc abandons), `tests/schema-errors.spec.ts` (3 tests — malformed CREATE TABLE errors, NUM(p,s) column count, bare INPUT stores its value), `tests/copycsv.spec.ts` (2 tests — COPY TO download + APPEND FROM upload), `tests/splash.spec.ts` (2 tests — version banner + demo discoverability), `tests/join.spec.ts` (1 test — JOIN materialization), `tests/propagation.spec.ts` (1 test — live multiuser refresh), `tests/program-side-effects.spec.ts` (1 test — CSV/report side-effects fire from inside a program block). + +## Test discipline + +Two bugs shipped through a 283-test suite (found in #45/#50). Both were structural blind +spots, not bad luck. When adding tests, remember what the existing ones cannot see: + +- **`toContain` can only prove presence, never absence.** Almost every assertion in this + repo greps rendered text for a substring, so a *phantom extra column* (`NUM(8,2)` used to + create a column literally named `2`) sailed through every `LIST`/`LIST STRUCTURE` check. + Assert **exact** structure — column lists, record counts — with `toEqual`/`toHaveLength` + wherever you can. `tests/DemoSchemas.test.ts` pins the demo tables for exactly this reason. +- **Test the surface, not the happy path through it.** Four of twelve `ClientMessage` types + had zero tests; `grid-edit` wrote straight to SQLite with no validation and nobody noticed, + because the grid tests only opened the grid and pressed Escape. Every WS message type + should have a test that drives it and asserts the database/UI effect + (`tests/GridMessages.test.ts`). +- **Green CI does not mean correct.** The cross-database `ColumnMetaStore` leak shipped with + seven passing tests, because they all used a single database. When state is keyed by name, + write the test that uses two. +- **Prefer failing loudly to guessing.** The parser used to absorb any token it didn't + understand and invent a column from it. `CREATE TABLE` is now strict; keep it that way. +- **`toContainText` / `toBeVisible` do not see clipping.** The BROWSE validation message was + present, styled `visible`, and clipped to nothing by the cell's `overflow: hidden` — users + saw a red border and no reason, while the tests passed. Assert `toBeInViewport()` (backed by + an IntersectionObserver, so it accounts for ancestor clipping) for anything that must be + *readable*, and look at a screenshot of a UI change before believing it works. + +Run `npm run coverage` when touching an area you suspect is untested. **Never run `npm test` +and `npx playwright test` concurrently** — both mutate `data/` and `data/system.sqlite3`, and +a state-dependent e2e test will fail for reasons that have nothing to do with your change. ## Definition of done diff --git a/README.md b/README.md index d0882e7..bc951ad 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,16 @@ Wizards open in the main area with a live W3Script preview. --- +### BROWSE — per-cell validation + +Grid edits are checked against the column's declared type before they commit. An invalid +value keeps the cell in edit mode, outlined in red, and says why; the error clears as soon +as you fix it, and `Esc` abandons the edit. Here a `TIME(15)` column rejects `08:07`. + +![BROWSE rejecting an invalid TIME(15) cell edit](docs/screenshots/screenshot-grid-validation.png) + +--- + ### Aggregate commands & dBASE III parity `SUM`, `AVERAGE`, `? ROUND(…)`, `? MAX(…)`, and `SORT ON … TO` — numeric aggregates and sorted copies, honouring the active filter. @@ -229,6 +239,11 @@ WebBase-III supports **unlimited work areas** — each independently holding a t > Column ops that can invalidate an index (DROP, RENAME, ALTER type) drop all of the table's indexes and warn you to rebuild with `INDEX ON`. +> `CREATE TABLE` rejects a malformed column list (missing comma, missing type, unclosed paren, a third +> type argument) with a parse error naming the offending column, and creates nothing. + +**Column types**: `CHAR(n)` (aliases `CHARACTER`/`VARCHAR`/`STRING`/`MEMO`), `NUM`/`NUM(p,s)` (`NUMERIC`/`FLOAT`/`DOUBLE`/`DECIMAL`), `INT`/`INTEGER`, `LOGICAL`/`BOOLEAN`, `DATE`, and `TIME`/`TIME(n)`. `TIME` stores `HH:MM` (24-hour); the optional `TIME(n)` qualifier (e.g. `TIME(15)`) requires minutes to be a multiple of `n`. `REPLACE ... WITH` rejects a malformed or off-granularity `TIME` value instead of silently coercing it, and `LIST STRUCTURE` prints the declared type (`NUM(8,2)`, `TIME(15)`) rather than SQLite's storage class. + > **CSV format (`COPY TO` / `APPEND FROM`):** Unlike dBASE III's headerless, > positional `DELIMITED`/`SDF` formats, WebBase-III uses modern **header-based CSV** > (RFC-4180, mapped by column name). Export downloads through the browser and @@ -283,14 +298,17 @@ WebBase-III supports **unlimited work areas** — each independently holding a t > seeded into the program store on every server start, overwriting any store copy. > Matching report definitions live in `demos/reports/*.json` and are seeded the same way. > -> Two of them are **usable example apps** you can build off — run them, then `EDIT` to adapt: +> Three of them are **usable example apps** you can build off — run them, then `EDIT` to adapt: > - **`DO crm`** — a mini-CRM: companies, contacts, and deals with pipeline totals > (`SUM … FOR`), top-deals sort, a grouped report, CSV export, and a companies+deals JOIN. > - **`DO inventory`** — a stock manager: categories, products (with reorder levels), and a > stock-movements ledger, plus valuation totals, a low-stock report, sort, CSV export, and > a products+categories JOIN. +> - **`DO overtime`** — an overtime tracker: per-employee weekly schedules, `TIME(15)` +> timesheets you edit in the validated grid, `WEEK()` / `DATEADD()` week handling, a live +> overtime balance (banked minus leave taken), a grouped report, and CSV export. > -> Both lean on multi-work-area relations (`alias.field`), indexes, and `@ SAY … GET`/`READ` +> They lean on multi-work-area relations (`alias.field`), indexes, and `@ SAY … GET`/`READ` > forms, and invite you to open a second window to watch live multiuser propagation. ### Variables & I/O @@ -348,6 +366,8 @@ Functions work anywhere an expression is accepted — `IF`, `DO WHILE`, `STORE`, | `YEAR(date)` | Numeric year from ISO date string | | `MONTH(date)` | Numeric month from ISO date string | | `DAY(date)` | Numeric day from ISO date string | +| `WEEK(date)` | ISO-8601 week number (1–53) — Monday-start weeks, week 1 holds the year's first Thursday | +| `DATEADD(date, n)` | ISO date `n` days later (`n` may be negative); `''` for an invalid date | ### Boolean literals @@ -377,6 +397,8 @@ Logical operators are accepted in both styles too: `NOT` / `.NOT.`, `AND` / `.AN | F5 | Refresh from DB | | Esc | Exit grid, return to terminal | +> **Cell validation.** An edit is checked against the column's declared type before it commits. An invalid value keeps the cell in edit mode, outlined in red, with the reason shown (`HH:MM`, `multiple of 15`, `at most 2 decimal place(s)`, `not a real date`); the error clears as soon as you fix it, and `Esc` abandons the edit. `DATE`, `TIME`/`TIME(n)`, `NUM(p,s)`, `INT` and `LOGICAL` are validated; `CHAR`/`MEMO` are not. The server re-checks every edit independently. + --- ## Architecture diff --git a/demos/overtime.prg b/demos/overtime.prg new file mode 100644 index 0000000..237146b --- /dev/null +++ b/demos/overtime.prg @@ -0,0 +1,458 @@ +* ============================================================ +* overtime.prg — WebBase-III Overtime Tracker (usable example app) +* +* Employees have a standard weekly shift schedule. Actual hours are +* entered per day, overtime is banked, and later taken as leave. +* +* Five linked tables: EMPLOYEES, SCHEDULEDAYS, TIMESHEET, +* WEEKSUMMARY, LEAVETAKEN. +* +* Shows off the v1.2.0 engine features: +* TIME(15) columns — quarter-hour times, validated on write and +* guarded per-cell as you type in BROWSE +* WEEK(date) — ISO-8601 week number +* DATEADD(date, n) — day arithmetic (Monday + 0..4 = Mon..Fri) +* plus multi-work-area + SET RELATION, SEEK, REPORT FORM, COPY TO csv. +* +* COPY THIS FILE (or EDIT overtime) to build your own tracker. +* ============================================================ + +* Start from a clean slate so work areas/relations left open by another +* program (or a previous run) in this session can't leak in. +CLOSE ALL + +USE DATABASE OVERTIME + +SELECT EMP +USE DATABASE OVERTIME +USE EMPLOYEES + +SELECT SCH +USE DATABASE OVERTIME +USE SCHEDULEDAYS + +SELECT TS +USE DATABASE OVERTIME +USE TIMESHEET + +SELECT WS +USE DATABASE OVERTIME +USE WEEKSUMMARY + +SELECT LV +USE DATABASE OVERTIME +USE LEAVETAKEN + +* ── First-run seeding ─────────────────────────────────────── +* Two employees on different schedules, so "standard hours" is a real +* per-employee sum and not a flat 40. +* +* The gate is EMPLOYEES, which the seed always fills. Gating on a table +* that legitimately stays empty (LEAVETAKEN, say) would re-seed — and so +* wipe TIMESHEET and WEEKSUMMARY — on every run until someone used it. +SELECT EMP +IF RECCOUNT() == 0 + DROP TABLE EMPLOYEES + CREATE TABLE EMPLOYEES (EMPID CHAR(4), NAME CHAR(30), SCHEDID CHAR(4)) + INDEX ON EMPID TO BYEMP + APPEND RECORD + REPLACE EMPID WITH "E001", NAME WITH "Ada Lovelace", SCHEDID WITH "S001" + APPEND RECORD + REPLACE EMPID WITH "E002", NAME WITH "Grace Hopper", SCHEDID WITH "S002" + + SELECT SCH + DROP TABLE SCHEDULEDAYS + CREATE TABLE SCHEDULEDAYS (SCHEDID CHAR(4), DOW NUM(1), TIMEIN TIME(15), BSTART TIME(15), BEND TIME(15), TIMEOUT TIME(15)) + INDEX ON SCHEDID TO BYSCHED + * S001 — 08:00-16:30 with a 30-minute break = 8.00 h/day, 40.00 h/week + STORE 1 TO d + DO WHILE d <= 5 + APPEND RECORD + REPLACE SCHEDID WITH "S001", DOW WITH d, TIMEIN WITH "08:00", BSTART WITH "12:00", BEND WITH "12:30", TIMEOUT WITH "16:30" + STORE d + 1 TO d + ENDDO + * S002 — 09:00-16:00 with a 45-minute break = 6.25 h/day, 31.25 h/week + STORE 1 TO d + DO WHILE d <= 5 + APPEND RECORD + REPLACE SCHEDID WITH "S002", DOW WITH d, TIMEIN WITH "09:00", BSTART WITH "12:00", BEND WITH "12:45", TIMEOUT WITH "16:00" + STORE d + 1 TO d + ENDDO + + SELECT TS + DROP TABLE TIMESHEET + CREATE TABLE TIMESHEET (EMPID CHAR(4), WEEKDATE DATE, DOW NUM(1), WORKDATE DATE, TIMEIN TIME(15), BSTART TIME(15), BEND TIME(15), TIMEOUT TIME(15), WORKEDHOURS NUM(6,2)) + INDEX ON EMPID TO TSEMP + + SELECT WS + DROP TABLE WEEKSUMMARY + CREATE TABLE WEEKSUMMARY (EMPID CHAR(4), WEEKDATE DATE, WEEKNO NUM(2), WORKEDHOURS NUM(6,2), STANDARDHOURS NUM(6,2), OVERTIME NUM(6,2)) + INDEX ON EMPID TO WSEMP + + SELECT LV + DROP TABLE LEAVETAKEN + CREATE TABLE LEAVETAKEN (EMPID CHAR(4), LDATE DATE, HOURS NUM(6,2), NOTES CHAR(40)) + INDEX ON EMPID TO LVEMP +ENDIF + +* ── Activate indexes + relations ───────────────────────────── +SELECT EMP +SET INDEX TO BYEMP + +SELECT TS +SET INDEX TO TSEMP +SET RELATION TO EMPID INTO EMP + +SELECT WS +SET INDEX TO WSEMP +SET RELATION TO EMPID INTO EMP + +SELECT LV +SET INDEX TO LVEMP +SET RELATION TO EMPID INTO EMP + +SELECT EMP + +* ── Main menu ──────────────────────────────────────────────── +STORE .T. TO running +DO WHILE running + CLEAR + @ 1, 5 SAY "================================================" + @ 2, 10 SAY " WEBBASE-III OVERTIME TRACKER (example app)" + @ 3, 5 SAY "================================================" + @ 5, 10 SAY "1. Add Employee" + @ 6, 10 SAY "2. Edit Standard Weekly Schedule (BROWSE)" + @ 7, 10 SAY "3. Open/Prep Week (creates + BROWSEs timesheet)" + @ 8, 10 SAY "4. Recalculate Week" + @ 9, 10 SAY "5. Register Leave Taken" + @ 10, 10 SAY "6. Overtime Balance" + @ 11, 10 SAY "7. Overtime Report" + @ 12, 10 SAY "8. Export Week Summary to CSV" + @ 13, 10 SAY "9. Browse Tables (read-only tour)" + @ 14, 10 SAY "Q. Quit" + @ 15, 5 SAY "================================================" + STORE " " TO choice + @ 16, 10 SAY "Enter choice: " GET choice + READ + + DO CASE + CASE UPPER(TRIM(choice)) == "1" + CLEAR + @ 2, 5 SAY "--- ADD EMPLOYEE ---" + STORE SPACE(4) TO m_emp + STORE SPACE(30) TO m_name + STORE SPACE(4) TO m_sch + @ 4, 5 SAY "Employee ID (4): " GET m_emp + @ 5, 5 SAY "Name (30): " GET m_name + @ 6, 5 SAY "Schedule ID (4): " GET m_sch + READ + SELECT EMP + SET INDEX TO BYEMP + SEEK TRIM(m_emp) + IF FOUND() + @ 8, 5 SAY "Employee already exists: " + TRIM(m_emp) + ELSE + APPEND RECORD + REPLACE EMPID WITH TRIM(m_emp), NAME WITH TRIM(m_name), SCHEDID WITH TRIM(m_sch) + @ 8, 5 SAY "Employee added: " + TRIM(m_emp) + ENDIF + INPUT "Press Enter to continue" TO pause + SELECT EMP + + CASE UPPER(TRIM(choice)) == "2" + CLEAR + @ 2, 5 SAY "--- STANDARD WEEKLY SCHEDULE ---" + @ 4, 5 SAY "All four time columns are TIME(15): the grid rejects" + @ 5, 5 SAY "anything that is not HH:MM on a quarter hour." + @ 7, 5 SAY "Try typing 08:07 into a time cell and press Enter." + INPUT "Press Enter to open the grid" TO pause + SELECT SCH + USE SCHEDULEDAYS + BROWSE + + CASE UPPER(TRIM(choice)) == "3" + CLEAR + @ 2, 5 SAY "--- OPEN / PREP WEEK ---" + STORE SPACE(4) TO m_emp + STORE "2026-07-06" TO m_week + @ 4, 5 SAY "Employee ID (4): " GET m_emp + @ 5, 5 SAY "Week Monday (YYYY-MM-DD): " GET m_week + READ + STORE TRIM(m_emp) TO m_emp + STORE TRIM(m_week) TO m_week + SELECT EMP + SET INDEX TO BYEMP + SEEK m_emp + IF FOUND() + STORE EMP.SCHEDID TO m_sch + STORE WEEK(m_week) TO m_wk + CLEAR + @ 2, 5 SAY "Employee : " + m_emp + " schedule " + TRIM(m_sch) + @ 3, 5 SAY "Week of : " + m_week + " ISO week " + STR(m_wk, 3) + + * Does this week already exist for this employee? + SELECT TS + USE TIMESHEET + STORE 0 TO m_have + GO TOP + DO WHILE .NOT. EOF() + IF EMPID == m_emp .AND. WEEKDATE == m_week + STORE m_have + 1 TO m_have + ENDIF + SKIP + ENDDO + + IF m_have == 0 + * Pre-fill Mon..Fri from the employee's standard schedule. + STORE 1 TO d + DO WHILE d <= 5 + * Read the schedule row for this day into memory variables. + STORE "" TO s_in + SELECT SCH + USE SCHEDULEDAYS + GO TOP + DO WHILE .NOT. EOF() + IF SCHEDID == m_sch .AND. DOW == d + STORE TIMEIN TO s_in + STORE BSTART TO s_bs + STORE BEND TO s_be + STORE TIMEOUT TO s_out + ENDIF + SKIP + ENDDO + IF .NOT. s_in == "" + SELECT TS + USE TIMESHEET + APPEND RECORD + REPLACE EMPID WITH m_emp, WEEKDATE WITH m_week, DOW WITH d, WORKDATE WITH DATEADD(m_week, d - 1) + REPLACE TIMEIN WITH s_in, BSTART WITH s_bs, BEND WITH s_be, TIMEOUT WITH s_out, WORKEDHOURS WITH 0 + ENDIF + STORE d + 1 TO d + ENDDO + @ 5, 5 SAY "Created 5 timesheet days, pre-filled from the schedule." + ELSE + @ 5, 5 SAY "Week already open: " + STR(m_have, 2) + " day(s) on file." + ENDIF + @ 7, 5 SAY "The grid guards the TIME(15) columns as you type." + @ 8, 5 SAY "(The grid shows every week on file, newest last.)" + INPUT "Press Enter to edit this week" TO pause + SELECT TS + USE TIMESHEET + BROWSE + ELSE + @ 7, 5 SAY "Employee not found: " + m_emp + INPUT "Press Enter to continue" TO pause + ENDIF + SELECT EMP + + CASE UPPER(TRIM(choice)) == "4" + CLEAR + @ 2, 5 SAY "--- RECALCULATE WEEK ---" + STORE SPACE(4) TO m_emp + STORE "2026-07-06" TO m_week + @ 4, 5 SAY "Employee ID (4): " GET m_emp + @ 5, 5 SAY "Week Monday (YYYY-MM-DD): " GET m_week + READ + STORE TRIM(m_emp) TO m_emp + STORE TRIM(m_week) TO m_week + + * Each day: worked = (TIMEOUT - TIMEIN) - (BEND - BSTART), in hours. + * Times are HH:MM text, so convert to minutes first. + SELECT TS + USE TIMESHEET + SET FILTER TO + STORE 0 TO m_worked + GO TOP + DO WHILE .NOT. EOF() + IF EMPID == m_emp .AND. WEEKDATE == m_week + STORE VAL(SUBSTR(TIMEOUT,1,2)) * 60 + VAL(SUBSTR(TIMEOUT,4,2)) TO t_out + STORE VAL(SUBSTR(TIMEIN,1,2)) * 60 + VAL(SUBSTR(TIMEIN,4,2)) TO t_in + STORE VAL(SUBSTR(BEND,1,2)) * 60 + VAL(SUBSTR(BEND,4,2)) TO t_be + STORE VAL(SUBSTR(BSTART,1,2)) * 60 + VAL(SUBSTR(BSTART,4,2)) TO t_bs + STORE ROUND(((t_out - t_in) - (t_be - t_bs)) / 60, 2) TO d_hours + REPLACE WORKEDHOURS WITH d_hours + STORE m_worked + d_hours TO m_worked + ENDIF + SKIP + ENDDO + + * Standard hours = the sum of this employee's own schedule days. + SELECT EMP + SET INDEX TO BYEMP + SEEK m_emp + STORE EMP.SCHEDID TO m_sch + SELECT SCH + USE SCHEDULEDAYS + STORE 0 TO m_std + GO TOP + DO WHILE .NOT. EOF() + IF SCHEDID == m_sch + STORE VAL(SUBSTR(TIMEOUT,1,2)) * 60 + VAL(SUBSTR(TIMEOUT,4,2)) TO t_out + STORE VAL(SUBSTR(TIMEIN,1,2)) * 60 + VAL(SUBSTR(TIMEIN,4,2)) TO t_in + STORE VAL(SUBSTR(BEND,1,2)) * 60 + VAL(SUBSTR(BEND,4,2)) TO t_be + STORE VAL(SUBSTR(BSTART,1,2)) * 60 + VAL(SUBSTR(BSTART,4,2)) TO t_bs + STORE m_std + ROUND(((t_out - t_in) - (t_be - t_bs)) / 60, 2) TO m_std + ENDIF + SKIP + ENDDO + + STORE ROUND(m_worked - m_std, 2) TO m_ot + STORE WEEK(m_week) TO m_wk + + * Upsert the week's summary row — never keep a running balance. + SELECT WS + USE WEEKSUMMARY + STORE .F. TO seen + GO TOP + DO WHILE .NOT. EOF() + IF EMPID == m_emp .AND. WEEKDATE == m_week + STORE .T. TO seen + REPLACE WEEKNO WITH m_wk, WORKEDHOURS WITH m_worked, STANDARDHOURS WITH m_std, OVERTIME WITH m_ot + ENDIF + SKIP + ENDDO + IF .NOT. seen + APPEND RECORD + REPLACE EMPID WITH m_emp, WEEKDATE WITH m_week, WEEKNO WITH m_wk + REPLACE WORKEDHOURS WITH m_worked, STANDARDHOURS WITH m_std, OVERTIME WITH m_ot + ENDIF + + CLEAR + @ 2, 5 SAY "--- WEEK RECALCULATED ---" + @ 4, 5 SAY "Employee : " + m_emp + @ 5, 5 SAY "Week of : " + m_week + " ISO week " + STR(m_wk, 3) + @ 6, 5 SAY "Worked hours : " + STR(m_worked, 8, 2) + @ 7, 5 SAY "Standard hours: " + STR(m_std, 8, 2) + @ 8, 5 SAY "Overtime : " + STR(m_ot, 8, 2) + INPUT "Press Enter to continue" TO pause + SELECT EMP + + CASE UPPER(TRIM(choice)) == "5" + CLEAR + @ 2, 5 SAY "--- REGISTER LEAVE TAKEN ---" + @ 3, 5 SAY "Hours must be a quarter-hour step (0.25 / 0.5 / 0.75 / 1.0 ...)" + STORE SPACE(4) TO m_emp + STORE "2026-07-13" TO m_date + STORE 0.00 TO m_hrs + STORE SPACE(40) TO m_note + @ 5, 5 SAY "Employee ID (4): " GET m_emp + @ 6, 5 SAY "Date (YYYY-MM-DD): " GET m_date + @ 7, 5 SAY "Hours (num): " GET m_hrs + @ 8, 5 SAY "Notes (40): " GET m_note + READ + STORE TRIM(m_emp) TO m_emp + * Quarter-hour check: hours * 4 must be a whole number. + STORE m_hrs * 4 TO m_q + IF .NOT. m_q == INT(m_q) + @ 10, 5 SAY "Rejected: " + STR(m_hrs, 8, 2) + " is not a quarter-hour step." + ELSE + IF m_hrs <= 0 + @ 10, 5 SAY "Rejected: hours must be greater than zero." + ELSE + SELECT EMP + SET INDEX TO BYEMP + SEEK m_emp + IF FOUND() + SELECT LV + USE LEAVETAKEN + APPEND RECORD + REPLACE EMPID WITH m_emp, LDATE WITH TRIM(m_date), HOURS WITH m_hrs, NOTES WITH TRIM(m_note) + @ 10, 5 SAY "Leave registered: " + STR(m_hrs, 6, 2) + " h for " + m_emp + ELSE + @ 10, 5 SAY "Employee not found: " + m_emp + ENDIF + ENDIF + ENDIF + INPUT "Press Enter to continue" TO pause + SELECT EMP + + CASE UPPER(TRIM(choice)) == "6" + CLEAR + @ 2, 5 SAY "--- OVERTIME BALANCE ---" + STORE SPACE(4) TO m_emp + @ 4, 5 SAY "Employee ID (4): " GET m_emp + READ + STORE TRIM(m_emp) TO m_emp + + * Computed live from the source rows — no stored balance field to + * drift when a week is re-edited. (SUM ... FOR is spliced into SQL + * and cannot see a memory variable, so accumulate in a loop.) + SELECT WS + USE WEEKSUMMARY + STORE 0 TO m_banked + GO TOP + DO WHILE .NOT. EOF() + IF EMPID == m_emp + STORE m_banked + OVERTIME TO m_banked + ENDIF + SKIP + ENDDO + + SELECT LV + USE LEAVETAKEN + STORE 0 TO m_taken + GO TOP + DO WHILE .NOT. EOF() + IF EMPID == m_emp + STORE m_taken + HOURS TO m_taken + ENDIF + SKIP + ENDDO + + STORE ROUND(m_banked - m_taken, 2) TO m_bal + CLEAR + @ 2, 5 SAY "--- OVERTIME BALANCE ---" + @ 4, 5 SAY "Employee : " + m_emp + @ 5, 5 SAY "Overtime banked : " + STR(m_banked, 8, 2) + @ 6, 5 SAY "Leave taken : " + STR(m_taken, 8, 2) + @ 7, 5 SAY "Balance : " + STR(m_bal, 8, 2) + INPUT "Press Enter to continue" TO pause + SELECT EMP + + CASE UPPER(TRIM(choice)) == "7" + CLEAR + SELECT WS + USE WEEKSUMMARY + REPORT FORM overtimebyemp + INPUT "Press Enter to continue" TO pause + SET INDEX TO WSEMP + SET RELATION TO EMPID INTO EMP + SELECT EMP + + CASE UPPER(TRIM(choice)) == "8" + CLEAR + SELECT WS + USE WEEKSUMMARY + COPY TO weeksummary.csv + @ 2, 5 SAY "Week summary exported to weeksummary.csv (check your downloads)." + INPUT "Press Enter to continue" TO pause + SET INDEX TO WSEMP + SET RELATION TO EMPID INTO EMP + SELECT EMP + + CASE UPPER(TRIM(choice)) == "9" + CLEAR + @ 2, 5 SAY "--- TABLE TOUR ---" + SELECT EMP + USE EMPLOYEES + LIST + SELECT SCH + USE SCHEDULEDAYS + LIST + SELECT WS + USE WEEKSUMMARY + LIST + SELECT LV + USE LEAVETAKEN + LIST + INPUT "Press Enter to continue (output is on the terminal)" TO pause + SELECT EMP + + CASE UPPER(TRIM(choice)) == "Q" + STORE .F. TO running + + ENDCASE +ENDDO +CLEAR +@ 2, 5 SAY "Overtime demo closed. Type DO overtime to run it again," +@ 3, 5 SAY "or EDIT overtime to customize it." diff --git a/demos/reports/overtimebyemp.json b/demos/reports/overtimebyemp.json new file mode 100644 index 0000000..28cd753 --- /dev/null +++ b/demos/reports/overtimebyemp.json @@ -0,0 +1,14 @@ +{ + "title": "Overtime by Employee", + "pageWidth": 80, + "columns": [ + { "field": "WEEKDATE", "heading": "Week of", "width": 12 }, + { "field": "WEEKNO", "heading": "Wk", "width": 5 }, + { "field": "WORKEDHOURS", "heading": "Worked", "width": 10, "total": true }, + { "field": "STANDARDHOURS", "heading": "Standard", "width": 10, "total": true }, + { "field": "OVERTIME", "heading": "Overtime", "width": 10, "total": true } + ], + "groupBy": "EMPID", + "pageHeader": "WebBase-III Overtime — Weekly Summary", + "pageFooter": "Page {PAGE}" +} diff --git a/docs/screenshots/demo.gif b/docs/screenshots/demo.gif index 94c7c75..7f0dd96 100644 Binary files a/docs/screenshots/demo.gif and b/docs/screenshots/demo.gif differ diff --git a/docs/screenshots/screenshot-assistant-wizard.png b/docs/screenshots/screenshot-assistant-wizard.png index 50bf4d6..0edd42e 100644 Binary files a/docs/screenshots/screenshot-assistant-wizard.png and b/docs/screenshots/screenshot-assistant-wizard.png differ diff --git a/docs/screenshots/screenshot-assistant.png b/docs/screenshots/screenshot-assistant.png index 2569418..52d8c26 100644 Binary files a/docs/screenshots/screenshot-assistant.png and b/docs/screenshots/screenshot-assistant.png differ diff --git a/docs/screenshots/screenshot-csv.png b/docs/screenshots/screenshot-csv.png index c6baf23..25fa95e 100644 Binary files a/docs/screenshots/screenshot-csv.png and b/docs/screenshots/screenshot-csv.png differ diff --git a/docs/screenshots/screenshot-grid-validation.png b/docs/screenshots/screenshot-grid-validation.png new file mode 100644 index 0000000..11af91e Binary files /dev/null and b/docs/screenshots/screenshot-grid-validation.png differ diff --git a/docs/screenshots/screenshot-index.png b/docs/screenshots/screenshot-index.png index 8409764..8884642 100644 Binary files a/docs/screenshots/screenshot-index.png and b/docs/screenshots/screenshot-index.png differ diff --git a/docs/screenshots/screenshot-list.png b/docs/screenshots/screenshot-list.png index 6354e2d..c635a5b 100644 Binary files a/docs/screenshots/screenshot-list.png and b/docs/screenshots/screenshot-list.png differ diff --git a/docs/screenshots/screenshot-parity.png b/docs/screenshots/screenshot-parity.png index 334d077..4ade517 100644 Binary files a/docs/screenshots/screenshot-parity.png and b/docs/screenshots/screenshot-parity.png differ diff --git a/docs/screenshots/screenshot-repl-session.png b/docs/screenshots/screenshot-repl-session.png index 5a4f4c1..f8bb091 100644 Binary files a/docs/screenshots/screenshot-repl-session.png and b/docs/screenshots/screenshot-repl-session.png differ diff --git a/docs/screenshots/screenshot-terminal.png b/docs/screenshots/screenshot-terminal.png index be3e18f..124140a 100644 Binary files a/docs/screenshots/screenshot-terminal.png and b/docs/screenshots/screenshot-terminal.png differ diff --git a/package-lock.json b/package-lock.json index b32584e..7688817 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "webbase-iii", - "version": "0.6.1", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "webbase-iii", - "version": "0.6.1", + "version": "1.2.0", "dependencies": { "better-sqlite3": "^12.10.0", "ws": "^8.21.0" @@ -16,6 +16,7 @@ "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.9.2", "@types/ws": "^8.18.1", + "@vitest/coverage-v8": "^4.1.10", "concurrently": "^10.0.3", "tsx": "^4.22.4", "typescript": "^5.4.5", @@ -23,22 +24,82 @@ "vitest": "^4.1.8" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "dev": true, "license": "MIT", "optional": true, @@ -47,9 +108,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -499,6 +560,16 @@ "node": ">=12" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -506,15 +577,26 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.3" }, "funding": { "type": "github", @@ -526,9 +608,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", "dev": true, "license": "MIT", "funding": { @@ -552,9 +634,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", "cpu": [ "arm64" ], @@ -569,9 +651,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", - "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", "cpu": [ "arm64" ], @@ -586,9 +668,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", "cpu": [ "x64" ], @@ -603,9 +685,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", "cpu": [ "x64" ], @@ -620,9 +702,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", "cpu": [ "arm" ], @@ -637,9 +719,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", "cpu": [ "arm64" ], @@ -654,9 +736,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", "cpu": [ "arm64" ], @@ -671,9 +753,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", "cpu": [ "ppc64" ], @@ -688,9 +770,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", "cpu": [ "s390x" ], @@ -705,9 +787,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", "cpu": [ "x64" ], @@ -722,9 +804,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", "cpu": [ "x64" ], @@ -739,9 +821,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", "cpu": [ "arm64" ], @@ -756,9 +838,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", "cpu": [ "wasm32" ], @@ -766,18 +848,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" }, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", "cpu": [ "arm64" ], @@ -792,9 +874,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", "cpu": [ "x64" ], @@ -1173,9 +1255,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -1238,17 +1320,48 @@ "@types/node": "*" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", - "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1257,9 +1370,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", - "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1270,13 +1383,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", - "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.8", + "@vitest/utils": "4.1.10", "pathe": "^2.0.3" }, "funding": { @@ -1284,14 +1397,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", - "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1300,9 +1413,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", - "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", "dev": true, "license": "MIT", "funding": { @@ -1310,13 +1423,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", - "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.8", + "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -1360,6 +1473,18 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", + "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -1722,6 +1847,23 @@ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", "license": "MIT" }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -1754,6 +1896,65 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -2025,6 +2226,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", @@ -2127,9 +2356,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -2187,9 +2416,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "dev": true, "funding": [ { @@ -2282,13 +2511,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", - "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.133.0", + "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -2298,21 +2527,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.3", - "@rolldown/binding-darwin-arm64": "1.0.3", - "@rolldown/binding-darwin-x64": "1.0.3", - "@rolldown/binding-freebsd-x64": "1.0.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.3", - "@rolldown/binding-linux-arm64-musl": "1.0.3", - "@rolldown/binding-linux-ppc64-gnu": "1.0.3", - "@rolldown/binding-linux-s390x-gnu": "1.0.3", - "@rolldown/binding-linux-x64-gnu": "1.0.3", - "@rolldown/binding-linux-x64-musl": "1.0.3", - "@rolldown/binding-openharmony-arm64": "1.0.3", - "@rolldown/binding-wasm32-wasi": "1.0.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, "node_modules/rollup": { @@ -3197,19 +3426,19 @@ } }, "node_modules/vitest": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", - "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.8", - "@vitest/mocker": "4.1.8", - "@vitest/pretty-format": "4.1.8", - "@vitest/runner": "4.1.8", - "@vitest/snapshot": "4.1.8", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -3237,12 +3466,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.8", - "@vitest/browser-preview": "4.1.8", - "@vitest/browser-webdriverio": "4.1.8", - "@vitest/coverage-istanbul": "4.1.8", - "@vitest/coverage-v8": "4.1.8", - "@vitest/ui": "4.1.8", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -3287,9 +3516,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -3305,9 +3534,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -3323,9 +3552,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -3341,9 +3570,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -3359,9 +3588,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -3377,9 +3606,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -3395,9 +3624,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -3413,9 +3642,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -3431,9 +3660,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -3449,9 +3678,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -3467,9 +3696,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -3485,9 +3714,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -3503,9 +3732,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -3521,9 +3750,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -3539,9 +3768,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -3557,9 +3786,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -3575,9 +3804,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -3592,10 +3821,28 @@ "node": ">=18" } }, + "node_modules/vitest/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -3610,10 +3857,28 @@ "node": ">=18" } }, + "node_modules/vitest/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -3628,10 +3893,28 @@ "node": ">=18" } }, + "node_modules/vitest/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/vitest/node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -3647,9 +3930,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -3665,9 +3948,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -3683,9 +3966,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -3701,13 +3984,13 @@ } }, "node_modules/vitest/node_modules/@vitest/mocker": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", - "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.8", + "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -3728,9 +4011,9 @@ } }, "node_modules/vitest/node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -3743,45 +4026,45 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/vitest/node_modules/vite": { - "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.3", + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", "tinyglobby": "^0.2.17" }, "bin": { @@ -3798,7 +4081,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", diff --git a/package.json b/package.json index 9b80088..8f2a160 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "webbase-iii", - "version": "1.1.1", + "version": "1.2.0", "description": "dBASE III is back. In your browser. USE customers like it's 1984.", "private": true, "type": "module", @@ -9,13 +9,15 @@ "build": "tsc --noEmit && vite build", "serve": "npm run build && tsx server/index.ts", "test": "vitest run --config vitest.config.ts", - "clean:data": "node scripts/clean-data.mjs" + "clean:data": "node scripts/clean-data.mjs", + "coverage": "vitest run --config vitest.config.ts --coverage" }, "devDependencies": { "@playwright/test": "^1.60.0", "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.9.2", "@types/ws": "^8.18.1", + "@vitest/coverage-v8": "^4.1.10", "concurrently": "^10.0.3", "tsx": "^4.22.4", "typescript": "^5.4.5", diff --git a/scripts/capture-screenshots.mjs b/scripts/capture-screenshots.mjs index 4ba2023..875809b 100644 --- a/scripts/capture-screenshots.mjs +++ b/scripts/capture-screenshots.mjs @@ -270,5 +270,30 @@ console.log('13. NEW: COPY TO CSV output'); await page.close(); } +// ── 14. screenshot-grid-validation.png (NEW in v1.2.0) ──────────────────── +console.log('14. NEW: BROWSE per-cell validation rejecting a TIME(15) edit'); +{ + const page = await newPage(); + await boot(page); + await cmd(page, 'USE DATABASE screenshotdb', 800); + await cmd(page, 'DROP TABLE shifts', 400); + await cmd(page, 'CREATE TABLE shifts (PERSON CHAR(20), STARTTIME TIME(15), DUE DATE)', 700); + await cmd(page, 'USE shifts', 500); + await cmd(page, 'APPEND RECORD', 500); + await cmd(page, 'REPLACE PERSON WITH "Ada Lovelace", STARTTIME WITH "08:15"', 700); + await cmd(page, 'BROWSE', 1200); + await page.waitForSelector('#grid-view:not(.hidden)', { timeout: 8000 }); + + // Type an off-quarter time into the TIME(15) column and let the grid reject it. + const td = page.locator('#grid-tbody td[data-ri="0"][data-ci="1"]'); + await td.dblclick(); + await td.locator('input.cell-ed').fill('08:07'); + await page.keyboard.press('Enter'); + await page.waitForSelector('#grid-tbody td.cell-invalid .cell-error', { timeout: 5000 }); + await page.waitForTimeout(300); + await snap(page, 'screenshot-grid-validation.png'); + await page.close(); +} + await browser.close(); console.log('\nDone. All screenshots written to docs/screenshots/'); diff --git a/server/ColumnMetaStore.ts b/server/ColumnMetaStore.ts new file mode 100644 index 0000000..ce43b6a --- /dev/null +++ b/server/ColumnMetaStore.ts @@ -0,0 +1,98 @@ +import Database from 'better-sqlite3'; +import fs from 'fs'; +import path from 'path'; +import type { IColumnMetaStore, ColumnTypeInfo } from '../src/shared/types.js'; + +const DATA_DIR = path.join(process.cwd(), 'data'); +const DB_PATH = path.join(DATA_DIR, 'system.sqlite3'); + +/** + * Declared column types, keyed by (database, table, column). + * + * SQLite only records a storage affinity (TEXT/REAL/INTEGER), which cannot tell + * TIME from DATE from CHAR, LOGICAL from INT, or recover a NUM(p,s) qualifier. + * The grid and REPLACE need the declared type to validate writes. + * + * Scoping by database matters: two databases may each hold a table of the same + * name with different column types. + */ +export class ColumnMetaStore implements IColumnMetaStore { + private db: Database.Database; + + constructor(dbPath = DB_PATH) { + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + this.db = new Database(dbPath); + this.db.pragma('journal_mode = WAL'); + this.db.exec(` + CREATE TABLE IF NOT EXISTS column_types ( + db_name TEXT NOT NULL, + table_name TEXT NOT NULL, + col_name TEXT NOT NULL, + base_type TEXT NOT NULL, + qualifier INTEGER, + scale INTEGER, + PRIMARY KEY (db_name, table_name, col_name) + ); + `); + // v1.2.0 dev migration: the first cut of this table (#43) had neither db_name + // nor scale. The rows only cache what CREATE TABLE re-records, so rebuilding + // is cheaper and safer than back-filling an unscoped key. + const cols = this.db.prepare('PRAGMA table_info(column_types)').all() as { name: string }[]; + if (!cols.some(c => c.name === 'db_name') || !cols.some(c => c.name === 'scale')) { + this.db.exec(` + DROP TABLE column_types; + CREATE TABLE column_types ( + db_name TEXT NOT NULL, + table_name TEXT NOT NULL, + col_name TEXT NOT NULL, + base_type TEXT NOT NULL, + qualifier INTEGER, + scale INTEGER, + PRIMARY KEY (db_name, table_name, col_name) + ); + `); + } + } + + setColumnType(dbName: string, tableName: string, colName: string, baseType: string, qualifier: number | null, scale: number | null): void { + this.db.prepare(` + INSERT INTO column_types (db_name, table_name, col_name, base_type, qualifier, scale) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(db_name, table_name, col_name) DO UPDATE SET + base_type = excluded.base_type, qualifier = excluded.qualifier, scale = excluded.scale + `).run(dbName, tableName, colName, baseType, qualifier, scale); + } + + getColumnType(dbName: string, tableName: string, colName: string): ColumnTypeInfo | null { + const row = this.db.prepare( + 'SELECT base_type AS baseType, qualifier, scale FROM column_types WHERE db_name = ? AND table_name = ? AND col_name = ?' + ).get(dbName, tableName, colName) as ColumnTypeInfo | undefined; + return row ?? null; + } + + listColumnTypes(dbName: string, tableName: string): Record { + const rows = this.db.prepare( + 'SELECT col_name AS colName, base_type AS baseType, qualifier, scale FROM column_types WHERE db_name = ? AND table_name = ?' + ).all(dbName, tableName) as Array; + const out: Record = {}; + for (const r of rows) out[r.colName] = { baseType: r.baseType, qualifier: r.qualifier, scale: r.scale }; + return out; + } + + renameColumn(dbName: string, tableName: string, oldName: string, newName: string): void { + this.db.prepare( + 'UPDATE column_types SET col_name = ? WHERE db_name = ? AND table_name = ? AND col_name = ?' + ).run(newName, dbName, tableName, oldName); + } + + dropColumn(dbName: string, tableName: string, colName: string): void { + this.db.prepare('DELETE FROM column_types WHERE db_name = ? AND table_name = ? AND col_name = ?') + .run(dbName, tableName, colName); + } + + dropTable(dbName: string, tableName: string): void { + this.db.prepare('DELETE FROM column_types WHERE db_name = ? AND table_name = ?').run(dbName, tableName); + } +} + +export const columnMetaStore = new ColumnMetaStore(); diff --git a/server/IndexStore.ts b/server/IndexStore.ts index 1d9951c..8c53c28 100644 --- a/server/IndexStore.ts +++ b/server/IndexStore.ts @@ -6,71 +6,187 @@ import type { IIndexStore, IndexDef } from '../src/shared/types.js'; const DATA_DIR = path.join(process.cwd(), 'data'); const DB_PATH = path.join(DATA_DIR, 'system.sqlite3'); +/** + * Index metadata, keyed by (database, table, tag). + * + * Scoping by database matters: two databases may each hold a table of the same + * name. Before v1.2.0 (#50) the key omitted the database, so opening `PEOPLE` in + * one database silently activated an index defined on another database's + * `PEOPLE` — pointing the record order at a column that need not even exist. + */ export class IndexStore implements IIndexStore { private db: Database.Database; - constructor(dbPath = DB_PATH) { + constructor(dbPath = DB_PATH, dataDir = DATA_DIR) { fs.mkdirSync(path.dirname(dbPath), { recursive: true }); this.db = new Database(dbPath); this.db.pragma('journal_mode = WAL'); this.db.exec(` CREATE TABLE IF NOT EXISTS indexes ( id INTEGER PRIMARY KEY, + db_name TEXT NOT NULL DEFAULT '', table_name TEXT NOT NULL, tag TEXT NOT NULL, expression TEXT NOT NULL, created_at INTEGER DEFAULT (unixepoch()), - UNIQUE(table_name, tag) + UNIQUE(db_name, table_name, tag) ); CREATE TABLE IF NOT EXISTS active_indexes ( - table_name TEXT PRIMARY KEY, - tag TEXT NOT NULL + db_name TEXT NOT NULL DEFAULT '', + table_name TEXT NOT NULL, + tag TEXT NOT NULL, + PRIMARY KEY (db_name, table_name) ); `); + this.addDbNameColumn(dataDir); + this.migrateUnscoped(dataDir); + } + + /** + * A pre-#50 system.sqlite3 has `indexes`/`active_indexes` without db_name (and + * with the wrong key). CREATE TABLE IF NOT EXISTS leaves those alone, so rebuild + * them here, carrying every row across with an empty db_name for migrateUnscoped + * to adopt. + */ + private addDbNameColumn(_dataDir: string): void { + const hasCol = (t: string) => + (this.db.prepare(`PRAGMA table_info(${t})`).all() as { name: string }[]) + .some(c => c.name === 'db_name'); + if (hasCol('indexes') && hasCol('active_indexes')) return; + + this.db.transaction(() => { + if (!hasCol('indexes')) { + this.db.exec(` + CREATE TABLE indexes_new ( + id INTEGER PRIMARY KEY, + db_name TEXT NOT NULL DEFAULT '', + table_name TEXT NOT NULL, + tag TEXT NOT NULL, + expression TEXT NOT NULL, + created_at INTEGER DEFAULT (unixepoch()), + UNIQUE(db_name, table_name, tag) + ); + INSERT INTO indexes_new (db_name, table_name, tag, expression) + SELECT '', table_name, tag, expression FROM indexes; + DROP TABLE indexes; + ALTER TABLE indexes_new RENAME TO indexes; + `); + } + if (!hasCol('active_indexes')) { + this.db.exec(` + CREATE TABLE active_indexes_new ( + db_name TEXT NOT NULL DEFAULT '', + table_name TEXT NOT NULL, + tag TEXT NOT NULL, + PRIMARY KEY (db_name, table_name) + ); + INSERT INTO active_indexes_new (db_name, table_name, tag) + SELECT '', table_name, tag FROM active_indexes; + DROP TABLE active_indexes; + ALTER TABLE active_indexes_new RENAME TO active_indexes; + `); + } + })(); + } + + /** + * Pre-#50 rows carry no db_name. Index definitions are not re-derivable (only + * `INDEX ON` creates them), so rather than discard them, adopt each row into the + * one database that actually owns a table of that name. Rows whose owner is + * ambiguous or gone are dropped — keeping them unscoped is what caused the bug. + */ + private migrateUnscoped(dataDir: string): void { + const hasLegacy = (this.db.prepare( + `SELECT COUNT(*) AS n FROM indexes WHERE db_name = ''` + ).get() as { n: number }).n > 0; + if (!hasLegacy) return; + + const owners = new Map(); // TABLE (upper) → [dbName] + if (fs.existsSync(dataDir)) { + for (const f of fs.readdirSync(dataDir)) { + if (!f.endsWith('.sqlite3') || f === 'system.sqlite3') continue; + const dbName = f.slice(0, -8); + try { + const user = new Database(path.join(dataDir, f), { readonly: true }); + const tables = user.prepare( + "SELECT name FROM sqlite_master WHERE type='table'" + ).all() as { name: string }[]; + user.close(); + for (const t of tables) { + const key = t.name.toUpperCase(); + owners.set(key, [...(owners.get(key) ?? []), dbName]); + } + } catch { /* unreadable file — skip, its rows become ambiguous */ } + } + } + + const legacy = this.db.prepare( + `SELECT table_name, tag FROM indexes WHERE db_name = ''` + ).all() as { table_name: string; tag: string }[]; + + const adopt = this.db.prepare( + `UPDATE indexes SET db_name = ? WHERE db_name = '' AND table_name = ?` + ); + const adoptActive = this.db.prepare( + `UPDATE active_indexes SET db_name = ? WHERE db_name = '' AND table_name = ?` + ); + const migrate = this.db.transaction(() => { + for (const row of legacy) { + const cands = owners.get(row.table_name.toUpperCase()) ?? []; + if (cands.length === 1) { + adopt.run(cands[0], row.table_name); + adoptActive.run(cands[0], row.table_name); + } + } + this.db.prepare(`DELETE FROM indexes WHERE db_name = ''`).run(); + this.db.prepare(`DELETE FROM active_indexes WHERE db_name = ''`).run(); + }); + migrate(); } - saveIndex(tableName: string, tag: string, expression: string): void { + saveIndex(dbName: string, tableName: string, tag: string, expression: string): void { this.db.prepare(` - INSERT INTO indexes (table_name, tag, expression) - VALUES (?, ?, ?) - ON CONFLICT(table_name, tag) DO UPDATE SET expression = excluded.expression - `).run(tableName, tag, expression); + INSERT INTO indexes (db_name, table_name, tag, expression) + VALUES (?, ?, ?, ?) + ON CONFLICT(db_name, table_name, tag) DO UPDATE SET expression = excluded.expression + `).run(dbName, tableName, tag, expression); } - listIndexes(tableName: string): IndexDef[] { + listIndexes(dbName: string, tableName: string): IndexDef[] { return this.db.prepare( - 'SELECT tag, expression FROM indexes WHERE table_name = ? ORDER BY tag' - ).all(tableName) as IndexDef[]; + 'SELECT tag, expression FROM indexes WHERE db_name = ? AND table_name = ? ORDER BY tag' + ).all(dbName, tableName) as IndexDef[]; } - getActive(tableName: string): IndexDef | null { + getActive(dbName: string, tableName: string): IndexDef | null { const row = this.db.prepare(` SELECT i.tag, i.expression FROM active_indexes a - JOIN indexes i ON i.table_name = a.table_name AND i.tag = a.tag - WHERE a.table_name = ? - `).get(tableName) as IndexDef | undefined; + JOIN indexes i ON i.db_name = a.db_name AND i.table_name = a.table_name AND i.tag = a.tag + WHERE a.db_name = ? AND a.table_name = ? + `).get(dbName, tableName) as IndexDef | undefined; return row ?? null; } - setActive(tableName: string, tag: string): void { + setActive(dbName: string, tableName: string, tag: string): void { const exists = this.db.prepare( - 'SELECT 1 FROM indexes WHERE table_name = ? AND tag = ?' - ).get(tableName, tag); + 'SELECT 1 FROM indexes WHERE db_name = ? AND table_name = ? AND tag = ?' + ).get(dbName, tableName, tag); if (!exists) throw new Error(`Index '${tag}' not found on table '${tableName}'`); this.db.prepare(` - INSERT INTO active_indexes (table_name, tag) VALUES (?, ?) - ON CONFLICT(table_name) DO UPDATE SET tag = excluded.tag - `).run(tableName, tag); + INSERT INTO active_indexes (db_name, table_name, tag) VALUES (?, ?, ?) + ON CONFLICT(db_name, table_name) DO UPDATE SET tag = excluded.tag + `).run(dbName, tableName, tag); } - clearActive(tableName: string): void { - this.db.prepare('DELETE FROM active_indexes WHERE table_name = ?').run(tableName); + clearActive(dbName: string, tableName: string): void { + this.db.prepare('DELETE FROM active_indexes WHERE db_name = ? AND table_name = ?') + .run(dbName, tableName); } - dropTable(tableName: string): void { - this.db.prepare('DELETE FROM active_indexes WHERE table_name = ?').run(tableName); - this.db.prepare('DELETE FROM indexes WHERE table_name = ?').run(tableName); + dropTable(dbName: string, tableName: string): void { + this.db.prepare('DELETE FROM active_indexes WHERE db_name = ? AND table_name = ?').run(dbName, tableName); + this.db.prepare('DELETE FROM indexes WHERE db_name = ? AND table_name = ?').run(dbName, tableName); } } diff --git a/server/Session.ts b/server/Session.ts index 3357ddc..fef543f 100644 --- a/server/Session.ts +++ b/server/Session.ts @@ -6,6 +6,8 @@ import { ServerDatabaseBridge } from './ServerDatabaseBridge.js'; import { programStore } from './ProgramStore.js'; import { reportStore } from './ReportStore.js'; import { indexStore } from './IndexStore.js'; +import { columnMetaStore } from './ColumnMetaStore.js'; +import { validateCellValue } from '../src/shared/cellValidation.js'; import type { ClientMessage, ServerMessage, ColInfo } from '../src/shared/types.js'; export class Session { @@ -23,7 +25,7 @@ export class Session { private notifyChange?: (db: string, table: string) => void, ) { this.bridge = new ServerDatabaseBridge(); - this.executor = new Executor(this.bridge, indexStore); + this.executor = new Executor(this.bridge, indexStore, columnMetaStore); this.bridge.onMutate = () => { this.dirty = true; }; // Fire-and-forget client side-effects (CSV download, report preview, CSV // upload picker) are emitted immediately so they work at any nesting depth, @@ -44,11 +46,14 @@ export class Session { await this.runCommand(msg.text); break; - case 'form-submit': + case 'form-submit': { + // Always store what the form collected. A bare `INPUT "…" TO var` at the + // REPL leaves no continuation (there is no following statement), and + // gating the assignment on one silently discarded the typed value. (#50) + for (const [k, v] of Object.entries(msg.values)) { + this.executor.setVar(k, v); + } if (this.pendingContinuation !== null) { - for (const [k, v] of Object.entries(msg.values)) { - this.executor.setVar(k, v); - } const cont = this.pendingContinuation; const fromProgram = this.pendingFromProgram; this.pendingContinuation = null; @@ -59,13 +64,26 @@ export class Session { } finally { if (fromProgram) this.executor.exitProgram(); } + } else { + this.send({ type: 'view-terminal' }); + this.sendStatus(); } break; + } case 'grid-edit': { const { rowid, col, value } = msg; const table = this.executor.area.table; if (table) { + // Authoritative check — the grid validates client-side for fast + // feedback, but a message can reach here without passing through it. + const db = this.executor.area.db ?? ''; + const err = validateCellValue(col, value, columnMetaStore.getColumnType(db, table, col)); + if (err) { + this.send({ type: 'output', lines: [{ text: `** ${err}`, cls: 'error' }] }); + await this.sendGridData(); + break; + } await this.bridge.exec( `UPDATE ${q(table)} SET ${q(col)} = ? WHERE rowid = ?`, [value, rowid] @@ -127,8 +145,8 @@ export class Session { } if (area.table && await this.bridge.tableExists(area.table)) { columns = await this.bridge.getStructure(area.table); - const active = indexStore.getActive(area.table); - indexes = indexStore.listIndexes(area.table) + const active = indexStore.getActive(area.db ?? '', area.table); + indexes = indexStore.listIndexes(area.db ?? '', area.table) .map(i => ({ tag: i.tag, expression: i.expression, active: active?.tag === i.tag })); } } @@ -345,8 +363,9 @@ export class Session { return; } const columns = await this.bridge.getStructure(area.table); + const columnTypes = columnMetaStore.listColumnTypes(area.db ?? '', area.table); const rows = await this.executor.getOrderedRowsWithIds(2000); - this.send({ type: 'grid-open', table: area.table, filter: area.filter, columns, rows }); + this.send({ type: 'grid-open', table: area.table, filter: area.filter, columns, columnTypes, rows }); } private sendStatus(): void { diff --git a/src/interpreter/Builtins.ts b/src/interpreter/Builtins.ts index a29c20f..910e157 100644 --- a/src/interpreter/Builtins.ts +++ b/src/interpreter/Builtins.ts @@ -3,6 +3,29 @@ * All args are already-evaluated values (string | number | boolean). * Throws on unknown function name so Executor can distinguish from stateful functions. */ +/** + * Parse a date as a UTC instant at midnight, or null if it isn't a real calendar + * day. Accepts ISO `YYYY-MM-DD` (parsed by component) or anything `Date` groks, + * e.g. `MM/DD/YY`. Working in UTC keeps a local timezone offset from shifting the + * day; the round-trip check rejects impossible dates, which `Date.UTC` would + * otherwise roll over (Feb 30 → Mar 1). + */ +function parseDateUTC(raw: string): Date | null { + const iso = raw.match(/^(\d{4})-(\d{2})-(\d{2})$/); + let y: number, m: number, day: number; + if (iso) { + y = Number(iso[1]); m = Number(iso[2]); day = Number(iso[3]); + } else { + const local = new Date(raw); + if (isNaN(local.getTime())) return null; + y = local.getFullYear(); m = local.getMonth() + 1; day = local.getDate(); + } + const dt = new Date(Date.UTC(y, m - 1, day)); + if (isNaN(dt.getTime())) return null; + if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== m - 1 || dt.getUTCDate() !== day) return null; + return dt; +} + export function callStateless(fn: string, args: unknown[]): unknown { const s = (i: number) => String(args[i] ?? ''); const n = (i: number) => Number(args[i] ?? 0); @@ -92,6 +115,25 @@ export function callStateless(fn: string, args: unknown[]): unknown { const d = new Date(s(0)); return isNaN(d.getTime()) ? 0 : d.getDate(); } + case 'WEEK': { + // ISO-8601 week number: Monday-start weeks, week 1 holds the year's first + // Thursday. Dates in early January can therefore belong to week 52/53 of + // the previous year, and late December to week 1 of the next. + const dt = parseDateUTC(s(0)); + if (!dt) return 0; + const dow = dt.getUTCDay() || 7; // Mon=1 … Sun=7 + dt.setUTCDate(dt.getUTCDate() + 4 - dow); // Thursday fixes the week's year + const yearStart = Date.UTC(dt.getUTCFullYear(), 0, 1); + return Math.ceil(((dt.getTime() - yearStart) / 86400000 + 1) / 7); + } + case 'DATEADD': { + // The ISO date n days later (n may be negative). Day arithmetic in UTC is + // exact — no timezone offset, and no DST hour to lose. + const dt = parseDateUTC(s(0)); + if (!dt) return ''; + dt.setUTCDate(dt.getUTCDate() + Math.trunc(n(1))); + return dt.toISOString().slice(0, 10); + } default: throw new Error(`Unknown function: ${fn}`); } diff --git a/src/interpreter/Executor.ts b/src/interpreter/Executor.ts index 39e930d..f08a627 100644 --- a/src/interpreter/Executor.ts +++ b/src/interpreter/Executor.ts @@ -1,10 +1,11 @@ -import { IDatabaseBridge, IIndexStore, OutputLine, FormField, WorkArea, ClientSideEffect } from '../shared/types'; +import { IDatabaseBridge, IIndexStore, IColumnMetaStore, ColumnTypeInfo, OutputLine, FormField, WorkArea, ClientSideEffect } from '../shared/types'; import { ASTNode, Expr, ColDef, Parser } from './Parser'; import { Lexer } from './Lexer'; import { callStateless } from './Builtins'; import { IndexCommands, IndexCommandsHost } from './IndexCommands'; import { ReportCommands } from './ReportCommands'; import { toCSV, parseCSV, MAX_EXPORT_ROWS, MAX_IMPORT_BYTES, MAX_IMPORT_SKIPS } from '../shared/csv'; +import { validateCellValue } from '../shared/cellValidation'; export type { OutputLine, FormField } from '../shared/types'; @@ -35,13 +36,21 @@ type DbType = 'TEXT' | 'REAL' | 'INTEGER' | 'BLOB'; function mapType(t: string): DbType { switch (t.toUpperCase()) { - case 'CHAR': case 'CHARACTER': case 'VARCHAR': case 'STRING': case 'MEMO': case 'DATE': return 'TEXT'; + case 'CHAR': case 'CHARACTER': case 'VARCHAR': case 'STRING': case 'MEMO': case 'DATE': case 'TIME': return 'TEXT'; case 'NUM': case 'NUMERIC': case 'FLOAT': case 'DOUBLE': case 'DECIMAL': return 'REAL'; case 'INT': case 'INTEGER': case 'LOGICAL': case 'BOOLEAN': return 'INTEGER'; default: return 'TEXT'; } } +/** Render a declared column type the way it was written: NUM(8,2), TIME(15), DATE. */ +function declaredTypeText(info: ColumnTypeInfo | undefined): string | null { + if (!info) return null; + if (info.qualifier === null) return info.baseType; + if (info.scale !== null && info.scale !== undefined) return `${info.baseType}(${info.qualifier},${info.scale})`; + return `${info.baseType}(${info.qualifier})`; +} + function makeArea(alias: string): WorkArea { return { alias, @@ -69,6 +78,7 @@ export class Executor implements IndexCommandsHost { constructor( public db: IDatabaseBridge, public indexStore: IIndexStore | null = null, + public columnMetaStore: IColumnMetaStore | null = null, ) { this.areas = new Map([['1', makeArea('1')]]); this.activeAlias = '1'; @@ -82,6 +92,11 @@ export class Executor implements IndexCommandsHost { return this.areas.get(this.activeAlias)!; } + /** Database key for index/column metadata — same-named tables in different DBs must not collide. */ + get metaDb(): string { + return this.area.db ?? ''; + } + /** Backwards-compat shim — Session.ts and legacy tests still read executor.state */ get state(): State { const a = this.area; @@ -223,7 +238,7 @@ export class Executor implements IndexCommandsHost { this.area.table = name; this.area.filter = null; this.area.rowPtr = 1; - this.area.activeIndex = this.indexStore?.getActive(name) ?? null; + this.area.activeIndex = this.indexStore?.getActive(this.metaDb, name) ?? null; const exists = await this.db.tableExists(name); const storage = this.area.opfsAvailable ? 'OPFS (persistent)' : 'server-side persistent'; const lines: OutputLine[] = [ @@ -367,13 +382,15 @@ export class Executor implements IndexCommandsHost { private async doListStruct(): Promise { this.requireTable(); const cols = await this.db.getStructure(this.area.table!); + const meta = this.columnMetaStore?.listColumnTypes(this.metaDb, this.area.table!) ?? {}; const out: OutputLine[] = [ { text: `Structure of table: ${this.area.table}`, cls: 'hdr' }, { text: `${'#'.padEnd(4)} ${'Field'.padEnd(20)} ${'Type'.padEnd(10)} ${'Null'.padEnd(5)} ${'PK'}`, cls: 'hdr' }, { text: `${'─'.repeat(55)}`, cls: 'sep' }, ]; cols.forEach(c => { - out.push({ text: `${String(c.cid + 1).padEnd(4)} ${c.name.padEnd(20)} ${c.type.padEnd(10)} ${c.notnull ? 'NO' : 'YES'.padEnd(5)} ${c.pk ? 'PK' : ''}` }); + const typeText = declaredTypeText(meta[c.name]) ?? c.type; + out.push({ text: `${String(c.cid + 1).padEnd(4)} ${c.name.padEnd(20)} ${typeText.padEnd(10)} ${c.notnull ? 'NO' : 'YES'.padEnd(5)} ${c.pk ? 'PK' : ''}` }); }); return { output: out }; } @@ -411,6 +428,17 @@ export class Executor implements IndexCommandsHost { this.requireTable(); await this.refreshRecCount(); const pairs = fields.map(f => ({ field: f.field, value: this.evalExpr(f.value) })); + // TIME is the only declared type REPLACE enforces (#43). The grid validates + // every declared type (#45) because it has no other guard; widening REPLACE + // would change the semantics of existing programs. + for (const p of pairs) { + if (p.value === null || p.value === undefined) continue; + const info = this.columnMetaStore?.getColumnType(this.metaDb, this.area.table!, p.field); + if (info?.baseType === 'TIME') { + const err = validateCellValue(p.field, String(p.value), info); + if (err) throw new Error(err); + } + } const setClauses = pairs.map(p => `${q(p.field)} = ?`).join(', '); const params = pairs.map(p => typeof p.value === 'boolean' ? (p.value ? 1 : 0) : p.value); if (scope === 'ALL') { @@ -740,8 +768,22 @@ export class Executor implements IndexCommandsHost { const colsSql = cols.length ? cols.map(c => `${q(c.name)} ${mapType(c.colType)}`).join(', ') : '"id" INTEGER PRIMARY KEY AUTOINCREMENT'; + for (const c of cols) { + if (c.colType.toUpperCase() === 'TIME' && c.size !== undefined && (!Number.isInteger(c.size) || c.size < 1 || c.size > 59)) { + throw new Error(`CREATE TABLE: invalid TIME granularity qualifier TIME(${c.size}) — must be an integer between 1 and 59`); + } + } const sql = `CREATE TABLE IF NOT EXISTS ${q(name)} (${colsSql})`; await this.db.exec(sql); + // Record the *declared* type of every column. SQLite only stores an affinity + // (TEXT/REAL/INTEGER), which can't tell TIME from DATE from CHAR, LOGICAL from + // INT, or recover a NUM(p,s) precision — the grid needs the declared type to + // validate edits. + for (const c of cols) { + this.columnMetaStore?.setColumnType( + this.metaDb, name, c.name, c.colType.toUpperCase(), c.size ?? null, c.scale ?? null, + ); + } this.area.table = name; this.area.filter = null; this.area.rowPtr = 1; @@ -847,7 +889,8 @@ export class Executor implements IndexCommandsHost { private async doDropTable(name: string): Promise { await this.db.exec(`DROP TABLE IF EXISTS ${q(name)}`); - this.indexStore?.dropTable(name); + this.indexStore?.dropTable(this.metaDb, name); + this.columnMetaStore?.dropTable(this.metaDb, name); if (this.area.table === name) { this.area.table = null; this.area.activeIndex = null; @@ -873,6 +916,8 @@ export class Executor implements IndexCommandsHost { if (node.op === 'ADD') { if (has(node.col)) return { output: [{ text: `ALTER TABLE: column already exists: ${node.col}`, cls: 'error' }] }; await this.db.exec(`ALTER TABLE ${q(name)} ADD COLUMN ${q(node.col)} ${mapType(node.colType)}`); + // ALTER TABLE drops any (n)/(p,s) qualifier — the parser skips it. + this.columnMetaStore?.setColumnType(this.metaDb, name, node.col, node.colType.toUpperCase(), null, null); await this.refreshIfActive(name); return { output: [{ text: `Added column ${node.col} to ${name}.`, cls: 'ok' }] }; } @@ -881,6 +926,7 @@ export class Executor implements IndexCommandsHost { if (cols.length <= 1) return { output: [{ text: 'ALTER TABLE: cannot drop the only column', cls: 'error' }] }; const dropped = await this.dropAllIndexes(name); await this.db.exec(`ALTER TABLE ${q(name)} DROP COLUMN ${q(node.col)}`); + this.columnMetaStore?.dropColumn(this.metaDb, name, node.col); await this.refreshIfActive(name); return { output: [ { text: `Dropped column ${node.col} from ${name}.`, cls: 'ok' }, @@ -893,6 +939,7 @@ export class Executor implements IndexCommandsHost { if (has(node.newName)) return { output: [{ text: `ALTER TABLE: column already exists: ${node.newName}`, cls: 'error' }] }; const dropped = await this.dropAllIndexes(name); await this.db.exec(`ALTER TABLE ${q(name)} RENAME COLUMN ${q(node.col)} TO ${q(node.newName)}`); + this.columnMetaStore?.renameColumn(this.metaDb, name, node.col, node.newName); await this.refreshIfActive(name); return { output: [ { text: `Renamed ${node.col} to ${node.newName} in ${name}.`, cls: 'ok' }, @@ -921,6 +968,7 @@ export class Executor implements IndexCommandsHost { await this.db.exec(`CREATE TABLE ${q(name)} (${colDefs})`); await this.db.exec(`INSERT INTO ${q(name)} SELECT ${colList} FROM ${q(tmp)}`); await this.db.exec(`DROP TABLE ${q(tmp)}`); + this.columnMetaStore?.setColumnType(this.metaDb, name, node.col, node.colType.toUpperCase(), null, null); await this.refreshIfActive(name); return { output: [ { text: `Changed type of ${node.col} to ${node.colType} in ${name}.`, cls: 'ok' }, @@ -943,12 +991,12 @@ export class Executor implements IndexCommandsHost { // Used by column ops that can invalidate an index expression. private async dropAllIndexes(table: string): Promise { if (!this.indexStore) return []; - const tags = this.indexStore.listIndexes(table).map(i => i.tag); + const tags = this.indexStore.listIndexes(this.metaDb, table).map(i => i.tag); for (const tag of tags) { const sqlName = `idx_${table}_${tag}`.replace(/"/g, '""'); await this.db.exec(`DROP INDEX IF EXISTS "${sqlName}"`); } - this.indexStore.dropTable(table); // clears metadata + active marker + this.indexStore.dropTable(this.metaDb, table); // clears metadata + active marker if (this.area.table === table) this.area.activeIndex = null; return tags; } @@ -1015,6 +1063,7 @@ export class Executor implements IndexCommandsHost { { text: 'Demos / examples:' }, { text: 'DO crm — usable CRM example app (EDIT crm to customize)' }, { text: 'DO inventory — usable inventory example app (EDIT inventory to customize)' }, + { text: 'DO overtime — overtime tracker example app (TIME(15), WEEK(), DATEADD())' }, { text: '' }, { text: 'QUIT — exit' }, ]}; diff --git a/src/interpreter/IndexCommands.ts b/src/interpreter/IndexCommands.ts index 79e71d9..db4aeb7 100644 --- a/src/interpreter/IndexCommands.ts +++ b/src/interpreter/IndexCommands.ts @@ -8,6 +8,8 @@ export interface IndexCommandsHost { readonly area: WorkArea; readonly activeAlias: string; readonly indexStore: IIndexStore | null; + /** Database key for index/column metadata — same-named tables in different DBs must not collide. */ + readonly metaDb: string; readonly db: IDatabaseBridge; evalExpr(e: Expr): unknown; requireTable(): void; @@ -23,7 +25,7 @@ export class IndexCommands { this.host.requireTable(); if (!this.host.indexStore) return { output: [{ text: '** IndexStore not available', cls: 'error' }] }; const table = this.host.area.table!; - this.host.indexStore.saveIndex(table, tag, expression); + this.host.indexStore.saveIndex(this.host.metaDb, table, tag, expression); if (/^[A-Z_][A-Z0-9_]*$/i.test(expression.trim())) { try { await this.host.db.exec( @@ -31,7 +33,7 @@ export class IndexCommands { ); } catch { /* ignore — expression may not be a valid SQL column ref */ } } - this.host.indexStore.setActive(table, tag); + this.host.indexStore.setActive(this.host.metaDb, table, tag); this.host.area.activeIndex = { tag, expression }; return { output: [{ text: `Index created: ${tag} ON ${expression}`, cls: 'ok' }] }; } @@ -41,13 +43,13 @@ export class IndexCommands { if (!this.host.indexStore) return { output: [{ text: '** IndexStore not available', cls: 'error' }] }; const table = this.host.area.table!; if (tag === null) { - this.host.indexStore.clearActive(table); + this.host.indexStore.clearActive(this.host.metaDb, table); this.host.area.activeIndex = null; return { output: [{ text: 'Active index cleared', cls: 'ok' }] }; } - const def = this.host.indexStore.listIndexes(table).find(i => i.tag.toUpperCase() === tag.toUpperCase()); + const def = this.host.indexStore.listIndexes(this.host.metaDb, table).find(i => i.tag.toUpperCase() === tag.toUpperCase()); if (!def) return { output: [{ text: `Index '${tag}' not found — use INDEX ON to create it`, cls: 'warn' }] }; - this.host.indexStore.setActive(table, def.tag); + this.host.indexStore.setActive(this.host.metaDb, table, def.tag); this.host.area.activeIndex = { tag: def.tag, expression: def.expression }; return { output: [{ text: `Index active: ${def.tag} (${def.expression})`, cls: 'ok' }] }; } @@ -62,7 +64,7 @@ export class IndexCommands { this.host.requireTable(); if (!this.host.indexStore) return { output: [{ text: '** IndexStore not available', cls: 'error' }] }; const table = this.host.area.table!; - const indexes = this.host.indexStore.listIndexes(table); + const indexes = this.host.indexStore.listIndexes(this.host.metaDb, table); if (!indexes.length) return { output: [{ text: '(No indexes defined)', cls: 'info' }] }; const out: OutputLine[] = [ { text: `Indexes for table: ${table}`, cls: 'hdr' }, diff --git a/src/interpreter/Parser.ts b/src/interpreter/Parser.ts index 2a2141b..97993d9 100644 --- a/src/interpreter/Parser.ts +++ b/src/interpreter/Parser.ts @@ -9,6 +9,7 @@ const BUILTIN_FUNCTIONS = new Set([ // #4 (PR #17, @kas2804) — implemented in Builtins.ts; must be whitelisted here // too or the parser won't recognise the call. 'ROUND','MOD','MAX','MIN','TIME','YEAR','MONTH','DAY', + 'WEEK','DATEADD', ]); // ── AST Node Types ────────────────────────────────────────────────────────── @@ -75,7 +76,7 @@ export type ASTNode = | { type: 'FIND'; value: string } | { type: 'UNKNOWN'; raw: string }; -export interface ColDef { name: string; colType: string; size?: number; } +export interface ColDef { name: string; colType: string; size?: number; scale?: number; } export type Expr = | { k: 'lit'; v: string | number | boolean } @@ -472,22 +473,64 @@ export class Parser { if (this.peek().type === 'LPAREN') { this.adv(); while (!this.end() && this.peek().type !== 'RPAREN') { - const cname = this.ident(); - const ctype = this.ident(); + const cname = this.colName(); + const ctype = this.colType(cname); let size: number | undefined; + let scale: number | undefined; if (this.peek().type === 'LPAREN') { this.adv(); - size = this.tryNum() ?? undefined; - if (this.peek().type === 'RPAREN') this.adv(); + size = this.typeArg(cname); + // NUM(p,s) — the second argument is the scale. Without consuming it the + // comma ends the column and the scale is parsed as the next column name. + if (this.peek().type === 'COMMA') { + this.adv(); + scale = this.typeArg(cname); + } + this.expectRParen(`type qualifier for column '${cname}'`); } - cols.push({ name: cname, colType: ctype, size }); + cols.push({ name: cname, colType: ctype, size, scale }); if (this.peek().type === 'COMMA') this.adv(); + else break; // no comma → the list must end here } - if (this.peek().type === 'RPAREN') this.adv(); + this.expectRParen(`column list of table '${name}'`); } return { type: 'CREATE_TABLE', name, cols }; } + // ── CREATE TABLE column-list parsing ─────────────────────────────────────── + // Strict on purpose (#50). The old code called ident() — "take the next token, + // whatever it is" — so a stray ')' or ',' became a column name or a type, and + // NUM(8,2) silently produced a phantom column named "2" of type ")". + + private createErr(msg: string): never { + const t = this.peek(); + const at = t.type === 'EOF' ? 'end of input' : `'${t.val}'`; + throw new Error(`CREATE TABLE: ${msg} (at ${at}, line ${t.line})`); + } + + private colName(): string { + const t = this.peek(); + if (t.type !== 'ID' && t.type !== 'KW') this.createErr('expected a column name'); + return this.adv().val; + } + + private colType(colName: string): string { + const t = this.peek(); + if (t.type !== 'ID' && t.type !== 'KW') this.createErr(`expected a type for column '${colName}'`); + return this.adv().val; + } + + private typeArg(colName: string): number { + const n = this.tryNum(); + if (n === null) this.createErr(`expected a number in the type qualifier for column '${colName}'`); + return n; + } + + private expectRParen(what: string): void { + if (this.peek().type !== 'RPAREN') this.createErr(`expected ')' to close the ${what}`); + this.adv(); + } + private parseDrop(): ASTNode { this.adv(); this.skipKw('TABLE'); @@ -505,12 +548,13 @@ export class Parser { throw new Error('Expected ADD, DROP, RENAME, or ALTER after ALTER TABLE '); } - // Consume an optional "(n)" length suffix on a type (e.g. CHAR(20)); the - // length is ignored — SQLite types are not length-bound (matches CREATE TABLE). + // Consume an optional "(n)" or "(p,s)" suffix on a type (e.g. CHAR(20), NUM(8,2)); + // the values are ignored — ALTER TABLE does not carry the qualifier through. private skipTypeSize(): void { if (this.peek().type === 'LPAREN') { this.adv(); this.tryNum(); + if (this.peek().type === 'COMMA') { this.adv(); this.tryNum(); } if (this.peek().type === 'RPAREN') this.adv(); } } diff --git a/src/shared/cellValidation.ts b/src/shared/cellValidation.ts new file mode 100644 index 0000000..6ad5579 --- /dev/null +++ b/src/shared/cellValidation.ts @@ -0,0 +1,89 @@ +/** + * Declared-column-type validation, shared by the browser grid (fast inline + * feedback while typing) and the server (the authoritative check on write). + * + * Both sides must agree, so the rules live here rather than being duplicated. + * Returns an error message, or null when the value is acceptable. + */ + +export interface ColumnMeta { + baseType: string; + qualifier: number | null; // CHAR(n) length, TIME(n) minute granularity, NUM(p,s) precision + scale: number | null; // NUM(p,s) scale +} + +const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/; +const ISO_DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/; +const LOGICAL_VALUES = new Set(['.T.', '.F.', '.TRUE.', '.FALSE.', 'T', 'F', 'TRUE', 'FALSE', '1', '0']); + +/** True when y-m-d names a real calendar day (rejects Feb 30, month 13, …). */ +export function isRealDate(y: number, m: number, d: number): boolean { + const dt = new Date(Date.UTC(y, m - 1, d)); + return dt.getUTCFullYear() === y && dt.getUTCMonth() === m - 1 && dt.getUTCDate() === d; +} + +export function validateCellValue( + colName: string, + value: string, + meta: ColumnMeta | null | undefined, +): string | null { + if (!meta) return null; // untracked column — no constraint + const v = value.trim(); + if (v === '') return null; // clearing a cell is always allowed + + switch (meta.baseType.toUpperCase()) { + case 'TIME': { + const m = TIME_RE.exec(v); + if (!m) return `${colName}: expected a time as HH:MM (00-23:00-59)`; + if (meta.qualifier && Number(m[2]) % meta.qualifier !== 0) { + return `${colName}: minutes must be a multiple of ${meta.qualifier}`; + } + return null; + } + + case 'DATE': { + const m = ISO_DATE_RE.exec(v); + if (!m) return `${colName}: expected a date as YYYY-MM-DD`; + if (!isRealDate(Number(m[1]), Number(m[2]), Number(m[3]))) { + return `${colName}: "${v}" is not a real date`; + } + return null; + } + + case 'LOGICAL': + case 'BOOLEAN': + return LOGICAL_VALUES.has(v.toUpperCase()) + ? null + : `${colName}: expected a logical value (.T. / .F.)`; + + case 'INT': + case 'INTEGER': + return /^[+-]?\d+$/.test(v) ? null : `${colName}: expected a whole number`; + + case 'NUM': + case 'NUMERIC': + case 'FLOAT': + case 'DOUBLE': + case 'DECIMAL': { + if (!/^[+-]?(\d+(\.\d*)?|\.\d+)$/.test(v)) return `${colName}: expected a number`; + const [intPart, decPart = ''] = v.replace(/^[+-]/, '').split('.'); + const scale = meta.scale ?? 0; + if (meta.scale !== null && decPart.length > meta.scale) { + return `${colName}: at most ${meta.scale} decimal place(s)`; + } + if (meta.qualifier !== null) { + // NUM(p,s): p is total digits, so p - s bounds the integer part. + // NUM(p) with no scale: p bounds the integer part directly. + const maxIntDigits = meta.qualifier - scale; + const digits = intPart.replace(/^0+(?=\d)/, ''); + if (digits.length > maxIntDigits) { + return `${colName}: at most ${maxIntDigits} digit(s) before the decimal point`; + } + } + return null; + } + + default: + return null; // CHAR / MEMO / anything else — unconstrained + } +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 53a1489..c9ce093 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -41,13 +41,30 @@ export interface IndexDef { expression: string; } +// Scoped by database: two databases can hold same-named tables with different indexes. export interface IIndexStore { - saveIndex(tableName: string, tag: string, expression: string): void; - listIndexes(tableName: string): IndexDef[]; - getActive(tableName: string): IndexDef | null; - setActive(tableName: string, tag: string): void; - clearActive(tableName: string): void; - dropTable(tableName: string): void; + saveIndex(dbName: string, tableName: string, tag: string, expression: string): void; + listIndexes(dbName: string, tableName: string): IndexDef[]; + getActive(dbName: string, tableName: string): IndexDef | null; + setActive(dbName: string, tableName: string, tag: string): void; + clearActive(dbName: string, tableName: string): void; + dropTable(dbName: string, tableName: string): void; +} + +// Metadata for the column types SQLite's own affinity can't distinguish (TIME vs +// DATE vs CHAR are all TEXT; LOGICAL vs INT are both INTEGER; NUM(p,s) loses its +// precision/scale). qualifier carries CHAR(n) length / TIME(n) granularity / +// NUM(p,s) precision; scale carries the NUM(p,s) scale. +export type ColumnTypeInfo = import('./cellValidation').ColumnMeta; + +// Scoped by database: two databases can hold same-named tables with different types. +export interface IColumnMetaStore { + setColumnType(dbName: string, tableName: string, colName: string, baseType: string, qualifier: number | null, scale: number | null): void; + getColumnType(dbName: string, tableName: string, colName: string): ColumnTypeInfo | null; + listColumnTypes(dbName: string, tableName: string): Record; + renameColumn(dbName: string, tableName: string, oldName: string, newName: string): void; + dropColumn(dbName: string, tableName: string, colName: string): void; + dropTable(dbName: string, tableName: string): void; } export interface ReportColumn { @@ -111,7 +128,6 @@ export interface Catalog { // Client → Server export type ClientMessage = | { type: 'command'; text: string } - | { type: 'input-response'; value: string } | { type: 'form-submit'; values: Record } | { type: 'grid-edit'; rowid: number; col: string; value: string } | { type: 'grid-delete'; rowid: number } @@ -127,8 +143,7 @@ export type ClientMessage = export type ServerMessage = | { type: 'output'; lines: OutputLine[] } | { type: 'status'; db: string | null; table: string | null; record: number; total: number } - | { type: 'input-request'; prompt: string } - | { type: 'grid-open'; table: string; filter: string | null; columns: ColInfo[]; rows: Record[] } + | { type: 'grid-open'; table: string; filter: string | null; columns: ColInfo[]; columnTypes: Record; rows: Record[] } | { type: 'modstruct-open'; table: string; columns: ColInfo[] } | { type: 'form-open'; fields: FormField[] } | { type: 'program-open'; name: string; content: string } diff --git a/src/styles/main.css b/src/styles/main.css index da91c23..ebd78b4 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -240,7 +240,7 @@ html, body { outline-offset: -2px; } -#grid-table td.editing { padding: 0; } +#grid-table td.editing { padding: 0; position: relative; } #grid-table td input.cell-ed { width: 100%; height: 100%; min-height: 25px; padding: 3px 10px; @@ -248,6 +248,18 @@ html, body { color: #ffffff; font-family: var(--font); font-size: 13px; outline: none; } +/* A rejected edit (#45): the cell stays in edit mode and explains why. + Cells clip their content (`overflow: hidden` above, for ellipsis), which would + swallow the message entirely — the user would see only a red border. */ +#grid-table td.cell-invalid { overflow: visible; } +#grid-table td.cell-invalid input.cell-ed { border-color: #cc0000; background: #2a0000; } +#grid-table td.cell-invalid .cell-error { + position: absolute; left: 0; top: 100%; z-index: 20; + max-width: 320px; padding: 3px 8px; + background: #cc0000; color: #ffffff; + font-family: var(--font); font-size: 12px; white-space: normal; +} + /* ── FORM VIEW ── */ #form-view { flex: 1; display: flex; flex-direction: column; diff --git a/src/terminal/Terminal.ts b/src/terminal/Terminal.ts index c9e7112..2afd2df 100644 --- a/src/terminal/Terminal.ts +++ b/src/terminal/Terminal.ts @@ -67,7 +67,7 @@ export class Terminal { ws.on('grid-open', (msg) => { const m = msg as any; - this.openGrid(m.table, m.filter, m.columns, m.rows); + this.openGrid(m.table, m.filter, m.columns, m.columnTypes, m.rows); }); ws.on('data-changed', (msg) => { @@ -227,7 +227,7 @@ export class Terminal { // ── Views ────────────────────────────────────────────────────────────── - private openGrid(table: string, filter: string | null, columns: any[], rows: any[]) { + private openGrid(table: string, filter: string | null, columns: any[], columnTypes: any, rows: any[]) { this.termView.classList.add('hidden'); this.gridView.classList.remove('hidden'); @@ -235,6 +235,7 @@ export class Terminal { table, filter, columns, + columnTypes: columnTypes ?? {}, rows, ws: this.ws, onExit: () => this.closeGrid(), @@ -378,6 +379,7 @@ export class Terminal { { text: 'Try a full example app:', cls: 'hdr' }, { text: ' DO crm — a working mini-CRM (companies, contacts, deals)', cls: 'out' }, { text: ' DO inventory — a working stock manager (categories, products, movements)', cls: 'out' }, + { text: ' DO overtime — an overtime tracker (schedules, timesheets, leave)', cls: 'out' }, { text: ' These are complete, editable programs — EDIT crm to build your own.', cls: 'info' }, { text: '' }, ].forEach(l => this.printLine(l.text, l.cls)); diff --git a/src/ui/Assistant.ts b/src/ui/Assistant.ts index ab1177d..2a6e9ab 100644 --- a/src/ui/Assistant.ts +++ b/src/ui/Assistant.ts @@ -65,6 +65,7 @@ const CATEGORIES: { name: string; actions: ActionDef[] }[] = [ { name: 'Programs', actions: [ { label: 'Run CRM demo', command: 'DO crm' }, { label: 'Run Inventory demo', command: 'DO inventory' }, + { label: 'Run Overtime demo', command: 'DO overtime' }, { label: 'Run program…', picker: 'programs', onPick: (n, h) => h.run(`DO ${n}`) }, { label: 'Edit program…', picker: 'programs', onPick: (n, h) => h.run(`EDIT ${n}`) }, ]}, diff --git a/src/ui/Grid.ts b/src/ui/Grid.ts index dfdf6d7..5c9f7f1 100644 --- a/src/ui/Grid.ts +++ b/src/ui/Grid.ts @@ -1,10 +1,12 @@ import type { WsClient } from '../ws/WsClient'; -import type { ColInfo } from '../shared/types'; +import type { ColInfo, ColumnTypeInfo } from '../shared/types'; +import { validateCellValue } from '../shared/cellValidation'; export interface GridOptions { table: string; filter: string | null; columns: ColInfo[]; + columnTypes: Record; rows: Record[]; ws: WsClient; onExit: () => void; @@ -22,6 +24,7 @@ export class Grid { private rows: Row[] = []; private cols: string[] = []; + private columnTypes: Record = {}; private selRow = 0; private selCol = 1; private editingCell: { r: number; c: number } | null = null; @@ -42,6 +45,7 @@ export class Grid { this.onStatus = opts.onStatusChange; this.rows = opts.rows as Row[]; + this.columnTypes = opts.columnTypes ?? {}; this.cols = this.rows.length > 0 ? Object.keys(this.rows[0]).filter(c => c !== '_rowid') : opts.columns.map(c => c.name); @@ -61,6 +65,7 @@ export class Grid { this.ws.on('grid-open', (msg) => { const m = msg as any; this.rows = m.rows as Row[]; + this.columnTypes = m.columnTypes ?? this.columnTypes; this.cols = this.rows.length > 0 ? Object.keys(this.rows[0]).filter((c: string) => c !== '_rowid') : m.columns.map((c: ColInfo) => c.name); @@ -174,10 +179,15 @@ export class Grid { inp.focus(); inp.select(); this.editingCell = { r: ri, c: ci }; + // Clear a stale error as soon as the value becomes valid again. + inp.addEventListener('input', () => { + if (!validateCellValue(colName, inp.value, this.columnTypes[colName])) this.clearCellError(td); + }); + inp.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); e.stopPropagation(); - this.commitEdit(inp.value); + if (!this.commitEdit(inp.value)) return; // invalid — stay in edit mode if (e.key === 'Tab') this.selectCell(ri, ci + 2); } else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); @@ -186,15 +196,46 @@ export class Grid { }); } - private commitEdit(newValue: string) { - if (!this.editingCell) return; + /** @returns false when the value was rejected and the cell stays in edit mode. */ + private commitEdit(newValue: string): boolean { + if (!this.editingCell) return false; const { r, c } = this.editingCell; + const colName = this.cols[c]; + + const error = validateCellValue(colName, newValue, this.columnTypes[colName]); + if (error) { + const td = this.tbody.querySelector(`td[data-ri="${r}"][data-ci="${c}"]`); + if (td) this.showCellError(td, error); + this.onStatus(error); + return false; + } + const row = this.rows[r]; - this.ws.send({ type: 'grid-edit', rowid: row._rowid as number, col: this.cols[c], value: newValue }); - row[this.cols[c]] = newValue; + this.ws.send({ type: 'grid-edit', rowid: row._rowid as number, col: colName, value: newValue }); + row[colName] = newValue; this.editingCell = null; this.renderBody(); this.refreshSelection(); + return true; + } + + private showCellError(td: HTMLTableCellElement, message: string) { + td.classList.add('cell-invalid'); + td.title = message; + let tip = td.querySelector('.cell-error'); + if (!tip) { + tip = document.createElement('div'); + tip.className = 'cell-error'; + td.appendChild(tip); + } + tip.textContent = message; + td.querySelector('.cell-ed')?.focus(); + } + + private clearCellError(td: HTMLTableCellElement) { + td.classList.remove('cell-invalid'); + td.removeAttribute('title'); + td.querySelector('.cell-error')?.remove(); } private cancelEdit() { diff --git a/src/ui/wizards/ModStructWizard.ts b/src/ui/wizards/ModStructWizard.ts index eac8723..43d7577 100644 --- a/src/ui/wizards/ModStructWizard.ts +++ b/src/ui/wizards/ModStructWizard.ts @@ -2,7 +2,7 @@ import { WizardShell } from './WizardShell'; import type { ColInfo } from '../../shared/types'; const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; -const TYPES = ['CHAR', 'NUM', 'INT', 'DATE', 'LOGICAL', 'MEMO'] as const; +const TYPES = ['CHAR', 'NUM', 'INT', 'DATE', 'TIME', 'LOGICAL', 'MEMO'] as const; // Map an existing SQLite storage type back to a W3Script type for the picker. function w3type(sqlType: string): string { diff --git a/src/ui/wizards/TableWizard.ts b/src/ui/wizards/TableWizard.ts index 49d6040..12978c1 100644 --- a/src/ui/wizards/TableWizard.ts +++ b/src/ui/wizards/TableWizard.ts @@ -1,8 +1,9 @@ import { WizardShell } from './WizardShell'; const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; -const TYPES = ['CHAR', 'NUM', 'INT', 'DATE', 'LOGICAL', 'MEMO'] as const; +const TYPES = ['CHAR', 'NUM', 'INT', 'DATE', 'TIME', 'LOGICAL', 'MEMO'] as const; const NEEDS_LEN = new Set(['CHAR', 'NUM']); +const OPTIONAL_LEN = new Set(['TIME']); interface ColRow { name: HTMLInputElement; type: HTMLSelectElement; len: HTMLInputElement; } @@ -26,10 +27,30 @@ export function openTableWizard(run: (cmd: string) => void, onClose: () => void) if (!n) continue; // blank rows are skipped if (!NAME_RE.test(n)) return { cmd: null, err: `Invalid column name: ${n}` }; const t = r.type.value; - if (NEEDS_LEN.has(t)) { + if (t === 'NUM') { + // Accept a plain width ("8") or a precision,scale pair ("8,2"). + const raw = r.len.value.trim(); + const m = raw.match(/^(\d+)\s*(?:,\s*(\d+))?$/); + if (!m) return { cmd: null, err: `Length required for ${n} (NUM) — e.g. 8 or 8,2` }; + const p = parseInt(m[1], 10); + if (!p || p < 1) return { cmd: null, err: `Length required for ${n} (NUM)` }; + if (m[2] === undefined) { cols.push(`${n} NUM(${p})`); continue; } + const s = parseInt(m[2], 10); + if (s >= p) return { cmd: null, err: `Scale must be smaller than precision for ${n} (NUM)` }; + cols.push(`${n} NUM(${p},${s})`); + } else if (NEEDS_LEN.has(t)) { const len = parseInt(r.len.value, 10); if (!len || len < 1) return { cmd: null, err: `Length required for ${n} (${t})` }; cols.push(`${n} ${t}(${len})`); + } else if (OPTIONAL_LEN.has(t)) { + const raw = r.len.value.trim(); + if (raw) { + const len = parseInt(raw, 10); + if (!len || len < 1) return { cmd: null, err: `Invalid granularity for ${n} (${t})` }; + cols.push(`${n} ${t}(${len})`); + } else { + cols.push(`${n} ${t}`); + } } else { cols.push(`${n} ${t}`); } @@ -56,7 +77,7 @@ export function openTableWizard(run: (cmd: string) => void, onClose: () => void) type.appendChild(o); } const len = document.createElement('input'); - len.type = 'text'; len.className = 'wz-col-len'; len.placeholder = 'len'; len.style.minWidth = '50px'; len.style.width = '50px'; + len.type = 'text'; len.className = 'wz-col-len'; len.placeholder = 'len'; len.title = 'CHAR: length · NUM: width or precision,scale (8,2) · TIME: minute granularity'; len.style.minWidth = '56px'; len.style.width = '56px'; row.append(name, type, len); colsWrap.appendChild(row); rows.push({ name, type, len }); @@ -65,7 +86,7 @@ export function openTableWizard(run: (cmd: string) => void, onClose: () => void) shell = new WizardShell( 'New table', - 'Define columns; blank rows are ignored. CHAR and NUM need a length.', + 'Define columns; blank rows are ignored. CHAR needs a length, NUM a width or precision,scale (8 or 8,2); TIME takes an optional minute-granularity (e.g. 15).', { okLabel: 'Create table', onOk: () => { const { cmd } = buildCommand(); if (cmd) { run(cmd); shell.close(); } diff --git a/tests/Builtins.test.ts b/tests/Builtins.test.ts index 621c200..6337c5e 100644 --- a/tests/Builtins.test.ts +++ b/tests/Builtins.test.ts @@ -124,6 +124,60 @@ describe('DAY', () => { it('invalid date returns 0', () => expect(callStateless('DAY', ['not-a-date'])).toBe(0)); }); +describe('WEEK', () => { + it('returns the ISO week number of a mid-year date', () => expect(callStateless('WEEK', ['2024-05-12'])).toBe(19)); + it('week 1 starts on the Monday of the week holding the first Thursday', () => { + expect(callStateless('WEEK', ['2024-01-01'])).toBe(1); // Monday, Jan 1 + expect(callStateless('WEEK', ['2026-01-01'])).toBe(1); // Thursday, Jan 1 + }); + it('early-January dates can belong to the last week of the previous year', () => { + expect(callStateless('WEEK', ['2021-01-01'])).toBe(53); // Friday → week 53 of 2020 + expect(callStateless('WEEK', ['2022-01-01'])).toBe(52); // Saturday → week 52 of 2021 + expect(callStateless('WEEK', ['2023-01-01'])).toBe(52); // Sunday → week 52 of 2022 + }); + it('late-December dates can belong to week 1 of the next year', () => { + expect(callStateless('WEEK', ['2024-12-30'])).toBe(1); // Monday → week 1 of 2025 + expect(callStateless('WEEK', ['2019-12-30'])).toBe(1); // Monday → week 1 of 2020 + }); + it('handles 53-week years', () => { + expect(callStateless('WEEK', ['2020-12-31'])).toBe(53); + expect(callStateless('WEEK', ['2026-12-31'])).toBe(53); + expect(callStateless('WEEK', ['2016-01-03'])).toBe(53); // Sunday → week 53 of 2015 + }); + it('accepts MM/DD/YY display dates', () => expect(callStateless('WEEK', ['05/12/24'])).toBe(19)); + it('invalid date returns 0', () => expect(callStateless('WEEK', ['not-a-date'])).toBe(0)); + it('ISO-shaped but impossible dates return 0 rather than rolling over', () => { + expect(callStateless('WEEK', ['2024-13-45'])).toBe(0); // month 13, day 45 + expect(callStateless('WEEK', ['2024-02-30'])).toBe(0); // Feb 30 never exists + expect(callStateless('WEEK', ['2023-02-29'])).toBe(0); // 2023 is not a leap year + }); + it('accepts a real leap day', () => expect(callStateless('WEEK', ['2024-02-29'])).toBe(9)); +}); + +describe('DATEADD', () => { + it('adds days within the same month', () => expect(callStateless('DATEADD', ['2024-05-12', 1])).toBe('2024-05-13')); + it('rolls over a month boundary', () => expect(callStateless('DATEADD', ['2024-01-31', 1])).toBe('2024-02-01')); + it('rolls over a year boundary', () => expect(callStateless('DATEADD', ['2024-12-31', 1])).toBe('2025-01-01')); + it('lands on a leap day', () => expect(callStateless('DATEADD', ['2024-02-28', 1])).toBe('2024-02-29')); + it('skips Feb 29 in a non-leap year', () => expect(callStateless('DATEADD', ['2023-02-28', 1])).toBe('2023-03-01')); + it('accepts a negative offset', () => { + expect(callStateless('DATEADD', ['2024-03-01', -1])).toBe('2024-02-29'); + expect(callStateless('DATEADD', ['2024-05-12', -20])).toBe('2024-04-22'); + }); + it('n = 0 returns the same date, normalised to ISO', () => { + expect(callStateless('DATEADD', ['2024-05-12', 0])).toBe('2024-05-12'); + expect(callStateless('DATEADD', ['05/12/24', 0])).toBe('2024-05-12'); + }); + it('derives a work week from its Monday', () => { + expect(callStateless('DATEADD', ['2026-07-06', 4])).toBe('2026-07-10'); + }); + it('invalid or impossible dates return an empty string', () => { + expect(callStateless('DATEADD', ['not-a-date', 1])).toBe(''); + expect(callStateless('DATEADD', ['2023-02-29', 1])).toBe(''); + expect(callStateless('DATEADD', ['2024-13-01', 1])).toBe(''); + }); +}); + describe('unknown function', () => { it('throws', () => expect(() => callStateless('FOOBAR', [])).toThrow('Unknown function: FOOBAR')); }); diff --git a/tests/BuiltinsParse.test.ts b/tests/BuiltinsParse.test.ts index 9cb5cfb..82fc67b 100644 --- a/tests/BuiltinsParse.test.ts +++ b/tests/BuiltinsParse.test.ts @@ -23,4 +23,8 @@ describe('built-in functions reachable through the REPL parser', () => { it('YEAR', async () => { expect(await evalPrint('YEAR(CTOD("12/25/2026"))')).toContain('2026'); }); it('MONTH', async () => { expect(await evalPrint('MONTH(CTOD("12/25/2026"))')).toContain('12'); }); it('DAY', async () => { expect(await evalPrint('DAY(CTOD("12/25/2026"))')).toContain('25'); }); + it('WEEK', async () => { expect(await evalPrint('WEEK("2024-05-12")')).toContain('19'); }); + it('WEEK across a year boundary', async () => { expect(await evalPrint('WEEK("2021-01-01")')).toContain('53'); }); + it('DATEADD', async () => { expect(await evalPrint('DATEADD("2024-01-31", 1)')).toContain('2024-02-01'); }); + it('DATEADD composes with CTOD', async () => { expect(await evalPrint('DATEADD(CTOD("05/12/24"), 4)')).toContain('2024-05-16'); }); }); diff --git a/tests/CellValidation.test.ts b/tests/CellValidation.test.ts new file mode 100644 index 0000000..da4fbf3 --- /dev/null +++ b/tests/CellValidation.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from 'vitest'; +import { validateCellValue } from '../src/shared/cellValidation'; +import type { ColumnMeta } from '../src/shared/cellValidation'; + +const meta = (baseType: string, qualifier: number | null = null, scale: number | null = null): ColumnMeta => + ({ baseType, qualifier, scale }); + +describe('validateCellValue', () => { + it('accepts any value for an unknown/untracked column', () => { + expect(validateCellValue('X', 'anything', undefined)).toBeNull(); + expect(validateCellValue('X', 'anything', null)).toBeNull(); + }); + + it('accepts an empty value for every type (clearing a cell to NULL)', () => { + for (const t of ['TIME', 'DATE', 'NUM', 'INT', 'LOGICAL']) { + expect(validateCellValue('X', '', meta(t))).toBeNull(); + expect(validateCellValue('X', ' ', meta(t))).toBeNull(); + } + }); + + it('does not constrain CHAR/MEMO', () => { + expect(validateCellValue('X', 'anything at all', meta('CHAR', 5))).toBeNull(); + expect(validateCellValue('X', 'anything at all', meta('MEMO'))).toBeNull(); + }); + + describe('TIME', () => { + it('accepts well-formed HH:MM', () => { + expect(validateCellValue('T', '00:00', meta('TIME'))).toBeNull(); + expect(validateCellValue('T', '23:59', meta('TIME'))).toBeNull(); + expect(validateCellValue('T', '09:07', meta('TIME'))).toBeNull(); + }); + it('rejects malformed or out-of-range values', () => { + expect(validateCellValue('T', '9:30', meta('TIME'))).toMatch(/HH:MM/); + expect(validateCellValue('T', '24:00', meta('TIME'))).toMatch(/HH:MM/); + expect(validateCellValue('T', '08:60', meta('TIME'))).toMatch(/HH:MM/); + expect(validateCellValue('T', 'noon', meta('TIME'))).toMatch(/HH:MM/); + }); + it('enforces the minute granularity qualifier', () => { + expect(validateCellValue('T', '08:15', meta('TIME', 15))).toBeNull(); + expect(validateCellValue('T', '08:45', meta('TIME', 15))).toBeNull(); + expect(validateCellValue('T', '08:07', meta('TIME', 15))).toMatch(/multiple of 15/); + expect(validateCellValue('T', '08:30', meta('TIME', 30))).toBeNull(); + expect(validateCellValue('T', '08:15', meta('TIME', 30))).toMatch(/multiple of 30/); + }); + }); + + describe('DATE', () => { + it('accepts a valid ISO calendar date', () => { + expect(validateCellValue('D', '2024-02-29', meta('DATE'))).toBeNull(); + expect(validateCellValue('D', '2026-12-31', meta('DATE'))).toBeNull(); + }); + it('rejects a wrong format', () => { + expect(validateCellValue('D', '12/25/26', meta('DATE'))).toMatch(/YYYY-MM-DD/); + expect(validateCellValue('D', '2024-2-9', meta('DATE'))).toMatch(/YYYY-MM-DD/); + }); + it('rejects an impossible calendar date', () => { + expect(validateCellValue('D', '2023-02-29', meta('DATE'))).toMatch(/not a real date/); + expect(validateCellValue('D', '2024-02-30', meta('DATE'))).toMatch(/not a real date/); + expect(validateCellValue('D', '2024-13-01', meta('DATE'))).toMatch(/not a real date/); + }); + }); + + describe('LOGICAL', () => { + it('accepts the dBASE and plain boolean literal set', () => { + for (const v of ['.T.', '.F.', '.TRUE.', '.FALSE.', 'T', 'F', 'true', 'FALSE', '1', '0']) { + expect(validateCellValue('L', v, meta('LOGICAL'))).toBeNull(); + } + }); + it('rejects anything else', () => { + expect(validateCellValue('L', 'yes', meta('LOGICAL'))).toMatch(/\.T\.|\.F\./); + expect(validateCellValue('L', '2', meta('LOGICAL'))).toMatch(/\.T\.|\.F\./); + }); + }); + + describe('INT', () => { + it('accepts integers, including negatives', () => { + expect(validateCellValue('I', '42', meta('INT'))).toBeNull(); + expect(validateCellValue('I', '-7', meta('INT'))).toBeNull(); + }); + it('rejects decimals and non-numbers', () => { + expect(validateCellValue('I', '4.2', meta('INT'))).toMatch(/whole number/); + expect(validateCellValue('I', 'abc', meta('INT'))).toMatch(/whole number/); + }); + }); + + describe('NUM', () => { + it('accepts any number when unqualified', () => { + expect(validateCellValue('N', '3.14159', meta('NUM'))).toBeNull(); + expect(validateCellValue('N', '-12', meta('NUM'))).toBeNull(); + }); + it('rejects non-numeric input', () => { + expect(validateCellValue('N', 'abc', meta('NUM'))).toMatch(/number/); + }); + it('enforces scale (digits after the decimal point)', () => { + expect(validateCellValue('N', '10.25', meta('NUM', 8, 2))).toBeNull(); + expect(validateCellValue('N', '10.257', meta('NUM', 8, 2))).toMatch(/2 decimal/); + }); + it('enforces precision (total digits)', () => { + expect(validateCellValue('N', '123456.78', meta('NUM', 8, 2))).toBeNull(); // 8 digits + expect(validateCellValue('N', '1234567.89', meta('NUM', 8, 2))).toMatch(/6 digit/); // 7 int digits > 8-2 + }); + it('treats NUM(n) with no scale as an integer-width limit', () => { + expect(validateCellValue('N', '123456', meta('NUM', 6))).toBeNull(); + expect(validateCellValue('N', '1234567', meta('NUM', 6))).toMatch(/6 digit/); + }); + }); +}); diff --git a/tests/ColumnMeta.test.ts b/tests/ColumnMeta.test.ts new file mode 100644 index 0000000..173e58f --- /dev/null +++ b/tests/ColumnMeta.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { Lexer } from '../src/interpreter/Lexer'; +import { Parser } from '../src/interpreter/Parser'; +import { Session } from '../server/Session'; +import type { ServerMessage } from '../src/shared/types'; +import fs from 'fs'; +import path from 'path'; + +let dbCounter = 0; +function makeSession() { + const sent: ServerMessage[] = []; + return { session: new Session((m: ServerMessage) => { sent.push(m); }), sent }; +} +function uniqueDb() { return `test_colmeta_${Date.now()}_${++dbCounter}`; } + +afterEach(() => { + const dataDir = path.join(process.cwd(), 'data'); + if (fs.existsSync(dataDir)) { + fs.readdirSync(dataDir) + .filter(f => f.toLowerCase().startsWith('test_colmeta_')) + .forEach(f => fs.unlinkSync(path.join(dataDir, f))); + } +}); + +async function run(session: Session, sent: ServerMessage[], text: string): Promise { + sent.length = 0; + await session.handleMessage({ type: 'command', text }); + const out = sent.find(m => m.type === 'output') as any; + return (out?.lines ?? []).map((l: any) => l.text); +} + +function parse(src: string) { + return new Parser(new Lexer(src).tokenize()).parse(); +} + +describe('Parser: NUM(p,s) precision/scale', () => { + it('captures both precision and scale, without inventing a phantom column', () => { + const ast = parse('CREATE TABLE t (price NUM(8,2))')[0] as any; + expect(ast.cols).toEqual([{ name: 'PRICE', colType: 'NUM', size: 8, scale: 2 }]); + }); + + it('keeps parsing the columns that follow a NUM(p,s)', () => { + const ast = parse('CREATE TABLE t (price NUM(8,2), active LOGICAL)')[0] as any; + expect(ast.cols.map((c: any) => c.name)).toEqual(['PRICE', 'ACTIVE']); + expect(ast.cols[1]).toEqual({ name: 'ACTIVE', colType: 'LOGICAL' }); + }); + + it('still parses a single-arg size', () => { + const ast = parse('CREATE TABLE t (name CHAR(40), qty NUM(6))')[0] as any; + expect(ast.cols).toEqual([ + { name: 'NAME', colType: 'CHAR', size: 40 }, + { name: 'QTY', colType: 'NUM', size: 6 }, + ]); + }); +}); + +describe('CREATE TABLE with NUM(p,s) creates only the declared columns', () => { + it('does not create a phantom column named after the scale', async () => { + const { session, sent } = makeSession(); + await run(session, sent, `USE DATABASE ${uniqueDb()}`); + await run(session, sent, 'CREATE TABLE products (name CHAR(10), price NUM(8,2), active LOGICAL)'); + await run(session, sent, 'USE products'); + const lines = await run(session, sent, 'LIST STRUCTURE'); + const struct = lines.join('\n'); + expect(struct).toContain('NAME'); + expect(struct).toContain('PRICE'); + expect(struct).toContain('ACTIVE'); + expect(struct).not.toMatch(/^\d+\s+2\s/m); // no column literally named "2" + }); +}); + +describe('LIST STRUCTURE prints declared types', () => { + it('shows CHAR(n), NUM(p,s), DATE, TIME(n), LOGICAL as declared', async () => { + const { session, sent } = makeSession(); + await run(session, sent, `USE DATABASE ${uniqueDb()}`); + await run(session, sent, 'CREATE TABLE t (a CHAR(10), b NUM(8,2), c DATE, d TIME(15), e LOGICAL, f INT)'); + await run(session, sent, 'USE t'); + const struct = (await run(session, sent, 'LIST STRUCTURE')).join('\n'); + expect(struct).toMatch(/A\s+CHAR\(10\)/); + expect(struct).toMatch(/B\s+NUM\(8,2\)/); + expect(struct).toMatch(/C\s+DATE/); + expect(struct).toMatch(/D\s+TIME\(15\)/); + expect(struct).toMatch(/E\s+LOGICAL/); + expect(struct).toMatch(/F\s+INT/); + }); +}); + +describe('grid-open carries declared column types', () => { + it('sends a columnTypes map alongside the raw SQLite columns', async () => { + const { session, sent } = makeSession(); + await run(session, sent, `USE DATABASE ${uniqueDb()}`); + await run(session, sent, 'CREATE TABLE t (name CHAR(10), price NUM(8,2), shift TIME(15))'); + await run(session, sent, 'USE t'); + sent.length = 0; + await session.handleMessage({ type: 'command', text: 'BROWSE' }); + const grid = sent.find(m => m.type === 'grid-open') as any; + expect(grid).toBeDefined(); + expect(grid.columnTypes.PRICE).toEqual({ baseType: 'NUM', qualifier: 8, scale: 2 }); + expect(grid.columnTypes.SHIFT).toEqual({ baseType: 'TIME', qualifier: 15, scale: null }); + expect(grid.columnTypes.NAME).toEqual({ baseType: 'CHAR', qualifier: 10, scale: null }); + }); +}); + +describe('grid-edit is validated server-side', () => { + async function browseTable(session: Session, sent: ServerMessage[]) { + await run(session, sent, `USE DATABASE ${uniqueDb()}`); + await run(session, sent, 'CREATE TABLE t (shift TIME(15), price NUM(8,2))'); + await run(session, sent, 'USE t'); + await run(session, sent, 'APPEND RECORD'); + sent.length = 0; + await session.handleMessage({ type: 'command', text: 'BROWSE' }); + const grid = sent.find(m => m.type === 'grid-open') as any; + return grid.rows[0]._rowid as number; + } + + it('rejects an invalid TIME(15) cell edit and does not write it', async () => { + const { session, sent } = makeSession(); + const rowid = await browseTable(session, sent); + + sent.length = 0; + await session.handleMessage({ type: 'grid-edit', rowid, col: 'SHIFT', value: '08:07' }); + const out = sent.find(m => m.type === 'output') as any; + expect(out).toBeDefined(); + expect(out.lines.map((l: any) => l.text).join('\n')).toMatch(/multiple of 15/); + + const lines = await run(session, sent, 'LIST'); + expect(lines.join('\n')).not.toContain('08:07'); + }); + + it('accepts a valid cell edit and writes it', async () => { + const { session, sent } = makeSession(); + const rowid = await browseTable(session, sent); + + await session.handleMessage({ type: 'grid-edit', rowid, col: 'SHIFT', value: '08:15' }); + const lines = await run(session, sent, 'LIST'); + expect(lines.join('\n')).toContain('08:15'); + }); + + it('rejects an out-of-scale NUM(8,2) cell edit', async () => { + const { session, sent } = makeSession(); + const rowid = await browseTable(session, sent); + + sent.length = 0; + await session.handleMessage({ type: 'grid-edit', rowid, col: 'PRICE', value: '1.234' }); + const out = sent.find(m => m.type === 'output') as any; + expect(out.lines.map((l: any) => l.text).join('\n')).toMatch(/2 decimal/); + }); +}); + +describe('column metadata is scoped per database', () => { + it('does not leak a declared type between same-named tables in different databases', async () => { + const { session, sent } = makeSession(); + const dbA = uniqueDb(); + const dbB = uniqueDb(); + + await run(session, sent, `USE DATABASE ${dbA}`); + await run(session, sent, 'CREATE TABLE shared (val TIME(15))'); + + await run(session, sent, `USE DATABASE ${dbB}`); + await run(session, sent, 'CREATE TABLE shared (val CHAR(20))'); + await run(session, sent, 'USE shared'); + await run(session, sent, 'APPEND RECORD'); + // CHAR is unconstrained — this must be accepted, not judged against TIME(15). + const lines = await run(session, sent, 'REPLACE val WITH "hello"'); + expect(lines.join('\n')).toContain('Replaced'); + + // And dbA's TIME(15) must still be enforced. + await run(session, sent, `USE DATABASE ${dbA}`); + await run(session, sent, 'USE shared'); + await run(session, sent, 'APPEND RECORD'); + const bad = await run(session, sent, 'REPLACE val WITH "08:07"'); + expect(bad.join('\n')).toMatch(/\*\* Error/); + }); +}); diff --git a/tests/ColumnMetaStore.test.ts b/tests/ColumnMetaStore.test.ts new file mode 100644 index 0000000..357c012 --- /dev/null +++ b/tests/ColumnMetaStore.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import Database from 'better-sqlite3'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { ColumnMetaStore } from '../server/ColumnMetaStore'; + +const tmpFiles: string[] = []; +function tmpDbPath(): string { + const p = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'wb3-colmeta-')), 'system.sqlite3'); + tmpFiles.push(p); + return p; +} + +afterEach(() => { + while (tmpFiles.length) { + const p = tmpFiles.pop()!; + fs.rmSync(path.dirname(p), { recursive: true, force: true }); + } +}); + +describe('ColumnMetaStore', () => { + it('round-trips a declared type with qualifier and scale', () => { + const store = new ColumnMetaStore(tmpDbPath()); + store.setColumnType('DB', 'T', 'PRICE', 'NUM', 8, 2); + expect(store.getColumnType('DB', 'T', 'PRICE')).toEqual({ baseType: 'NUM', qualifier: 8, scale: 2 }); + }); + + it('scopes metadata per database', () => { + const store = new ColumnMetaStore(tmpDbPath()); + store.setColumnType('A', 'SHARED', 'VAL', 'TIME', 15, null); + store.setColumnType('B', 'SHARED', 'VAL', 'CHAR', 20, null); + expect(store.getColumnType('A', 'SHARED', 'VAL')).toEqual({ baseType: 'TIME', qualifier: 15, scale: null }); + expect(store.getColumnType('B', 'SHARED', 'VAL')).toEqual({ baseType: 'CHAR', qualifier: 20, scale: null }); + }); + + it('returns null for an untracked column', () => { + const store = new ColumnMetaStore(tmpDbPath()); + expect(store.getColumnType('DB', 'T', 'NOPE')).toBeNull(); + }); + + it('drops, renames, and lists per (db, table)', () => { + const store = new ColumnMetaStore(tmpDbPath()); + store.setColumnType('DB', 'T', 'A', 'TIME', 15, null); + store.setColumnType('DB', 'T', 'B', 'DATE', null, null); + store.setColumnType('DB', 'OTHER', 'A', 'INT', null, null); + + expect(Object.keys(store.listColumnTypes('DB', 'T')).sort()).toEqual(['A', 'B']); + + store.renameColumn('DB', 'T', 'A', 'RENAMED'); + expect(store.getColumnType('DB', 'T', 'RENAMED')?.baseType).toBe('TIME'); + expect(store.getColumnType('DB', 'T', 'A')).toBeNull(); + + store.dropColumn('DB', 'T', 'B'); + expect(store.getColumnType('DB', 'T', 'B')).toBeNull(); + + store.dropTable('DB', 'T'); + expect(store.listColumnTypes('DB', 'T')).toEqual({}); + expect(store.getColumnType('DB', 'OTHER', 'A')?.baseType).toBe('INT'); // untouched + }); + + // The #43 cut of this table had neither db_name nor scale. Opening an old file + // must migrate rather than throw "no such column". + it('migrates a pre-#45 column_types table', () => { + const p = tmpDbPath(); + const legacy = new Database(p); + legacy.exec(` + CREATE TABLE column_types ( + table_name TEXT NOT NULL, col_name TEXT NOT NULL, + base_type TEXT NOT NULL, qualifier INTEGER, + PRIMARY KEY (table_name, col_name) + ); + `); + legacy.prepare('INSERT INTO column_types VALUES (?,?,?,?)').run('SHIFTS', 'STARTTIME', 'TIME', 15); + legacy.close(); + + const store = new ColumnMetaStore(p); + store.setColumnType('MYDB', 'SHIFTS', 'STARTTIME', 'TIME', 15, null); + expect(store.getColumnType('MYDB', 'SHIFTS', 'STARTTIME')).toEqual({ baseType: 'TIME', qualifier: 15, scale: null }); + expect(store.getColumnType('OTHERDB', 'SHIFTS', 'STARTTIME')).toBeNull(); + }); +}); diff --git a/tests/CreateTableParse.test.ts b/tests/CreateTableParse.test.ts new file mode 100644 index 0000000..5da91bd --- /dev/null +++ b/tests/CreateTableParse.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest'; +import { Lexer } from '../src/interpreter/Lexer'; +import { Parser } from '../src/interpreter/Parser'; + +function parse(src: string) { + return new Parser(new Lexer(src).tokenize()).parse(); +} +function cols(src: string) { + return (parse(src)[0] as any).cols; +} + +// The parser used to absorb any token it did not understand and invent a column +// from it. That is how `NUM(8,2)` silently produced a phantom column named "2" +// of type ")". Malformed input must fail loudly instead. (#50) +describe('CREATE TABLE rejects malformed column definitions', () => { + it('rejects a missing comma between columns', () => { + expect(() => parse('CREATE TABLE t (a CHAR(10) b INT)')).toThrow(/CREATE TABLE/i); + }); + + it('rejects an empty column slot (double comma)', () => { + expect(() => parse('CREATE TABLE t (a CHAR(10),, b INT)')).toThrow(/CREATE TABLE/i); + }); + + it('rejects a column with no type', () => { + expect(() => parse('CREATE TABLE t (a)')).toThrow(/CREATE TABLE/i); + }); + + it('rejects an unclosed column list', () => { + expect(() => parse('CREATE TABLE t (a CHAR(10)')).toThrow(/CREATE TABLE/i); + }); + + it('rejects a third argument in a type qualifier', () => { + expect(() => parse('CREATE TABLE t (a NUM(8,2,9))')).toThrow(/CREATE TABLE/i); + }); + + it('rejects a non-numeric type qualifier', () => { + expect(() => parse('CREATE TABLE t (a CHAR(x))')).toThrow(/CREATE TABLE/i); + }); + + it('rejects an unclosed type qualifier', () => { + expect(() => parse('CREATE TABLE t (a CHAR(10, b INT)')).toThrow(/CREATE TABLE/i); + }); + + it('names the offending column in the error', () => { + expect(() => parse('CREATE TABLE t (a CHAR(10) b INT)')).toThrow(/b/i); + }); +}); + +describe('CREATE TABLE still accepts every valid form', () => { + it('a bare table with no column list', () => { + expect((parse('CREATE TABLE t')[0] as any).cols).toEqual([]); + }); + + it('types with no qualifier', () => { + expect(cols('CREATE TABLE t (a DATE, b LOGICAL, c INT, d MEMO)')).toEqual([ + { name: 'A', colType: 'DATE' }, + { name: 'B', colType: 'LOGICAL' }, + { name: 'C', colType: 'INT' }, + { name: 'D', colType: 'MEMO' }, + ]); + }); + + it('single-argument qualifiers', () => { + expect(cols('CREATE TABLE t (a CHAR(40), b NUM(6), c TIME(15))')).toEqual([ + { name: 'A', colType: 'CHAR', size: 40 }, + { name: 'B', colType: 'NUM', size: 6 }, + { name: 'C', colType: 'TIME', size: 15 }, + ]); + }); + + it('two-argument NUM(p,s)', () => { + expect(cols('CREATE TABLE t (price NUM(8,2), active LOGICAL)')).toEqual([ + { name: 'PRICE', colType: 'NUM', size: 8, scale: 2 }, + { name: 'ACTIVE', colType: 'LOGICAL' }, + ]); + }); + + it('a trailing comma before the closing paren', () => { + // dBASE-era sources are sloppy; a trailing comma is harmless, not corrupting. + expect(cols('CREATE TABLE t (a INT,)')).toEqual([{ name: 'A', colType: 'INT' }]); + }); + + it('the exact demo-table definitions still parse to their declared columns', () => { + expect(cols('CREATE TABLE PRODUCTS (PRODID CHAR(6), CATID CHAR(4), NAME CHAR(40), STOCK NUM(6), REORDER NUM(6), PRICE NUM(8,2), ACTIVE LOGICAL)') + .map((c: any) => c.name)) + .toEqual(['PRODID', 'CATID', 'NAME', 'STOCK', 'REORDER', 'PRICE', 'ACTIVE']); + }); +}); diff --git a/tests/DemoSchemas.test.ts b/tests/DemoSchemas.test.ts new file mode 100644 index 0000000..4621c40 --- /dev/null +++ b/tests/DemoSchemas.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { Session } from '../server/Session'; +import { Lexer } from '../src/interpreter/Lexer'; +import { Parser } from '../src/interpreter/Parser'; +import type { ServerMessage } from '../src/shared/types'; +import fs from 'fs'; +import path from 'path'; + +/** + * Golden schemas for the tables the demo programs create. (#50) + * + * These are deliberate pins, not derived from the source — a demo schema change + * must be a conscious edit here too. They exist because `NUM(8,2)` used to add a + * phantom column named "2" to PRODUCTS/DEALS/SALES and every `toContain`-style + * assertion in the suite sailed straight past it. + */ +const DEMO_SCHEMAS: Record = { + COMPANIES: ['COMPID', 'NAME', 'INDUSTRY', 'CITY'], + CONTACTS: ['CONTID', 'COMPID', 'NAME', 'EMAIL', 'PHONE'], + DEALS: ['DEALID', 'COMPID', 'TITLE', 'STAGE', 'VALUE', 'CLOSEMONTH'], + CATEGORIES: ['CATID', 'CATNAME', 'NOTES'], + PRODUCTS: ['PRODID', 'CATID', 'NAME', 'STOCK', 'REORDER', 'PRICE', 'ACTIVE'], + MOVEMENTS: ['MOVID', 'PRODID', 'KIND', 'QTY', 'MMONTH', 'REASON'], + SALES: ['REGION', 'PRODUCT', 'AMOUNT', 'QTY'], + + // overtime.prg (#46) + EMPLOYEES: ['EMPID', 'NAME', 'SCHEDID'], + SCHEDULEDAYS: ['SCHEDID', 'DOW', 'TIMEIN', 'BSTART', 'BEND', 'TIMEOUT'], + TIMESHEET: ['EMPID', 'WEEKDATE', 'DOW', 'WORKDATE', 'TIMEIN', 'BSTART', 'BEND', 'TIMEOUT', 'WORKEDHOURS'], + WEEKSUMMARY: ['EMPID', 'WEEKDATE', 'WEEKNO', 'WORKEDHOURS', 'STANDARDHOURS', 'OVERTIME'], + LEAVETAKEN: ['EMPID', 'LDATE', 'HOURS', 'NOTES'], +}; + +const DEMOS_DIR = path.join(process.cwd(), 'demos'); + +/** Every `CREATE TABLE …` statement written in demos/*.prg, keyed by table name. */ +function demoCreateStatements(): Map { + const out = new Map(); + for (const f of fs.readdirSync(DEMOS_DIR).filter(f => f.toLowerCase().endsWith('.prg'))) { + const src = fs.readFileSync(path.join(DEMOS_DIR, f), 'utf8'); + for (const line of src.split('\n')) { + const m = line.trim().match(/^CREATE\s+TABLE\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/i); + if (m) out.set(m[1].toUpperCase(), line.trim()); + } + } + return out; +} + +let dbCounter = 0; +function uniqueDb() { return `test_demoschema_${Date.now()}_${++dbCounter}`; } + +afterEach(() => { + const dataDir = path.join(process.cwd(), 'data'); + if (fs.existsSync(dataDir)) { + fs.readdirSync(dataDir) + .filter(f => f.toLowerCase().startsWith('test_demoschema_')) + .forEach(f => fs.unlinkSync(path.join(dataDir, f))); + } +}); + +describe('demo table schemas', () => { + const statements = demoCreateStatements(); + + it('every pinned table is actually created by a demo program', () => { + expect([...statements.keys()].sort()).toEqual(Object.keys(DEMO_SCHEMAS).sort()); + }); + + for (const [table, expectedCols] of Object.entries(DEMO_SCHEMAS)) { + it(`${table} parses to exactly its declared columns`, () => { + const stmt = statements.get(table); + expect(stmt, `no CREATE TABLE ${table} found in demos/*.prg`).toBeDefined(); + const ast = new Parser(new Lexer(stmt!).tokenize()).parse()[0] as any; + expect(ast.cols.map((c: any) => c.name)).toEqual(expectedCols); + }); + + it(`${table} creates exactly its declared columns in SQLite`, async () => { + const sent: ServerMessage[] = []; + const session = new Session((m) => sent.push(m)); + await session.handleMessage({ type: 'command', text: `USE DATABASE ${uniqueDb()}` }); + await session.handleMessage({ type: 'command', text: statements.get(table)! }); + await session.handleMessage({ type: 'command', text: `USE ${table}` }); + + sent.length = 0; + await session.handleMessage({ type: 'command', text: 'BROWSE' }); + const grid = sent.find(m => m.type === 'grid-open') as any; + expect(grid.columns.map((c: any) => c.name)).toEqual(expectedCols); + }); + } + + it('no demo table has a column whose name is a bare number', () => { + // The phantom-column signature: NUM(8,2) leaked a column literally named "2". + for (const [table, stmt] of statements) { + const ast = new Parser(new Lexer(stmt).tokenize()).parse()[0] as any; + for (const c of ast.cols) { + expect(/^\d+$/.test(c.name), `${table} has a numeric column name "${c.name}"`).toBe(false); + } + } + }); +}); diff --git a/tests/GridMessages.test.ts b/tests/GridMessages.test.ts new file mode 100644 index 0000000..dd54155 --- /dev/null +++ b/tests/GridMessages.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { Session } from '../server/Session'; +import type { ServerMessage } from '../src/shared/types'; +import fs from 'fs'; +import path from 'path'; + +/** + * The grid's write path had no test at all before #50: `grid-edit`, `grid-delete`, + * `grid-new-row` and `grid-refresh` were never sent by any test, so `grid-edit` + * could `UPDATE` any column with any value unnoticed. These drive each message and + * assert the effect on the database. + */ +let dbCounter = 0; +function uniqueDb() { return `test_gridmsg_${Date.now()}_${++dbCounter}`; } + +afterEach(() => { + const dataDir = path.join(process.cwd(), 'data'); + if (fs.existsSync(dataDir)) { + fs.readdirSync(dataDir) + .filter(f => f.toLowerCase().startsWith('test_gridmsg_')) + .forEach(f => fs.unlinkSync(path.join(dataDir, f))); + } +}); + +async function setup() { + const sent: ServerMessage[] = []; + const session = new Session((m) => sent.push(m)); + const run = async (text: string) => { + sent.length = 0; + await session.handleMessage({ type: 'command', text }); + const out = sent.find(m => m.type === 'output') as any; + return (out?.lines ?? []).map((l: any) => l.text).join('\n'); + }; + await run(`USE DATABASE ${uniqueDb()}`); + await run('CREATE TABLE t (name CHAR(20), qty INT)'); + await run('USE t'); + return { session, sent, run }; +} + +/** Open the grid and return its rows. */ +async function browse(session: Session, sent: ServerMessage[]) { + sent.length = 0; + await session.handleMessage({ type: 'command', text: 'BROWSE' }); + const grid = sent.find(m => m.type === 'grid-open') as any; + return grid; +} + +describe('grid WebSocket messages', () => { + it('grid-new-row inserts a blank record and returns the refreshed grid', async () => { + const { session, sent, run } = await setup(); + await browse(session, sent); + + sent.length = 0; + await session.handleMessage({ type: 'grid-new-row' }); + const grid = sent.find(m => m.type === 'grid-open') as any; + expect(grid.rows).toHaveLength(1); + expect(grid.rows[0].NAME).toBeNull(); + + expect(await run('LIST')).not.toContain('(No records)'); + }); + + it('grid-edit writes the value to the right row and column', async () => { + const { session, sent, run } = await setup(); + await run('APPEND RECORD'); + await run('APPEND RECORD'); + const grid = await browse(session, sent); + const secondRowId = grid.rows[1]._rowid; + + await session.handleMessage({ type: 'grid-edit', rowid: secondRowId, col: 'NAME', value: 'second' }); + + const after = await browse(session, sent); + expect(after.rows[0].NAME).toBeNull(); // first row untouched + expect(after.rows[1].NAME).toBe('second'); + }); + + it('grid-delete removes only the targeted row', async () => { + const { session, sent, run } = await setup(); + await run('APPEND RECORD'); + await run('REPLACE name WITH "keep"'); + await run('APPEND RECORD'); + await run('REPLACE name WITH "drop"'); + + const grid = await browse(session, sent); + const dropId = grid.rows.find((r: any) => r.NAME === 'drop')._rowid; + + sent.length = 0; + await session.handleMessage({ type: 'grid-delete', rowid: dropId }); + const refreshed = sent.find(m => m.type === 'grid-open') as any; + expect(refreshed.rows).toHaveLength(1); + expect(refreshed.rows[0].NAME).toBe('keep'); + + expect(await run('LIST')).not.toContain('drop'); + }); + + it('grid-refresh re-reads the table after an out-of-band change', async () => { + const { session, sent, run } = await setup(); + await run('APPEND RECORD'); + await browse(session, sent); + + // Mutate through the REPL while the grid is open. + await run('REPLACE name WITH "changed"'); + + sent.length = 0; + await session.handleMessage({ type: 'grid-refresh' }); + const grid = sent.find(m => m.type === 'grid-open') as any; + expect(grid.rows[0].NAME).toBe('changed'); + }); + + it('grid-edit is rejected when it violates the declared column type', async () => { + const sent: ServerMessage[] = []; + const session = new Session((m) => sent.push(m)); + const run = async (text: string) => { + sent.length = 0; + await session.handleMessage({ type: 'command', text }); + const out = sent.find(m => m.type === 'output') as any; + return (out?.lines ?? []).map((l: any) => l.text).join('\n'); + }; + await run(`USE DATABASE ${uniqueDb()}`); + await run('CREATE TABLE s (shift TIME(15))'); + await run('USE s'); + await run('APPEND RECORD'); + const grid = await browse(session, sent); + + sent.length = 0; + await session.handleMessage({ type: 'grid-edit', rowid: grid.rows[0]._rowid, col: 'SHIFT', value: '08:07' }); + const out = sent.find(m => m.type === 'output') as any; + expect(out.lines.map((l: any) => l.text).join('\n')).toMatch(/multiple of 15/); + expect(await run('LIST')).not.toContain('08:07'); + }); +}); + +describe('INPUT command', () => { + // `INPUT` collects its value through the form surface (form-open / form-submit). + // The `input-request`/`input-response` message types were declared in the protocol + // but never sent or handled by anything; they were removed in #50. + it('opens a form and stores the submitted value in the variable', async () => { + const sent: ServerMessage[] = []; + const session = new Session((m) => sent.push(m)); + + await session.handleMessage({ type: 'command', text: 'INPUT "Name? " TO who' }); + const form = sent.find(m => m.type === 'form-open') as any; + expect(form).toBeDefined(); + expect(form.fields.at(-1)).toMatchObject({ varName: 'WHO', label: 'Name? ' }); + + await session.handleMessage({ type: 'form-submit', values: { WHO: 'Ada' } }); + + sent.length = 0; + await session.handleMessage({ type: 'command', text: '? who' }); + const out = sent.find(m => m.type === 'output') as any; + expect(out.lines.map((l: any) => l.text).join('\n')).toContain('Ada'); + }); +}); diff --git a/tests/IndexStoreMigration.test.ts b/tests/IndexStoreMigration.test.ts new file mode 100644 index 0000000..bfcbe3b --- /dev/null +++ b/tests/IndexStoreMigration.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { IndexStore } from '../server/IndexStore'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const dirs: string[] = []; +function workspace(): { sysPath: string; dataDir: string } { + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wb3-idxmig-')); + dirs.push(dataDir); + return { sysPath: path.join(dataDir, 'system.sqlite3'), dataDir }; +} +function legacySystemDb(sysPath: string, rows: [string, string, string][]) { + const d = new Database(sysPath); + d.exec(` + CREATE TABLE indexes ( + id INTEGER PRIMARY KEY, table_name TEXT NOT NULL, tag TEXT NOT NULL, + expression TEXT NOT NULL, created_at INTEGER DEFAULT (unixepoch()), + UNIQUE(table_name, tag) + ); + CREATE TABLE active_indexes (table_name TEXT PRIMARY KEY, tag TEXT NOT NULL); + `); + for (const [t, tag, expr] of rows) { + d.prepare('INSERT INTO indexes (table_name, tag, expression) VALUES (?,?,?)').run(t, tag, expr); + d.prepare('INSERT OR REPLACE INTO active_indexes (table_name, tag) VALUES (?,?)').run(t, tag); + } + d.close(); +} +function userDbWithTable(dataDir: string, dbName: string, table: string) { + const d = new Database(path.join(dataDir, `${dbName}.sqlite3`)); + d.exec(`CREATE TABLE "${table}" (x TEXT)`); + d.close(); +} + +afterEach(() => { while (dirs.length) fs.rmSync(dirs.pop()!, { recursive: true, force: true }); }); + +describe('IndexStore migration from the pre-#50 unscoped schema', () => { + it('adopts a legacy index into the one database that owns the table', () => { + const { sysPath, dataDir } = workspace(); + legacySystemDb(sysPath, [['PEOPLE', 'BYNAME', 'LASTNAME']]); + userDbWithTable(dataDir, 'HRDB', 'PEOPLE'); + + const store = new IndexStore(sysPath, dataDir); + expect(store.listIndexes('HRDB', 'PEOPLE')).toEqual([{ tag: 'BYNAME', expression: 'LASTNAME' }]); + expect(store.getActive('HRDB', 'PEOPLE')).toEqual({ tag: 'BYNAME', expression: 'LASTNAME' }); + expect(store.listIndexes('', 'PEOPLE')).toEqual([]); // no unscoped rows survive + }); + + it('drops a legacy index whose owning database is ambiguous', () => { + const { sysPath, dataDir } = workspace(); + legacySystemDb(sysPath, [['PEOPLE', 'BYNAME', 'LASTNAME']]); + userDbWithTable(dataDir, 'HRDB', 'PEOPLE'); + userDbWithTable(dataDir, 'CRMDB', 'PEOPLE'); // two owners → ambiguous + + const store = new IndexStore(sysPath, dataDir); + expect(store.listIndexes('HRDB', 'PEOPLE')).toEqual([]); + expect(store.listIndexes('CRMDB', 'PEOPLE')).toEqual([]); + }); + + it('drops a legacy index whose table no longer exists anywhere', () => { + const { sysPath, dataDir } = workspace(); + legacySystemDb(sysPath, [['GHOST', 'BYNAME', 'LASTNAME']]); + + const store = new IndexStore(sysPath, dataDir); + expect(store.listIndexes('', 'GHOST')).toEqual([]); + expect(store.getActive('', 'GHOST')).toBeNull(); + }); + + it('is idempotent — reopening an already-migrated store keeps the rows', () => { + const { sysPath, dataDir } = workspace(); + legacySystemDb(sysPath, [['PEOPLE', 'BYNAME', 'LASTNAME']]); + userDbWithTable(dataDir, 'HRDB', 'PEOPLE'); + + new IndexStore(sysPath, dataDir); + const reopened = new IndexStore(sysPath, dataDir); + expect(reopened.listIndexes('HRDB', 'PEOPLE')).toEqual([{ tag: 'BYNAME', expression: 'LASTNAME' }]); + }); +}); diff --git a/tests/Indexing.test.ts b/tests/Indexing.test.ts index 2e15856..a837950 100644 --- a/tests/Indexing.test.ts +++ b/tests/Indexing.test.ts @@ -25,8 +25,8 @@ afterEach(() => { describe('IndexStore', () => { it('saves and retrieves an index definition', () => { const store = new IndexStore(tmpPath()); - store.saveIndex('customers', 'byname', 'lastname+firstname'); - const indexes = store.listIndexes('customers'); + store.saveIndex('DB', 'customers', 'byname', 'lastname+firstname'); + const indexes = store.listIndexes('DB', 'customers'); expect(indexes).toHaveLength(1); expect(indexes[0].tag).toBe('byname'); expect(indexes[0].expression).toBe('lastname+firstname'); @@ -34,36 +34,61 @@ describe('IndexStore', () => { it('sets and gets active index', () => { const store = new IndexStore(tmpPath()); - store.saveIndex('customers', 'byname', 'lastname'); - store.setActive('customers', 'byname'); - expect(store.getActive('customers')).toEqual({ tag: 'byname', expression: 'lastname' }); + store.saveIndex('DB', 'customers', 'byname', 'lastname'); + store.setActive('DB', 'customers', 'byname'); + expect(store.getActive('DB', 'customers')).toEqual({ tag: 'byname', expression: 'lastname' }); }); it('clears active index', () => { const store = new IndexStore(tmpPath()); - store.saveIndex('customers', 'byname', 'lastname'); - store.setActive('customers', 'byname'); - store.clearActive('customers'); - expect(store.getActive('customers')).toBeNull(); + store.saveIndex('DB', 'customers', 'byname', 'lastname'); + store.setActive('DB', 'customers', 'byname'); + store.clearActive('DB', 'customers'); + expect(store.getActive('DB', 'customers')).toBeNull(); }); it('returns null getActive when no index set', () => { const store = new IndexStore(tmpPath()); - expect(store.getActive('customers')).toBeNull(); + expect(store.getActive('DB', 'customers')).toBeNull(); }); it('upserts index definition on duplicate tag', () => { const store = new IndexStore(tmpPath()); - store.saveIndex('customers', 'byname', 'lastname'); - store.saveIndex('customers', 'byname', 'firstname'); - const indexes = store.listIndexes('customers'); + store.saveIndex('DB', 'customers', 'byname', 'lastname'); + store.saveIndex('DB', 'customers', 'byname', 'firstname'); + const indexes = store.listIndexes('DB', 'customers'); expect(indexes).toHaveLength(1); expect(indexes[0].expression).toBe('firstname'); }); it('setActive throws when tag does not exist', () => { const store = new IndexStore(tmpPath()); - expect(() => store.setActive('customers', 'ghost')).toThrow("Index 'ghost' not found on table 'customers'"); + expect(() => store.setActive('DB', 'customers', 'ghost')).toThrow("Index 'ghost' not found on table 'customers'"); + }); + + // #50 — the key used to omit the database, so opening PEOPLE in one database + // activated an index defined on another database's PEOPLE. + it('scopes index definitions and the active marker per database', () => { + const store = new IndexStore(tmpPath()); + store.saveIndex('A', 'PEOPLE', 'BYNAME', 'LASTNAME'); + store.setActive('A', 'PEOPLE', 'BYNAME'); + + expect(store.listIndexes('B', 'PEOPLE')).toEqual([]); + expect(store.getActive('B', 'PEOPLE')).toBeNull(); + expect(store.getActive('A', 'PEOPLE')).toEqual({ tag: 'BYNAME', expression: 'LASTNAME' }); + + store.saveIndex('B', 'PEOPLE', 'BYFULL', 'FULLNAME'); + store.setActive('B', 'PEOPLE', 'BYFULL'); + expect(store.getActive('A', 'PEOPLE')?.tag).toBe('BYNAME'); // unchanged + }); + + it('dropTable only clears the named database', () => { + const store = new IndexStore(tmpPath()); + store.saveIndex('A', 'PEOPLE', 'BYNAME', 'LASTNAME'); + store.saveIndex('B', 'PEOPLE', 'BYNAME', 'LASTNAME'); + store.dropTable('A', 'PEOPLE'); + expect(store.listIndexes('A', 'PEOPLE')).toEqual([]); + expect(store.listIndexes('B', 'PEOPLE')).toHaveLength(1); }); }); diff --git a/tests/TimeType.test.ts b/tests/TimeType.test.ts new file mode 100644 index 0000000..d9b747d --- /dev/null +++ b/tests/TimeType.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { Session } from '../server/Session'; +import type { ServerMessage } from '../src/shared/types'; +import fs from 'fs'; +import path from 'path'; + +let dbCounter = 0; +function makeSession() { + const sent: ServerMessage[] = []; + const send = (msg: ServerMessage) => { sent.push(msg); }; + return { session: new Session(send), sent }; +} +function uniqueDb() { return `test_time_${Date.now()}_${++dbCounter}`; } + +afterEach(() => { + const dataDir = path.join(process.cwd(), 'data'); + if (fs.existsSync(dataDir)) { + fs.readdirSync(dataDir) + .filter(f => f.toLowerCase().startsWith('test_time_')) + .forEach(f => fs.unlinkSync(path.join(dataDir, f))); + } +}); + +async function run(session: Session, sent: ServerMessage[], text: string): Promise { + sent.length = 0; + await session.handleMessage({ type: 'command', text }); + const out = sent.find(m => m.type === 'output') as any; + return (out?.lines ?? []).map((l: any) => l.text); +} + +describe('TIME column type', () => { + it('creates a table with plain TIME and TIME(15) columns, LIST STRUCTURE shows them', async () => { + const { session, sent } = makeSession(); + const db = uniqueDb(); + await run(session, sent, `USE DATABASE ${db}`); + await run(session, sent, 'CREATE TABLE shifts (person CHAR(20), starttime TIME, breaktime TIME(15))'); + await run(session, sent, 'USE shifts'); + const lines = await run(session, sent, 'LIST STRUCTURE'); + const struct = lines.join('\n'); + expect(struct).toContain('STARTTIME'); + expect(struct).toMatch(/STARTTIME\s+TIME\b/); + expect(struct).toMatch(/BREAKTIME\s+TIME\(15\)/); + }); + + it('rejects a malformed TIME value on REPLACE', async () => { + const { session, sent } = makeSession(); + const db = uniqueDb(); + await run(session, sent, `USE DATABASE ${db}`); + await run(session, sent, 'CREATE TABLE shifts (starttime TIME)'); + await run(session, sent, 'USE shifts'); + await run(session, sent, 'APPEND RECORD'); + const lines = await run(session, sent, 'REPLACE starttime WITH "9:30"'); + expect(lines.join('\n')).toMatch(/\*\* Error/); + }); + + it('rejects an out-of-range TIME value on REPLACE', async () => { + const { session, sent } = makeSession(); + const db = uniqueDb(); + await run(session, sent, `USE DATABASE ${db}`); + await run(session, sent, 'CREATE TABLE shifts (starttime TIME)'); + await run(session, sent, 'USE shifts'); + await run(session, sent, 'APPEND RECORD'); + const lines = await run(session, sent, 'REPLACE starttime WITH "25:00"'); + expect(lines.join('\n')).toMatch(/\*\* Error/); + }); + + it('accepts a well-formed TIME value on REPLACE', async () => { + const { session, sent } = makeSession(); + const db = uniqueDb(); + await run(session, sent, `USE DATABASE ${db}`); + await run(session, sent, 'CREATE TABLE shifts (starttime TIME)'); + await run(session, sent, 'USE shifts'); + await run(session, sent, 'APPEND RECORD'); + const lines = await run(session, sent, 'REPLACE starttime WITH "09:30"'); + expect(lines.join('\n')).toContain('Replaced'); + const listLines = await run(session, sent, 'LIST'); + expect(listLines.join('\n')).toContain('09:30'); + }); + + it('rejects a TIME(15) value that violates the granularity qualifier', async () => { + const { session, sent } = makeSession(); + const db = uniqueDb(); + await run(session, sent, `USE DATABASE ${db}`); + await run(session, sent, 'CREATE TABLE shifts (breaktime TIME(15))'); + await run(session, sent, 'USE shifts'); + await run(session, sent, 'APPEND RECORD'); + const lines = await run(session, sent, 'REPLACE breaktime WITH "08:07"'); + expect(lines.join('\n')).toMatch(/\*\* Error/); + }); + + it('accepts a TIME(15) value on a quarter-hour boundary', async () => { + const { session, sent } = makeSession(); + const db = uniqueDb(); + await run(session, sent, `USE DATABASE ${db}`); + await run(session, sent, 'CREATE TABLE shifts (breaktime TIME(15))'); + await run(session, sent, 'USE shifts'); + await run(session, sent, 'APPEND RECORD'); + const lines = await run(session, sent, 'REPLACE breaktime WITH "08:15"'); + expect(lines.join('\n')).toContain('Replaced'); + }); + + it('allows APPEND RECORD to leave TIME columns NULL without validation error', async () => { + const { session, sent } = makeSession(); + const db = uniqueDb(); + await run(session, sent, `USE DATABASE ${db}`); + await run(session, sent, 'CREATE TABLE shifts (starttime TIME(15))'); + await run(session, sent, 'USE shifts'); + const lines = await run(session, sent, 'APPEND RECORD'); + expect(lines.join('\n')).toContain('Record appended'); + }); +}); diff --git a/tests/assistant.spec.ts b/tests/assistant.spec.ts index 4e3b12b..e69f106 100644 --- a/tests/assistant.spec.ts +++ b/tests/assistant.spec.ts @@ -57,6 +57,39 @@ test.describe('Assistant sidebar', () => { await page.keyboard.press('Escape'); await expect(page.locator('#terminal-view')).toBeVisible({ timeout: 5000 }); }); + + // #45 — the grid opened from the Assistant validates edits per declared type. + test('grid opened via the Assistant Browse action validates cell edits', async ({ page }) => { + await boot(page); + for (const c of [ + 'USE DATABASE ASSISTDEMO', + 'DROP TABLE asst_shifts', + 'CREATE TABLE asst_shifts (STARTTIME TIME(15))', + 'USE asst_shifts', + 'APPEND RECORD', + ]) { + await page.locator('#terminal-input').fill(c); + await page.locator('#terminal-input').press('Enter'); + await page.waitForTimeout(400); + } + + await clickAction(page, 'Browse'); + await expect(page.locator('#grid-view')).toBeVisible({ timeout: 5000 }); + + const td = page.locator('#grid-tbody td[data-ri="0"][data-ci="0"]'); + await td.dblclick(); + await td.locator('input.cell-ed').fill('08:07'); + await page.keyboard.press('Enter'); + await expect(td).toHaveClass(/cell-invalid/); + await expect(td.locator('.cell-error')).toContainText('multiple of 15'); + + await td.locator('input.cell-ed').fill('08:30'); + await page.keyboard.press('Enter'); + await expect(td).toContainText('08:30'); + + await page.keyboard.press('Escape'); + await expect(page.locator('#terminal-view')).toBeVisible({ timeout: 5000 }); + }); }); test.describe('Assistant wizards — table', () => { @@ -85,6 +118,92 @@ test.describe('Assistant wizards — table', () => { await expect(page.locator('#terminal-output')).toContainText('. CREATE TABLE wiz_products (NAME CHAR(30))'); await expect(page.locator('#status-table')).toContainText('WIZ_PRODUCTS', { timeout: 5000 }); }); + + test('New table wizard supports TIME(n) and REPLACE validates it end-to-end', async ({ page }) => { + await boot(page); + await page.locator('#terminal-input').fill('USE DATABASE ASSISTDEMO'); + await page.locator('#terminal-input').press('Enter'); + await page.waitForTimeout(400); + await page.locator('#terminal-input').fill('DROP TABLE wiz_shifts'); + await page.locator('#terminal-input').press('Enter'); + await page.waitForTimeout(400); + + await clickAction(page, 'New table…'); + await expect(page.locator('#wizard-view')).toBeVisible({ timeout: 5000 }); + + await page.locator('#wz-table-name').fill('wiz_shifts'); + await page.locator('.wz-col-name').first().fill('STARTTIME'); + await page.locator('.wz-col-type').first().selectOption('TIME'); + await page.locator('.wz-col-len').first().fill('15'); + + await expect(page.locator('.wz-preview')).toContainText('CREATE TABLE wiz_shifts (STARTTIME TIME(15))'); + await page.locator('#wizard-view button', { hasText: 'Create table' }).click(); + await expect(page.locator('#terminal-view')).toBeVisible({ timeout: 5000 }); + await expect(page.locator('#terminal-output')).toContainText('. CREATE TABLE wiz_shifts (STARTTIME TIME(15))'); + + await page.locator('#terminal-input').fill('LIST STRUCTURE'); + await page.locator('#terminal-input').press('Enter'); + await page.waitForTimeout(400); + await expect(page.locator('#terminal-output')).toContainText('TIME(15)'); + + await page.locator('#terminal-input').fill('APPEND RECORD'); + await page.locator('#terminal-input').press('Enter'); + await page.waitForTimeout(400); + + // Off-granularity value is rejected — no silent coercion. + await page.locator('#terminal-input').fill('REPLACE STARTTIME WITH "08:07"'); + await page.locator('#terminal-input').press('Enter'); + await page.waitForTimeout(400); + await expect(page.locator('#terminal-output')).toContainText('** Error'); + + // Valid quarter-hour value commits. + await page.locator('#terminal-input').fill('REPLACE STARTTIME WITH "08:15"'); + await page.locator('#terminal-input').press('Enter'); + await page.waitForTimeout(400); + await expect(page.locator('#terminal-output')).toContainText('Replaced'); + + await page.locator('#terminal-input').fill('LIST'); + await page.locator('#terminal-input').press('Enter'); + await page.waitForTimeout(400); + await expect(page.locator('#terminal-output')).toContainText('08:15'); + }); + + // #50 — NUM(p,s) is a real qualifier now, so the wizard must be able to express it. + test('New table wizard emits NUM(p,s) and the table has exactly the declared columns', async ({ page }) => { + await boot(page); + for (const c of ['USE DATABASE ASSISTDEMO', 'DROP TABLE wiz_priced']) { + await page.locator('#terminal-input').fill(c); + await page.locator('#terminal-input').press('Enter'); + await page.waitForTimeout(400); + } + + await clickAction(page, 'New table…'); + await expect(page.locator('#wizard-view')).toBeVisible({ timeout: 5000 }); + + await page.locator('#wz-table-name').fill('wiz_priced'); + await page.locator('.wz-col-name').first().fill('PRICE'); + await page.locator('.wz-col-type').first().selectOption('NUM'); + await page.locator('.wz-col-len').first().fill('8,2'); + await expect(page.locator('.wz-preview')).toContainText('CREATE TABLE wiz_priced (PRICE NUM(8,2))'); + + // Scale must be smaller than precision — the wizard blocks it. + await page.locator('.wz-col-len').first().fill('2,8'); + await expect(page.locator('.wz-error')).toContainText('Scale must be smaller'); + + await page.locator('.wz-col-len').first().fill('8,2'); + await page.locator('#wizard-view button', { hasText: 'Create table' }).click(); + await expect(page.locator('#terminal-view')).toBeVisible({ timeout: 5000 }); + + await page.locator('#terminal-input').fill('LIST STRUCTURE'); + await page.locator('#terminal-input').press('Enter'); + await page.waitForTimeout(500); + await expect(page.locator('#terminal-output')).toContainText('NUM(8,2)'); + + // Exactly one column — no phantom "2" from the scale. + const lines = await page.locator('#terminal-output .t-line').allTextContents(); + const numbered = lines.filter(l => /^\s*\d+\s+\w+/.test(l) && !/record/i.test(l)); + expect(numbered).toHaveLength(1); + }); }); test.describe('Assistant wizards — filter / index / search', () => { diff --git a/tests/grid-validation.spec.ts b/tests/grid-validation.spec.ts new file mode 100644 index 0000000..b59d915 --- /dev/null +++ b/tests/grid-validation.spec.ts @@ -0,0 +1,120 @@ +/** #45 — BROWSE per-cell validation, exercised in a real browser. */ +import { test, expect, Page } from '@playwright/test'; + +async function cmd(page: Page, command: string, waitMs = 600): Promise { + const input = page.locator('#terminal-input'); + await input.fill(command); + await input.press('Enter'); + await page.waitForTimeout(waitMs); +} + +async function boot(page: Page, db: string): Promise { + await page.goto('/'); + await expect(page.locator('#terminal-output')).toContainText('Connected.', { timeout: 8000 }); + await cmd(page, `USE DATABASE ${db}`); +} + +/** Open the cell editor for a given row/column index. */ +async function editCell(page: Page, ri: number, ci: number) { + const td = page.locator(`#grid-tbody td[data-ri="${ri}"][data-ci="${ci}"]`); + await td.dblclick(); + await expect(td.locator('input.cell-ed')).toBeVisible(); + return td; +} + +test.describe('BROWSE cell validation', () => { + test('rejects an invalid TIME(15) edit inline and commits a valid one', async ({ page }) => { + await boot(page, `e2e_gridval_time_${Date.now()}`); + await cmd(page, 'CREATE TABLE shifts (person CHAR(20), starttime TIME(15))'); + await cmd(page, 'USE shifts'); + await cmd(page, 'APPEND RECORD'); + await cmd(page, 'BROWSE', 1000); + await expect(page.locator('#grid-view')).toBeVisible({ timeout: 5000 }); + + // Malformed time — rejected, cell stays in edit mode with a visible reason. + const td = await editCell(page, 0, 1); + await td.locator('input.cell-ed').fill('9:30'); + await page.keyboard.press('Enter'); + await expect(td).toHaveClass(/cell-invalid/); + await expect(td.locator('.cell-error')).toContainText('HH:MM'); + // The cell has `overflow: hidden`, so the message can be present, styled + // visible, and still clipped away from the user. toBeInViewport uses an + // IntersectionObserver and therefore accounts for ancestor clipping; + // toContainText / toBeVisible do not. + await expect(td.locator('.cell-error')).toBeInViewport(); + await expect(td.locator('input.cell-ed')).toBeVisible(); // still editing + + // Off-granularity time — rejected for a different reason. + await td.locator('input.cell-ed').fill('08:07'); + await page.keyboard.press('Enter'); + await expect(td).toHaveClass(/cell-invalid/); + await expect(td.locator('.cell-error')).toContainText('multiple of 15'); + await expect(td.locator('input.cell-ed')).toBeVisible(); + + // Valid quarter-hour — the error clears as you type and the edit commits. + await td.locator('input.cell-ed').fill('08:15'); + await expect(td).not.toHaveClass(/cell-invalid/); + await page.keyboard.press('Enter'); + await expect(td.locator('input.cell-ed')).toHaveCount(0); // edit closed + await expect(td).toContainText('08:15'); + + // And it really landed in the database. + await page.keyboard.press('Escape'); + await expect(page.locator('#terminal-view')).toBeVisible({ timeout: 5000 }); + await cmd(page, 'LIST'); + await expect(page.locator('#terminal-output')).toContainText('08:15'); + }); + + test('rejects a bad NUM(8,2) and DATE edit, and an unconstrained CHAR accepts anything', async ({ page }) => { + await boot(page, `e2e_gridval_types_${Date.now()}`); + await cmd(page, 'CREATE TABLE t (name CHAR(20), price NUM(8,2), due DATE)'); + await cmd(page, 'USE t'); + await cmd(page, 'APPEND RECORD'); + await cmd(page, 'BROWSE', 1000); + await expect(page.locator('#grid-view')).toBeVisible({ timeout: 5000 }); + + // CHAR is unconstrained — commits as typed. + const name = await editCell(page, 0, 0); + await name.locator('input.cell-ed').fill('anything at all'); + await page.keyboard.press('Enter'); + await expect(name).toContainText('anything at all'); + + // NUM(8,2) — too many decimals. + const price = await editCell(page, 0, 1); + await price.locator('input.cell-ed').fill('1.234'); + await page.keyboard.press('Enter'); + await expect(price).toHaveClass(/cell-invalid/); + await expect(price.locator('.cell-error')).toContainText('2 decimal'); + await price.locator('input.cell-ed').fill('1.23'); + await page.keyboard.press('Enter'); + await expect(price).toContainText('1.23'); + + // DATE — not a real calendar date. + const due = await editCell(page, 0, 2); + await due.locator('input.cell-ed').fill('2023-02-29'); + await page.keyboard.press('Enter'); + await expect(due).toHaveClass(/cell-invalid/); + await expect(due.locator('.cell-error')).toContainText('not a real date'); + await due.locator('input.cell-ed').fill('2024-02-29'); + await page.keyboard.press('Enter'); + await expect(due).toContainText('2024-02-29'); + }); + + test('Escape abandons an invalid edit and restores the original value', async ({ page }) => { + await boot(page, `e2e_gridval_esc_${Date.now()}`); + await cmd(page, 'CREATE TABLE s (starttime TIME(15))'); + await cmd(page, 'USE s'); + await cmd(page, 'APPEND RECORD'); + await cmd(page, 'REPLACE starttime WITH "09:00"'); + await cmd(page, 'BROWSE', 1000); + + const td = await editCell(page, 0, 0); + await td.locator('input.cell-ed').fill('99:99'); + await page.keyboard.press('Enter'); + await expect(td).toHaveClass(/cell-invalid/); + + await page.keyboard.press('Escape'); // abandon the edit + await expect(td.locator('input.cell-ed')).toHaveCount(0); + await expect(td).toContainText('09:00'); // original value intact + }); +}); diff --git a/tests/overtime.spec.ts b/tests/overtime.spec.ts new file mode 100644 index 0000000..aeeafe5 --- /dev/null +++ b/tests/overtime.spec.ts @@ -0,0 +1,216 @@ +/** Playwright E2E for demos/overtime.prg — overtime tracker showcase (#46). */ +import { test, expect, Page } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PRG_SRC = fs.readFileSync(path.join(__dirname, '..', 'demos', 'overtime.prg'), 'utf8'); +const PRG_NAME = 'overtime'; +const WEEK = '2026-07-06'; // a Monday; ISO week 28 + +async function cmd(page: Page, command: string, waitMs = 700): Promise { + const input = page.locator('#terminal-input'); + await input.fill(command); + await input.press('Enter'); + await page.waitForTimeout(waitMs); +} +async function waitForOutput(page: Page, text: string, timeout = 6000): Promise { + await expect(page.locator('#terminal-output')).toContainText(text, { timeout, ignoreCase: true }); +} +async function boot(page: Page): Promise { + await page.goto('http://localhost:5173'); + await waitForOutput(page, 'Connected.', 8000); +} +async function seedProgram(page: Page): Promise { + await cmd(page, `EDIT ${PRG_NAME}`, 1500); + await expect(page.locator('#editor-view')).toBeVisible({ timeout: 6000 }); + await page.locator('#editor-textarea').fill(PRG_SRC); + await page.waitForTimeout(300); + await page.keyboard.press('Control+s'); + await page.waitForTimeout(400); + await page.keyboard.press('Escape'); + await page.waitForTimeout(400); +} +/** Submit the menu's GET field (or any single-field form). */ +async function menuChoice(page: Page, choice: string, waitMs = 1500): Promise { + await expect(page.locator('#form-view')).toBeVisible({ timeout: 6000 }); + const input = page.locator('#form-view input.f-get').last(); + await input.fill(choice); + await input.press('Enter'); + await page.waitForTimeout(waitMs); +} +/** Fill the visible form's GET fields in order, then submit. */ +async function fillForm(page: Page, values: string[], waitMs = 1500): Promise { + await expect(page.locator('#form-view')).toBeVisible({ timeout: 6000 }); + const fields = page.locator('#form-view input.f-get'); + for (let i = 0; i < values.length; i++) await fields.nth(i).fill(values[i]); + await fields.nth(values.length - 1).press('Enter'); + await page.waitForTimeout(waitMs); +} +/** In the open TIMESHEET grid, make Friday (row 5) finish 2h late. */ +async function workLateOnFriday(page: Page): Promise { + await expect(page.locator('#grid-view')).toBeVisible({ timeout: 6000 }); + // TIMESHEET columns: EMPID(0) WEEKDATE(1) DOW(2) WORKDATE(3) TIMEIN(4) BSTART(5) BEND(6) TIMEOUT(7) + const td = page.locator('#grid-tbody td[data-ri="4"][data-ci="7"]'); + await td.dblclick(); + await td.locator('input.cell-ed').fill('18:30'); + await page.keyboard.press('Enter'); + await expect(td).toContainText('18:30'); + await page.waitForTimeout(400); + await page.keyboard.press('Escape'); // leave grid, back to the menu + await page.waitForTimeout(1200); +} + +/** Dismiss an "Press Enter to continue" INPUT prompt. */ +async function ack(page: Page, waitMs = 1000): Promise { + const form = page.locator('#form-view'); + if (await form.isVisible({ timeout: 1500 }).catch(() => false)) { + const input = form.locator('input.f-get').last(); + await input.fill(''); + await input.press('Enter'); + await page.waitForTimeout(waitMs); + } +} + +test.describe('Overtime demo', () => { + test.beforeEach(async ({ page }) => { + await boot(page); + await seedProgram(page); + // Clean slate: the OVERTIME database persists server-side across suites. + await cmd(page, 'USE DATABASE OVERTIME'); + for (const t of ['EMPLOYEES', 'SCHEDULEDAYS', 'TIMESHEET', 'WEEKSUMMARY', 'LEAVETAKEN']) { + await cmd(page, `DROP TABLE ${t}`, 150); + } + await cmd(page, `DO ${PRG_NAME}`, 2000); + }); + + test('runs and shows the main menu', async ({ page }) => { + await expect(page.locator('#form-view')).toBeVisible({ timeout: 8000 }); + await expect(page.locator('#form-view')).toContainText('OVERTIME TRACKER', { timeout: 6000 }); + }); + + test('seeds two employees on different schedules', async ({ page }) => { + await menuChoice(page, '9'); // table tour → LIST to the terminal + // Assert before dismissing: the menu loop's CLEAR wipes the terminal on its + // next iteration, and the form overlay is merely covering it, not clearing it. + await expect(page.locator('#terminal-output')).toContainText('Ada Lovelace'); + await expect(page.locator('#terminal-output')).toContainText('Grace Hopper'); + await expect(page.locator('#terminal-output')).toContainText('S002'); + await ack(page); + }); + + test('prep week derives Mon-Fri work dates with DATEADD and shows the ISO week', async ({ page }) => { + await menuChoice(page, '3'); + await fillForm(page, ['E001', WEEK], 2000); + // WEEK("2026-07-06") == 28, rendered in the form next to its label. + await expect(page.locator('#form-view')).toContainText('28', { timeout: 6000 }); + await expect(page.locator('#form-view')).toContainText(/created 5 timesheet days/i, { timeout: 6000 }); + + await ack(page, 1500); // "Press Enter to edit this week" → BROWSE + await expect(page.locator('#grid-view')).toBeVisible({ timeout: 6000 }); + // DATEADD walked Monday..Friday. + await expect(page.locator('#grid-tbody')).toContainText('2026-07-06'); + await expect(page.locator('#grid-tbody')).toContainText('2026-07-10'); + // Leaving the grid resumes the program, so the menu comes back — not the terminal. + await page.keyboard.press('Escape'); + await expect(page.locator('#form-view')).toContainText('OVERTIME TRACKER', { timeout: 6000 }); + }); + + test('the schedule grid rejects an off-quarter TIME(15) edit', async ({ page }) => { + await menuChoice(page, '2'); + await ack(page, 1500); // "Press Enter to open the grid" + await expect(page.locator('#grid-view')).toBeVisible({ timeout: 6000 }); + + // TIMEIN is column index 2 (SCHEDID, DOW, TIMEIN, ...). + const td = page.locator('#grid-tbody td[data-ri="0"][data-ci="2"]'); + await td.dblclick(); + await td.locator('input.cell-ed').fill('08:07'); + await page.keyboard.press('Enter'); + await expect(td).toHaveClass(/cell-invalid/); + await expect(td.locator('.cell-error')).toContainText('multiple of 15'); + + await td.locator('input.cell-ed').fill('08:15'); + await page.keyboard.press('Enter'); + await expect(td).toContainText('08:15'); + await page.keyboard.press('Escape'); + }); + + test('recalculate computes worked, standard and overtime hours', async ({ page }) => { + await menuChoice(page, '3'); + await fillForm(page, ['E001', WEEK], 2000); + await ack(page, 1500); // → BROWSE the new week + await workLateOnFriday(page); // 16:30 → 18:30, through the validated grid + + await menuChoice(page, '4'); + await fillForm(page, ['E001', WEEK], 2500); + + // 4 x 8.00 + 10.00 = 42.00 worked; schedule S001 = 40.00 standard; +2.00 overtime. + const form = page.locator('#form-view'); + await expect(form).toContainText('42.00', { timeout: 6000 }); + await expect(form).toContainText('40.00', { timeout: 6000 }); + await expect(form).toContainText('2.00', { timeout: 6000 }); + }); + + test('overtime balance = banked overtime minus leave taken', async ({ page }) => { + // Bank 2h of overtime first. + await menuChoice(page, '3'); + await fillForm(page, ['E001', WEEK], 2000); + await ack(page, 1500); + await workLateOnFriday(page); + + await menuChoice(page, '4'); + await fillForm(page, ['E001', WEEK], 2500); + await ack(page, 1200); + + // Take 1.5h of leave. + await menuChoice(page, '5'); + await fillForm(page, ['E001', '2026-07-13', '1.5', 'early friday'], 2000); + await expect(page.locator('#form-view')).toContainText(/leave registered/i, { timeout: 6000 }); + await ack(page, 1200); + + // Balance = 2.00 banked - 1.50 taken = 0.50 + await menuChoice(page, '6'); + await fillForm(page, ['E001'], 2000); + const form = page.locator('#form-view'); + await expect(form).toContainText('2.00', { timeout: 6000 }); // banked + await expect(form).toContainText('1.50', { timeout: 6000 }); // taken + await expect(form).toContainText('0.50', { timeout: 6000 }); // balance + }); + + test('leave form rejects hours that are not a quarter-hour step', async ({ page }) => { + await menuChoice(page, '5'); + await fillForm(page, ['E001', '2026-07-13', '1.3', 'bad step'], 2000); + await expect(page.locator('#form-view')).toContainText(/not a quarter-hour step/i, { timeout: 6000 }); + }); + + test('overtime report renders grouped by employee', async ({ page }) => { + await menuChoice(page, '3'); + await fillForm(page, ['E001', WEEK], 2000); + await ack(page, 1500); + await workLateOnFriday(page); + await menuChoice(page, '4'); + await fillForm(page, ['E001', WEEK], 2500); + await ack(page, 1200); + + await menuChoice(page, '7', 2500); + // Assert before the menu's next CLEAR wipes the terminal. + await expect(page.locator('#terminal-output')).toContainText('Overtime by Employee', { timeout: 6000 }); + await expect(page.locator('#terminal-output')).toContainText('E001'); + }); + + test('exports the week summary to CSV', async ({ page }) => { + await menuChoice(page, '3'); + await fillForm(page, ['E001', WEEK], 2000); + await ack(page, 1500); + await workLateOnFriday(page); + await menuChoice(page, '4'); + await fillForm(page, ['E001', WEEK], 2500); + await ack(page, 1200); + + const download = page.waitForEvent('download', { timeout: 10000 }); + await menuChoice(page, '8', 2000); + const file = await download; + expect(file.suggestedFilename()).toBe('weeksummary.csv'); + }); +}); diff --git a/tests/parity-commands.spec.ts b/tests/parity-commands.spec.ts index 4581807..dade118 100644 --- a/tests/parity-commands.spec.ts +++ b/tests/parity-commands.spec.ts @@ -18,6 +18,14 @@ async function boot(page: Page, dbName: string): Promise { await waitForOutput(page, 'Opened database', 3000); } +// Run `? ` and return the printed value — the last rendered output line, +// which is the result rather than the echoed command. +async function printResult(page: Page, expr: string): Promise { + await cmd(page, `? ${expr}`); + const text = await page.locator('#terminal-output .t-line').last().textContent() ?? ''; + return text.trim(); +} + test.describe('Parity commands e2e', () => { test('1. ? / ?? print expressions', async ({ page }) => { @@ -63,6 +71,42 @@ test.describe('Parity commands e2e', () => { await expect(page.locator('#terminal-output')).toContainText(/\d\d:\d\d:\d\d/, { timeout: 3000 }); }); + // #44 — WEEK() ISO-8601 week number, asserted on the printed value (not just + // "the digits appear somewhere in the scrollback"). + test('2b. WEEK() built-in via ?', async ({ page }) => { + await boot(page, `e2e_parity_week_${Date.now()}`); + + expect(await printResult(page, 'WEEK("2024-05-12")')).toBe('19'); + + // Week 1 is the week holding the year's first Thursday. + expect(await printResult(page, 'WEEK("2026-01-01")')).toBe('1'); + + // Early January can fall in the previous year's last week … + expect(await printResult(page, 'WEEK("2021-01-01")')).toBe('53'); + expect(await printResult(page, 'WEEK("2023-01-01")')).toBe('52'); + + // … and late December in week 1 of the next. + expect(await printResult(page, 'WEEK("2024-12-30")')).toBe('1'); + + // Composes with CTOD, like the other date built-ins. + expect(await printResult(page, 'WEEK(CTOD("05/12/24"))')).toBe('19'); + }); + + // #52 — DATEADD() day arithmetic, asserted on the printed value. + test('2c. DATEADD() built-in via ?', async ({ page }) => { + await boot(page, `e2e_parity_dateadd_${Date.now()}`); + + expect(await printResult(page, 'DATEADD("2024-05-12", 1)')).toBe('2024-05-13'); + expect(await printResult(page, 'DATEADD("2024-01-31", 1)')).toBe('2024-02-01'); // month rollover + expect(await printResult(page, 'DATEADD("2024-12-31", 1)')).toBe('2025-01-01'); // year rollover + expect(await printResult(page, 'DATEADD("2024-02-28", 1)')).toBe('2024-02-29'); // leap day + expect(await printResult(page, 'DATEADD("2023-02-28", 1)')).toBe('2023-03-01'); // non-leap + expect(await printResult(page, 'DATEADD("2024-03-01", -1)')).toBe('2024-02-29'); // negative + + // The Monday-to-Friday span a timesheet week needs. + expect(await printResult(page, 'DATEADD("2026-07-06", 4)')).toBe('2026-07-10'); + }); + test('3. SUM and AVERAGE', async ({ page }) => { const db = `e2e_parity_sum_${Date.now()}`; await boot(page, db); diff --git a/tests/schema-errors.spec.ts b/tests/schema-errors.spec.ts new file mode 100644 index 0000000..71da012 --- /dev/null +++ b/tests/schema-errors.spec.ts @@ -0,0 +1,70 @@ +/** #50 — CREATE TABLE fails loudly on malformed input instead of inventing columns. */ +import { test, expect, Page } from '@playwright/test'; + +async function cmd(page: Page, command: string, waitMs = 600): Promise { + const input = page.locator('#terminal-input'); + await input.fill(command); + await input.press('Enter'); + await page.waitForTimeout(waitMs); +} + +async function boot(page: Page, db: string): Promise { + await page.goto('/'); + await expect(page.locator('#terminal-output')).toContainText('Connected.', { timeout: 8000 }); + await cmd(page, `USE DATABASE ${db}`); +} + +test.describe('CREATE TABLE schema errors', () => { + test('a malformed column list reports an error in the REPL', async ({ page }) => { + await boot(page, `e2e_schema_err_${Date.now()}`); + + await cmd(page, 'CREATE TABLE bad (a CHAR(10) b INT)'); // missing comma + await expect(page.locator('#terminal-output')).toContainText('Parse error'); + await expect(page.locator('#terminal-output')).toContainText("expected ')'"); + await expect(page.locator('#terminal-output')).toContainText("table 'BAD'"); + + // The table must not exist — a failed parse creates nothing. + await cmd(page, 'LIST TABLES'); + await expect(page.locator('#terminal-output')).toContainText('(No tables)'); + }); + + test('NUM(p,s) creates exactly the declared columns — no phantom "2"', async ({ page }) => { + await boot(page, `e2e_schema_nps_${Date.now()}`); + + await cmd(page, 'CREATE TABLE prod (name CHAR(20), price NUM(8,2), active LOGICAL)'); + await cmd(page, 'USE prod'); + await cmd(page, 'LIST STRUCTURE', 900); + + const text = await page.locator('#terminal-output').textContent() ?? ''; + expect(text).toContain('NAME'); + expect(text).toContain('PRICE'); + expect(text).toContain('ACTIVE'); + expect(text).toContain('NUM(8,2)'); + + // Exactly three columns: the structure listing numbers them 1..n. + const rows = await page.locator('#terminal-output .t-line').allTextContents(); + const numbered = rows.filter(l => /^\s*\d+\s+\w+/.test(l) && !/record/i.test(l)); + expect(numbered).toHaveLength(3); + }); +}); + +test.describe('INPUT at the REPL', () => { + // A bare `INPUT … TO var` produces no continuation, and the submitted value used + // to be discarded. It only worked inside a program, where a following statement + // happened to create one. (#50) + test('a bare INPUT stores the submitted value in the variable', async ({ page }) => { + await boot(page, `e2e_input_${Date.now()}`); + + await cmd(page, 'INPUT "Name? " TO who'); + await expect(page.locator('#form-view')).toBeVisible({ timeout: 5000 }); + + const field = page.locator('#form-view input.f-get').last(); + await field.fill('Ada'); + await field.press('Enter'); + await expect(page.locator('#form-view')).toBeHidden({ timeout: 5000 }); + + await cmd(page, '? who'); + const last = await page.locator('#terminal-output .t-line').last().textContent(); + expect(last?.trim()).toBe('Ada'); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 8363e16..b14efea 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,5 +4,14 @@ export default defineConfig({ test: { environment: 'node', include: ['tests/**/*.test.ts'], + // Reporting only — no thresholds. `npm run coverage` exists so untested + // modules stop hiding: two bugs shipped in code no test ever executed (#50). + coverage: { + provider: 'v8', + reporter: ['text-summary', 'html'], + reportsDirectory: 'coverage', + include: ['src/**/*.ts', 'server/**/*.ts'], + exclude: ['src/main.ts', '**/*.d.ts'], + }, }, });