feat(screener): useCanvasLayout hook (node positions + reset)
This commit is contained in:
45
src/pages/stock/screener/hooks/useCanvasLayout.js
Normal file
45
src/pages/stock/screener/hooks/useCanvasLayout.js
Normal 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 };
|
||||
}
|
||||
49
src/pages/stock/screener/hooks/useCanvasLayout.test.js
Normal file
49
src/pages/stock/screener/hooks/useCanvasLayout.test.js
Normal 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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user