feat(screener): ScoreNodeCard with weight slider + active toggle + params

This commit is contained in:
2026-05-13 21:46:09 +09:00
parent b1a1bb22f9
commit 4e134eb59a
2 changed files with 167 additions and 0 deletions

View File

@@ -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 (
<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);

View File

@@ -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(
<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();
});
});