@ifc-lite/viewer 1.17.6 → 1.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +20 -15
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +949 -0
- package/dist/assets/{basketViewActivator-86rgogji.js → basketViewActivator-RZy5c3Td.js} +1 -1
- package/dist/assets/decode-worker-Collf_X_.js +1320 -0
- package/dist/assets/{exporters-CcPS9MK5.js → exporters-BraHBeoi.js} +4194 -3025
- package/dist/assets/{geometry.worker-BFUYA08u.js → geometry.worker-DQEZB2rB.js} +1 -1
- package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
- package/dist/assets/index-0XpVr_S5.css +1 -0
- package/dist/assets/{index-Bfms9I4A.js → index-BOi3BuUI.js} +46423 -31181
- package/dist/assets/index-XwKzDuw6.js +22 -0
- package/dist/assets/{native-bridge-DUyLCMZS.js → native-bridge-CpBeOPQa.js} +1 -1
- package/dist/assets/sandbox-Baez7n-t.js +9682 -0
- package/dist/assets/{server-client-BuZK7OST.js → server-client-BB6cMAXE.js} +1 -1
- package/dist/assets/{wasm-bridge-JsqEGDV8.js → wasm-bridge-CAYCUHbE.js} +1 -1
- package/dist/index.html +6 -6
- package/package.json +11 -10
- package/src/apache-arrow.d.ts +30 -0
- package/src/components/viewer/AddElementPanel.tsx +758 -0
- package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
- package/src/components/viewer/ChatPanel.tsx +64 -2
- package/src/components/viewer/CommandPalette.tsx +56 -7
- package/src/components/viewer/EntityContextMenu.tsx +168 -4
- package/src/components/viewer/ExportChangesButton.tsx +25 -5
- package/src/components/viewer/ExportDialog.tsx +19 -1
- package/src/components/viewer/MainToolbar.tsx +73 -12
- package/src/components/viewer/PointCloudPanel.tsx +174 -0
- package/src/components/viewer/PropertiesPanel.tsx +222 -22
- package/src/components/viewer/SearchInline.tsx +669 -0
- package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
- package/src/components/viewer/SearchModal.filter.tsx +514 -0
- package/src/components/viewer/SearchModal.text.tsx +388 -0
- package/src/components/viewer/SearchModal.tsx +235 -0
- package/src/components/viewer/ToolOverlays.tsx +5 -0
- package/src/components/viewer/ViewerLayout.tsx +24 -4
- package/src/components/viewer/Viewport.tsx +29 -2
- package/src/components/viewer/ViewportContainer.tsx +45 -5
- package/src/components/viewer/ViewportOverlays.tsx +13 -2
- package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
- package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
- package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
- package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
- package/src/components/viewer/bcf/BCFTopicDetail.tsx +1 -1
- package/src/components/viewer/lists/ListPanel.tsx +14 -21
- package/src/components/viewer/properties/RawStepCard.tsx +332 -0
- package/src/components/viewer/properties/RawStepRow.tsx +261 -0
- package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
- package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
- package/src/components/viewer/properties/raw-step-format.ts +193 -0
- package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
- package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
- package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
- package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
- package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
- package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
- package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
- package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
- package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
- package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
- package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
- package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
- package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
- package/src/components/viewer/schedule/generate-schedule.ts +648 -0
- package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
- package/src/components/viewer/schedule/schedule-animator.ts +488 -0
- package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
- package/src/components/viewer/schedule/schedule-selection.ts +163 -0
- package/src/components/viewer/schedule/schedule-utils.ts +223 -0
- package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
- package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
- package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
- package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
- package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
- package/src/components/viewer/selectionHandlers.ts +446 -0
- package/src/components/viewer/tools/AddElementOverlay.tsx +581 -0
- package/src/components/viewer/useDuplicateShortcut.ts +77 -0
- package/src/components/viewer/useMouseControls.ts +9 -1
- package/src/components/viewer/usePointCloudLifecycle.ts +64 -0
- package/src/components/viewer/usePointCloudSync.ts +98 -0
- package/src/hooks/ingest/pointCloudIngest.ts +391 -0
- package/src/hooks/ingest/viewerModelIngest.ts +32 -3
- package/src/hooks/useIfcFederation.ts +72 -3
- package/src/hooks/useIfcLoader.ts +89 -13
- package/src/hooks/useKeyboardShortcuts.ts +25 -0
- package/src/hooks/useSandbox.ts +1 -1
- package/src/hooks/useSearchIndex.ts +125 -0
- package/src/index.css +66 -0
- package/src/lib/llm/system-prompt.test.ts +14 -0
- package/src/lib/llm/system-prompt.ts +102 -1
- package/src/lib/llm/types.ts +6 -0
- package/src/lib/recent-files.ts +38 -4
- package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
- package/src/lib/scripts/templates/construction-schedule.ts +223 -0
- package/src/lib/scripts/templates.ts +7 -0
- package/src/lib/search/common-ifc-types.ts +36 -0
- package/src/lib/search/filter-evaluate.test.ts +537 -0
- package/src/lib/search/filter-evaluate.ts +610 -0
- package/src/lib/search/filter-rules.test.ts +119 -0
- package/src/lib/search/filter-rules.ts +198 -0
- package/src/lib/search/filter-schema.test.ts +233 -0
- package/src/lib/search/filter-schema.ts +146 -0
- package/src/lib/search/recent-searches.test.ts +116 -0
- package/src/lib/search/recent-searches.ts +93 -0
- package/src/lib/search/result-export.test.ts +101 -0
- package/src/lib/search/result-export.ts +104 -0
- package/src/lib/search/saved-filters.test.ts +118 -0
- package/src/lib/search/saved-filters.ts +154 -0
- package/src/lib/search/tier0-scan.test.ts +196 -0
- package/src/lib/search/tier0-scan.ts +237 -0
- package/src/lib/search/tier1-index.test.ts +242 -0
- package/src/lib/search/tier1-index.ts +448 -0
- package/src/sdk/adapters/export-adapter.test.ts +434 -1
- package/src/sdk/adapters/export-adapter.ts +404 -1
- package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
- package/src/sdk/adapters/export-schedule-splice.ts +87 -0
- package/src/sdk/adapters/model-compat.ts +8 -2
- package/src/sdk/adapters/schedule-adapter.ts +73 -0
- package/src/sdk/adapters/store-adapter.ts +201 -0
- package/src/sdk/adapters/visibility-adapter.ts +3 -0
- package/src/sdk/local-backend.ts +16 -8
- package/src/services/desktop-export.ts +3 -1
- package/src/services/desktop-native-metadata.ts +41 -18
- package/src/services/file-dialog.ts +8 -3
- package/src/services/tauri-modules.d.ts +25 -0
- package/src/store/basketVisibleSet.ts +3 -0
- package/src/store/globalId.ts +4 -1
- package/src/store/index.ts +79 -1
- package/src/store/slices/addElementMeshes.ts +365 -0
- package/src/store/slices/addElementSlice.ts +275 -0
- package/src/store/slices/annotationsSlice.test.ts +133 -0
- package/src/store/slices/annotationsSlice.ts +251 -0
- package/src/store/slices/dataSlice.test.ts +23 -4
- package/src/store/slices/dataSlice.ts +1 -1
- package/src/store/slices/modelSlice.test.ts +67 -9
- package/src/store/slices/modelSlice.ts +39 -7
- package/src/store/slices/mutationSlice.ts +964 -3
- package/src/store/slices/overlayCompositor.test.ts +164 -0
- package/src/store/slices/overlaySlice.test.ts +93 -0
- package/src/store/slices/overlaySlice.ts +151 -0
- package/src/store/slices/pinboardSlice.test.ts +6 -1
- package/src/store/slices/playbackSlice.ts +128 -0
- package/src/store/slices/pointCloudSlice.ts +102 -0
- package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
- package/src/store/slices/schedule-edit-helpers.ts +179 -0
- package/src/store/slices/scheduleSlice.test.ts +694 -0
- package/src/store/slices/scheduleSlice.ts +1330 -0
- package/src/store/slices/searchSlice.test.ts +342 -0
- package/src/store/slices/searchSlice.ts +341 -0
- package/src/store/slices/selectionSlice.test.ts +46 -0
- package/src/store/slices/selectionSlice.ts +20 -0
- package/src/store/types.ts +7 -0
- package/src/store.ts +14 -0
- package/vite.config.ts +1 -0
- package/dist/assets/ifc-lite_bg-BINvzoCP.wasm +0 -0
- package/dist/assets/index-_bfZsDCC.css +0 -1
- package/dist/assets/sandbox-C8575tul.js +0 -5951
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Filter schema discovery.
|
|
7
|
+
*
|
|
8
|
+
* Mirrors the Rust `get_filter_schema` Tauri command — returns the set
|
|
9
|
+
* of distinct values available for each filter dimension in the active
|
|
10
|
+
* model so the chip UI can populate dropdowns instead of free-text
|
|
11
|
+
* inputs (the single largest UX gap in the existing visual builder).
|
|
12
|
+
*
|
|
13
|
+
* Cheap parts (storeys, ifcTypes) read straight from already-built
|
|
14
|
+
* indexes. Pset / Qto schema requires touching on-demand extractors,
|
|
15
|
+
* so it is split out as `discoverPropertyAndQuantitySchema` — the
|
|
16
|
+
* caller decides when to pay that cost (e.g. behind a "Show all
|
|
17
|
+
* properties" expander rather than on every modal open).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
extractPropertiesOnDemand,
|
|
22
|
+
extractQuantitiesOnDemand,
|
|
23
|
+
type IfcDataStore,
|
|
24
|
+
} from '@ifc-lite/parser';
|
|
25
|
+
|
|
26
|
+
export interface FilterSchema {
|
|
27
|
+
/** [storeyName, elevationMeters | null] sorted by name. */
|
|
28
|
+
storeys: Array<[string, number | null]>;
|
|
29
|
+
/** Distinct IFC type names actually present (e.g. "IfcWall"). Sorted. */
|
|
30
|
+
ifcTypes: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface PsetQtoSchema {
|
|
34
|
+
/** [setName, [propertyName, ...]] sorted. */
|
|
35
|
+
psets: Array<[string, string[]]>;
|
|
36
|
+
/** [setName, [[quantityName, unit], ...]] sorted. unit is "" when unknown. */
|
|
37
|
+
qtos: Array<[string, Array<[string, string]>]>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Cheap pass — uses already-materialised indexes. Safe to call on every
|
|
42
|
+
* modal open / chip-edit.
|
|
43
|
+
*/
|
|
44
|
+
export function discoverFilterSchema(store: IfcDataStore): FilterSchema {
|
|
45
|
+
return {
|
|
46
|
+
storeys: collectStoreys(store),
|
|
47
|
+
ifcTypes: collectIfcTypes(store),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function collectStoreys(store: IfcDataStore): Array<[string, number | null]> {
|
|
52
|
+
const hierarchy = store.spatialHierarchy;
|
|
53
|
+
if (!hierarchy) return [];
|
|
54
|
+
const out: Array<[string, number | null]> = [];
|
|
55
|
+
// Iterate byStorey keys (== storey expressIds). Keep one entry per
|
|
56
|
+
// unique storey *name* — duplicates would confuse the chip dropdown
|
|
57
|
+
// even though the underlying storey IDs differ.
|
|
58
|
+
const seen = new Map<string, number | null>();
|
|
59
|
+
for (const storeyId of hierarchy.byStorey.keys()) {
|
|
60
|
+
const name = store.entities.getName(storeyId);
|
|
61
|
+
if (!name) continue;
|
|
62
|
+
if (seen.has(name)) continue;
|
|
63
|
+
const elevation = hierarchy.storeyElevations.get(storeyId) ?? null;
|
|
64
|
+
seen.set(name, elevation);
|
|
65
|
+
}
|
|
66
|
+
for (const [name, elev] of seen) out.push([name, elev]);
|
|
67
|
+
out.sort((a, b) => a[0].localeCompare(b[0]));
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function collectIfcTypes(store: IfcDataStore): string[] {
|
|
72
|
+
const types = new Set<string>();
|
|
73
|
+
// entityIndex.byType maps the UPPERCASE STEP type name to expressIds.
|
|
74
|
+
// Resolve the canonical (PascalCase) form per-entity via getTypeName
|
|
75
|
+
// so the chip dropdown shows "IfcWall" rather than "IFCWALL".
|
|
76
|
+
for (const ids of store.entityIndex.byType.values()) {
|
|
77
|
+
if (ids.length === 0) continue;
|
|
78
|
+
const sample = ids[0];
|
|
79
|
+
const canonical = store.entities.getTypeName(sample);
|
|
80
|
+
if (canonical) types.add(canonical);
|
|
81
|
+
}
|
|
82
|
+
const out = Array.from(types);
|
|
83
|
+
out.sort();
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Expensive pass — walks every entity that has an on-demand pset/qto
|
|
89
|
+
* map entry and extracts the set/property names. Run once per model
|
|
90
|
+
* lifetime (cache the result in the slice). For a 100K-entity model
|
|
91
|
+
* this is still ~milliseconds because we read the map keys, not values.
|
|
92
|
+
*
|
|
93
|
+
* For the value-extraction path (turning each property into a chip
|
|
94
|
+
* value dropdown) the caller should sample a bounded subset of
|
|
95
|
+
* entities — full enumeration is O(entities × props) and would defeat
|
|
96
|
+
* the on-demand laziness.
|
|
97
|
+
*/
|
|
98
|
+
export function discoverPropertyAndQuantitySchema(store: IfcDataStore): PsetQtoSchema {
|
|
99
|
+
const psetMap = new Map<string, Set<string>>();
|
|
100
|
+
const qtoMap = new Map<string, Map<string, string>>();
|
|
101
|
+
|
|
102
|
+
// Properties — iterate the on-demand map's element keys (already
|
|
103
|
+
// narrowed to entities that declare any pset). For each, extract
|
|
104
|
+
// names only; values are intentionally not collected here.
|
|
105
|
+
if (store.onDemandPropertyMap) {
|
|
106
|
+
for (const entityId of store.onDemandPropertyMap.keys()) {
|
|
107
|
+
const sets = extractPropertiesOnDemand(store, entityId);
|
|
108
|
+
for (const set of sets) {
|
|
109
|
+
let bucket = psetMap.get(set.name);
|
|
110
|
+
if (!bucket) { bucket = new Set(); psetMap.set(set.name, bucket); }
|
|
111
|
+
for (const p of set.properties) bucket.add(p.name);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (store.onDemandQuantityMap) {
|
|
117
|
+
for (const entityId of store.onDemandQuantityMap.keys()) {
|
|
118
|
+
const sets = extractQuantitiesOnDemand(store, entityId);
|
|
119
|
+
for (const set of sets) {
|
|
120
|
+
let bucket = qtoMap.get(set.name);
|
|
121
|
+
if (!bucket) { bucket = new Map(); qtoMap.set(set.name, bucket); }
|
|
122
|
+
for (const q of set.quantities) {
|
|
123
|
+
// Unit isn't carried in the on-demand quantity row today —
|
|
124
|
+
// emit "" so the schema shape matches `filter.rs::FilterSchema`.
|
|
125
|
+
if (!bucket.has(q.name)) bucket.set(q.name, '');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const psets: Array<[string, string[]]> = Array.from(psetMap, ([set, props]) => [
|
|
132
|
+
set,
|
|
133
|
+
Array.from(props).sort(),
|
|
134
|
+
]);
|
|
135
|
+
psets.sort((a, b) => a[0].localeCompare(b[0]));
|
|
136
|
+
|
|
137
|
+
const qtos: Array<[string, Array<[string, string]>]> = Array.from(qtoMap, ([set, qtys]) => [
|
|
138
|
+
set,
|
|
139
|
+
Array.from(qtys, ([name, unit]) => [name, unit] as [string, string]).sort((a, b) =>
|
|
140
|
+
a[0].localeCompare(b[0]),
|
|
141
|
+
),
|
|
142
|
+
]);
|
|
143
|
+
qtos.sort((a, b) => a[0].localeCompare(b[0]));
|
|
144
|
+
|
|
145
|
+
return { psets, qtos };
|
|
146
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
|
+
|
|
5
|
+
import { describe, it, beforeEach } from 'node:test';
|
|
6
|
+
import assert from 'node:assert';
|
|
7
|
+
import {
|
|
8
|
+
loadRecentSearches,
|
|
9
|
+
pushRecentSearch,
|
|
10
|
+
clearRecentSearches,
|
|
11
|
+
__internal,
|
|
12
|
+
} from './recent-searches.js';
|
|
13
|
+
|
|
14
|
+
/** Minimal Storage surface that `recent-searches` actually calls. Keeping
|
|
15
|
+
* it narrow (no `length`, `clear`, `key`) is fine — the safeStorage()
|
|
16
|
+
* helper in the module under test only touches get/set/remove. We assign
|
|
17
|
+
* it via `unknown` so we don't have to stub the full DOM Storage API. */
|
|
18
|
+
interface MemoryStorageLike {
|
|
19
|
+
getItem(key: string): string | null;
|
|
20
|
+
setItem(key: string, value: string): void;
|
|
21
|
+
removeItem(key: string): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
class MemoryStorage implements MemoryStorageLike {
|
|
25
|
+
private store = new Map<string, string>();
|
|
26
|
+
getItem(key: string): string | null { return this.store.get(key) ?? null; }
|
|
27
|
+
setItem(key: string, value: string): void { this.store.set(key, value); }
|
|
28
|
+
removeItem(key: string): void { this.store.delete(key); }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const g = globalThis as { localStorage?: unknown };
|
|
32
|
+
|
|
33
|
+
describe('recent-searches', () => {
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
g.localStorage = new MemoryStorage();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('returns an empty list when nothing is stored', () => {
|
|
39
|
+
assert.deepStrictEqual(loadRecentSearches(), []);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('records a committed query', () => {
|
|
43
|
+
pushRecentSearch('wall');
|
|
44
|
+
assert.deepStrictEqual(loadRecentSearches(), ['wall']);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('ignores empty and whitespace-only queries', () => {
|
|
48
|
+
pushRecentSearch('');
|
|
49
|
+
pushRecentSearch(' ');
|
|
50
|
+
assert.deepStrictEqual(loadRecentSearches(), []);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('trims queries before storing them', () => {
|
|
54
|
+
pushRecentSearch(' wall ');
|
|
55
|
+
assert.deepStrictEqual(loadRecentSearches(), ['wall']);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('move-to-front dedupes repeated queries', () => {
|
|
59
|
+
pushRecentSearch('wall');
|
|
60
|
+
pushRecentSearch('door');
|
|
61
|
+
pushRecentSearch('wall'); // move-to-front
|
|
62
|
+
assert.deepStrictEqual(loadRecentSearches(), ['wall', 'door']);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('caps the list at MAX_ENTRIES', () => {
|
|
66
|
+
for (let i = 0; i < __internal.MAX_ENTRIES + 5; i++) {
|
|
67
|
+
pushRecentSearch(`q-${i}`);
|
|
68
|
+
}
|
|
69
|
+
const list = loadRecentSearches();
|
|
70
|
+
assert.strictEqual(list.length, __internal.MAX_ENTRIES);
|
|
71
|
+
// Most-recent-first: the last insert is at the head.
|
|
72
|
+
assert.strictEqual(list[0], `q-${__internal.MAX_ENTRIES + 4}`);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('drops queries longer than MAX_QUERY_LEN', () => {
|
|
76
|
+
const huge = 'x'.repeat(__internal.MAX_QUERY_LEN + 1);
|
|
77
|
+
pushRecentSearch(huge);
|
|
78
|
+
assert.deepStrictEqual(loadRecentSearches(), []);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('clears the list on demand', () => {
|
|
82
|
+
pushRecentSearch('wall');
|
|
83
|
+
pushRecentSearch('door');
|
|
84
|
+
clearRecentSearches();
|
|
85
|
+
assert.deepStrictEqual(loadRecentSearches(), []);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('gracefully recovers from malformed storage payloads', () => {
|
|
89
|
+
(g.localStorage as MemoryStorageLike).setItem(__internal.STORAGE_KEY, '{not-an-array}');
|
|
90
|
+
assert.deepStrictEqual(loadRecentSearches(), []);
|
|
91
|
+
// Write should succeed after the malformed payload was auto-cleared.
|
|
92
|
+
pushRecentSearch('wall');
|
|
93
|
+
assert.deepStrictEqual(loadRecentSearches(), ['wall']);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('returns an empty list when no localStorage is available', () => {
|
|
97
|
+
delete g.localStorage;
|
|
98
|
+
assert.deepStrictEqual(loadRecentSearches(), []);
|
|
99
|
+
// Writes are silent no-ops.
|
|
100
|
+
pushRecentSearch('wall');
|
|
101
|
+
assert.deepStrictEqual(loadRecentSearches(), []);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('returns an empty list when storage writes throw (sandbox/quota)', () => {
|
|
105
|
+
const throwing: MemoryStorageLike = {
|
|
106
|
+
getItem: () => null,
|
|
107
|
+
setItem: () => {
|
|
108
|
+
throw new Error('QuotaExceeded');
|
|
109
|
+
},
|
|
110
|
+
removeItem: () => {},
|
|
111
|
+
};
|
|
112
|
+
g.localStorage = throwing;
|
|
113
|
+
// probe setItem will throw → safeStorage returns null → empty list.
|
|
114
|
+
assert.deepStrictEqual(loadRecentSearches(), []);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Recent searches — small localStorage-backed MRU list surfaced in the
|
|
7
|
+
* search popover when the field is focused with an empty query.
|
|
8
|
+
*
|
|
9
|
+
* Pure module — safe to import from tests (stubs a storage object when
|
|
10
|
+
* `window.localStorage` is not present). All entries are trimmed
|
|
11
|
+
* non-empty strings; exact-duplicate entries are moved to the front
|
|
12
|
+
* instead of appended, giving a natural MRU without bookkeeping.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const STORAGE_KEY = 'ifc-lite:search:recents';
|
|
16
|
+
const MAX_ENTRIES = 8;
|
|
17
|
+
const MAX_QUERY_LEN = 200;
|
|
18
|
+
|
|
19
|
+
interface StorageLike {
|
|
20
|
+
getItem(key: string): string | null;
|
|
21
|
+
setItem(key: string, value: string): void;
|
|
22
|
+
removeItem(key: string): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function safeStorage(): StorageLike | null {
|
|
26
|
+
try {
|
|
27
|
+
const ls = (globalThis as typeof globalThis & { localStorage?: StorageLike }).localStorage;
|
|
28
|
+
if (!ls) return null;
|
|
29
|
+
// Some environments (sandbox, private mode) throw on write — probe it.
|
|
30
|
+
const probe = `${STORAGE_KEY}:__probe__`;
|
|
31
|
+
ls.setItem(probe, '1');
|
|
32
|
+
ls.removeItem(probe);
|
|
33
|
+
return ls;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Returns the MRU list, most-recent first. Always a fresh array. */
|
|
40
|
+
export function loadRecentSearches(): string[] {
|
|
41
|
+
const ls = safeStorage();
|
|
42
|
+
if (!ls) return [];
|
|
43
|
+
const raw = ls.getItem(STORAGE_KEY);
|
|
44
|
+
if (!raw) return [];
|
|
45
|
+
try {
|
|
46
|
+
const parsed: unknown = JSON.parse(raw);
|
|
47
|
+
if (!Array.isArray(parsed)) return [];
|
|
48
|
+
const out: string[] = [];
|
|
49
|
+
for (const v of parsed) {
|
|
50
|
+
if (typeof v === 'string') {
|
|
51
|
+
const trimmed = v.trim();
|
|
52
|
+
if (trimmed.length > 0 && trimmed.length <= MAX_QUERY_LEN) out.push(trimmed);
|
|
53
|
+
}
|
|
54
|
+
if (out.length >= MAX_ENTRIES) break;
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
} catch {
|
|
58
|
+
// Malformed payload — drop it rather than letting it block future writes.
|
|
59
|
+
ls.removeItem(STORAGE_KEY);
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Record a committed query. No-ops on empty/whitespace input. */
|
|
65
|
+
export function pushRecentSearch(query: string): string[] {
|
|
66
|
+
const trimmed = query.trim();
|
|
67
|
+
if (trimmed.length === 0 || trimmed.length > MAX_QUERY_LEN) return loadRecentSearches();
|
|
68
|
+
const existing = loadRecentSearches();
|
|
69
|
+
// Move-to-front: remove any prior exact match, then prepend.
|
|
70
|
+
const deduped = existing.filter((q) => q !== trimmed);
|
|
71
|
+
const next = [trimmed, ...deduped].slice(0, MAX_ENTRIES);
|
|
72
|
+
|
|
73
|
+
const ls = safeStorage();
|
|
74
|
+
if (ls) {
|
|
75
|
+
try {
|
|
76
|
+
ls.setItem(STORAGE_KEY, JSON.stringify(next));
|
|
77
|
+
} catch {
|
|
78
|
+
// Quota exceeded or unexpected write failure — swallow; the next
|
|
79
|
+
// pushRecentSearch call may succeed once other storage is freed.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return next;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Wipe the list. Useful for privacy + the "Clear recents" UI affordance. */
|
|
86
|
+
export function clearRecentSearches(): void {
|
|
87
|
+
const ls = safeStorage();
|
|
88
|
+
if (!ls) return;
|
|
89
|
+
ls.removeItem(STORAGE_KEY);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Exposed for tests. */
|
|
93
|
+
export const __internal = { STORAGE_KEY, MAX_ENTRIES, MAX_QUERY_LEN };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
|
+
|
|
5
|
+
import { describe, it } from 'node:test';
|
|
6
|
+
import assert from 'node:assert';
|
|
7
|
+
import { formatCsv, formatJson, __internal } from './result-export.js';
|
|
8
|
+
|
|
9
|
+
const sample = {
|
|
10
|
+
columns: ['express_id', 'name', 'is_external', 'note'],
|
|
11
|
+
rows: [
|
|
12
|
+
[10, 'Wall A', true, 'plain'],
|
|
13
|
+
[20, 'Wall, "B"', false, 'has comma + quote'],
|
|
14
|
+
[30, 'Wall\nMulti', null, 'has newline'],
|
|
15
|
+
],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe('formatCsv', () => {
|
|
19
|
+
it('writes a header row plus a body that contains every row', () => {
|
|
20
|
+
const csv = formatCsv(sample);
|
|
21
|
+
// Header line is the first physical line — easy to assert on.
|
|
22
|
+
assert.ok(csv.startsWith('express_id,name,is_external,note\n'));
|
|
23
|
+
// Each row's first cell value should appear in the CSV body.
|
|
24
|
+
for (const row of sample.rows) {
|
|
25
|
+
assert.ok(csv.includes(String(row[0])), `CSV missing express_id ${row[0]}`);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('quotes cells containing comma, quote, or newline', () => {
|
|
30
|
+
const csv = formatCsv(sample);
|
|
31
|
+
// Wall, "B" → wrapped in quotes with embedded "" escapes.
|
|
32
|
+
assert.ok(csv.includes('"Wall, ""B"""'));
|
|
33
|
+
// Newline cell wrapped — embedded \n stays literal inside the quotes.
|
|
34
|
+
assert.ok(csv.includes('"Wall\nMulti"'));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('renders booleans as true / false and null as empty', () => {
|
|
38
|
+
const csv = formatCsv(sample);
|
|
39
|
+
// Row 1 uses no special chars → its full unquoted line is locatable.
|
|
40
|
+
assert.ok(csv.includes('10,Wall A,true,plain'));
|
|
41
|
+
// Null cell collapses to empty between two commas.
|
|
42
|
+
assert.ok(csv.includes(',,has newline'));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('terminates with a newline (POSIX-friendly)', () => {
|
|
46
|
+
const csv = formatCsv({ columns: ['a'], rows: [['1']] });
|
|
47
|
+
assert.ok(csv.endsWith('\n'));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('handles empty result sets (header only)', () => {
|
|
51
|
+
const csv = formatCsv({ columns: ['a', 'b'], rows: [] });
|
|
52
|
+
assert.strictEqual(csv, 'a,b\n');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('formatJson', () => {
|
|
57
|
+
it('produces an array of column→value objects', () => {
|
|
58
|
+
const json = formatJson(sample);
|
|
59
|
+
const parsed = JSON.parse(json);
|
|
60
|
+
assert.strictEqual(parsed.length, 3);
|
|
61
|
+
assert.strictEqual(parsed[0].express_id, '10');
|
|
62
|
+
assert.strictEqual(parsed[0].is_external, 'true');
|
|
63
|
+
assert.strictEqual(parsed[2].is_external, ''); // null → ''
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('is pretty-printed (multi-line, with indented keys)', () => {
|
|
67
|
+
const json = formatJson({ columns: ['a'], rows: [['1']] });
|
|
68
|
+
// Indent=2: the inner object lives at 4 spaces of indent inside the array.
|
|
69
|
+
assert.ok(json.includes('\n "a"'));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('handles empty result sets ("[]")', () => {
|
|
73
|
+
assert.strictEqual(formatJson({ columns: ['a'], rows: [] }), '[]');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('__internal helpers', () => {
|
|
78
|
+
it('cellToString covers booleans, bigint, numbers, and objects', () => {
|
|
79
|
+
assert.strictEqual(__internal.cellToString(null), '');
|
|
80
|
+
assert.strictEqual(__internal.cellToString(undefined), '');
|
|
81
|
+
assert.strictEqual(__internal.cellToString(true), 'true');
|
|
82
|
+
assert.strictEqual(__internal.cellToString(42n), '42');
|
|
83
|
+
assert.strictEqual(__internal.cellToString(3.14), '3.14');
|
|
84
|
+
assert.strictEqual(__internal.cellToString(NaN), '');
|
|
85
|
+
assert.strictEqual(__internal.cellToString({ a: 1 }), '{"a":1}');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('escapeCsvCell wraps + escapes only when needed', () => {
|
|
89
|
+
assert.strictEqual(__internal.escapeCsvCell(''), '');
|
|
90
|
+
assert.strictEqual(__internal.escapeCsvCell('plain'), 'plain');
|
|
91
|
+
assert.strictEqual(__internal.escapeCsvCell('a,b'), '"a,b"');
|
|
92
|
+
assert.strictEqual(__internal.escapeCsvCell('a"b'), '"a""b"');
|
|
93
|
+
assert.strictEqual(__internal.escapeCsvCell('a\nb'), '"a\nb"');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('sanitiseFilenameStem strips path-unsafe characters', () => {
|
|
97
|
+
assert.strictEqual(__internal.sanitiseFilenameStem('My Query.csv'), 'My_Query_csv');
|
|
98
|
+
assert.strictEqual(__internal.sanitiseFilenameStem(' '), 'query');
|
|
99
|
+
assert.strictEqual(__internal.sanitiseFilenameStem('weird/../path'), 'weird_path');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Result-table export — pure formatters + a Blob download trigger.
|
|
7
|
+
*
|
|
8
|
+
* `formatCsv` and `formatJson` are pure (testable in node:test); the
|
|
9
|
+
* download helper is browser-only and DOM-touching, so we keep it in
|
|
10
|
+
* the same module but isolated from the formatters.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface ExportResult {
|
|
14
|
+
columns: string[];
|
|
15
|
+
rows: unknown[][];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Cells DuckDB sometimes returns as Object / BigInt — normalise to a
|
|
19
|
+
* string the spreadsheet / JSON consumers can actually read. */
|
|
20
|
+
function cellToString(v: unknown): string {
|
|
21
|
+
if (v === null || v === undefined) return '';
|
|
22
|
+
if (typeof v === 'boolean') return v ? 'true' : 'false';
|
|
23
|
+
if (typeof v === 'bigint') return v.toString();
|
|
24
|
+
if (typeof v === 'number') return Number.isFinite(v) ? String(v) : '';
|
|
25
|
+
if (typeof v === 'string') return v;
|
|
26
|
+
// Fallback for Object / Date / Arrow row helpers — JSON.stringify is
|
|
27
|
+
// safe and round-trippable; CSV consumers see the JSON literal.
|
|
28
|
+
try {
|
|
29
|
+
return JSON.stringify(v);
|
|
30
|
+
} catch {
|
|
31
|
+
return String(v);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** RFC-4180-style escaping: quote any cell containing comma, quote, or
|
|
36
|
+
* newline; double-up embedded quotes inside the wrapped cell. */
|
|
37
|
+
function escapeCsvCell(raw: string): string {
|
|
38
|
+
if (raw.length === 0) return '';
|
|
39
|
+
const needsQuotes = raw.includes(',') || raw.includes('"') || raw.includes('\n') || raw.includes('\r');
|
|
40
|
+
if (!needsQuotes) return raw;
|
|
41
|
+
return `"${raw.replace(/"/g, '""')}"`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Serialize a result set to CSV (UTF-8). Trailing newline included. */
|
|
45
|
+
export function formatCsv(result: ExportResult): string {
|
|
46
|
+
const lines: string[] = [];
|
|
47
|
+
lines.push(result.columns.map((c) => escapeCsvCell(c)).join(','));
|
|
48
|
+
for (const row of result.rows) {
|
|
49
|
+
const cells: string[] = [];
|
|
50
|
+
for (let i = 0; i < result.columns.length; i++) {
|
|
51
|
+
cells.push(escapeCsvCell(cellToString(row[i])));
|
|
52
|
+
}
|
|
53
|
+
lines.push(cells.join(','));
|
|
54
|
+
}
|
|
55
|
+
return lines.join('\n') + '\n';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Serialize a result set to a pretty-printed JSON array of objects.
|
|
59
|
+
* bigint / Date / object cells are stringified through cellToString
|
|
60
|
+
* for round-trip consistency with CSV export. */
|
|
61
|
+
export function formatJson(result: ExportResult): string {
|
|
62
|
+
const out = result.rows.map((row) => {
|
|
63
|
+
const obj: Record<string, string> = {};
|
|
64
|
+
for (let i = 0; i < result.columns.length; i++) {
|
|
65
|
+
obj[result.columns[i]] = cellToString(row[i]);
|
|
66
|
+
}
|
|
67
|
+
return obj;
|
|
68
|
+
});
|
|
69
|
+
return JSON.stringify(out, null, 2);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Sanitise a stem for use as a download filename — strip path-unsafe
|
|
73
|
+
* characters, fall back to `query`. */
|
|
74
|
+
function sanitiseFilenameStem(stem: string): string {
|
|
75
|
+
const cleaned = stem.replace(/[^a-zA-Z0-9_\-]+/g, '_').replace(/^_+|_+$/g, '');
|
|
76
|
+
return cleaned.length > 0 ? cleaned.slice(0, 60) : 'query';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Build the `Blob` + temporary `<a>` download flow for a result. The
|
|
80
|
+
* caller passes a "stem" that becomes the filename root (`stem.csv`).
|
|
81
|
+
* Browser-only — does nothing useful when `document` is missing. */
|
|
82
|
+
export function downloadResult(
|
|
83
|
+
result: ExportResult,
|
|
84
|
+
format: 'csv' | 'json',
|
|
85
|
+
filenameStem = 'ifc-query',
|
|
86
|
+
): void {
|
|
87
|
+
if (typeof document === 'undefined' || typeof URL === 'undefined') return;
|
|
88
|
+
|
|
89
|
+
const content = format === 'csv' ? formatCsv(result) : formatJson(result);
|
|
90
|
+
const mime = format === 'csv' ? 'text/csv;charset=utf-8' : 'application/json;charset=utf-8';
|
|
91
|
+
const blob = new Blob([content], { type: mime });
|
|
92
|
+
const url = URL.createObjectURL(blob);
|
|
93
|
+
|
|
94
|
+
const a = document.createElement('a');
|
|
95
|
+
a.href = url;
|
|
96
|
+
a.download = `${sanitiseFilenameStem(filenameStem)}.${format}`;
|
|
97
|
+
document.body.appendChild(a);
|
|
98
|
+
a.click();
|
|
99
|
+
document.body.removeChild(a);
|
|
100
|
+
URL.revokeObjectURL(url);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Exposed for tests. */
|
|
104
|
+
export const __internal = { escapeCsvCell, cellToString, sanitiseFilenameStem };
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
|
+
|
|
5
|
+
import { describe, it, beforeEach } from 'node:test';
|
|
6
|
+
import assert from 'node:assert';
|
|
7
|
+
import {
|
|
8
|
+
loadSavedFilters,
|
|
9
|
+
saveFilter,
|
|
10
|
+
deleteSavedFilter,
|
|
11
|
+
clearSavedFilters,
|
|
12
|
+
__internal,
|
|
13
|
+
} from './saved-filters.js';
|
|
14
|
+
import { Rule } from './filter-rules.js';
|
|
15
|
+
|
|
16
|
+
interface MemoryStorageLike {
|
|
17
|
+
getItem(key: string): string | null;
|
|
18
|
+
setItem(key: string, value: string): void;
|
|
19
|
+
removeItem(key: string): void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class MemoryStorage implements MemoryStorageLike {
|
|
23
|
+
private store = new Map<string, string>();
|
|
24
|
+
getItem(key: string): string | null { return this.store.get(key) ?? null; }
|
|
25
|
+
setItem(key: string, value: string): void { this.store.set(key, value); }
|
|
26
|
+
removeItem(key: string): void { this.store.delete(key); }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const g = globalThis as { localStorage?: unknown };
|
|
30
|
+
|
|
31
|
+
describe('saved-filters', () => {
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
g.localStorage = new MemoryStorage();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('returns an empty list when nothing is stored', () => {
|
|
37
|
+
assert.deepStrictEqual(loadSavedFilters(), []);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('saves a preset and reads it back sorted by name', () => {
|
|
41
|
+
saveFilter('Bravo', 'AND', [Rule.ifcType(['IfcWall'])]);
|
|
42
|
+
saveFilter('Alpha', 'OR', [Rule.name('contains', 'EXT')]);
|
|
43
|
+
const list = loadSavedFilters();
|
|
44
|
+
assert.deepStrictEqual(list.map((p) => p.name), ['Alpha', 'Bravo']);
|
|
45
|
+
assert.strictEqual(list[0].combinator, 'OR');
|
|
46
|
+
assert.strictEqual(list[1].combinator, 'AND');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('overwrites an existing preset by case-insensitive name match', () => {
|
|
50
|
+
saveFilter('External Walls', 'AND', [Rule.ifcType(['IfcWall'])]);
|
|
51
|
+
saveFilter('external walls', 'OR', [Rule.ifcType(['IfcDoor'])]);
|
|
52
|
+
const list = loadSavedFilters();
|
|
53
|
+
assert.strictEqual(list.length, 1);
|
|
54
|
+
assert.strictEqual(list[0].combinator, 'OR');
|
|
55
|
+
assert.strictEqual(list[0].name, 'external walls');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('drops empty / whitespace / over-length names', () => {
|
|
59
|
+
saveFilter('', 'AND', []);
|
|
60
|
+
saveFilter(' ', 'AND', []);
|
|
61
|
+
saveFilter('x'.repeat(__internal.MAX_NAME_LEN + 1), 'AND', []);
|
|
62
|
+
assert.deepStrictEqual(loadSavedFilters(), []);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('takes a defensive copy of the rules array', () => {
|
|
66
|
+
const rules = [Rule.ifcType(['IfcWall'])];
|
|
67
|
+
saveFilter('Walls', 'AND', rules);
|
|
68
|
+
rules[0] = Rule.ifcType(['IfcDoor']);
|
|
69
|
+
const loaded = loadSavedFilters()[0];
|
|
70
|
+
// The mutation to the caller's array must not leak into storage.
|
|
71
|
+
const r = loaded.rules[0];
|
|
72
|
+
assert.strictEqual(r.kind, 'ifcType');
|
|
73
|
+
if (r.kind === 'ifcType') {
|
|
74
|
+
assert.deepStrictEqual(r.values, ['IfcWall']);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('deleteSavedFilter removes the named preset', () => {
|
|
79
|
+
saveFilter('Walls', 'AND', [Rule.ifcType(['IfcWall'])]);
|
|
80
|
+
saveFilter('Doors', 'AND', [Rule.ifcType(['IfcDoor'])]);
|
|
81
|
+
deleteSavedFilter('walls');
|
|
82
|
+
const list = loadSavedFilters();
|
|
83
|
+
assert.deepStrictEqual(list.map((p) => p.name), ['Doors']);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('deleteSavedFilter is a no-op for unknown names', () => {
|
|
87
|
+
saveFilter('Walls', 'AND', [Rule.ifcType(['IfcWall'])]);
|
|
88
|
+
deleteSavedFilter('Nope');
|
|
89
|
+
assert.strictEqual(loadSavedFilters().length, 1);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('clearSavedFilters wipes the catalog', () => {
|
|
93
|
+
saveFilter('Walls', 'AND', [Rule.ifcType(['IfcWall'])]);
|
|
94
|
+
clearSavedFilters();
|
|
95
|
+
assert.deepStrictEqual(loadSavedFilters(), []);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('drops malformed payloads in storage', () => {
|
|
99
|
+
(g.localStorage as MemoryStorage).setItem(__internal.STORAGE_KEY, '{not-json');
|
|
100
|
+
assert.deepStrictEqual(loadSavedFilters(), []);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('rejects entries with unknown rule kinds while preserving valid ones', () => {
|
|
104
|
+
(g.localStorage as MemoryStorage).setItem(
|
|
105
|
+
__internal.STORAGE_KEY,
|
|
106
|
+
JSON.stringify([
|
|
107
|
+
{ name: 'Mixed', combinator: 'AND', rules: [
|
|
108
|
+
{ kind: 'ifcType', values: ['IfcWall'], op: 'in' },
|
|
109
|
+
{ kind: 'unknown' },
|
|
110
|
+
], updatedAt: 1 },
|
|
111
|
+
]),
|
|
112
|
+
);
|
|
113
|
+
const list = loadSavedFilters();
|
|
114
|
+
assert.strictEqual(list.length, 1);
|
|
115
|
+
assert.strictEqual(list[0].rules.length, 1, 'invalid rule was filtered');
|
|
116
|
+
assert.strictEqual(list[0].rules[0].kind, 'ifcType');
|
|
117
|
+
});
|
|
118
|
+
});
|