Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
36f977f
fix(8f-followup): delete legacy variscout-wall-layout Dexie DB on init
jukka-matti May 13, 2026
21f063f
refactor(8f-followup): migrate canvas UI strings to typed message cat…
jukka-matti May 13, 2026
d0f2529
fix(8f-followup): migrate Canvas empty-state to message catalog
jukka-matti May 13, 2026
56bc889
test(8f-followup): cover CanvasLensPicker lens × level predicate
jukka-matti May 13, 2026
34e9ed6
docs(8f-followup): refresh stale wallLayoutStore references in store …
jukka-matti May 13, 2026
2570baa
docs(8f-followup): fix plan frontmatter category to allowed enum value
jukka-matti May 13, 2026
8101848
refactor(8f-followup): extract getStepColumnAssignments to @variscout…
jukka-matti May 13, 2026
bc8fd6a
fix(8f-followup): tie L1 specLimits to outcome's own measureSpecs entry
jukka-matti May 13, 2026
db3dd63
refactor(8f-followup): replace LocalMechanismView's focalStepColumns …
jukka-matti May 13, 2026
4a55ffb
docs(8f-followup): resolve lens × level matrix gap via spec amend
jukka-matti May 13, 2026
dfc331a
feat(8f-followup): replace setViewportLevel throw with warn + no-op (…
jukka-matti May 13, 2026
9f81362
refactor(8f-followup): co-locate level math constants in core/canvas/…
jukka-matti May 13, 2026
8b18580
feat(8f-followup): enforce 6px click-vs-drag deadband via clickDistan…
jukka-matti May 13, 2026
188cef1
chore(8f-followup): delete dead worldToWallSvg + document CanvasViewp…
jukka-matti May 13, 2026
3511d7f
feat(8f-followup): snap-to-LOD on wheel-stop via d3-zoom end handler …
jukka-matti May 13, 2026
07add8a
feat(8f-followup): real LOD cross-fade + d3-transition snap (4.1/4.2 …
jukka-matti May 13, 2026
39e1287
feat(8f-followup): per-Hub canvas viewport blob helpers in blobClient
jukka-matti May 13, 2026
2a359fa
feat(8f-followup): wire Azure canvas viewport lifecycle to Blob sync
jukka-matti May 13, 2026
a6fffdc
feat(8f-followup): expose 4 remaining response-path CTAs at L3 column…
jukka-matti May 13, 2026
7203338
feat(8f-followup): mobile L3 without focalStepId navigates to step-list
jukka-matti May 13, 2026
5de0631
perf(8f-followup): selector-scope canvasViewport subscribe in d3-zoom…
jukka-matti May 13, 2026
f016e30
chore(8f-followup): rename canvasViewport STORE_LAYER to annotation-p…
jukka-matti May 13, 2026
f6f9f9b
docs(8f-followup): mark 19 of 20 findings RESOLVED on followup branch
jukka-matti May 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
// vi.mock() MUST precede all imports (rules/testing.md).
vi.mock('@variscout/stores', async importOriginal => {
const actual = await importOriginal<typeof import('@variscout/stores')>();
return {
...actual,
persistCanvasViewport: vi.fn().mockResolvedValue(undefined),
rehydrateCanvasViewport: vi.fn().mockResolvedValue(undefined),
getLocalViewportUpdatedAt: vi.fn().mockResolvedValue(0),
};
});

vi.mock('../../../services/blobClient', () => ({
loadBlobCanvasViewport: vi.fn().mockResolvedValue(null),
saveBlobCanvasViewport: vi.fn().mockResolvedValue({ ok: true, etag: '"etag-v1"' }),
}));

vi.mock('../../../lib/appInsights', () => ({
safeTrackEvent: vi.fn(),
}));

import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
getCanvasViewportInitialState,
getLocalViewportUpdatedAt,
persistCanvasViewport,
rehydrateCanvasViewport,
useCanvasViewportStore,
} from '@variscout/stores';
import { loadBlobCanvasViewport, saveBlobCanvasViewport } from '../../../services/blobClient';
import type { LoadedViewport } from '../../../services/blobClient';
import { safeTrackEvent } from '../../../lib/appInsights';
import { useCanvasViewportLifecycle } from '../useCanvasViewportLifecycle';

const mockPersist = vi.mocked(persistCanvasViewport);
vi.mocked(rehydrateCanvasViewport); // mocked to no-op; called implicitly by lifecycle
const mockGetLocalUpdatedAt = vi.mocked(getLocalViewportUpdatedAt);
const mockLoadBlob = vi.mocked(loadBlobCanvasViewport);
const mockSaveBlob = vi.mocked(saveBlobCanvasViewport);
const mockTrackEvent = vi.mocked(safeTrackEvent);

const HUB_ID = 'hub-blob-test';

const BLOB_SNAPSHOT: LoadedViewport['snapshot'] = {
zoom: 2,
pan: { x: 50, y: -30 },
currentLevel: 'l1',
nodePositions: { 'step-1': { x: 100, y: 200 } },
groupByTributary: true,
updatedAt: 1700001000000,
};

beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
useCanvasViewportStore.setState(getCanvasViewportInitialState());

// Default: Blob returns null (no remote state).
mockLoadBlob.mockResolvedValue(null);
mockSaveBlob.mockResolvedValue({ ok: true, etag: '"etag-v1"' });
mockGetLocalUpdatedAt.mockResolvedValue(0);
});

afterEach(() => {
vi.useRealTimers();
});

describe('useCanvasViewportLifecycle — Blob sync (Azure)', () => {
// ── Round-trip: write → persist → fresh mount → recover from Blob ─────────

it('round-trip: applies Blob state to store when blob is newer than Dexie', async () => {
mockGetLocalUpdatedAt.mockResolvedValue(1699000000000); // older than blob
mockLoadBlob.mockResolvedValue({ snapshot: BLOB_SNAPSHOT, etag: '"etag-v2"' });

await act(async () => {
renderHook(() => useCanvasViewportLifecycle(HUB_ID));
// Flush promises so the async Blob load runs.
await Promise.resolve();
await Promise.resolve();
});

const vp = useCanvasViewportStore.getState().viewports[HUB_ID];
expect(vp).toBeDefined();
expect(vp?.zoom).toBe(2);
expect(vp?.pan).toEqual({ x: 50, y: -30 });
expect(vp?.currentLevel).toBe('l1');
expect(vp?.groupByTributary).toBe(true);

// Should write back to Dexie for offline use.
expect(mockPersist).toHaveBeenCalledWith(HUB_ID);
});

it('does NOT apply Blob state when Dexie is newer', async () => {
mockGetLocalUpdatedAt.mockResolvedValue(1800000000000); // newer than blob
mockLoadBlob.mockResolvedValue({ snapshot: BLOB_SNAPSHOT, etag: '"etag-v2"' });

await act(async () => {
renderHook(() => useCanvasViewportLifecycle(HUB_ID));
await Promise.resolve();
await Promise.resolve();
});

// Store should NOT have been updated with blob viewport.
const vp = useCanvasViewportStore.getState().viewports[HUB_ID];
expect(vp?.zoom).toBeUndefined(); // store is still at initial default

// Dexie write-back should NOT be triggered.
expect(mockPersist).not.toHaveBeenCalled();
});

// ── Multi-device: two instances read from same Blob ───────────────────────

it('multi-device: fresh mount picks up state written by another device', async () => {
const remoteViewport: LoadedViewport = {
snapshot: {
zoom: 3,
pan: { x: 10, y: 10 },
currentLevel: 'l2',
nodePositions: {},
groupByTributary: false,
updatedAt: 1750000000000,
},
etag: '"device-b-etag"',
};
mockGetLocalUpdatedAt.mockResolvedValue(0); // no local state
mockLoadBlob.mockResolvedValue(remoteViewport);

await act(async () => {
renderHook(() => useCanvasViewportLifecycle(HUB_ID));
await Promise.resolve();
await Promise.resolve();
});

const vp = useCanvasViewportStore.getState().viewports[HUB_ID];
expect(vp?.zoom).toBe(3);
expect(vp?.pan).toEqual({ x: 10, y: 10 });
});

// ── Mutation: debounced save to both Dexie and Blob ───────────────────────

it('debounced mutation writes to both Dexie and Blob after 500ms', async () => {
await act(async () => {
renderHook(() => useCanvasViewportLifecycle(HUB_ID));
await Promise.resolve();
await Promise.resolve();
});

mockPersist.mockClear();
mockSaveBlob.mockClear();

// Trigger a viewport change.
act(() => {
useCanvasViewportStore.getState().setZoom(HUB_ID, 4);
});

expect(mockPersist).not.toHaveBeenCalled();
expect(mockSaveBlob).not.toHaveBeenCalled();

await act(async () => {
vi.advanceTimersByTime(500);
await Promise.resolve();
});

expect(mockPersist).toHaveBeenCalledWith(HUB_ID);
expect(mockSaveBlob).toHaveBeenCalledOnce();

const [calledHubId, snapshot, priorEtag] = mockSaveBlob.mock.calls[0];
expect(calledHubId).toBe(HUB_ID);
expect(snapshot.zoom).toBe(4);
expect(typeof snapshot.updatedAt).toBe('number');
// On first write, priorEtag is null (no blob loaded yet).
expect(priorEtag).toBeNull();
});

// ── ETag conflict: precondition-failed → re-fetch, apply, update etagRef ─

it('ETag conflict: precondition-failed → telemetry logged, re-fetches blob', async () => {
// Blob initially returns null on mount.
mockLoadBlob.mockResolvedValue(null);

await act(async () => {
renderHook(() => useCanvasViewportLifecycle(HUB_ID));
await Promise.resolve();
await Promise.resolve();
});

mockPersist.mockClear();
mockLoadBlob.mockClear();
mockSaveBlob.mockClear();

// Simulate a conflict on the PUT.
mockSaveBlob.mockResolvedValueOnce({
ok: false,
reason: 'precondition-failed',
status: 412,
message: 'Precondition Failed',
});

// After conflict, loadBlobCanvasViewport is called again.
const conflictBlob: LoadedViewport = {
snapshot: {
zoom: 5,
pan: { x: 0, y: 0 },
currentLevel: 'l2',
nodePositions: {},
groupByTributary: false,
updatedAt: 1800000000000,
},
etag: '"etag-winner"',
};
mockLoadBlob.mockResolvedValueOnce(conflictBlob);

// Trigger a viewport change.
act(() => {
useCanvasViewportStore.getState().setZoom(HUB_ID, 1.5);
});

await act(async () => {
vi.advanceTimersByTime(500);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});

// Telemetry fired — structural only, no PII.
expect(mockTrackEvent).toHaveBeenCalledOnce();
const [eventName, props] = mockTrackEvent.mock.calls[0];
expect(eventName).toBe('canvas-viewport-sync-conflict');
expect(props).not.toHaveProperty('hubId', HUB_ID); // hubId must be redacted

// Re-fetch was called.
expect(mockLoadBlob).toHaveBeenCalledOnce();

// Store should reflect winning blob state.
const vp = useCanvasViewportStore.getState().viewports[HUB_ID];
expect(vp?.zoom).toBe(5);
});

// ── No blob write when viewport absent from store ─────────────────────────

it('skips blob write when hub viewport not in store', async () => {
await act(async () => {
renderHook(() => useCanvasViewportLifecycle(HUB_ID));
await Promise.resolve();
await Promise.resolve();
});

mockSaveBlob.mockClear();

// Change viewMode — no viewport entry for this hub in store.
act(() => {
useCanvasViewportStore.getState().setViewMode('wall');
});

await act(async () => {
vi.advanceTimersByTime(500);
await Promise.resolve();
});

// Dexie persisted (viewMode is a flat field).
expect(mockPersist).toHaveBeenCalled();
// Blob NOT written because viewports[HUB_ID] is undefined.
expect(mockSaveBlob).not.toHaveBeenCalled();
});

// ── Cancelled effect: no async ops after unmount ──────────────────────────

it('does not update state after unmount (cancelled guard)', async () => {
let resolveLoad: (val: LoadedViewport | null) => void = () => undefined;
mockLoadBlob.mockImplementationOnce(
() =>
new Promise<LoadedViewport | null>(resolve => {
resolveLoad = resolve;
})
);

const { unmount } = renderHook(() => useCanvasViewportLifecycle(HUB_ID));

unmount();

await act(async () => {
resolveLoad({ snapshot: BLOB_SNAPSHOT, etag: '"late-etag"' });
await Promise.resolve();
await Promise.resolve();
});

// Store must not have been updated.
const vp = useCanvasViewportStore.getState().viewports[HUB_ID];
expect(vp).toBeUndefined();
});
});
Loading