diff --git a/src/pages/stock/screener/hooks/useCanvasLayout.js b/src/pages/stock/screener/hooks/useCanvasLayout.js new file mode 100644 index 0000000..c37dbb3 --- /dev/null +++ b/src/pages/stock/screener/hooks/useCanvasLayout.js @@ -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 }; +} diff --git a/src/pages/stock/screener/hooks/useCanvasLayout.test.js b/src/pages/stock/screener/hooks/useCanvasLayout.test.js new file mode 100644 index 0000000..1d922db --- /dev/null +++ b/src/pages/stock/screener/hooks/useCanvasLayout.test.js @@ -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 }); + }); +});