feat(screener): ScoreNodeCard with weight slider + active toggle + params
This commit is contained in:
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user