diff --git a/crates/foxvm/src/data.rs b/crates/foxvm/src/data.rs index 6857829..366d5dc 100644 --- a/crates/foxvm/src/data.rs +++ b/crates/foxvm/src/data.rs @@ -25,11 +25,12 @@ pub const PAGE_RECORDS: u32 = 64; pub struct DataSession { areas: Vec>, current: usize, + next_generation: u64, } impl DataSession { pub fn new() -> DataSession { - DataSession { areas: Vec::new(), current: 0 } + DataSession { areas: Vec::new(), current: 0, next_generation: 0 } } /// The selected work area, 1-based, as `SELECT()` reports it. @@ -94,9 +95,11 @@ impl DataSession { /// Puts a freshly opened cursor in the selected area, replacing whatever was there. /// Returns the handle of the table that was closed, if any, so the caller can release it. - pub fn install(&mut self, cursor: Cursor) -> Option { + pub fn install(&mut self, mut cursor: Cursor) -> Option { let index = self.current; self.ensure(index); + self.next_generation += 1; + cursor.generation = self.next_generation; self.areas[index].replace(cursor).and_then(|old| old.handle()) } @@ -154,6 +157,8 @@ pub enum Source { /// One open table. #[derive(Debug)] pub struct Cursor { + /// Identity of this installation, to reject UI events for a replaced alias. + generation: u64, source: Source, /// The name this table answers to: the file stem, or whatever `USE ... ALIAS` said. pub alias: String, @@ -239,6 +244,8 @@ pub struct Cursor { } impl Cursor { + pub fn generation(&self) -> u64 { self.generation } + pub fn new(handle: u32, alias: String, path: String, header: DbfHeader) -> Cursor { Cursor::over(Source::Host { handle }, alias, path, header) } @@ -281,6 +288,7 @@ impl Cursor { let count = header.record_count; // VFP opens a table at record 1, which for an empty one is already end of file Cursor { + generation: 0, source, alias, path, diff --git a/crates/foxvm/src/vm.rs b/crates/foxvm/src/vm.rs index 833073c..55aadd1 100644 --- a/crates/foxvm/src/vm.rs +++ b/crates/foxvm/src/vm.rs @@ -1615,6 +1615,57 @@ impl Vm { &self.data } + /// A bounded, read-only preview for runtime grids. Never moves a work-area pointer. + pub fn grid_preview(&self, alias: &str) -> serde_json::Value { + use serde_json::json; + let Some(cursor) = self.data.find(&crate::data::AreaRef::Alias(alias.into())) else { + return json!({"error": "RecordSource alias is not open"}); + }; + if cursor.handle().is_some() || cursor.buffering() != 1 || cursor.ordered() + || !cursor.filter().is_empty() || cursor.key_limit().is_some() || !cursor.relations().is_empty() { + return json!({"error": "Grid preview supports unfiltered, unbuffered in-memory cursors without indexes or relations"}); + } + if cursor.header.fields.len() > 128 { + return json!({"error": "Grid preview supports at most 128 fields"}); + } + let columns: Vec<_> = cursor.header.fields.iter().map(|f| f.name.clone()).collect(); + let mut rows = Vec::new(); + let mut records = Vec::new(); + let mut truncated = false; + for (index, record) in cursor.all_rows().iter().enumerate() { + if record.deleted && self.settings.deleted { continue; } + if rows.len() == 200 { truncated = true; break; } + let cells: Vec<_> = record.values.iter().map(|v| { + let text = crate::value::display(&value_of_dbf(Some(v)), &self.settings); + let mut chars = text.chars(); + let mut bounded: String = chars.by_ref().take(512).collect(); + if chars.next().is_some() { bounded.push('…'); truncated = true; } + bounded + }).collect(); + records.push(index + 1); + rows.push(cells); + } + json!({"columns": columns, "rows": rows, "records": records, + "currentRecord": cursor.recno(), "generation": cursor.generation().to_string(), "truncated": truncated}) + } + + /// Select a physical record that is currently offered by the bounded grid view. + /// The generation rejects clicks queued before an alias was closed/recreated. + pub fn grid_select(&mut self, alias: &str, recno: u32, generation: &str) -> Result<(), String> { + let preview = self.grid_preview(alias); + if let Some(error) = preview["error"].as_str() { return Err(error.into()); } + if preview["generation"].as_str() != Some(generation) { + return Err("Grid source changed; select the row again".into()); + } + if !preview["records"].as_array().is_some_and(|records| records.iter().any(|r| r.as_u64() == Some(recno.into()))) { + return Err("Grid record is no longer visible".into()); + } + let area = crate::data::AreaRef::Alias(alias.into()); + self.data.select(&area).map_err(|_| "Grid source is no longer open".to_string())?; + self.data.cursor_mut().unwrap().seek(recno.into()); + Ok(()) + } + pub fn settings(&self) -> &Settings { &self.settings } diff --git a/crates/foxvm/src/wasm.rs b/crates/foxvm/src/wasm.rs index bad7598..9686510 100644 --- a/crates/foxvm/src/wasm.rs +++ b/crates/foxvm/src/wasm.rs @@ -860,6 +860,15 @@ impl FoxVm { to_js(&JsonValue::from_value(&self.vm.get_global(name).unwrap_or(crate::value::Value::Null))) } + /// A read-only grid preview; no host IO or record movement. + pub fn grid_preview(&self, alias: &str) -> JsValue { + to_js(&self.vm.grid_preview(alias)) + } + + pub fn grid_select(&mut self, alias: &str, recno: u32, generation: &str) -> Result<(), JsValue> { + self.vm.grid_select(alias, recno, generation).map_err(|error| JsValue::from_str(&error)) + } + /// `exact`, `decimals`, `century`, `date`, `talk`, `safety`, `escape`. pub fn set_setting(&mut self, name: &str, value: JsValue) { let s = self.vm.settings_mut(); diff --git a/crates/foxvm/tests/grid_preview.rs b/crates/foxvm/tests/grid_preview.rs new file mode 100644 index 0000000..e093b8a --- /dev/null +++ b/crates/foxvm/tests/grid_preview.rs @@ -0,0 +1,50 @@ +use foxvm::mock_host::{MockHost, run_program}; +use foxvm::data::AreaRef; + +fn run(source: &str) -> foxvm::vm::Vm { + run_program(source, &mut MockHost::new()).unwrap().0 +} + +#[test] +fn preview_preserves_alias_pointer_and_unicode() { + let vm = run("CREATE CURSOR choices (title C(30))\nINSERT INTO choices VALUES ('Ąžuolas')\nINSERT INTO choices VALUES ('Birch')\nGO TOP\nCREATE CURSOR other (id I)"); + let before = vm.data().current_area(); + let result = vm.grid_preview("CHOICES"); + assert!(result["error"].is_null(), "{result}"); + assert_eq!(result["columns"][0], "TITLE"); + assert!(result["rows"][0][0].as_str().unwrap().starts_with("Ąžuolas")); + assert_eq!(result["rows"].as_array().unwrap().len(), 2); + assert_eq!(vm.data().current_area(), before); + assert_eq!(vm.data().find(&AreaRef::Alias("choices".into())).unwrap().recno(), 1); +} + +#[test] +fn closed_and_filtered_aliases_report_limits() { + let vm = run("CREATE CURSOR choices (id I)\nINSERT INTO choices VALUES (1)\nSET FILTER TO id=1"); + assert!(vm.grid_preview("choices")["error"].is_string()); + assert!(vm.grid_preview("missing")["error"].is_string()); +} + +#[test] +fn preview_is_bounded_and_respects_deleted_setting() { + let vm = run("CREATE CURSOR choices (id I)\nFOR n=1 TO 205\nINSERT INTO choices VALUES (n)\nENDFOR\nGO TOP\nDELETE\nSET DELETED ON"); + let result = vm.grid_preview("choices"); + assert_eq!(result["rows"].as_array().unwrap().len(), 200); + assert_eq!(result["truncated"], true); + assert_eq!(result["rows"][0][0].as_str().unwrap().trim(), "2"); +} + +#[test] +fn selection_uses_physical_records_and_rejects_invalid_requests() { + let mut vm = run("CREATE CURSOR choices (id I)\nINSERT INTO choices VALUES (10)\nINSERT INTO choices VALUES (20)\nGO TOP\nDELETE\nSET DELETED ON\nCREATE CURSOR other (id I)"); + let preview = vm.grid_preview("choices"); + let generation = preview["generation"].as_str().unwrap(); + assert_eq!(preview["records"][0], 2); + assert!(vm.grid_select("choices", 1, generation).is_err()); + assert!(vm.grid_select("choices", 2, "stale").is_err()); + assert!(vm.grid_select("choices", 999, generation).is_err()); + vm.grid_select("choices", 2, generation).unwrap(); + assert_eq!(vm.grid_preview("choices")["currentRecord"], 2); + assert_eq!(vm.data().cursor().unwrap().recno(), 2); + assert!(vm.grid_select("missing", 2, generation).is_err()); +} diff --git a/docs/grid-cursor-preview.md b/docs/grid-cursor-preview.md new file mode 100644 index 0000000..626a844 --- /dev/null +++ b/docs/grid-cursor-preview.md @@ -0,0 +1,42 @@ +# Runtime grid cursor preview + +The runtime grid previously drew headers and blank cells without reading +RecordSource. RecordSourceType 1 now reads a bounded snapshot of an open, +unbuffered in-memory cursor and displays its fields. No selected work area, +record pointer or database file is changed by a preview. + +An empty Column.ControlSource uses the field at that column's ordinal; a simple +field name or matching alias.field maps explicitly, case-insensitively. Other +expressions show an unsupported-field indicator. Existing columns and captions +are preserved; automatic ColumnCount=-1 expansion is not implemented here. + +Snapshots refresh after runtime state changes and desktop revisions. Closing an +alias removes its old values and reports that the alias is not open. Rendering +uses text nodes, not HTML, and does not execute ControlSource expressions. + +Clicking a displayed cell or pressing Up/Down/Home/End selects its physical +record in the VM and selects the source work area. Thus form actions read the +chosen record. Highlighting follows the VM pointer, including programmatic +movement. Selection is limited to the displayed snapshot; cells remain read-only. + +Navigation dispatches BeforeRowColChange with the old column, then the current +control's Valid, selects the target record, dispatches the target control's When, +and finally AfterRowColChange with the new column. NODEFAULT in Before cancels; +Valid returning false cancels. When returning false restores the previous visible +record: this is a conservative fallback, not VFP's full alternate-focus search. +RowColChange uses 1=row, 2=column, 3=both. Keyboard KeyPress can suppress default +navigation. Mouse selection also dispatches Grid.Click; keyboard navigation does +not synthesize a click. Full child-control focus/click/double-click behavior, +cell editing and Dynamic* expressions remain separate work. + +Every cursor installation gets a generation token. Stale requests after closing +and recreating an alias are rejected, as are disabled/rebound grids and concurrent +navigation on the same grid. Events that move the cursor themselves cancel the +pending default movement. No host file writes are performed by navigation. + +Host-backed tables, buffering, SET FILTER, controlling indexes, key ranges and +relations report a limitation instead of showing an unfiltered or misleading +snapshot. SET DELETED ON omits deleted records for supported cursors. Preview +limits are 200 visible rows, 128 fields and 512 characters per cell; shortening is +reported. There is no pagination in this first slice. Private data sessions and +native Windows VFP comparisons are not validated. diff --git a/src/renderer/src/runtime/controls/index.tsx b/src/renderer/src/runtime/controls/index.tsx index 2ec6724..fc6deec 100644 --- a/src/renderer/src/runtime/controls/index.tsx +++ b/src/renderer/src/runtime/controls/index.tsx @@ -20,6 +20,8 @@ import { colorCss, designSurfaces, fontStyle } from '../../designer/surfaces'; import { pictureProblem, pictureUrl } from '../pictureUrl'; import { useSessionStore } from '../session'; import { ImageList, TreeView } from '@shared/runtime/oleObjects'; +import { selectGridCell } from '../gridNavigation'; +import type { GridPreview } from '../vmBridge'; import { OleTreeView } from './OleTreeView'; export interface RuntimeProps { @@ -543,10 +545,53 @@ const RGrid: FC = ({ obj, props: r }) => { const ev = useEvents(obj); const bind = useBind(obj); const columns = obj.children; - const rows = Math.max(1, Math.floor((Number(r['Height']) - Number(r['HeaderHeight'] ?? 17) - 2) / Number(r['RowHeight'] ?? 17))); + const vm = useSessionStore((s) => s.vm); + const status = useSessionStore((s) => s.status); + const revision = useSessionStore((s) => s.revision); + const alias = String(r['RecordSource'] ?? ''); + const sourceType = Number(r['RecordSourceType'] ?? 1); + const preview = useMemo(() => { + // Session revisions invalidate a preview even when an event starts and finishes + // between React renders and leaves the scheduler in the same waiting state. + void revision; + if (status === 'running' || !alias || !vm) return null; + if (sourceType !== 1) return {error: 'Grid preview requires RecordSourceType 1 (alias)'}; + try { return vm.gridPreview(alias); } + catch { return {error: 'Grid preview could not read the cursor'}; } + }, [vm, status, revision, alias, sourceType]); + const data = preview && !('error' in preview) ? preview : null; + const [selectionError, setSelectionError] = useState(''); + const select = async (record: number, column: number, click = true) => { + if (!data) return; + try { + if (await selectGridCell(obj, record, column, data.generation)) { + setSelectionError(''); + if (click) await obj.desktop.dispatch(obj, 'Click', []); + } + } catch (error) { setSelectionError(String(error)); } + }; + const keyDown = async (e: ReactKeyboardEvent) => { + if (!data || !['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(e.key)) { ev.common.onKeyDown(e); return; } + e.preventDefault(); + const key = e.key; + const outcome = await obj.desktop.dispatch(obj, 'KeyPress', [{ArrowUp: 5, ArrowDown: 24, Home: 1, End: 6}[key]!, shiftAltCtrl(e)]); + if (outcome?.nodefault) return; + const at = data.records.indexOf(data.currentRecord); + const index = key === 'Home' ? 0 : key === 'End' ? data.records.length - 1 + : Math.max(0, Math.min(data.records.length - 1, at + (key === 'ArrowDown' ? 1 : -1))); + const record = data.records[index]; + if (record !== undefined) await select(record, Math.max(1, Number(obj.get('ActiveColumn')) || 1), false); + }; + const positions = columns.map((col, i) => { + const source = String(col.get('ControlSource') ?? '').trim(); + if (!source) return i; + const parts = source.split('.'); + if (parts.length > 2 || (parts.length === 2 && parts[0]?.toLowerCase() !== alias.toLowerCase())) return -1; + return data?.columns.findIndex((name) => name.toLowerCase() === parts.at(-1)?.toLowerCase()) ?? -1; + }); return ( -
- +
{ void keyDown(e).catch(error => setSelectionError(String(error))); }}> +
{columns.map((col) => { @@ -560,15 +605,20 @@ const RGrid: FC = ({ obj, props: r }) => { - {Array.from({ length: rows }).map((_, i) => ( - - {columns.map((col) => ( - + {data?.rows.map((row, i) => ( + + {columns.map((col, j) => ( + { const record = data.records[i]; if (record !== undefined) void select(record, j + 1); }}>{positions[j]! < 0 ? '(unsupported field)' : row[positions[j]!] ?? ''} ))} ))}
+ {selectionError &&
{selectionError}
} + {preview && 'error' in preview &&
{preview.error}
} + {data &&
Select a row with a click or the arrow keys. Cell editing is not available.{data.truncated ? ' Preview shortened (200 rows / 512 characters per cell).' : ''}
} +
); }; diff --git a/src/renderer/src/runtime/gridNavigation.ts b/src/renderer/src/runtime/gridNavigation.ts new file mode 100644 index 0000000..82bb353 --- /dev/null +++ b/src/renderer/src/runtime/gridNavigation.ts @@ -0,0 +1,79 @@ +/** Navigation for the bounded, in-memory Grid view. No database writes. */ +import type { RuntimeObject } from '@shared/runtime/objectModel'; +import { useSessionStore } from './session'; + +const pending = new WeakSet(); + +function enabled(obj: RuntimeObject): boolean { + for (let current: RuntimeObject | null = obj; current; current = current.parent) { + if (!current.alive || current.get('Enabled') === false || current.get('Visible') === false) return false; + } + return true; +} + +function control(grid: RuntimeObject, column: number): RuntimeObject | undefined { + const col = grid.children[column - 1]; + return col?.child(String(col.get('CurrentControl') || 'Text1')); +} + +/** Record numbers are physical DBF positions, not indexes in the displayed array. */ +export async function selectGridCell(grid: RuntimeObject, record: number, column: number, generation: string): Promise { + const state = useSessionStore.getState(); + const vm = state.vm; + if (!vm || state.scheduler?.isExecuting || pending.has(grid) || !enabled(grid)) return false; + const alias = String(grid.get('RecordSource') ?? ''); + if (Number(grid.get('RecordSourceType')) !== 1 || column < 1 || column > grid.children.length) return false; + const before = vm.gridPreview(alias); + if ('error' in before) throw new Error(before.error); + if (before.generation !== generation || !before.records.includes(record)) return false; + const oldColumn = Math.max(1, Number(grid.get('ActiveColumn')) || 1); + const change = (before.currentRecord !== record ? 1 : 0) | (oldColumn !== column ? 2 : 0); + if (!change) return true; + const stillBound = () => enabled(grid) && useSessionStore.getState().vm === vm + && String(grid.get('RecordSource')) === alias && Number(grid.get('RecordSourceType')) === 1; + const unchanged = () => { + if (!stillBound()) return false; + const current = vm.gridPreview(alias); + return !('error' in current) && current.generation === generation && current.currentRecord === before.currentRecord; + }; + pending.add(grid); + try { + grid.set('RowColChange', change); + const entering = control(grid, column); + const leaving = control(grid, oldColumn); + const outcome = await grid.desktop.dispatch(grid, 'BeforeRowColChange', [oldColumn]); + if (outcome?.nodefault || !unchanged()) return false; + if (leaving) { + const valid = await grid.desktop.dispatch(leaving, 'Valid', []); + if (valid?.value === false || !unchanged()) return false; + } + vm.gridSelect(alias, record, generation); + if (entering) { + const when = await grid.desktop.dispatch(entering, 'When', []); + if (!stillBound()) return false; + const current = vm.gridPreview(alias); + if ('error' in current || current.generation !== generation || current.currentRecord !== record) return false; + if (when?.value === false) { + // This view has no alternate editable cell to focus. Restore the prior + // visible record on rejection rather than displaying a false selection. + if (current.records.includes(before.currentRecord)) vm.gridSelect(alias, before.currentRecord, generation); + return false; + } + } + if (!stillBound()) return false; + const selected = vm.gridPreview(alias); + if ('error' in selected || selected.generation !== generation || selected.currentRecord !== record) return false; + grid.set('ActiveRow', selected.records.indexOf(record) + 1); + grid.set('ActiveColumn', column); + grid.setFocus(); + if (!stillBound()) return false; + const focused = vm.gridPreview(alias); + if ('error' in focused || focused.generation !== generation || focused.currentRecord !== record) return false; + await grid.desktop.dispatch(grid, 'AfterRowColChange', [column]); + return true; + } finally { + pending.delete(grid); + // Direct VM movement has no scheduler transition; notify all bound grids. + useSessionStore.setState(s => ({revision: s.revision + 1})); + } +} diff --git a/src/renderer/src/runtime/session.ts b/src/renderer/src/runtime/session.ts index 66603dc..8d6fbc7 100644 --- a/src/renderer/src/runtime/session.ts +++ b/src/renderer/src/runtime/session.ts @@ -445,7 +445,7 @@ export const useSessionStore = create((set, get) => { set({ status: 'error', errorReport: { error, stack, resolve: (action) => { set({ errorReport: null }); resolve(action); } } }); print({ kind: 'error', text: `Error ${error.code} in ${error.program} line ${error.line}: ${error.message}` }); }), - onStateChange: (status) => set({ status }), + onStateChange: (status) => set((s) => ({ status, revision: s.revision + 1 })), onQuit: () => get().cancel(), // a program stopped at a breakpoint is a parked fiber, so the IDE carries on around it onBreak: (stop) => { diff --git a/src/renderer/src/runtime/vmBridge.ts b/src/renderer/src/runtime/vmBridge.ts index 6b04e0b..7e38cd3 100644 --- a/src/renderer/src/runtime/vmBridge.ts +++ b/src/renderer/src/runtime/vmBridge.ts @@ -13,6 +13,8 @@ import type { VmValue } from '@shared/runtime/values'; import { headerStem } from '@shared/runtime/programSource'; import { loadFoxVm, loadFoxVmSync, type FoxVmModule } from '../../../wasm/foxvm/loader'; +export type GridPreview = {error: string} | {columns: string[]; rows: string[][]; records: number[]; currentRecord: number; generation: string; truncated: boolean}; + export interface MethodSourceInput { objectPath: string; event: string; @@ -111,6 +113,13 @@ class WasmVm implements VmLike { getGlobal(name: string): VmValue { return this.guard('getGlobal', () => this.vm.get_global(name) as VmValue); } + gridPreview(alias: string): GridPreview { + return this.guard('gridPreview', () => this.vm.grid_preview(alias) as GridPreview); + } + gridSelect(alias: string, record: number, generation: string): void { + if (!Number.isInteger(record) || record < 1 || record > 0xffffffff) throw new Error('Invalid grid record'); + this.guard('gridSelect', () => this.vm.grid_select(alias, record, generation)); + } /** The text of an XML document from its bytes, decoded as its declaration says. */ xmlText(bytes: string): string { return this.guard('xmlText', () => this.vm.xml_text(bytes)); diff --git a/tests/runtime/gridCursor.test.tsx b/tests/runtime/gridCursor.test.tsx new file mode 100644 index 0000000..d0b7327 --- /dev/null +++ b/tests/runtime/gridCursor.test.tsx @@ -0,0 +1,118 @@ +import { act, screen } from '@testing-library/react'; +import { afterEach, expect, it, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { Desktop } from '@shared/runtime/objectModel'; +import { selectGridCell } from '@renderer/runtime/gridNavigation'; +import { RuntimeControl } from '@renderer/runtime/RuntimeControl'; +import { useSessionStore } from '@renderer/runtime/session'; +import type { WasmVm } from '@renderer/runtime/vmBridge'; +import { renderWithProviders } from '../helpers/render'; + +const grid = () => new Desktop().instantiate({name: 'Form1', props: {Visible: true}, methods: {}, children: [{ + id: 'grid', type: 'Grid', name: 'Choices', props: {RecordSource: 'choices', RecordSourceType: 1, Height: 180}, methods: {}, + children: [{id: 'col', type: 'Column', name: 'Column1', props: {}, methods: {}, children: [ + {id: 'head', type: 'Header', name: 'Header1', props: {Caption: 'Name'}, methods: {}}, + {id: 'text', type: 'TextBox', name: 'Text1', props: {}, methods: {}}, + ]}], +}]}, -1).children[0]!; + +afterEach(() => useSessionStore.setState({vm: null})); + +it('shows cursor values instead of empty placeholder cells', async () => { + const vm = {gridPreview: () => ({columns: ['NAME'], rows: [['Oak'], ['Birch']], records: [1, 2], currentRecord: 1, generation: '1', truncated: false})} as unknown as WasmVm; + useSessionStore.setState({vm, status: 'waiting'}); + renderWithProviders(); + expect(await screen.findByText('Oak')).toBeInTheDocument(); + expect(screen.getByText('Birch')).toBeInTheDocument(); +}); + +it('refreshes the preview after a runtime revision and clears closed sources', async () => { + let value = 'Oak'; + const vm = {gridPreview: () => value ? {columns: ['NAME'], rows: [[value]], records: [1], currentRecord: 1, generation: '1', truncated: false} : {error: 'RecordSource alias is not open'}} as unknown as WasmVm; + useSessionStore.setState({vm, status: 'waiting'}); + renderWithProviders(); + await screen.findByText('Oak'); + value = 'Maple'; + await act(() => useSessionStore.setState(s => ({revision: s.revision + 1}))); + expect(await screen.findByText('Maple')).toBeInTheDocument(); + expect(screen.queryByText('Oak')).toBeNull(); + value = ''; + await act(() => useSessionStore.setState(s => ({revision: s.revision + 1}))); + expect(await screen.findByText('RecordSource alias is not open')).toBeInTheDocument(); + expect(screen.queryByText('Maple')).toBeNull(); +}); + +it('maps explicit fields and reports expressions instead of showing the wrong field', async () => { + const vm = {gridPreview: () => ({columns: ['NAME', 'CODE'], rows: [['Oak', 'A1']], records: [1], currentRecord: 1, generation: '1', truncated: false})} as unknown as WasmVm; + useSessionStore.setState({vm, status: 'waiting'}); + const obj = grid(); + obj.children[0]!.set('ControlSource', 'choices.code'); + renderWithProviders(); + expect(await screen.findByText('A1')).toBeInTheDocument(); + expect(screen.queryByText('Oak')).toBeNull(); + await act(() => { obj.children[0]!.set('ControlSource', 'UPPER(name)'); useSessionStore.setState(s => ({revision: s.revision + 1})); }); + expect(await screen.findByText('(unsupported field)')).toBeInTheDocument(); +}); + +it('moves the cursor when a displayed record is clicked', async () => { + let currentRecord = 1; + const select = vi.fn((_alias: string, record: number) => { currentRecord = record; }); + const vm = {gridPreview: () => ({columns: ['NAME'], rows: [['Oak'], ['Birch']], records: [1, 2], currentRecord, generation: '1', truncated: false}), gridSelect: select} as unknown as WasmVm; + useSessionStore.setState({vm, status: 'waiting', scheduler: null}); + const obj = grid(); + renderWithProviders(); + await userEvent.click(await screen.findByText('Birch')); + expect(select).toHaveBeenCalledWith('choices', 2, '1'); + expect(currentRecord).toBe(2); + expect(obj.get('ActiveRow')).toBe(2); +}); + +for (const scenario of ['nodefault', 'recreated', 'disabled'] as const) { + it(`rejects navigation when BeforeRowColChange leaves the source ${scenario}`, async () => { + let generation = '1'; + const select = vi.fn(); + const vm = {gridPreview: () => ({columns: ['NAME'], rows: [['Oak'], ['Birch']], records: [1, 2], currentRecord: 1, generation, truncated: false}), gridSelect: select} as unknown as WasmVm; + useSessionStore.setState({vm, status: 'waiting', scheduler: null}); + const obj = grid(); + vi.spyOn(obj.desktop, 'dispatch').mockImplementation(async (_obj, event) => { + if (event === 'BeforeRowColChange') { + if (scenario === 'recreated') generation = '2'; + if (scenario === 'disabled') obj.set('Enabled', false); + return {value: null, nodefault: scenario === 'nodefault'}; + } + return {value: null, nodefault: false}; + }); + expect(await selectGridCell(obj, 2, 1, '1')).toBe(false); + expect(select).not.toHaveBeenCalled(); + }); +} + +it('dispatches old and new column events around movement and supports keyboard selection', async () => { + let currentRecord = 1; + const order: string[] = []; + const vm = {gridPreview: () => ({columns: ['NAME'], rows: [['Oak'], ['Birch']], records: [1, 2], currentRecord, generation: '1', truncated: false}), gridSelect: (_alias: string, record: number) => {order.push('move'); currentRecord = record;}} as unknown as WasmVm; + useSessionStore.setState({vm, status: 'waiting', scheduler: null}); + const obj = grid(); + vi.spyOn(obj.desktop, 'dispatch').mockImplementation(async (_obj, event) => {order.push(event); return {value: null, nodefault: false};}); + renderWithProviders(); + await userEvent.click(await screen.findByText('Birch')); + expect(order.indexOf('BeforeRowColChange')).toBeLessThan(order.indexOf('move')); + expect(order.indexOf('AfterRowColChange')).toBeGreaterThan(order.indexOf('move')); + order.length = 0; + await userEvent.keyboard('{ArrowUp}'); + expect(currentRecord).toBe(1); + expect(order).not.toContain('Click'); +}); + +for (const rejected of ['Valid', 'When']) { + it(`keeps the prior record when ${rejected} rejects navigation`, async () => { + let currentRecord = 1; + const vm = {gridPreview: () => ({columns: ['NAME'], rows: [['Oak'], ['Birch']], records: [1, 2], currentRecord, generation: '1', truncated: false}), gridSelect: (_alias: string, record: number) => {currentRecord = record;}} as unknown as WasmVm; + useSessionStore.setState({vm, status: 'waiting', scheduler: null}); + const obj = grid(); + const dispatch = vi.spyOn(obj.desktop, 'dispatch').mockImplementation(async (_obj, event) => ({value: event === rejected ? false : null, nodefault: false})); + expect(await selectGridCell(obj, 2, 1, '1')).toBe(false); + expect(currentRecord).toBe(1); + expect(dispatch.mock.calls.some(call => call[1] === 'AfterRowColChange')).toBe(false); + }); +} diff --git a/tests/runtime/vmBridge.test.ts b/tests/runtime/vmBridge.test.ts index 5e92003..6124664 100644 --- a/tests/runtime/vmBridge.test.ts +++ b/tests/runtime/vmBridge.test.ts @@ -102,6 +102,38 @@ describe('FoxVM bridge', () => { expect(host.lines).toEqual(['sum: 42', 'DONE']); }); + it('reads a cursor preview across WASM without moving the current work area', () => { + const host = new StubHost(); + const {vm, scheduler} = makeSession(host); + const out = compileProgram("CREATE CURSOR choices (title C(20))\nINSERT INTO choices VALUES ('Oak')\nCREATE CURSOR other (id I)", 'grid'); + expect(compileError(out)).toBeNull(); + scheduler.runProgram(vm.loadModule(out.bytes!)); + const preview = vm.gridPreview('choices'); + expect('error' in preview).toBe(false); + if ('error' in preview) throw new Error(preview.error); + expect(preview.rows[0]?.[0]?.trim()).toBe('Oak'); + const check = compileProgram('? ALIAS()', 'check'); + scheduler.runProgram(vm.loadModule(check.bytes!)); + expect(host.lines.at(-1)?.toLowerCase()).toBe('other'); + }); + + it('selects physical rows and rejects a recreated alias across WASM', () => { + const host = new StubHost(); + const {vm, scheduler} = makeSession(host); + const run = (text: string) => scheduler.runProgram(vm.loadModule(compileProgram(text, 'select-test').bytes!)); + run("CREATE CURSOR choices (id I)\nINSERT INTO choices VALUES (10)\nINSERT INTO choices VALUES (20)\nGO TOP"); + const preview = vm.gridPreview('choices'); + if ('error' in preview) throw new Error(preview.error); + vm.gridSelect('choices', 2, preview.generation); + run('? id'); + expect(host.lines.at(-1)?.trim()).toBe('20'); + run('USE IN choices\nCREATE CURSOR choices (id I)\nINSERT INTO choices VALUES (30)'); + expect(() => vm.gridSelect('choices', 1, preview.generation)).toThrow(/source changed/i); + expect(() => vm.gridSelect('choices', -1, preview.generation)).toThrow(); + run('? id'); + expect(host.lines.at(-1)?.trim()).toBe('30'); + }); + it('reports compile errors instead of a module', () => { const out = compileProgram('IF x\n', 'broken'); expect(out.bytes).toBeNull();