@ifc-lite/viewer 1.17.6 → 1.18.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 +17 -14
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +513 -0
- package/dist/assets/{basketViewActivator-86rgogji.js → basketViewActivator-Cm1QEk_R.js} +1 -1
- package/dist/assets/{exporters-CcPS9MK5.js → exporters-B_OBqIyD.js} +3235 -2648
- package/dist/assets/{geometry.worker-BFUYA08u.js → geometry.worker-xHHy-9DV.js} +1 -1
- package/dist/assets/{ifc-lite_bg-BINvzoCP.wasm → ifc-lite_bg-ADjKXSms.wasm} +0 -0
- package/dist/assets/{index-Bfms9I4A.js → index-BKq-M3Mk.js} +44124 -30920
- package/dist/assets/index-COnQRuqY.css +1 -0
- package/dist/assets/{native-bridge-DUyLCMZS.js → native-bridge-SHXiQwFW.js} +1 -1
- package/dist/assets/sandbox-jez21HtV.js +9627 -0
- package/dist/assets/{server-client-BuZK7OST.js → server-client-ncOQVNso.js} +1 -1
- package/dist/assets/{wasm-bridge-JsqEGDV8.js → wasm-bridge-DyfBSB8z.js} +1 -1
- package/dist/index.html +6 -6
- package/package.json +10 -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 +69 -10
- 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 +11 -1
- package/src/components/viewer/ViewportContainer.tsx +2 -0
- 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 +540 -0
- package/src/components/viewer/useDuplicateShortcut.ts +77 -0
- package/src/components/viewer/useMouseControls.ts +9 -1
- package/src/hooks/useIfcLoader.ts +22 -10
- 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 +4 -1
- 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 +70 -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/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.ts +14 -0
- package/dist/assets/index-_bfZsDCC.css +0 -1
- package/dist/assets/sandbox-C8575tul.js +0 -5951
|
@@ -0,0 +1,488 @@
|
|
|
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
|
+
* Schedule animator — compute per-product RGBA overrides for the current
|
|
7
|
+
* playback time.
|
|
8
|
+
*
|
|
9
|
+
* Professional 4D tools (Synchro / Navisworks Timeliner / Fuzor) classify
|
|
10
|
+
* each product into a *lifecycle phase* relative to its controlling task and
|
|
11
|
+
* paint it according to (a) the task's PredefinedType and (b) the phase:
|
|
12
|
+
*
|
|
13
|
+
* upcoming — task hasn't started yet (optionally outside a
|
|
14
|
+
* "look-ahead" window: hidden. Inside the window: ghost)
|
|
15
|
+
* preparation — within `preparationDays` before start: ghost-blue
|
|
16
|
+
* @ ~25 % opacity; signals imminent work
|
|
17
|
+
* ramp-in — first `rampInFraction` of the task window: opacity
|
|
18
|
+
* animates 0 → 1 (ease-out), painted in type-colour
|
|
19
|
+
* active — middle of the task: solid task-type colour
|
|
20
|
+
* settling — last `fadeOutFraction` of the window: type-colour
|
|
21
|
+
* fades toward full transparency of *the override* (so
|
|
22
|
+
* the real material shows through), not the product
|
|
23
|
+
* complete — task finished: no override (material default)
|
|
24
|
+
*
|
|
25
|
+
* Demolition-like tasks (DEMOLITION / DISMANTLE / REMOVAL / DISPOSAL) invert
|
|
26
|
+
* the lifecycle — the product exists normally before the task, fades out
|
|
27
|
+
* with a red tint during the window, and is hidden afterwards.
|
|
28
|
+
*
|
|
29
|
+
* This module is pure. It reads a `ScheduleExtraction` + a time + settings
|
|
30
|
+
* and returns a Map<expressId, RGBA> plus a hidden set. The caller wires the
|
|
31
|
+
* Map into `pendingColorUpdates` and the hidden set into the visibility
|
|
32
|
+
* layer.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import type { ScheduleExtraction, ScheduleTaskInfo } from '@ifc-lite/parser';
|
|
36
|
+
|
|
37
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
38
|
+
// Types
|
|
39
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
export type RGBA = [number, number, number, number];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* All IfcTaskTypeEnum values (IFC4) plus an extra `PREPARATION` slot that
|
|
45
|
+
* applies to every task during its look-ahead window, regardless of type.
|
|
46
|
+
*/
|
|
47
|
+
export type TaskPaletteKey =
|
|
48
|
+
| 'ATTENDANCE' | 'CONSTRUCTION' | 'DEMOLITION' | 'DISMANTLE'
|
|
49
|
+
| 'DISPOSAL' | 'INSTALLATION' | 'LOGISTIC' | 'MAINTENANCE'
|
|
50
|
+
| 'MOVE' | 'OPERATION' | 'REMOVAL' | 'RENOVATION'
|
|
51
|
+
| 'USERDEFINED' | 'NOTDEFINED'
|
|
52
|
+
| 'PREPARATION'
|
|
53
|
+
| 'COMPLETED';
|
|
54
|
+
|
|
55
|
+
export type TaskPalette = Record<TaskPaletteKey, RGBA>;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Task-type palette aligned with Synchro's default conventions.
|
|
59
|
+
*
|
|
60
|
+
* RGB channels are chosen for decent contrast on both light and dark
|
|
61
|
+
* viewport backgrounds; **alpha is 1.0 for task-type entries** because the
|
|
62
|
+
* per-frame `paletteIntensity` setting (not the palette) is how users dial
|
|
63
|
+
* how strongly the override paints over the real material.
|
|
64
|
+
*
|
|
65
|
+
* PREPARATION is treated separately — it's a ghost outline, not an active
|
|
66
|
+
* paint, so it keeps its own alpha baked in.
|
|
67
|
+
*/
|
|
68
|
+
export const DEFAULT_PALETTE: TaskPalette = {
|
|
69
|
+
CONSTRUCTION: [0.34, 0.76, 0.39, 1.0], // emerald green
|
|
70
|
+
INSTALLATION: [0.40, 0.60, 0.95, 1.0], // royal blue
|
|
71
|
+
DEMOLITION: [0.88, 0.27, 0.27, 1.0], // red
|
|
72
|
+
DISMANTLE: [0.97, 0.55, 0.16, 1.0], // orange
|
|
73
|
+
REMOVAL: [0.86, 0.40, 0.25, 1.0], // red-orange
|
|
74
|
+
DISPOSAL: [0.55, 0.35, 0.18, 1.0], // brown
|
|
75
|
+
RENOVATION: [0.62, 0.42, 0.88, 1.0], // purple
|
|
76
|
+
MAINTENANCE: [0.92, 0.80, 0.22, 1.0], // amber
|
|
77
|
+
LOGISTIC: [0.32, 0.78, 0.85, 1.0], // cyan
|
|
78
|
+
MOVE: [0.58, 0.66, 0.90, 1.0], // lavender
|
|
79
|
+
OPERATION: [0.45, 0.80, 0.58, 1.0], // teal-green
|
|
80
|
+
ATTENDANCE: [0.75, 0.75, 0.80, 1.0], // cool grey
|
|
81
|
+
USERDEFINED: [0.55, 0.72, 0.86, 1.0], // soft blue
|
|
82
|
+
NOTDEFINED: [0.70, 0.70, 0.70, 1.0], // neutral grey
|
|
83
|
+
// The preparation *ghost* is a dark NEUTRAL colour at moderate alpha.
|
|
84
|
+
// Now that the overlay pipeline has real src-alpha blending AND skips the
|
|
85
|
+
// glass-fresnel path (flags.x bit 1), a 0.55-alpha dark overlay composites
|
|
86
|
+
// as a clear dim silhouette on both light and dark viewport themes — you
|
|
87
|
+
// see the underlying material through it but it reads as pending/ghosted.
|
|
88
|
+
PREPARATION: [0.15, 0.17, 0.22, 0.55],
|
|
89
|
+
// "Completed" is an opt-in tint for built-but-static products so users can
|
|
90
|
+
// visually separate "already built" from "never-touched / untaskd". A
|
|
91
|
+
// muted cool grey at low alpha doesn't recolour the material heavily but
|
|
92
|
+
// clearly distinguishes it from material-default renders. Off by default.
|
|
93
|
+
COMPLETED: [0.55, 0.58, 0.62, 0.25],
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Enum values that represent "removing" work — products should be visible
|
|
98
|
+
* before the task and faded/hidden after. IFC4's IfcTaskTypeEnum identifies
|
|
99
|
+
* these explicitly; MOVE/RENOVATION are *not* removal by themselves.
|
|
100
|
+
*/
|
|
101
|
+
const REMOVAL_TASK_TYPES: ReadonlySet<string> = new Set([
|
|
102
|
+
'DEMOLITION', 'DISMANTLE', 'REMOVAL', 'DISPOSAL',
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Distinguishable lifecycle phase names. Callers don't need to branch on
|
|
107
|
+
* these directly; they're exposed so UI can report a count per phase.
|
|
108
|
+
*/
|
|
109
|
+
export type LifecyclePhase =
|
|
110
|
+
| 'upcoming-far' // not within look-ahead window
|
|
111
|
+
| 'upcoming-preparation' // within look-ahead window
|
|
112
|
+
| 'active-ramp-in'
|
|
113
|
+
| 'active'
|
|
114
|
+
| 'active-settling'
|
|
115
|
+
| 'complete'
|
|
116
|
+
| 'removal-active' // fading out during a demolition task
|
|
117
|
+
| 'removal-complete'; // already demolished
|
|
118
|
+
|
|
119
|
+
export interface AnimationSettings {
|
|
120
|
+
/**
|
|
121
|
+
* Whether any colour overlays paint over the base material during
|
|
122
|
+
* playback. Derived at the animator: true when ANY of
|
|
123
|
+
* `colorizeByTaskType` / `showPreparationGhost` / `showCompletedTint`
|
|
124
|
+
* is on. The Popover's "Minimal" / "Phased" tiles read + write the
|
|
125
|
+
* underlying flags as presets; there is no separate `style` mode.
|
|
126
|
+
*
|
|
127
|
+
* Earlier revisions stored `style: 'minimal' | 'phased'` alongside
|
|
128
|
+
* these flags — two ways to say the same thing invited drift. Now
|
|
129
|
+
* the flags are the source of truth.
|
|
130
|
+
*/
|
|
131
|
+
/** Days before task start that the preparation ghost is shown. */
|
|
132
|
+
preparationDays: number;
|
|
133
|
+
/** Fraction of the task window used for the ramp-in (0..0.5). */
|
|
134
|
+
rampInFraction: number;
|
|
135
|
+
/** Fraction of the task window used for the settling fade (0..0.5). */
|
|
136
|
+
fadeOutFraction: number;
|
|
137
|
+
/** Show ghost-blue preparation outline during the look-ahead window. */
|
|
138
|
+
showPreparationGhost: boolean;
|
|
139
|
+
/** Apply task-type colour during active phase. */
|
|
140
|
+
colorizeByTaskType: boolean;
|
|
141
|
+
/**
|
|
142
|
+
* 0 = no palette override (pure visibility-timing mode even inside
|
|
143
|
+
* `'phased'` style); 1 = full palette alpha. Multiplies the emitted
|
|
144
|
+
* RGBA alpha for every active / ramp-in / settling / removal phase so
|
|
145
|
+
* users can dial "subtle tint" vs. "saturated highlight" without
|
|
146
|
+
* editing the palette itself. Does not affect the PREPARATION ghost,
|
|
147
|
+
* which keeps its own baked alpha.
|
|
148
|
+
*/
|
|
149
|
+
paletteIntensity: number;
|
|
150
|
+
/** Animate DEMOLITION / DISMANTLE / REMOVAL / DISPOSAL as inverted fade. */
|
|
151
|
+
animateDemolition: boolean;
|
|
152
|
+
/**
|
|
153
|
+
* Hide products whose task hasn't started and isn't in the preparation
|
|
154
|
+
* window. When false, they render with the material default — useful if
|
|
155
|
+
* the user wants to see the whole model at once with colour-coding only.
|
|
156
|
+
*/
|
|
157
|
+
hideBeforePreparation: boolean;
|
|
158
|
+
/**
|
|
159
|
+
* Hide products that have NO controlling task in the active schedule.
|
|
160
|
+
*
|
|
161
|
+
* Without this, partial schedule coverage (e.g. an LLM-generated sequence
|
|
162
|
+
* that only covers the bottom floors, or a schedule whose tasks don't
|
|
163
|
+
* reach every product) leaves untaskd products rendering as their
|
|
164
|
+
* material default — which on bare concrete IFC reads as pure white and
|
|
165
|
+
* dominates the viewport. On by default because "untaskd" during an
|
|
166
|
+
* active construction animation effectively means "not yet built": the
|
|
167
|
+
* animation looks cleanest when those products simply don't exist yet.
|
|
168
|
+
*
|
|
169
|
+
* Turn off to show the whole model with only the scheduled portion
|
|
170
|
+
* highlighted (useful during schedule authoring so the user can still
|
|
171
|
+
* see everything they haven't taskd).
|
|
172
|
+
*/
|
|
173
|
+
hideUntaskedProducts: boolean;
|
|
174
|
+
/**
|
|
175
|
+
* Apply the COMPLETED palette tint to products whose task has finished.
|
|
176
|
+
* Off by default — completed products render as material-default, which
|
|
177
|
+
* is the Synchro/Navisworks convention ("done = the real model"). Turn
|
|
178
|
+
* on when you want a persistent visual distinction between built and
|
|
179
|
+
* untaskd products.
|
|
180
|
+
*/
|
|
181
|
+
showCompletedTint: boolean;
|
|
182
|
+
/** RGBA palette indexed by TaskPaletteKey. Defaults to DEFAULT_PALETTE. */
|
|
183
|
+
palette: TaskPalette;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export const DEFAULT_ANIMATION_SETTINGS: AnimationSettings = {
|
|
187
|
+
// Defaults to pure visibility-timing with zero colour overlays — the
|
|
188
|
+
// "Minimal" preset from the popover. Users who load a model should
|
|
189
|
+
// see it animate cleanly by default without any palette cast on top
|
|
190
|
+
// of their authoring. Every colour flag is OFF; the "Phased" preset
|
|
191
|
+
// in the popover flips `colorizeByTaskType` + raises
|
|
192
|
+
// `paletteIntensity`.
|
|
193
|
+
preparationDays: 2,
|
|
194
|
+
rampInFraction: 0.08,
|
|
195
|
+
fadeOutFraction: 0.10,
|
|
196
|
+
showPreparationGhost: false,
|
|
197
|
+
colorizeByTaskType: false,
|
|
198
|
+
// 0 = no palette tint even if `colorizeByTaskType` is flipped later
|
|
199
|
+
// by other code; the "Phased" preset bumps this to 0.6 (middle-ground)
|
|
200
|
+
// when the user selects it.
|
|
201
|
+
paletteIntensity: 0,
|
|
202
|
+
animateDemolition: true,
|
|
203
|
+
hideBeforePreparation: true,
|
|
204
|
+
hideUntaskedProducts: true,
|
|
205
|
+
showCompletedTint: false,
|
|
206
|
+
palette: DEFAULT_PALETTE,
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
export interface AnimationFrame {
|
|
210
|
+
/** Per-expressId RGBA overrides for `scene.setColorOverrides`. */
|
|
211
|
+
colorOverrides: Map<number, RGBA>;
|
|
212
|
+
/** Products that should be *fully hidden* for this frame (upcoming-far). */
|
|
213
|
+
hiddenIds: Set<number>;
|
|
214
|
+
/** Human-readable per-phase counts for a debug / UI readout. */
|
|
215
|
+
stats: Record<LifecyclePhase, number>;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
219
|
+
// Helpers
|
|
220
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
/** Classic cubic ease-out, same shape used by the camera animator. */
|
|
223
|
+
function easeOutCubic(t: number): number {
|
|
224
|
+
const clamped = Math.min(1, Math.max(0, t));
|
|
225
|
+
return 1 - Math.pow(1 - clamped, 3);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Mirror — used by the settling-phase fade so the override dissolves. */
|
|
229
|
+
function easeInCubic(t: number): number {
|
|
230
|
+
const clamped = Math.min(1, Math.max(0, t));
|
|
231
|
+
return clamped * clamped * clamped;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Parse an ISO 8601 datetime → epoch ms. Identical to
|
|
236
|
+
* `scheduleSlice.parseIsoDate`. We keep a local copy rather than importing
|
|
237
|
+
* from `@/store` because `scheduleSlice` already imports from this module
|
|
238
|
+
* for `AnimationSettings` / `DEFAULT_ANIMATION_SETTINGS` — sharing the
|
|
239
|
+
* helper through `@/store` closes the loop and breaks ESM initialisation
|
|
240
|
+
* order ("Cannot access X before initialization"). Any fix to TZ-less
|
|
241
|
+
* normalization must land in both copies; linked via comment.
|
|
242
|
+
*/
|
|
243
|
+
function parseEpoch(value: string | undefined): number | undefined {
|
|
244
|
+
if (!value) return undefined;
|
|
245
|
+
const hasTz = /Z$|[+-]\d{2}:?\d{2}$/.test(value);
|
|
246
|
+
const t = Date.parse(hasTz ? value : `${value}Z`);
|
|
247
|
+
return Number.isNaN(t) ? undefined : t;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function taskWindow(task: ScheduleTaskInfo): { start: number; finish: number } | null {
|
|
251
|
+
const start = parseEpoch(task.taskTime?.scheduleStart ?? task.taskTime?.actualStart);
|
|
252
|
+
const finish = parseEpoch(task.taskTime?.scheduleFinish ?? task.taskTime?.actualFinish);
|
|
253
|
+
if (start === undefined || finish === undefined || finish < start) return null;
|
|
254
|
+
return { start, finish };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function resolvePaletteKey(predefinedType: string | undefined): TaskPaletteKey {
|
|
258
|
+
if (!predefinedType) return 'NOTDEFINED';
|
|
259
|
+
const upper = predefinedType.toUpperCase();
|
|
260
|
+
if (upper in DEFAULT_PALETTE) return upper as TaskPaletteKey;
|
|
261
|
+
return 'USERDEFINED';
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function isRemovalTask(task: ScheduleTaskInfo): boolean {
|
|
265
|
+
return REMOVAL_TASK_TYPES.has((task.predefinedType ?? '').toUpperCase());
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Merge RGBA b over a using the `a.alpha` and `b.alpha` terms — standard
|
|
269
|
+
* "src-over" compositing. Only used for emptyPhase initialization. */
|
|
270
|
+
function withAlpha(rgba: RGBA, alpha: number): RGBA {
|
|
271
|
+
return [rgba[0], rgba[1], rgba[2], Math.min(1, Math.max(0, alpha))];
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const MS_PER_DAY = 86_400_000;
|
|
275
|
+
|
|
276
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
277
|
+
// Per-task phase computation
|
|
278
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
interface PhaseResult {
|
|
281
|
+
phase: LifecyclePhase;
|
|
282
|
+
/** RGBA to apply (undefined = no override = render material default). */
|
|
283
|
+
color?: RGBA;
|
|
284
|
+
/** True if the product should be hidden instead of coloured. */
|
|
285
|
+
hide: boolean;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function computeTaskPhase(
|
|
289
|
+
task: ScheduleTaskInfo,
|
|
290
|
+
playbackTime: number,
|
|
291
|
+
settings: AnimationSettings,
|
|
292
|
+
): PhaseResult | null {
|
|
293
|
+
const win = taskWindow(task);
|
|
294
|
+
if (!win) return null;
|
|
295
|
+
const { start, finish } = win;
|
|
296
|
+
const duration = Math.max(1, finish - start);
|
|
297
|
+
const prepStart = start - settings.preparationDays * MS_PER_DAY;
|
|
298
|
+
const typeKey = resolvePaletteKey(task.predefinedType);
|
|
299
|
+
const typeColor = settings.palette[typeKey] ?? settings.palette.NOTDEFINED;
|
|
300
|
+
const prepColor = settings.palette.PREPARATION;
|
|
301
|
+
// Clamp once; 0 disables task-type painting entirely even in 'phased'.
|
|
302
|
+
const intensity = Math.min(1, Math.max(0, settings.paletteIntensity));
|
|
303
|
+
// Helper: palette alpha × user intensity. Kept inline rather than lifted
|
|
304
|
+
// to the module scope because it closes over `typeColor[3]`.
|
|
305
|
+
const paint = (alpha: number): RGBA => [
|
|
306
|
+
typeColor[0], typeColor[1], typeColor[2],
|
|
307
|
+
Math.min(1, Math.max(0, alpha * typeColor[3] * intensity)),
|
|
308
|
+
];
|
|
309
|
+
|
|
310
|
+
// ── Removal-like tasks invert the lifecycle ──────────────────────────
|
|
311
|
+
if (settings.animateDemolition && isRemovalTask(task)) {
|
|
312
|
+
if (playbackTime < start) {
|
|
313
|
+
// Before demolition starts — product exists normally.
|
|
314
|
+
return { phase: 'upcoming-far', hide: false };
|
|
315
|
+
}
|
|
316
|
+
if (playbackTime > finish) {
|
|
317
|
+
return { phase: 'removal-complete', hide: true };
|
|
318
|
+
}
|
|
319
|
+
const p = (playbackTime - start) / duration;
|
|
320
|
+
// Fade red tint in and overall override alpha out. Intensity modulates
|
|
321
|
+
// the peak — `intensity=0` means the tint is never visible (the product
|
|
322
|
+
// simply disappears at `finish`).
|
|
323
|
+
return { phase: 'removal-active', hide: false, color: paint(1 - easeInCubic(p)) };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── Standard construction lifecycle ──────────────────────────────────
|
|
327
|
+
if (playbackTime < prepStart) {
|
|
328
|
+
return {
|
|
329
|
+
phase: 'upcoming-far',
|
|
330
|
+
hide: settings.hideBeforePreparation,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (playbackTime < start) {
|
|
335
|
+
if (!settings.showPreparationGhost) {
|
|
336
|
+
return { phase: 'upcoming-preparation', hide: settings.hideBeforePreparation };
|
|
337
|
+
}
|
|
338
|
+
return { phase: 'upcoming-preparation', hide: false, color: prepColor };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (playbackTime <= finish) {
|
|
342
|
+
// colorizeByTaskType=false → phase is tracked for stats but no override;
|
|
343
|
+
// paletteIntensity=0 is the same effect without changing the toggle.
|
|
344
|
+
if (!settings.colorizeByTaskType || intensity === 0) {
|
|
345
|
+
return { phase: 'active', hide: false };
|
|
346
|
+
}
|
|
347
|
+
const p = (playbackTime - start) / duration;
|
|
348
|
+
if (p < settings.rampInFraction) {
|
|
349
|
+
const t = p / Math.max(0.001, settings.rampInFraction);
|
|
350
|
+
return {
|
|
351
|
+
phase: 'active-ramp-in',
|
|
352
|
+
hide: false,
|
|
353
|
+
color: paint(easeOutCubic(t)),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
if (p > 1 - settings.fadeOutFraction) {
|
|
357
|
+
const t = (p - (1 - settings.fadeOutFraction)) / Math.max(0.001, settings.fadeOutFraction);
|
|
358
|
+
return {
|
|
359
|
+
phase: 'active-settling',
|
|
360
|
+
hide: false,
|
|
361
|
+
color: paint(1 - easeInCubic(t)),
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
return { phase: 'active', hide: false, color: paint(1) };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// After finish — material default, or an explicit "completed" tint when
|
|
368
|
+
// the user wants built products visually distinguished from untaskd ones.
|
|
369
|
+
if (settings.showCompletedTint) {
|
|
370
|
+
const completedColor = settings.palette.COMPLETED;
|
|
371
|
+
return { phase: 'complete', hide: false, color: completedColor };
|
|
372
|
+
}
|
|
373
|
+
return { phase: 'complete', hide: false };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
377
|
+
// Public — computeAnimationFrame
|
|
378
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Compute a frame of animation for the given playback time.
|
|
382
|
+
*
|
|
383
|
+
* Products with multiple controlling tasks take the *most-relevant* one:
|
|
384
|
+
* an active/ramp/settling task wins over preparation, which wins over
|
|
385
|
+
* upcoming-far, which wins over complete. Removal tasks are always
|
|
386
|
+
* preferred over construction tasks (their span dominates the product's
|
|
387
|
+
* visual state).
|
|
388
|
+
*/
|
|
389
|
+
export function computeAnimationFrame(
|
|
390
|
+
data: ScheduleExtraction | null,
|
|
391
|
+
playbackTime: number,
|
|
392
|
+
settings: AnimationSettings,
|
|
393
|
+
scheduleGlobalId?: string | null,
|
|
394
|
+
/**
|
|
395
|
+
* Universe of product expressIds in the model(s). When
|
|
396
|
+
* `settings.hideUntaskedProducts` is true, every id in this iterable that
|
|
397
|
+
* isn't covered by any in-scope task is added to `hiddenIds` so the caller
|
|
398
|
+
* doesn't render material-default for coverage gaps. Leave undefined when
|
|
399
|
+
* the caller already handles untasked products separately — the animator
|
|
400
|
+
* falls back to task-only hiding.
|
|
401
|
+
*/
|
|
402
|
+
allProductIds?: Iterable<number>,
|
|
403
|
+
): AnimationFrame {
|
|
404
|
+
const colorOverrides = new Map<number, RGBA>();
|
|
405
|
+
const hiddenIds = new Set<number>();
|
|
406
|
+
const stats: Record<LifecyclePhase, number> = {
|
|
407
|
+
'upcoming-far': 0,
|
|
408
|
+
'upcoming-preparation': 0,
|
|
409
|
+
'active-ramp-in': 0,
|
|
410
|
+
'active': 0,
|
|
411
|
+
'active-settling': 0,
|
|
412
|
+
'complete': 0,
|
|
413
|
+
'removal-active': 0,
|
|
414
|
+
'removal-complete': 0,
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
if (!data || data.tasks.length === 0) {
|
|
418
|
+
return { colorOverrides, hiddenIds, stats };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Phase priority used to pick the "winning" task for a product. */
|
|
422
|
+
const phasePriority: Record<LifecyclePhase, number> = {
|
|
423
|
+
'removal-active': 90,
|
|
424
|
+
'removal-complete': 80,
|
|
425
|
+
'active-ramp-in': 70,
|
|
426
|
+
'active-settling': 65,
|
|
427
|
+
'active': 60,
|
|
428
|
+
'upcoming-preparation': 40,
|
|
429
|
+
'upcoming-far': 20,
|
|
430
|
+
'complete': 10,
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
// Two-layer separation: *timing* (hiddenIds) is always emitted so
|
|
434
|
+
// minimal-mode still removes demolished products and hides upcoming
|
|
435
|
+
// ones. *Colour overlays* only emit when at least one colour-overlay
|
|
436
|
+
// feature is enabled — the "minimal" preset in the popover simply
|
|
437
|
+
// leaves all three flags off.
|
|
438
|
+
const emitColours =
|
|
439
|
+
settings.colorizeByTaskType
|
|
440
|
+
|| settings.showPreparationGhost
|
|
441
|
+
|| settings.showCompletedTint;
|
|
442
|
+
|
|
443
|
+
/** Per-product chosen phase so we resolve multi-task conflicts. */
|
|
444
|
+
const chosenByProduct = new Map<number, PhaseResult>();
|
|
445
|
+
|
|
446
|
+
for (const task of data.tasks) {
|
|
447
|
+
if (scheduleGlobalId
|
|
448
|
+
&& task.controllingScheduleGlobalIds.length > 0
|
|
449
|
+
&& !task.controllingScheduleGlobalIds.includes(scheduleGlobalId)) {
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
if (task.productExpressIds.length === 0) continue;
|
|
453
|
+
const phase = computeTaskPhase(task, playbackTime, settings);
|
|
454
|
+
if (!phase) continue;
|
|
455
|
+
|
|
456
|
+
for (const id of task.productExpressIds) {
|
|
457
|
+
const existing = chosenByProduct.get(id);
|
|
458
|
+
if (!existing || phasePriority[phase.phase] > phasePriority[existing.phase]) {
|
|
459
|
+
chosenByProduct.set(id, phase);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
for (const [id, result] of chosenByProduct) {
|
|
465
|
+
stats[result.phase] += 1;
|
|
466
|
+
if (result.hide) {
|
|
467
|
+
hiddenIds.add(id);
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
if (emitColours && result.color) {
|
|
471
|
+
colorOverrides.set(id, result.color);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Untasked-product hide: anything in the model universe that no in-scope
|
|
476
|
+
// task touched is treated as "not yet built" during animation. Without
|
|
477
|
+
// this, partial schedule coverage leaves the unscheduled portion of the
|
|
478
|
+
// model rendering as material default — which for bare concrete IFC reads
|
|
479
|
+
// as pure white and dominates the viewport. `chosenByProduct` is our
|
|
480
|
+
// tasked-set for the current filter; everything else gets hidden.
|
|
481
|
+
if (settings.hideUntaskedProducts && allProductIds) {
|
|
482
|
+
for (const id of allProductIds) {
|
|
483
|
+
if (!chosenByProduct.has(id)) hiddenIds.add(id);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return { colorOverrides, hiddenIds, stats };
|
|
488
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
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/strict';
|
|
7
|
+
import type { ScheduleExtraction, ScheduleTaskInfo } from '@ifc-lite/parser';
|
|
8
|
+
import {
|
|
9
|
+
collectProductLocalIdsForTasks,
|
|
10
|
+
findTaskForProductGlobalId,
|
|
11
|
+
findTaskForProductGlobalIdWithLocal,
|
|
12
|
+
} from './schedule-selection.js';
|
|
13
|
+
|
|
14
|
+
function task(over: Partial<ScheduleTaskInfo>): ScheduleTaskInfo {
|
|
15
|
+
return {
|
|
16
|
+
expressId: 0,
|
|
17
|
+
globalId: 'T',
|
|
18
|
+
name: 'Task',
|
|
19
|
+
isMilestone: false,
|
|
20
|
+
childGlobalIds: [],
|
|
21
|
+
productExpressIds: [],
|
|
22
|
+
productGlobalIds: [],
|
|
23
|
+
controllingScheduleGlobalIds: [],
|
|
24
|
+
...over,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function schedule(tasks: ScheduleTaskInfo[]): ScheduleExtraction {
|
|
29
|
+
return { hasSchedule: true, workSchedules: [], sequences: [], tasks };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('collectProductLocalIdsForTasks', () => {
|
|
33
|
+
it('returns empty set for null data', () => {
|
|
34
|
+
const out = collectProductLocalIdsForTasks(null, ['x']);
|
|
35
|
+
assert.equal(out.size, 0);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('returns the task own products for a leaf selection', () => {
|
|
39
|
+
const data = schedule([task({ globalId: 'leaf', productExpressIds: [1, 2, 3] })]);
|
|
40
|
+
const out = collectProductLocalIdsForTasks(data, ['leaf']);
|
|
41
|
+
assert.deepEqual(Array.from(out).sort((a, b) => a - b), [1, 2, 3]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('unions descendant products when a parent is selected', () => {
|
|
45
|
+
const data = schedule([
|
|
46
|
+
task({ globalId: 'parent', childGlobalIds: ['childA', 'childB'], productExpressIds: [10] }),
|
|
47
|
+
task({ globalId: 'childA', productExpressIds: [20, 21] }),
|
|
48
|
+
task({ globalId: 'childB', productExpressIds: [30], childGlobalIds: ['leaf'] }),
|
|
49
|
+
task({ globalId: 'leaf', productExpressIds: [40] }),
|
|
50
|
+
]);
|
|
51
|
+
const out = collectProductLocalIdsForTasks(data, ['parent']);
|
|
52
|
+
assert.deepEqual(Array.from(out).sort((a, b) => a - b), [10, 20, 21, 30, 40]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('is idempotent when multiple selected tasks overlap via children', () => {
|
|
56
|
+
const data = schedule([
|
|
57
|
+
task({ globalId: 'A', childGlobalIds: ['shared'], productExpressIds: [1] }),
|
|
58
|
+
task({ globalId: 'B', childGlobalIds: ['shared'], productExpressIds: [2] }),
|
|
59
|
+
task({ globalId: 'shared', productExpressIds: [99] }),
|
|
60
|
+
]);
|
|
61
|
+
const out = collectProductLocalIdsForTasks(data, ['A', 'B']);
|
|
62
|
+
assert.deepEqual(Array.from(out).sort((a, b) => a - b), [1, 2, 99]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('defends against cyclic childGlobalIds', () => {
|
|
66
|
+
const data = schedule([
|
|
67
|
+
task({ globalId: 'A', childGlobalIds: ['B'], productExpressIds: [1] }),
|
|
68
|
+
task({ globalId: 'B', childGlobalIds: ['A'], productExpressIds: [2] }),
|
|
69
|
+
]);
|
|
70
|
+
const out = collectProductLocalIdsForTasks(data, ['A']);
|
|
71
|
+
// Cycle must not hang and must visit each task exactly once.
|
|
72
|
+
assert.deepEqual(Array.from(out).sort((a, b) => a - b), [1, 2]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('skips unknown globalIds without throwing', () => {
|
|
76
|
+
const data = schedule([task({ globalId: 'A', productExpressIds: [1] })]);
|
|
77
|
+
const out = collectProductLocalIdsForTasks(data, ['A', 'does-not-exist']);
|
|
78
|
+
assert.deepEqual(Array.from(out), [1]);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('findTaskForProductGlobalId', () => {
|
|
83
|
+
const data = schedule([
|
|
84
|
+
task({ globalId: 'root', childGlobalIds: ['parent'] }),
|
|
85
|
+
task({
|
|
86
|
+
globalId: 'parent',
|
|
87
|
+
parentGlobalId: 'root',
|
|
88
|
+
childGlobalIds: ['hit', 'other'],
|
|
89
|
+
}),
|
|
90
|
+
task({
|
|
91
|
+
globalId: 'hit',
|
|
92
|
+
parentGlobalId: 'parent',
|
|
93
|
+
productExpressIds: [42],
|
|
94
|
+
productGlobalIds: ['42', '43'],
|
|
95
|
+
}),
|
|
96
|
+
task({
|
|
97
|
+
globalId: 'other',
|
|
98
|
+
parentGlobalId: 'parent',
|
|
99
|
+
productExpressIds: [99],
|
|
100
|
+
productGlobalIds: ['99'],
|
|
101
|
+
}),
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
it('returns the owning task and its ancestor chain (fast path via productGlobalIds)', () => {
|
|
105
|
+
const result = findTaskForProductGlobalId(data, 42);
|
|
106
|
+
assert.ok(result);
|
|
107
|
+
assert.equal(result.taskGlobalId, 'hit');
|
|
108
|
+
assert.deepEqual(result.ancestorGlobalIds, ['root', 'parent']);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('returns null when no task owns the product', () => {
|
|
112
|
+
const result = findTaskForProductGlobalId(data, 777);
|
|
113
|
+
assert.equal(result, null);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('returns null for null schedule data', () => {
|
|
117
|
+
const result = findTaskForProductGlobalId(null, 42);
|
|
118
|
+
assert.equal(result, null);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('findTaskForProductGlobalIdWithLocal', () => {
|
|
123
|
+
// Extracted (non-generated) schedules have empty productGlobalIds — we
|
|
124
|
+
// must be able to translate a global → local and still find the owner.
|
|
125
|
+
const data = schedule([
|
|
126
|
+
task({ globalId: 'only', productExpressIds: [42], productGlobalIds: [] }),
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
it('falls back to the local-id scan when productGlobalIds is empty', () => {
|
|
130
|
+
const result = findTaskForProductGlobalIdWithLocal(
|
|
131
|
+
data,
|
|
132
|
+
1042, // global
|
|
133
|
+
(g) => g - 1000, // idOffset = 1000
|
|
134
|
+
);
|
|
135
|
+
assert.ok(result);
|
|
136
|
+
assert.equal(result.taskGlobalId, 'only');
|
|
137
|
+
assert.deepEqual(result.ancestorGlobalIds, []);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('returns null when the local translator yields nothing or the scan misses', () => {
|
|
141
|
+
const result = findTaskForProductGlobalIdWithLocal(
|
|
142
|
+
data,
|
|
143
|
+
9999,
|
|
144
|
+
() => undefined,
|
|
145
|
+
);
|
|
146
|
+
assert.equal(result, null);
|
|
147
|
+
});
|
|
148
|
+
});
|