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