From 8bcbdddc55424605404bc5e05b71999c8265566a Mon Sep 17 00:00:00 2001 From: Dennis Decoene Date: Thu, 9 Jul 2026 18:20:43 +0200 Subject: [PATCH 1/9] release: begin v1.2.0 line Bump version to 1.2.0 and add Unreleased changelog heading for TIME columns, WEEK(), BROWSE cell validation, and the Overtime Tracking demo. --- CHANGELOG.md | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebf29dd..6192e90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ Versions follow [Semantic Versioning](https://semver.org/) — minor bump per su --- +## [Unreleased] — v1.2.0 — TIME columns, WEEK(), grid validation, Overtime demo + +### Added +- (pending) + +--- + ## [1.1.1] — 2026-07-05 — Docs refresh ### Changed diff --git a/package-lock.json b/package-lock.json index b32584e..7534dfc 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" diff --git a/package.json b/package.json index 9b80088..f6f2f6f 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", From 9254627c8b4438778b859c0c45c9f1389272ec0a Mon Sep 17 00:00:00 2001 From: Dennis Decoene Date: Thu, 9 Jul 2026 18:34:11 +0200 Subject: [PATCH 2/9] feat: TIME column type with granularity qualifier (#43) Add TIME/TIME(n) as a first-class CREATE TABLE column type, stored as HH:MM text. The optional TIME(n) qualifier requires minutes to be a multiple of n (e.g. TIME(15) for quarter-hour increments). A new ColumnMetaStore (data/system.sqlite3, same pattern as IndexStore) tracks base type + qualifier per column since SQLite's own type affinity can't distinguish TIME from CHAR/DATE (all TEXT). REPLACE ... WITH validates against it and rejects malformed or off-granularity values instead of silently coercing them; LIST STRUCTURE prints the declared type. The New table wizard and Modify structure wizard both offer TIME as a column type. --- CHANGELOG.md | 6 +- CLAUDE.md | 15 ++++ README.md | 2 + server/ColumnMetaStore.ts | 66 ++++++++++++++++++ server/Session.ts | 3 +- src/interpreter/Executor.ts | 51 +++++++++++++- src/shared/types.ts | 17 +++++ src/ui/wizards/ModStructWizard.ts | 2 +- src/ui/wizards/TableWizard.ts | 14 +++- tests/TimeType.test.ts | 111 ++++++++++++++++++++++++++++++ tests/assistant.spec.ts | 49 +++++++++++++ 11 files changed, 328 insertions(+), 8 deletions(-) create mode 100644 server/ColumnMetaStore.ts create mode 100644 tests/TimeType.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6192e90..cec76f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,11 @@ Versions follow [Semantic Versioning](https://semver.org/) — minor bump per su ## [Unreleased] — v1.2.0 — TIME columns, WEEK(), grid validation, Overtime demo ### Added -- (pending) +- `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) --- diff --git a/CLAUDE.md b/CLAUDE.md index c504659..5be64f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -166,6 +166,21 @@ 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 yet 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`, `TIME(15)`) rather than the raw SQLite storage class. + ### Indexing & search | Command | What it does | |---|---| diff --git a/README.md b/README.md index d0882e7..6e866c5 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,8 @@ 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`. +**Column types**: `CHAR(n)` (aliases `CHARACTER`/`VARCHAR`/`STRING`/`MEMO`), `NUM` (`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. + > **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 diff --git a/server/ColumnMetaStore.ts b/server/ColumnMetaStore.ts new file mode 100644 index 0000000..deef153 --- /dev/null +++ b/server/ColumnMetaStore.ts @@ -0,0 +1,66 @@ +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'); + +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 ( + table_name TEXT NOT NULL, + col_name TEXT NOT NULL, + base_type TEXT NOT NULL, + qualifier INTEGER, + PRIMARY KEY (table_name, col_name) + ); + `); + } + + setColumnType(tableName: string, colName: string, baseType: string, qualifier: number | null): void { + this.db.prepare(` + INSERT INTO column_types (table_name, col_name, base_type, qualifier) + VALUES (?, ?, ?, ?) + ON CONFLICT(table_name, col_name) DO UPDATE SET base_type = excluded.base_type, qualifier = excluded.qualifier + `).run(tableName, colName, baseType, qualifier); + } + + getColumnType(tableName: string, colName: string): ColumnTypeInfo | null { + const row = this.db.prepare( + 'SELECT base_type AS baseType, qualifier FROM column_types WHERE table_name = ? AND col_name = ?' + ).get(tableName, colName) as ColumnTypeInfo | undefined; + return row ?? null; + } + + listColumnTypes(tableName: string): Record { + const rows = this.db.prepare( + 'SELECT col_name AS colName, base_type AS baseType, qualifier FROM column_types WHERE table_name = ?' + ).all(tableName) as Array; + const out: Record = {}; + for (const r of rows) out[r.colName] = { baseType: r.baseType, qualifier: r.qualifier }; + return out; + } + + renameColumn(tableName: string, oldName: string, newName: string): void { + this.db.prepare( + 'UPDATE column_types SET col_name = ? WHERE table_name = ? AND col_name = ?' + ).run(newName, tableName, oldName); + } + + dropColumn(tableName: string, colName: string): void { + this.db.prepare('DELETE FROM column_types WHERE table_name = ? AND col_name = ?').run(tableName, colName); + } + + dropTable(tableName: string): void { + this.db.prepare('DELETE FROM column_types WHERE table_name = ?').run(tableName); + } +} + +export const columnMetaStore = new ColumnMetaStore(); diff --git a/server/Session.ts b/server/Session.ts index 3357ddc..1ffd776 100644 --- a/server/Session.ts +++ b/server/Session.ts @@ -6,6 +6,7 @@ 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 type { ClientMessage, ServerMessage, ColInfo } from '../src/shared/types.js'; export class Session { @@ -23,7 +24,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, diff --git a/src/interpreter/Executor.ts b/src/interpreter/Executor.ts index 39e930d..3452fc1 100644 --- a/src/interpreter/Executor.ts +++ b/src/interpreter/Executor.ts @@ -1,4 +1,4 @@ -import { IDatabaseBridge, IIndexStore, OutputLine, FormField, WorkArea, ClientSideEffect } from '../shared/types'; +import { IDatabaseBridge, IIndexStore, IColumnMetaStore, OutputLine, FormField, WorkArea, ClientSideEffect } from '../shared/types'; import { ASTNode, Expr, ColDef, Parser } from './Parser'; import { Lexer } from './Lexer'; import { callStateless } from './Builtins'; @@ -35,13 +35,30 @@ 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'; } } +const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/; + +// Throws if `value` is not a well-formed HH:MM string satisfying `qualifier` +// (a minute-granularity requirement, e.g. 15 for TIME(15)). +function validateTimeValue(field: string, value: unknown, qualifier: number | null): void { + if (value === null || value === undefined) return; + const s = String(value); + const m = TIME_RE.exec(s); + if (!m) { + throw new Error(`${field}: invalid TIME value "${s}" — expected HH:MM (00-23:00-59)`); + } + const minutes = Number(m[2]); + if (qualifier && minutes % qualifier !== 0) { + throw new Error(`${field}: TIME value "${s}" violates TIME(${qualifier}) granularity — minutes must be a multiple of ${qualifier}`); + } +} + function makeArea(alias: string): WorkArea { return { alias, @@ -69,6 +86,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'; @@ -367,13 +385,16 @@ 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.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 info = meta[c.name]; + const typeText = info ? (info.qualifier ? `${info.baseType}(${info.qualifier})` : info.baseType) : 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 +432,12 @@ export class Executor implements IndexCommandsHost { this.requireTable(); await this.refreshRecCount(); const pairs = fields.map(f => ({ field: f.field, value: this.evalExpr(f.value) })); + for (const p of pairs) { + const info = this.columnMetaStore?.getColumnType(this.area.table!, p.field); + if (info?.baseType === 'TIME') { + validateTimeValue(p.field, p.value, info.qualifier); + } + } 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 +767,18 @@ 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); + for (const c of cols) { + if (c.colType.toUpperCase() === 'TIME') { + this.columnMetaStore?.setColumnType(name, c.name, 'TIME', c.size ?? null); + } + } this.area.table = name; this.area.filter = null; this.area.rowPtr = 1; @@ -848,6 +885,7 @@ 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.columnMetaStore?.dropTable(name); if (this.area.table === name) { this.area.table = null; this.area.activeIndex = null; @@ -881,6 +919,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(name, node.col); await this.refreshIfActive(name); return { output: [ { text: `Dropped column ${node.col} from ${name}.`, cls: 'ok' }, @@ -893,6 +932,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(name, node.col, node.newName); await this.refreshIfActive(name); return { output: [ { text: `Renamed ${node.col} to ${node.newName} in ${name}.`, cls: 'ok' }, @@ -921,6 +961,11 @@ 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)}`); + if (node.colType.toUpperCase() === 'TIME') { + this.columnMetaStore?.setColumnType(name, node.col, 'TIME', null); + } else { + this.columnMetaStore?.dropColumn(name, node.col); + } await this.refreshIfActive(name); return { output: [ { text: `Changed type of ${node.col} to ${node.colType} in ${name}.`, cls: 'ok' }, diff --git a/src/shared/types.ts b/src/shared/types.ts index 53a1489..fb5e8e0 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -50,6 +50,23 @@ export interface IIndexStore { dropTable(tableName: string): void; } +// Metadata for column types SQLite's own affinity can't distinguish (e.g. TIME +// vs CHAR — both store as TEXT). qualifier carries a type-specific parameter, +// e.g. the minute-granularity in TIME(15). +export interface ColumnTypeInfo { + baseType: string; + qualifier: number | null; +} + +export interface IColumnMetaStore { + setColumnType(tableName: string, colName: string, baseType: string, qualifier: number | null): void; + getColumnType(tableName: string, colName: string): ColumnTypeInfo | null; + listColumnTypes(tableName: string): Record; + renameColumn(tableName: string, oldName: string, newName: string): void; + dropColumn(tableName: string, colName: string): void; + dropTable(tableName: string): void; +} + export interface ReportColumn { field: string; heading: string; 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..4ab08aa 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; } @@ -30,6 +31,15 @@ export function openTableWizard(run: (cmd: string) => void, onClose: () => void) 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}`); } @@ -65,7 +75,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 and NUM need a length; 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/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..8166f76 100644 --- a/tests/assistant.spec.ts +++ b/tests/assistant.spec.ts @@ -85,6 +85,55 @@ 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'); + }); }); test.describe('Assistant wizards — filter / index / search', () => { From 45019e9b811efb7fed29a0939661cffb6724f34d Mon Sep 17 00:00:00 2001 From: Dennis Decoene Date: Thu, 9 Jul 2026 19:02:26 +0200 Subject: [PATCH 3/9] feat: WEEK() ISO-8601 week-number built-in (#44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WEEK(date) returns the ISO-8601 week number (1-53): Monday-start weeks, week 1 being the week that contains the year's first Thursday. Early-January dates therefore 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. Computed in UTC so a local timezone offset can't shift the day, and ISO-shaped but impossible dates (2024-02-30, 2023-02-29) return 0 rather than silently rolling over the way Date.UTC does. Registered in Parser's BUILTIN_FUNCTIONS — implementing a built-in without whitelisting it there is how the #4 built-ins shipped broken. Covered directly (Builtins.test.ts), through the parser (BuiltinsParse.test.ts), and end-to-end in the REPL (parity-commands.spec.ts), asserting the printed value. Also refreshes the doc test counts and adds ColumnMetaStore.ts / TimeType.test.ts to the CLAUDE.md trees, both missed in #43. --- CHANGELOG.md | 4 ++++ CLAUDE.md | 34 ++++++++++++++++++++++++++++++++-- README.md | 1 + src/interpreter/Builtins.ts | 25 +++++++++++++++++++++++++ src/interpreter/Parser.ts | 1 + tests/Builtins.test.ts | 30 ++++++++++++++++++++++++++++++ tests/BuiltinsParse.test.ts | 2 ++ tests/parity-commands.spec.ts | 29 +++++++++++++++++++++++++++++ 8 files changed, 124 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cec76f2..1bc397c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ Versions follow [Semantic Versioning](https://semver.org/) — minor bump per su 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) --- diff --git a/CLAUDE.md b/CLAUDE.md index 5be64f9..1570138 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,6 +51,7 @@ server/ 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 + ColumnMetaStore.ts Declared column types (TIME, TIME(n)) in data/system.sqlite3 — SQLite affinity can't distinguish TIME from CHAR/DATE 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) @@ -109,6 +110,7 @@ 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 Print.test.ts `?` / `??` print command Aggregate.test.ts `SUM` / `AVERAGE` Builtins.test.ts / BuiltinsParse.test.ts built-in functions (direct + through the parser) @@ -221,6 +223,25 @@ 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`. + +| 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. | + ### Control flow | Command | What it does | |---|---| @@ -254,6 +275,7 @@ 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()` added in v1.2.0 (#44). 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 ✅ @@ -266,6 +288,14 @@ 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 — in progress) + +- ~~`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 (#45) +- `demos/overtime.prg` — overtime tracker showcasing all three of the above (#46) + ## 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.`. @@ -273,11 +303,11 @@ 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 (281 tests) 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 (75 tests): `tests/integration.spec.ts` (20 tests — full REPL scenario), `tests/assistant.spec.ts` (21 tests — sidebar, wizards, report designer, MODIFY STRUCTURE round-trip, `TIME(15)` column + REPLACE validation, 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/parity-commands.spec.ts` (5 tests — `?`/`??`, built-in functions, `WEEK()`, `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/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). ## Definition of done diff --git a/README.md b/README.md index 6e866c5..abaa01b 100644 --- a/README.md +++ b/README.md @@ -350,6 +350,7 @@ 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 | ### Boolean literals diff --git a/src/interpreter/Builtins.ts b/src/interpreter/Builtins.ts index a29c20f..9b8e560 100644 --- a/src/interpreter/Builtins.ts +++ b/src/interpreter/Builtins.ts @@ -92,6 +92,31 @@ 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 raw = s(0); + 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 d = new Date(raw); + if (isNaN(d.getTime())) return 0; + y = d.getFullYear(); m = d.getMonth() + 1; day = d.getDate(); + } + // Work in UTC so no local timezone offset can shift the day. + const dt = new Date(Date.UTC(y, m - 1, day)); + if (isNaN(dt.getTime())) return 0; + // Date.UTC rolls impossible dates over (Feb 30 → Mar 1), so reject any + // input the round-trip doesn't reproduce exactly. + if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== m - 1 || dt.getUTCDate() !== day) 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); + } default: throw new Error(`Unknown function: ${fn}`); } diff --git a/src/interpreter/Parser.ts b/src/interpreter/Parser.ts index 2a2141b..b38f40d 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', ]); // ── AST Node Types ────────────────────────────────────────────────────────── diff --git a/tests/Builtins.test.ts b/tests/Builtins.test.ts index 621c200..33e6695 100644 --- a/tests/Builtins.test.ts +++ b/tests/Builtins.test.ts @@ -124,6 +124,36 @@ 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('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..c78b673 100644 --- a/tests/BuiltinsParse.test.ts +++ b/tests/BuiltinsParse.test.ts @@ -23,4 +23,6 @@ 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'); }); }); diff --git a/tests/parity-commands.spec.ts b/tests/parity-commands.spec.ts index 4581807..ecdae8b 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,27 @@ 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'); + }); + test('3. SUM and AVERAGE', async ({ page }) => { const db = `e2e_parity_sum_${Date.now()}`; await boot(page, db); From 45ec0bd51f07b21cb023fac8f822ca35133ff97b Mon Sep 17 00:00:00 2001 From: Dennis Decoene Date: Thu, 9 Jul 2026 19:41:41 +0200 Subject: [PATCH 4/9] feat: BROWSE per-cell validation by declared column type (#45) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grid edits are now checked against the column's declared type before they commit. An invalid edit keeps the cell in edit mode, outlined in red, showing why; the error clears as soon as the value becomes valid, and Esc abandons the edit leaving the original value intact. The rules live in src/shared/cellValidation.ts and run on both sides: Grid.ts for instant feedback, and Session's grid-edit handler as the authoritative check. grid-edit previously bypassed the Executor and wrote straight to SQLite with no validation at all, so a WS message could plant any value in any column. Serving that needed the declared type of every column, not just TIME: SQLite's affinity cannot tell TIME/DATE/CHAR apart (all TEXT), nor LOGICAL from INT (both INTEGER), nor recover a NUM(p,s) qualifier. CREATE TABLE now records all of them, grid-open ships them to the client, and LIST STRUCTURE prints them as declared. Two bugs surfaced and are fixed here: - CREATE TABLE t (price NUM(8,2)) created a phantom column named "2" of type ")" — the parser read the precision and then treated the scale as the next column definition. The PRODUCTS, DEALS and SALES demo tables all carried the stray column. - Column metadata was keyed by (table, column) with no database scope, so same-named tables in different databases overwrote each other's declared types. Now keyed by (db, table, column), with a migration for the pre-#45 schema. --- CHANGELOG.md | 19 ++++ CLAUDE.md | 35 +++++-- README.md | 4 +- server/ColumnMetaStore.ts | 72 ++++++++++---- server/Session.ts | 13 ++- src/interpreter/Executor.ts | 67 ++++++------- src/interpreter/Parser.ts | 16 +++- src/shared/cellValidation.ts | 89 +++++++++++++++++ src/shared/types.ts | 27 +++--- src/styles/main.css | 11 ++- src/terminal/Terminal.ts | 5 +- src/ui/Grid.ts | 53 +++++++++-- tests/CellValidation.test.ts | 107 +++++++++++++++++++++ tests/ColumnMeta.test.ts | 174 ++++++++++++++++++++++++++++++++++ tests/ColumnMetaStore.test.ts | 82 ++++++++++++++++ tests/assistant.spec.ts | 33 +++++++ tests/grid-validation.spec.ts | 115 ++++++++++++++++++++++ 17 files changed, 834 insertions(+), 88 deletions(-) create mode 100644 src/shared/cellValidation.ts create mode 100644 tests/CellValidation.test.ts create mode 100644 tests/ColumnMeta.test.ts create mode 100644 tests/ColumnMetaStore.test.ts create mode 100644 tests/grid-validation.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bc397c..7f3ed83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,25 @@ Versions follow [Semantic Versioning](https://semver.org/) — minor bump per su 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) +- `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) + +### 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) --- diff --git a/CLAUDE.md b/CLAUDE.md index 1570138..d96fd90 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ server/ 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 - ColumnMetaStore.ts Declared column types (TIME, TIME(n)) in data/system.sqlite3 — SQLite affinity can't distinguish TIME from CHAR/DATE + 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) @@ -68,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) @@ -81,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 @@ -111,6 +112,9 @@ tests/ 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 Print.test.ts `?` / `??` print command Aggregate.test.ts `SUM` / `AVERAGE` Builtins.test.ts / BuiltinsParse.test.ts built-in functions (direct + through the parser) @@ -181,7 +185,24 @@ WebBase-III supports **unlimited work areas** (no DOS 10-area limit). Cross-area | `DATE` | | `TEXT` | | `TIME` / `TIME(n)` | | `TEXT` | -`TIME` stores `HH:MM` (24-hour). The optional `TIME(n)` qualifier (only via `CREATE TABLE` — not yet 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`, `TIME(15)`) rather than the raw SQLite storage class. +`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 | @@ -293,7 +314,7 @@ line. - ~~`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 (#45) +- ~~BROWSE per-cell validation~~ — grid rejects invalid edits per column type, validated on both client and server via `src/shared/cellValidation.ts` (#45) ✅ - `demos/overtime.prg` — overtime tracker showcasing all three of the above (#46) ## Boolean literals @@ -303,11 +324,11 @@ Both styles accepted: `TRUE`/`FALSE` and `.T.`/`.TRUE.`/`.F.`/`.FALSE.` (dBASE I ## Testing ```bash -npm test # Vitest unit + integration (281 tests) +npm test # Vitest unit + integration (316 tests) npx playwright test # E2E browser tests — requires dev server on :5173/:3000 ``` -Playwright suites (75 tests): `tests/integration.spec.ts` (20 tests — full REPL scenario), `tests/assistant.spec.ts` (21 tests — sidebar, wizards, report designer, MODIFY STRUCTURE round-trip, `TIME(15)` column + REPLACE validation, 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/parity-commands.spec.ts` (5 tests — `?`/`??`, built-in functions, `WEEK()`, `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/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 (79 tests): `tests/assistant.spec.ts` (22 tests — sidebar, wizards, report designer, MODIFY STRUCTURE round-trip, `TIME(15)` column + REPLACE validation, 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/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` (5 tests — `?`/`??`, built-in functions, `WEEK()`, `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/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). ## Definition of done diff --git a/README.md b/README.md index abaa01b..d2c732c 100644 --- a/README.md +++ b/README.md @@ -229,7 +229,7 @@ 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`. -**Column types**: `CHAR(n)` (aliases `CHARACTER`/`VARCHAR`/`STRING`/`MEMO`), `NUM` (`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. +**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** @@ -380,6 +380,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/server/ColumnMetaStore.ts b/server/ColumnMetaStore.ts index deef153..ce43b6a 100644 --- a/server/ColumnMetaStore.ts +++ b/server/ColumnMetaStore.ts @@ -6,6 +6,16 @@ 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; @@ -15,51 +25,73 @@ export class ColumnMetaStore implements IColumnMetaStore { 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, - PRIMARY KEY (table_name, col_name) + 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(tableName: string, colName: string, baseType: string, qualifier: number | null): void { + setColumnType(dbName: string, tableName: string, colName: string, baseType: string, qualifier: number | null, scale: number | null): void { this.db.prepare(` - INSERT INTO column_types (table_name, col_name, base_type, qualifier) - VALUES (?, ?, ?, ?) - ON CONFLICT(table_name, col_name) DO UPDATE SET base_type = excluded.base_type, qualifier = excluded.qualifier - `).run(tableName, colName, baseType, qualifier); + 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(tableName: string, colName: string): ColumnTypeInfo | null { + getColumnType(dbName: string, tableName: string, colName: string): ColumnTypeInfo | null { const row = this.db.prepare( - 'SELECT base_type AS baseType, qualifier FROM column_types WHERE table_name = ? AND col_name = ?' - ).get(tableName, colName) as ColumnTypeInfo | undefined; + '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(tableName: string): Record { + listColumnTypes(dbName: string, tableName: string): Record { const rows = this.db.prepare( - 'SELECT col_name AS colName, base_type AS baseType, qualifier FROM column_types WHERE table_name = ?' - ).all(tableName) as Array; + '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 }; + for (const r of rows) out[r.colName] = { baseType: r.baseType, qualifier: r.qualifier, scale: r.scale }; return out; } - renameColumn(tableName: string, oldName: string, newName: string): void { + renameColumn(dbName: string, tableName: string, oldName: string, newName: string): void { this.db.prepare( - 'UPDATE column_types SET col_name = ? WHERE table_name = ? AND col_name = ?' - ).run(newName, tableName, oldName); + 'UPDATE column_types SET col_name = ? WHERE db_name = ? AND table_name = ? AND col_name = ?' + ).run(newName, dbName, tableName, oldName); } - dropColumn(tableName: string, colName: string): void { - this.db.prepare('DELETE FROM column_types WHERE table_name = ? AND col_name = ?').run(tableName, colName); + 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(tableName: string): void { - this.db.prepare('DELETE FROM column_types WHERE table_name = ?').run(tableName); + dropTable(dbName: string, tableName: string): void { + this.db.prepare('DELETE FROM column_types WHERE db_name = ? AND table_name = ?').run(dbName, tableName); } } diff --git a/server/Session.ts b/server/Session.ts index 1ffd776..b1724b6 100644 --- a/server/Session.ts +++ b/server/Session.ts @@ -7,6 +7,7 @@ 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 { @@ -67,6 +68,15 @@ export class Session { 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] @@ -346,8 +356,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/Executor.ts b/src/interpreter/Executor.ts index 3452fc1..acf1a4b 100644 --- a/src/interpreter/Executor.ts +++ b/src/interpreter/Executor.ts @@ -1,10 +1,11 @@ -import { IDatabaseBridge, IIndexStore, IColumnMetaStore, 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'; @@ -42,21 +43,12 @@ function mapType(t: string): DbType { } } -const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/; - -// Throws if `value` is not a well-formed HH:MM string satisfying `qualifier` -// (a minute-granularity requirement, e.g. 15 for TIME(15)). -function validateTimeValue(field: string, value: unknown, qualifier: number | null): void { - if (value === null || value === undefined) return; - const s = String(value); - const m = TIME_RE.exec(s); - if (!m) { - throw new Error(`${field}: invalid TIME value "${s}" — expected HH:MM (00-23:00-59)`); - } - const minutes = Number(m[2]); - if (qualifier && minutes % qualifier !== 0) { - throw new Error(`${field}: TIME value "${s}" violates TIME(${qualifier}) granularity — minutes must be a multiple of ${qualifier}`); - } +/** 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 { @@ -100,6 +92,11 @@ export class Executor implements IndexCommandsHost { return this.areas.get(this.activeAlias)!; } + /** Database key for column metadata — same-named tables in different DBs must not collide. */ + private 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; @@ -385,15 +382,14 @@ 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.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 => { - const info = meta[c.name]; - const typeText = info ? (info.qualifier ? `${info.baseType}(${info.qualifier})` : info.baseType) : c.type; + 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 }; @@ -432,10 +428,15 @@ 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) { - const info = this.columnMetaStore?.getColumnType(this.area.table!, p.field); + if (p.value === null || p.value === undefined) continue; + const info = this.columnMetaStore?.getColumnType(this.metaDb, this.area.table!, p.field); if (info?.baseType === 'TIME') { - validateTimeValue(p.field, p.value, info.qualifier); + const err = validateCellValue(p.field, String(p.value), info); + if (err) throw new Error(err); } } const setClauses = pairs.map(p => `${q(p.field)} = ?`).join(', '); @@ -774,10 +775,14 @@ export class Executor implements IndexCommandsHost { } 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) { - if (c.colType.toUpperCase() === 'TIME') { - this.columnMetaStore?.setColumnType(name, c.name, 'TIME', c.size ?? null); - } + 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; @@ -885,7 +890,7 @@ 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.columnMetaStore?.dropTable(name); + this.columnMetaStore?.dropTable(this.metaDb, name); if (this.area.table === name) { this.area.table = null; this.area.activeIndex = null; @@ -911,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' }] }; } @@ -919,7 +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(name, 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' }, @@ -932,7 +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(name, node.col, 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' }, @@ -961,11 +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)}`); - if (node.colType.toUpperCase() === 'TIME') { - this.columnMetaStore?.setColumnType(name, node.col, 'TIME', null); - } else { - this.columnMetaStore?.dropColumn(name, node.col); - } + 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' }, diff --git a/src/interpreter/Parser.ts b/src/interpreter/Parser.ts index b38f40d..db4a66f 100644 --- a/src/interpreter/Parser.ts +++ b/src/interpreter/Parser.ts @@ -76,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 } @@ -476,12 +476,19 @@ export class Parser { const cname = this.ident(); const ctype = this.ident(); let size: number | undefined; + let scale: number | undefined; if (this.peek().type === 'LPAREN') { this.adv(); size = this.tryNum() ?? undefined; + // 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.tryNum() ?? undefined; + } if (this.peek().type === 'RPAREN') this.adv(); } - cols.push({ name: cname, colType: ctype, size }); + cols.push({ name: cname, colType: ctype, size, scale }); if (this.peek().type === 'COMMA') this.adv(); } if (this.peek().type === 'RPAREN') this.adv(); @@ -506,12 +513,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 fb5e8e0..5118e4f 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -50,21 +50,20 @@ export interface IIndexStore { dropTable(tableName: string): void; } -// Metadata for column types SQLite's own affinity can't distinguish (e.g. TIME -// vs CHAR — both store as TEXT). qualifier carries a type-specific parameter, -// e.g. the minute-granularity in TIME(15). -export interface ColumnTypeInfo { - baseType: string; - qualifier: number | null; -} +// 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(tableName: string, colName: string, baseType: string, qualifier: number | null): void; - getColumnType(tableName: string, colName: string): ColumnTypeInfo | null; - listColumnTypes(tableName: string): Record; - renameColumn(tableName: string, oldName: string, newName: string): void; - dropColumn(tableName: string, colName: string): void; - dropTable(tableName: string): void; + 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 { @@ -145,7 +144,7 @@ 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..be6181f 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,15 @@ 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. */ +#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..04df9c5 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(), 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/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/assistant.spec.ts b/tests/assistant.spec.ts index 8166f76..9e347e5 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', () => { diff --git a/tests/grid-validation.spec.ts b/tests/grid-validation.spec.ts new file mode 100644 index 0000000..1e077b9 --- /dev/null +++ b/tests/grid-validation.spec.ts @@ -0,0 +1,115 @@ +/** #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'); + 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 + }); +}); From e727a66dc5a7921b69de16e4650794ef2bcca342 Mon Sep 17 00:00:00 2001 From: Dennis Decoene Date: Thu, 9 Jul 2026 20:20:46 +0200 Subject: [PATCH 5/9] test: strict CREATE TABLE, demo schema pins, grid message coverage (#50) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs shipped through a 283-test suite. Both were structural blind spots in how the tests were written, not bad luck: - Nearly every assertion is `toContain` on rendered text, which can prove a thing is present but never that something extra is absent. A phantom column named "2" therefore sailed through every LIST and LIST STRUCTURE check. - Four of twelve ClientMessage types had no test at all. grid-edit wrote straight to SQLite unvalidated because the grid tests only opened the grid and pressed Escape. Root cause first: parseCreate used to absorb any token it did not understand and invent a column from it. It now rejects a malformed column list — missing comma, missing type, unclosed paren, a third type argument — naming the offending column and creating nothing. Then the coverage those bugs needed: - DemoSchemas.test.ts pins the exact column list of every table the demos create, and asserts no column name is a bare number. - GridMessages.test.ts drives grid-edit, grid-delete, grid-new-row and grid-refresh, asserting the effect on the database. - CreateTableParse.test.ts covers the strict grammar both ways. Writing those tests immediately found two more bugs, fixed here: - Index metadata was keyed by table name alone, so opening PEOPLE in one database silently activated an index defined on a different database's PEOPLE — pointing record order at a column that need not exist there. Now keyed by (db, table, tag). Existing definitions are adopted into the database that owns the table; ambiguous ones are dropped and must be recreated with INDEX ON. - A bare `INPUT "..." TO var` at the REPL discarded the typed value: form-submit only stored values when a continuation existed, which never happens for a single statement. Also removes the input-request/input-response message types (declared but never sent or handled), teaches the New table wizard to emit NUM(p,s), and adds `npm run coverage` so untested modules stop hiding. --- .gitignore | 1 + CHANGELOG.md | 32 +- CLAUDE.md | 38 +- README.md | 3 + package-lock.json | 767 ++++++++++++++++++++---------- package.json | 4 +- server/IndexStore.ts | 170 +++++-- server/Session.ts | 19 +- src/interpreter/Executor.ts | 12 +- src/interpreter/IndexCommands.ts | 14 +- src/interpreter/Parser.ts | 47 +- src/shared/types.ts | 15 +- src/ui/wizards/TableWizard.ts | 17 +- tests/CreateTableParse.test.ts | 88 ++++ tests/DemoSchemas.test.ts | 92 ++++ tests/GridMessages.test.ts | 152 ++++++ tests/IndexStoreMigration.test.ts | 79 +++ tests/Indexing.test.ts | 53 ++- tests/assistant.spec.ts | 37 ++ tests/schema-errors.spec.ts | 70 +++ vitest.config.ts | 9 + 21 files changed, 1396 insertions(+), 323 deletions(-) create mode 100644 tests/CreateTableParse.test.ts create mode 100644 tests/DemoSchemas.test.ts create mode 100644 tests/GridMessages.test.ts create mode 100644 tests/IndexStoreMigration.test.ts create mode 100644 tests/schema-errors.spec.ts 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 7f3ed83..f2b6f71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ Versions follow [Semantic Versioning](https://semver.org/) — minor bump per su --- -## [Unreleased] — v1.2.0 — TIME columns, WEEK(), grid validation, Overtime demo +## [Unreleased] — v1.2.0 — TIME columns, WEEK(), grid validation, test hardening, Overtime demo ### Added - `TIME` column type — `CREATE TABLE ... (col TIME)` / `TIME(n)` for a minute-granularity @@ -27,6 +27,14 @@ Versions follow [Semantic Versioning](https://semver.org/) — minor bump per su 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. (#45) The Assistant's + **New table** wizard accepts a width (`8`) or a precision,scale pair (`8,2`). (#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) ### Fixed - `CREATE TABLE t (price NUM(8,2))` silently created a **phantom column named `2`** of @@ -38,6 +46,28 @@ Versions follow [Semantic Versioning](https://semver.org/) — minor bump per su 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) + +### 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) --- diff --git a/CLAUDE.md b/CLAUDE.md index d96fd90..e3426f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,7 @@ 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 @@ -115,6 +115,10 @@ tests/ 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) @@ -315,6 +319,8 @@ line. 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 showcasing all three of the above (#46) ## Boolean literals @@ -324,11 +330,37 @@ Both styles accepted: `TRUE`/`FALSE` and `.T.`/`.TRUE.`/`.F.`/`.FALSE.` (dBASE I ## Testing ```bash -npm test # Vitest unit + integration (316 tests) +npm test # Vitest unit + integration (358 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 (79 tests): `tests/assistant.spec.ts` (22 tests — sidebar, wizards, report designer, MODIFY STRUCTURE round-trip, `TIME(15)` column + REPLACE validation, 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/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` (5 tests — `?`/`??`, built-in functions, `WEEK()`, `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/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 (83 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/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` (5 tests — `?`/`??`, built-in functions, `WEEK()`, `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. + +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 d2c732c..2a7cd11 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,9 @@ 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, diff --git a/package-lock.json b/package-lock.json index 7534dfc..7688817 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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 f6f2f6f..8f2a160 100644 --- a/package.json +++ b/package.json @@ -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/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 b1724b6..fef543f 100644 --- a/server/Session.ts +++ b/server/Session.ts @@ -46,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; @@ -61,8 +64,12 @@ 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; @@ -138,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 })); } } diff --git a/src/interpreter/Executor.ts b/src/interpreter/Executor.ts index acf1a4b..f155f03 100644 --- a/src/interpreter/Executor.ts +++ b/src/interpreter/Executor.ts @@ -92,8 +92,8 @@ export class Executor implements IndexCommandsHost { return this.areas.get(this.activeAlias)!; } - /** Database key for column metadata — same-named tables in different DBs must not collide. */ - private get metaDb(): string { + /** Database key for index/column metadata — same-named tables in different DBs must not collide. */ + get metaDb(): string { return this.area.db ?? ''; } @@ -238,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[] = [ @@ -889,7 +889,7 @@ 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; @@ -991,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; } 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 db4a66f..0ae02b3 100644 --- a/src/interpreter/Parser.ts +++ b/src/interpreter/Parser.ts @@ -473,29 +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; + 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.tryNum() ?? undefined; + scale = this.typeArg(cname); } - if (this.peek().type === 'RPAREN') this.adv(); + this.expectRParen(`type qualifier for column '${cname}'`); } 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'); diff --git a/src/shared/types.ts b/src/shared/types.ts index 5118e4f..c9ce093 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -41,13 +41,14 @@ 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 @@ -127,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 } @@ -143,7 +143,6 @@ 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[]; columnTypes: Record; rows: Record[] } | { type: 'modstruct-open'; table: string; columns: ColInfo[] } | { type: 'form-open'; fields: FormField[] } diff --git a/src/ui/wizards/TableWizard.ts b/src/ui/wizards/TableWizard.ts index 4ab08aa..12978c1 100644 --- a/src/ui/wizards/TableWizard.ts +++ b/src/ui/wizards/TableWizard.ts @@ -27,7 +27,18 @@ 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})`); @@ -66,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 }); @@ -75,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; TIME takes an optional minute-granularity (e.g. 15).', + '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/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..a5f524a --- /dev/null +++ b/tests/DemoSchemas.test.ts @@ -0,0 +1,92 @@ +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'], +}; + +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/assistant.spec.ts b/tests/assistant.spec.ts index 9e347e5..e69f106 100644 --- a/tests/assistant.spec.ts +++ b/tests/assistant.spec.ts @@ -167,6 +167,43 @@ test.describe('Assistant wizards — table', () => { 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/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'], + }, }, }); From 4856ceb89ded2e575eee25c0064a81fc4ddb020c Mon Sep 17 00:00:00 2001 From: Dennis Decoene Date: Thu, 9 Jul 2026 20:42:21 +0200 Subject: [PATCH 6/9] feat: DATEADD() date-arithmetic built-in (#52) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DATEADD(date, n) returns the ISO date n days later; n may be negative. W3Script had no date arithmetic at all — CTOD/DTOC only reformat and YEAR/MONTH/DAY only decompose — so there was no way to say "the day after this one". demos/overtime.prg (#46) needs it to derive each TIMESHEET.WORKDATE from the week's Monday. Computed in UTC, so month, year and leap-day boundaries are exact and no local timezone offset can shift the day. Impossible ISO dates return '' rather than rolling over, matching WEEK(). WEEK() already parsed dates exactly this way, so that logic is extracted into a shared parseDateUTC() rather than duplicated. Registered in Parser's BUILTIN_FUNCTIONS, and covered directly, through the parser, and end-to-end in the REPL — the three-way coverage the #4 built-ins lacked when they shipped broken. --- CHANGELOG.md | 5 ++++ CLAUDE.md | 9 ++++--- README.md | 1 + src/interpreter/Builtins.ts | 49 +++++++++++++++++++++++------------ src/interpreter/Parser.ts | 2 +- tests/Builtins.test.ts | 24 +++++++++++++++++ tests/BuiltinsParse.test.ts | 2 ++ tests/parity-commands.spec.ts | 15 +++++++++++ 8 files changed, 86 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2b6f71..ea4f8fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,11 @@ Versions follow [Semantic Versioning](https://semver.org/) — minor bump per su 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`, …); diff --git a/CLAUDE.md b/CLAUDE.md index e3426f8..202daaf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -261,11 +261,12 @@ Implemented in `src/interpreter/Builtins.ts` (stateless) and `Executor.ts` (stat 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`. +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 | @@ -300,7 +301,7 @@ 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()` added in v1.2.0 (#44). + - `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 ✅ @@ -330,12 +331,12 @@ Both styles accepted: `TRUE`/`FALSE` and `.T.`/`.TRUE.`/`.F.`/`.FALSE.` (dBASE I ## Testing ```bash -npm test # Vitest unit + integration (358 tests) +npm test # Vitest unit + integration (369 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 (83 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/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` (5 tests — `?`/`??`, built-in functions, `WEEK()`, `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). +Playwright suites (84 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/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 diff --git a/README.md b/README.md index 2a7cd11..8d61250 100644 --- a/README.md +++ b/README.md @@ -354,6 +354,7 @@ Functions work anywhere an expression is accepted — `IF`, `DO WHILE`, `STORE`, | `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 diff --git a/src/interpreter/Builtins.ts b/src/interpreter/Builtins.ts index 9b8e560..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); @@ -96,27 +119,21 @@ export function callStateless(fn: string, args: unknown[]): unknown { // 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 raw = s(0); - 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 d = new Date(raw); - if (isNaN(d.getTime())) return 0; - y = d.getFullYear(); m = d.getMonth() + 1; day = d.getDate(); - } - // Work in UTC so no local timezone offset can shift the day. - const dt = new Date(Date.UTC(y, m - 1, day)); - if (isNaN(dt.getTime())) return 0; - // Date.UTC rolls impossible dates over (Feb 30 → Mar 1), so reject any - // input the round-trip doesn't reproduce exactly. - if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== m - 1 || dt.getUTCDate() !== day) return 0; + 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/Parser.ts b/src/interpreter/Parser.ts index 0ae02b3..97993d9 100644 --- a/src/interpreter/Parser.ts +++ b/src/interpreter/Parser.ts @@ -9,7 +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', + 'WEEK','DATEADD', ]); // ── AST Node Types ────────────────────────────────────────────────────────── diff --git a/tests/Builtins.test.ts b/tests/Builtins.test.ts index 33e6695..6337c5e 100644 --- a/tests/Builtins.test.ts +++ b/tests/Builtins.test.ts @@ -154,6 +154,30 @@ describe('WEEK', () => { 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 c78b673..82fc67b 100644 --- a/tests/BuiltinsParse.test.ts +++ b/tests/BuiltinsParse.test.ts @@ -25,4 +25,6 @@ describe('built-in functions reachable through the REPL parser', () => { 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/parity-commands.spec.ts b/tests/parity-commands.spec.ts index ecdae8b..dade118 100644 --- a/tests/parity-commands.spec.ts +++ b/tests/parity-commands.spec.ts @@ -92,6 +92,21 @@ test.describe('Parity commands e2e', () => { 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); From 3c51de57bd7a8d9544f87a9fbfc08192968b571e Mon Sep 17 00:00:00 2001 From: Dennis Decoene Date: Thu, 9 Jul 2026 21:10:55 +0200 Subject: [PATCH 7/9] =?UTF-8?q?feat:=20demos/overtime.prg=20=E2=80=94=20Ov?= =?UTF-8?q?ertime=20Tracking=20demo=20(#46)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An overtime tracker, 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 to Friday. Five linked tables (EMPLOYEES, SCHEDULEDAYS, TIMESHEET, WEEKSUMMARY, LEAVETAKEN) across five work areas with relations into EMPLOYEES. Each employee has 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; the balance is computed live from the source rows, so re-editing a week cannot leave a stale running total. Two engine constraints shaped the implementation, both verified before writing the program: - SUM ... FOR and SET FILTER are spliced raw into SQL and cannot see a memory variable, so the per-employee balance accumulates in a DO WHILE / IF loop rather than the crm.prg SUM ... FOR ... TO idiom, whose condition is a literal. - Field references only resolve where the row cache is primed (IF, DO WHILE, @ SAY), which is why the time arithmetic lives inside the loop body. Times are HH:MM text, so worked hours convert to minutes first: (TIMEOUT - TIMEIN) - (BEND - BSTART), rounded to two decimals. Seeds a grouped report and is reachable from the splash screen, HELP, and the Assistant (Programs -> Run Overtime demo). The DemoSchemas golden pins from #50 caught the new tables immediately and now cover them. --- CHANGELOG.md | 10 + CLAUDE.md | 12 +- README.md | 7 +- demos/overtime.prg | 458 +++++++++++++++++++++++++++++++ demos/reports/overtimebyemp.json | 14 + src/interpreter/Executor.ts | 1 + src/terminal/Terminal.ts | 1 + src/ui/Assistant.ts | 1 + tests/DemoSchemas.test.ts | 7 + tests/overtime.spec.ts | 216 +++++++++++++++ 10 files changed, 721 insertions(+), 6 deletions(-) create mode 100644 demos/overtime.prg create mode 100644 demos/reports/overtimebyemp.json create mode 100644 tests/overtime.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ea4f8fc..cef3200 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,16 @@ Versions follow [Semantic Versioning](https://semver.org/) — minor bump per su `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 diff --git a/CLAUDE.md b/CLAUDE.md index 202daaf..11b068d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,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 @@ -322,7 +323,10 @@ line. - ~~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 showcasing all three of the above (#46) +- ~~`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 @@ -331,12 +335,12 @@ Both styles accepted: `TRUE`/`FALSE` and `.T.`/`.TRUE.`/`.F.`/`.FALSE.` (dBASE I ## Testing ```bash -npm test # Vitest unit + integration (369 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 (84 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/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). +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 diff --git a/README.md b/README.md index 8d61250..eca1685 100644 --- a/README.md +++ b/README.md @@ -288,14 +288,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 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/src/interpreter/Executor.ts b/src/interpreter/Executor.ts index f155f03..f08a627 100644 --- a/src/interpreter/Executor.ts +++ b/src/interpreter/Executor.ts @@ -1063,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/terminal/Terminal.ts b/src/terminal/Terminal.ts index 04df9c5..2afd2df 100644 --- a/src/terminal/Terminal.ts +++ b/src/terminal/Terminal.ts @@ -379,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/tests/DemoSchemas.test.ts b/tests/DemoSchemas.test.ts index a5f524a..4621c40 100644 --- a/tests/DemoSchemas.test.ts +++ b/tests/DemoSchemas.test.ts @@ -22,6 +22,13 @@ const DEMO_SCHEMAS: Record = { 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'); 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'); + }); +}); From dc304236c230134e451179aa70e2ecaac4807c85 Mon Sep 17 00:00:00 2001 From: Dennis Decoene Date: Thu, 9 Jul 2026 21:39:54 +0200 Subject: [PATCH 8/9] fix: BROWSE validation message was clipped away; refresh docs + screenshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-cell validation error (#45) was never visible. Grid cells set overflow:hidden for the ellipsis on long values, which clipped the absolutely-positioned tooltip to nothing — an invalid edit showed a red border and no explanation of what was wrong. The e2e tests passed the whole time: toContainText and toBeVisible do not account for clipping by an ancestor's overflow. The assertion now uses toBeInViewport(), which is backed by an IntersectionObserver and does. Verified it fails against the unfixed CSS (viewport ratio 0). Found by looking at a screenshot of the feature rather than trusting a green test — now recorded in CLAUDE.md's test-discipline notes. Also regenerates every screenshot and the README demo GIF against v1.2.0 (they still showed the v1.1.1 banner), and adds a new screenshot-grid-validation.png plus its capture step and a README section for it. --- CHANGELOG.md | 5 ++++ CLAUDE.md | 5 ++++ README.md | 10 +++++++ docs/screenshots/demo.gif | Bin 608913 -> 632133 bytes .../screenshot-assistant-wizard.png | Bin 61158 -> 65325 bytes docs/screenshots/screenshot-assistant.png | Bin 102515 -> 107609 bytes docs/screenshots/screenshot-csv.png | Bin 108275 -> 112539 bytes .../screenshot-grid-validation.png | Bin 0 -> 55214 bytes docs/screenshots/screenshot-index.png | Bin 118213 -> 122971 bytes docs/screenshots/screenshot-list.png | Bin 123508 -> 122835 bytes docs/screenshots/screenshot-parity.png | Bin 115700 -> 120406 bytes docs/screenshots/screenshot-repl-session.png | Bin 107836 -> 104592 bytes docs/screenshots/screenshot-terminal.png | Bin 77993 -> 82595 bytes scripts/capture-screenshots.mjs | 25 ++++++++++++++++++ src/styles/main.css | 5 +++- tests/grid-validation.spec.ts | 5 ++++ 16 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 docs/screenshots/screenshot-grid-validation.png diff --git a/CHANGELOG.md b/CHANGELOG.md index cef3200..954259e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,11 @@ Versions follow [Semantic Versioning](https://semver.org/) — minor bump per su - 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 diff --git a/CLAUDE.md b/CLAUDE.md index 11b068d..ca37288 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -362,6 +362,11 @@ spots, not bad luck. When adding tests, remember what the existing ones cannot s 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 diff --git a/README.md b/README.md index eca1685..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. diff --git a/docs/screenshots/demo.gif b/docs/screenshots/demo.gif index 94c7c75098b2449fc9be2bebf876b12ea0f6b597..7f0dd96e0b19e81bb0248a749ea2c5d6cc50cec9 100644 GIT binary patch delta 184016 zcmWKXWmwY<7smf9Hqz0J8`6z*y3s8q-Q6Lb+vwDhBb9CtkPaoK5kXS)0YO^DKv7iQ z_v`t1pX<8sbDiHgIR(6vvwZj@P!c!^k_7F76*S`D00eM?P=ixCCk7jv+aEuEoRgEo zy}`}Y$)Q+i1IdAaii(y_cb8~a7k?tL=ro6ly zy*@oPhJjKJ4Of7ZnP4EM5Hb}Js47%UOpHqijS#0t2(XZeFu){WPzfj?3PAaxP!jnA z7$q6gEmk-W4e4(mRGgIo&It$50B{okH~?}cYH~U%fC+%oK}n#$5EoMWAQ?3T0f&?9 zvmwl!RFq&46+lG^P*MT_1&o3M2Bv_(;V=jpgp7jpr5}nWEhR!}acCNH-OiesJOQ&N zyP3|~ITBd=L6!pj60uY&czK95#A&gFKjg>v{-<>&mGU_7D*4mKm3jejE6g-~(`vJo zGgzfhrg{B|C98ps7Xg&m=>C#I+V{s8tOq<-7&eF5vBdJ0D{|CspF)ETXCwrf$Z4vq!PT+-%VP z56bwxx5)95wT3fd!@U8G5zBLX939bFWG>vAmAoM@O^zbNQkDgB15y) z9RMJHJ?xoO(mk#Z*_o{O0~RKwN5NoGa(fL^UUQIj5v#YmE;F~Z1+DBB&#D2gU#1|O zNkgz4=S5L>PlNV9Z}L};P#H2uTC?E-wxEMr*oUnQUT5^ZXdEIG0ldZy_>KT$rH1IMY3)4pweeH5NtV3 zB483IZFMMe$q$>W0NtG41lb|u&!Wixv*8-ouCon)+gb_UxvJk%M>P6dRj}L!aEYgMPjiVb?UQ>kDf!+K-nZhEL( zPlI*gN+?I9@yKzT+-dY;o9#2YH?XL~b*p#jKT47^S8YkLCybHUATqr1=YI1$P*3cM z!ll!r`QG#jhs_&lHwpW91l~ognDKUfKJyH9i9&tWX-t4!*`Hg%SMJ&bVh+7&ZA(yquZSf`ckh=Xb?hpR!X~e@Uk^_5ix@IkVJX* z+G0<15NRc%WXYoQ*7g=&Qj{Bu;llE08p_+Olr2UC4)jXsb^Mz2aavoNW{t(l``a>u z<_+l6Q=@{}8ZjZUjtrb-XeLv(m&Op%M86hN#los45LQs{x8uU?#xO4a4cVmpx_`Y7 zc&eq=8hqQ-dXMEN3oTU;9uYYw^3nt_V+X|3#JP>4$!NLMk4ogar#YElYFw+4AY)5hT;iw z61lG4gB0sI7YF_@#UVUG(>jHI&F^*(gg{kqI{3zfteD@DN8JEKO~LM(+0iA z<*oh{34n0q3#>eb#m0*J^X2(GX!+js`2=O@FgSIij<()%ZU%h;-^3wBpqw6)#qAWO zP>)-I2v%^*r`l?RT8u@#S>EsO^%&|V<0m0cS0qovz{i}YRHm=jKN>XRiRxyldaF$~ z7fn_RCk3+=t8LL2%}&&(`a`I-&XS83FT-Y=^;yzMADIkp+~#%NyImS+@rj$<+~#72 z2|_K1+)G7&Yq?~Nm-7h2lti>4gkn(a=W0k<3F7*70Q9MeN^3yGoNc!G8cL}&f#dcL zaShCkXj%XV!*dB#LrhQEk4El;uxm$a76Z!*3BLZ6h_wL=lvjYkD@Hj7Np-OHPY2gzzIGc!)#j=!q8s!2PYw*9KsED z9>@ZPXqNZ3J!VIC92(V@$Zy~Zdu}m^{KRREWA@uaDs4WI;^jqYcs&)!JNK}B^c5kh zKooi2Ub@ZUe7BK}keo*9XAk;>sVd!~?EQJ&y)r50Z>_)m-1Q_WO6txB14!%z1=Gp=X8wmw;VdusQP2-x)C zyTj_n{oC;Hl{!!1g@w=Y%>_B+A3Vx&JLLhGd_pq!_eudyT|FbkSfMJvs^|PhU)mEY za<|ApV381lI0G4|`RNaHiADEhZVyS$Yf)K}@Z#4(Tmg-QoR2B&YjXW8$ARRXllfxa z)%y3_A22rtmQszrSF!&+(z*NF?N9J^+)RW;ZNaD26p1$gt0l5 zlQ?)1nOge~g56s;ByxIvDuAfa{`U)0`rs~WU}XPGETsHl?4iQ>uO&#;IT-YJoB9c; zTNrwyVygGe8wcWCBIieUaix#ASE}Lb|GoQ~`|t{D{2y}S^FNd!PU*nxX5`dUm9N?S zh&NxHVhNbXA35;QmP!e`Hf zZ`uiXfWQ&-|GY^=OT>5bCkaJHZ?7kD@5On+qyJe(UKYkBdq$u~qD|JL!r{>qq~ub| zXv+rVLmx)GFQnFnIzV?UR<4JdJCy zOk09y5G+%T1T48QDvW3l(gf5lZ~V?PiUN_))SH~ej|)fdBpXiQfsIhRDZF)BghC(g zhi5u$G&N#9Y)U?YJ~BO-6ah_x|LDadM<9j#5vzT;t<=;QuZY^ztbcHwWKt|NEqrV$ zDK{;mf`kiWi7b$h*z|nh?}?|pOr?~Mceg^0vBXy#YEO_dy&{s8i?UDQ4^I!nYu5=A z@HpHlBF=FtqxI5)90{;B0_?N^91ZI9LQ>)}JjvPi0@0a=QDFkvgubk`^~g7o4>y?! z@&d^}r^6PmnQ1H5Lw~Y_QT)mkVj(_e0pDYRNeV`fX=f??ie5Z=n8TkTPo4)H=gQ55 zDYqvNMkHy=BaNcce|g3?@<(4yg>fL^II^7Nqr+@pmej|jyg8kR_Vr;h{n>aVJV!h3 zkfp$&Au`01(lk)N?0pi_l2` zxw=TkogxMdvfSHXRUjD>m9;jNk{N+dLd5$bKw*8^RaVh)8_`#C@%p;iZn(aZ7gpK7 zBCFg$%kp6NK#~p_9LlZnKJ?r-oMh9Jd-)CqW!1w-!3y@0_FCkxXYC2pED`cf<=D3 z0Gb>j2u6SaZqP^$qp=(ahsuN?0ovId2pU8#1aL(au?T?}+H?L?=OB1M%DEL_Xw?Lm zJB=KXBv@JF9ogv_jg8EBgvcQ4OK&(#3-_#cV<~#gk`peUOjwWF)~VjLjAW6(Kk`g{ zmWo_qDYQJwyM2;+3NKARO3gj0LR4nFhF44fPO0`pG9n(V*W)s3k7^!yR$oPB5G}Lw zBQvV{9>z_lgM=!4G>ajl7(5C0kEf`_g}0F+XL`c}<>I>ZkUl>nu661?d4WnnWNC5zlp?@H!3{wHf5t1g6p%|o z$fXm|xQU$5Km$cZ1tYPAkb!y2R$0kf89#gL%(|bY(w8DiNii8@-D}V%_7ToI+AeI8bHBzJnj}6f5_!@0NVrvvB zsi&N+Vrl}uH#IU!a6@u(k`%a;`l~V$ia48q<}a-RUppDwE1I#Oq-%wCj$bWF>3100 zfu-mt%BqmG)4e0ng^1fU6#h<(?p=-cZ}#@R;7n(%$I7-q<(2@$9Ce7Rcid ztbN_CVCOzYLLaM%GiQZ@DAqUOsyCNX1B(RFAVEdG$fA1yJKiC64h)wFxJwIy2leCI zK{QK!#dEGCcALV?ei{j62RkSNhl0}Xc=TQk6g4}M-VA_gp)^7LXRAm!5!9zS0N?Qt zriH?cJxWSJaEbn9Uxgw~fEEM5gMdX_B#H7KTw+N4x^j3sAGEG-SJ~{e5HnnF3(z+11R{$(XmMRp|K`dKMXq{X9rBUKmVyTLL2ma zCdRIEt{?1-8#NI@T5=4y^Ff&m#{OQ7c!}IrGamY!Y`u67cvA4(x5a@o77jA7t$af} zZ#x2Y?pNV+m53b&2airLLMc+lNy6}<>#Kn;?8ELu6aP+!ez1=tzxW1d-G5+fs#C!obo(nFg!>Z zdB#2=l!bi5KF=faEOl;(j%h-oY%=j|sD|>z7h78xaiTNJirludyAS#2cMd$bk?zVY z0vN~Lmv|}q8Wb8d>_8loFn~`|+HSQtQD@OMXCmu3Ccw^yN-eL1?K~nRVDj6bwS16< z-4KN=talG!4j)t;0#W)KO3V+@UXMx{jIbNgUriTVUxhB*!jSQjTww zO$nc^Y8kkeax5yAE&TSKE#*-Ec{*c1Z1;+NP4+L6bNJQw<_YIv5NTD-TV;CFz!vmp zH1TXTg%fI9CbgeA>@hUy4FisXR`ve}&R4uun+3Ob4{R`M-wwPH@aDiZE@ z3zcsrxuTBnKm+Y;&w++-1l}&Y+KJ)?n5xbp#SXbsN?Y90pBU4poMJme?E?*lix5E# z;Xal3eAfOBKG*l{;(L3!?cIAlzRjqDOgWp-Ftnez=Qyb1uxF3_Qrlm8>Qj|T@!n{6Uzp2ko~?o8^3?ANSKRcaf+?f; zl}{_jY$r&9sa$j)&h~%*U7}LmLYC(I5-PUj{1D{eKwZNo^{;};e4mFBV902>!M5i} zv)2tqL_eyh5w4_OY>B?sJK~%zcK%nNS%|J=o!Rn78{6X9e+@tONI(v#G8tL?ryd%4Wx&ECtzC#d+o6@QNvmU+caNf(E z9wnD3pB7)z7a2YM=pr>xaf9}A29l~MGnGHWOQMwCns~)r+6<4nbcix#DYfr?;DAVl zT4mRhaM1$EWH-NAUem>!z*GEA(i^PsF2}Uv6vQUhsMGk|yE8bi{!h1~qMvW1&^o7i z3q-HU=bg4ivG%3lGUV%T<1Sd$zOC^;;=gl_x~?m|q+RqvT3V${$PtW>aaU~_F;kx! zPom&OKcu`%Cgt<{AXz(wI|St$wr@ zm&GdJKi5YhJ8~!bqNxNjMkc++7r4NE*r-$Rv$ zv`?QCaiMi0&mZ0v%$LAr?Hp=LTq9G8;Bo`FV9$&%Y1zGvVwRC3-`yU-y}l=yq@&tj3-r9pX2_Iqfck-cM%V)Hb4(9j&eU zOhzVM1aO|k+T_-wxy8Hy!?5mWGmP`==KOIe?je+{OpK4epak|oVQ8A3VegvBs)eZE7^U(>EeDZZ`yAaQ^8!8naVP1^vCBu@uU zgZ{3Dq$XqD@_CgK(kEDx)w8BUi~S~jLZuK5#e1!rIOuI1bkr6Kk`yhHTS=1ztx4o7 zoe$O#tgq>uMSVD-MT;n$p(rs!c1M!}tvpW+q+asm%p{g6BMcI@HKdFb4r{*2%g_!y zHB$Mx|MYIheU1=rnvm~EB>FllUw;9|<@o)niT2IaG%!raDm?+lWQu=*5ZZE@q1iaA zFiS`{l7`4SQeEilSh^S71A$q9H6qh=ekF{|N&vXwK_$&C{xs;DWm3{~=YqvU&7PM~ zsJsB)K(GM=CZ}m~0#P8EP+Q(NzG47S8%$%%o|nZb0TvF^z}q*MqpTzT!zDB8fO=pW zAUg;GMTi9zqZ~a~F62Nyj^!435Sfz&7@%2WBm$QUmU#WW+CG;|ar2)Qaeu~R87j**%cv-}}7((thnco{A$t+X~_dKFsnzKSO(3xvui-Z`{3c|1b8lcsa`btogr!f6qJxuU=6`!?&%$fP@r)FbD?v^2Y+m5P)=h z!ev55BzS4$8b@sk1Tnk41#1>{k||OF=eF};rb{72lZ3@xGiN}xK^$RJk)Te$J1*8c zLap~Ol~uzD8M3CH+I{MJX=6*tj7ZCma&o1=T3jlDiKRzThtTC!Jxd|eoNO6&UORov zw4Ne7Kl;f-c4!j}tw@Dx7+TDI9k~+dYp#qMiBg-u-RhJm>hkV>6*RboBD+~9&hz!m z2eHYF6=MWW4gC^R&#K1Hc_m;~XWs5Z9H^8+aX~IW??gK%aQFwpZPW9xD2-aK)wCz! zhMh?q-2LQ21=~?PUNFhatoFd%WE}s3C8mL=(WkNvBaDR-$`7}9psIZ%zPuk(GN^c9pS+4# zXq+tll4`XW8~S2oi1~LZ_rXuYut^7}sg&oqDvbLq|99{EI9bjAF8_HxhgK{py?=W1 z`ZfF+#eAU4*A*|<$P+@**4=7{uE*RjJy%XY+>OEheSGaPu$s+s9yrweN{hA%-jPw? z0##`CUbZo6>j^{8@f2H%S9Kt+Z_>&ax+ini%Jm0qu=ONvo$mx58Kf2Tj2$+Ij!EWp`Iir7gCFX5Vkon_B`Bgc#~vhqv6* zM_J*0K`L1ue{D+bbO&wUxwJUt<-Hzr;UxrY3VRIhF>MKqNSLVl`|p|I$5`s#yt7yz zxMCgY3fBRVS5Svil;Nq5DF*ZYW_klfIHsMVKK!S(PXnKm#iLksk)@0x9*dlOFyB=X z_hmolnzN1PWF&Ezyrrd&0ZD#u(21=1pbU-{JM)?x^q=Xr`EHLnzLX&xy(o02=SvH( zXWS(tZA|cM1!vqFa$)M-m=sS5&T?0FWxEzL9yVoE2d`GMU+GPayCBN4x`{40))PJl zr)!;@V38b7_FpLd2RaYMm)!(yHKwsYBy$LMu426}bnf-tMitfY$&fo!=&B-$S?||N zHb$X+z$@jQ5*``T3JXxeea&zUqH_AL#YnB&kF_s6?=b&fbb5d&hh-mX-WYfl*E3zo zrQ)U_T!I#_`c};T*4-##YvnN+F04UQ#mAJi^}1f`BFmWMW6}GYhcVK*wLzcu#=zEE zk8Ft(C!b-a#K*PgS{KiU>M)?74cp5Vu`Q%Ue~kiV&jq-2^d$MoU+|kPF$=3HD?W8T z50Sy7asK`j-u4<%L#ONLVztp||L}SiA0X^g@19;?AX)>opHe(uxQ3C$^}P9y|6Sn5 zH5=dOK+>RR*PvV!sqS<1N3)LW6!}D255a&`>L)OdkK+;!*2(t5juiA8&|iHWVcOgK ztNztP3sY~`YYjl%x5Wlre)Q%FR@h(3JRZ91z^3(a`%v&$A@A1GVZ~m}k*7lR{f#6f z+%Myp^(YF;aURly;{n4FID_TSOQ9)#c$6+&<7GEZ8YF-C%u!njo10gqO|`BLYV zYbdfkwOj-qq=-FV47(G!18@?@Icq*e7+StPXX%fAH|bZy73O5-rix!@e$S)TG4e8N zK>dJxnb1f-0m^-ewT(P8HWtACCyky<)XYDYmdpP3zH#zqa|4rk26B`TC(UO4b;Eq{N0cDp4#= z62EA_`U}MVyn4-u%U6;R1gjDMyty&<=lR{ozsm2#t;8@8M!q{B{6DL}`<|aD;g_$- z->MJ3qXZv>D}&2x{Wp7DVSu3YIS=kO3DxZil>U3KD0qUuvyMR@y= z%onAZOgzmwa>zFqj92WK2H*lr^?OPuA-7qE5(PGa=Tf;e--l@WAP4kABIioyd8$>! zk%LTCzI0Xo#?b|R6^bsk3wn(59Ps4ZfO)a1Y@^!O7}^`SGS%+E5$x$GLV)R-<@K|J z>LISIp_|I;?bAvJl&XJh5$MS0GBc`V+xXKEy5qrT@_(?^6^Z78ioZifYn2dmU1}75 zcaBhl)K-|6A!-<_5kuO9m#zew_Mx-WAsT5qc*EFCrZNvG>F&fG24!qFJEh8>L5_aK zTa9-**%eiB($DHbhQa3p&GZQ()6Y)mrRtlXi;7TKi*yN~H8Dg8owAC;d9I-Xo*{OE zd}x50m`FwiZnV|9S{YR7nm{QJqS8jJ{1gTBhoxIoCvCN!yqWkWtXbofyGTFOnWy}u zRB@(M(bQlxPFb6C{+Y&1ab2W6)rhwRqM20cxv2MwiCWOON#0`P|{D~RYO;l1) zL;6O2K}Sp0CCdYyzA|Y&HxrIem7=-irFE~E`E&6%a`A;VHv_ON@MRH0o5FvU`Tv4v zpI72!X>8fNyl)YTIrbH2yOimhu_D!yMHEEYIP$h&*IDnxvibD6)h3SIP5l7%_C$ir z?Ei;oeNsbBE2$AbV=_0%LtpueX>IIgDz-iVM1oZF6uj#DvP{x7k?-B?c4eF0bY=?{ zHrPJtY19yZVQwh+t1xD+ZQ7hI?3eD$89&ZZpS3(wG|sv_Z&djCcY$jg7w#NvY1C#> zZPbFb?ZSS3j>}b1DXmkS?G*aQxptw*6V`L8)RQfXNN(ZzFqea}bvzU#FH zITu#_E-k5KC}w!5ES#S4N3sKPDoyAkaGxka=pI$f#Kn!gr1-;MNQaZ;%EV` zk;6FQXa@O>sX(wjdXtIboT>T?z6n%5sxSvSM-7y4P=}%3&lasBR3KRht>PEI-O_9f zq}2xziTT=ELus2=2sujnVOPQJc=v^tkY`OZ+O(m-aK zl$1$^*I4rT;0Q`BYa@r(ZH#x-bQ>39+8+e;JLRj*BhVv?{F|!Rlcx#-_Gyi8bQm?$ zQl#_Wh0s}t;tjI^1r+0diDtJ%b0{^9Zhlcjx-o#V0Eu9{sM{-zzlyt8L+v3$zJG?l zg-pnYtZoyh&CoqL0_cK3)w)AvL)wQ!nBa&MKp zV`%r9%`@dS>eUYZ)LahDSw1*FO?y>Ug?jiR#^S@?IPc6e z|BDw{Ma%Ers18aEeik0&w^w*w2Qmm~vNm%ff8= z!<G6t3>_R#;~h3u@iu?Nc)SXcHO{Gib*d)z zk@nr*3lSFe!%|i6IAvV>oW-E9#dNmy(A=wLVI>z4bvseunyVbKZK%@NeCIJEMwT!o zMr~&%{7Q+OQ6_VEa*ocVL%NHe(I7t!HS|>B>KXS8-I)r7_#YM4zu5ac2&RU2=k8s` zf_7PeBQ{UvXY%H^@@FP*)9CE!omJFLj#u_Cq}$&(c71bX?#RWZOse#n*%n+n{=2us zwmXE7-+`svpt@XO9$Ta_}{UEju(lIdDV0hmk!4;KjzTJM@oo67#q}%^Lkd zv;Uwq&JS*gX=Yq$Vx)g2_gHP0vxgYEB~wbvn~Y|*>nJAe$^Nbqjfr9{E+)x!J|R`= zauvG#lb25|o^8Mh_8bl{hEy}v&M*<01=fGF%yrZX%I;nNYj|HTI7|DszDh)|{ZEa% zLT_T)Vnb+Q5xKd7uG7%wo|Hw_sI{2Kui|qm=0oZBzXN6VyNe-G`s=y#LH&i^!No!S zy4Q%!2RhTLs#G0sUTS?u7#<*;{a06PpYwv;@2F;49kc(&nHC3pX#ua4_Abj;{bII? zDhQuhtc)r^-e-N#D0|H^PAIr5K8eV-r#6nA-WUGwA``LwQT>*r)m6=Mm)Sv%(|Dl9 z9S{4>U+NC#vJd1Q<^A3pu650G2_BZuM9i`?N`J7~6dqU|mnPk#q^o&qbcPr49`?|* zl>BDDTWWm~gTpABc$&`ubKf?lhE%lPNI&#adw*;2pjzWPrQJ606Ai4D{+QnKEQIcp z)S%Jk>jytxJXbe4-C%gfZuN1+6jWw4?xh4T%xF9viDQuV-nKQ*zO6Mm)J$*hEb`90 zAD-SyW2&^*H8^-! zWO`=zq4B`5rH5LWgNKq*y>|O!6w$ z9P20+Ps!8C@fR-)xA8`=2V;1cdYe}sVzl^7wEm1w;e@^qp3;0eIT@<7Fuip(NHy)# zv~a@3Iu*yHzU_{6=8!swIqB@vgols4hCENy7&|!CVPXK!im40M1l^gNk_|JdWYqFl zOG{#Sk!`6kq}%5N7ki#%8o&nj)B1X*NG$csz%+zk~Cn+`D1$8^|$S>QjVF4SIqiYv@E~4 z9=c;Aw@_rQ@NSdDDfVt*qJ**|>yKp?U+g!`b$a3{%lPKJ_excvVEcSjv3ok-=lmY4 z$`;})#_`JU-u<)nx+OE-ZgDfNSolSbd`~@8ZxXjJ`{{(KkAkx%DpWqgs0+*Nsk{Fr z>*5G3FrCZGK6*Eu=LYcHr2U8yjr?KknfYvY>A56JVx@5ss+R2U{L7Z5RPQI=W*igg zt=4TnQs_fcJ4%Fp(p-J?jUQfHYfJOrwoVTkUaHpO7EDKFEw^PNE=r$MSkm!hPYTXW z5(;pt13fHw<@Az)jUr3=+HkY%fwix~;NSS6= z%_G}vC=c^DWSUjZuRU;r_+NmNijItKkY}qZ*WL@^){Qr=*p3?s%@)BAB?1*MBLixb zo{yjNVPh~w3GQJl{#k&}_k{b|Ug=Dy4WeE66is&NGZT)WQcIdCeT<{Zn_qiF70j8q zyim?9LOERO?>IN->J$8GI_C{_<~8i!C$k*X-%3`|H?E<6cg(`nps99b>w$*>QeB*C zM!}Z(7Un*pn5LddHXijKs(3D}nztciQQ=lQS^B!{baZztN;&@H(?8~`hws0+9nJk> z7B4h>ZDC_zexhVqBswzq!*UX`(K2X)ga5O7v-Z|D~xSm52^4`b8F=CP*U^Fit|ixdDp%BUmVbQ!C{U0`dMu)=l=#pv`tYVsa>r z&)G4(YYLd-##ktMn#(wfh1tivBzT-GiBBr})dsOqqfUbT#dm=qH1;7%GnekqafrHg z3ab_yEVO-hf*RggMDkN3Z!Y$K}^5BBTZ98*0qT$JtPq z9iN{!s<^T``|VRpR&eJlB`-5=;^n2vpUba@D^CKiRsa3@{qysOqHbJ`4y!}M z*EP;Q`WJ7JbA|#RuGqa~(~Qjf-w{&|v;#S+39g;lA~W@9C-~i;Eu^Ikq@4kqv$n4h z@&i`M86No_6npu;?pPz08IivtM6qygm6XvqvlTu{^Vo5@n;*VoUYEwCxBr-+8f{sk zOuI$j;MW$-*V?@mE!@_BGSKYGT62%bWdvMmk~6;MF4D1x{u15N8G{}a>3XH7B-*`d zGw26jOAZt5eH(ox+P9mbBu0A21CziFNsLn~{gIfUz0!nFvK4DdzNpq63LbrFGbuTZ zX72$*cpZtf*Z*hkYWHWTtnkD+Zpc9}l6`)*Xex;{=A_N%!LbWd7})mMgxybAYFXEJ z0sv7lU`FQfR!h>HC<#LVOqJgO2EjP$L+uq9-PS-FQhZYz5H2m+Ee)eu9E&dP+p;3N z_PF8|9ad5EaIIqUO^yvh=9#n_ERuS9MRhDz9%V_X`oWJykdev!PDkDAgg0EG{nmV% zjMz+5U7K8U#scYqI*7IQfO!X5a40hGG}(2(<_;SZmf0mK=B|-KSSs>K>8ILM?@5Mo z(=~VIN7|fSf91a;mF&q-IA$as30l)Ol>foRFfy|KYP`V4yx@zdB{3*?ny7IEx3ADT zeO$11tx3m6e&;ubf5k0z;W&pKwV}Lc*&|Q5VLqAmBe|kU8cBg%{Y%-1@0w&LJu$`&+=R!hF^SA(z7AQvY> z8$Mnj5<{5ZamO_s^%A?Zq~R`{mhYel1@lil=yd$%3%Wb&EHES25OPlewJH)(10i@F z<)C>kPwd(!hUabGbaeXKzvNT{@$Fy$3BmNLW0!OvD_}UEo6o=gvy3H~)9^wB7YH*% zVC_3C3#dP3OQ&jZnzq?urz>$rc!OVd~>>L%Sa@XxWiZ zK4?i&WEsoaoVBKd8}D=9SI`5g9q0AhCk6i9Hq?HtU6=sP5%?Nwd*iU~W>i;WVD5s(u<$Q}( zIj00~;p_(P2$gFfeA7$yY4nV9OC$pSC|AI;P4f*Gr4wP?Gn{K*?@2NWK>U4uu8$w_ zSn$?$wFZQ-+$&!dS(I#!aK>7A8s8gmljLce7&Ebh4CYt~PCa2`Tse>}wn86$Z+#Fa z;zs^WYpVU81p5iz@mBNZ!hH+JRM@;HOnqZ97uVBOmu%@Vp1-xy+0)$?C+g=a{AP2x zr>AFFG$5q;&D*a%z0dx(jxwO|ARY{OL`Cd=9((Jq9ts3B;O|!pzdg?I2429DfUHvd z|DOzybVy7%CoxV0U@gY6P1cV4Y2?)k20PDfu9B$ z1EGvaJWK!psL3{Ulip*7C2*;pssw!vh6vK{Pg8g38qcOEZWnu!*Ya?%woZm75S1(3 z7akTjsH<|MLb91pxuMfcKSpwBtMC_1mf%d;+b}vLpzX;#nnt#%#)0I{ z#khsFFr??JelKWnqkO6Y`rxK|G|TrEO^G%^=wddX+59?@`tSzffg8_Y<+nfQZ-dSp zve9YH53}*+WjI;;L2uqDgozzFyVO>hF=EY1#ZNBq&_}Ue*BF*aFjzJK0OS3IGuQjT z_|6nTs?KfuI`lIJXSM&PIp!Gv9UYFfH_JXqhczBClfuCK? zB>K%MyHWZW_3%{Xb{6F-QNwSs8ZCifWgP$Hmq z<*DzA*i#BbeVZOMYK#5Yl8xyU>*$NVABu19bEAN;wbtV{C(0kbe4(z!Y}TqUEw_B6 zk^Ud~e`c`^`P7jFrHk+B-mx-6FY>2c)GZ&<)k^)Cd3oKl5Wt!a*(&>$%Ya`o_-evN z|C?SSb02pdmG`iyYLvhDxZX%;n&Z_^x)P^%AdW6HC|?c8Nb1ll#ZlNQ8b=;~N0PEC zm)*DaCPkcmG-z-&%HOSRUGCUtpr{XL%2j#u#@9rI?E2|LC2I4hp_RLyCI4Ot!Sm_) z_f07m5cN&Z1`ota6 zI#CxNMm+*X&T|9-Pz*q~rp>N~*TRg@{eyC9RdUf7gp1nXaWH^3*ETwigv=ln+^Y;T zWDS?xNCtsXde$(WjXB5WEBOLo^wLWJpKW?p|U9PtG z&!GTJr#OOjlTnd7)C7aR&7#J^O>}2bGsnY4@FGHZQ6`(YWYWmIGh*BM?hY0)w9C%z zET!toKY**0;!YxyuNL@|ABIH8>i$)%^DO2GBH}+=}n@3u@OxemPfNpu*j+ zp_SwtjC{pG?nz2wHZf>uW%@cB1r=B19{Zzet$Ak-@EH47Ji8bg>I9&?Trmfg@&lD* zq1iSEn#&1*R=6k@)(n;5*zfS+3Fxgn1(!tK5@iR=NRtt$yo(_d zNwjkxd|s6oT*^x`F9RME_@}|nwXZc+$yvgUvf`1uy{8lI%CU$w7OU0Ta^?@;ms4~{ zSk>r;yAM*iipv())LJIGZv>52&)JhiDMo6dECD=omF{Lx1wj=3e#v_50ARLk=L%8V%3-f zG@qg?LZ?=W8_nhCDj2XQ9dHX;wr6fyE&r%a%ZJb3rB3rcugU!n3Xk^uZFsOu`~KWe zn)h{_|M;+KfYVyteKG#{9ncM84NfZv2)NB3W>Ft+P#@+|A0EIT5ndk=&mWmmANhbk zs<1xl5r1@DeRLatOiz7`kr@v+%X}*{?9VOVw+?r=9hlDewa)nC-yeSR%fx}b?)I^` zB9CI?`E5(eB5AGjOg6pKGit3UG+&cfvY7uXNJ`S1Y-U*q=`)$WIK)O;Y2l@g8Y(NUH{N#`nRVF^ z+5|G6k>CIx&QN@-=R;5}bIunh!-0YbX~)0zN%>2fpA0A*VjUgI^spdJT?BN?TnoOD zt!G~DE1yKV?HwuR6>hzeiOX1zb}xl13)ZSwFhzRb+c(lG^Hw*QEkApJE&e~iv(^%r z^h}!Ob0-2^$%C(wo0pQK&JEI*8^fx2s&*Um7vF=oc_Z%O^Pe$pO?aF9qAd}kgd1j< zmCa(y1T4uxmMj7_;K?qNBm#G4(X)wH4abp@LJ>c1q0)RJ{7JUlxFlvaRz~iL+L^5O z;z{+$W3na91?9=4XBy(1$-YSH2LY!Ke=GjVfX-W-%=@$~yc1bGXjvq^ zO@XlE!1JLl^+7(+*G&7STX33x{|Akp{4O6~%eJm3WZveU&;}pS`d~J|W1GLB@FG$E zHc>5$RyP;ujdoGB&>FHKfBqMu+iV}UgsR^LxV&Tgpe^KTBDTBD<-8UD5fP0kBrBT! zIteXv#>=!#MBWne*<*LP-j|8K1@Gg74z=FJWxZz?+LL;RmeJz%tmEc*hdww!Tim~O z??KziyN{-i#I$NY$&aDwR9)4C+Q_%u`&ccTz9uKjYyeKS5$K``p1nf{UOBx3KA$~) zmp1j3Pp)bnoGJcNs=Epqu0m|QYAGKt9G*B+)UvDYt~upovol`Pq9TE|xQDiDq} z(}`FvNl>wh4kHCo0|3-&)W?~oy7Ey5snKXlmCpdZ?(E(pt|frxaHwT})_UX;=ro2# z3*Y9gXtN!0Q7jR_FnSGKvXVsd%9T*bs!)K81X&jgOyi3TiU z*F7}!d|QoOpn>;Y-j01|32_h75)E+RHL&!5ZQA-;tqY+gNd2RVQnAEl*{Kkx8qhhg z!*=5$%!D;mvhA*RxA|R}dzRhz9Azv#yDb7`EF-!taWYn^-Bu4}tVv-s(S-P77;fxw zCas`aOSiorP2o8-oyn&`*=NAQwEo9YbxF75wMO+?a`Nwno4>LqyM1jmG?(m-?0dB; zp}rXb4_<9*W+rP^fUm*vzlBh^4Qj0X-L!0q()m+IODBL|c-+bFx@XvTZ~ZfUMdcMTR#F zHTyB>y7)I*|2o2sEUO#$4C)zee9%n0FH};|^TChq>TkGr`J1Wt!H)Yl;h1z=d!7x$ zy8As?=3s^QVbq^T@Ul+5r~c1-$~JB$Z);-TX4Sk)?t7%?-cnSRz+{=+8=c40>B1Z> z3c&s}=e)90Y$k&jZzbA-#0p=N78?|_FX97rga`$+J_j4z3% zHvZK6eD&jpzGk{>iib_OGq-6n&ioDc&rqkXg`79(o_8;!A>3jfEYEGK=BH|K>_71oZf`=rUUiL>Me8-;J$houhGbKFaS1y=o`sSI?fc4Kjt4 zlM>$rCoqz@!Pne49VILK+1{?6ntT~%8zX#y=!VWstCZgDyt-dxG{5@x)~Z&kBQ;qQ z%1i5%df9~7wB#eG2Rfr@ErGiu;UF}d&p;O0(2GL~-N2!#pOiguL(>nHXGlXckCkWZ zhi0EB-&1c55W6)urTnC;Qfqrez1n{>{=N2c>-^85Vka?GocMPRjlF||ws*gkmos&x z$c6=lKJa;bp#$Gv_Vn;3F=4c8oP26z19;sEKfm^^kYFPF=b_QaCAXjZp*D_LZ;fB8 zqG=+_W$9+EmIL3-5DZ7`FxgnI)4YpT*OBAKAh^piZ&Rt}PBGq|?LY0T^_Q(Wir*<5 z`QBMNMcVK7$KkFm=V*8B_=Jh}|H5@Q8`$5^nJ9}r>dXisB~1!wy)K*66jWZTkrW$z z|D^pV7oXa^KT1Dt|J#4%Hr4}gToP5RULw8GuLpOqSN=pmUIYW1M3P>Z}J%mULh%`tlAqpt1aCrCL zXYY5Nb>4IKI_D3V53|*LaoRPalriqDq^{Fi6(e zA%qdd3{gITBP~>sxWoV}+{#ci9+o7)_`nhX3E?AI0hc1mCvkxwE#)GMeL$|Di2zw; z)4i+=1OSF0*LpH(L_L@276{6Gsf1dBQW58SPH%C)sYV-NHS1x0bYT@OlsH{dpz5SOfgnP_ngA}o#DxFoD{|jHjfd@lPrmNQ(K_NP%zxhyaO(+=Oo0gb>mC?5q-fLQ6A|cx4NYXn18y z7}2JvB}gLzZ5{sn*)bxH6pQ~|6ii?rF{vGJQW8e!Z}G53-56yEqw|NBD2X1TcP2$P zl*tnL-K0P;vAk=`gi-+`#Gs^~Ok zkU-QloE+aaMM@54M;J(+=m1%{Cx?rMXXtK*;PQwvaf#CwCyHPKhxDWY%dLs9>JD#k zWI&N%0#W#hP7%jw1S7tV($7r-dUdoQJ$ujb7C2-?)Djg=QnKq6kI;n6>SZaNe38vr zC)kFG4>W6r$zin(TT91d;{Sry=dz%bR^CgBo>~2;6HP}` zX!WDC83LZ%#P7A%{SuaaUjia|zkz_#+XoHlu4%y@G4A6eegGYr>d31d+?)=Ic0wy+V8?O39|hB|u<_;c5W?GQc6CIMRm6z{gr; zs0uo0F-$<+6z5J4Cuf!(WT>@KR_%tvJ7%dSG=ZT2v~jZMBlW1^h>%S!q&PanO)qch zF)zyq2}TBwu|w|Y9v~DdI8U$RvV!^POBQKnG~w^#J68+PjuI(RwtE9#xcOs%Lrjqj zTL>E>_5OlIZ)k6$A@QXg!46LT;OK<%fr2Okp;25cDOtT>4ImIn3J`6ikB}K9)=15Y zfNruuR4~Bm=r-ugY2X|C<%?2s%ooT&BHI4tqnU*!_WVbl7F6`@nwt%ADL_SjTXREKKNzz^Yt^=8>2emrqy$JC0R84-ufheiqT#TOG z(&J|XT8R78-vrBG>g;JY9zFd5ofn_;d9`!rrjUxU(p3QUKr0Fe*CvPQDH;Gkkd3D3mTxSDWxYzu1R;_8;oqXqX7@tx@_~us{*p3Jggg?_WA{AK=E@D+zv9( zIA39l-pjXlzY48ya?XH@`p;>e2W4$+C~zI=TjDVyWKPP_EfQK2k#-i35z|^`XoZQi zpJc~TGBlaBU<^BIG%yCphFB}55#%Mwjsb^LlXvL?0K^?C>drv*viu7Er&YL#WvSK~ z$Zsqkw39YEQ$G&)7PmI${a2Z@W?G9*iM^hbKBd=FLtSO+<{E z_Ou5zNPTZ}NepztZXGS~LDITHk+LsY0o3E5opi&=lM(~g&m$Qv?Lf2?;Hl{k?k|p0 z+_P|2;Z>tnsW79%tU?PDCdTn(--NLjr*rTjg=&s7Gk(+K-WSbsXB4|u*x5|j!{FF3 zlkgt94%C`-ivUhD8h~i$Xbg@^xfci-LXdy_sPa?o8-wec{tqKuDuAxyg4aR60wmkE zF8i_Q+(I7ZZ$d<_2K*>(*=9d;c`LptX0Un{{ycyCw#|yH6ak54>!G)-l&d5uDdovk z=)<;pAcmF?U2o1Wj;Do&3d}C_3TL`lyvijAwd{OSSxCX#9R+&#z?MI zD+)?e^xWF9=fLb-f8?b~uS_)Nlvb;QM6?KGw~qvyl7|pvTY_MsGGH2Xpn^0imu(Vo z#)vE}Ju3LS$4fY<-yZ;+rw>*7lu0>Bhx&@30Ys4~F3lc$diGD4V+@le=g6%HCKDfx z*TwxrKA+?6gDc+Q=KWwS@~{X^;dFezHoXHw zTCxzp8BTWEegc?2VQD5q@HPR5n^;iH8>qz@UJ<|$14*g}&>+)bXn^l0-)NNEB@HHU z@-=Qw!&9zZfH1<00cj*|f%B*c=jj22`87eL8#XQa(WV9H{fxn;#d%=zeFqpOz6sEc zID|7KXd>}h>v4SGj(`sk!ADX0ud&Mjo=mULL-|1OPr%FdFoC4A5sOPxx0@>@0ecdC z<@2*TD9mC8=NAI%?1saLMm_Q##q+YaJ+uI^)MI~l1LA5!1vdeqXqbFTN>5o_V$z+! z0hT5j7G4DCDaZA4Cx7-1Pu?Dz5e;c62hbg0;UPkW(Eu8Z1_0~#y)HY{JR0!gBg8A| zToVnT+a$s-vAWPYLr$xX^WkuRhuOL5LeV;~O{|_$fN2!%_aqc{0WVG-%NYX_>YNOL zR)Iv&kD8Ru6gL4yb|%nGkQ|IacoXkq5fQG10bY^Yw_lXMkl1=-Sa<-KbTa6}422&& zG1Z-fGM>1M$Z$BBYO4~*2!quJhs_bekB60gPnj(RxCaq~EF%#16l}qmbJl z8s0eyd4l+jvOfVRx?i@OT3631xXnhx87?n)31+aVpI|5?FiambIcl2X6d(S35evTi zC0YTSjO7xq2z0?oAovNZh&cd?Q2$eI^+LP$st81fxV#56!2p&(_UD%{G>j|!Tp7xU zb%vltH-@8m4^*JP5SOqwAiB*XS$-TdzVPT&9MtjaU8^^OV!t^xzu_Gefrg6kI+fvJ z2+}aEiA3iVxxMJw5(J;kLEYajg0!UiIS%a zf-&lN5Pph$1f7i!Z-CeQ678R1GlAe8pzmZG0nn&J*;&PYgi}5sbwXTkRv{9v=;W0vy|R3D}6Cejcw=Ph}pV&8qo+ zQ=L7F5pb1Hr>&V)D1L*Z^aqe!M)eHE1B!Mdg1E8GYGFz-kSBkzep*0nqo9H@3}y|W zT_j`7sq#AW8J#DNbv@^kd7Ga|f+y(^*I9)Oeqe)kI320Rr#bi|%#NZi1@0X3AYE{n zkk{LV!H|%i7}0uIj?~{HDNBWzDj0@aIQy3&jFl1xkxt^9;JQjPn~y#;4`PgXR`(@b z?Vt#>Au|*Y>{9na#j#Xx8XFEH6PD8BFi?O7e(!aiP2L}S6aGzQi6254YM}K+H%cC_ z@FViQW}>c^sWC782A*~RGqR${z#qADPmHHMHf)&oU8&^Uuk<_?@yo@uT(gzzsd}w2 zV1W0p70~#|gdFp=bidU&6xG3oiCH*b8P`K3Pc7+|v@u~u@+-|_#-z2P&0N#W2y6(Z z0rRfS(G4tpKauKM`xY+Y^v;#^0DX&3yJoSKTIqWP!}1(^doDm>3w(w?(}&*!BDv-W zI3~?HAaOJdL*RuHj$wtT!;wH;#J1B9b)F&7%tql0IXYoG?r}R5|E@g6k-R>W9kt$b zn43w8w_xB9fBM5MDRnV(Ye1yKYEg`X(eVaQZU%k81}JJN$z+aIcg&F$J7IWv9Ojxj zy;T?gW8}39|AGG8MWC#*CaE6p{iARhCK&gMu*0=5m+}OUr(6zK0zVK0=OU!{jeBk~ z#A+zpX#>ptIkkWSmvGomg3#OUE}ZQ%WGs|0RgU`CGK;QEMI+EDH|<&nRA}r)OWTNs z^8ul#vszn=mDiYeHgVX7qJcO0Q%}EkAKEh35-VWCAlrqm8)|%1adCuc=mQ@kAaM}H z0ho;>XQg}B4zR$o;qrH|dgC?d(|Z{}y~|PbnBDx{2oz<&lKT#UUF7&$jt77Rh~4H_ z#t(SK;9TL&d1B?PLIChjs5F!Xc#{W0e9=ckAh%is8#18VCxoQrqoWi3*%y%3PKXn} z(3?!J0Od1K=-2aJ+5fDLWc|+;H%_z`I1Yxo^QnO_hv>{5tgEnw!V=L9g!|q zk?x{VUMSxvzmBM&tEdps=m_8Fn2zX#tLPNbm<->ToQ{}+tC(WZ*s?3}{i0a>lGs{b z`dvUA5kp+XRos9t16L7%0ssbBLMRXklt@G$2FO)};6f(gZv!cc02>tWHHyR`-=u+# z1l*!TTtE^o0Bg<{0uGgjgu>|3v9Kv0UhGjRxESEB1DN%ZX3YvnTOp3Nl6%>)BXCWsQQoGx#$i9afugEf%-IXg8=EI&>Zm7sQ= z(`cApe4Rvgop8UDDw7j$MhV|9O6)YsKkkfryPP?(ndagL88s|?Sj3CvWYy=S`!uB4 zUKf(BMDb#h@vR_;T!wtVV)m{Q?wb;R@pnT0@5H*^N!`4Y5ieEnFIDL()wn6u7BAED zFO#D{<*Cr8PWjPV@fmiN`)G@y=BJ*MSuIX_(UW4rUBoL-6WfSI-OOYiaWG#69?qjh zj+Q?*A{kLwmRKdmm9;mUOtm=_)<9GBR{i&1Br$D|i~L`gm~UOWPu$GAUREy~Z-z4h z3vta$FjYnY4L9el!892qNq{xSK~Rf~1|)janT;+?ryN!{1&y1bnV)TeWnk(Ckq z?Rro^Q?W*Sp{yy%qDc_pdnX1Cdv()Kxn+?WGuc+}Uv8U#vYYfI${e>vOMxR?Cj5W7 zS-3>J$mH6x89sDOxz}g1TYy&eB|0JkI%2vz5^g(EBswz!I&-=^3vN4$CA!K2x~jUn zYHzz5CAwP!x;wkOKWwADP@wQ9q2<)T%HG?aBT$7gw+wwK&^(v^jI?(zK)SSz&2+mg zS6L)*u;HWEn({t6yF;IgUEgWYmTKzX-%nUl zGmxMe+};_&e=|flA)S5eU;nX`mH_L_L%bOLBh*5~f_~7h z&);WPiU6`2v#dXq5_TE`JB`DfzEc0B{wEZ!68b819CcjCPLWrJ2;@gkv|>G*OM3Tw z=U6~lZT)I{^mX+Y^Uz`CIDyE^(Sg+YTeJnd`APDe8LGciX&Cf4k3 zR)0=6HbNc7`-QU62j$~>6)#bt(b61XwNyy!c~ z_TACj1n%p^PfH0{+du@8oQt%j0XTz)O@N(sEr5kUm5pa`nREr>3YJgiz_{UTZXSq` zJczz&N2g%1Mm|`7W+0}S9#{h50<2J`m(N$}))@E33e_ysns)pD!^`9d0|x5D-BgPv zk>bL8lD5bw#!ttr4<3$^{Kg3f@(BVVtk%fe)I=%_a z`-f*Xkn)603uE~*LPAhNIzhbk@XLFX?qEWblBqdy8G}fwo9}i&rD8m3$AM#H4;cJW zI#IIoY;Q78;u!(uAM*_-7Y0u@@)>d<^icYr)XL!6k53bM&yu8jZ_exYHZy*caPP1A zJ=tbBx{C_s88=4*VagUE*!OfD+9+9&S@MjGCJ}g+9Km;jkROry^Fg+pe$+CG zE*NSRT{SCH63tQ=Z57K=H)<8f(+9PV7noMIP7vORwoVlPIcl9GeFxn|n}bzsQWWW8 zO66_WQf$&RrEzW3Uude>X6W-M4rds=jwu-cLF+akvxxzG z=rW&RJjY5Ayao&sOId3m53PjYT*(+f4X_9a#LDin2aq|71B$TN z_*fI28d{u{oFpaYny2nxpuoq9pH+2hD_2|tQXiBH4{}xXk~m8?8rtm$glQvq5FjWY z7l5n*Pq~nIzpEP9CJwGooYTMkg}mpHaBG7@xVRCx&q>{sK?W@+1CKaq8s!K*;@n5* zaJ3Fk@ahnn*rxq=0V#`pT(!#@Qg>XWd6`)os)JWuE7jGE9r@V-5CZ`6wtw5)RSp9=qC3 zcy?D?M?Cx<02o2tmj}46aQ)QqA1mN&mr}3qFaB(^ zKJq!oe0J@*+U$SSAN_BX|4tcl@_~ccVMK&o)22MxntpS`V7xto!9hs5!=$ zfL4)?C9#u`XPiY4Ubdo(#@F^s<4^8vF56ZL5-ss-itZFg#aAueBDeylhkul}-)lR$ z`Y>AJzOEF;QE0P|=i}>gd zT#mC0q10a=_oJXAlzel?QC3{&s~QQCh;bCM z&$L=$KoDQ@o(YNRzAm2I)U(q}AtZ2*=(lA~$R@1vNO2lA|-^y0id4fRwG7vk?c7PnUACtT)V0IaSJWaa-^&`znc1st(sX^tgR<;1 z?A>B-SPRp;NL32U0>?r>n^J$f>ci^yZHI?7B4m~?zXN^ea!Ay$=n|ri!O*1=zQNeukUo+x@1>#+_vU@blkof$@#NmH`D2-hgp8s zvs=(JR72|6=|@CV&kk96jPuzT+gGM?BxhD<@&uG$SwN1i%@jV#{{8)Lj3}>RlOjP~ z&+i%eUo~Wt$^r8pv+5%M>Ob-_YiNC9DeZ3gY&e8pnj`Ca%v7j?=fIL{9uROY^XfLA za>}T^fVcMLEr(5s`!u(39i}a>ZZ+`oo||Z0RgEElDX(dSR&>+nw)SfKFEE%z#*@|f zBG-Z!pM59$)lb*m=dca07WdcVLUp-Wu3cR%kLhmK9XDbx4=XJGW{=_Ts#Z;%!04h)&Oy8-+# zzr$X7c0;y{!rprF7c9_!2{{P?sz(Fq1!O=1@KB2PboU&-$yeN^!Q@;;U_s@<-GoOW zH)@tpN`_~k$mij>?nMxC!$GQ*lSt9YVwlh9P#R|)(ZfD%C3RGOdoNKX(0Kc+{n;>! zaSBUP4TJ5bVrfS&`|JL}*gAP;?(hZ`2LHKj4UF#x;qqbcZEfpKZ;$G+e0O@YKt|FD zs2H=%&zP`o#wY8_qqztkdY{xCdGIok!i~ERj#vgG|PV4e3X1LfDIjKhO#`b`=@_&pMldrDCWD z2|b|=np5@D*RV*+RSnM#*XineP)YK=L! zf`O6H*!y_-BXgYFvey@fbuJ2{?6?z4cvU~Wx@B3HHdmI$@oGvkr{3HBhaK6!%@`B1 z|4QkWW;XSrMOT2^(O_a_q3fcxA)!`jVqWXZ{kX8-ah`*sCn{id!#upbkI>a(@cY0v ziyJ*`xXLZ-D8*4ctDMKS&Qs=vOy;0l*V6GQHhak$ncZbKp+hm2E!)OX?qa9_O_l$^ z#KuL}W$#Hu(G%>FD2p#5^p{g}fmkUoWB!QrvHs&ld*PX0V=XESdlkDoKxt11-3I8? zE&a~h`>`ar22rG%>{y_PExgbZjG1eQiARZ14H7V?(2izH<%@xo$Br6I(ori=X%-g+7O!n`dvD zHTI?G_(l+a1|haB?$BX^7ug zVdu=xy!sS1VVBdGRQtm|Z*+ylx2+{F!NsL{a&@?Gg)DXI*e+~!WmjLgsm9FN>uvY? zOz`FMAfby#^X*1Cy-%MD=MVP;2WDiPk$masuc#l~oWLktIh?|i$jjR;cv8pkgB>~6 z@NB_iXsnnP zUoLhXl>CJICNs{22&LDQ5`R{UZ=;2gf8X*7G- z$_JmTrTzO}K{0Lj>HMROl*|1{&^5WqrU)9_7Y)ru<8`13qWuXwbXzacDVK1*cE>z# zco9noU6}{Bfi;hUAC+#h8 zDhf>z9V!|fZWtZu8yy{O2c!3k%Z}E+M?G~~+l!_aH80eM^C|f=VuuN`#R%($SVa=W zi^@@s#_a0&vI+b1{jq)9q3am!o?ZDOXDOtnT*RD$MtDDv)IIK6#FE`!+Z8oz5 zBfXS>wwrP+R+y2WFKX1&i)JfwYAHxnM;#|5Xta`fLR8IT6h2Uy(CtICKtZwWi%O^} zjw4P{!Gx%-`x5ni57=1>Kq@CLZ+jjs!aHmdKQJkobtp?wB%D_UJ6P$K7cnUl)e$~X5W-DU&*{7#HG2qfo zUY6-#UMMIm>04+j=9{P+nHV8s8Y=SsPXabkdM;b^RD4)3KDrxwH&-j+YLB zO3|5aRVlA1vqb--c=~1e=4APIW(6*11z%^ODYHXyrJ`{{|5#<+&SmzEn4l6^c~nHR z?tJN_+0`Dg`%{7wtUIZ;IG!&BUV~0M;`KBT%X5hh_QFtu&mYDqo zBI6lbG_qFZZ4RSt5xbUUxyRnv+pOR`Y${X=$e0&nR2*xampUL^!sO$VUF>)53m*zw z6(zoZtWBjFdaA<5DX4wgL9FyU#eEwS5#J0k?C3Z8*V*j6Bt&{EE>GSihZ^P4gYs&XswavS4vJO6U~+;Wto zf0obV7^iWOimUPo9dq0W|Eu3`-`w!mzRLFV7Y|&ipb2w8kBk0VF3DhvBNwlX?W&Bw zU#U#IsZ6G-N)@k4H?9g8E7smAuExzYcC5+>${% z&O02+YHP=w0!jf%j+(#A*}SZAkNHcV-?dedHs-MT7d+>9`@O6N%eW?O&Zhydpu_&1 z#r6BoY}tqk$MJC~Ns$d139`*JlrXt2)U{JOCf<2B4rqA!=55bSLyd1OK{t13>>CoI3Sy`CBqrQsPPG}V zcKF?mS)rjI?YbNh4^}4`dd?bM${?zVvLj=b)!xOm6X?8ZMt9?NZW{>?@Q?;nTc@0Qw!ORNWW*#e;v)Xw@WOltVV63)K$Ui zN3Ob!rTXiY3U-Lfm)pqaOzE=1H z@R~YiBI0jc-za|N$CjjV>UdIK!|l&8RZ{B6C0NgnqF4!CK^`Q(@m_VLDP;6BE;}4U;!-@I^YC za^W78d(rUu;0!Y8lL4qE#h>O$5)>+ zh`X_Ssdw{dbV6qi4rT~kD0hY4+%22^qnV{Bn5F8SrCFb)`!|cAonw%iW18|LH=ASA z;5B_8&GpZQC~t1c7G<8DJ#BZN*~k|}%&6?a$~{LEEuHW4Sy#I<2Wkn?wZSKxS1y=W z?VVR&pV$01uSNUih13__b%~yIXeqVmX}0JcwCG!~=-;~-xV{+tZxKzq6dJ@|Actbr6w^nYGwsU@hYBqf zJe{Z-5|5EHNG#~_wH~YaE9xPymSDD=`@`VV;Dls8YksOk!SS+|r&3P5K??1l@{gq| z>t4(mQ_CD@oa}IA!D?6UYR~#=@kL!9?b@K!+He8MSJSmo4NBRB&X;B(P6^g|5}*0G zc^tLYzYszfT-H%y__KE<*a0&boB;U7))dYz;YK7&ixPL`8(OXnDKO5-1`!{a=w#!h z7w3CWv%&fs-mAG$JWfXp4$dZk@dN-V!lu>&(DDIcZh%0yuVb`C8$kdfG$eoz7~uAx zDhJbIK-3mrAGs23xB!3s13dnzih~5mZEii$B-*IOMzK$WeouiYf`LRB5G?`@>)XcP zT9?!UP+KrSFd)X{Eyo!w9JGx%Y%2f*!qEa~Y;0-#+|^867c1Pwqu-E(Z=ewy+b(-# z+UpkyHp^yv<{NOEzD6vp|4hF?fCshc|Da#ZZ{HR@q{k3T2;wGUan%37UyuJkw#T&p z-`isk^y`13FDxc303+Zb6#!rW(9zLR&}6|n!F2z1i|oH*VU+*(L9z!HmgOc0WJl#! zx+%!S;qn9Llp7g8CV-!L)BMja*|iqae@0>+8=h6o6`$TB!|-VRPnn*VD5r6ljki2> z$+TGM5>#9Nqf6H8xIX^nV7^YbKa@7k6M(?7`CM%_(N_Ds(QNB3{iw}Vy@m8teEvvl z7q=S?!ly(1Alm*~;~BuhGP%U^@<9$SEgkLiW@kbfbj5U%40tuEM{Z4)koI){a{xDM zi=>gz;bIZKFN;bMdT6=9GS{`s#Qo=2Ml(559=8bh*xnF)mb%ZsUxgI9fKvY%i5KmE zc4x!{nYDTT7}sIBfAqXBaQ*b#=f&aCPT@ZnGZm}esOR@mp5Ugh-}~L87#CQIgUA*}FcGwE z``dARBhUp8(hVP4)Z>vT>tNPx!!56?Mz)<~`Ns~sDSR$UGpJ3mSBzc@L}&mC4^J24 zBg;^gwcUcvnguU~@S{SzLXhtw9S^H2Q}rBWYRVrVRzv3fyrW$6h~rUh?n3NQUC+hg zQGKv@XDMoMLzydFoH%YT$zi&yx{g5RD7oc5(1|;b1XjZxGord&gc7&&JMP$xq z(kvh(X9zILn4KOlWYE;ciX>R!W0uW*0tu7lz7`69`D^tp@Sb^FjsZ4<_$_C7;|k>% zb7u?T)m462=1LLKX-DflEqZL$As>x|GkiMStP~xElPER@>f2c5JpGtDlAx)$L0KFi zelZ}Ln9O`29__MwDMO+SH#%gX@7PyHYe!T{7`#Aj-%1-c$#2Y>eU?EmYLv=e#i%8? zIj-)ljdqFj)p@H9;eQj35wCCMe&xWV%E9l)q8&W#T%ZeOHCTkRR;#B-+U(_-R#BO} znRcxXU_SIznUvNT@qjLy?3)UzEtCDuachd-d$Bz}8Uf20{mcBceQe6r|99Tz|0siH z@pWJmq-_RMqwN1mgX=G^Y7ncFkIN9)9tW1=AKNMlmbM*<58{!DOYh5b}= zLUzl-cT7|<%J0xi(@ZiPbD3RxNdOLjJW1w@)DIJ7qOJHJ7{+IR{J~ciBdiY5XP+;j zIU>+4*s2E!U@K3*Q*OfGZPd(2? z4L=$$czKttnvvsoeGqbWvFm-WPtEzz3cF~j)TnhdsF7?gtaU2>FgYiD+0}Jl?Zu@U zfZ}#}c_6kLe3th9pAMhLt?Kkh8~>2m+IQzTlObCjuX;TvzOYr+?K%yY&OK^3l%P?V zd=~dGH;>1x;__Z@YFF5x>9U=KL1(0hr0b7W_s+A8-5O_%_kM|*b#(TjTO)t4!8q!r) zmo-J|x@CX*-rjx%JnF#gdG3aMPa7~EX&&gMY{%hca&P&&7oO35g8!IjZm~4pBjVZ( ziK?3;!&&2}#aRa9w7rJh8~mU~$y-tvdd`}KEf zR*R^yo=#Y~zS6G^`F|z#mr}##@3auvpY191MPYgIY(>j4j+J z=5+g%u7{pjX6s>A41G=*37c`beN_0wgg+xX+H z`mI+yVD5Vmy+y=db;qy%t~`C0M}MVGqMrZVroO+Dk?~jRiVL)VOvNnoTKcr`c`6mi z`}^E$x^p#Y!`XH6o#p2n_ZKL}LW@`COM0!VwvVnm-T^0<%z!25hKkqL1|+@)Xr4E&10w)RzfHyKkah*}Sv{Fbny--o=~-ygS+r+X1U>~?k0R1a zG}6W}(#|*1K07i#OI+8(o`}fHQS|AWlI`)UH%H`Ao$ow*z8D2uh%&#jeFHM~`EB49 z9mVx7uvyTWuEM>*D=Hs#|G_a!nc zB=wEFqk}Q-VY>2sO-TcgLLVro<9oj!(7d<)a?^=_ab5xfd- zIrD$cN>WG>!yFy%|3~lbrT>v|e5rw4GecaruzVtU{G(YvOvh_2)CZpzyYaG>-oi4@ z&rZF?lwGGN65rYi9?zYZc^4*?n{U4O0m4=DkqQc zIW0MPU7dM7%Xxj*c^Jz4kU7-g_0wUaeEl#Xf)vq@G5KzCF<6-nNrr4vY%JObxwa$v zVy_Ih6%n?-vYIn>!=1v=8w z&$u$uzcM;&R^Wj#-3bfJD;1>p#QU)(FgR98wp01$au(gd2epNTMXJl2N}FS$5%eI~2^8p~0kin_*e4;vSPp+xGE1S*CaqjN{<(yU@# zyMiYd9nSpOy~hjU4ePIS*_L%hZ^z#*lGoq+H$d|m@VXlaRvTcq4RGp4Vu?ml3Eze& z-$LISvhGIO5}{~Al3NjXH1O>>RS@H%Rb^$y1@?Q*6}RXJQAL(YuZ}{RJQn(UlZGRo zVDg3Nl;36>BGq)}>=8=U*^lt@hl`uV&#kdJQe&g$;im zx<4~%H7={CCNJ0800OteD= z13N0CD16+XvTBStc3_7(q$^gqsdkE8iUiK%?!vnykAjbZa_sUeqvEXpH8% zi*>tmDs?HR#s%MqwCy&g=(N|xQRM`5cXfC7takU^c4Me}fM>1-OQ>SxK*r!v;n)Ow zySSI6W9dXqj*ef^Yz^6qgBDUh(>DD2U{!>IW6#@rs=lkpY=NuHr=l%8SD~|F9bal# zs=AvcC!+Vfs)I8<*H-%y8c{ycZ<4R7t4)lm#-py(EKSGzQ^@)S<7_f2V?jNgiQX8$ zFG2e_rVTfZovRpKcND6C#N^@fdUToiT-88ec|Su>0Y%^-TmB$N&mfnnF{!E_+@z`XQpmJ_%?@Vn%@MRQl-wheYYZc;*7!LbGnB}h+WKj5inf9j7aK|_TY-G9S zvfYg2C00HR*h_hE4b_jy7JnSFTuYu|BvE1VuD%y}?e6^6r6!a^R7yv^@VRsH#dW&4 zJcq%-sQ>lH5Bm{K;tES9Rl)MegfZ!-R4Tl~O1922C4L{i9w3i^I^cAr3t2~RQf)>i zm5u}2#B^E~8zod9moz#?Ud_nqur=`MXBk<^)Tyd|0l{%A-&V4f`ifPf!kVtX-M!p@ zN$Wqq>J&FT5-&fUm|9mS3%cl_dfe>lIQ`v}jQ6`5StJJkVtRZ)pUSSh^2i3tRUEh4 z`@qBm-3PFa8CpzcpanB{y)y*sGq8U%aN1d7saaC9*A1hHWU~gMD zNp$>FU&ujOHjD__E|o5X@w*=c_7e~+ZVK6YaI1N1P$ZL;{zT241x&Xj7L_aU|jS{6K=%qr!jl=Yqt&Ie&jTWqp_pVK@ zuTB43o1tCT*{dF7UFS_%S|DtSRHzS*HiF;VS$agQw`r{VM7&q-b{}BcsQ*_sJP-uI z>GmvG&#jIAL+A-7m^tIF+vX|=-dFAZVp%D^b}h9DHs8b!-h>uz;`MD3Y;3~rHjNnu zNg7eh@Vx*4QsAC;R;!;2qJz9r3;$$&DT9yB#FmuB`N~y!ozTB5CUuuSz1%TFNd-)2`Ao6TZjp=ae~d zDt>b7y#en@Bpts}5Id}*w0fy8BqEf3etrv;-VZn5j||?AzAxO5?b}!M+FsGvjh)zMo?Rr)ZvtwrN%sle z{q6HH?)>M(;s8G^kv=SOZPUPRt#PT2q-6}8IfU;W*1@0+(nk%2*tQZCTc!uzBnK#) zH-NsMM~r;HK35>M8-TV5r!BZGoZ>5kK1yKX>ux?h*|}`j2+lV-Af+3C2myvP977&b zfXP2ndElS#`~n+5o0>qzWUN3pzy~_4z=uYOG<=We=ulQW&;m%ChFu#BXr09hMEv}% z`O{JTCyN_^u>niA0qd9!i%|>k1I2R^57V|Xh5ILCL6Wg$(WfcHI7DuMvc!G6Vx<`Q zQ`R(W9HbHqeF{MW+3o@Ud?&k9cZW|O9W_ZG`Oh5ke?1hqhnH#|qTz=$)bRgI6afGW z5Z?bHipBpy6qgai%_O3k`v0+~%OQIJiq)m}5F$zOMd)$FaIm$Jp#M%3rNBHqJaioNP+k%!8wEBODTMD~W(Wpw z1Hi1{|4bCKPGcav|IYvs3zmic31<3VOS=D(9zyNI3QPe5F6dMfQ z9n#Xw*UR*fVBxb=XE#`v;8ORe(_Ej(j)zQ0Z%q0%>P$dBhED&W6gc=)pi^T$)%8ne zEY|WBO>F1M{z8Ys!|0CM>h);$G4YRot3@FzooV$p9J+pv4SJ7=pLFC|xo<$~@t-!_ ziq9WXRaoCY>F*7^JYD}lPL6tR_S<21dglpgazS7K64%#>xA6Y*I(#LAwI~pL2YI1$ zLbgxlS(z|gh(ySsa%3GM`UckiZgcXtWy?g4@acZc8-NPE=XWm0 ziemd<1sfs=bJfVMEqs?Xt~X3RLsHCTa7%vty&BLwRz^S4$dzUkkwMPL?v5hZs3 zO2xj4Gz~|`{0)HBS0A5`6-BZ=NJG2D`=RqLu2VD3xQ%fZh9;VUB4v@5F)?p`inkA=S-hMr#jH}w00AQZi9vWnM> zN8=JSb)kL<@@3tlV}ARoHbV<|%~MnFb1oS99=032LRLo8K)MPoRU ze@$(Uv5lH}V;HJQE%g9U-D7rQMY9Y&i;yur^!kkjm8quq*Ydkx z^(X{r^?f5xLW$|m#ZNY>AtOY!az~2(mxJ&5C$suu*}nh$dh_)6QsUXmw_*tUFKB}y zVRB10L&K%>OS%c_$I|7o7T4rf)=jo@rWEwT4{NUWd>6Gj4=(*X~j~&+7G9 z?7}~X?z!@8M(&N38ymOOyEW#LzSLkXOM743uJAl|arS9CE7$kDP*w9S*&;tX_mPFL z-fw!WIJPTIU?Kc&?oEb@Iy53s%u+cI!Qt*YYt}>Agd)EqJ-x&zKRr zLl`H2eR=4vdzD(g{snltF(9Yl;}D0yaDY&hcTcxClW5g0wD@CasK&i6H;_25t(=$^_y}9-UN>s(#MK-5H0_{WItPg z9fbSc#Y}pmXADJ_v+GY>KDBud2a;_67l4Uhw{Cqj;;7h@Ypkbo>f%pe2SXW0G)TFW z3O_>zw~Tx)Nlk>9YK?AKR{tvb9k*d4LIyw;OC}{>Byyf7YCPjJ6FdiKtdulAd3@3; z-bTy|tDmb#A<R=C#&uH!h^vsHSDcI1zbPh| zs*vNKP?MEwD4}q~Ar}WdrX(~Cl>!JEl{AUw)5%nHIpRq$na9~Em06O>v!ahv{ci)Ffnq5 zYktXIqLF?3=!8xs;yQ!g$(ltl2EPACvY^o#0k?GTU zg+kn8v~fT$&b9A$TLtM40SPu3!lT2M_ zYh>U|cju1XP~q!gJ(#uKI8cA+D*|Z)#9Dscw(P$DUA)U#3*oN!S@Qr^Mgux3ZibU1 zEOLgkD;yEXgm}4$WuzUlnPe`Z%9Zx}Oh++@EA>dLDV3MT7?_?x;rmTDwS3a@*ugD+ zlBrZ8)l-Yc`*G5E*{$DcT?!%84H{(=@9su+YEfY;m@wkkoO{1n`*QU-1IB16L{>SG zB3_RprlAP>Fm*C;lh79ze`C&JBcWjMG@nUlWJL+eJ5}8;^*h`kgAHToS94!qsP{Pj z)wDW{!FI4%5pEf__O4iWgD4l*^|#os#YDXwqNQzzU-c1z*dF>1t7B~q^-&k69%2@2 zlkt3^D}s)8N`KekhTGcOr!IW!icEJ@fgSI7oT5^@H{OZycS*j#@a)d%AHPfP@~utu z{VMDYy3khb(`xzU-(9zZ(d->S_VX7?0r4(^l+S<`{>N@qLuOKS?@rRH-(eeUADI0f zqmxQ{K>ob`hQhVY91y)HLCAtp0|0wmIpg;yTZuJ z9EluXHNDFPfRSEKfFHZ&Gp29KgkMfSB|Pn$fbsyk9}~~8a0-+gUcc&C8=du*2(9qC zd9qD}MiUbHD@~ALet^7OxFmFM9;@DWZMK7_&d{uVqc zpTZQkG~L&rim>iEp#SKLz}9tDaqXVN=FHq`9^?c(6gj?rmoz6ldQ(7eYBL(4|2W;i z3u^Nm16oHvr6E+`Rf2LAq9S`O5en)9 z9=X0gdMi01)z6%schmG;EZ9T8{Mx{sG2ijn7SDK`+4+9EKl1W3;p_7;`~KL%*O9xg z&}dSqLxw@h-V?qlLb>~&JGYe^5NxI{4(Q030v3#z1VZ&u4n<}L_?X_{1xND+!#}ux z$W+1B^&sdld0w&qQEJ!i1$qAfCI|ALBIB?!kh2p&JmEnZxFO@g0dle-xTYao0{p>< z`V@_NBEepz_*j?tAuFRHqN~0fvi|P3em%TeietDMHqx@Cp+#?JVo!J`XP> zzi6XN#+2XbI#xcIR%b#IR)T9YL8dbIpKab~WTay*fzN)6Ju@)4i6^2nDW-XqkT^6F zJSN$TjJYBm%S0({-VqR`8{1x@5hrVaUKGMwq&?mdx5tk?2#iDH3fw)3rD+To2@Nx5 zkZB(dXEcr9(+#Ld)*m_vr5OnLL6CqrFL9Ta036W5QuA-*k3(q`e?k!g6(A;5V@m3` zgdk@ru4j^%-6#I1?6$MuMv`UrSoW#nA*uBssgo{-!8iDg{n6;Dc1J?k@KX>v+~2NS zROxIBRo7wsJ9IC2%?!WL058Ti;1lKB-g zR2AtD6fv$Bvpp4a5S4HVmGJ17ESRV9tt$y+m-OSf^9rVkoGD)(fSaicd(BHT%Iv5J z<&zY|GP8)5qjZddOEGtkdB64TW; zi)~fRHag5~y|k_Ui>h@r8Oq0tmJ*`9utl9>-7~uq!qQ0JNHok$mWgaP6ul^@J{Fo{ z1<70{OjLwBW0u~WpH_QTEMh)#uvc7H_E*$O=OX{` z>L#J;7X9irzv_aKO(fUfFtnvcC_)pCn2(eyPaw*~`tHU02u4Zu{c%9^bqH?^#q zStgOz$a0?XC@H<#&Gp*tr`lbjx;>$~1O2)qzq*s`y0fmjWCkgt120m9dWc_zb|`jT zR}MU|);lV@@JMXt-v2wY9Y_m5gKfScefgD zv>N|yH6d;@6K=EEQ0Fpe`(pk@TEW9Qrmb+niR!#cZd1vr)ROlEMmAA~HpW#`4A8&j(xvO}%G269zoW$Y zKFaAzR44bZH#-%lH+|2T@NY7*7Au7=!>Tb$9-K5b z3%4{^wk&Ma&_AZk2#b%d_D(Bx-n^@jJnw{qZDOqM>d>n}1g(OTO1wTOiH1ITuWm#& zbN8R<^+Ug7zIH2GqE(M{ci|Wg-~|j2}L`fOe%445gwp> zCO|y$0k7C2Z`M$E47zjMZ!pEID5a!yeT2WHerAlV9|7MoeP0Nnx4_m879e zUwa7^~H?(nf6J^giz7Mg+L9(Nj4;FtG~)H zYdm)MPDGASIEaS7{|Z=)d&U-bf8Q57`SpG&P_{fhBgr zO{_JF$QI*)579!gL*D-1YC^l#W2351?Ct!AD{HVl46uVTrhEK`W}XJY=b(;v)0h9$ zsjNs-(f48dNzk0w9AFm6bdFPKQ9555fhyjX5$Pr)esZ+5w-m- zHDmZH!{N2GX}_3-=_6Cj8~f?*;cDT%lqPiz8`I{y!q3N6$z?%NbvB2yhOTrTJM+&U zwWKm>_w-~0Ds(Kp7F33_ZYzhEGZ&eN7HK97K-MjbIZ^69v^syozE0X!CFp4j#w~s_ z)G9(;s!%S6J_4%K%2c=XL{%#1-sk(?X6eNZ&$Q~SAnNQQYE0eE>4s?rw9fC~&d;{a zkx9$-cm}9100nN1gPO-?2W=at95RCVdChr5zuz4nB? zfYR5XJ%rOE;M>>~A(7!8tM&4!Y0aNQ)UX?kqS$vgF%u*yR{C`Xv9 z(JBM1n~dd~Kis<~|49btMYk4>ww438R`a&jd$%^XwhEzvn0`lFCe8e+p;AXGFr#an zL|amOQygU2{L)CWb5WKZOWW_RStdby+S3rD1O=-pvmI&pLUx|r7Zny9^xfRx)s)ax zqd^74T?OR$-H?I85cd_1Tc8x5)h+7}lIkJhzkeHxiuiPxZKIdre0=-o^y|-AvWt1Ki$&v$<)Dkz{EPLz zi%m8mh=;&oD~~b`Cxiy@{x?6u_C*uFg5%IG`b2no?WJNV=ZEb}9#MIGmAtDy5QDV# zFXq%)&6FP*wpM?G^mLG{<7ouYrq%z{UB)4>z=&VQ9Teb&$RUFM5ET3&`uax|e}Dw~ zhl2cyY6sih7;7@}OiZvFZGl3K40Kw^)+~62)E_q#vPCYfEuU1-eVHT|$$*@$E+r-g&E>KBgZ&Ak1BXe_y4M8y_5M+FR&=_@R%a0}n{S zPedgD6SRbf;eki}H?+(zD1L*MLIR=3sdbKPb{pmU_(1>PVMP`WoUjdn$(Bg@R>&>yy0paVbqaGHlY z-P*M=7Z#d#ARrS5#^PK7N0sf#f&%=S&sR!Jztd%9wam_EV(OQ@zv7c^tfpShSJ|u# zp6&^xlTp>D1i8bPBbN^|di(u3_lYpy>1*Kg=(D!3f?`banYtw51=8b_b_GgZZ0?8D zT;4THz;q!}eTXOd`uE@paSYcT?jHma39Tc`JEIaGFF+L0J6{@usV&W?vDZ^i8o+c? zB&YJIL+F%x!T$*k;jDbq5Ze0iBZfju69$5h`3Woy;u%E2`JCE_ujlW|>$Mh2lJs^9 zTAXN^!1RtXD<}R_*;@!kMk<|3`dEMuc?Pod- zj#~mr+cXdDy{9X!g9vcbkMqIB78JQWbT%fi{PC3)nTS*z)Y;+Tg0n@*NdMULt{)mG zOEH34aWq7EaTg3^w`jJ@v&y1pO9b0akFx4VpbCk(V39MrlDZN7%Bp|5ko~V{Wp6JF zjMmlG>o)eKP1D&n4YP907cGls_*O-6+xjQX?GsNYO&ynt;F4}_`^(OgvT7#1BbJzK-$&hc~Z&v%)-s~l>kku6Be4OaKbecAxsOj+j+qje{Z_%Gb1{33_WUb(F#4o5b0wPL*QWd%;ry#w&nme94|=R^7$Ib@-EIFB)NmML8+cTE z74+-qu&*Banu(MkdIaUhY417|eiK@rwFWElyXMkT4xG3*TBaLeN?#h3z>I|`2sM8q{( zqo$bPc3Fm6&XvlLq=d+EC5x4fCYIcl8ZWP7M5q`&-fMFI-Jgm#Z22bv{JxX|Sz%0y z=qfSt4O>#nb;}C537=Av>k@&ouoU@dl34~0sQCroj%_JYrCTWc7E@5`v=O!9`hDNS zRDDn{)!fUI4qs)?76{oe!!ka#A7%Ob>cR33g7Cut0ECgMzeMx{4fJ-*_ndfZb)*H! zfFQsqSMU0Fde33tDkPqknM5PN>=|u2HWZyY#vovuE;?;Q;BpLi9Bn^@CcROxjsuIF zYo7x7G3HZ1Ns_qX0j==k_^0@$)N(|b+ z_t;o0BZw~r5jqvB1;V+4#HtV7z8wEhp@vhN6?jUkJceJr%iH+7)gyvZyuuqes?0O;#Z`wtRHQtUm|6~~e7Y3TG zO(_`5o;r~H>KK%NO-*O^$>*VuF)(0qw-s#l;mkOehFm5$lZAREweZdr zL4M}Hb2y#F>R1qG@D$YwJky=&TE5l&Rr|5+-1uvCPt?y70p#qTrs5Xah*NjmO}&vO zAN`TBm5#Ig^ezp5xe}Ma9d+!#gjj)$g?qpKl=X0a`V~&%FD>_KRC42SK^C8+b<*GnXCBf&D zkB;Zi+vb`1OY5P7#rux20djvE`e$Z2XXa@g{G>JS(u1Phpr^_T=*vzk^c5crCjdrx z@Q%%}K#bJ!JMuxepauRXda6BW%YkTnR--4Aa04djn)SKQuX4flv7E#wg*IjsCJI z)SEgW9eG!DNnyCM@KY&MGXYCMHE?2<3enY@RBs$?{IJs zP!_bsa^@D#lu4^dV~KL3o?c?zP3E*&i*PCN?YqPBf;Z~8_XCw1e5W;rALop0PHVXV zB$|Vg6wKYfqv-t9Byr73CZx=a>5Mtob)D)+jQ7ooAV{Sx%F0~J%74r%B+M=r%r4c- zhBwO&YJpUAN+$6+AGT%F;mFWcaK)W+wPxu{mj>ftYq}{UUU9~2-1=vvTTa7rj(aEh zDu4_b$>SE?><(SVQL);ijElWE9Z&?wJTXDLKI%f=9xJCBbK_a5&`N;bi3K21HjplM zBun{dH3SJ>*+iOT0X12p0$}c&;-i-G@iyGIsGvbGe<(wF?J=XzH*+T}5mim3#v%ws z-(&2tfTqZeQZj<0$>k9rmk_nceXoFMy@>3oh=Qn?N~oAdznIRin6fgPL0_1wt(c)v z(E0MHn2Se;iK&F_I;Ke=`kPIOpo~sRBt&FFCetUsxq{qJt3)zos*M2DB|dS@H+1c% zO`X<9=RlH;X|5Oti6}ic%j8)RYT|Y)Uf`|J$X}-^$8{~Bt(3LNX0x&hNg>Sh@Wrxx zN&=7Q`r5J8UzhhqgcwSfD?po76yXuw{3;5fqDxV@Xpi$77ea-RU1UQly*2nF7b*=2 zvcvRQRp_g}j|(B13f+TjizM}{=H~fz%FEJD%f)>VH%e7-^a@IuqVpNJIuy&ihy>`$ zLiPM=#{N%2BNJ{zcCC?CWp*ZHW1{xe%`%m#juT#P0L47SuFl}7`sWkZVYJujQ{9bz z{heR^Lw5aBSN-#P{p(YG{T=8F`X7-gB*iBAq-c$VYt)8Ng=(X3Ap~?EFKlaSrIRW& zs&UlvGxQr~1ghLm8{aeMRx7G{!WU5dEhK>r$nc94zp19ftUK(8?@4#KAMxy{p6YkjO$<+3doKG#TsT`pM72b^v z8vIU<)|jH_%mKtj6ne4IwDpt*oFUyE@qarKi93^pJ5voh)BQU$b2_uTJ8O_+;=XsT zD`Fiybr#y=n$S8Q9A$%4bGlRvy3z;D8VRge&swg}y8MfM1kY-#a-!OpSvxkmh985g zSG$AvoW2rw^mO|S`LlI&*Gb`+v32*%3->M>^e+4NuIBW9q-;>sHEZFk!Gjik+OF<3 zR%^7P79y78k)UtC%WCfR3WAmE(}-$-muw(C;wypg8z#ck`^F10J+Hj_i^VMLX1Q(> zEd&s>H;KNka6KAd@(pp=?VF0eUO{dMLcaIwtjZ7{t0sC5Q-1XGq%K0Qh-Ts* zXm9%(_P1PyWB?VU7(YbVBY{_sk5kiS7okiX-LKR=K-^7DBVf|qZ2Ggrnxx+yQdguD zYga=bINV*6(><`-; zf1_2|JT{J1u5CI{+KkZpgs7(WWf0Czxp!#%{6?7;R|NukKc2Ful<^!#sy06Emw42| zT=YD?x}==9DY~bjY|UtRkrs%*0x0unHKB*fs6&d1ZOu&B(T-5_82h^xo zDldB`T4jN>_#hswGMX96TC)ho7n+Qn%9(i|T9Ue2nmKvye8qSZ3FYncSvK-@O zyZ%rPsmEYRJ-2PwWPy4d9m?jwKXHwbY)gQp<9~;+08Wr1_C54CrzTFZY~DSwY}H``M{Ch(1xPo<@+>@MH@uO0g-D~3ebzf(Hv0c-slnTB6+*f-EXPg zU7Y+MM1B7&#Du-ty+iM&Gsdc|%x5FpWp)bgKCfn&UguD)5Fp!U&naoH+%NuX%X_*1 zFTL@es~ zY({92%lPyeQAz2PPKpBIg9}Vw&_Aq3-$LXew)3g8Q*pa@+yF1tj|>d*b9tv^X99=mf{5|vFgCyjNL;+xkwtw6@R};qa4v>0pr1vr zXKx;3-!!rz_`q6W+RYI#_zFY1d(jgFvm6BTe0g}N^Qi|FQ_2N~I1wq}&22P+3)npe z&;BxwAICw66Vh5mN<#7j{D~~S?5UGLxV_Bo1Ng$6Z@*qi5%we2*CDh10@x;AH>qBM z3$OV~`H#Ic=%2YjWBB*~CWQrs#mULZWNTz^dL!jq20aT03<-B~a?-bTC2=5u z4~YDi+Gv}DXhn?RfS{$NCG}BBQc8xOUl3av=RfR5NqF`D`(FC}|E4#R;2@JC!%)J& zki)=`z@Xxxqhh`PFH#7L37fIi2q%~kbcsOs|44A8ct8*Yk?W)UCn!`(en&^s(qJ+G z%;rN}uBl9+r^x2<#4w!YHdT(qj-=XrOtG0QQ}jMUIB>60DO3x*Bk`SuOlFvTi_Xh5 zk(h@?!6;+Rgn`xnLPTFknkZqS*Uoj=RV&<9zw;I0D-sz{l4JKz;; zt@xV#D!hz4AywGSpQl~lM%eJV?=-<+b~S`0#IiJ)9HzTDIQjr6`6}M?zzTRW+*>uv z&In;V>w5van$rO4?w`@mW49(@Sub-SdXvw8DwoL(;aok}J_cjbW_^sOY$=dL!Q#Qy zQ9Q&g)X?;8U9x<)YXz%AZ7DFX1G~+?8_mMT6AS}aggB;uN_emyJ1r=HqlGkJv*J;l zg-<3qgsW*91EgZ4p+I{cJ&;xv8aGMS-=+QE>8a|Mk|k-$8AoK5;cdGf+>aDILD>@U9dP3eKO z!Inj|Fr2k7d@LylLu*#VPFPTntRd75zh7g_m}xL-TuZcDu?WG;j0Ul8AcOr0#4P%) ziIO$tt7*pG%*&LsXuA5;(JP@(h%b5)Eh!~ffTfBeIJo)+b?jD{MRjvg&x(RU0~oE6 zpe@`P`op!fZp<`62PqI&%g#C~7Q+1+?v!I?sRAe5JCr)s0*QahP)3iQo zir~l4ps2vQpM3rtf;yTes8dk~UJAUL^Gzvb0Wq|z^9afCMG%%6q7PH46LNM_u8EDn z)RP<$>OI~uo%+{JZ~5}?uYTmO^gXrjcjA`1LpH zmM2BO&oHHM{sTw`h>6hz8+^n0Przv3J{tDiND)Eu=0%Q=1>?9;C1?U^aGQb^h?LE~7;89I@A5%%>NBBK*K+5Oav;N&p)b zaFtl#0VLN_P%wK+N*g;cJHi4E2D{Aqm@RAqZ613-9|@ha6Qzqe37e)wov@R~-_zqD zuS!aoI5W%zkhAdei+<^;hvARfVjWhP@w4OfgD=YG@x+3vsDEilKMycj)aU<>xqe-U z;z%Vpklwn4t{J%BO($*8uHZI#uP+_d0Cq*AHq{?IpH zG@FHr<#zl@?rOS1_U-OkOdc#+|3X?uV>u!|$TN*-w*Qr^pQMs#sEQ?uNwMW=v99(Ul8oU%8ZBGRsbE~huNhuQ10cN=W2Yf%Qu0`0~XwKgzhs*{K93~|qw_p90{ z3N6{J{h7C>m*0r*Fsmo5($=oFchd&#m_xIH(e|rXj|Z)jv-P{}>%O$!fIvqq=i|gS z)>62e-p`hdN*f_8-mTeT=RseD^x)b-d|k7w4epOZyZegwO_N#<9tQqehsiqa9lb2B zyg6G)IT>xBFBhKW%!VlRd>#HTtR95#bx_FfyI|;?O&M8sNax2a$p%&>X(YJO^?B>p zF4#ZEN&W zUDq17e_=KludPxbD3yL}`Db5LIr_dy(PiPgo`OpCi z!uWO8=&~AB!(B_FY7F3|vwO;K*-Z9HOwm6Oe#0919X3j05&`y_PfWiyQF}ILZ(g21 z4RjC1Hn38MUu|Oy_biu0-vhRTghmp!UGl;hz*MHkg4MiwL2Hb?ig5a%b6|b+_ZXvdtA~Mt*55tu z_LoKr-8(>SPQP;%9dF_L!QWx*UgDd^x6vKnB!dL0f_wPwH$qyRhuTjrOB}{O$5n@sxG!A8m07bTW6c`?6?mPNT z^4}fuG3b2>dm!YQ6D~qV{PEk)cNAr=XTVzD!#fwSNm(h(Z}!ONycwt{CIuXVOW-4Y z5V9)_$Q7nnh6#oNfK0*n&<-Yck$7jLiedvkvS6>q4*n1s{9qmYo&Z)97jrctkc|_J zAPITNfG~ra0ymBW;V>ba@*qSCkpDq2xGmyWzz& z2^I|t8XXD*9f09YL4F7*d}AR@hS7PA!OiHTuXK=I=3pdNn4aMZWeS`tv@_Bfg3P3X1G5OvDf< zw%6v(B?QwGMBAj2t|z%;N#YBK15A*mjUFJqC=sm$VeuW9eLjHc2S^wVNZcoU4JF|l z1K;tze4s}RSw}o-V`SZGP)17_(vRS@mdJ+)z!km<>2FBHNy7SxEC&_A-7R3Z!x~L3 z8n;Dgt|f%TH#p8GK4&2T-6jlQO$`*Jo18}gL2eM_ct|=1hO<0M#1TYYl|-{aQ4>Fy zhEPTUSjROYXhIZE!eW7unkUJh$29&l!6Gz-oDw8iEwYNKJILd+tx0-?$^mQ#$XKx1 zHt@XS23aTRyiE1d0va8O2t_84Rf8~Nkbk6Cga%GpE4bJyCuqXBpr)zwz<@;B9KkvO zC4RuBZdxlQ8}vt<5FIrOw_E8ChX6`9?m_Q;A6;}tinPiMbD1tQ@n5hNRH zJ$xJquTUv6+eSR~D{|WQG{O}I_u#B1L12KdTZE23%P5x+ohQDYgAW~S1R;05uY<`r zYo)LGHd1`2V9uniJRA&}HAtR_eK4C#)9g#9M9w2;GI5A6rEQUhXZL_-m#<~#qot!BW*dm>E=pv;KvV*pz+%*Py2LGR z8^P4VxBE+5I-I6Q%Vh3`gqewC0r2`F>!l-~U25is#h7j-L}Y=qY)sy0A$|9`yUb#o z)(*+bYMmGJrPV5n7G$8dWGH-AV*XUlrdDPzYiSd0)p1)yHfJAVSN0WZsgti|2peAU zailnnue5sDRv)P%J6d1=$fj?}mWAjG4(1nKKj%Czi`XR-Gc7#~O7n!Hswy|DXK^q77** zd-i1-2@)P*kc^}V5{HVav=VVnlRzpDm`g(YkC@sYF@imHrvDBN3cU@CH=6y;K_Ex?=rh3*y{o{U)rg*WyUk&%ui39EGF#%=*Q8kINr-l`T8FnQ;IUj)OB zpZVFp?GOTkC!hUIjRto%KYgJMku$wTiUcGwLJ0qZ+W9=rW~2S4C@Z*$t8$zfK}CvCKf- zc=4lLTd4UH+HVKm$bqA%nK((*pUYw(m#3DmU&=NjDC(+^SS-DW)O#6ExT4!LIf zT}&8>`84NuD6ok(_OA32B{sa&o&~~#ZmHy`lsxN6cw+maO35}eYi={9L$*oW-{)!e zFo(HHtT}67c$B@?yt+fJ2qamC+-}IZirU)fJ$J6NkuYI+;K-ho0Q=gsJd+?UbsZbs z_j&pOz3ZH${w!Q(hVp2i7$B%T_G)8~AtP`DBS_F-wnMH_ZIQ8RLDB^L^q6-%yO^V# z&!ZkPuu&u9oYDlVMc;r9Lz~o|khviyWyhoWe4p^IIz4OwlU%D!Ts$OW5$TL)#opN> z@<)HhI%ATI2w^xNV;j_@tjZvO4;`(OGC?XF*@jyOsv-4E?pOd`Um(f%Zjqsm+yO!O z{`I5*=-+-t5wbl>BHW(?h(G)9Hzto_yH|@QhdRF&|7m;7nf%c+@R&0Np5x0YpLq?y z)XgBHssZRSdn3Ngpwt4Ql$c#N09;A#kw~M=_+L?bzr(7ewdZ6rNxOj{2GsLq-xrQD zxd$DF4Cm-xNP2=@DY4yn7W#dw`%WbbGOE4aLj&jewixF5+;UG{*&^^P)da<)V1vuD z^*jAU0vGsVha)2YWcnewE|zO-pAqPe{}~%S3`|f!$os^qp)^T zS3em5@`hFce^w)EqW51$9fS6)Yn>+JeDh$fj^b1`Gp zo98v<5PIad$(N+6zU-y8yj{H1e9V~WRu8ZpERYMxkxTGtxIrg_})F5a4 zf~G7c`}6VUlCOh3ly&P(qOuFA10(6fj{;{?y2D4=5gh@6XG--pyN017fA zMe1VMre*)G1kv@ye)Nwywg%sNhj5=n0~!<5fO`=P3E$@eMP8%-9!D+d0uH?6RgHmo znIWtgctX?QBOv&z#l9qltSkYj#XKK`409xo5%fOt5VcWWuOb04DX}M$0Gt^^ED<7- z49bc^ch26C(DbUXW1ScX3rgS#x)=@8v^m!<4GdgNWMB(wBKuLFx!1W0*dJ{j z>!4bf|Jkp(zo&z+KDz(aTt0)w1jNa)U{PVhu47+<=+O;DywZ5}OnOjuX`uNws4YCgg93V|; zRF4ZfYr?639UK z)B>yht|h}Lh^lrZx-ff4al~i=h83J|XNtg~`ks+3 zqsUp)akBB4zqQixOB|fw{DAGFNHN|b(*12R7=}(Rm#h27Y$O`d>yt31<6tG2i9Ys| z$B(TslpU*J4?4p{ribNo?A2!AXjioe{38o>EgafSzC5>xL0#acX$yovlN{*z) zfIS75tOA-`*jJ+Uol8+ppX#e1Xbr#V3ErhTbmD$|`oIwkvv5*%as$r=q>P|x0#d`r ze58rCI*%-)Aq+X=U&cvx<4(5ncWYgql#WsurxM~)<{YJIR_30O;hqXRP;XY@-wEYb z6+ABGZkTMU;r=Lkf6)9<9Ey-~p7;TTs{uj4N~9=<#et-%DX|apXs96LO28n{#kJ7F zvJvr8BH*m@XldzKwk}a1qu^>uFeJhR1MO8zDD-h9OyD$NB~1zuR7rs_hR9TSiKc;` zt$Nl4VSM^Iu_}=e!;oH2u!4J_hjwU(7M`|cOj^H&3H5D@AzGM)IfPg~hBjgbX{(de|>K07BNdhJHf z7;`fh!4W+ga$uM_rByj>kf z$kvDcqQyA&f|PgO5K6D*Ljfv>U0dExhy9agt*IniVd!tK&C>?oRuc7{^RViylm+Te z!P?SE#8MM$_!q0HIqm50L3xT38~wEERg+k$8kc=3ge?YUrXrao_sc+aNp8_G$uHq+ zYtjqyi5p?Vg527ye0O|*EWXf1_c{MJS+daW7iTIsy?4$WAwtWO9|d-r75B?- z#L`1$sAOlyKtw0$(4+v+mtX z2$aH`PmGYUpu+70%JI!7C4^eg(5_{o0%1()DK})X04>Re4#@=wmJF_3Ol5hYDHsFz zQVfNSIZ^Xz^`Vw;jwUl21I*_Z{fsH8utYT7D`W0vn$aLH1y2Qv3~+i@w4pfN0U*?eiOigUC=kUo;2gL%vH#pR_&fAf{L-$8*FHj7t7~#p< z9|y5YVMwddFpfw4uT0ql)}A_qlP9D5`B)U%I_>wOR7nYcI`V{2^r{;U4h>e*`SneU zq-hAuIj3wI1QFrL;$+}NXZn=(#A>qkW`;K%v#qr9qWw%ORA#n?n-pYpN4opG8>_ec zx+uq^T>)~1ZiRf7ZrTUz=Y{+J`X8*lWm{YgyKRZP6z=XW!7aGEySoKS^@F~xDR!gGIsoRM zm_e+CM3+sPk!PQ-IVp#yoRy#_Vv(?^}JVb~@ z)6_Plr5md%vW_7u_31>Cnv)@UXcjqsv$SUiiYSsMRB#|M#ERf5e!HFKAW5WK6QWx$z1h!nsHeDhFv z#_~1Ty}L#>&J=jGz~sWDiZUANAI8F;pyB-Zpc7o6?P2qv;CAoCkpL%u4KO!Owop2+ zam34}aa#KgMUHrkD{vSm zwZV*4Y6k@$(SdZ(Jl)a=;tf1+{)SXTM(N1lV%a3Wxv^^ODAIzJoS!HcSt&xf-y=h-i&AETBzD#ES3wcmUXhEXJta9oIIS=B(w0VyqAzJjz?g^jq}S*N zUw0SS{f%FI;$w)CF8=X7)(`DQM%3Drysduh2*&ev0J42F!JWy;c5|RznMY14=1c$| zxdOs@>0YlEVF(6$w|g85;Qr9yENr{Iqc!Q$#4i8rm+3(36MxqP*{mRHrDc@LIZ^y? z+$3R5Y#+kyr;aS^c>ZJIQGRi#&Wx>gL3UL{R>=ZlQQET@J>vEzvbT~1r=WH1V*#Cz z9exUlY$_{o;0;qee;V)=Q-s`uQ%+JkhcI|#q|O3xbfzgQ^1{ek$MP~|!)&&{{Xj!r zl6vR%C4?td{DDs5X@T6O8npzU~RZ)+z2b0si|R1G3M8AL4Q7xw5_qNNI7xt2sD}ldvp7bY?n*>=sRWgP zuNWtQ_&R&Q1S872y2Qv&04jC&uffJ!q`1v_8DxjJVkr=gbXNmZS?|e+V*?BB5;6hCu7P{^pLQ37SJYXHwcH@3aho{W^H+R)51~` z$+qdKk?HB>>6wG++27N1a5M9`Ga_7-QM>sSi?OLhGb_&IN!<*|9xA!wQ1WfK=<`sk zBQv?;1U9)UA^ziUsX~PCfck`u3Jc^+lwGj*X@zSI9`d}TvVSOIm&%AAR8+NUYS-*G ztE5#<_Am?q(*a`vgwS1rpwjYvv`8`dq}%-YG8AHzI%3}pa>YDK!~BUd5`4QF zX@0H-Bs(OB@bgn$Bn*B?PAFH9yaiUh<{pvxLgi+{e5&7k*sl81hz1Y|J`#C!;i6ZA z>~i4&Z4t3xmh6u@MLtV5m9#}oT?GoBV+oY)O(tA6)K*$(7&bl@I-~N922~%3CR1(e zb@7pPG3>L-TjZlAC)pClj0UaBBH46<269%`1fJOhBjcayYUFBF_r)|ARd>fZ8Hg&T z$r9hCIxpEGg^U)pi#Cv7W(h%56Eks1Q3V$bZz*zMp1)y%%4VKoW zeOX(SN>N5f$9qLDYDGVD#h_xvuzkg7bj5gO#pH0s^lrrre$^ar)naAZw1w6wH2ZDz>zGZ8)(lGQA0e78jVtWqU|9ZL#W7!3Kwi zAH_7+Fp+7dSng#45{@MGCTp`}n{|oJ`E0SkN-Y<|IC`|WnzKt4%PkZ1ryOE9s&J#C z?NEbtnD-`9uFrnZnN+M7RZxCBO%Ma08H7TR7t5TsQQ)|u3sAzoam&vf)&G^tlbFc> zt*)DfQVsAX_9g#Q8M2t4Fr%Ld$}f!CuxHNh$3}KX+bomWEZ5nr0H1ro5djT(R+f2e zVwEp7qM(dq=!{xsmRs&NTmKk#tZ4J1b)s%v0bk zsktmuqia5i7!S!9Q>$!KjxM99Xdwh^LH^JrrV6muOY#k^g{ zuZkSo_Jqne?qF?Xb`z@1c}W>fl-ciS_@=a(W~46j(ud#0utWC(a=a9ofTvZdEriF) z3*DB(tcfbOncoIQO-SfXrGw|u@ii&fmJl#3I7fG7{#a1W?w-ABQp~C@l3B2xnM98e z2Gz)9X&@1F)CK&S;R0@dC92dI3N~(CS>|^!`Hg2aioX3e>b*qAXT_~+HEgpz=d%CN zk~zkkr~*bV!JoeRj81GcC--z)0O24CF#98@Hnlm6wXeYut9lDXZ;y0SJo-RB>p-FM zK(XUM>Fa^=>VeA9f$IH%8p5G^2u^X|dIP16Mx|0D-MZpbo*)oAB@-ZV#EraY-X$k> z=yJIc+GisLQ&kxj>yDn6CQuF9t_+r#IU*9sl1ac4IW+OXsX!sjHrH>!j+H;nl~K(O zsWF-M#b7nRaEblobHwAX>PQ3yzs~5MHXd+_`};yOnl&>kRj+QN!WkydTWJ2{ zxAd6WLz>t_k5yp4FP~a&A70>sZKRJT5T<5$s4k8>`wf~sV{AlsNMeH@rsUf4FdWZb zSAjs99Ly$`9avK<4VUModr%)xwbfwh)(plww5^~z9+F>A`iqm4m}9aC{=045(~?Uh zS_u-Quk=`S-&w=%a0rqaGPh(nyvSvi#y6`O{Y3^Y)db2!R%@aFn5;J$YgNzQbsr(f z_;AHTVc0oIido8W3lXOT!A#$gLx(;uWGSPpHR&4eww;^c@Ae*@_urp?LAV&ezZj&y z7~;DSQ!r|0`!?Kfh-7MZI&F4}YxK2abI(SLtkN0sXPfFsLw)8#oLN0bfKDzlme*SU zxR=@G7cJ01&}`~z0pah3f9^)s&zVT`wajYGr4WWUX#}}8TLJ{zrO~9F^$>FRJ%UUo zaK3_tH4$NmI}`Ynv3{XJZ7<7h(aweIQu|l73p9zyO5Jz-t&w0OX*yTYGy)cQ{Q5Lk zzGeI@_v60WTo*LP8YYYAP1B(Org#k`&qey+Jz^lc>F$`=H*~VAU#=?E0g}4d*>=RC zpTbpwzdF9R?Y2be{+J?d$z+Cy*}faEqG1u7Xq`e>cUh+DWC$JN|2e zv8Ie^R(-QW>}erYUTF{&`ZI~?=q&3bqA@2EI;$bc;r(f$-Ug>^Qmfhvk zf(Rycsi`rgzaYUu@(uY#llboP1wI`rs}dBf0%BbErK{Ub@f#JYDXZ#0r1%EzHJhqV z)I8-yE?t%}IHPCPnHzP%mKEkVcrH{fZ?9Ee3Ue-ti>_z{HO(x+C0h9!1a2JD7FR$A zbN_`5a)*W?C&J)gN{%uI`I+cz?pY3_xy{*cgnhKSh`a)84|(SFE?0QrLyOfDRqLc# z!?`nF#{{PScV-f-fyj9J&6z1VGb&AwbZw@rhYmT&M@TV5G1ZqYBg`=jH&vP^X+mn% z%riwfIo-kjln1g_d|=*kmMs`{34V;dYI(z9TvN437QRnJV7f8Xow20QEpRN`avjV00gYK1RAIz8nTLk7Z^$?U$pp1j#uC@QyD%p z)IC)%*+vJRdz>#^;qozdPgTmw#0SxI$Xn@_bZwVBGIBwJd?JE-aMKcdTS{<(@Za*# z;Eqt%#hio|LYatSrb&VEMc+b(Iz@gX(Jysqt3O75)Cm>I2af?kGY@02;{s*&vi#>C zZs$T0Uxbkb<#4T=F2?|OHE1OF2IvZ2!4G2s&9ER?cqlA5p3juUu%XnrpinSY5F9Nq z%^WKn14D?>&rM1tQqXD^?x45|HUcH8ZzxpWCU^7{^yhgvg=iw3|8`g!_Jy(lo9 ze)@c-TdNY!JZ<=ST&oeT)m6MJ3~D995Wc_O>4E)QZ@|N^%ku}0-GT72H$yh#u1md6 zhv3>wKQC&_JS@v0>CG%EiD|gbZ`RB}Nb!7CJjYkK7r^N|k{kzryBlI|J%SiXZm+b= zcrn=RdwX^H!vC`u4_h882g>qhcaj&MN`3cf1qN=jKMJEeRD5K;`BTVMe(7E59I9Wi zs$RQ)DNF;2cVT+TLv1cJ*8KDG-7q!G7lz-;#l7NW0(aYvdV+GrTCXJ!WQK@9ZoDu% z1m2M%U1WA&s%Rp|E~*#?Ca7^J7^Gb+cpBmw$bRA31Ya3*3&NI7~6VwjHpl2Ekyvtp?_W zt!pn%m>oiZv0Dy>tQpsZIeBW)ppe@S8HF|`5VKKMN$>Tn0*kQB#3he zS#P--1P>vy&4l-7zA1z|J^h^Wd*7hBI{xdqZ8tEy`pk_cx}NiGlBuqjd&*uBzy)8W ziCZgsE^GZE?Hj$GM5a7<=j(@g2~<&@7~JXjG*B0IAJ3AC@E@LBF)2~r6&<5s-cofUjSb8!h&R`UTE zcmwmWBe0$6{6Ri!3*;yzFGT3f+dzEaP`N?u>G-#dJQOs-va0%Jho_jJ1gdnl(9c2C z@2S^)$$y0@`{!JtDMoJ#gO29R#6<2^j3}@!GbrA+kF(X(ilCcFaD^p0)IwhFPU|RM zVHWpXAt!B1Nj-0`BC4sFpv$a}U&P-2u>2K;7I+Z;Zjj{t8_WDCF6PULLsRhIKuS1d zhL4jCNj|Vl;tz~=;_!-gA&*YQ_tan;USwL}ug@`JuT+2t#t|xPcQ<4=oLO>gE*cFu zMVTOKTs`e7WKuat9vmHgWd@32Erjn$WlrTsl^|7_C30G}Ji$*!DNE8e>eecyiVanhN5k6U5lS zGO@@i>IKEr!q)>iYT10wSY!iDMU5(EB1T5|G$rP@QXEDrrm2Kol;oz@^mKVTMnN4^ zz$Lap_FuRO0my<5lh6TcdX2n#ETfLQ<|fu4ZiNH>G03+xtzf8a0*h)_z%N&vG?Que z9RQktB*GxHR5R<{(pqp8#31s6Y0e*Paten8teFe`)mrrTl|dYqc|L6a(k5{mgu{fb z8qTflr?*@o!<;e#GHx!xhbJ#A#VyC3aa5AQUit#p&d%=LN~s>Vl%+UXt;2Rx%FagG z9!@mvRl!^W?$xd2tDT_CIgxsQUF$Yw)*gXBm50e5h)L6K%5PavXT%@A6v%*It_+PlnB(Y$mM|rRB*OhuQeHV@r*wH~xiVGGnz=F5-roC@ra~qDT0+dvzV+pbP-J(Z_0OC{K%_Z&s}g@H6CB;jptjDmzF(Vi1O{p)Uj(; zbU)pGrGmY$R^Hi*9w*(C%Rv{I(T||JH1Ug>t&9#4!icxgCazOK7w%Xdrg;_ip||q% zD~oz%3vFM=+wVq3Jf?TK2)ki&oXjs-X-DFa=IHtA-D5H5FPO-wv~u^RB6MT6jJa!N zv#39)nvfH4HJE+WR<=a3Arq?a6wNZ$LnbZAq;5ZVR%`tsn4TEMV+Flan7AZgiE%yUYvp0 zU&sP2#5N;MepHzZab)E+yB3sbF6SvkOC8We|HWaouQD@_atMc2xZx~C8SM*Z$E_AD zQ8o3GfZ_@EuUgLntWTFfg^I^^&9A-iPU#jkbKPQmlJw3W;D3803)H#7FuIL(L_aEx z_w#kMHe9~Bc=l|4`^(og%y#9T-m`u5j?i3cxbkl8*|`eg@BJYBqPS3p z1mAsU_>+3-K(jE!$^P?Y&c9+v26i!T3NAI1zHE%nN@%?OI=V+sg$M0WL%j)YE;Zh_ zxAtAS0)@AKus=YSfPGg!UwV?51r>YwyK_FdD1<$cZw*H~t9_xx=v7v9L8;DAs_(^z zc{Nh_82(n>8UCpuVJVNblN`~`>zS^N+ptbG>$lk_W^8T(aP*2DdAr%=kycWl{Qj}CsIOyGI%=L&e~jxG4dxC+^C5J zvGE{A_8Y}R#UMs;7_2=dqR;L(G^jB@bTlDpfNm5*i>$&4B%i8qh1c$YXCu#>N}jyy85;nQi+5B)EH1>+b@WM0%L^0XB1!4SXW>G z9$OsYr-TcqCG5{Z4COJ>MGHHPeA|pDa?4VL*=QX!YDqW>X=4kxS2$YoP&nC81birD zC^H%{Dms)EsEa%b$1$g6YDd~ggt#c#WrPRs{kPLFWL&6sXFH8)81ldrj0+W^!E zDXHVGasp6JWnfQZe3Ts<^fO29y&NSaEl%zh15p2gOk2i{zj|Ll^B8RQ5L^TZ*q5%i z;%H$jdvGZtH{N|{FcVbWIMbj!*V%=;vJ&&jVL_mkz%Q2|rv{C{3WvB3Q&Kk2_^zC+W({O=Y?@Sj+Cn*aGg3I+@2 z|II)O2Lqa?#atnZ0kM!Yhud;0h0|&>|0xxACQrcgY-} zQmRT+qEUaQ)k)CHaoj=4U>pInwPOEPym8cSwEquZq{CLH+mA(&trX{}RBN1o8KZU% z$!-r=r*=V34ro{mWB_2=kI6jA(j6$~s!Zfy9T7yZ$(E2~o-&sy6_0PV{1yFOQ}aQ@Bn-AE7 zjC}{*ynovYN8>x$jUc=8p#%;S9Tx?{6e4{0pR*89_e7Rt+y~KS+3$Pzmvm%DewwnELUA9G<|$In3fdtR(nnb;xnRQdN}=+B^(Wd_gCuo+Z^hA;#f zxgF0%)?>7eMScp=+YKW3nT!oVVe5M-jxVCA+b|{tSUdK|efP8KJ#Q{vYeAm4p1>7g zpvjVtA4!t>qRNu!<1$>$CHH;`X2ZiF)hL<3Zsx}Zzx}LFrhbQUfsy=&BFHm*tN9#a zLPthd(G@=sHwt*qeLItcH-aC&E%+EKT|O@k4exx|$i?{{_>1YIsn7-bCOwqdlCZX@ z9p+_L&ISEk!LzKD)3T4ok32Dv>q6NWjQ1K8?2xF;wbiFT&%gCpg6*ydG6)P_J~a_Z z-NX}|)bk8H>r@6KA)EiDZ}|u-#A*8a@?G^?)c~TQn-*SgF~T``5IwDt0GPr+z$}ke z$0Jp+{4+}6K;CzRCfoRF>|?z@R^6Med@*q>A_#_Q06m#8m5{xeQEj>eH}6E6oLM?n zIo1*zQBs=Ca3P-biHacBew0?XIYH5jny_m8E66yWs^5S{`tu_er_gZ>zlaqMV#e2~ zpK9GW>FplIC!g@>9 z9J^#i(=^i&CJyMeVkgz<4>G_92N@!#m3N#ivcAq3WbRMnIzw2dM%q8xpR=W};g%An z6|JzIZmBH|M{*DsY&gb1Urh|<6BW|vz>Nfhi9DY> z`5sG|s4iA@lx1G~Z`@QXvZVF5&Cj}#%76|)9o!QQzblVls_g}sH4itG%LMuy)G{Rn zGH#M8@|+4aA`}-&&oC;)Y78FDQFIm;>Z!z7$hAa?mr{wFytLZKAKSWDTJ~$|Y*p$E z@A&dfWzGd2B{7UO*4kIQg%}MQu`#|NwKUjn+%=&R)ApYo>QUlheFn8BxA=hSLmcc= z29)cq-G|4I9&H7qbh6DDrZ%>>SIP*kk8vW@& zroSya`+g=i1syBBH-pls26V0t;^1&y(C)BEq6rBTkx>o_Y?)4*`T z-q(z82@XL^Fz4xGS8OLSUr?=yT0c+=v$q&Dzo_7`fG;!Qselj$1+muLwQb2OMg6z@ zZ5{SJEa3yeB0V36j#f#QM=MbdNYAQFOVPjB@STSuza4NI?6-zAGtdnyXg7vVWD(D@ z9tXV)nO#mr+`E^kUfVLI(a$vh4Waa7eI<70I`xqM+ucxZR8XV$J^SmKQN-s#vU_V_ zSa9$VCJxIrthUP~3%j^dBsWKosY21DQIAt+KISjlz9wm2~z zo}Du{Nq=4r1?Kz*?`E#gqRPTjtdM+L>=V60@ac%HcXfks_Gi|9{jja}_vkMjxl%G%#RJB^P2vSAum>;DCwdp+u$ zXj6>qzol3&_;>|mZu-i)apqpg`S#hKvibSxt*WEwmA?a&BHN!2P5KL$DXFK$;EFZ`k$^qITLEgF;RPH>?(Ws!7P>@0cD(9Zgb;@1 z7gk^!)*TZ@x*kUU7)D7LPAw2VBIiqM|B>NQV$0T?K06$kqA5C5>BN4*xy|EY$E_ex z5>~Vp+Wz1k7$b#)6f$OjUl4r!)LSi`tYbOlpPV~6{SPy z)rzPHKjv`V@&1X(0VX@LqciGV3+dPu?YtiC`WWp-7~>%jtXOA44>kQ*Y#%=>ZtV~ntn%^Lj9+n|8QEI|;+HhzJF?@uyW)G-A_)~cdAwCy*IWD5J{dDS9&JJA5lOR2D{6h5Qn5lyqA}E5vB)ro}Crv{FM~4zRg&EO2RE|C;8$_0)^`f8B1)Ik45Rm~AW#prq^%POd z6tu)k@P6}2=_(Q0ElUF)eF&$i@dF(vOB-a!`e(@JWGF&1ls7U|pEA^mGBpJ=4Y@?w ztJ1ZJ%)e!1Vr6+;4S%Hf^)*F~GtQymVsK(w^{{cEiy*Lz@5(awNB}crxH)8b3QBlG zRQz)o^&#MpCvYfHcDP`6q(L@-I~@T!HQIn48ItW4E(AKwwgsjMusS3;_=BLIw0apW zVOPN`5;;SJ8DsXKYW>_ae=-=lL_b28xemHagE%X}4DJ)-s#8jjr?f>r{}BDWHut&hcLE`*Er_z;%$U7^U$J?F zDL>h#=y`uMEEnnyt6nRA7jtEKMI)t_*=8qY)0g-12Z}SuDH1C_9_uyfxf%!HVB|)3 zN2;(W%63MoXjqqVkCd+PRAtgB^I3+}S}XqvkC3BPaGI3auu~c>mlj)Au8|J4q|ds4 zbMRDW1_lwX79z)iZ<*}C4|&$Hd6IlJVO+8GOgL$iHHpNQMYV(IibH*CfQ1A8(!pC!rRJqf=vG-hzo$SCTy?Uy09 z)L#%2WaH^Qr8U*3ao*@PiSp>h?UBMpG{P_Ht}zGy&Hb-h$au9jp=L^|Li{|Egn-)f zoMv*8!c4A5=aeHqruWnL{+S3L_&;gM`hb)^e+UwQHO0{hkag$ zV^4?kR!4(EjjM3G`wI^AVH!tZp=W$5S6Hp)+@}z;}F-a8MjcdyW6QXIXR$HY@+7@EFpZuGDQ>*Ip9;F^Z+AW~y6twfi$kPm6F* zn^8|kU{6=xf7&5You;9svsNhjG^xIGH4RhsUU5Snx*MKL4~yD2$bfnpf;Zuy$5de;^_MwX zz&kqyrUa2vB%bTU1eY~hOFM`Rzz-q@s6){YAo@82^xmN@;!qkQ zqriMb2Xh!A0&^IK&~czLz<&UUn07IiYz*Kc9d;rF{Chm`j}q=bm?_=Wb*+WP<^O}3 z(uPfu$@>3aIzac|)0JUpVX`1_tp5w40C^%T0r;;yJm!C(Q(%w~!5S?vB>y*hICTej zbKp4je_Z`ptSF!w34AHU7Pm8ZW}Q0CD(0QZ_IJ**BwJ&sVyg2rp8Z%ZTduB)a<$5W zCLR-*+ECp0M$%@B?`o&l01r~l?5mcQx-qlzr`*l z8acWVl4EhQn9rd2XS$o$G@`1gpWPm~&UCf`BbV$m)6;qV9n~9Att-%beH&y2YxXS- zT>>gJpT+t|zKho>Y%^vf=?&lQHG59Lo1Q-DtHs_|iHL2Hzl9TRi#}LbLv~RRV!Lxx$qh@^;@uCY&Fsm5Yb*QLI+LJ4a zzgKE!y5$U=NsNlr|G=kIS(){01U0ZkY;+-R^?sV$|19KL_Pgf0Uw~ZdI^T@O9Fn*V zeARWj_=0F9G(YgnXLLRIm$}!iAk}hZStrh*BGWHLCJuXsXKOphMPTaH!;#OD^oM>I z1aD2-?(UA;KyIuaKTy%Q6Fz5Al4FcV1KmlQb&MXf-1!Rv@U-tnEf~FPGw{31;~t2i(NCS3->IREB%O$Di1HIF zV8em~JeczSNEEZdi-Kn>;3vQw+xd60&t*c7d~?-1vnc-ogd2aV#rFlIwzCo}HW1kK|7X_|S(Cp?P4?mY~_$ z^P`G6nNL4cHbcpCHu9!Ki=$btyv@=IkjGud{J|lDpG2cH1Iz*jgFAg)Cg^M0K;F?UK7E(i*SH$_HhB=teTIs_#OAj|hF zCELrA>XK$6!OKG-!x||>EN4QKSUoNF_aZ&AC8L={S|;k#0i%k-6n$e$T5=nR#c5)S zvV%LTco|a?x1!wguR`juv@Hi%L0psjHYeMQo~KG-#?kOLcYV5&uXAE1J;W0U*f%i{ zp3xflLGV~$ttKtt@|+XvclH?ji9mqu&v3Ti`+3=9aBBG!w zNx&vf_?4&ikyo`O-5HjD(nbCXioH0+nOY+8?=luHDsid+^*G(gQlh(K3BG_uv|mdl zR5Uf>Q?Zgz_eE#7bR9G zO{tGc*qNpyf;w8Ob#~YjYoSAt-UAa&JgCyqQBbX zYu`FL!EV*?yru+Hla^ExKM}>9m=WGk6N$hLxGNeA31mAip<^ zPv8Aa2D}WQO9MM+{9u-zP_u2xpxHl|!l!L1*_#Dq+S{HG4?d44tDnX3eM8fupTD*nQ= zFuaEL1kSuOO%}zA^|VwdUS0t#i(NGaOt9Zm2$<{MS^gx2Z2#@2TxPk8cTI$yLcBM2 zZH_7m6DpD8d26Mtg<+X#_=G$!2v|T5=v!KI~mz9 zNr8eTGL5%HO1;PEYst)Ps6QuRM8Cw%J}!izU8sdWZ7Y4ecEBO`zB9a^v65}itozq- z&wIqWPQ-fO9Ai?iZKJIR9{^R6v2(Gh079Q^5vFyC#GYTwJB>rl+zT|~9V zA(9Y!a?Sg_mAuFG%G_llwSbJ8-p6pD*v`#yf5Qhb8)czhfAYk=$ar(VcQvpucT~3W zgsbIZ$yaOct>eDnviF@*W_n)d`-iG2=_U+zX$I^aaOVBVv*$SSdbdRQW(V||M+kiS z;C!Z>THEU>_V#)HU9j3Z_veb?`gvLIqtX%ai&TA#R40sjrv+&1N9p=S=`%{FW=8cM z`j2^s8#BtL-cbkhxe`7a_1cA5Aj!QV1_AsGM#|kjF@`!l%GQiHo@Y5I-YZ3DJDKap z@U%rKcDlYX7zS&)^pr-5BjLwAhTpv%d(m(kSB^5+@+#&ZDV8Aw{?P-nC!UZLAb~1x zV@VgZVg_}1sN#y(*{i!WYe2qZYjF zUO(ZhU&45H!em#%^m@YVW5PUP;;$tc?kv_tB$eO@PLu<2KfL(06D-CfuFSNLPZ4H~ zkBRbBngBi>X<7+4x&$D|?5Uk*3`0uI{v*RgMshL|1(kySs;h=PcDR0lvGM;9-<#0pBqPXweQf3%ekbR;6Q zbJWebNtZ`GR(3yf&3_Ujsm{!U#C#G+e=EfY{-b%@5Oe6tqiJ&jo#w4ZfCvZ-2buC` zVC~*w@_SDKdE@!Fp84}R`H_T*Ypdx?IR%@Lg6)lhZ%+lgM1}irf`tbLg-8B{Cpm>@ zf;fwi!dmYzU3!08c)QEf!k8b{U7h(W4n=J~c0uylMLI>VfxAwX2|bb?TwVJ%%W*nr(0fbRi=7opdnPDZCIfjP@$h& zVc1<^yjfxTT=8N+Uv7>2D{54 z;Q}4OX-64BxwQ>9PL03wW6x>@!po&5iDi}#BQ-^l4%Pk7wdo9? zM5fAqpZdnVoG504?6v9!y1Lm~9N+c8L2v_ykT(T!(Axjub(43^QKizz(x_Flg5aRi z$2l8Vf^$EpPE#zC_qURALDEjP~{O~X7A7g>~J6$?(6-~^C zT7)aQj@}x`z>I)0Q2qRHtj||YWLx~!MrIe-0eeA}g>W;f{ zHgauT1Ok$~ZA80^fO(y!(6IyESO)uHkuP|0c~t;=1r_Fcb%xydK*GXG<;f=iFQh0l zkTCDQ2)o%(SJH7U8U&A$U9!Wr>XPJR1R zc%%q@^3s-!SyJccL?{ZZE7US?nr*-v8<`b>e~x-6(g7He0XX9UgrI@d@@}M8?2Ni< zR5a2iT3ZtNddz&BkKiu61j3Q_(5S9@q6FN&(|n5h&eBL*a9t=(K2H2{CI*@ZRrLT9 z=`gp*Fpu#tU(m2X{;*K*u*mi>$hslse3%KV%Yu1?7rMI6D^L~63h=i??@Yi(rxK^6IfN!FzeHo4&A zNhp*F)Y>T&teDt;)04f^)7#Utuha9SGmEUidwWi_3Jd>RUi$QY zTW+=E^T|si095)HOPW5O(Z0rBX$|Qnu6EX6MiwKlGss%~xaR_}!$98S@|oRU4pQB= zztjA8D{3?G6G}Yvccd0b<=m*t?MvJ97=PvgWDD4$3%Di=_ywZDJ=m?R1A`7<4OvwP z>&s0|Kv-wjrrmy2BJOB`=(OPE|s>?+s}pO=i)7ek4r)b69$ zl*Jqd5M^DK{eLbcI{<6wL?BWo!id3G^g7FAZlB*(RdfyiXs<$dm*{84O+`9m)0bXG z7A1R{D6*P4r?4D~5oy)m!L-mA;!wr|hz{@A+hW9k-4#6LRa%Ws)Tz%nfvVn@P$cHd z!JnZ+&0#oJpo9~LUCx{AX|O^HK9eW`GXE^T8?eYeugT270}{?T40`6tPN_dwr7cY{v| zUJoi5v~|+6ZD>L|+>(~jvyN>yd{L7jgNA2!``i=^!+{4~DLT2D7>}s293Z~s)373} zzKn@(DSWvBd>6r)pf4c*+D=vh{~ILtoss)L>LAS{|H^wR-t!)^4Kl1YZ1PNIm>it{ z!pIGd`%gx0{y&Udm;aWL`=4zPdglM-yocz2O34LdI>Ly6{~w1v|AET=rwO7-2^A(m zm_^&=aiAE>AvKw=&|3Llnjk1-?^%jMBm*kF%48~&Muk+FVu50N?Me!lu!)PRFQrJY)mF$&|MLFp%zER&Pp)dTI8w`V(3ouAa3LSRwJ>)l8Hf? zM&S1AIy&@(ek4Pl)9>R1l?k;%5YO+iTyri#`rMZ3u$4-3_UrZcU30^0Z@+upe!GX2 zNx@==7>gGFKHqEA!^{;Ad%Pb#|33HS%i9~2DNx2Rbp%jGr_DUO=BB z;5gWJAPm~JdM}#P=VZ?lQS6a2;0lkQIs#4xv>OPkJ5Cu*@s;sM0?(@b59Ol*0qQ_h zO?k^80-+O{up*+9{X}{Cnu9cDSHEv=#mH-v@u(;Y6>)@$*}JJ4Casb( z3QlS3dI)VSyU`7+`=QC7MK#Y#VxO=6=Bw&gy_@tOh7Q!hXS&lj6O7lsC4sCY?%T!00`PQEOtU z=K;rJ-B;E>W&>qCtuEUSllU7U+v-cMy;qQz;GpZeI+O03VwwY?@58{E`NK)z-A169jaYUt9C!uEOJPRc(F%kVMQQm zxtN}gcGyYEG^v@JmQe|I3xX#EYEus=nNq`j#->;kFJ<>B zS9azz0UTAWVc`Wr)lke;OafYI;6R~+-A~F9_MVK0Kk*$#m;e?ik$D%&^MU(0Gf4Sb zGDeC3kz}nmDA&T)0<4oN=}^Jxf|T2W@5JIW*u5ll(ri!A=r<|sV1qMAI*Qozz@r#e@*7?i19 zC!*pc!CI@ru@_&aGlLA&^kmiykK?I8(5n1!t)*VTKGI0l=V%PeGUxbwmwnvEppRIm zt5fnziJh)S3SCL>Ysd%~d5}OVIZh*zyQ6rx+}7YNb~Pe0>oc&=KCU)EeXi0THp|7i z&{eAz>8(#E`>EMQ3f7Tg5oM_fUoh1HXtWYdJ|M>3-oQaEV>if=P64DTAO+y zH&xqGHbG`>(DIY#2#$HbBrmWheXGoSltJ{6p34Z8@2K7zl%ZJ63vzXJ42T9@!M zk^FDOz4c#{f!p_eZftDyMviW2#Xw46G)Q-cNJ@i9yV0XZj+AaBq>*kxLOK-`5EK;) zDc#NYx}N90pX>E{etCX+_6O{@ojcCs_5a*PA!|$am znoGnYJ3GAij>YG#)9kdNY@HjCO|lt2Pv{Bq;$Rqt_3e7R9&In?hr*B;LCm%^o2g;|($?rC!_Pgegf+>Dc+-ms>B z(Ny@mXtwuj;D0c--NY__cq<%N zoXIpVaAIIsiH0I|GTKaUtHuoOT2`CrXh>bVn}b+1w&gL=ez+Rb-)dpKYQY_2=x<^} z(j3VyL9g2ubGzC|9Tm%QONY87DhnE28W00Dup4PbEUV}aR2aBbGvyFR<3>#_3{mC@ z@YtYEgv_2{Xs1?|Q@rDWg+fp~MoCe30-}Rj!r@W8QBpWBI8rG{yRJEY{#T5BbJWwE zgwFq+7{Sq%n}zplcnoUzjpan>2}BH;D-Y-BqgJt5Fl35xVDESAw4i)yU=r(~Z(3Z^ ziuwH{b&cf-#m?{-pvMJ5rJBt|@Jh$fDg82@JuHGRJP`z#*wK{zp($ z^(ykY6V5bE_f`%a`(7%WZ(?bX84<_F5`auKN2r`4=qya3YYDAz{lRjb_=5z@X`DJL zjwYASHOHt_75_~|7jkA&n8S2`6o1z|mZ3TFT1;F95uTnEV8zI9!k^3ioB=;)Y{<#X zAug4M>r7+Al<3^8$<$=le&v`!ewOh*BJ=iHCJL13kfxoynn@jua5MK+?Mlordj0X}6aa?_2jgorC-`C;IoY74K(-q$49*2JK9#os#Tv^>mI_!O`Ih~?oLLcOb$f4xXm zrbO%iUNhOVR65913KLQtSsWVU^N)fmnLaARb_1$c@|o09zfV-E{LCE_$U6wqoKugi zaH|Sj&trcQ#9^j+<71%PIj7q}&IPfHkMb3>2Sde1)~XB)V|qSt_`1r6Xxj~A)ySS&_vu>dTf_rl!pG@Q`4s3a6rGQ_Ku!=`RJuU1-JU$As?W#kn&W?=9tEU3L4M zige8zPUBxNjhe0m>o1>UqL=m>9xQbbDq`8lJM;xQe{oy4d5RPP$&&oIY2q%|5P+NpLrR}xlnHP040Xtn z>1{8Sq+ijAbW^#%D%QsN91hq@*L+}{wSe5+VYBsKN zw%7M=zaFs`9qGbft)BkR(&^sxqV@D;Y0_Pf>4-_hjRFC4HA(J8#q~{pjX`BH^WjFO z5H-xDvf>+AGe`FiY>~fl1#RteCZ?)>*{a6BRC5_;_t?TDSE}T#s~m?O`ivQ`P6S zdPVX=5Q_(kdSW8zs!9`2QM1A&DdnZW7q6{#W*Xz3Kze5$gfF`mEt<-EedqxiJ&O~hZfxv2RRD*5Xp zn0dCX*S3XxDU3={%4;HgYgwLeinr!VJ=+#e!zVSY(1B}zcV^;aCBLyPoLM_HKUBSZ zE&XCz1)to;220&Dp9y+#H^zDHU6fnn-OaJT_c(vK3LCpxUL?gn8cwZPuM?Xw*q;e+ z-y9EjTM2)&p1j3EOrB)K&CK_5)@0v(U5I%Uwx#+>&Lt-KZQ_@=Qf$G;uETOkt5+hf z)53S>z3(o!-u?Ug4&c}Z%Wp$$x2GMHKd{e}HV8IK`_*=BrzlmS{^Yu-D{~~zAW{Uz z;-9cJY~$}-vrk^F5qiVXfGi;F#QIeSC+tuolLh+(TMuK z_U#Axe;?2syZ*cJdv-Bb8{e_9( zz}7>Ht>pVubDQI1W7X;+)PnQFa~jD{?D~@KyiJC+rn)x>+^3%z>fH;}ofG9ix>4@= zuYBC>R6~CG7)hj7ek2;iC6>Ro`H@=e*@0c@A}(d=tLsGiGXm!Yh!N9v>xqol$J(63 zSeS-$_(8Q7?=6nQFXpL|@)0DJA6ioAI^uUyI38o==X(1%JKQSkE#4QJ9gY2)%YKR! z_*(glZ}{~yHq&QEn8#oECL+ej?0Rg!Y;$~lFaLGd_UlPoA6v=u{X2y6^(l+$*X5`R zm7p};n6JRssPWMWtjCqD{b{+wR1@<~&d_dy)E<{uC?`p38uGnWa`(1m7M|>d!S<5j zl#`L#xmnZRfZA`Xa#ektwY8>o@oVU~5N!-&Du=+V&5V^U+-~4oP4i>7ZBAlh$#n+< z5OX*XtAA7|dbgC*AGh-w2<0 zbD*dJMe>UrR1)oGfmq6LK1*jU?)pb<*1d_}88O`5`|931$#>(Vqu{q`f4Bdsk!}A= z&~PzU0=fV47gOWlyY+788`}#3MtTm$HZ;J{|5X8+ zlQ~OCDFD->vL=H z{@bd*bKLXMkUWz1kXz%y24B=>Zv0bt^Y81d0GxyP%Z_-0A)Xe2iRT2XW|$`Cb|r`e z5`6vW@OHIsscJHZYM$|-VWm!?7auTzHbz69QuCqPYEA0E&vhAs+CCY*aGHLmns55W zvYo#zL?VabvvsrF=l0Qjvv0P2;eY=v^}pZ(3zg<0Uaq|NZ?-go&d;MYQ&45*iip9`Mz=xCBZUEv`gHpxYiKr3*peDWU5= zN;C?Pn>7^tEX~EV$|^Ti84|mXhp3pMktKA@*3D|H-j0W(3C<(U>YSJnJU55Lu6{8i zJ0GnEKf(CL5TyuTwK8AXk^cQ_jU%_T#5#6gXx-?C+$Q`&fyEr=N{JAZAFl|NQ|p@y zHS5Jv4fcxG`Lt_t@JYdpJFbH&Vo-@iA=Y1u8p2Vcenyejm&V3sHsJ`P7>B)9w9pl! zVCDX8?Ozkxuyhl%2XPY3TogKgB}{l8Sks$1{0BAbMo4af328v@R@xtq68b`a?h&S_ zQWqCjB7h*n`KL5r_0$szvi$6#Iq%Ny=vJvLnd5Gpe6vgBeIa3yq~NUgkk4vq(H^g- zChe4E=G;ZN=S;$(c>w3XE8|9fQ_0e;N=PJkSeW?m%iLO3qu1vV+M8k~!R@!)dcS$I9TI8AeYJfiUYG!(b-8nm}EejYh1?`q-qletg0_mjb>5pR|J_FQ0j$fzhUZ=W- z8iY4A&1ai7lu5K%Yx+LQ#r}7-ml?nUSGOEe;1&rK9Q)t5oWkPDt6L7+8r#(^hdoR1 z|Miw*NfJc@ar^&x%=!QMmO}y~hZDB@U<{a%w7dVO#!JqAzqMMufIN-GW^$2l{=Y6P zbFH;Yb>=OPpf$$jgAG>02{x|wpH#sHta7GVHh&n$n;1@0nETlFXMxmv!-89OhKs(j2(osy9zI;Sau_$<5Ak#u zfd2BTtnb?1n}ayesc40?eS0?$MZsx{{PuYy{B~S}&68iA0~y~eul-kzca_2D?i8!1 z)7RKxWg^YH{Fc+LnU*@c*}mS3yxv!^*tcsh0*6~{DA;;0dkM%Ss1L?`C}zcOl9{j^ z{>xX>>cOu!*?YOJeSg@Vl8jbaJKK*5Jg7hb!Y91PAbsQAfrCYLARX?r$RO^*|vQkIt zOYGu&ko2}f;CcOi?dOIbLU}7#z!bJN`lVxFogZ!XLB_bjj3$^OyJNdOpRWtsWR=zf zd1Hm|{kl(4-f2e$xzYW7`Q=xollOJs29^J9Jny0xauOdxe4`Loq6t*-9!0u{@{X|t zUW$x!WE{_-Fruo2rM!8ukb_|7uSsdMvLKzGb1Fwm#Xu{I4*PeuY9W?D_!7@bkb(t_9+k z8!At~iFH1rHu6BO_|r5Ol=ehuTv`R#H#*Kpl=cGk?>#QnvgWN*EL)i*i)yA)&lIWO)K|DN6TlzdcO z_?m3L-^63EwNAte87n&m$0gulC+MHIb?L1SZ#Qd^mO*q?o6+W&gDe zJ`1=p?sgo_b7}YX(|i;CwPOb{z8ne@sbQA&<5;QJCDegAA~8tOXdjS+K9OVf1}YmL z$MxRchc-~90RBb*dXZ0Ggd6F6u5R;pveXo3F3s%MoWOkU@T4hdqyzWe*TP(sfU1Fy ziM&X=De{<-SrXSE*`6lH)bh}%b3&y{#W&_NOFYLnjR_@|A9!5SCKsJhzxTdlA4wD+X-h-ZEmS%~0oobf9tOhlc zOAWN1p0pfdGA*$Es3wP%`+_4f9*cDvj6TBdg^ZM z9p@w%-Q(Rk_$jI4Mck%sxZCaM5<0gNmD^4289|>YjQu;Nw!Vh^dAUv17&NZ+_D9~I z9=xh|(`BtKuk_F8BTsMKV)b0~h*=+dI;->dm$idGnC=(1e#6v7FNN;;wvgyHMMgMo zDMDpW2N5Yv(E{2#*_vSv(|Jw`RtIF9wWjOA<8q5pCx}Z_RH^v5b!s^449B> zAJQvJ6?)UjQt}qn4nJ@D)GzeBWnMBrbGZ8bsJzL6|;rmc3Y;OKESd()se}<;v)CY&C=~?@I-wy9{9WZ{XbWBtUe>xc*6W zO(oOXdDHQZI!h{*su9JTMJ4*EfxZ^C2I3AyN5bcYs1Z6$Ohi< zgMI(5#bN5SuW}3@HibM@w*TIKI~ zHA+uN##dk8W6@@h{xhE1r<$oQXY53{fq9cmJdumsY7%bdIQ2j2 zSqz8wzdsy}{o0@Ug7QBTi5}l?+=gLqxCBFTkXe5o+4^E=K4OBUov~pO9x$m0dh-az zpa@h>1WV_Cbz@hp6@N#>xOnp%JUW3YB}hi_yVyUG^cK>Ip;6IFQqd~r(P}}_8m@9yqu8Ud=z1n6@xZ7*q}aYnWU@NuslRW*UNj{E zBl?9aa6ElUrF zoU-*G6TBU6Hv|fCx`y|>5nPiHz__|_vQLC7?s9q@Hb&SLNNFbXb^pN*EsYs4XtN)oxXTCwKtL+e9L34|cOp zag9kaT#fuZff~orhS`Im+W<@-kS&I7x|&CutDRM+_F)8>=}TZrXn+cV-!#V`OFj`U zfraT`<&dt*WmhI{KLZ#oqb9BM5R9D*qBIABbm6X2P$+A;>IM3 z!ws64K!oJl4xc&Hbz?wRREpa!Dw31+8QXF*jy@c8#t^hSF)*Wek3@#n(f?qsK!HM3 z$9RMcC!m667w*2h^QJ(NX7eaAzKB2?{|auM|c(^X;V+k|-bhPoNV~6h3Qk zypB10rEVAYPAC%`1^dfnM}AVM&&_orw8{xRjrXxb&|7oq<4%yL+u`8vOz%BTvgY0< z@^?w0!$5r4cSuiMHUOY@;kZPyMUBBnQXDr;UF7|;cWn$K%pU?2L6T?;o}Nu{FoSBS z+DZKeWM>e}c!@Tkp>P!EfjVCwq305g7JsM6x2;~3Z}sBOv#e9afI%>qeiIr&6vIYP z@Q5PbhuhyesES7M`3xc{M13Ml=$v2KWy6E3-=bs2T*`{%j&eESv0#UT8u?!x=o0Md zoNHLv81Z9N2_u|b1B1c#m@+)eSw0j|kq2l8nn)-SSa{2|?Sc$4z9uxe+!0C$V8X5* zf$I!>p2M-=_eWO-yaA^VrI>IWiO$ z&tJlb$Lf=U$7R;@P$7IQXK`xE0m}di2Y>I6D#u_W;2eIP=-9%w^~I=OmfaF^ z=x;^`7bd{E0vp7^4SxC~KFHPO4|~EFC{sDmlqJwQSc3ieV8CzcXusxofr?qq1277) z^6J7?=6iun!HDuMJT(P6%S?Bxc^OE`Rw_@MS;_4R%w<8lJQ}9@y+yI(?n|KI5$xQ!(W8ScW$>D%|y8ClXKITLuqIpAf(@ZeKYQEbP4sn{j|Xl---1kdBlZ; zsGV0w^S=A2yZ|fNL{-s?hBG{qcZ}Pv=otHBc_7@RrHGJs>~sm^Xb-O}5see&am;^s z{jUpsX8zYAqbd-vK8&2^MeKj=*? z)IuCVPCJs0A|E8ZNd_mo6-`#yQ;bo(o15mu2otiSI#WOatfIUQ<}S( zu&!sKy(SPe5qm0hd5c&sRiR7mT4S2esUrRXwa(4%n603?WY*^))lKya~j%S-1_1RXKj5~E6vVAeO) zUL_s;?zn9uSvGLRI?20h06@H7V4+XeswnN%D7c%rKqxK*srej$wU6MFB!`puESAwO z4rx#gL}ktIfN3*}H5Bf@ieQ_UmRNFWw6?FHK+JG4EAM_J-Mgu>pYSkgH^;rWtWz;u zmjE6F+J9I~#Ii%|nSwGf@a&w@V#D(47t+Fy3(D>!`BJ9M#@CK#ucF5+jH-@b=wB7} z@gGL((NtxKxz4=eq>AI92jTdV%;j4j&F!zCCUwx`jGH4vCJ%0&g1AKIBCWWO@6sP* zm77|$@KBr%!C#Raoz0HVih(r5$E)AuQj~POQ^|BJNJ423FcL8u__k~OXb&-XYYr_y zBACTTVt;>L<-UHps4Lj|T}F}2x(ZskRQR!HAVW@AxjZ8DXhFLr-{<+*%bikI#gX4z z)0t-68l#X_juDWm#K%#fh`P3t2t^UJW) zVcvfX4m>}%@ja$xY|!gZZ{C?-k%=1sy^Z2?duc%8&OJ!hE)-SNVI8xG8rI9!)-PxK zAZ&FuX`LoUVP0oN`jN4s)5$YJbJA*ud}W}7l3zVb+fir8<~Om*Jf)@JqxMHJ%^!ZG zwnDK!Y}@Jc#{C^j)m{&3HP^r90@74M{L1Jq8QJqU_>|Ui_aRL*mKLE?{aEo%vZjFR zdAb+ACGnch|G?k8T#vF1BJ|$?GiPu-5x~f#MWIKM5z;yNo7=h_-7C`^CH#u;H%JW{ zT|f-|npIX{m7pH24|#HN@ea__WQ4zH3=ZU4W-speAOn7`2fO0fq)I987DsaAyo8Aw zMUd^vK_73(a%+i1^nuc1XtD0Q7eqrv806NY8=2cAoG?P-eevTXtO(iSAfXp;=i7+07@Z+Nn6pTazWXSf<;+KEF^ktLu2Ihhv-zfN^qA{@qOZP@=KUkC#KO;-G{TAVa{WkYL zWx_71kQU%T99$13)5ZsOTJ^1AEQ^bW6*XaTBtt*RKyu}J;?dFN_KQYE=c)6$_z2mi z!?U*}lUdJr8Zp*yJvecR+4~{Esg_*A$KX-Tr)Td4@~58BYp5}E!FD_D4$$}bdfuFl zN;cH5;AFrRD;IBS{y~F11zr+QLCtcY78^l*vfDgV;Et>0bvA)VDQ@;`sf=5zfZ zo=!5&1bw)8x5@!^b#J%BqNM}XG_5Pcx=q5tu~q@905{%%r__o&;E`q(;)+Sod>7G9 zfO1SA{#GJLRziQ6BIJA`;M@?oxlrCy%=K!ImL-EydZQp`Apgp`^&(8X%~>UuQ#MGt zFjH~ncjVQqK5^IT{TRkl6mQVZ&+{08U5^9BK6yyLs%a=_Kd7q7!f&s(8e@y{Xla9C z_g&Vi?92sSv0M>;2&`b1y(n6G$|c6`ieoe3UdL};$j4L9AgzC(spD$J%%}JmAJz{w z>XnFK9&rq~n$;&BaTC#2*Ro={p!Z3)qwb-k^#pnFu0V__#t2I4%35KapJSbDo=}=CTuV6h!gzd5Z` z6KH56aqjfa_yOIh6k@_PiSm0Ayd;^VIf=T&Vx^OA$u(&+hjykDv3Gzt6hwSLC4a4E z`iM)y{Fq3Aa3`&-rYxHyc3cq`2gyu3saT&Bh(Xc{Diw=O`ix7yI8ELRN`>rzD1;D5 z=9GV^v`dZDt05jN4F)t3Kg|)72Y6Twf>9{#T5viTltwEJ=44LcKTBC%hSl$bW=|7m zTvGueAA*$`--k=S*Oft9lT22V0#nSuj7_A2q!Awv5?Qe%a3MUeMY8@HLd_!8)B-V- zleKh^d4DZSa}6)rl}Z(#W-gt6;F|RXm6n&4WonV~(L8D0HDfC$!znjioXmquAH;bR zgm?f7VRkXGAjov#2XpWfIFg8XymUN%5SOCNoT|^9BV&;|C6#SglMyeSsfuAHXu1(# z7AaG!Ib1?{wl#=DspNaX2ZWrj*1DxIxGhTg)|Al_~TL@Zy1RWk=uPtE~ zMqq>xLNanE<{7Y>oO`d*uAe0(Nf*L2i{DA-oVg*F&T=`(FldL|OzYsJZ4~~?LDI@; zdVWpjid(kuSt7ukwt&lr`KIKlO0(q_ldo04B8q7(@}<_ZHq9%wyYYzJaw8dp;aW1A zP$iYHJ6ODAZcTLPRleJ8i;zhINpThQCI}eFA7(BAbCn>wt4PGFr-(w;7@2~hSJh$N z)pUr09%&2^+?vP2Twz02d?uKu|0)|Rog);E9qGM;zxwxvqBsCLf#h4*!3r2E)4qEe~+QK}KsojnK&w72s zH2@R;JZG{Id=pp`22wwXT^DZrB8(U&1HW9$e@k01)K$$`TyQO+21x;goK;P}uV%4I z)cwRzF-gZ+*I2O(;}vcomqmc>LBm2BbTKvI1248}32UFka)_2Kq*Kp_q?<##n?uQ) zFy#5b5;>g9r-bjy#eIR`n#-pStwy>RxZN%tK7=tgRe_s%)AC;Yfwa--KbIY9nFcqS zxJ!fxmEg~-u-ng%EnC3FfaXQJ&*##x_<|v65Cyv9&QvMcLg_VfIKwf)pQ1CYhJev( zWf&kZ-|xg)HH5$Hr2E_dd@~ygxMKa1S^0Py` zDk!?KK3(}21?93`O%xqztpAg5lTYX1iU=Keym<1m*Q$rt6w+^o*jb?+$wy3o?zw}E z#Bzn+PV0ek_m1WFTJnH4r+O&_yrIMakai#d^#KokrlrLk1Jqm~xd9L@7h*I2$)^pF z`%3S3cW>%rAkGXzI{g>(1CTkAP_7Xsec(=*q8xTao^k|x49F#o&~o1g8vx6p2o`OGg*M{-zHfhi zpM&|(wALt_^%#2?fW?k|6@gJB2GOE0TErmq7-$fHxoQtdvJG+>Kw&=Pq}*O`MC4H@ zn5qZH?=vn;31=hfL%veLg*OKzMKRuRU;sWhV7S@uFaf$g->+Z@s9voRxDZKfNEm() zTr$Mxitv36f+Hpql1Abw;l7)b@AJnr!h$?R2cNSA_`V(q)rWYp4PNlTXb@A*VXuSV zkSG@qnG8Htr0kcy1pKaM7i|P&03*7vW<`tHagG+s}`eF3HsiiW^=r-2_$YdKOs3rPY8D-$nVWAYpgpug ze7J`YCidJALX02uj9}oGeTzo```iFm0T?L}{3-wTBsF%72Gg{ZDi&*mpKv&xFWDTh zWSS(U>=zk-CACEYKYppdg}Auan<_SCY=a1GS_30rb-+d!Ho$Ra>nBNTV9_Xz!!q&=q&qzrR42~F5N*Y-ya2}~2TC-WF zQJiI~ekV{o_{C;@+Xiseez*I%UomMUFl>8vVNxkfkqr&(7rd=1SRazxgj#jHUvDvc z*}NOl2H9vgEvt^oBSdvKMNc-auD>Vy(8M#D-y(~Vo9RJtcRu0o)WYgpa4W7t4MHvh zd%Ki;l(}}n1|cK{Ar&hm_h$l`41j#vJ+>SU1$jiXDfsNNw?km>^rMv0*3O1$mElGk z4LO+RezTR;d-AFG#M>4qtCg|Cs|TZmP-p?q{Zo+Hx)XQEa6 z16d46tF8bn3ox^++#+jXXgVMh5tofDJwAWV+?_vJ+I%R(pSZsH?O;*n!KawF9&yxd z;e9XoA4!Z$KOc#&$xhgJZ6M%fo>PW?45``Ix(ZL!CVGsFG^Yn;TeUsS2o0L*5 z!dejesO?&9dq)Eh{;x&pv&19Ysu%LlkqM=k%i0#8P89RCgE8^w;Hx+JKnKG%EbDp` z%JO|nXsPI<0!CQ_f%TH0`yIRMu&lo3lYh&g#L{rB*0E<_;qN>N?ugPU#I5*lnj47G zuCCmTZiwRd@|VZ-jooDLzH<=2b8-IQQTV}U_v4yg+_cn-`LZ9#!5;#|ADU^kYs6DJ zOoCh5wi^OcQ`A9@Y#>XQ^)8-zR4=aZD>EW<2bVL@T?S42c^wIp_pZE%Pv#EICcdhp zIjr4UtyAg$Z8KQQv{qwt_=DFs$!IOtge+@N@L5ORiNkE&LRalM3crF%_niIxq@PZ) zHp5b((1tvv^c?9_R1wLH2Oec=h7?+2@@fO#Ws^V3UeGMm!u(0^XZWaz(6!4CY5ddI znP>D3DHl>Vd73va{PUVcqw(zN{otIX_@uX;ry0a^K?6L6P?D>C>h`PimUpz{LBFQX zE6r&(Me+JZ7nkihv%%^Wp6OK6W{6-?U3a}-PDX* zd0C2_r_-}ubytI=$v=qiKN4TJQLVoph)*&{!11PtpWcWc3J9x{f9HChyrWin@CX0| zpM!#-RGoupG#8f`DLJh;fEyg9pdd8XK~Mxt01QSbYKOxxBe_N{8ej$}3;o0ZP!!Eg zYk9}sepU%gc_MD#Wc10n*5tYEXr9Su(*~Q~r_c)%UA+kIJmJjMMegD<3U(zb(1BQD zQ^1$E|2xtIrA)8iY+)di;TtFuQ;yRN*Y#jA|L;iCSb@dQ{Up*b`6SWDZbmH{S=Z|^ zr}Sbj%g=54W7(}vh#q|csTbMy-k15j)gQ-c_a7Vg&&f1@BHDz`NG33qCu8JPV1O1LzCHODcA zJN{P+%5Y3jJ^F{HcraV@==Ccbw~hos_5nPl?@q=j#}7IO-digpS2pfuOf7(*aQsvj z3H3Tsj3g&Y+*eddMA5%_tu_cLAEMSC@YA~zbJJe~K~Mk|bp6MNQh9V+b*kd+yltaI z2=Ji9Ktj8em$Rk%v1wvbPxZWZ;9i><{#*l%jR62YeTr*2_S_VV2Me0|I{B(s zc0Q4uViUCamxpW4;ZTgEweM{Pz%qjNm;?Z5&6)7z5V$E9XxY<@i{h?M_5vun(0)tT zilIF+04fz=9Yv!irDe(#5@?0K1VDfr9Z+tECM{ldNEAGw!{Zd2R%uc!OiVxybpJ_I z$%+Op8o&;evCgm@`vs7A-{}K4Ni`$?So2-I#J=>QQcdt;b4mGMgolR)p| zg`X^FzYL7+Z(IOL$u|5X#nCSH1n=?jh%C_u7X4=d1M92Qx8ZLq+gXwATTM;xKUy-# zaQU~B^z4Rw(C5Z)2k}taQ~wKSwhk8v0oJhxulCX?{3Sm>wvk5A4g}4S)2==XEn2wo zE)?BfH4rfHNzd+&*RtICbn4SzBoOFO2lhOCRu2QC$&s5j8Ib@H8`movh>E{O z05I7=HyDA<2Nm1zGVcIN<`Gxi&S*y5D%@zIK`#ame)Dq*44{W}Z_XLH@*(~sjEK~0RAa-@Ze867x8%bKB0Y>a_Up)xs*9t(ruBj{NM?{<5*?`G)_Dj}&Nb-HV=gtc}|(ft9~Y?h4L`C*hV zUkR&Ca0AU)dh~O!A}~XW_12uv!FXhR;9$}m0`!w|r;jQGrg$gyUJ)Cz+y(cP#!$jW zny)cfw>=I82KDD}ZsDVo%HAZIj_Lk0NF09s{^nk8yX+IMIL7K-4rXR7`DT3#6I~Hl zZ|W|r$SpLb4G=wXl=U#0m3uk6j{+q$3EPk1JWQz{^9}EiiCva zKmDAn-3N3fomkUni?z4U_LH<3`b=;7L{ibeyWq;{BWoJG_buunnAtPsCb!GxVCPQN*)?kng;T*qoiq;DzviiE(_c2=}N@c%@ zDHK?(Rcp!Y`Sh@%mc_Zv&@dT)a!Z0e-c2=sEm+6ic#oVxUV*3S#o*k-TN~B}SppkN zC(9jV7pw#64E0Y2b+}B*xF-PdMg_5sHtC;hl~N~GpG+l- zfd_Usp|IXrdV!Ves#iN@xzhVhhxQ5kwcEen)?3R`yc=cfxE+|KA7(>`8)-LHS{Ygy zfH~8yuF6U{JPUk$6lvWH8G2W_7ufst{41EPUIr4lI?S1{R;(7V(+x6@1C8OTu3xJU zP)!=8akq@m&(|0d!!~0$$9z+^%DQ44>u9)GZAPPG)11UqhA|Gr61>2l4~m6i7UQ)! zdynrq373rrP_x@Q{%vxK5Y3)SWtjN>f^uJ&u=d(T%|CCc$N6r@+7+kFzhG&ZgkdpP z<#GFMu5>Ow8CJ4c;RD62Pn50^tI|p#9IfOIzgb>^w9sOz zSV8v1UB4=1K1jZ;cCW`{t)Lh(E3e4#V*AL&45v6tnnC{T?-7r7#5%v!V*8+Dg(r>M zhVyxQ+bnyzKLsBYvvG~Comu6pOAG6|f=pmcbdwj}cxUrwX&@O9r(b!c5T_tA!uuPy zc>I;E)(=TDdO#&#JjXw6IMe>=Is2Q&=V(p+jWMu%n~Fb{uIDYC3$ncm@9o^NxpyNj z__dR~{}m~HUvfNn#{I4E$HUE!3a}{zVdG&sAM=5_`uY6{OyZ6_(x2~4CuFfgKE1xg zvnzavDd=0k=JKu6cBW=#E_(6C8Up*_Us^^h-sLKLGD#o#3*))4{Z{{yP6ygVRNQo5 zU>;|k5#~Lk%Z+yXp>TI&`VZeGVj1M=er$c0h|BAl+|bC2skIIb-HiUs(cHb^X?41I z@j{55H8*uGPTl>u|?qrhW|YX_(pU-C42gGRe1mLEPr1j zYVJ{R)Vl|PoG5nR^0TS_24VwSt*CP2Ilk97IbkkB>OuR3oDa4X;Etn}+U8;RL06ab z|I5+X2B1=zs@YdCQ4N5XUB5qNzcG!DZ5x0e_kXp*;N-S}q@ccrV+G(I@OuglXrF$HT>!j03_DO1;znXKu-s`;61!;bT>v;5(6|S{1eKZuBk1s0 z?rp5uPi57|!?YSB3Y$@AdKC<58rJmn0K?I+0zDjpP=Qn|3XLDD?@xTMYpj57}5TKkpvBTWiox0 zr+f;Qi)!aq@S7@Qag_10qC?i!Qmq+PFv{o)+`Xh( zlme12hdt=Wy&REuq6g`=0q$Q3+&Fcz%c*}Ycq#~_k8POQ5l;id7YA4=vL$ETcvQ zk0|&D-G~CEnu4g>Ew)JF`EXVuprD$R#v?v)uGO#MHuYd+boY{6^|9`s#3_f@I$Ufs z?XCC^M`}^@=?|iHsdFOjjYs_J$yXm^Y4K4z_tc}pR054gl8mQMDdXXbxP93HGSPUt za;o!P4DRm~jZGKLzsy+>{~Wl2M(Fk&q~bbj3w&=F7ZZ_NgX~MeiKJqMpriWj@wpjf zO^4}yv#f=+A8?!>^l#B+C9lDkU-eNsy^#au|0d6(lO=TLzG)|s571BKGR9QzqkCVF|YXwStal2J9ocg2{J4eP?MuqlOK-Ejd@kBu$NPVG&p|5LTiQV zUdyb`eR;h?EJ886Qu@Y#OkYaw+)wxnVS)zq1?Rp+?pZ-bEFVbPHqFT-1F@U=J~sgy z%$sR0l5H^UMiM%CTjT`|O41r0GUORxf|-I836$^Y+x<|%YfOx^eU8sx{zzklOjC;W zOyQdbVDzB_U-O+L^M526#kl66LCiJ_v7}v7FA2OkdH)_)9K@3hzX3bC0cMj!E*>?9k1l8jz)nORaAe4j$cZp2_#vF1s7SU z(tgb!O%@#z#YfSJDe-QG+f%RE$0U-{OfSuo^ffK+ zDN9)^6aP(ECnRsYxjhl&s5}%lCTgx&pG8u?hb(kNGKzw>L=`1OfgjeHwKlK8nd&x| zO0U|;5yqL9xT${`*yj2XRJ{7FGi|S*V_@UW#31EAF>BPtlY0yp-PH=TXOcD=t@DSD z?BTsB##43dPm8CU{a`7ej_Pd<@1XTjgv4t*+MS6QpDk1C@n70A)QB_^sR%bwyQJjx zA0#R?zrgIx=mrh?51wNjIgv6)_DZb^NRENfB=pHHoP!OFV^8Bh8fNgdIj+M?W@^5N zs(k6)N(8no9xGs$E8ZP=4&pBrp|;!Qbp2l1+b_k`5>bkYQdsg}K%GtVOV(he4ZMI) zg~;*U_SIs75)vnRtUC02NaokTi+c)H^yzYf5&w&%^NyzakN^1RE_cx7+I-Qq3Ly#E zU3>2llCF_mNmj_c_RQWH*WPeVmoV_&|^K zTi*E9B6|xV#%{O1o?iF)AJaOlg6(c!lNbfaPip5o2pq|nA4%5(wPN+0s>tKg&nQnL zlCrE*!Evv^)rs6uN#o;)mpMHQQQf=KPUknDQT!fhTE2~9ZlC8~N-7Yxk3oNDOog2F z8~g=q8aZ!2P9qmEAs)WK3C>0Tb$+9Oa5>L(5t+lB0g2hW2ERi$UKmnPN;(e+e`VKCL))VbJkv(#s^EW#BPlI|WI?fRgue9%=SI?Jz1Z0!k~k!{}PG4@BNvKv3L zf=4%--(Y@!ylmH#exNn8(VM=5R7C0!hp%O9D3hL67`7a{U9#PpO;K3WPP4mIZ?|HY zy`1e?@$w)#>tK1hJKdf9M*i7I?f90-z1R7oeT0`rJ!SM;44q2zM|MJax!cn|3FI@g zXTf|+cjrFn*Kw(t2Z~(o8eyH;rA?u8JzuJg%Ty8;&6UG1WOqR1o1{37Ml%jpGWUc` z+%{4iK;o6?M8Q;Ke%vLG=h6TA?5De8_o#0RK2e)nd${KJE?`IAr6&Uj_z)Y#a>Uc| z@HgSVRvl)}?2L(|%%Ai|zsvim!|tA2Y!uF|q`ut&Ro{L%F*hcrdRp~4kq%Rkrr#{T zoSg0vOWDn?$0^LZym!d!w4Ip!#;b(?9s~JtksnYfF!jz zI_HvRsTcW`p&NR9^0%ej$4qmS?Bl!TQj`en&ArmU>Hj;we}hluro(_^GoqJij8=p8 zwF1M{rEVF){xMZNpgK0-1pvBzA_-3CYO5d7QOdUu`;v;)R`^tv`1(G5i^<{xE;}SG zdFk8otxkFa{AGh&biMN91D^74rH97kIl6His;3hJ44A_vNhC11cHgv5*WCcD+f9Hc zJ{2-(qW!kQX}iklFnR$)5|^K3UVr*%dl{%~KIQ=>ij7_?)fedk22z7Tt^C@5dc9^LHQrR13 zwpLf+_Og@crcWeNlhp5fjXd@pOe0(wylE4K#*LLiX_!^}pr3Ks)={_w?}(gz9*;4< z$)S>BU$JF<8ZN5`4%#)aU+F~c^NPS^j-VDP4WMu7INfCYw(eqcn1%S^@NMV_?fBOh z2B6S>Rqwxj^l?eUUUpX(?lu?q``_vY8=hPZa}ucThR_^HOl55e(JB9e3mLU=}z^ro1dU|hC5RHa+)2Wm@JAck|JNuqNzGEf3IPMf*Mj3$b!JX z%=yEK$ba)0{}#&reSGzAap?JZZeKcbSRr}+U+k}cODt#I^HY~am{!=*I&Z$Szn8z_ z_FinO;ctB2*Bd*_uYxM@Za?0OMFev+I9}UOuXegXUCCtBs;A$sV54lw-uV0~V~6(q z&6~4dLuaFCR)0%*hSTcsXC^DIuk(M@vcnJnRtTU=1jta_g-xLzuLNL2i9B4o(i!It zAf5}}+0QF)b7XyEws~>nuuv>;$qX>U!EQ5MA^t*QzM4=2LO>(n3??cv zt|Mqb)=NMchlCz3Zybw^@_2lIe%HOU3};&mWFpk`8p1-Fctf4; zqMtV`Z6<71_8?!KW8X}Oq?jm2ATUwSRDJCY)=3@qI{}6vHCLH8K*rz@2F{0*L1W9o z57VnbM`*$CzJpx!T$-5z-$C$X&ycN%E$pcp8o_1lGRj zT=euxd)uPtGDsW>ZhZnrz-zood~mqD2Va<_YdimW#=UQ=De9?AUTK-SNWQcHsE7oU zlG}6s1pp&?%%t?xQR*te!p$sVR>|h0*Bv;+Jzwic8Sx>dryN3Ay%2<^p)(w9;cWlZ z0?ZTqki+ZO%P0nKvNeh5aj}(9Q?(Mnaw^|4N-hx9*wkKDFjU4Q$Wp$lc56*3d6(;R z_k_J_tmKQ*vWrUMpbz`-@g8mQenkPNqEKbA0tX8pRyCq{6?qY)$1g^8=+8jh%TJ$) zzTT+pcyx!=l0s31cuPL+K!u1shMm|n<6+EGFZbS@Q3WP-ZViU5t1TVvyV#6)!i9aV zlGX2~y}oqA$gR7DVqL;t^?5lp8UKJSN1yy6%%)X#yzVG=By;S5ZqMPGht zF)r;1eJ}E0wH3i{sUM;GA+(fDkHZ@Ef$ihR3HB#ZTWV_3hJa5%q7Y~V@ymi6%c~NN zi{aX^#-kMl;p-f3`)qWV9*w~F6=Ex*v>SL%U3AD(DAZROZaBPWlGt;4^q5nrfb zH-QD2k4Iz>f^mZ5gZ!QH;_*@cOKU~#wtrV6>epsf2uu~`zWmw&sfH`j)F!_~um&-* z@3;iXT%1pu zC7`JId+jAb@$_1RhydJIFvG(Smik=qT%CVDXd{JoUqI;z`;LC)5%3PY3n~Om1V9V^ zQ=xGLJzam|)Th5!B9iuVh!$6Zv45`|0PWSAL0y2?WEIpXL`lnK8d^tSn z1NrK-FllE&MIos+x+M~&gqT9@z#Z2VC!70?2?6yW#_EV zih4&>X5{^Nicq@l%97yHUm(&jb4gJ}=zUVMSCsfR?!I=$J1HasK~Vo`<#g^rbD82l zLBmj?nF9Kja&-kE3B8ZhPBw7qa zX9rU~NA#lRsCC0Y?rW7A@l5aBNBF7~C+?}^(>c)fO}QTbsx;YfK+i?&lc9%9EyAJjni)b^&4aVZXC%lFGEyEA6yqoSI)4GEtUXfO)$bgYdAo^-vK;dDp|n8**zE zk@E|^^wrO95J6WO=a*b%pdu6*a6l2L-MI^9V)d~jXG z!%EwD6T=yy7b36}YuzlD#4WJTyPPJ1kJ%UIPuh|UK@m*f4|{`aCK6hYAFe$H1U`e$ zeEB1*TF+qPq6fihUEdba!3Im8liB%KCR|s|S#(m1W#`LdVf3D_quC}x7zJdaFrEcM zKS2F=xCoD!CLI1U+&RJdei3XE@BYKI{ip?R4nS7GFCJE#HDpvursKk4(s|*UYPF5Q ztaO$ZKT8T#+F`P;yqx%b=+6%vQ0rUT%*Je39ddzt?nDTCzb1EqLxGE40%Uq1DlPH* z8=ZHeW-D8X;c>nt^4l;_%?S0JK1+LMLAuzG5y6-#^J$AGGXTmLNiC=lcKf*?1%2^J z#lC0sLC>a7eJqox8FhGlc!iNA^6M^>8B?#0C3RNtVS#nzTF0&+dO#unShcjG;n3_ zBmz#-(1E$}zD=CMR(Ss8SlmQS%~fSM=%wT<;{J6kLf%ZwKfwo0_r)_gT*JO9%YC|{ zS)@Is`iF3t)YZgqYB}?L;TvoZS`M08r_IH&zcnBDDtSCp3qMf4rzQ8g=c6WvUrD0g zYPdkk3n~?JB5p_5^a}hQt!&N9_3X$<7B)if9u-@z;BR;TfA-QJcLu>YXSN;sB92(W z3^0rFZtvB)B|oC6-J)s+M5R8NL=xByRmR9&_96224R$|HqTjSH${EeoY+v^{pYTMt zWpZ}eH*&q4F!DdQib#X{ru>tM_=KXTl%1&ZbIZ&HEgPL0!NFu1n@4?wNb=uzUL78oJ z>@0AF1Qx|Yq(~4&EL5EY)yKlj2_%>eR{ifKsz(<_Z@jLAI=C$o9*;$);|(O#4JGj zOmo_A6iD@e2C9r#gVAx@4G#hR(RP~ZFfsLDM4J2pgO)t*esDibm4JLv5Nmxn$S!q9 zgLr2w3K|b+H7`JY)<%82hJ2xhSX-UnGRsuT1ycq@Q39vPx5H8 zgjTAW*1Xu;Qx8qTAH)h~4$mzGL;MQ1!t$u?V=*?iR=@yMY{0OD=U;U~DgHxhy>1&G z-NMSHfn)nOqa8l4&a4|=Vdyt0H+6=p9w*x+bgvHLrPh zmdP-j#*%3xLEj_x6_L6sY&41k_yK%nV22{`0|XivLFtfM85)Jd6QN|fjNm%nC!o%BSkIH?)suOKwp28c&47CFWrc{W2WZ++@I^D9p zv)Ua(M2RZvESFJ?_L+6>4?gS*{Tvrl!G-tH7K@V}i%bg9{i>CIcsK zJky>Q$ynD7#$NgyaEZaUCy`1a-)Cw57&9nKc%4OM8`=BA;JLSBt?H#YzaDBWy6du$ zI4)B^#8q9&RIKzTVY_@M^Q#X*TG1XaEA=pujO4-LINoQG%%5O$cx3xFjmeuAqk)m- zfKGTZ@XRq&hnY&ym>Fy~T&`yXPl~M(Scn)gp!LXdeLm!6eJPx%{qC7H&dFWm} z-E{Rdaz|5xqK%r#BV&dZ^cWK6B7jf*taG*s56;Vz-kZmc)22I9hx6P|7X44dL8&u$ zGQ~5sEiM24ZvPc7!Hl>0v|YAZ0`L%Tf?!s9MOL0*c4DYnikEK$a|SDN-U{Z< zRpfpa%==c6r%$~2#}N9eJa7B+Hw6orH?8Be)P(^IyS=Id_A_hI7Z26DVnU>kGgR(C zSRAq7(Wd6ZaDZ_*s9cRF0_$Ww0A7WYV(^GTYV?(GSeu5SE*YTN)Va5b#|sh4zPajr z!e7=@e~>z8<+o|^CR*7&Tzzolfnmg*P9Zztfjg#9<7J$>YLAv`aC1<9iyicWUH9s> zaia~);u{GSuU;Z+XI2eU0zxkT8Uxed?wX#fzm9Re%UYo8;;P|XSz}k(1@3Ki?Qhkt zYW{W&>j$ViRMqJBx9+;Vx`>ouT3~hvBFa4ChB=i}>SpyfttzQa3p=R8VNd0$5;(_I z7bDyo1Rde2?gfXGe%xw3-XhyU-?;XVptlix)uZCVZ>ca&d{j!L@b*eIe&#BRaX7yf zG(Q5SoOn%@4q8(?GwxW`?bu(!Ro%T(SxU9t>Q5ZA>mSz_Y5D9vO88biKX<8JYDGyn zEQAsIk!Jxs5YNR<$If+3z4mKF?RvcEMtW@q1@|SpcC%J= ztF?CPjp+7Z?e<&I9l~7g&S%lxZ?(I-qI<`+duO8iRCW7#{Z)#*X5^~j(hI;>7{M|4 zr%l*T(Y=EpPIcA2LswP$93tt#eM99vBynTkXUmpm`G%D1!CUr=W8bI_?8U zWvO~94$jxp8y5Bpn)@_0KW4b+zslZ))&mVOUjphgxMVn?z-OYnGuo9?S zkN7W`;Sfv+LH>$*1UxxADyO-*ZM%&(p$z3ARG&L<-;Xw64AmSf24->7Xr@SdxHiZ2 zspGl#|K)K$o2Q~0GPFVN?{sE4vw0%PSKlN(VT-+f=WnD1(fwi+)lN9f%=7?#-4Jo| zLrSSa_Cat0(BOW0IezszXAaR?S7V1eR$(E61=>is(8;38$@j+9Wp0ci*^HkdtzwJ! zd2c-mSkhp>2Cxij1(IBUGY@z2rFmk1sB`GR1uWs?gT3x4e=5mv@>R)&X#-UDL*R+z z^b-whUAVxLv3+$W)1I^T^F0V`RQhySKKJGzDFn8$2%PWXdA@Kql=1TZF2+N=0q|^HFDg<$mpC*+APEu9u+z^pTvZ!+NuBlGOJ%!d|qkOfnhMvb>yzxxGfw{Kf*|A_d~#m@;kaDWxpSPjPan~!g) zn5(1h_+FM%^d9()gq+tp>Aa!{)%Ms4*>7#R^qoBIJDFRla=k2yf93a*M~$kI5bhwM zZYAnxmRk8qT{S*dXxK*FFwsr#)6s{x747R3$G7)alt5WT&rESr#WW1}| z$9QhV`1VI)+4iJopuqi2jb-t%6{cv;V)w<-7itnfT!Nl8lRUcdp zQ!^X@U%T0}Pi>^zs+Qx*r`TG1t3&U%(q+TV=f6NLb$I8cp)dFVb=)6SL)S-ga&3_v zZ3%Mi86EBUa<9ueUe}$=b-e26=$7jolH2ePrdz*xX!H1*?-LFAx|y6QWa0rq?83un zkpd-zA#_qW-NMe5ek3Gv{~G<%mU4sH4f=<_k;@lg&HpJa&N4)O86`q@>$_(h{>NoV z=dTF~-L= zL%*nwKy+aRAz!S-Z>r{PLu~A~b9$tSlFdVOF7o0b-gKQFiw12b5pjKdDGeZJKpo#He3d)++Glp@Gu-f34WAc4i%lZO`%znHmGonAPG7{B+e6&F}exE5=Nq*LlK&ZcsvY5w9yB| zKv%}HBtanTFL7HKzzp!S0-9H%g0TRr9zq!)LM8rKOpVN(JD62b2WT2LG-yOJLIV)G z?;wV>GAcmHuWXoMBm+_3i&emP>uv~VGTmP*=IuL;kO0=edGG63ZkJ^+RrAr~3+FlK z=yN~maKhIxK{}h+&a9_Slle-K%nI3FPN@c0aT7={@Z_8*=ywAae@s?{FdoiiHTB{^ z45u`@m15EppTq*LB5bl{`n%C0x3-_~z!td(204CQ7JqP?kXNO8Pk7{&7(%8UY4aWa z{tR|zGConR4-42uLRcd1UcMbP5~?Q(rG7vc=IbN^~q9vB~-?I8ZP6$&*(4SWsyYJ3YP#EL5uzGt^V z82g&(K0OhS2sC*svr#?$R<6bvUy>FA?&+JcF=iM@t2IT0mFef;CSc|Vjr;}BTs*OW zUi;Fd&W#DvX+4>pnQ4fEL%B34ggTA1rpuIuy=|<}GOMUBFF9x6QZqYu_sN9W9a)p5 z*$?-FFPYEVMvKhN+b8LnFFeS4IJe-GkyH)F5;Ep4scR00`Xbqv@6pcGeE1yjW485P zgjCcyOX8)|os%yF*xL`3P>eq7{PWAbwpb#lrvM)=SUbN0;e6P*63uO+x$+$H#$q)| z>ujHwPTYo4`a+1#q+-sml4OW8=kQ&L(Q^45cbslhL+vB|ImGwa>c>FMb2>q02H-{+NH~)*5k#L^v&}NVrzw=( zcv!QRkzE<6nwh*?Q#nW6dlvLoG4`Dy_#ZUB(oak zCeu3R@~tB9Pc*pTTTXn03tBS^HLbZ|1975>F(0O_x0vVMxBKek~f0u{7DD$}y^#k#HPc-r8 zA2T)8Hc<)=BeA|0!@}o&8luU9d=_GC8C>IKkX@}P@1;=Qs6$P3IAh6+Rib9KDZQ8{ zHd(oooX_@8fV-*souFI0=DpoU38DJf%M