feat(screener): useCanvasLayout hook (node positions + reset)

This commit is contained in:
2026-05-13 21:40:37 +09:00
parent 2fd2ea33c7
commit 1505518ca6
2 changed files with 94 additions and 0 deletions

View File

@@ -0,0 +1,45 @@
import { useState, useCallback } from 'react';
const STORAGE_KEY = 'screener-canvas-layout-v1';
function readPositions(initial) {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return initial;
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') return initial;
// 누락 ID 보충
return { ...initial, ...filterValidEntries(parsed) };
} catch {
return initial;
}
}
function filterValidEntries(obj) {
const out = {};
for (const [k, v] of Object.entries(obj)) {
if (v && typeof v.x === 'number' && typeof v.y === 'number') {
out[k] = { x: v.x, y: v.y };
}
}
return out;
}
export function useCanvasLayout(initialPositions) {
const [positions, setPositions] = useState(() => readPositions(initialPositions));
const updateNodePosition = useCallback((nodeId, pos) => {
setPositions((prev) => {
const next = { ...prev, [nodeId]: { x: pos.x, y: pos.y } };
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } catch {}
return next;
});
}, []);
const reset = useCallback(() => {
setPositions(initialPositions);
try { localStorage.removeItem(STORAGE_KEY); } catch {}
}, [initialPositions]);
return { positions, updateNodePosition, reset };
}

View File

@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useCanvasLayout } from './useCanvasLayout';
const INITIAL = {
a: { x: 0, y: 0 },
b: { x: 100, y: 100 },
c: { x: 200, y: 200 },
};
describe('useCanvasLayout', () => {
it('초기 호출 시 INITIAL 반환', () => {
const { result } = renderHook(() => useCanvasLayout(INITIAL));
expect(result.current.positions).toEqual(INITIAL);
});
it('updateNodePosition 호출 시 state + localStorage 모두 갱신', () => {
const { result } = renderHook(() => useCanvasLayout(INITIAL));
act(() => result.current.updateNodePosition('a', { x: 50, y: 50 }));
expect(result.current.positions.a).toEqual({ x: 50, y: 50 });
const stored = JSON.parse(localStorage.getItem('screener-canvas-layout-v1'));
expect(stored.a).toEqual({ x: 50, y: 50 });
});
it('reset 호출 시 INITIAL 복원 + localStorage 삭제', () => {
const { result } = renderHook(() => useCanvasLayout(INITIAL));
act(() => result.current.updateNodePosition('a', { x: 50, y: 50 }));
act(() => result.current.reset());
expect(result.current.positions).toEqual(INITIAL);
expect(localStorage.getItem('screener-canvas-layout-v1')).toBeNull();
});
it('손상된 localStorage 는 INITIAL 로 fallback', () => {
localStorage.setItem('screener-canvas-layout-v1', 'NOT_JSON');
const { result } = renderHook(() => useCanvasLayout(INITIAL));
expect(result.current.positions).toEqual(INITIAL);
});
it('localStorage 에 일부 ID 만 있으면 누락 ID 는 INITIAL 보충', () => {
localStorage.setItem(
'screener-canvas-layout-v1',
JSON.stringify({ a: { x: 999, y: 999 } })
);
const { result } = renderHook(() => useCanvasLayout(INITIAL));
expect(result.current.positions.a).toEqual({ x: 999, y: 999 });
expect(result.current.positions.b).toEqual({ x: 100, y: 100 });
expect(result.current.positions.c).toEqual({ x: 200, y: 200 });
});
});