/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import { describe, it, beforeEach } from 'node:test'; import assert from 'node:assert'; import { createStore, type StoreApi } from 'zustand/vanilla'; import { createSearchSlice, type SearchSlice } from './searchSlice.js'; import type { SearchResult } from '@/lib/search/tier0-scan'; function makeResult(modelId: string, expressId: number, name: string): SearchResult { return { modelId, expressId, typeName: 'IfcWall', name, globalId: `g${String(expressId).padStart(21, 'x')}`, description: '', objectType: '', matchField: 'name', score: 100, }; } describe('searchSlice — vim cycle', () => { let store: StoreApi; const rs = (name: string, id: number): SearchResult => makeResult('m', id, name); beforeEach(() => { store = createStore((set, get, api) => createSearchSlice(set, get, api)); }); it('is inactive by default', () => { assert.strictEqual(store.getState().searchVimCycle, null); }); it('enters with a frozen results snapshot and clamped index', () => { const results = [rs('a', 1), rs('b', 2), rs('c', 3)]; store.getState().enterVimCycle('wall', results, 1); const cycle = store.getState().searchVimCycle; assert.ok(cycle); assert.strictEqual(cycle!.query, 'wall'); assert.strictEqual(cycle!.index, 1); assert.strictEqual(cycle!.results, results, 'snapshot is the same reference'); }); it('clamps entry index into range', () => { const results = [rs('a', 1), rs('b', 2)]; store.getState().enterVimCycle('w', results, 99); assert.strictEqual(store.getState().searchVimCycle!.index, 1); store.getState().enterVimCycle('w', results, -5); assert.strictEqual(store.getState().searchVimCycle!.index, 0); }); it('no-ops when called with an empty results list', () => { store.getState().enterVimCycle('w', [], 0); assert.strictEqual(store.getState().searchVimCycle, null); }); it('step +1 advances; step -1 retreats; both wrap around', () => { const results = [rs('a', 1), rs('b', 2), rs('c', 3)]; store.getState().enterVimCycle('w', results, 0); store.getState().stepVimCycle(1); assert.strictEqual(store.getState().searchVimCycle!.index, 1); store.getState().stepVimCycle(1); assert.strictEqual(store.getState().searchVimCycle!.index, 2); store.getState().stepVimCycle(1); assert.strictEqual(store.getState().searchVimCycle!.index, 0, 'wraps forward'); store.getState().stepVimCycle(-1); assert.strictEqual(store.getState().searchVimCycle!.index, 2, 'wraps backward'); }); it('step creates a new cycle object (for React change detection)', () => { const results = [rs('a', 1), rs('b', 2)]; store.getState().enterVimCycle('w', results, 0); const first = store.getState().searchVimCycle; store.getState().stepVimCycle(1); const second = store.getState().searchVimCycle; assert.notStrictEqual(first, second, 'new object reference per step'); assert.strictEqual(second!.results, first!.results, 'results snapshot is stable'); }); it('step is a no-op when inactive', () => { store.getState().stepVimCycle(1); assert.strictEqual(store.getState().searchVimCycle, null); }); it('exits cleanly', () => { const results = [rs('a', 1), rs('b', 2)]; store.getState().enterVimCycle('w', results, 0); store.getState().exitVimCycle(); assert.strictEqual(store.getState().searchVimCycle, null); }); it('typing breaks the cycle (setSearchQuery clears vimCycle)', () => { const results = [rs('a', 1), rs('b', 2)]; store.getState().enterVimCycle('w', results, 0); store.getState().setSearchQuery('door'); assert.strictEqual(store.getState().searchVimCycle, null); }); it('resetSearch clears the cycle', () => { const results = [rs('a', 1), rs('b', 2)]; store.getState().enterVimCycle('w', results, 0); store.getState().resetSearch(); assert.strictEqual(store.getState().searchVimCycle, null); }); it('closeSearch preserves the cycle (user can hit n/N after popover closes)', () => { const results = [rs('a', 1), rs('b', 2)]; store.getState().enterVimCycle('w', results, 0); store.getState().closeSearch(); assert.ok(store.getState().searchVimCycle, 'cycle still active after popover close'); }); }); describe('searchSlice — advanced modal state', () => { let store: StoreApi; beforeEach(() => { store = createStore((set, get, api) => createSearchSlice(set, get, api)); }); it('modal is closed by default with "all" field filter and null model filter', () => { const s = store.getState(); assert.strictEqual(s.searchModalOpen, false); assert.strictEqual(s.searchFieldFilter, 'all'); assert.strictEqual(s.searchModelFilter, null); }); it('setSearchModalOpen + toggleSearchModal flip the open flag', () => { store.getState().setSearchModalOpen(true); assert.strictEqual(store.getState().searchModalOpen, true); store.getState().toggleSearchModal(); assert.strictEqual(store.getState().searchModalOpen, false); store.getState().toggleSearchModal(); assert.strictEqual(store.getState().searchModalOpen, true); }); it('setSearchFieldFilter updates the chip selection', () => { store.getState().setSearchFieldFilter('name'); assert.strictEqual(store.getState().searchFieldFilter, 'name'); store.getState().setSearchFieldFilter('all'); assert.strictEqual(store.getState().searchFieldFilter, 'all'); }); it('toggleSearchModelFilter materialises the include set on first toggle', () => { const available = ['m1', 'm2', 'm3']; store.getState().toggleSearchModelFilter('m2', available); const filter = store.getState().searchModelFilter; assert.ok(filter); assert.deepStrictEqual(Array.from(filter!).sort(), ['m1', 'm3']); }); it('toggleSearchModelFilter re-including the last excluded model collapses back to null', () => { const available = ['m1', 'm2']; store.getState().toggleSearchModelFilter('m1', available); // now filter is {m2} store.getState().toggleSearchModelFilter('m1', available); // user re-included m1 → all available included → collapse to null assert.strictEqual(store.getState().searchModelFilter, null); }); it('toggleSearchModelFilter successive toggles on different models', () => { const available = ['a', 'b', 'c']; store.getState().toggleSearchModelFilter('a', available); store.getState().toggleSearchModelFilter('b', available); const filter = store.getState().searchModelFilter; assert.ok(filter); assert.deepStrictEqual(Array.from(filter!).sort(), ['c']); }); it('clearSearchModelFilter resets to null', () => { const available = ['a', 'b', 'c']; store.getState().toggleSearchModelFilter('a', available); store.getState().clearSearchModelFilter(); assert.strictEqual(store.getState().searchModelFilter, null); }); }); describe('searchSlice — filter rule actions', () => { let store: StoreApi; beforeEach(() => { store = createStore((set, get, api) => createSearchSlice(set, get, api)); }); it('starts with the empty filter state', () => { const s = store.getState(); assert.deepStrictEqual(s.searchFilter.rules, []); assert.strictEqual(s.searchFilter.combinator, 'AND'); assert.strictEqual(s.searchFilter.limit, 500); assert.strictEqual(s.searchFilterSchema.size, 0); assert.strictEqual(s.searchFilterResult, null); assert.strictEqual(s.searchFilterRunning, false); assert.strictEqual(s.searchFilterError, null); }); it('setFilterCombinator / setFilterLimit patch the filter state', () => { store.getState().setFilterCombinator('OR'); assert.strictEqual(store.getState().searchFilter.combinator, 'OR'); store.getState().setFilterLimit(100); assert.strictEqual(store.getState().searchFilter.limit, 100); }); it('addFilterRule appends a rule', () => { const r = { kind: 'ifcType' as const, values: ['IfcWall'], op: 'in' as const }; store.getState().addFilterRule(r); const rules = store.getState().searchFilter.rules; assert.strictEqual(rules.length, 1); assert.deepStrictEqual(rules[0], r); }); it('updateFilterRule replaces a rule at the given index', () => { const r1 = { kind: 'ifcType' as const, values: ['IfcWall'], op: 'in' as const }; const r2 = { kind: 'ifcType' as const, values: ['IfcDoor'], op: 'in' as const }; store.getState().addFilterRule(r1); store.getState().updateFilterRule(0, r2); assert.deepStrictEqual(store.getState().searchFilter.rules[0], r2); }); it('updateFilterRule is a no-op for out-of-range indices', () => { const before = store.getState().searchFilter; store.getState().updateFilterRule(5, { kind: 'ifcType' as const, values: [], op: 'in' as const, }); assert.strictEqual(store.getState().searchFilter, before); }); it('removeFilterRule drops the rule at the given index', () => { const r1 = { kind: 'ifcType' as const, values: ['IfcWall'], op: 'in' as const }; const r2 = { kind: 'name' as const, op: 'contains' as const, value: 'EXT' }; store.getState().addFilterRule(r1); store.getState().addFilterRule(r2); store.getState().removeFilterRule(0); const rules = store.getState().searchFilter.rules; assert.strictEqual(rules.length, 1); assert.strictEqual(rules[0].kind, 'name'); }); it('removeFilterRule is a no-op for out-of-range indices', () => { const before = store.getState().searchFilter; store.getState().removeFilterRule(2); assert.strictEqual(store.getState().searchFilter, before); }); it('clearFilterRules empties rules but preserves combinator + limit', () => { store.getState().setFilterCombinator('OR'); store.getState().setFilterLimit(123); store.getState().addFilterRule({ kind: 'ifcType' as const, values: ['IfcWall'], op: 'in' as const, }); store.getState().clearFilterRules(); const f = store.getState().searchFilter; assert.deepStrictEqual(f.rules, []); assert.strictEqual(f.combinator, 'OR'); assert.strictEqual(f.limit, 123); }); it('setSearchFilter replaces the whole filter state', () => { const next = { rules: [{ kind: 'name' as const, op: 'eq' as const, value: 'X' }], combinator: 'OR' as const, limit: 42, }; store.getState().setSearchFilter(next); assert.strictEqual(store.getState().searchFilter, next); }); }); describe('searchSlice — Filter run result/error pairing', () => { let store: StoreApi; beforeEach(() => { store = createStore((set, get, api) => createSearchSlice(set, get, api)); }); it('setSearchFilterResult clears the prior error (success supersedes failure)', () => { store.getState().setSearchFilterError('boom'); store.getState().setSearchFilterResult({ columns: ['a'], rows: [[1]], runMs: 5 }); assert.strictEqual(store.getState().searchFilterError, null); assert.deepStrictEqual(store.getState().searchFilterResult?.columns, ['a']); }); it('setSearchFilterError preserves the prior result (notebook semantics)', () => { store.getState().setSearchFilterResult({ columns: ['a'], rows: [[1]], runMs: 5 }); store.getState().setSearchFilterError('boom'); // Error and result coexist — UI stacks the error above the last good // result. Without this, debugging a failed run loses the previous // table the user was reading. assert.strictEqual(store.getState().searchFilterError, 'boom'); assert.deepStrictEqual(store.getState().searchFilterResult?.columns, ['a']); }); it('callers can wipe both via explicit nulls', () => { store.getState().setSearchFilterResult({ columns: ['a'], rows: [], runMs: 1 }); store.getState().setSearchFilterError('x'); store.getState().setSearchFilterResult(null); store.getState().setSearchFilterError(null); assert.strictEqual(store.getState().searchFilterError, null); assert.strictEqual(store.getState().searchFilterResult, null); }); }); describe('searchSlice — schema cache', () => { let store: StoreApi; beforeEach(() => { store = createStore((set, get, api) => createSearchSlice(set, get, api)); }); it('setFilterSchema inserts a basic entry with null psetQto', () => { store.getState().setFilterSchema('m1', { storeys: [['L1', 0]], ifcTypes: ['IfcWall'] }); const entry = store.getState().searchFilterSchema.get('m1'); assert.ok(entry); assert.deepStrictEqual(entry!.basic.ifcTypes, ['IfcWall']); assert.strictEqual(entry!.psetQto, null); }); it('setFilterSchema preserves existing psetQto when re-setting basic', () => { store.getState().setFilterSchema('m1', { storeys: [], ifcTypes: ['IfcWall'] }); store.getState().setFilterPsetQtoSchema('m1', { psets: [['Pset_X', ['P']]], qtos: [] }); store.getState().setFilterSchema('m1', { storeys: [], ifcTypes: ['IfcWall', 'IfcDoor'] }); const entry = store.getState().searchFilterSchema.get('m1'); assert.ok(entry?.psetQto); assert.deepStrictEqual(entry!.psetQto!.psets, [['Pset_X', ['P']]]); }); it('setFilterPsetQtoSchema is a no-op without a prior basic entry', () => { store.getState().setFilterPsetQtoSchema('mX', { psets: [], qtos: [] }); assert.strictEqual(store.getState().searchFilterSchema.has('mX'), false); }); it('removeFilterSchema drops the model entry', () => { store.getState().setFilterSchema('m1', { storeys: [], ifcTypes: [] }); store.getState().removeFilterSchema('m1'); assert.strictEqual(store.getState().searchFilterSchema.has('m1'), false); }); });