From 4e134eb59a291354651c0deeab78d1ee4d37dd5b Mon Sep 17 00:00:00 2001 From: gahusb Date: Wed, 13 May 2026 21:46:09 +0900 Subject: [PATCH] feat(screener): ScoreNodeCard with weight slider + active toggle + params --- .../components/canvas/nodes/ScoreNodeCard.jsx | 96 +++++++++++++++++++ .../canvas/nodes/ScoreNodeCard.test.jsx | 71 ++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 src/pages/stock/screener/components/canvas/nodes/ScoreNodeCard.jsx create mode 100644 src/pages/stock/screener/components/canvas/nodes/ScoreNodeCard.test.jsx diff --git a/src/pages/stock/screener/components/canvas/nodes/ScoreNodeCard.jsx b/src/pages/stock/screener/components/canvas/nodes/ScoreNodeCard.jsx new file mode 100644 index 0000000..8de03fe --- /dev/null +++ b/src/pages/stock/screener/components/canvas/nodes/ScoreNodeCard.jsx @@ -0,0 +1,96 @@ +import React, { memo, useState } from 'react'; +import { Handle, Position } from '@xyflow/react'; + +const DEFAULT_WEIGHT = 0.5; + +function ParamField({ name, schema, value, onChange }) { + return ( + + ); +} + +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 ( +
+ +
+ {icon} + {meta?.label || meta?.name} + {description && ( + + )} +
+ {summary &&
{summary}
} +
+ onWeightChange(Number(e.target.value))} + aria-label="가중치" + /> + {weight.toFixed(2)} +
+ + + {expanded && ( +
+ {Object.entries(meta?.param_schema || {}).map(([key, schema]) => ( + + ))} +
+ )} + +
+ ); +} + +export default memo(ScoreNodeCard); diff --git a/src/pages/stock/screener/components/canvas/nodes/ScoreNodeCard.test.jsx b/src/pages/stock/screener/components/canvas/nodes/ScoreNodeCard.test.jsx new file mode 100644 index 0000000..194b025 --- /dev/null +++ b/src/pages/stock/screener/components/canvas/nodes/ScoreNodeCard.test.jsx @@ -0,0 +1,71 @@ +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( + + + + ); +} + +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(); + }); +});