/* 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 { createSectionSlice, customPlaneCenter, loadLastSectionMode, type SectionSlice, } from './sectionSlice.js'; import { SECTION_PLANE_DEFAULTS } from '../constants.js'; import type { CustomSectionPlane } from '../types.js'; // ─── Test helpers ────────────────────────────────────────────────────── // Replaces the original `dot` + literal-tuple comparison style with two // shared helpers that make geometric assertions both stricter and less // brittle: // • `assertVecClose` — epsilon compare, ignores signed-zero noise // (`Math.abs(0 - (-0)) === 0`) so tests don't couple to flipped // normal sign quirks (CR feedback PR #650). // • `assertOrthonormalBasis` — checks both orthogonality AND unit // length of tangent + bitangent. The previous "orthonormal" // assertion only checked dot products, so a basis with non-unit // tangent/bitangent would pass silently (CR feedback PR #650). function assertVecClose(actual: ArrayLike, expected: ArrayLike, eps = 1e-9): void { assert.strictEqual(actual.length, expected.length, `length mismatch: ${actual.length} vs ${expected.length}`); for (let i = 0; i < expected.length; i++) { const diff = Math.abs(actual[i] - expected[i]); assert.ok(diff < eps, `axis ${i}: expected ${expected[i]}, got ${actual[i]} (|diff|=${diff})`); } } function assertOrthonormalBasis(t: number[], b: number[], n: number[]): void { const dot = (a: number[], c: number[]) => a[0] * c[0] + a[1] * c[1] + a[2] * c[2]; const tLen = Math.hypot(t[0], t[1], t[2]); const bLen = Math.hypot(b[0], b[1], b[2]); assert.ok(Math.abs(tLen - 1) < 1e-9, `tangent must be unit length (got ${tLen})`); assert.ok(Math.abs(bLen - 1) < 1e-9, `bitangent must be unit length (got ${bLen})`); assert.ok(Math.abs(dot(t, n)) < 1e-9, `tangent must be perpendicular to normal (dot=${dot(t, n)})`); assert.ok(Math.abs(dot(b, n)) < 1e-9, `bitangent must be perpendicular to normal (dot=${dot(b, n)})`); assert.ok(Math.abs(dot(t, b)) < 1e-9, `tangent must be perpendicular to bitangent (dot=${dot(t, b)})`); } // In-memory localStorage shim for tests that exercise the slice's // persistence helpers (last-used section mode, issue #243 follow-up). // node:test runs without a DOM, so the slice's `typeof window === // 'undefined'` guards short-circuit by default. We install a real // `window.localStorage` for the duration of the persistence tests so // save/load actually round-trips through code rather than no-opping. class MemoryStorage { private store = new Map(); get length(): number { return this.store.size; } clear(): void { this.store.clear(); } getItem(key: string): string | null { return this.store.has(key) ? this.store.get(key)! : null; } setItem(key: string, value: string): void { this.store.set(key, String(value)); } removeItem(key: string): void { this.store.delete(key); } key(i: number): string | null { return Array.from(this.store.keys())[i] ?? null; } } function installWindowShim(): { uninstall: () => void; storage: MemoryStorage } { const storage = new MemoryStorage(); const g = globalThis as unknown as { window?: unknown }; const had = 'window' in g; const prev = g.window; g.window = { localStorage: storage } as unknown; return { storage, uninstall: () => { if (had) g.window = prev; else delete g.window; }, }; } describe('SectionSlice', () => { let state: SectionSlice; let setState: (partial: Partial | ((state: SectionSlice) => Partial)) => void; beforeEach(() => { // Create a mock set function that updates state setState = (partial) => { if (typeof partial === 'function') { const updates = partial(state); state = { ...state, ...updates }; } else { state = { ...state, ...partial }; } }; // Create slice with mock set function state = createSectionSlice(setState, () => state, {} as any); }); describe('initial state', () => { it('should have default section plane values', () => { assert.strictEqual(state.sectionPlane.axis, SECTION_PLANE_DEFAULTS.AXIS); assert.strictEqual(state.sectionPlane.position, SECTION_PLANE_DEFAULTS.POSITION); assert.strictEqual(state.sectionPlane.enabled, SECTION_PLANE_DEFAULTS.ENABLED); assert.strictEqual(state.sectionPlane.flipped, SECTION_PLANE_DEFAULTS.FLIPPED); }); }); describe('setSectionPlaneAxis', () => { it('should update the axis', () => { state.setSectionPlaneAxis('front'); assert.strictEqual(state.sectionPlane.axis, 'front'); }); it('should preserve other section plane properties', () => { state.sectionPlane.position = 75; state.setSectionPlaneAxis('side'); assert.strictEqual(state.sectionPlane.axis, 'side'); assert.strictEqual(state.sectionPlane.position, 75); }); it('should auto-enable the clip so the axis change is immediately visible', () => { // Simulate a user who disabled clipping, then picks a new axis — they // almost certainly want to see the new cut, not stay in "Clip off". state.sectionPlane.enabled = false; state.setSectionPlaneAxis('front'); assert.strictEqual(state.sectionPlane.enabled, true); }); }); describe('setSectionPlanePosition', () => { it('should update the position', () => { state.setSectionPlanePosition(75); assert.strictEqual(state.sectionPlane.position, 75); }); it('should clamp position to minimum 0', () => { state.setSectionPlanePosition(-10); assert.strictEqual(state.sectionPlane.position, 0); }); it('should clamp position to maximum 100', () => { state.setSectionPlanePosition(150); assert.strictEqual(state.sectionPlane.position, 100); }); it('should handle NaN by defaulting to 0', () => { state.setSectionPlanePosition(NaN); assert.strictEqual(state.sectionPlane.position, 0); }); it('should coerce string numbers', () => { state.setSectionPlanePosition('50' as any); assert.strictEqual(state.sectionPlane.position, 50); }); it('should auto-enable the clip when the slider moves', () => { // This is the fix for the "it jitters, doesn't cut" user report: moving // the slider implicitly turns on clipping so the user doesn't have to // hunt for the toggle. state.sectionPlane.enabled = false; state.setSectionPlanePosition(42); assert.strictEqual(state.sectionPlane.enabled, true); assert.strictEqual(state.sectionPlane.position, 42); }); }); describe('setSectionPlaneEnabled', () => { it('should set enabled to true explicitly', () => { state.sectionPlane.enabled = false; state.setSectionPlaneEnabled(true); assert.strictEqual(state.sectionPlane.enabled, true); }); it('should set enabled to false explicitly', () => { state.setSectionPlaneEnabled(false); assert.strictEqual(state.sectionPlane.enabled, false); }); }); describe('setSectionShowCap', () => { it('should toggle the showCap flag without touching clipping', () => { // Explicitly enable clipping first so we can assert "cap toggle // didn't disable it". Default `enabled` is now `false` (issue // #243 follow-up: opening the section tool starts uncut). state.setSectionPlaneEnabled(true); assert.strictEqual(state.sectionPlane.showCap, true); state.setSectionShowCap(false); assert.strictEqual(state.sectionPlane.showCap, false); // Clipping unchanged — cap is a visual-only add-on. assert.strictEqual(state.sectionPlane.enabled, true); }); }); describe('setSectionShowOutlines', () => { it('should toggle the showOutlines flag independently of showCap and clipping', () => { state.setSectionPlaneEnabled(true); assert.strictEqual(state.sectionPlane.showOutlines, true); state.setSectionShowOutlines(false); assert.strictEqual(state.sectionPlane.showOutlines, false); assert.strictEqual(state.sectionPlane.showCap, true); assert.strictEqual(state.sectionPlane.enabled, true); }); it('should set showOutlines back to true', () => { state.setSectionShowOutlines(false); state.setSectionShowOutlines(true); assert.strictEqual(state.sectionPlane.showOutlines, true); }); }); describe('setSectionCapStyle', () => { it('should partially update the cap style without clobbering other fields', () => { const before = state.sectionPlane.capStyle; state.setSectionCapStyle({ pattern: 'concrete' }); assert.strictEqual(state.sectionPlane.capStyle.pattern, 'concrete'); assert.strictEqual(state.sectionPlane.capStyle.spacingPx, before.spacingPx); assert.strictEqual(state.sectionPlane.capStyle.angleRad, before.angleRad); }); it('should accept custom fill and stroke colours', () => { state.setSectionCapStyle({ fillColor: [0.2, 0.3, 0.4, 1.0], strokeColor: [0.9, 0.1, 0.1, 1.0], }); assert.deepStrictEqual(state.sectionPlane.capStyle.fillColor, [0.2, 0.3, 0.4, 1.0]); assert.deepStrictEqual(state.sectionPlane.capStyle.strokeColor, [0.9, 0.1, 0.1, 1.0]); }); }); describe('toggleSectionPlane', () => { it('should toggle enabled from true to false', () => { // Default is now `false` (issue #243 follow-up). Set explicitly // so this test exercises the true → false transition regardless // of the default. state.setSectionPlaneEnabled(true); assert.strictEqual(state.sectionPlane.enabled, true); state.toggleSectionPlane(); assert.strictEqual(state.sectionPlane.enabled, false); }); it('should toggle enabled from false to true', () => { state.sectionPlane.enabled = false; state.toggleSectionPlane(); assert.strictEqual(state.sectionPlane.enabled, true); }); }); describe('flipSectionPlane', () => { it('should toggle flipped from false to true', () => { assert.strictEqual(state.sectionPlane.flipped, false); state.flipSectionPlane(); assert.strictEqual(state.sectionPlane.flipped, true); }); it('should toggle flipped from true to false', () => { state.sectionPlane.flipped = true; state.flipSectionPlane(); assert.strictEqual(state.sectionPlane.flipped, false); }); }); describe('face-pick (custom plane)', () => { it('setSectionPlaneFromFace stores a unit-length normal + signed distance', () => { // Non-unit input: the slice should renormalise before persisting. state.setSectionPlaneFromFace([2, 0, 0], [3, 4, 5]); const c = state.sectionPlane.custom; assert.ok(c, 'custom plane should be set'); // Use the epsilon helper so signed-zero noise from renormalisation // (e.g. `0 / 2 === 0` vs `-0 / 2 === -0`) doesn't cause spurious // failures (CR feedback PR #650). assertVecClose(c!.normal, [1, 0, 0]); assert.strictEqual(c!.distance, 3); // dot([3,4,5], [1,0,0]) assert.deepStrictEqual(c!.pickedAt, [3, 4, 5]); assert.strictEqual(state.sectionPlane.enabled, true); assert.strictEqual(state.sectionPickMode, false); }); it('setSectionPlaneFromFace updates axis + flipped to the signed-dominant cardinal', () => { // CR P1 from #581: dropping the sign produced inverted exports. state.setSectionPlaneFromFace([-1, 0, 0], [0, 0, 0]); assert.strictEqual(state.sectionPlane.axis, 'side'); assert.strictEqual(state.sectionPlane.flipped, true); state.setSectionPlaneFromFace([0, 0, 1], [0, 0, 0]); assert.strictEqual(state.sectionPlane.axis, 'front'); assert.strictEqual(state.sectionPlane.flipped, false); }); it('setSectionPlaneFromFace updates position % when bounds are supplied', () => { // CR P2 from #581: leaving position stale produced wrong fallback cuts. state.setSectionPlaneFromFace( [0, 1, 0], [0, 5, 0], { min: [0, 0, 0], max: [10, 10, 10] }, ); assert.strictEqual(state.sectionPlane.position, 50); }); it('setSectionPlaneFromFace stores an orthonormal tangent + bitangent', () => { state.setSectionPlaneFromFace([0, 0, 1], [0, 0, 0]); const c = state.sectionPlane.custom!; assertOrthonormalBasis([...c.tangent], [...c.bitangent], [...c.normal]); }); it('setSectionPlaneFromFace ignores a degenerate (zero-length) normal', () => { state.setSectionPickMode(true); state.setSectionPlaneFromFace([0, 0, 0], [1, 2, 3]); assert.strictEqual(state.sectionPlane.custom, undefined); assert.strictEqual(state.sectionPickMode, false); }); it('setSectionPlaneAxis clears any custom plane', () => { state.setSectionPlaneFromFace([1, 0, 0], [5, 0, 0]); assert.ok(state.sectionPlane.custom); state.setSectionPlaneAxis('down'); assert.strictEqual(state.sectionPlane.custom, undefined); assert.strictEqual(state.sectionPlane.axis, 'down'); }); it('flipSectionPlane toggles `flipped` without mutating custom geometry', () => { // The renderer applies `flipped` independently in the clip shader // (`side = flipped ? -1 : 1`). Mutating `normal` / `distance` here // as well would double-cancel and the flip button would have no // visible effect — see flipSectionPlane in the slice. state.setSectionPlaneFromFace([0, 0, 1], [0, 0, 5]); const before = state.sectionPlane.custom!; assert.strictEqual(state.sectionPlane.flipped, false); assert.strictEqual(before.distance, 5); state.flipSectionPlane(); const after = state.sectionPlane.custom!; assert.strictEqual(state.sectionPlane.flipped, true); // Geometry is untouched — only the `flipped` boolean changes. assert.deepStrictEqual(after.normal, before.normal); assert.strictEqual( after.distance, before.distance); assert.deepStrictEqual(after.pickedAt, before.pickedAt); assert.deepStrictEqual(after.tangent, before.tangent); assert.deepStrictEqual(after.bitangent, before.bitangent); }); it('flipSectionPlane is its own inverse — two flips return to the original state', () => { state.setSectionPlaneFromFace([0, 0, 1], [0, 0, 5]); const original = state.sectionPlane.custom!; const originalFlipped = state.sectionPlane.flipped; state.flipSectionPlane(); state.flipSectionPlane(); const after = state.sectionPlane.custom!; assert.strictEqual(state.sectionPlane.flipped, originalFlipped); // Geometry must never have been mutated through the round-trip. assert.deepStrictEqual(after.normal, original.normal); assert.strictEqual( after.distance, original.distance); assert.deepStrictEqual(after.pickedAt, original.pickedAt); assert.deepStrictEqual(after.tangent, original.tangent); assert.deepStrictEqual(after.bitangent, original.bitangent); }); it('flipSectionPlane toggles `flipped` for cardinal planes too (no custom)', () => { assert.strictEqual(state.sectionPlane.custom, undefined); assert.strictEqual(state.sectionPlane.flipped, false); state.flipSectionPlane(); assert.strictEqual(state.sectionPlane.flipped, true); assert.strictEqual(state.sectionPlane.custom, undefined); state.flipSectionPlane(); assert.strictEqual(state.sectionPlane.flipped, false); }); it('setSectionCustomDistance updates distance without touching anything else', () => { state.setSectionPlaneFromFace([0, 1, 0], [0, 3, 0]); const before = state.sectionPlane.custom!; state.setSectionCustomDistance(7); const after = state.sectionPlane.custom!; assert.strictEqual(after.distance, 7); assert.deepStrictEqual(after.normal, before.normal); assert.deepStrictEqual(after.pickedAt, before.pickedAt); assert.deepStrictEqual(after.tangent, before.tangent); }); it('setSectionCustomDistance is a no-op without a custom plane', () => { assert.strictEqual(state.sectionPlane.custom, undefined); state.setSectionCustomDistance(42); assert.strictEqual(state.sectionPlane.custom, undefined); }); it('setSectionPickMode arms / disarms pick mode', () => { assert.strictEqual(state.sectionPickMode, false); state.setSectionPickMode(true); assert.strictEqual(state.sectionPickMode, true); state.setSectionPickMode(false); assert.strictEqual(state.sectionPickMode, false); }); it('setSectionPickPreview stores the preview only while pick mode is armed', () => { // Mode OFF: a stray late-fired hover event must not put the // overlay back on screen with no way to commit it. assert.strictEqual(state.sectionPickMode, false); state.setSectionPickPreview({ normal: [0, 1, 0], point: [1, 2, 3], faceKey: 'mode-off', }); assert.strictEqual(state.sectionPickPreview, null); // Mode ON: preview is accepted. state.setSectionPickMode(true); const p: import('./sectionSlice.js').SectionPickPreview = { normal: [0, 1, 0], point: [4, 5, 6], faceKey: 'mode-on', }; state.setSectionPickPreview(p); assert.deepStrictEqual(state.sectionPickPreview, p); // Explicit clear is always allowed (the hover handler uses // `null` to hide the overlay even after disarm — the inverse // case of the guard above). state.setSectionPickPreview(null); assert.strictEqual(state.sectionPickPreview, null); }); it('setSectionPickMode(false) clears any active preview', () => { state.setSectionPickMode(true); state.setSectionPickPreview({ normal: [1, 0, 0], point: [0, 0, 0], faceKey: 'fk', }); assert.ok(state.sectionPickPreview); state.setSectionPickMode(false); assert.strictEqual(state.sectionPickPreview, null); assert.strictEqual(state.sectionPickMode, false); }); it('setSectionPlaneFromFace clears the preview on commit', () => { // Visually continuous handoff — the preview disappears the same // frame the cap appears so we don't double-paint the face. state.setSectionPickMode(true); state.setSectionPickPreview({ normal: [0, 0, 1], point: [0, 0, 5], faceKey: 'fk', }); state.setSectionPlaneFromFace([0, 0, 1], [0, 0, 5]); assert.strictEqual(state.sectionPickPreview, null); }); it('setSectionPlaneFromFace clears the preview even on a degenerate normal', () => { state.setSectionPickMode(true); state.setSectionPickPreview({ normal: [0, 0, 1], point: [0, 0, 5], faceKey: 'fk', }); state.setSectionPlaneFromFace([0, 0, 0], [1, 2, 3]); assert.strictEqual(state.sectionPickPreview, null); assert.strictEqual(state.sectionPickMode, false); }); it('resetSectionPlane clears the preview', () => { state.setSectionPickMode(true); state.setSectionPickPreview({ normal: [0, 1, 0], point: [0, 0, 0], faceKey: 'fk', }); state.resetSectionPlane(); assert.strictEqual(state.sectionPickPreview, null); }); it('resetSectionPlane clears the custom plane and disarms pick mode', () => { state.setSectionPlaneFromFace([1, 0, 0], [5, 0, 0]); state.setSectionPickMode(true); state.resetSectionPlane(); assert.strictEqual(state.sectionPlane.custom, undefined); assert.strictEqual(state.sectionPickMode, false); }); }); // Mirrors the auto-arm useEffect in `SectionPanel.tsx` that flips // `sectionPickMode` on after a 200ms debounce when the panel mounts and // turns it off on unmount. We exercise the same lifecycle here at the // slice level because the viewer app has no React component test harness // (tests run via `node:test` + `tsx`); the component-side code path is a // single setTimeout + setSectionPickMode call so re-creating the timer // semantics here is sufficient regression coverage. describe('section panel auto-arm lifecycle', () => { it('mounting arms pick mode after the 200ms debounce', async () => { assert.strictEqual(state.sectionPickMode, false); // Mirror the panel's mount effect: schedule the arm, wait, observe. const t = setTimeout(() => state.setSectionPickMode(true), 200); try { // Still false before the debounce fires — guards against the // tool-open click bleeding through into the canvas pick handler. assert.strictEqual(state.sectionPickMode, false); await new Promise((resolve) => setTimeout(resolve, 220)); assert.strictEqual(state.sectionPickMode, true); } finally { clearTimeout(t); } }); it('unmounting before the debounce fires never arms pick mode', async () => { assert.strictEqual(state.sectionPickMode, false); const t = setTimeout(() => state.setSectionPickMode(true), 200); // Immediate unmount: cancel the pending arm + disarm explicitly, // matching the cleanup function in the panel's useEffect. clearTimeout(t); state.setSectionPickMode(false); // Wait past the original debounce window — pick mode must stay off // because the timer was cancelled. await new Promise((resolve) => setTimeout(resolve, 220)); assert.strictEqual(state.sectionPickMode, false); }); it('unmounting after auto-arm disarms pick mode and clears any preview', () => { // Simulate "panel was mounted long enough for the debounce to land, // user closed the tool". The cleanup must drop pick mode and any // hover preview so the next tool doesn't inherit the violet quad. state.setSectionPickMode(true); state.setSectionPickPreview({ normal: [0, 1, 0], point: [0, 0, 0], faceKey: 'unmount-preview', }); assert.strictEqual(state.sectionPickMode, true); assert.ok(state.sectionPickPreview); // Cleanup body in the panel's useEffect. state.setSectionPickMode(false); assert.strictEqual(state.sectionPickMode, false); assert.strictEqual(state.sectionPickPreview, null); }); it('clicking a cardinal axis while armed still works and clears any custom plane', () => { // Regression guard for the demoted cardinal-axis row: even though // the buttons are visually secondary now, clicking one must commit // the cardinal cut and clear any face-picked custom plane (the // existing behaviour the panel relied on). state.setSectionPlaneFromFace([1, 0, 0], [5, 0, 0]); assert.ok(state.sectionPlane.custom); state.setSectionPickMode(true); state.setSectionPlaneAxis('front'); assert.strictEqual(state.sectionPlane.custom, undefined); assert.strictEqual(state.sectionPlane.axis, 'front'); assert.strictEqual(state.sectionPlane.enabled, true); }); }); describe('customPlaneCenter', () => { // Bug guard for the cap polygons + 3D drag gizmo "anchored at original // pick" regression: as `distance` drifts (drag/slider) the visual // center of the plane must slide along the normal, not stay glued to // the original pickedAt — otherwise the cap and gizmo render at the // pick location while the geometry clip moves to the new distance. it('returns pickedAt unchanged when distance == dot(pickedAt, normal)', () => { const plane: CustomSectionPlane = { normal: [1, 0, 0], distance: 10, pickedAt: [10, 0, 0], tangent: [0, 1, 0], bitangent: [0, 0, 1], }; const center = customPlaneCenter(plane); assert.deepStrictEqual(center, [10, 0, 0]); }); it('slides along the normal as distance changes (axis-aligned)', () => { const base: CustomSectionPlane = { normal: [1, 0, 0], distance: 25, pickedAt: [10, 0, 0], tangent: [0, 1, 0], bitangent: [0, 0, 1], }; assert.deepStrictEqual(customPlaneCenter(base), [25, 0, 0]); const zeroed: CustomSectionPlane = { ...base, distance: 0 }; assert.deepStrictEqual(customPlaneCenter(zeroed), [0, 0, 0]); }); it('produces a point that satisfies dot(center, normal) == distance for an arbitrary normal', () => { const inv = 1 / Math.sqrt(3); const plane: CustomSectionPlane = { normal: [inv, inv, inv], distance: 4.2, pickedAt: [1, 2, 3], tangent: [1, 0, 0], // unused by the projection bitangent: [0, 1, 0], }; const c = customPlaneCenter(plane); const dot = c[0] * plane.normal[0] + c[1] * plane.normal[1] + c[2] * plane.normal[2]; assert.ok(Math.abs(dot - plane.distance) < 1e-9, `dot(center, normal) = ${dot}, want ${plane.distance}`); }); it('preserves the lateral (in-plane) offset of pickedAt — center is the perpendicular projection', () => { // Slide pickedAt along the normal only — the projection should land // exactly on the plane and keep the orthogonal components intact. const plane: CustomSectionPlane = { normal: [0, 1, 0], distance: 5, pickedAt: [7, 9, 4], // off-plane by (9 − 5) = 4 along +Y tangent: [1, 0, 0], bitangent: [0, 0, 1], }; const c = customPlaneCenter(plane); // X and Z (in-plane) preserved; Y projected to the plane. assert.deepStrictEqual(c, [7, 5, 4]); }); }); describe('resetSectionPlane', () => { it('should reset to default values', () => { state.setSectionPlaneAxis('side'); state.setSectionPlanePosition(25); state.setSectionPlaneEnabled(false); state.flipSectionPlane(); state.setSectionShowCap(false); state.setSectionShowOutlines(false); state.setSectionCapStyle({ pattern: 'brick' }); state.resetSectionPlane(); assert.strictEqual(state.sectionPlane.axis, SECTION_PLANE_DEFAULTS.AXIS); assert.strictEqual(state.sectionPlane.position, SECTION_PLANE_DEFAULTS.POSITION); assert.strictEqual(state.sectionPlane.enabled, SECTION_PLANE_DEFAULTS.ENABLED); assert.strictEqual(state.sectionPlane.flipped, SECTION_PLANE_DEFAULTS.FLIPPED); assert.strictEqual(state.sectionPlane.showCap, SECTION_PLANE_DEFAULTS.SHOW_CAP); assert.strictEqual(state.sectionPlane.showOutlines, SECTION_PLANE_DEFAULTS.SHOW_OUTLINES); // Default cap pattern restored. assert.strictEqual(state.sectionPlane.capStyle.pattern, 'diagonal'); }); }); }); // Last-used section mode persistence (issue #243 follow-up). // // These tests run in their own top-level describe with an installed // `window.localStorage` shim because the slice's persistence helpers // short-circuit when `window` is undefined. Keeping the shim scoped to // this block (install in beforeEach, uninstall in afterEach) means the // rest of the suite still exercises the no-window code path. describe('SectionSlice — last-used mode persistence', () => { const SECTION_MODE_KEY = 'ifc-lite:section-last-mode'; let state: SectionSlice; let setState: (partial: Partial | ((state: SectionSlice) => Partial)) => void; let shim: ReturnType; beforeEach(() => { shim = installWindowShim(); setState = (partial) => { if (typeof partial === 'function') { const updates = partial(state); state = { ...state, ...updates }; } else { state = { ...state, ...partial }; } }; state = createSectionSlice(setState, () => state, {} as any); }); // No afterEach hook is registered (node:test exposes it differently // across versions). Each beforeEach reinstalls the shim, replacing // the previous global, so leakage between tests is bounded. describe('default `enabled` and storage state', () => { it('default enabled is `false` so opening the section tool starts uncut', () => { // Bug #1 from PR #650: `ENABLED: true` here meant a Down cut // appeared the moment the panel mounted, before the auto-arm // useEffect could install pick mode. assert.strictEqual(SECTION_PLANE_DEFAULTS.ENABLED, false); assert.strictEqual(state.sectionPlane.enabled, false); }); }); describe('loadLastSectionMode', () => { it('returns the default pick mode when storage is empty', () => { assert.deepStrictEqual(loadLastSectionMode(), { kind: 'pick' }); }); it('round-trips a stored pick entry', () => { shim.storage.setItem(SECTION_MODE_KEY, JSON.stringify({ kind: 'pick' })); assert.deepStrictEqual(loadLastSectionMode(), { kind: 'pick' }); }); it('round-trips a valid cardinal entry', () => { shim.storage.setItem(SECTION_MODE_KEY, JSON.stringify({ kind: 'cardinal', axis: 'side', position: 33.5, flipped: true, })); assert.deepStrictEqual(loadLastSectionMode(), { kind: 'cardinal', axis: 'side', position: 33.5, flipped: true, }); }); it('clamps cardinal `position` to [0, 100] on restore', () => { // Belt-and-braces: position is clamped at the slice level too, // but a tampered or stale value shouldn't poison the slider. shim.storage.setItem(SECTION_MODE_KEY, JSON.stringify({ kind: 'cardinal', axis: 'down', position: 9999, flipped: false, })); const m = loadLastSectionMode(); assert.strictEqual(m.kind, 'cardinal'); if (m.kind === 'cardinal') assert.strictEqual(m.position, 100); }); it('falls back to pick when JSON is corrupted', () => { shim.storage.setItem(SECTION_MODE_KEY, 'not-valid-json{'); assert.deepStrictEqual(loadLastSectionMode(), { kind: 'pick' }); }); it('falls back to pick on an unknown `kind`', () => { shim.storage.setItem(SECTION_MODE_KEY, JSON.stringify({ kind: 'martian' })); assert.deepStrictEqual(loadLastSectionMode(), { kind: 'pick' }); }); it('falls back to pick on a cardinal entry with a bad axis', () => { shim.storage.setItem(SECTION_MODE_KEY, JSON.stringify({ kind: 'cardinal', axis: 'sideways', position: 50, flipped: false, })); assert.deepStrictEqual(loadLastSectionMode(), { kind: 'pick' }); }); it('falls back to pick on a cardinal entry with a non-finite position', () => { shim.storage.setItem(SECTION_MODE_KEY, JSON.stringify({ kind: 'cardinal', axis: 'down', position: 'oops', flipped: false, })); assert.deepStrictEqual(loadLastSectionMode(), { kind: 'pick' }); }); }); describe('save side-effects on slice actions', () => { it('setSectionPlaneAxis writes a cardinal entry to localStorage', () => { state.setSectionPlaneAxis('front'); const raw = shim.storage.getItem(SECTION_MODE_KEY); assert.ok(raw, 'expected a stored entry'); assert.deepStrictEqual(JSON.parse(raw!), { kind: 'cardinal', axis: 'front', position: state.sectionPlane.position, flipped: state.sectionPlane.flipped, }); }); it('setSectionPlanePosition writes a cardinal entry (position carries through)', () => { state.setSectionPlanePosition(42.5); const raw = shim.storage.getItem(SECTION_MODE_KEY); assert.ok(raw); const parsed = JSON.parse(raw!); assert.strictEqual(parsed.kind, 'cardinal'); assert.strictEqual(parsed.position, 42.5); }); it('flipSectionPlane (cardinal mode) writes the new `flipped` to localStorage', () => { state.flipSectionPlane(); const raw = shim.storage.getItem(SECTION_MODE_KEY); assert.ok(raw); const parsed = JSON.parse(raw!); assert.strictEqual(parsed.kind, 'cardinal'); assert.strictEqual(parsed.flipped, true); }); it('setSectionPlanePosition does NOT write while in custom mode', () => { // Custom-mode position drives a model-relative distance which we // deliberately don't persist — re-arm pick mode on next open // instead so the user can re-cut on a different model. state.setSectionPlaneFromFace([1, 0, 0], [5, 0, 0]); // The face-pick wrote `{ kind: 'pick' }`; clear it so we can // observe whether the subsequent slider move overwrites it. shim.storage.removeItem(SECTION_MODE_KEY); state.setSectionPlanePosition(60); assert.strictEqual(shim.storage.getItem(SECTION_MODE_KEY), null); }); it('flipSectionPlane does NOT write while in custom mode', () => { state.setSectionPlaneFromFace([1, 0, 0], [5, 0, 0]); shim.storage.removeItem(SECTION_MODE_KEY); state.flipSectionPlane(); assert.strictEqual(shim.storage.getItem(SECTION_MODE_KEY), null); }); it('setSectionPlaneFromFace writes `{ kind: "pick" }` to localStorage', () => { state.setSectionPlaneFromFace([0, 0, 1], [0, 0, 5]); const raw = shim.storage.getItem(SECTION_MODE_KEY); assert.ok(raw); assert.deepStrictEqual(JSON.parse(raw!), { kind: 'pick' }); }); it('resetSectionPlane removes the storage key', () => { state.setSectionPlaneAxis('front'); assert.ok(shim.storage.getItem(SECTION_MODE_KEY)); state.resetSectionPlane(); assert.strictEqual(shim.storage.getItem(SECTION_MODE_KEY), null); }); }); });