15-task TDD plan. 의존성 + 테스트 환경 셋업 → 상수/hooks/카드/캔버스 → Screener.jsx 통합 → 수동 검증 + 배포. 단위 테스트 20개 (4 파일), react-flow lazy import로 모바일 번들 보호. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
53 KiB
Screener Node Canvas Mode Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: /stock/screener 에 n8n 스타일 노드 캔버스 모드를 추가한다. 폼 모드와 토글로 전환하며 같은 settings state를 공유하고, 11 노드 / 16 엣지 고정 토폴로지로 파이프라인을 시각화한다.
Architecture: 백엔드 변경 없이 프론트 전용. @xyflow/react 도입, useScreenerMode / useCanvasLayout 두 hook 추가, canvas/ 하위에 노드 카드 + 캔버스 컨테이너 + floating 툴바 컴포넌트 신설. 모드 분기는 Screener.jsx 한 곳에서 처리하고 결과 영역(ResultTable, TelegramPreview, RunHistoryList)은 두 모드가 공유. 모바일은 useIsMobile 분기로 폼 강제.
Tech Stack: React 18.2, Vite 7, @xyflow/react ^12, vitest + jsdom + @testing-library/react, localStorage.
선행 spec: docs/superpowers/specs/2026-05-13-screener-node-canvas-design.md
파일 구조
신규 파일:
src/pages/stock/screener/
hooks/
useScreenerMode.js
useCanvasLayout.js
components/
ModeToggle.jsx
canvas/
CanvasLayout.jsx
ScreenerCanvas.jsx
CanvasToolbar.jsx
nodes/
ScoreNodeCard.jsx
GateNodeCard.jsx
FixedNodeCard.jsx
constants/
canvasLayout.js
Canvas.css
src/test-setup.js
수정 파일:
package.json (의존성 추가, test script)
vite.config.js (test 섹션)
src/pages/stock/screener/Screener.jsx
src/pages/stock/screener/Screener.css (ModeToggle 스타일 추가)
테스트 파일:
src/pages/stock/screener/hooks/useScreenerMode.test.js
src/pages/stock/screener/hooks/useCanvasLayout.test.js
src/pages/stock/screener/components/canvas/constants/canvasLayout.test.js
src/pages/stock/screener/components/canvas/nodes/ScoreNodeCard.test.jsx
Task 1: 의존성 + 테스트 환경 셋업
Files:
-
Modify:
package.json -
Modify:
vite.config.js -
Create:
src/test-setup.js -
Step 1:
@xyflow/react와 테스트 의존성 설치
cd C:\Users\jaeoh\Desktop\workspace\web-ui
npm install @xyflow/react@^12
npm install -D vitest@^2 jsdom@^25 @testing-library/react@^16 @testing-library/jest-dom@^6 @testing-library/user-event@^14
Expected: 새 의존성 6개가 package.json 에 추가됨. npm install 성공.
- Step 2:
package.json에 test 스크립트 추가
scripts 객체에 다음 줄을 추가:
"test": "vitest",
"test:run": "vitest run"
- Step 3:
vite.config.js에 test 섹션 추가
기존 defineConfig({ ... }) 블록 안 plugins 옆에 추가:
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test-setup.js'],
include: ['src/**/*.test.{js,jsx}'],
},
- Step 4:
src/test-setup.js작성
import '@testing-library/jest-dom/vitest';
// jsdom polyfills for react-flow
if (typeof window !== 'undefined') {
if (!window.matchMedia) {
window.matchMedia = (query) => ({
matches: false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
});
}
if (!window.ResizeObserver) {
window.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
};
}
if (!window.DOMMatrixReadOnly) {
window.DOMMatrixReadOnly = class {
constructor() {}
m22 = 1;
};
}
}
beforeEach(() => {
localStorage.clear();
});
- Step 5: 셋업 동작 확인
npx vitest --run --reporter=verbose
Expected: "No test files found" 메시지 (테스트 파일이 아직 없어 정상). 에러 없이 종료.
- Step 6: Commit
git add package.json package-lock.json vite.config.js src/test-setup.js
git commit -m "chore(screener): add @xyflow/react + vitest test environment"
Task 2: canvasLayout.js 상수 + 정합성 테스트
Files:
-
Create:
src/pages/stock/screener/components/canvas/constants/canvasLayout.js -
Test:
src/pages/stock/screener/components/canvas/constants/canvasLayout.test.js -
Step 1: 실패하는 정합성 테스트 작성
import { describe, it, expect } from 'vitest';
import {
NODE_IDS, INITIAL_NODE_POSITIONS, EDGES,
NODE_KIND_MAP, SCORE_NODE_NAME_MAP,
} from './canvasLayout';
describe('canvasLayout', () => {
it('NODE_IDS — 11개 키, 모두 unique', () => {
const ids = Object.values(NODE_IDS);
expect(ids).toHaveLength(11);
expect(new Set(ids).size).toBe(11);
});
it('INITIAL_NODE_POSITIONS — 모든 NODE_IDS에 좌표 존재', () => {
for (const id of Object.values(NODE_IDS)) {
expect(INITIAL_NODE_POSITIONS[id]).toMatchObject({
x: expect.any(Number),
y: expect.any(Number),
});
}
});
it('EDGES — 16개, source/target이 모두 NODE_IDS 안에 존재', () => {
expect(EDGES).toHaveLength(16);
const validIds = new Set(Object.values(NODE_IDS));
for (const e of EDGES) {
expect(validIds.has(e.source)).toBe(true);
expect(validIds.has(e.target)).toBe(true);
expect(e.id).toBeTruthy();
}
});
it('EDGES — 7개 점수 노드는 모두 gate 입력 + combine 출력을 가짐', () => {
const SCORE_IDS = [
NODE_IDS.FOREIGN, NODE_IDS.VOLUME, NODE_IDS.MOMENTUM,
NODE_IDS.HIGH52W, NODE_IDS.RS, NODE_IDS.MA, NODE_IDS.VCP,
];
for (const sid of SCORE_IDS) {
const hasGateInput = EDGES.some(
(e) => e.source === NODE_IDS.GATE && e.target === sid
);
const hasCombineOutput = EDGES.some(
(e) => e.source === sid && e.target === NODE_IDS.COMBINE
);
expect(hasGateInput).toBe(true);
expect(hasCombineOutput).toBe(true);
}
});
it('NODE_KIND_MAP — 각 노드의 kind ∈ {data,gate,score,combine,result}', () => {
const valid = new Set(['data','gate','score','combine','result']);
for (const id of Object.values(NODE_IDS)) {
expect(valid.has(NODE_KIND_MAP[id])).toBe(true);
}
});
it('SCORE_NODE_NAME_MAP — 7개 점수 노드 ID → backend node name', () => {
expect(Object.keys(SCORE_NODE_NAME_MAP)).toHaveLength(7);
expect(SCORE_NODE_NAME_MAP[NODE_IDS.FOREIGN]).toBe('foreign_buy');
expect(SCORE_NODE_NAME_MAP[NODE_IDS.VOLUME]).toBe('volume_surge');
});
});
- Step 2: 테스트 실패 확인
npx vitest run src/pages/stock/screener/components/canvas/constants/canvasLayout.test.js
Expected: FAIL — "Cannot find module './canvasLayout'".
- Step 3:
canvasLayout.js구현
export const NODE_IDS = {
DATA: 'data',
GATE: 'gate-hygiene',
FOREIGN: 'score-foreign-buy',
VOLUME: 'score-volume-surge',
MOMENTUM: 'score-momentum',
HIGH52W: 'score-high52w',
RS: 'score-rs-rating',
MA: 'score-ma-alignment',
VCP: 'score-vcp-lite',
COMBINE: 'combine',
RESULT: 'result',
};
export const NODE_KIND_MAP = {
[NODE_IDS.DATA]: 'data',
[NODE_IDS.GATE]: 'gate',
[NODE_IDS.FOREIGN]: 'score',
[NODE_IDS.VOLUME]: 'score',
[NODE_IDS.MOMENTUM]: 'score',
[NODE_IDS.HIGH52W]: 'score',
[NODE_IDS.RS]: 'score',
[NODE_IDS.MA]: 'score',
[NODE_IDS.VCP]: 'score',
[NODE_IDS.COMBINE]: 'combine',
[NODE_IDS.RESULT]: 'result',
};
// 캔버스 노드 ID → 백엔드 score node name (registry 키)
export const SCORE_NODE_NAME_MAP = {
[NODE_IDS.FOREIGN]: 'foreign_buy',
[NODE_IDS.VOLUME]: 'volume_surge',
[NODE_IDS.MOMENTUM]: 'momentum',
[NODE_IDS.HIGH52W]: 'high52w',
[NODE_IDS.RS]: 'rs_rating',
[NODE_IDS.MA]: 'ma_alignment',
[NODE_IDS.VCP]: 'vcp_lite',
};
// 4단 layout: DATA → GATE → (점수 7개 세로) → COMBINE → RESULT
export const INITIAL_NODE_POSITIONS = {
[NODE_IDS.DATA]: { x: 40, y: 280 },
[NODE_IDS.GATE]: { x: 240, y: 280 },
[NODE_IDS.FOREIGN]: { x: 480, y: 0 },
[NODE_IDS.VOLUME]: { x: 480, y: 90 },
[NODE_IDS.MOMENTUM]: { x: 480, y: 180 },
[NODE_IDS.HIGH52W]: { x: 480, y: 270 },
[NODE_IDS.RS]: { x: 480, y: 360 },
[NODE_IDS.MA]: { x: 480, y: 450 },
[NODE_IDS.VCP]: { x: 480, y: 540 },
[NODE_IDS.COMBINE]: { x: 800, y: 280 },
[NODE_IDS.RESULT]: { x: 1080, y: 280 },
};
const SCORE_KEYS = ['FOREIGN','VOLUME','MOMENTUM','HIGH52W','RS','MA','VCP'];
export const EDGES = [
{ id: 'e-data-gate', source: NODE_IDS.DATA, target: NODE_IDS.GATE },
...SCORE_KEYS.map((k) => ({
id: `e-gate-${k.toLowerCase()}`,
source: NODE_IDS.GATE,
target: NODE_IDS[k],
})),
...SCORE_KEYS.map((k) => ({
id: `e-${k.toLowerCase()}-combine`,
source: NODE_IDS[k],
target: NODE_IDS.COMBINE,
})),
{ id: 'e-combine-result', source: NODE_IDS.COMBINE, target: NODE_IDS.RESULT },
];
export const SCORE_NODE_LABEL = {
[NODE_IDS.FOREIGN]: { icon: '🌏', title: '외국인 매수' },
[NODE_IDS.VOLUME]: { icon: '📊', title: '거래량 급증' },
[NODE_IDS.MOMENTUM]: { icon: '🚀', title: '모멘텀' },
[NODE_IDS.HIGH52W]: { icon: '🔝', title: '52주 고가' },
[NODE_IDS.RS]: { icon: '💪', title: 'RS Rating' },
[NODE_IDS.MA]: { icon: '📈', title: '이평선 정렬' },
[NODE_IDS.VCP]: { icon: '🌀', title: 'VCP-lite' },
};
- Step 4: 테스트 통과 확인
npx vitest run src/pages/stock/screener/components/canvas/constants/canvasLayout.test.js
Expected: PASS — 6 tests passed.
- Step 5: Commit
git add src/pages/stock/screener/components/canvas/constants/
git commit -m "feat(screener): add canvas layout constants (11 nodes, 16 edges)"
Task 3: useScreenerMode hook + 테스트
Files:
-
Create:
src/pages/stock/screener/hooks/useScreenerMode.js -
Test:
src/pages/stock/screener/hooks/useScreenerMode.test.js -
Step 1: 실패하는 테스트 작성
import { describe, it, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useScreenerMode } from './useScreenerMode';
describe('useScreenerMode', () => {
it('초기값은 "form"', () => {
const { result } = renderHook(() => useScreenerMode());
expect(result.current.mode).toBe('form');
});
it('localStorage 에 저장된 값 복원', () => {
localStorage.setItem('screener-mode-v1', 'canvas');
const { result } = renderHook(() => useScreenerMode());
expect(result.current.mode).toBe('canvas');
});
it('손상된 localStorage 는 "form" 으로 fallback', () => {
localStorage.setItem('screener-mode-v1', 'INVALID_MODE');
const { result } = renderHook(() => useScreenerMode());
expect(result.current.mode).toBe('form');
});
it('setMode 호출 시 state 와 localStorage 모두 갱신', () => {
const { result } = renderHook(() => useScreenerMode());
act(() => result.current.setMode('canvas'));
expect(result.current.mode).toBe('canvas');
expect(localStorage.getItem('screener-mode-v1')).toBe('canvas');
});
});
- Step 2: 테스트 실패 확인
npx vitest run src/pages/stock/screener/hooks/useScreenerMode.test.js
Expected: FAIL — "Cannot find module './useScreenerMode'".
- Step 3:
useScreenerMode.js구현
import { useState } from 'react';
const STORAGE_KEY = 'screener-mode-v1';
const VALID_MODES = new Set(['form', 'canvas']);
function readMode() {
try {
const v = localStorage.getItem(STORAGE_KEY);
return VALID_MODES.has(v) ? v : 'form';
} catch {
return 'form';
}
}
export function useScreenerMode() {
const [mode, setModeState] = useState(readMode);
const setMode = (m) => {
if (!VALID_MODES.has(m)) return;
setModeState(m);
try { localStorage.setItem(STORAGE_KEY, m); } catch {}
};
return { mode, setMode };
}
- Step 4: 테스트 통과 확인
npx vitest run src/pages/stock/screener/hooks/useScreenerMode.test.js
Expected: PASS — 4 tests passed.
- Step 5: Commit
git add src/pages/stock/screener/hooks/useScreenerMode.js src/pages/stock/screener/hooks/useScreenerMode.test.js
git commit -m "feat(screener): useScreenerMode hook (form|canvas + localStorage)"
Task 4: useCanvasLayout hook + 테스트
Files:
-
Create:
src/pages/stock/screener/hooks/useCanvasLayout.js -
Test:
src/pages/stock/screener/hooks/useCanvasLayout.test.js -
Step 1: 실패하는 테스트 작성
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 });
});
});
- Step 2: 테스트 실패 확인
npx vitest run src/pages/stock/screener/hooks/useCanvasLayout.test.js
Expected: FAIL — "Cannot find module './useCanvasLayout'".
- Step 3:
useCanvasLayout.js구현
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 };
}
- Step 4: 테스트 통과 확인
npx vitest run src/pages/stock/screener/hooks/useCanvasLayout.test.js
Expected: PASS — 5 tests passed.
- Step 5: Commit
git add src/pages/stock/screener/hooks/useCanvasLayout.js src/pages/stock/screener/hooks/useCanvasLayout.test.js
git commit -m "feat(screener): useCanvasLayout hook (node positions + reset)"
Task 5: ModeToggle 컴포넌트
Files:
-
Create:
src/pages/stock/screener/components/ModeToggle.jsx -
Step 1:
ModeToggle.jsx구현
import React from 'react';
export default function ModeToggle({ value, onChange }) {
return (
<div className="screener-mode-toggle" role="tablist" aria-label="화면 모드">
<button
type="button"
role="tab"
aria-selected={value === 'form'}
className={value === 'form' ? 'active' : ''}
onClick={() => onChange('form')}
>
폼
</button>
<button
type="button"
role="tab"
aria-selected={value === 'canvas'}
className={value === 'canvas' ? 'active' : ''}
onClick={() => onChange('canvas')}
>
캔버스
</button>
</div>
);
}
- Step 2: 빠른 sanity import 검증
vite dev 서버는 아직 띄우지 않음. lint 만:
npx eslint src/pages/stock/screener/components/ModeToggle.jsx
Expected: 0 errors.
- Step 3: Commit
git add src/pages/stock/screener/components/ModeToggle.jsx
git commit -m "feat(screener): ModeToggle segment control component"
Task 6: FixedNodeCard (데이터/결합/결과 노드)
Files:
-
Create:
src/pages/stock/screener/components/canvas/nodes/FixedNodeCard.jsx -
Step 1:
FixedNodeCard.jsx구현
import React, { memo } from 'react';
import { Handle, Position } from '@xyflow/react';
function FixedNodeCard({ data }) {
const { icon, title, subtitle, kind } = data;
const hasInput = kind !== 'data';
const hasOutput = kind !== 'result';
return (
<div className={`canvas-node canvas-node--fixed canvas-node--${kind}`}>
{hasInput && <Handle type="target" position={Position.Left} isConnectable={false} />}
<div className="canvas-node-title">
<span className="canvas-node-icon">{icon}</span>
<span>{title}</span>
</div>
{subtitle && <div className="canvas-node-subtitle">{subtitle}</div>}
{hasOutput && <Handle type="source" position={Position.Right} isConnectable={false} />}
</div>
);
}
export default memo(FixedNodeCard);
- Step 2: Commit
git add src/pages/stock/screener/components/canvas/nodes/FixedNodeCard.jsx
git commit -m "feat(screener): FixedNodeCard for data/combine/result nodes"
Task 7: GateNodeCard (위생 게이트)
Files:
-
Create:
src/pages/stock/screener/components/canvas/nodes/GateNodeCard.jsx -
Step 1:
GateNodeCard.jsx구현
import React, { memo, useState } from 'react';
import { Handle, Position } from '@xyflow/react';
function ParamField({ name, schema, value, onChange }) {
if (schema?.type === 'boolean') {
return (
<label className="canvas-param-field">
<input
type="checkbox"
checked={!!value}
onChange={(e) => onChange(name, e.target.checked)}
/>
<span>{schema.label || name}</span>
</label>
);
}
return (
<label className="canvas-param-field">
<span>{schema?.label || name}</span>
<input
type="number"
value={value ?? schema?.default ?? 0}
step={schema?.step ?? 1}
onChange={(e) => onChange(name, Number(e.target.value))}
/>
</label>
);
}
function GateNodeCard({ data }) {
const { meta, params, onChange, description } = data;
const [expanded, setExpanded] = useState(false);
const update = (key, v) => onChange({ ...params, [key]: v });
return (
<div className="canvas-node canvas-node--gate">
<Handle type="target" position={Position.Left} isConnectable={false} />
<div className="canvas-node-title">
<span className="canvas-node-icon">🛡️</span>
<span>{meta?.label || '위생 게이트'}</span>
{description && (
<span className="canvas-node-info" title={description}>ⓘ</span>
)}
</div>
<div className="canvas-node-summary">통과해야 점수 단계 진입</div>
<button
type="button"
className="canvas-node-expand"
onClick={() => setExpanded((v) => !v)}
>
{expanded ? '▴ 파라미터' : '▾ 파라미터'}
</button>
{expanded && (
<div className="canvas-node-params">
{Object.entries(meta?.param_schema || {}).map(([key, schema]) => (
<ParamField
key={key}
name={key}
schema={schema}
value={params?.[key]}
onChange={update}
/>
))}
</div>
)}
<Handle type="source" position={Position.Right} isConnectable={false} />
</div>
);
}
export default memo(GateNodeCard);
- Step 2: Commit
git add src/pages/stock/screener/components/canvas/nodes/GateNodeCard.jsx
git commit -m "feat(screener): GateNodeCard for hygiene gate"
Task 8: ScoreNodeCard 컴포넌트 + 테스트
Files:
-
Create:
src/pages/stock/screener/components/canvas/nodes/ScoreNodeCard.jsx -
Test:
src/pages/stock/screener/components/canvas/nodes/ScoreNodeCard.test.jsx -
Step 1: 실패하는 테스트 작성
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { ReactFlowProvider } from '@xyflow/react';
import ScoreNodeCard from './ScoreNodeCard';
const baseData = {
meta: {
name: 'volume_surge',
label: '거래량 급증',
param_schema: {
lookback_days: { type: 'integer', default: 20, label: 'lookback' },
multiplier: { type: 'number', default: 2.0, step: 0.1, label: 'mult' },
},
},
weight: 0.5,
params: { lookback_days: 20, multiplier: 2.0 },
summary: '20일 평균 대비 2배 이상',
description: '거래량이 평균 대비 급증한 종목을 가산',
accent: '#3b82f6',
onWeightChange: vi.fn(),
onParamsChange: vi.fn(),
};
function renderInFlow(data) {
return render(
<ReactFlowProvider>
<ScoreNodeCard data={data} />
</ReactFlowProvider>
);
}
describe('ScoreNodeCard', () => {
it('타이틀과 한 줄 요약을 표시한다', () => {
renderInFlow(baseData);
expect(screen.getByText('거래량 급증')).toBeInTheDocument();
expect(screen.getByText('20일 평균 대비 2배 이상')).toBeInTheDocument();
});
it('가중치 슬라이더 변경 시 onWeightChange 호출', () => {
const onWeightChange = vi.fn();
renderInFlow({ ...baseData, onWeightChange });
const slider = screen.getByRole('slider');
fireEvent.change(slider, { target: { value: '0.8' } });
expect(onWeightChange).toHaveBeenCalledWith(0.8);
});
it('활성 체크박스 uncheck 시 onWeightChange(0)', () => {
const onWeightChange = vi.fn();
renderInFlow({ ...baseData, weight: 0.5, onWeightChange });
const checkbox = screen.getByRole('checkbox', { name: /활성/ });
expect(checkbox).toBeChecked();
fireEvent.click(checkbox);
expect(onWeightChange).toHaveBeenCalledWith(0);
});
it('weight=0 상태에서 활성 체크 시 기본값 0.5로 복원', () => {
const onWeightChange = vi.fn();
renderInFlow({ ...baseData, weight: 0, onWeightChange });
const checkbox = screen.getByRole('checkbox', { name: /활성/ });
expect(checkbox).not.toBeChecked();
fireEvent.click(checkbox);
expect(onWeightChange).toHaveBeenCalledWith(0.5);
});
it('파라미터 펼치기 토글', () => {
renderInFlow(baseData);
expect(screen.queryByLabelText('lookback')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /파라미터/ }));
expect(screen.getByLabelText('lookback')).toBeInTheDocument();
});
});
- Step 2: 테스트 실패 확인
npx vitest run src/pages/stock/screener/components/canvas/nodes/ScoreNodeCard.test.jsx
Expected: FAIL — "Cannot find module './ScoreNodeCard'".
- Step 3:
ScoreNodeCard.jsx구현
import React, { memo, useState } from 'react';
import { Handle, Position } from '@xyflow/react';
const DEFAULT_WEIGHT = 0.5;
function ParamField({ name, schema, value, onChange }) {
return (
<label className="canvas-param-field">
<span>{schema?.label || name}</span>
<input
type="number"
value={value ?? schema?.default ?? 0}
step={schema?.step ?? 1}
onChange={(e) => onChange(name, Number(e.target.value))}
/>
</label>
);
}
function ScoreNodeCard({ data }) {
const {
meta, weight, params, summary, description, accent, icon,
onWeightChange, onParamsChange,
} = data;
const [expanded, setExpanded] = useState(false);
const active = weight > 0;
const toggleActive = () => {
if (active) onWeightChange(0);
else onWeightChange(DEFAULT_WEIGHT);
};
const updateParam = (key, v) =>
onParamsChange({ ...params, [key]: v });
return (
<div
className={`canvas-node canvas-node--score ${active ? '' : 'is-inactive'}`}
style={{ '--canvas-accent': accent || '#3b82f6' }}
>
<Handle type="target" position={Position.Left} isConnectable={false} />
<div className="canvas-node-title">
<span className="canvas-node-icon">{icon}</span>
<span>{meta?.label || meta?.name}</span>
{description && (
<span className="canvas-node-info" title={description}>ⓘ</span>
)}
</div>
{summary && <div className="canvas-node-summary">{summary}</div>}
<div className="canvas-node-weight">
<input
type="range"
min={0}
max={1}
step={0.05}
value={weight}
onChange={(e) => onWeightChange(Number(e.target.value))}
aria-label="가중치"
/>
<span className="canvas-node-weight-value">{weight.toFixed(2)}</span>
</div>
<label className="canvas-node-active">
<input
type="checkbox"
checked={active}
onChange={toggleActive}
/>
<span>활성</span>
</label>
<button
type="button"
className="canvas-node-expand"
onClick={() => setExpanded((v) => !v)}
>
{expanded ? '▴ 파라미터' : '▾ 파라미터'}
</button>
{expanded && (
<div className="canvas-node-params">
{Object.entries(meta?.param_schema || {}).map(([key, schema]) => (
<ParamField
key={key}
name={key}
schema={schema}
value={params?.[key]}
onChange={updateParam}
/>
))}
</div>
)}
<Handle type="source" position={Position.Right} isConnectable={false} />
</div>
);
}
export default memo(ScoreNodeCard);
- Step 4: 테스트 통과 확인
npx vitest run src/pages/stock/screener/components/canvas/nodes/ScoreNodeCard.test.jsx
Expected: PASS — 5 tests passed.
- Step 5: Commit
git add src/pages/stock/screener/components/canvas/nodes/ScoreNodeCard.jsx src/pages/stock/screener/components/canvas/nodes/ScoreNodeCard.test.jsx
git commit -m "feat(screener): ScoreNodeCard with weight slider + active toggle + params"
Task 9: CanvasToolbar (floating Panel)
Files:
-
Create:
src/pages/stock/screener/components/canvas/CanvasToolbar.jsx -
Step 1:
CanvasToolbar.jsx구현
import React from 'react';
import { Panel, useReactFlow } from '@xyflow/react';
export default function CanvasToolbar({
onRunPreview,
onRunSave,
onPersistSettings,
onResetLayout,
dirty,
running,
}) {
const { fitView } = useReactFlow();
return (
<Panel position="top-left" className="canvas-toolbar">
<button
type="button"
className="canvas-toolbar-btn canvas-toolbar-btn--primary"
disabled={running}
onClick={onRunPreview}
title="현재 가중치로 미리보기 실행"
>
{running ? '실행 중…' : '▶ 실행'}
</button>
<button
type="button"
className="canvas-toolbar-btn"
disabled={running}
onClick={onRunSave}
title="실행 결과를 DB에 저장"
>
💾 저장 실행
</button>
<button
type="button"
className="canvas-toolbar-btn"
disabled={!dirty}
onClick={onPersistSettings}
title="현재 설정을 영구 저장"
>
📌 설정 저장{dirty ? ' *' : ''}
</button>
<button
type="button"
className="canvas-toolbar-btn"
onClick={onResetLayout}
title="노드 위치를 초기 좌표로 복귀"
>
🔄
</button>
<button
type="button"
className="canvas-toolbar-btn"
onClick={() => fitView({ padding: 0.2, duration: 300 })}
title="화면에 맞춤"
>
⛶
</button>
</Panel>
);
}
- Step 2: Commit
git add src/pages/stock/screener/components/canvas/CanvasToolbar.jsx
git commit -m "feat(screener): CanvasToolbar floating panel"
Task 10: ScreenerCanvas (React Flow root)
Files:
-
Create:
src/pages/stock/screener/components/canvas/ScreenerCanvas.jsx -
Step 1:
ScreenerCanvas.jsx구현
import React, { useMemo, useCallback } from 'react';
import {
ReactFlow, Background, Controls, ReactFlowProvider,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import {
NODE_IDS, NODE_KIND_MAP, SCORE_NODE_NAME_MAP,
EDGES, SCORE_NODE_LABEL, INITIAL_NODE_POSITIONS,
} from './constants/canvasLayout';
import { useCanvasLayout } from '../../hooks/useCanvasLayout';
import ScoreNodeCard from './nodes/ScoreNodeCard';
import GateNodeCard from './nodes/GateNodeCard';
import FixedNodeCard from './nodes/FixedNodeCard';
import CanvasToolbar from './CanvasToolbar';
const nodeTypes = {
score: ScoreNodeCard,
gate: GateNodeCard,
fixed: FixedNodeCard,
};
function activeAccent(weight, baseAccent) {
if (weight > 0) return baseAccent;
return '#374151';
}
function buildEdges(weights) {
return EDGES.map((e) => {
const targetKind = NODE_KIND_MAP[e.target];
const sourceKind = NODE_KIND_MAP[e.source];
// gate → 점수: 해당 점수 노드 weight 가 활성인지에 따라 stroke
let active = true;
if (sourceKind === 'gate' && targetKind === 'score') {
const nodeName = SCORE_NODE_NAME_MAP[e.target];
active = (weights?.[nodeName] ?? 0) > 0;
} else if (sourceKind === 'score' && targetKind === 'combine') {
const nodeName = SCORE_NODE_NAME_MAP[e.source];
active = (weights?.[nodeName] ?? 0) > 0;
}
return {
...e,
animated: active,
style: {
stroke: active ? '#fbbf24' : '#374151',
strokeWidth: active ? 1.5 : 1,
strokeDasharray: active ? undefined : '4 4',
},
};
});
}
function ScreenerCanvasInner({
meta, settings, setLocal, result, running, dirty,
onRunPreview, onRunSave, onPersistSettings,
}) {
const { positions, updateNodePosition, reset } = useCanvasLayout(INITIAL_NODE_POSITIONS);
const onWeightChange = useCallback((nodeId, weight) => {
const name = SCORE_NODE_NAME_MAP[nodeId];
if (!name) return;
setLocal({ ...settings, weights: { ...settings.weights, [name]: weight } });
}, [settings, setLocal]);
const onParamsChange = useCallback((nodeId, params) => {
const name = SCORE_NODE_NAME_MAP[nodeId];
if (!name) return;
setLocal({ ...settings, node_params: { ...settings.node_params, [name]: params } });
}, [settings, setLocal]);
const onGateParamsChange = useCallback((params) => {
setLocal({ ...settings, gate_params: params });
}, [settings, setLocal]);
const scoreMetaByName = useMemo(() => {
const map = {};
for (const m of meta?.score_nodes ?? []) map[m.name] = m;
return map;
}, [meta]);
const gateMeta = meta?.gate_nodes?.[0];
const nodes = useMemo(() => {
const arr = [];
arr.push({
id: NODE_IDS.DATA,
type: 'fixed',
position: positions[NODE_IDS.DATA],
data: { icon: '📥', title: 'KRX 데이터', subtitle: '~2,800종목 · FDR', kind: 'data' },
draggable: true,
});
arr.push({
id: NODE_IDS.GATE,
type: 'gate',
position: positions[NODE_IDS.GATE],
data: {
meta: gateMeta,
params: settings.gate_params,
description: gateMeta?.label || '위생 게이트',
onChange: onGateParamsChange,
},
draggable: true,
});
for (const [nodeId, backendName] of Object.entries(SCORE_NODE_NAME_MAP)) {
const m = scoreMetaByName[backendName];
const label = SCORE_NODE_LABEL[nodeId] || { icon: '📈', title: backendName };
arr.push({
id: nodeId,
type: 'score',
position: positions[nodeId],
data: {
meta: m ? { ...m, label: label.title } : { name: backendName, label: label.title },
weight: settings.weights?.[backendName] ?? 0,
params: settings.node_params?.[backendName] ?? {},
summary: m?.summary || '',
description: m?.description || m?.label || '',
accent: m?.color || '#3b82f6',
icon: label.icon,
onWeightChange: (w) => onWeightChange(nodeId, w),
onParamsChange: (p) => onParamsChange(nodeId, p),
},
draggable: true,
});
}
const tp = settings.top_n;
const rr = settings.rr_ratio;
const am = settings.atr_stop_mult;
arr.push({
id: NODE_IDS.COMBINE,
type: 'fixed',
position: positions[NODE_IDS.COMBINE],
data: {
icon: '⚙️',
title: '가중합 + TopN + ATR',
subtitle: `Top ${tp} · RR ${rr} · ATR×${am}`,
kind: 'combine',
},
draggable: true,
});
const survivors = result?.survivors_count;
const asof = result?.asof;
arr.push({
id: NODE_IDS.RESULT,
type: 'fixed',
position: positions[NODE_IDS.RESULT],
data: {
icon: '📊',
title: '결과',
subtitle: asof ? `${asof} · ${survivors ?? '-'}종목 통과` : '아직 실행 안 됨',
kind: 'result',
},
draggable: true,
});
return arr;
}, [positions, settings, meta, scoreMetaByName, gateMeta,
onWeightChange, onParamsChange, onGateParamsChange, result]);
const edges = useMemo(() => buildEdges(settings.weights), [settings.weights]);
const handleNodeDragStop = useCallback((_evt, node) => {
updateNodePosition(node.id, node.position);
}, [updateNodePosition]);
return (
<div className="screener-canvas-wrap">
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
nodesConnectable={false}
edgesUpdatable={false}
edgesFocusable={false}
onNodeDragStop={handleNodeDragStop}
defaultViewport={{ x: 0, y: 0, zoom: 0.85 }}
fitView
fitViewOptions={{ padding: 0.2 }}
proOptions={{ hideAttribution: true }}
>
<Background gap={20} size={1} color="#1f2937" />
<Controls showInteractive={false} />
<CanvasToolbar
onRunPreview={onRunPreview}
onRunSave={onRunSave}
onPersistSettings={onPersistSettings}
onResetLayout={reset}
dirty={dirty}
running={running}
/>
</ReactFlow>
</div>
);
}
export default function ScreenerCanvas(props) {
return (
<ReactFlowProvider>
<ScreenerCanvasInner {...props} />
</ReactFlowProvider>
);
}
- Step 2: lint 통과 확인
npx eslint src/pages/stock/screener/components/canvas/ScreenerCanvas.jsx
Expected: 0 errors.
- Step 3: Commit
git add src/pages/stock/screener/components/canvas/ScreenerCanvas.jsx
git commit -m "feat(screener): ScreenerCanvas root component (react-flow + 11 nodes + 16 edges)"
Task 11: CanvasLayout (캔버스 + 결과 영역 그리드)
Files:
-
Create:
src/pages/stock/screener/components/canvas/CanvasLayout.jsx -
Step 1:
CanvasLayout.jsx구현
import React from 'react';
import ScreenerCanvas from './ScreenerCanvas';
import ResultTable from '../ResultTable';
import TelegramPreview from '../TelegramPreview';
import RunHistoryList from '../RunHistoryList';
export default function CanvasLayout({
meta, settings, setLocal, save, dirty,
result, running, previewHistory, runPreview, runSave, selectPreview,
runs, runs_loading, selectRun, selectedRun,
compareId, setCompareId,
}) {
const compareItem = previewHistory.find((p) => p.id === compareId);
const compareResult = compareItem?.result ?? null;
const activeResult = selectedRun || result;
return (
<div className="screener-canvas-layout">
<section className="screener-canvas-area">
<ScreenerCanvas
meta={meta}
settings={settings}
setLocal={setLocal}
result={activeResult}
running={running}
dirty={dirty}
onRunPreview={() => runPreview(settings)}
onRunSave={() => runSave(settings)}
onPersistSettings={save}
/>
</section>
<section className="screener-canvas-results">
<div className="screener-canvas-results-main">
<ResultTable
result={activeResult}
compareWith={compareResult}
compareLabel={compareItem ? new Date(compareItem.timestamp).toLocaleTimeString() : null}
/>
<TelegramPreview payload={activeResult?.telegram_payload} />
</div>
<aside className="screener-canvas-results-side">
<RunHistoryList
runs={runs}
loading={runs_loading}
onSelect={selectRun}
selectedId={selectedRun?.meta?.id}
previewHistory={previewHistory}
onSelectPreview={selectPreview}
onSetCompare={setCompareId}
compareId={compareId}
/>
</aside>
</section>
</div>
);
}
- Step 2: Commit
git add src/pages/stock/screener/components/canvas/CanvasLayout.jsx
git commit -m "feat(screener): CanvasLayout (canvas + result grid)"
Task 12: Screener.jsx 모드 분기 통합
Files:
-
Modify:
src/pages/stock/screener/Screener.jsx -
Step 1: 기존 코드 전체 교체
import React, { useState, lazy, Suspense } from 'react';
import { Link } from 'react-router-dom';
import './Screener.css';
import { useScreenerMeta } from './hooks/useScreenerMeta';
import { useScreenerSettings } from './hooks/useScreenerSettings';
import { useScreenerRun } from './hooks/useScreenerRun';
import { useScreenerHistory } from './hooks/useScreenerHistory';
import { useScreenerMode } from './hooks/useScreenerMode';
import { useIsMobile } from '../../../hooks/useIsMobile';
import GatePanel from './components/GatePanel';
import NodePanel from './components/NodePanel';
import GlobalControls from './components/GlobalControls';
import ResultTable from './components/ResultTable';
import TelegramPreview from './components/TelegramPreview';
import RunHistoryList from './components/RunHistoryList';
import ModeToggle from './components/ModeToggle';
const CanvasLayout = lazy(() => import('./components/canvas/CanvasLayout'));
export default function Screener() {
const { meta, loading: metaLoading } = useScreenerMeta();
const { settings, dirty, setLocal, save } = useScreenerSettings();
const { result, running, previewHistory, runPreview, runSave, selectPreview } = useScreenerRun();
const { runs, runs_loading, selectRun, selectedRun } = useScreenerHistory();
const { mode, setMode } = useScreenerMode();
const isMobile = useIsMobile();
const effectiveMode = isMobile ? 'form' : mode;
const [compareId, setCompareId] = useState(null);
const compareItem = previewHistory.find((p) => p.id === compareId);
const compareResult = compareItem?.result ?? null;
const activeResult = selectedRun || result;
if (metaLoading || !meta || !settings) {
return <div className="screener-loading">로딩 중…</div>;
}
return (
<div className="screener-page">
<header className="screener-header">
<div>
<h1>스크리너</h1>
<p className="meta">
최근 자동 잡: {runs?.find(r => r.mode === 'auto')?.asof ?? '-'}
· 분석 기준일: {activeResult?.asof ?? settings.asof ?? '-'}
</p>
</div>
<div className="screener-header-right">
{!isMobile && <ModeToggle value={mode} onChange={setMode} />}
<nav>
<Link to="/stock">시장</Link>
<Link to="/stock/trade">트레이드</Link>
</nav>
</div>
</header>
{effectiveMode === 'form' ? (
<div className="screener-grid">
<aside className="screener-left">
<GatePanel
meta={meta.gate_nodes[0]}
value={settings.gate_params}
onChange={(p) => setLocal({ ...settings, gate_params: p })}
/>
<NodePanel
meta={meta.score_nodes}
weights={settings.weights}
params={settings.node_params}
onWeights={(w) => setLocal({ ...settings, weights: w })}
onParams={(p) => setLocal({ ...settings, node_params: p })}
/>
<GlobalControls
settings={settings} setSettings={setLocal}
onRun={() => runPreview(settings)}
onSave={() => runSave(settings)}
onPersist={save}
dirty={dirty}
running={running}
/>
</aside>
<main className="screener-center">
<ResultTable
result={activeResult}
compareWith={compareResult}
compareLabel={compareItem ? new Date(compareItem.timestamp).toLocaleTimeString() : null}
/>
<TelegramPreview payload={activeResult?.telegram_payload} />
</main>
<aside className="screener-right">
<RunHistoryList
runs={runs}
loading={runs_loading}
onSelect={selectRun}
selectedId={selectedRun?.meta?.id}
previewHistory={previewHistory}
onSelectPreview={selectPreview}
onSetCompare={setCompareId}
compareId={compareId}
/>
</aside>
</div>
) : (
<Suspense fallback={<div className="screener-loading">캔버스 로딩 중…</div>}>
<CanvasLayout
meta={meta}
settings={settings}
setLocal={setLocal}
save={save}
dirty={dirty}
result={result}
running={running}
previewHistory={previewHistory}
runPreview={runPreview}
runSave={runSave}
selectPreview={selectPreview}
runs={runs}
runs_loading={runs_loading}
selectRun={selectRun}
selectedRun={selectedRun}
compareId={compareId}
setCompareId={setCompareId}
/>
</Suspense>
)}
</div>
);
}
- Step 2: lint 통과 확인
npx eslint src/pages/stock/screener/Screener.jsx
Expected: 0 errors.
- Step 3: Commit
git add src/pages/stock/screener/Screener.jsx
git commit -m "feat(screener): integrate mode toggle (form|canvas) with lazy canvas"
Task 13: 캔버스 CSS
Files:
-
Create:
src/pages/stock/screener/components/canvas/Canvas.css -
Modify:
src/pages/stock/screener/Screener.css -
Step 1:
Canvas.css작성
/* ─────────── ModeToggle 헤더 컨트롤 ─────────── */
.screener-mode-toggle {
display: inline-flex;
background: #111827;
border: 1px solid #1f2937;
border-radius: 8px;
overflow: hidden;
}
.screener-mode-toggle button {
padding: 6px 14px;
background: transparent;
color: #9ca3af;
border: 0;
cursor: pointer;
font-size: 0.9rem;
}
.screener-mode-toggle button.active {
background: #fbbf24;
color: #111827;
font-weight: 600;
}
.screener-header-right {
display: flex;
align-items: center;
gap: 16px;
}
/* ─────────── CanvasLayout 그리드 ─────────── */
.screener-canvas-layout {
display: flex;
flex-direction: column;
gap: 16px;
}
.screener-canvas-area {
height: 65vh;
min-height: 480px;
border: 1px solid #1f2937;
border-radius: 12px;
overflow: hidden;
background: #0b1220;
}
.screener-canvas-results {
display: grid;
grid-template-columns: 1fr 300px;
gap: 16px;
}
.screener-canvas-results-main { display: flex; flex-direction: column; gap: 12px; }
.screener-canvas-results-side { min-width: 0; }
/* ─────────── React Flow 내부 ─────────── */
.screener-canvas-wrap { width: 100%; height: 100%; }
/* ─────────── 노드 카드 공통 ─────────── */
.canvas-node {
background: #111827;
border: 1px solid #1f2937;
border-radius: 10px;
color: #e5e7eb;
font-size: 12px;
padding: 10px 12px;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.4);
}
.canvas-node-title {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
font-size: 13px;
}
.canvas-node-icon { font-size: 14px; }
.canvas-node-info {
margin-left: auto;
color: #9ca3af;
cursor: help;
}
.canvas-node-subtitle,
.canvas-node-summary {
color: #9ca3af;
font-size: 11px;
margin-top: 4px;
}
/* ─────────── 고정 노드 (회색) ─────────── */
.canvas-node--fixed { width: 200px; }
.canvas-node--data { border-left: 3px solid #4b5563; }
.canvas-node--combine { border-left: 3px solid #6b7280; }
.canvas-node--result { border-left: 3px solid #6b7280; }
/* ─────────── 게이트 노드 (노랑) ─────────── */
.canvas-node--gate {
width: 220px;
border-left: 4px solid #facc15;
}
/* ─────────── 점수 노드 (accent) ─────────── */
.canvas-node--score {
width: 240px;
border-left: 4px solid var(--canvas-accent, #3b82f6);
}
.canvas-node--score.is-inactive {
opacity: 0.45;
filter: grayscale(0.6);
}
.canvas-node-weight {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.canvas-node-weight input[type=range] { flex: 1; }
.canvas-node-weight-value {
min-width: 32px;
text-align: right;
color: var(--canvas-accent, #3b82f6);
font-variant-numeric: tabular-nums;
}
.canvas-node-active {
display: flex;
align-items: center;
gap: 6px;
margin-top: 6px;
color: #d1d5db;
}
.canvas-node-expand {
width: 100%;
margin-top: 8px;
padding: 4px 0;
background: transparent;
color: #9ca3af;
border: 1px dashed #374151;
border-radius: 6px;
cursor: pointer;
font-size: 11px;
}
.canvas-node-params {
margin-top: 6px;
display: flex;
flex-direction: column;
gap: 6px;
}
.canvas-param-field {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
color: #d1d5db;
font-size: 11px;
}
.canvas-param-field input[type=number] {
width: 70px;
background: #0b1220;
color: #e5e7eb;
border: 1px solid #1f2937;
border-radius: 4px;
padding: 2px 6px;
}
/* ─────────── floating toolbar ─────────── */
.canvas-toolbar {
display: flex;
gap: 6px;
padding: 6px;
background: rgba(17, 24, 39, 0.75);
backdrop-filter: blur(8px);
border: 1px solid #1f2937;
border-radius: 10px;
}
.canvas-toolbar-btn {
padding: 6px 12px;
background: #1f2937;
color: #e5e7eb;
border: 1px solid #374151;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
}
.canvas-toolbar-btn:hover:not(:disabled) {
background: #374151;
}
.canvas-toolbar-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.canvas-toolbar-btn--primary {
background: #fbbf24;
color: #111827;
border-color: #fbbf24;
font-weight: 600;
}
.canvas-toolbar-btn--primary:hover:not(:disabled) { background: #f59e0b; }
/* ─────────── 모바일 (캔버스는 숨겨지므로 ModeToggle만 영향) ─────────── */
@media (max-width: 768px) {
.screener-canvas-results { grid-template-columns: 1fr; }
}
- Step 2:
Screener.css에 Canvas.css import 추가
Screener.css 첫 줄에 다음 추가:
@import './components/canvas/Canvas.css';
- Step 3: Commit
git add src/pages/stock/screener/components/canvas/Canvas.css src/pages/stock/screener/Screener.css
git commit -m "style(screener): canvas mode styles (toggle, nodes, toolbar, layout)"
Task 14: 전체 테스트 스위트 통과 확인
Files:
-
(테스트만 실행)
-
Step 1: 전체 테스트 실행
cd C:\Users\jaeoh\Desktop\workspace\web-ui
npx vitest run
Expected: 4개 테스트 파일, 총 20개 테스트 PASS.
-
canvasLayout.test.js— 6 tests -
useScreenerMode.test.js— 4 tests -
useCanvasLayout.test.js— 5 tests -
ScoreNodeCard.test.jsx— 5 tests -
Step 2: 빌드 통과 확인
npm run build
Expected: dist/ 디렉토리 생성, 빌드 에러 없음. @xyflow/react 가 별도 chunk로 분리되었는지 확인 (lazy import 때문).
- Step 3: lint 통과 확인
npm run lint
Expected: 0 errors. warning은 무시 가능.
Task 15: 수동 검증 + 배포
Files:
-
(실행만, 수동 점검)
-
Step 1: dev 서버 실행
cd C:\Users\jaeoh\Desktop\workspace\web-ui
npm run dev
브라우저에서 http://localhost:3007/stock/screener 열기.
- Step 2: 폼 모드 회귀 검증
폼 모드(기본)에서:
-
좌측 GatePanel, NodePanel, GlobalControls 정상 렌더
-
가중치 슬라이더 변경 → dirty 표시
-
"지금 실행" → 결과 테이블 갱신
-
모든 기존 기능 정상
-
Step 3: 캔버스 모드 검증
헤더 우상단 [폼][캔버스] 토글에서 캔버스 클릭:
-
캔버스에 11개 노드, 16개 엣지 사전 배치 표시
-
점수 노드 카드: 가중치 슬라이더, 활성 체크, ▾ 파라미터 펼치기 동작
-
점수 카드 변경 → 토글로 폼 모드 복귀 시 값 유지
-
게이트 카드: ▾ 파라미터 펼치기, 값 변경 후 폼 모드 복귀 시 유지
-
결합 카드 부제목에
Top N · RR · ATR×M표시 -
결과 카드 부제목에 마지막 실행 정보 표시
-
floating 툴바 5개 버튼 모두 클릭 동작
-
▶ 실행 → 하단 ResultTable 갱신
-
노드 드래그 후 새로고침 → 위치 복원
-
🔄 → 초기 좌표 복귀
-
점수 노드 weight=0 으로 만들면 카드 흐릿 + 해당 엣지 점선·회색
-
⛶ → 화면 맞춤
-
Step 4: 모바일 검증
DevTools → 모바일 뷰 (360×640):
-
헤더에 토글 미표시
-
폼 모드 모바일 카드 layout 정상
-
localStorage에
canvas가 저장돼 있어도 강제로 폼 모드 -
Step 5: localStorage 손상 복구 검증
DevTools Application 탭에서:
-
screener-mode-v1을"INVALID"로 수정 → 새로고침 → 폼 모드로 fallback -
screener-canvas-layout-v1을"NOT_JSON"으로 수정 → 새로고침 → 캔버스 초기 좌표로 복귀 -
Step 6: 배포
cd C:\Users\jaeoh\Desktop\workspace\web-ui
npm run release:nas
Expected: 빌드 + robocopy 성공. NAS에서 https://gahusb.synology.me/stock/screener 접속해 동작 확인.
- Step 7: CLAUDE.md 업데이트
web-ui/CLAUDE.md 의 페이지 구조 표에서 /stock/screener 행 설명을 다음으로 갱신:
| `/stock/screener` | `Screener` | 노드 기반 강세주 스크리너 (폼 ↔ n8n 스타일 캔버스 모드 토글, 점수 노드 7 + 위생 게이트 + ATR 포지션 사이저) |
- Step 8: 최종 commit + push
git add src/pages/stock/screener/CLAUDE.md # if needed
git add CLAUDE.md
git commit -m "docs(screener): note canvas mode in page structure"
git push origin main
완료 후 후속 메모 업데이트 안내
본 plan 완료 시 다음 메모리 파일을 업데이트 권장:
project_stock_screener.md— 캔버스 모드 완료 표시 + 후속 슬라이스에서 "노드 캔버스 UI" 항목 제거- 신규 reference:
reference_react_flow.md—@xyflow/react사용 패턴 (lazy import + jsdom polyfills + Panel 슬롯)
다음 자연스러운 슬라이스 (spec §14):
- 주간 자가학습 (가중치 자동 조정 제안)
- 백테스트 화면
- AI 뉴스 호재/악재 노드