From 9254627c8b4438778b859c0c45c9f1389272ec0a Mon Sep 17 00:00:00 2001 From: Dennis Decoene Date: Thu, 9 Jul 2026 18:34:11 +0200 Subject: [PATCH] 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', () => {