diff --git a/src/renderer/components/main/Modal/content/settings/NoteSetting.test.tsx b/src/renderer/components/main/Modal/content/settings/NoteSetting.test.tsx
new file mode 100644
index 00000000..2578a9a6
--- /dev/null
+++ b/src/renderer/components/main/Modal/content/settings/NoteSetting.test.tsx
@@ -0,0 +1,155 @@
+import React, { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { getDefaultNoteSettings } from '@src/renderer/defaults';
+import type { NoteSettings } from '@src/types/settings/noteSettings';
+
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+vi.mock('@contexts/useTranslation', () => ({
+ useTranslation: () => ({
+ t: (key: string, values?: { value?: number }) =>
+ values?.value === undefined ? key : `${key}:${values.value}`,
+ }),
+}));
+vi.mock('@hooks/usePressAction', () => ({
+ usePressAction: (action?: () => void) => ({ onClick: action }),
+}));
+vi.mock('@components/main/common/Checkbox', () => ({
+ default: ({
+ checked,
+ onChange,
+ }: {
+ checked: boolean;
+ onChange: () => void;
+ }) => (
+
+ ),
+}));
+vi.mock('@components/main/common/TabSwitch', () => ({
+ default: ({
+ tabs,
+ onTabChange,
+ }: {
+ tabs: { id: string; label: string }[];
+ onTabChange: (id: string) => void;
+ }) => (
+
+ {tabs.map((tab) => (
+
+ ))}
+
+ ),
+}));
+vi.mock('@components/main/Grid/PropertiesPanel/PropertyInputs', () => ({
+ PropertyRow: ({
+ label,
+ children,
+ }: {
+ label: React.ReactNode;
+ children: React.ReactNode;
+ }) => (
+
+ ),
+ PropertySection: ({ children }: { children: React.ReactNode }) => (
+
+ ),
+}));
+vi.mock('../../Modal', () => ({
+ default: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+}));
+
+import NoteSetting from './NoteSetting';
+
+describe('NoteSetting 단노트 정책 안내', () => {
+ let container: HTMLDivElement;
+ let root: Root;
+ let originalResizeObserver: typeof ResizeObserver | undefined;
+ let originalRequestAnimationFrame: typeof requestAnimationFrame | undefined;
+ let originalCancelAnimationFrame: typeof cancelAnimationFrame | undefined;
+
+ const renderAdvanced = (overrides: Partial) => {
+ const settings = {
+ ...getDefaultNoteSettings(),
+ ...overrides,
+ };
+ act(() => {
+ root.render();
+ });
+ act(() => {
+ (
+ container.querySelector('[data-tab="advanced"]') as HTMLButtonElement
+ ).click();
+ });
+ };
+
+ beforeEach(() => {
+ originalResizeObserver = globalThis.ResizeObserver;
+ originalRequestAnimationFrame = globalThis.requestAnimationFrame;
+ originalCancelAnimationFrame = globalThis.cancelAnimationFrame;
+ globalThis.ResizeObserver = class ResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ };
+ globalThis.requestAnimationFrame = vi.fn(() => 1);
+ globalThis.cancelAnimationFrame = vi.fn();
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ globalThis.ResizeObserver = originalResizeObserver as typeof ResizeObserver;
+ globalThis.requestAnimationFrame =
+ originalRequestAnimationFrame as typeof requestAnimationFrame;
+ globalThis.cancelAnimationFrame =
+ originalCancelAnimationFrame as typeof cancelAnimationFrame;
+ vi.restoreAllMocks();
+ });
+
+ it('자동 계산에 트랙 높이로 제한한 최소 길이를 쓴다', () => {
+ renderAdvanced({
+ delayedNoteEnabled: true,
+ speed: 70,
+ trackHeight: 20,
+ shortNoteThresholdMs: 300,
+ shortNoteMinLengthPx: 9999,
+ });
+
+ // 9999px가 아니라 트랙 높이 20px 기준 -> 이동 285.7 + 지연 7.1
+ expect(container.textContent).toContain('laboratory.keyDelayAuto:293');
+ });
+
+ it('최대 허용 조합의 추천 지연과 30000ms 입력 상한을 노출한다', () => {
+ renderAdvanced({
+ delayedNoteEnabled: true,
+ speed: 70,
+ trackHeight: 2000,
+ shortNoteThresholdMs: 2000,
+ shortNoteMinLengthPx: 1,
+ });
+
+ expect(container.textContent).toContain('laboratory.keyDelayAuto:29564');
+ const input = container.querySelector(
+ '[data-label="laboratory.keyDelay"] input',
+ ) as HTMLInputElement;
+ expect(input.max).toBe('30000');
+ });
+});
diff --git a/src/renderer/components/main/Modal/content/settings/NoteSetting.tsx b/src/renderer/components/main/Modal/content/settings/NoteSetting.tsx
index 1f779e2f..45c1dddc 100644
--- a/src/renderer/components/main/Modal/content/settings/NoteSetting.tsx
+++ b/src/renderer/components/main/Modal/content/settings/NoteSetting.tsx
@@ -13,6 +13,11 @@ import {
clampValue,
} from '../../../../../../types/settings/noteSettingsConstraints';
import type { NoteSettings } from '../../../../../../types/settings/noteSettings';
+import {
+ toDisplayDelayMs,
+ toEffectiveMinLengthPx,
+ toMinLengthMs,
+} from '@utils/core/noteLengthPolicy';
type ConstraintKey = keyof typeof NOTE_SETTINGS_CONSTRAINTS;
@@ -115,18 +120,27 @@ const NoteSetting = ({
setTabContentHeight((prev) => (prev === nextHeight ? prev : nextHeight));
};
- const calculatedDelay = (() => {
- const safeSpeed = sanitizeNumericValue(speed, 'speed');
- const safeTrackHeight = sanitizeNumericValue(trackHeight, 'trackHeight');
- const safeThreshold = sanitizeNumericValue(
- shortNoteThresholdMs,
- 'shortNoteThresholdMs',
- );
-
- if (safeSpeed <= 0) return 0;
- const travelDelay = Math.round((safeTrackHeight / safeSpeed) * 1000);
- return delayedNoteEnabled ? travelDelay + safeThreshold : travelDelay;
- })();
+ const safeSpeed = sanitizeNumericValue(speed, 'speed');
+ const safeTrackHeight = sanitizeNumericValue(trackHeight, 'trackHeight');
+ const safeThreshold = sanitizeNumericValue(
+ shortNoteThresholdMs,
+ 'shortNoteThresholdMs',
+ );
+ const safeMinLengthPx = sanitizeNumericValue(
+ shortNoteMinLengthPx,
+ 'shortNoteMinLengthPx',
+ );
+ const effectiveMinPx = toEffectiveMinLengthPx(
+ safeMinLengthPx,
+ safeTrackHeight,
+ );
+ const minLengthMs = toMinLengthMs(effectiveMinPx, safeSpeed);
+ const travelDelay = (safeTrackHeight / safeSpeed) * 1000;
+ // 노트 표시 지연은 오버레이 길이 정책과 같은 식을 써야 키·카운터와 정렬이 맞음
+ const noteDisplayDelay = toDisplayDelayMs(minLengthMs, safeThreshold);
+ const calculatedDelay = Math.round(
+ delayedNoteEnabled ? travelDelay + noteDisplayDelay : travelDelay,
+ );
const handleAutoCalculate = () => {
const clamped = clampValue(calculatedDelay, 'keyDisplayDelayMs');
diff --git a/src/renderer/components/overlay/WebGLTracksOGL.tsx b/src/renderer/components/overlay/WebGLTracksOGL.tsx
index ca27d469..49cbf538 100644
--- a/src/renderer/components/overlay/WebGLTracksOGL.tsx
+++ b/src/renderer/components/overlay/WebGLTracksOGL.tsx
@@ -2,6 +2,7 @@ import React, { useEffect, useRef } from 'react';
import { Renderer, Camera, Transform, Program, Geometry, Mesh } from 'ogl';
import type { OGLRenderingContext } from 'ogl';
import { animationScheduler } from '@utils/animation/animationScheduler';
+import { DEFAULT_NOTE_SETTINGS } from '@constants/overlayDefaults';
import { resolvedFadeValues } from '@src/types/settings/noteSettings';
import type { NoteSettings } from '@src/types/settings/noteSettings';
import {
@@ -609,11 +610,15 @@ export function WebGLTracksOGL({
depthWrite: false,
uniforms: {
uTime: { value: 0 },
- uFlowSpeed: { value: noteSettings.speed || 180 },
+ uFlowSpeed: {
+ value: noteSettings.speed || DEFAULT_NOTE_SETTINGS.speed,
+ },
uScreenHeight: { value: window.innerHeight },
uCanvasBottomDomY: { value: window.innerHeight },
uDomPerPx: { value: 1 },
- uTrackHeight: { value: noteSettings.trackHeight || 150 },
+ uTrackHeight: {
+ value: noteSettings.trackHeight || DEFAULT_NOTE_SETTINGS.trackHeight,
+ },
uReverse: { value: noteSettings.reverse ? 1.0 : 0.0 },
uFadeTopPx: { value: resolvedFadeValues(noteSettings).topPx },
uFadeBottomPx: { value: resolvedFadeValues(noteSettings).bottomPx },
@@ -845,8 +850,10 @@ export function WebGLTracksOGL({
const uniforms = programRef.current.uniforms;
frameLimitRef.current = normalizeFrameLimit(noteSettings?.frameLimit);
resetFrameClock(frameClockRef.current);
- uniforms.uFlowSpeed.value = noteSettings.speed || 180;
- uniforms.uTrackHeight.value = noteSettings.trackHeight || 150;
+ uniforms.uFlowSpeed.value =
+ noteSettings.speed || DEFAULT_NOTE_SETTINGS.speed;
+ uniforms.uTrackHeight.value =
+ noteSettings.trackHeight || DEFAULT_NOTE_SETTINGS.trackHeight;
uniforms.uReverse.value = noteSettings.reverse ? 1.0 : 0.0;
const fade = resolvedFadeValues(noteSettings);
uniforms.uFadeTopPx.value = fade.topPx;
@@ -891,7 +898,8 @@ export function WebGLTracksOGL({
};
const refreshCrop = (): void => {
- const trackHeight = noteSettings?.trackHeight || 150;
+ const trackHeight =
+ noteSettings?.trackHeight || DEFAULT_NOTE_SETTINGS.trackHeight;
const desired = computeTrackBounds(tracks, trackHeight);
const applied = cropAppliedRef.current;
// 노트가 살아있는 동안엔 확장만 — 축소는 버퍼가 빌 때 반영 (기존 노트 클리핑 방지)
diff --git a/src/renderer/hooks/app/useAppBootstrap.counterDelayTimers.test.tsx b/src/renderer/hooks/app/useAppBootstrap.counterDelayTimers.test.tsx
new file mode 100644
index 00000000..44c752cb
--- /dev/null
+++ b/src/renderer/hooks/app/useAppBootstrap.counterDelayTimers.test.tsx
@@ -0,0 +1,434 @@
+import React, { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ getKeyCounterSignal,
+ setKeyCounter,
+} from '@stores/signals/keyCounterSignals';
+import { useSettingsStore } from '@stores/useSettingsStore';
+
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+interface MockKeyState {
+ selectedKeyType: string;
+ isBootstrapped: boolean;
+ customTabs: unknown[];
+}
+
+type MockKeyStoreListener = (
+ state: MockKeyState,
+ previousState: MockKeyState,
+) => void;
+
+const mocks = vi.hoisted(() => ({
+ counterChangedListener: null as null | ((payload: unknown) => void),
+ countersChangedListener: null as null | ((payload: unknown) => void),
+ modeChangedListener: null as null | ((payload: unknown) => void),
+ bootstrap: vi.fn(),
+ keyState: {
+ selectedKeyType: '4key',
+ isBootstrapped: true,
+ customTabs: [],
+ } as MockKeyState,
+ keyStoreListeners: new Set(),
+}));
+
+vi.mock('@contexts/useTranslation', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+vi.mock('@stores/data/useKeyStore', () => ({
+ useKeyStore: {
+ getState: vi.fn(() => mocks.keyState),
+ setState: vi.fn(
+ (
+ update:
+ | Partial
+ | ((state: MockKeyState) => Partial),
+ ) => {
+ const previousState = mocks.keyState;
+ const patch =
+ typeof update === 'function' ? update(previousState) : update;
+ mocks.keyState = { ...previousState, ...patch };
+ mocks.keyStoreListeners.forEach((listener) => {
+ listener(mocks.keyState, previousState);
+ });
+ },
+ ),
+ subscribe: vi.fn((listener: MockKeyStoreListener) => {
+ mocks.keyStoreListeners.add(listener);
+ return () => mocks.keyStoreListeners.delete(listener);
+ }),
+ },
+}));
+vi.mock('@stores/data/useStatItemStore', () => ({
+ useStatItemStore: { getState: vi.fn(() => ({ positions: {} })) },
+}));
+vi.mock('@stores/data/useGraphItemStore', () => ({
+ useGraphItemStore: { getState: vi.fn(() => ({ positions: {} })) },
+}));
+vi.mock('@stores/data/useKnobItemStore', () => ({
+ useKnobItemStore: { getState: vi.fn(() => ({ positions: {} })) },
+}));
+vi.mock('@stores/data/useLayerGroupStore', () => ({
+ useLayerGroupStore: { getState: vi.fn(() => ({ layerGroups: {} })) },
+}));
+vi.mock('@stores/useFontStore', () => ({
+ useFontStore: { getState: vi.fn(() => ({})), setState: vi.fn() },
+ syncFontCSS: vi.fn(),
+}));
+vi.mock('@api/pluginDisplayElements', () => ({
+ getUndoRedoInProgress: vi.fn(() => false),
+}));
+vi.mock('@api/modules/obsApi', () => ({
+ obsApi: { onResync: vi.fn(() => vi.fn()) },
+}));
+vi.mock('@api/modules/shared', () => ({
+ notifyLocaleChanged: vi.fn(),
+ subscribe: vi.fn(() => vi.fn()),
+}));
+vi.mock('@api/modules/appApi', () => ({
+ acknowledgeLifecycleAfterEditorFlush: vi.fn(),
+ cancelLifecycleEditorFlush: vi.fn(),
+}));
+vi.mock('@src/renderer/editor/runtime/editorStateCoordinator', () => ({
+ editorCoordinator: {
+ subscribe: vi.fn(() => vi.fn()),
+ getState: vi.fn(() => ({
+ conflict: null,
+ failureKind: null,
+ error: null,
+ })),
+ resolveConflict: vi.fn(),
+ start: vi.fn(),
+ sync: vi.fn(),
+ },
+}));
+vi.mock('@api/modules/selectionSessionApi', () => ({
+ panelWindowApi: {
+ onVisibility: vi.fn(() => vi.fn()),
+ isOpen: vi.fn(),
+ takeViewState: vi.fn(),
+ },
+}));
+vi.mock('@src/renderer/editor/runtime/selectionSync', () => ({
+ initSelectionSync: vi.fn(),
+ resetSelectionForModeChange: vi.fn(),
+}));
+vi.mock('@stores/grid/usePanelWindowStore', () => ({
+ usePanelWindowStore: { getState: vi.fn(() => ({ setDetached: vi.fn() })) },
+}));
+vi.mock('@stores/grid/panelViewHandoff', () => ({
+ applyPanelViewState: vi.fn(),
+}));
+vi.mock('@plugins/rpc/pluginRpcHandler', () => ({
+ initPluginRpcHandler: vi.fn(),
+}));
+vi.mock('@plugins/rpc/pluginSettingsSession', () => ({
+ initPluginSettingsSessionHost: vi.fn(),
+ notePanelVisibilityForSettingsSession: vi.fn(),
+}));
+vi.mock('@plugins/runtime/displayElement/instancesUndoSync', () => ({
+ initPluginInstancesUndoSync: vi.fn(),
+}));
+vi.mock('@api/modules/historyApi', () => ({
+ historyApi: { onStatus: vi.fn(() => vi.fn()) },
+}));
+vi.mock('@stores/data/useHistoryStatusStore', () => ({
+ useHistoryStatusStore: { getState: vi.fn(() => ({ applyStatus: vi.fn() })) },
+ syncHistoryStatus: vi.fn(),
+}));
+vi.mock('@src/renderer/editor/runtime/lifecycleEditorFlush', () => ({
+ flushFocusedEditorForLifecycle: vi.fn(),
+}));
+vi.mock('@src/renderer/editor/runtime/historyEditorFlushLock', () => ({
+ acquireHistoryEditorFlushLock: vi.fn(),
+ releaseHistoryEditorFlushLock: vi.fn(),
+ resetHistoryEditorFlushLock: vi.fn(),
+}));
+vi.mock('@src/renderer/defaults', () => ({
+ initDefaults: vi.fn(),
+ getDefaultNoteSettings: vi.fn(() => ({
+ frameLimit: 0,
+ speed: 400,
+ trackHeight: 300,
+ reverse: false,
+ fadePosition: 'auto',
+ fadeTopPx: 50,
+ fadeBottomPx: 0,
+ reverseFadeTopPx: 0,
+ reverseFadeBottomPx: 50,
+ delayedNoteEnabled: false,
+ shortNoteThresholdMs: 50,
+ shortNoteMinLengthPx: 30,
+ keyDisplayDelayMs: 0,
+ })),
+ getDefaultFontSettings: vi.fn(() => ({ customFonts: [] })),
+ getDefaultGridSettings: vi.fn(() => ({})),
+ getDefaultShortcuts: vi.fn(() => ({})),
+}));
+vi.mock('@utils/grid/cursorUtils', () => ({
+ initializeCursorSystem: vi.fn(),
+ refreshCursorSettings: vi.fn(() => Promise.resolve()),
+}));
+
+import { useAppBootstrap } from './useAppBootstrap';
+
+const Harness = () => {
+ useAppBootstrap();
+ return null;
+};
+
+const makeApiMock = () =>
+ ({
+ app: { bootstrap: mocks.bootstrap },
+ settings: { onChanged: vi.fn(() => vi.fn()) },
+ keys: {
+ onModeChanged: vi.fn((listener: (payload: unknown) => void) => {
+ mocks.modeChangedListener = listener;
+ return vi.fn();
+ }),
+ onCountersChanged: vi.fn((listener: (payload: unknown) => void) => {
+ mocks.countersChangedListener = listener;
+ return vi.fn();
+ }),
+ onCounterChanged: vi.fn((listener: (payload: unknown) => void) => {
+ mocks.counterChangedListener = listener;
+ return vi.fn();
+ }),
+ customTabs: { onChanged: vi.fn(() => vi.fn()) },
+ },
+ noteTab: {
+ onChanged: vi.fn(() => vi.fn()),
+ onChangedAll: vi.fn(() => vi.fn()),
+ },
+ presets: { onSnapshot: vi.fn(() => vi.fn()) },
+ overlay: {
+ onLock: vi.fn(() => vi.fn()),
+ onAnchor: vi.fn(() => vi.fn()),
+ },
+ css: {
+ onUse: vi.fn(() => vi.fn()),
+ onContent: vi.fn(() => vi.fn()),
+ },
+ js: {
+ onUse: vi.fn(() => vi.fn()),
+ onState: vi.fn(() => vi.fn()),
+ },
+ } as unknown as Window['api']);
+
+describe('카운터 지연 타이머', () => {
+ let container: HTMLDivElement;
+ let root: Root;
+ let mounted: boolean;
+ let originalApi: Window['api'];
+ let originalWindowType: typeof window.__dmn_window_type;
+
+ const emitCounter = (count: number, mode = '4key', key = 'KeyK') => {
+ act(() => {
+ mocks.counterChangedListener?.({
+ mode,
+ key,
+ count,
+ });
+ });
+ };
+
+ beforeEach(async () => {
+ vi.useFakeTimers();
+ originalApi = window.api;
+ originalWindowType = window.__dmn_window_type;
+ window.__dmn_window_type = 'overlay';
+ window.api = makeApiMock();
+ mocks.bootstrap.mockReset();
+ mocks.bootstrap.mockReturnValue(new Promise(() => {}));
+ mocks.counterChangedListener = null;
+ mocks.countersChangedListener = null;
+ mocks.modeChangedListener = null;
+ mocks.keyState = {
+ selectedKeyType: '4key',
+ isBootstrapped: true,
+ customTabs: [],
+ };
+ mocks.keyStoreListeners.clear();
+ setKeyCounter('4key', 'KeyK', 0);
+ setKeyCounter('8key', 'KeyL', 0);
+ useSettingsStore.setState((state) => ({
+ noteSettings: {
+ ...state.noteSettings,
+ keyDisplayDelayMs: 30000,
+ },
+ tabNoteOverrides: {},
+ }));
+
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+ mounted = true;
+ await act(async () => {
+ root.render();
+ });
+ });
+
+ afterEach(() => {
+ if (mounted) {
+ act(() => root.unmount());
+ }
+ container.remove();
+ window.api = originalApi;
+ window.__dmn_window_type = originalWindowType;
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ });
+
+ it('지연 설정 변경 시 대기 중인 카운터를 예약 순서대로 반영한다', () => {
+ const transitions: number[] = [];
+ const unsubscribe = getKeyCounterSignal('4key', 'KeyK').subscribe(
+ (value) => {
+ transitions.push(value);
+ },
+ );
+ transitions.length = 0;
+
+ emitCounter(1);
+ emitCounter(2);
+ expect(vi.getTimerCount()).toBe(2);
+
+ act(() => {
+ useSettingsStore.setState((state) => ({
+ noteSettings: {
+ ...state.noteSettings,
+ keyDisplayDelayMs: 10000,
+ },
+ }));
+ });
+
+ expect(vi.getTimerCount()).toBe(0);
+ expect(transitions).toEqual([1, 2]);
+ expect(getKeyCounterSignal('4key', 'KeyK').value).toBe(2);
+
+ const transitionCount = transitions.length;
+ vi.advanceTimersByTime(30000);
+ expect(transitions).toHaveLength(transitionCount);
+ expect(getKeyCounterSignal('4key', 'KeyK').value).toBe(2);
+
+ emitCounter(3);
+ vi.advanceTimersByTime(9999);
+ expect(getKeyCounterSignal('4key', 'KeyK').value).toBe(2);
+ vi.advanceTimersByTime(1);
+ expect(getKeyCounterSignal('4key', 'KeyK').value).toBe(3);
+ unsubscribe();
+ });
+
+ it('지연을 0으로 변경해도 대기 중인 카운터를 즉시 반영한다', () => {
+ emitCounter(3);
+ expect(vi.getTimerCount()).toBe(1);
+
+ act(() => {
+ useSettingsStore.setState((state) => ({
+ noteSettings: {
+ ...state.noteSettings,
+ keyDisplayDelayMs: 0,
+ },
+ }));
+ });
+
+ expect(vi.getTimerCount()).toBe(0);
+ expect(getKeyCounterSignal('4key', 'KeyK').value).toBe(3);
+ vi.advanceTimersByTime(30000);
+ expect(getKeyCounterSignal('4key', 'KeyK').value).toBe(3);
+ });
+
+ it('활성 탭 오버라이드 변경 시 대기 값을 반영하고 새 지연을 사용한다', () => {
+ emitCounter(1);
+ expect(vi.getTimerCount()).toBe(1);
+
+ act(() => {
+ useSettingsStore.setState({
+ tabNoteOverrides: {
+ '4key': { keyDisplayDelayMs: 10000 },
+ },
+ });
+ });
+
+ expect(vi.getTimerCount()).toBe(0);
+ expect(getKeyCounterSignal('4key', 'KeyK').value).toBe(1);
+
+ emitCounter(2);
+ vi.advanceTimersByTime(9999);
+ expect(getKeyCounterSignal('4key', 'KeyK').value).toBe(1);
+ vi.advanceTimersByTime(1);
+ expect(getKeyCounterSignal('4key', 'KeyK').value).toBe(2);
+ });
+
+ it('탭 전환 시 이전 탭 대기 값을 반영하고 새 탭 지연을 사용한다', () => {
+ act(() => {
+ useSettingsStore.setState({
+ tabNoteOverrides: {
+ '4key': { keyDisplayDelayMs: 30000 },
+ '8key': { keyDisplayDelayMs: 10000 },
+ },
+ });
+ });
+
+ emitCounter(1);
+ expect(vi.getTimerCount()).toBe(1);
+
+ act(() => {
+ mocks.modeChangedListener?.({ mode: '8key' });
+ });
+
+ expect(vi.getTimerCount()).toBe(0);
+ expect(getKeyCounterSignal('4key', 'KeyK').value).toBe(1);
+
+ emitCounter(1, '8key', 'KeyL');
+ vi.advanceTimersByTime(9999);
+ expect(getKeyCounterSignal('8key', 'KeyL').value).toBe(0);
+ vi.advanceTimersByTime(1);
+ expect(getKeyCounterSignal('8key', 'KeyL').value).toBe(1);
+ });
+
+ it('카운터 reset 스냅샷 수신 시 모든 카운터 타이머를 취소한다', () => {
+ const transitions: number[] = [];
+ const unsubscribe = getKeyCounterSignal('4key', 'KeyK').subscribe(
+ (value) => {
+ transitions.push(value);
+ },
+ );
+ transitions.length = 0;
+
+ emitCounter(4);
+ expect(vi.getTimerCount()).toBe(1);
+
+ act(() => {
+ mocks.countersChangedListener?.({ '4key': { KeyK: 0 } });
+ });
+
+ expect(vi.getTimerCount()).toBe(0);
+ expect(transitions).not.toContain(4);
+ vi.advanceTimersByTime(30000);
+ expect(getKeyCounterSignal('4key', 'KeyK').value).toBe(0);
+ unsubscribe();
+ });
+
+ it('unmount 시 모든 카운터 타이머를 취소한다', () => {
+ const transitions: number[] = [];
+ const unsubscribe = getKeyCounterSignal('4key', 'KeyK').subscribe(
+ (value) => {
+ transitions.push(value);
+ },
+ );
+ transitions.length = 0;
+
+ emitCounter(5);
+ expect(vi.getTimerCount()).toBe(1);
+
+ act(() => root.unmount());
+ mounted = false;
+
+ expect(vi.getTimerCount()).toBe(0);
+ expect(transitions).not.toContain(5);
+ vi.advanceTimersByTime(30000);
+ expect(getKeyCounterSignal('4key', 'KeyK').value).toBe(0);
+ unsubscribe();
+ });
+});
diff --git a/src/renderer/hooks/app/useAppBootstrap.ts b/src/renderer/hooks/app/useAppBootstrap.ts
index df3ce718..8cb26a35 100644
--- a/src/renderer/hooks/app/useAppBootstrap.ts
+++ b/src/renderer/hooks/app/useAppBootstrap.ts
@@ -54,7 +54,10 @@ import {
import type { BootstrapPayload } from '@src/types/app';
import type { CustomTab } from '@src/types/key/keys';
import type { EditorCoordinatorState } from '@src/renderer/editor/runtime/editorCoordinator';
-import type { TabNoteOverrides } from '@src/types/settings/noteSettings';
+import {
+ mergeNoteSettings,
+ type TabNoteOverrides,
+} from '@src/types/settings/noteSettings';
import type {
SettingsDiff,
OverlayResizeAnchor,
@@ -147,9 +150,14 @@ export function useAppBootstrap() {
let conflictDialogOpen = false;
let lastShownPermanentEditorError: unknown = null;
// 키 표시 딜레이와 동기화를 위한 카운터 업데이트 지연
+ type CounterDelayTimerHandle = ReturnType;
const counterDelayTimers = new Map<
string,
- Set>
+ Map void>
+ >();
+ const pendingCounterDelayTimers = new Map<
+ CounterDelayTimerHandle,
+ () => void
>();
const composeCounterKey = (mode?: string, key?: string) =>
@@ -159,16 +167,31 @@ export function useAppBootstrap() {
if (composedKey) {
const timers = counterDelayTimers.get(composedKey);
if (timers) {
- timers.forEach((timer) => clearTimeout(timer));
+ timers.forEach((_apply, timer) => {
+ clearTimeout(timer);
+ pendingCounterDelayTimers.delete(timer);
+ });
counterDelayTimers.delete(composedKey);
}
return;
}
- counterDelayTimers.forEach((timers) => {
- timers.forEach((timer) => clearTimeout(timer));
- });
+ pendingCounterDelayTimers.forEach((_apply, timer) => clearTimeout(timer));
+ pendingCounterDelayTimers.clear();
+ counterDelayTimers.forEach((timers) => timers.clear());
+ counterDelayTimers.clear();
+ };
+
+ const flushCounterDelayTimers = () => {
+ const pending = [...pendingCounterDelayTimers.entries()];
+ pendingCounterDelayTimers.clear();
+ counterDelayTimers.forEach((timers) => timers.clear());
counterDelayTimers.clear();
+
+ pending.forEach(([timer, apply]) => {
+ clearTimeout(timer);
+ apply();
+ });
};
const scheduleEditorCoordinatorRecovery = () => {
@@ -185,12 +208,29 @@ export function useAppBootstrap() {
}, 1_000);
};
- const getCounterDelayMs = () => {
- const { noteSettings } = useSettingsStore.getState();
- const delay = Number(noteSettings?.keyDisplayDelayMs ?? 0);
+ const resolveCounterDelayMs = (
+ noteSettings: SettingsStateSnapshot['noteSettings'],
+ tabNoteOverrides: SettingsStateSnapshot['tabNoteOverrides'],
+ selectedKeyType: string,
+ ) => {
+ const effectiveSettings = mergeNoteSettings(
+ noteSettings,
+ tabNoteOverrides?.[selectedKeyType],
+ );
+ const delay = Number(effectiveSettings.keyDisplayDelayMs ?? 0);
return delay > 0 ? delay : 0;
};
+ const getCounterDelayMs = () => {
+ const { noteSettings, tabNoteOverrides } = useSettingsStore.getState();
+ const { selectedKeyType } = useKeyStore.getState();
+ return resolveCounterDelayMs(
+ noteSettings,
+ tabNoteOverrides,
+ selectedKeyType,
+ );
+ };
+
const scheduleCounterUpdate = (
mode: string,
key: string,
@@ -205,24 +245,30 @@ export function useAppBootstrap() {
return;
}
- const timer = setTimeout(() => {
+ const apply = () => {
if (disposed) return;
setKeyCounter(mode, key, count);
+ };
+ const timer = setTimeout(() => {
+ const pendingApply = pendingCounterDelayTimers.get(timer);
+ if (!pendingApply) return;
+
+ pendingCounterDelayTimers.delete(timer);
const timers = counterDelayTimers.get(composedKey);
- if (timers) {
- timers.delete(timer);
- if (timers.size === 0) {
- counterDelayTimers.delete(composedKey);
- }
+ timers?.delete(timer);
+ if (timers?.size === 0) {
+ counterDelayTimers.delete(composedKey);
}
+ pendingApply();
}, delayMs);
const existing = counterDelayTimers.get(composedKey);
if (existing) {
- existing.add(timer);
+ existing.set(timer, apply);
} else {
- counterDelayTimers.set(composedKey, new Set([timer]));
+ counterDelayTimers.set(composedKey, new Map([[timer, apply]]));
}
+ pendingCounterDelayTimers.set(timer, apply);
};
const { setAll, merge } = useSettingsStore.getState();
@@ -757,12 +803,24 @@ export function useAppBootstrap() {
void runResync();
}),
useSettingsStore.subscribe((state, previousState) => {
- const nextDelay = Number(state.noteSettings?.keyDisplayDelayMs ?? 0);
- const prevDelay = previousState
- ? Number(previousState.noteSettings?.keyDisplayDelayMs ?? 0)
- : 0;
- if (nextDelay <= 0 && prevDelay > 0) {
- clearCounterDelayTimers();
+ const { selectedKeyType } = useKeyStore.getState();
+ const nextDelay = resolveCounterDelayMs(
+ state.noteSettings,
+ state.tabNoteOverrides,
+ selectedKeyType,
+ );
+ const prevDelay = resolveCounterDelayMs(
+ previousState.noteSettings,
+ previousState.tabNoteOverrides,
+ selectedKeyType,
+ );
+ if (nextDelay !== prevDelay) {
+ flushCounterDelayTimers();
+ }
+ }),
+ useKeyStore.subscribe((state, previousState) => {
+ if (state.selectedKeyType !== previousState.selectedKeyType) {
+ flushCounterDelayTimers();
}
}),
];
diff --git a/src/renderer/hooks/overlay/useNoteSystem.continuity.test.tsx b/src/renderer/hooks/overlay/useNoteSystem.continuity.test.tsx
new file mode 100644
index 00000000..043a7a9b
--- /dev/null
+++ b/src/renderer/hooks/overlay/useNoteSystem.continuity.test.tsx
@@ -0,0 +1,356 @@
+import React, { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { useNoteSystem } from './useNoteSystem';
+
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+type HookResult = ReturnType;
+
+interface Settings {
+ speed: number;
+ trackHeight: number;
+ delayedNoteEnabled: boolean;
+ shortNoteThresholdMs: number;
+ shortNoteMinLengthPx: number;
+}
+
+const Harness = ({
+ noteSettings,
+ onResult,
+}: {
+ noteSettings: Settings;
+ onResult: (r: HookResult) => void;
+}) => {
+ onResult(useNoteSystem({ noteEffect: true, noteSettings }));
+ return null;
+};
+
+// 계약서 수식을 독립 구현한 오라클 - 구현 코드를 참조하지 않는다
+const oracle = (s: Settings) => {
+ const effectiveMinPx = Math.min(s.shortNoteMinLengthPx, s.trackHeight);
+ const m = (effectiveMinPx * 1000) / s.speed;
+ const T = s.shortNoteThresholdMs;
+ if (T <= m) {
+ return {
+ m,
+ T,
+ D: 0,
+ C: m,
+ W: T - m,
+ L: (h: number) => Math.max(m, h),
+ };
+ }
+ const D = (T - m) / 2;
+ const C = m + D;
+ const W = T - C;
+ const L = (h: number) => {
+ if (h <= C) return m;
+ if (h >= T) return h;
+ const x = (h - C) / W;
+ return h - D + D * (3 * x * x - 2 * x * x * x);
+ };
+ return { m, T, D, C, W, L };
+};
+
+const USER: Settings = {
+ speed: 700,
+ trackHeight: 300,
+ delayedNoteEnabled: true,
+ shortNoteThresholdMs: 60,
+ shortNoteMinLengthPx: 24,
+};
+const LEGACY: Settings = {
+ speed: 400,
+ trackHeight: 300,
+ delayedNoteEnabled: true,
+ shortNoteThresholdMs: 100,
+ shortNoteMinLengthPx: 10,
+};
+const HIGH_SPEED: Settings = {
+ speed: 3000,
+ trackHeight: 300,
+ delayedNoteEnabled: true,
+ shortNoteThresholdMs: 60,
+ shortNoteMinLengthPx: 1,
+};
+// degenerate: T <= m (m = 50*1000/700 ≈ 71.4 > T = 40)
+const DEGEN: Settings = {
+ speed: 700,
+ trackHeight: 300,
+ delayedNoteEnabled: true,
+ shortNoteThresholdMs: 40,
+ shortNoteMinLengthPx: 50,
+};
+const TRACK_CAPPED: Settings = {
+ speed: 1000,
+ trackHeight: 20,
+ delayedNoteEnabled: true,
+ shortNoteThresholdMs: 100,
+ shortNoteMinLengthPx: 500,
+};
+const TRACK_EXPANDED: Settings = {
+ ...TRACK_CAPPED,
+ trackHeight: 500,
+};
+
+describe('노트 길이 연속성 계약 독립 검증', () => {
+ let container: HTMLDivElement;
+ let root: Root;
+ let result: HookResult;
+ let nowMs: number;
+
+ const advance = async (ms: number) => {
+ const target = nowMs + ms;
+ while (nowMs < target) {
+ nowMs = Math.min(nowMs + 1, target);
+ vi.advanceTimersByTime(1);
+ }
+ };
+
+ const render = async (noteSettings: Settings) => {
+ await act(async () => {
+ root.render(
+ {
+ result = v;
+ }}
+ />,
+ );
+ });
+ };
+
+ beforeEach(() => {
+ nowMs = 0;
+ vi.useFakeTimers();
+ vi.spyOn(performance, 'now').mockImplementation(() => nowMs);
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ });
+
+ const noteOf = (key: string) => result.notesRef.current[key]?.[0];
+
+ // hold h 를 재생하고 최종 (startTime, endTime) 반환
+ // upBeforeStart=true 면 노트 생성 전에 UP을 흘려보낸다 (타이머 경합 경로)
+ const play = async (
+ key: string,
+ h: number,
+ s: Settings,
+ upBeforeStart: boolean,
+ ) => {
+ const down = nowMs;
+ result.handleKeyDown(key, { displayTime: down, physTime: down });
+ const o = oracle(s);
+ if (!upBeforeStart) {
+ // 전달 지연 0: 정확히 down+h 시점에 UP 처리 (NoShrink 클램프가 끼어들지 않는 조건)
+ await advance(h);
+ }
+ result.handleKeyUp(key, {
+ displayTime: down + h,
+ physTime: down + h,
+ holdDurationMs: h,
+ });
+ // 완료될 때까지만 전진 - 더 가면 cleanup이 노트를 회수한다
+ const cap =
+ Math.ceil(o.D + Math.max(o.L(h), h) + s.shortNoteThresholdMs) + 5;
+ let n = noteOf(key);
+ for (let i = 0; i < cap && (!n || n.endTime == null); i += 1) {
+ await advance(1);
+ n = noteOf(key);
+ }
+ return n && n.endTime != null
+ ? { start: n.startTime - down, end: n.endTime - down }
+ : null;
+ };
+
+ for (const [label, s] of [
+ ['유저 설정 24/60/700', USER],
+ ['기존 테스트 설정 10/100/400', LEGACY],
+ ] as const) {
+ it(`${label}: L(h)가 계약 수식과 일치하고 정확 구간 결손이 0이다`, async () => {
+ const o = oracle(s);
+ for (const h of [
+ 10, 25, 34, 40, 47, 50, 55, 59, 60, 62, 70, 80, 99, 100, 150, 300,
+ ]) {
+ await render(s);
+ const r = await play(`K${h}`, h, s, false);
+ expect(r, `hold ${h}`).not.toBeNull();
+ // 시작 시각은 downTime + D
+ expect(r!.start, `hold ${h} start`).toBeCloseTo(o.D, 3);
+ // 길이는 계약 수식
+ expect(r!.end - r!.start, `hold ${h} length`).toBeCloseTo(o.L(h), 3);
+ // 롱노트 결손 0
+ if (h >= Math.max(o.T, o.m))
+ expect(r!.end - r!.start, `hold ${h} 롱노트 결손`).toBeCloseTo(h, 3);
+ await act(async () => root.unmount());
+ container.remove();
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+ nowMs += 1000;
+ }
+ });
+
+ it(`${label}: 경계 구간을 실제 훅으로 스윕해 연속·단조를 확인한다`, async () => {
+ const o = oracle(s);
+ // C와 T 주변을 0.5ms 간격으로 실제 훅에 통과시킨다
+ const holds: number[] = [];
+ for (let h = Math.max(0, o.C - 3); h <= o.T + 3; h += 0.5) holds.push(h);
+
+ const measured: number[] = [];
+ for (const h of holds) {
+ await render(s);
+ const r = await play(`S${h}`, h, s, false);
+ expect(r, `hold ${h}`).not.toBeNull();
+ measured.push(r!.end - r!.start);
+ await act(async () => root.unmount());
+ container.remove();
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+ nowMs += 1000;
+ }
+
+ for (let i = 0; i < holds.length; i += 1) {
+ // 구현이 계약 수식과 일치
+ expect(measured[i], `hold ${holds[i]}`).toBeCloseTo(o.L(holds[i]), 3);
+ if (i === 0) continue;
+ const step = holds[i] - holds[i - 1];
+ const delta = measured[i] - measured[i - 1];
+ // 단조증가
+ expect(delta, `hold ${holds[i]} 단조성`).toBeGreaterThanOrEqual(-1e-6);
+ // 계단 없음: 최대 기울기 2.5를 넘는 도약이 없어야 한다
+ expect(delta, `hold ${holds[i]} 계단`).toBeLessThanOrEqual(
+ step * 2.5 + 1e-6,
+ );
+ }
+ });
+
+ it(`${label}: UP이 노트 생성 전/후 어느 쪽이든 결과가 같다`, async () => {
+ for (const h of [30, 55, 70, 120]) {
+ await render(s);
+ const a = await play(`A${h}`, h, s, true);
+ await act(async () => root.unmount());
+ container.remove();
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+ nowMs += 1000;
+
+ await render(s);
+ const b = await play(`B${h}`, h, s, false);
+ expect(a, `hold ${h}`).not.toBeNull();
+ expect(b, `hold ${h}`).not.toBeNull();
+ expect(a!.end - a!.start, `hold ${h} 경로 불변`).toBeCloseTo(
+ b!.end - b!.start,
+ 3,
+ );
+ await act(async () => root.unmount());
+ container.remove();
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+ nowMs += 1000;
+ }
+ });
+ }
+
+ it('최소 길이 px를 ms 반올림 없이 보존한다', async () => {
+ for (const [key, s, h] of [
+ ['USER', USER, 30],
+ ['HIGH_SPEED', HIGH_SPEED, 1],
+ ] as const) {
+ await render(s);
+ const r = await play(key, h, s, true);
+ expect(r).not.toBeNull();
+ const lengthPx = ((r!.end - r!.start) * s.speed) / 1000;
+ expect(lengthPx).toBeCloseTo(s.shortNoteMinLengthPx, 6);
+ await act(async () => root.unmount());
+ container.remove();
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+ nowMs += 1000;
+ }
+ });
+
+ it('실수 D와 동일한 램프 폭을 보존한다', () => {
+ const userPolicy = oracle(USER);
+ const legacyPolicy = oracle(LEGACY);
+
+ expect(userPolicy.D).toBeCloseTo(12.857142857142858, 12);
+ expect(userPolicy.W).toBeCloseTo(userPolicy.D, 12);
+ expect(legacyPolicy.D).toBe(37.5);
+ expect(legacyPolicy.W).toBe(37.5);
+ });
+
+ it('최소 길이를 트랙 높이로 제한하고 트랙 확장 시 저장값을 다시 적용한다', async () => {
+ for (const [key, s, expectedPx] of [
+ ['CAPPED', TRACK_CAPPED, 20],
+ ['EXPANDED', TRACK_EXPANDED, 500],
+ ] as const) {
+ await render(s);
+ const r = await play(key, 10, s, false);
+ expect(r).not.toBeNull();
+ const lengthPx = ((r!.end - r!.start) * s.speed) / 1000;
+ expect(lengthPx).toBeCloseTo(expectedPx, 6);
+ expect(s.shortNoteMinLengthPx).toBe(500);
+ await act(async () => root.unmount());
+ container.remove();
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+ nowMs += 1000;
+ }
+ });
+
+ it('degenerate T<=m: L(h)=max(m,h), 지연 0, 계단 없음', async () => {
+ const s = DEGEN;
+ const o = oracle(s);
+ expect(o.T).toBeLessThanOrEqual(o.m);
+ for (const h of [10, 40, 71, 100, 200]) {
+ await render(s);
+ const r = await play(`D${h}`, h, s, false);
+ expect(r, `hold ${h}`).not.toBeNull();
+ expect(r!.start, `hold ${h} start`).toBeCloseTo(0, 3);
+ expect(r!.end - r!.start, `hold ${h}`).toBeCloseTo(Math.max(o.m, h), 3);
+ await act(async () => root.unmount());
+ container.remove();
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+ nowMs += 1000;
+ }
+ });
+
+ it('NoShrink: UP 전달이 D보다 늦어도 노트가 줄어들지 않는다', async () => {
+ const s = USER;
+ const o = oracle(s);
+ await render(s);
+ const down = nowMs;
+ result.handleKeyDown('S', { displayTime: down, physTime: down });
+ // 노트 생성 후 한참 자란 뒤에야 UP이 도착 - hold 자체는 평탄 구간(40ms)
+ await advance(200);
+ const shownAtUp = nowMs - (down + o.D);
+ result.handleKeyUp('S', {
+ displayTime: down + 40,
+ physTime: down + 40,
+ holdDurationMs: 40,
+ });
+ await advance(400);
+ const n = noteOf('S');
+ expect(n).toBeDefined();
+ const finalLen = n!.endTime! - n!.startTime;
+ // 이미 화면에 그려진 길이보다 짧아지면 시각적 shrink
+ expect(finalLen).toBeGreaterThanOrEqual(shownAtUp - 1e-6);
+ });
+});
diff --git a/src/renderer/hooks/overlay/useNoteSystem.test.tsx b/src/renderer/hooks/overlay/useNoteSystem.test.tsx
index e9e3e87e..6d89b2ad 100644
--- a/src/renderer/hooks/overlay/useNoteSystem.test.tsx
+++ b/src/renderer/hooks/overlay/useNoteSystem.test.tsx
@@ -25,7 +25,7 @@ const Harness = ({ noteEffect, noteSettings, onResult }: HarnessProps) => {
return null;
};
-// threshold 100ms, 최소 길이 10px @ 400px/s = 25ms
+// m=25, T=100, D=37.5, C=62.5, W=37.5
const DELAY_SETTINGS = {
speed: 400,
trackHeight: 300,
@@ -34,7 +34,17 @@ const DELAY_SETTINGS = {
shortNoteMinLengthPx: 10,
};
-describe('useNoteSystem 단/롱 판정', () => {
+const REFERENCE_LENGTHS = [
+ { holdMs: 40, lengthMs: 25 },
+ { holdMs: 62.5, lengthMs: 25 },
+ { holdMs: 70, lengthMs: 36.4 },
+ { holdMs: 80, lengthMs: 59.37777777777778 },
+ { holdMs: 99, lengthMs: 98.92142222222222 },
+ { holdMs: 100, lengthMs: 100 },
+ { holdMs: 150, lengthMs: 150 },
+] as const;
+
+describe('useNoteSystem 길이 연속성', () => {
let container: HTMLDivElement;
let root: Root;
let result: HookResult;
@@ -68,6 +78,25 @@ describe('useNoteSystem 단/롱 판정', () => {
const noteOf = (key: string, index = 0) =>
result.notesRef.current[key]?.[index];
+ const releaseWithDaemonHold = async (
+ key: string,
+ holdMs: number,
+ settleMs = Math.ceil(37.5 + Math.max(25, holdMs)) + 1,
+ ) => {
+ const downTime = nowMs;
+ result.handleKeyDown(key, {
+ displayTime: downTime,
+ physTime: downTime,
+ });
+ result.handleKeyUp(key, {
+ displayTime: downTime + holdMs,
+ physTime: downTime + holdMs,
+ holdDurationMs: holdMs,
+ });
+ await advance(settleMs);
+ return { downTime, note: noteOf(key)! };
+ };
+
beforeEach(() => {
vi.useFakeTimers();
nowMs = 0;
@@ -84,118 +113,175 @@ describe('useNoteSystem 단/롱 판정', () => {
vi.restoreAllMocks();
});
- it('UP이 startTimer 이후 도착해도 hold < threshold면 고정 길이 단노트다', async () => {
+ it('계약 참조 수치대로 램프 길이를 계산한다', async () => {
await render();
- // 물리 hold 80ms < threshold 100ms인 탭. 전달 지연으로 UP 콜백은
- // 타이머 발화(100ms) 뒤인 110ms에 도착 - 1.6.1 회귀의 핵심 시나리오
- result.handleKeyDown('Z', { displayTime: 0, physTime: 0 });
- await advance(105);
- result.handleKeyUp('Z', {
- displayTime: 80,
- physTime: 80,
- holdDurationMs: 80,
+ for (const [index, { holdMs, lengthMs }] of REFERENCE_LENGTHS.entries()) {
+ const { downTime, note } = await releaseWithDaemonHold(
+ `R${index}`,
+ holdMs,
+ );
+
+ expect(note.isActive).toBe(false);
+ expect(note.startTime).toBe(downTime + 37.5);
+ expect(note.endTime).toBeCloseTo(downTime + 37.5 + lengthMs, 3);
+ }
+ });
+
+ it('C와 T 경계에서 길이가 연속이다', async () => {
+ await render();
+
+ const atC = await releaseWithDaemonHold('C0', 62.5);
+ const afterC = await releaseWithDaemonHold('C1', 62.501);
+ const beforeT = await releaseWithDaemonHold('T0', 99.999);
+ const atT = await releaseWithDaemonHold('T1', 100);
+
+ const lengthAtC = atC.note.endTime! - atC.note.startTime;
+ const lengthAfterC = afterC.note.endTime! - afterC.note.startTime;
+ const lengthBeforeT = beforeT.note.endTime! - beforeT.note.startTime;
+ const lengthAtT = atT.note.endTime! - atT.note.startTime;
+
+ expect(lengthAtC).toBe(25);
+ expect(lengthAfterC - lengthAtC).toBeCloseTo(0.001, 3);
+ expect(lengthAtT).toBe(100);
+ expect(lengthAtT - lengthBeforeT).toBeCloseTo(0.001, 3);
+ });
+
+ it('hold가 증가할 때 길이가 감소하지 않고 램프 이후에는 증가한다', async () => {
+ await render();
+
+ const measuredLengths: number[] = [];
+ for (const [index, { holdMs }] of REFERENCE_LENGTHS.entries()) {
+ const { note } = await releaseWithDaemonHold(`M${index}`, holdMs);
+ measuredLengths.push(note.endTime! - note.startTime);
+ }
+
+ for (let index = 1; index < measuredLengths.length; index += 1) {
+ expect(measuredLengths[index]).toBeGreaterThanOrEqual(
+ measuredLengths[index - 1],
+ );
+ }
+ for (let index = 2; index < measuredLengths.length; index += 1) {
+ expect(measuredLengths[index]).toBeGreaterThan(
+ measuredLengths[index - 1],
+ );
+ }
+ });
+
+ it('h >= T에서는 hold 길이를 그대로 보존한다', async () => {
+ await render();
+
+ for (const [index, holdMs] of [100, 150, 500].entries()) {
+ const { note } = await releaseWithDaemonHold(`L${index}`, holdMs);
+ expect(note.endTime! - note.startTime).toBe(holdMs);
+ }
+ });
+
+ it('T <= m이면 D=0과 L=max(m,h)로 폴백한다', async () => {
+ await render({ ...DELAY_SETTINGS, shortNoteThresholdMs: 20 });
+
+ result.handleKeyDown('SYNC', { displayTime: nowMs, physTime: nowMs });
+ expect(noteOf('SYNC')).toBeDefined();
+ result.handleKeyUp('SYNC', {
+ displayTime: nowMs + 10,
+ physTime: nowMs + 10,
+ holdDurationMs: 10,
});
- await advance(30);
- const note = noteOf('Z');
- expect(note).toBeDefined();
- expect(note!.isActive).toBe(false);
- expect(note!.startTime).toBe(100);
- // 단노트 고정 길이 25ms (80ms가 아님)
- expect(note!.endTime).toBe(125);
+ const short = await releaseWithDaemonHold('D0', 10, 30);
+ const boundary = await releaseWithDaemonHold('D1', 20, 30);
+ const long = await releaseWithDaemonHold('D2', 40, 50);
+
+ expect(short.note.startTime).toBe(short.downTime);
+ expect(short.note.endTime! - short.note.startTime).toBe(25);
+ expect(boundary.note.startTime).toBe(boundary.downTime);
+ expect(boundary.note.endTime! - boundary.note.startTime).toBe(25);
+ expect(long.note.startTime).toBe(long.downTime);
+ expect(long.note.endTime! - long.note.startTime).toBe(40);
});
- it('UP이 startTimer 이전에 도착해도 hold >= threshold면 롱노트다', async () => {
+ it('UP 전달이 늦으면 NoShrink 클램프로 현재 시각보다 줄지 않는다', async () => {
await render();
- // 배치 전달로 DOWN/UP이 함께 늦게 도착한 진짜 600ms 홀드.
- // 기존 코드는 타이머 생존만 보고 단노트로 오분류했다
+ result.handleKeyDown('N', { displayTime: 0, physTime: 0 });
+ await advance(80);
+ result.handleKeyUp('N', {
+ displayTime: 62.5,
+ physTime: 62.5,
+ holdDurationMs: 62.5,
+ });
+
+ const note = noteOf('N');
+ expect(note!.startTime).toBe(37.5);
+ expect(note!.endTime).toBe(80);
+ });
+
+ it('startTimer 이전 UP도 데몬 hold가 T 이상이면 롱노트로 보존한다', async () => {
+ await render();
+
+ // 배치 전달된 실제 600ms 홀드를 타이머 생존 여부로 오분류하지 않음
result.handleKeyDown('Z', { displayTime: 0, physTime: 0 });
result.handleKeyUp('Z', {
displayTime: 5,
physTime: 5,
holdDurationMs: 600,
});
- await advance(100);
- await advance(650);
+ await advance(638);
const note = noteOf('Z');
expect(note!.isActive).toBe(false);
- expect(note!.startTime).toBe(100);
- expect(note!.endTime).toBe(700);
- });
-
- it('경계 정책: hold == threshold는 롱, 미만은 단', async () => {
- await render();
-
- result.handleKeyDown('A', { displayTime: 0, physTime: 0 });
- await advance(105);
- result.handleKeyUp('A', {
- displayTime: 100,
- physTime: 100,
- holdDurationMs: 100,
- });
- await advance(120);
- expect(noteOf('A')!.endTime).toBe(200);
-
- result.handleKeyDown('B', { displayTime: nowMs, physTime: nowMs });
- const bDown = nowMs;
- await advance(105);
- result.handleKeyUp('B', {
- displayTime: bDown + 99,
- physTime: bDown + 99,
- holdDurationMs: 99,
- });
- await advance(30);
- expect(noteOf('B')!.endTime).toBe(bDown + 125);
+ expect(note!.startTime).toBe(37.5);
+ expect(note!.endTime).toBe(637.5);
});
it('holdDurationMs가 NaN·음수면 비클램프 시각 차로 폴백한다', async () => {
await render();
result.handleKeyDown('N', { displayTime: 0, physTime: 0 });
- await advance(105);
result.handleKeyUp('N', {
displayTime: 30,
physTime: 30,
holdDurationMs: Number.NaN,
});
- await advance(30);
- // 폴백 hold 30ms < 100ms → 단노트
- expect(noteOf('N')!.endTime).toBe(125);
+ await advance(63);
+ expect(noteOf('N')!.startTime).toBe(37.5);
+ expect(noteOf('N')!.endTime).toBe(62.5);
result.handleKeyDown('M', { displayTime: nowMs, physTime: nowMs });
const mDown = nowMs;
- await advance(105);
result.handleKeyUp('M', {
displayTime: mDown + 40,
physTime: mDown + 40,
holdDurationMs: -5,
});
- await advance(30);
- expect(noteOf('M')!.endTime).toBe(mDown + 125);
+ await advance(63);
+ expect(noteOf('M')!.startTime).toBe(mDown + 37.5);
+ expect(noteOf('M')!.endTime).toBe(mDown + 62.5);
});
- it('mid-press 설정 변경에도 press 시작 시점의 threshold로 판정한다', async () => {
+ it('mid-press 설정 변경에도 m, T, D, C, W 스냅샷을 유지한다', async () => {
await render();
result.handleKeyDown('S', { displayTime: 0, physTime: 0 });
- // press 진행 중 threshold를 100 → 30으로 낮춰도 진행 중 press에는 미적용
- await render({ ...DELAY_SETTINGS, shortNoteThresholdMs: 30 });
+ await render({
+ ...DELAY_SETTINGS,
+ speed: 800,
+ shortNoteThresholdMs: 60,
+ shortNoteMinLengthPx: 40,
+ });
result.handleKeyUp('S', {
- displayTime: 60,
- physTime: 60,
- holdDurationMs: 60,
+ displayTime: 80,
+ physTime: 80,
+ holdDurationMs: 80,
});
- await advance(105);
- await advance(30);
+ await advance(100);
- // 스냅샷 threshold 100 기준 단노트 (라이브 값 30이었다면 롱노트 160)
- expect(noteOf('S')!.endTime).toBe(125);
+ const note = noteOf('S');
+ expect(note!.startTime).toBe(37.5);
+ expect(note!.endTime).toBeCloseTo(96.87777777777778, 3);
});
- it('reconcile: 스냅샷에 없는 키의 활성 노트를 현재 시각으로 종료한다', async () => {
+ it('UP 유실 시 reconcileActiveNotes가 활성 노트를 현재 시각으로 종료한다', async () => {
await render();
result.handleKeyDown('Z', { displayTime: 0, physTime: 0 });
@@ -222,7 +308,7 @@ describe('useNoteSystem 단/롱 판정', () => {
expect(noteOf('H')!.isActive).toBe(true);
result.handleKeyDown('P', { displayTime: nowMs, physTime: nowMs });
- await advance(50);
+ await advance(20);
act(() => {
result.reconcileActiveNotes(new Set(['H']));
});
diff --git a/src/renderer/hooks/overlay/useNoteSystem.ts b/src/renderer/hooks/overlay/useNoteSystem.ts
index 2dfdffb9..7a2640a9 100644
--- a/src/renderer/hooks/overlay/useNoteSystem.ts
+++ b/src/renderer/hooks/overlay/useNoteSystem.ts
@@ -6,6 +6,13 @@ import {
NoteBuffer,
TrackLayoutInput,
} from '@stores/signals/noteBuffer';
+import {
+ computeNoteLengthMs,
+ createNoteLengthPolicy,
+ toEffectiveMinLengthPx,
+ toMinLengthMs,
+ type NoteLengthPolicy,
+} from '@utils/core/noteLengthPolicy';
interface Note {
id: string;
@@ -40,9 +47,8 @@ interface NoteState {
noteId: string | null;
created: boolean;
released: boolean;
- // press 시작 시점 정책 스냅샷 - mid-press 설정 변경에도 판정·길이 일관 유지
- delayMs?: number;
- minLengthMs?: number;
+ // press 시작 시점 길이 정책 스냅샷
+ lengthPolicy?: NoteLengthPolicy;
}
// 키 이벤트 시각 정보. displayTime은 클램프 보정(표시 위치 전용),
@@ -457,17 +463,22 @@ export function useNoteSystem({
};
const computeMinLengthMs = (): number => {
- const minPx = shortNoteMinLengthPxRef.current || 0;
- const flowSpeed = flowSpeedRef.current || DEFAULT_NOTE_SETTINGS.speed;
- if (minPx <= 0 || flowSpeed <= 0) return 0;
- return Math.round((minPx * 1000) / flowSpeed);
+ const minLengthPx = shortNoteMinLengthPxRef.current || 0;
+ const trackHeight =
+ trackHeightRef.current || DEFAULT_NOTE_SETTINGS.trackHeight;
+ return toMinLengthMs(
+ toEffectiveMinLengthPx(minLengthPx, trackHeight),
+ flowSpeedRef.current || DEFAULT_NOTE_SETTINGS.speed,
+ );
};
const scheduleNoteFinalization = (
keyName: string,
state: NoteState,
): void => {
- if (!state?.noteId || state.startTime == null) return;
+ if (!state?.noteId || state.startTime == null || !state.lengthPolicy) {
+ return;
+ }
const releaseTime = state.releaseTime ?? performance.now();
// 판정용 물리 hold: 데몬 authoritative 값 우선, 없으면 비클램프 보정 시각 차 폴백
@@ -476,16 +487,20 @@ export function useNoteSystem({
const fallbackHold =
physDown != null ? Math.max(0, physRelease - physDown) : 0;
const holdMs = state.holdDurationMs ?? fallbackHold;
- // 단/롱 판정은 실행 순서(타이머 vs UP 도착)가 아니라 물리 hold와 press 시점
- // 스냅샷 threshold의 비교로만 결정 - 전달 지연·지터에 불변
- const threshold = state.delayMs ?? 0;
- const minLengthMs = state.minLengthMs ?? computeMinLengthMs();
- const isShort = state.useDelay && holdMs < threshold;
- const desiredDuration = isShort
- ? minLengthMs
- : Math.max(minLengthMs, holdMs);
- const safeDuration = Math.max(desiredDuration, 1);
- const targetEndTime = state.startTime + safeDuration;
+ // 데몬 hold와 press 시점 정책으로만 길이 결정
+ const computedNoteLengthMs = computeNoteLengthMs(
+ holdMs,
+ state.lengthPolicy,
+ );
+ // 잘못된 입력에서만 노트 소실 방지
+ const noteLengthMs =
+ Number.isFinite(computedNoteLengthMs) && computedNoteLengthMs > 0
+ ? computedNoteLengthMs
+ : 1;
+ const targetEndTime = Math.max(
+ state.startTime + noteLengthMs,
+ performance.now(),
+ );
if (state.finalizeTimer) {
clearTimeout(state.finalizeTimer);
@@ -493,6 +508,10 @@ export function useNoteSystem({
state.finalizeTimer = null;
}
+ // 타이머가 늦게 실행돼 targetEndTime이 과거가 돼도 실행 시점으로 다시 밀지 않는다.
+ // 셰이더에서 머리 위치는 startTime에만 의존해 완료 전후가 동일하고, 꼬리만
+ // 원래 떨어졌어야 할 자리로 이동한다. 다시 클램프하면 꼬리를 제자리에 묶어
+ // 롱노트 길이만 늘어난다
const finalizeState = (): void => {
finalizeTimersRef.current.delete(state.noteId!);
state.finalizeTimer = null;
@@ -527,7 +546,11 @@ export function useNoteSystem({
}
if (useDelay) {
- const delayMs = delayMsRef.current;
+ const thresholdMs = delayMsRef.current;
+ const lengthPolicy = createNoteLengthPolicy(
+ computeMinLengthMs(),
+ thresholdMs,
+ );
const downTime = timing?.displayTime ?? performance.now();
const state: NoteState = {
useDelay: true,
@@ -540,18 +563,18 @@ export function useNoteSystem({
noteId: null,
created: false,
released: false,
- delayMs,
- minLengthMs: computeMinLengthMs(),
+ lengthPolicy,
};
- const startTimer = setTimeout(() => {
+ const createDelayedNote = (): void => {
state.startTimer = null;
if (!noteEffectEnabled.current) {
removeState(keyName, state);
return;
}
- const overrideStart = state.downTime! + state.delayMs!;
+ const overrideStart =
+ state.downTime! + state.lengthPolicy!.displayDelayMs;
const noteId = createNote(keyName, overrideStart);
state.noteId = noteId;
state.created = true;
@@ -561,12 +584,18 @@ export function useNoteSystem({
if (state.released) {
scheduleNoteFinalization(keyName, state);
}
- // 실제 입력 시각 기준으로 노트 등장 시점을 맞춤. 입력 시각이 과거면
- // 남은 대기를 0으로 clamp해 타이머가 음수가 되지 않도록 함
- }, Math.max(0, downTime + delayMs - performance.now()));
+ };
- state.startTimer = startTimer;
stateList.push(state);
+ // 실제 입력 시각 기준으로 노트 등장 시점을 맞춤
+ const remainingDelay =
+ downTime + lengthPolicy.displayDelayMs - performance.now();
+ if (remainingDelay <= 0) {
+ createDelayedNote();
+ return;
+ }
+
+ state.startTimer = setTimeout(createDelayedNote, remainingDelay);
return;
}
diff --git a/src/renderer/utils/core/noteLengthPolicy.ts b/src/renderer/utils/core/noteLengthPolicy.ts
new file mode 100644
index 00000000..a712185f
--- /dev/null
+++ b/src/renderer/utils/core/noteLengthPolicy.ts
@@ -0,0 +1,96 @@
+/**
+ * 단노트 길이 일관성(delayed note) 모드의 표시 길이 정책
+ *
+ * 균일 구간을 threshold 앞에서 끝내고 램프로 이어붙여 경계 불연속을 없앤다.
+ * 정확 구간(hold >= max(threshold, minLengthMs))은 실제 hold를 그대로 쓴다.
+ * 여기서 threshold를 빼면 롱노트가 짧아지는 회귀가 재발한다.
+ * threshold <= minLengthMs면 최소 길이 클램프가 우세해 정확 구간이 minLengthMs부터 시작한다
+ *
+ * 설계 근거: tasks/plan/note-length-continuity.md
+ */
+
+export interface NoteLengthPolicy {
+ /** 단노트 최소 길이 (ms 환산) */
+ minLengthMs: number;
+ /** 단노트 구분 시간 - 이 이상은 실제 hold 그대로 */
+ thresholdMs: number;
+ /** 노트가 화면에 나타나기까지의 지연 */
+ displayDelayMs: number;
+ /** 균일 구간 끝 */
+ constantEndMs: number;
+ /** 램프 폭 */
+ rampWidthMs: number;
+}
+
+/**
+ * 트랙 높이를 넘는 최소 길이는 셰이더가 어차피 잘라내므로 계산에도 유효값만 쓴다.
+ * 저장값은 건드리지 않아 트랙을 키우면 원래 설정이 다시 살아난다
+ */
+export const toEffectiveMinLengthPx = (
+ minLengthPx: number,
+ trackHeight: number,
+): number => Math.min(minLengthPx, trackHeight);
+
+/** 최소 길이(px)를 ms로 환산 */
+export const toMinLengthMs = (
+ minLengthPx: number,
+ flowSpeed: number,
+): number => {
+ if (minLengthPx <= 0 || flowSpeed <= 0) return 0;
+ return (minLengthPx * 1000) / flowSpeed;
+};
+
+/**
+ * 표시 지연 D. 균일 구간 상한이 D + minLengthMs라서 남은 예산을
+ * 지연과 램프 폭에 반씩 나눈다
+ */
+export const toDisplayDelayMs = (
+ minLengthMs: number,
+ thresholdMs: number,
+): number => (thresholdMs > minLengthMs ? (thresholdMs - minLengthMs) / 2 : 0);
+
+export const createNoteLengthPolicy = (
+ minLengthMs: number,
+ thresholdMs: number,
+): NoteLengthPolicy => {
+ const displayDelayMs = toDisplayDelayMs(minLengthMs, thresholdMs);
+ const constantEndMs = minLengthMs + displayDelayMs;
+
+ return {
+ minLengthMs,
+ thresholdMs,
+ displayDelayMs,
+ constantEndMs,
+ rampWidthMs: thresholdMs - constantEndMs,
+ };
+};
+
+/** hold(ms)에 대한 최종 표시 길이(ms) */
+export const computeNoteLengthMs = (
+ holdMs: number,
+ policy: NoteLengthPolicy,
+): number => {
+ const {
+ minLengthMs,
+ thresholdMs,
+ displayDelayMs,
+ constantEndMs,
+ rampWidthMs,
+ } = policy;
+
+ // 램프를 놓을 자리가 없는 설정 - 연속·단조만 지키고 폴백
+ if (thresholdMs <= minLengthMs) {
+ return Math.max(minLengthMs, holdMs);
+ }
+ if (holdMs <= constantEndMs) {
+ return minLengthMs;
+ }
+ // 정확 구간은 hold 그대로. threshold나 지연을 빼지 않는다
+ if (holdMs >= thresholdMs) {
+ return holdMs;
+ }
+
+ const progress = (holdMs - constantEndMs) / rampWidthMs;
+ const smoothstep = 3 * progress ** 2 - 2 * progress ** 3;
+ return holdMs - displayDelayMs + displayDelayMs * smoothstep;
+};
diff --git a/src/renderer/windows/overlay/App.keyDelayTimers.test.tsx b/src/renderer/windows/overlay/App.keyDelayTimers.test.tsx
new file mode 100644
index 00000000..68f58fb3
--- /dev/null
+++ b/src/renderer/windows/overlay/App.keyDelayTimers.test.tsx
@@ -0,0 +1,274 @@
+import React, { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { createDefaultKeyPosition } from '@src/renderer/editor/model/keys';
+import { useKeyStore } from '@stores/data/useKeyStore';
+import { getKeySignal, resetAllKeySignals } from '@stores/signals/keySignals';
+import { useSettingsStore } from '@stores/useSettingsStore';
+
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+const mocks = vi.hoisted(() => ({
+ bootstrap: vi.fn(),
+ keyEventListener: null as null | ((payload: unknown) => void),
+ keysResetListener: null as null | ((payload: unknown) => void),
+}));
+
+vi.mock('@tauri-apps/api/window', () => ({
+ currentMonitor: vi.fn(),
+ getCurrentWindow: () => ({
+ startDragging: vi.fn(() => Promise.resolve()),
+ }),
+ Window: { getByLabel: vi.fn() },
+}));
+vi.mock('@tauri-apps/api/dpi', () => ({
+ LogicalPosition: class LogicalPosition {},
+ PhysicalPosition: class PhysicalPosition {},
+}));
+vi.mock('@tauri-apps/api/menu', () => ({
+ Menu: { new: vi.fn() },
+}));
+vi.mock('@contexts/useTranslation', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+vi.mock('@hooks/app/useCustomCssInjection', () => ({
+ useCustomCssInjection: vi.fn(),
+}));
+vi.mock('@hooks/app/useCustomJsInjection', () => ({
+ useCustomJsInjection: vi.fn(),
+}));
+vi.mock('@hooks/app/useBlockBrowserShortcuts', () => ({
+ useBlockBrowserShortcuts: vi.fn(),
+}));
+vi.mock('@hooks/app/useAppBootstrap', () => ({ useAppBootstrap: vi.fn() }));
+vi.mock('@hooks/overlay/useBuiltinStatsSubscription', () => ({
+ useBuiltinStatsSubscription: vi.fn(),
+}));
+vi.mock('@hooks/overlay/useNoteSystem', () => ({
+ useNoteSystem: () => ({
+ notesRef: { current: {} },
+ subscribe: vi.fn(() => () => {}),
+ handleKeyDown: vi.fn(),
+ handleKeyUp: vi.fn(),
+ finalizeAllActive: vi.fn(),
+ reconcileActiveNotes: vi.fn(),
+ noteBuffer: {},
+ updateTrackLayouts: vi.fn(),
+ }),
+}));
+vi.mock('@stores/data/useStatItemStore', () => ({
+ useStatItemStore: (
+ selector: (state: { positions: Record }) => T,
+ ) => selector({ positions: {} }),
+}));
+vi.mock('@stores/data/useGraphItemStore', () => ({
+ useGraphItemStore: (
+ selector: (state: { positions: Record }) => T,
+ ) => selector({ positions: {} }),
+}));
+vi.mock('@stores/data/useKnobItemStore', () => ({
+ useKnobItemStore: (
+ selector: (state: { positions: Record }) => T,
+ ) => selector({ positions: {} }),
+}));
+vi.mock('@stores/plugin/usePluginDisplayElementStore', () => ({
+ usePluginDisplayElementStore: (
+ selector: (state: { elements: never[] }) => T,
+ ) => selector({ elements: [] }),
+}));
+vi.mock('@components/shared/OverlayScene', () => ({ default: () => null }));
+vi.mock('@hooks/shared/useLayoutComputation', () => ({
+ computeLayout: () => ({
+ bounds: null,
+ displayPositions: [],
+ displayStatPositions: [],
+ displayGraphPositions: [],
+ displayKnobPositions: [],
+ positionOffset: { x: 0, y: 0 },
+ webglTracks: [],
+ }),
+}));
+vi.mock('@utils/core/axisEventBus', () => ({
+ axisEventBus: { initialize: vi.fn() },
+}));
+vi.mock('@utils/core/keyEventBus', () => ({
+ keyEventBus: {
+ subscribe: vi.fn((listener: (payload: unknown) => void) => {
+ mocks.keyEventListener = listener;
+ return vi.fn();
+ }),
+ initialize: vi.fn(() => Promise.resolve()),
+ },
+}));
+vi.mock('@api/modules/obsApi', () => ({
+ obsApi: {
+ onResync: vi.fn(() => vi.fn()),
+ },
+}));
+
+import App from './App';
+
+const makeApiMock = () =>
+ ({
+ app: { bootstrap: mocks.bootstrap },
+ keys: {
+ onKeysReset: vi.fn((listener: (payload: unknown) => void) => {
+ mocks.keysResetListener = listener;
+ return vi.fn();
+ }),
+ },
+ } as unknown as Window['api']);
+
+describe('키 표시 지연 타이머', () => {
+ let container: HTMLDivElement;
+ let root: Root;
+ let mounted: boolean;
+ let originalApi: Window['api'];
+
+ const flushAsync = async () => {
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ };
+
+ const emitKeyState = (state: 'DOWN' | 'UP') => {
+ act(() => {
+ mocks.keyEventListener?.({
+ key: 'KeyK',
+ state,
+ mode: '4key',
+ eventAgeMs: 0,
+ });
+ });
+ };
+
+ beforeEach(async () => {
+ vi.useFakeTimers();
+ originalApi = window.api;
+ window.api = makeApiMock();
+ mocks.bootstrap.mockReset();
+ mocks.bootstrap.mockResolvedValue({ activeKeys: [] });
+ mocks.keyEventListener = null;
+ mocks.keysResetListener = null;
+ resetAllKeySignals();
+
+ useKeyStore.setState({
+ selectedKeyType: '4key',
+ customTabs: [],
+ keyMappings: { '4key': ['KeyK'] },
+ positions: {
+ '4key': [createDefaultKeyPosition(0, 0)],
+ },
+ canonicalPositions: { '4key': [] },
+ isBootstrapped: true,
+ isLocalUpdateInProgress: false,
+ });
+ useSettingsStore.setState((state) => ({
+ noteEffect: false,
+ noteSettings: {
+ ...state.noteSettings,
+ keyDisplayDelayMs: 30000,
+ },
+ tabNoteOverrides: {},
+ }));
+
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+ mounted = true;
+ await act(async () => {
+ root.render();
+ });
+ await flushAsync();
+ });
+
+ afterEach(() => {
+ if (mounted) {
+ act(() => root.unmount());
+ }
+ container.remove();
+ resetAllKeySignals();
+ window.api = originalApi;
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ });
+
+ it('지연 설정 변경 시 대기 중인 DOWN/UP을 예약 순서대로 반영한다', async () => {
+ const transitions: boolean[] = [];
+ const unsubscribe = getKeySignal('KeyK').subscribe((value) => {
+ transitions.push(value);
+ });
+ transitions.length = 0;
+
+ emitKeyState('DOWN');
+ emitKeyState('UP');
+ expect(vi.getTimerCount()).toBe(2);
+
+ await act(async () => {
+ useSettingsStore.setState((state) => ({
+ noteSettings: {
+ ...state.noteSettings,
+ keyDisplayDelayMs: 10000,
+ },
+ }));
+ });
+
+ expect(vi.getTimerCount()).toBe(0);
+ expect(transitions).toEqual([true, false]);
+ expect(getKeySignal('KeyK').value).toBe(false);
+
+ const transitionCount = transitions.length;
+ vi.advanceTimersByTime(30000);
+ expect(transitions).toHaveLength(transitionCount);
+ expect(getKeySignal('KeyK').value).toBe(false);
+
+ emitKeyState('DOWN');
+ vi.advanceTimersByTime(9999);
+ expect(getKeySignal('KeyK').value).toBe(false);
+ vi.advanceTimersByTime(1);
+ expect(getKeySignal('KeyK').value).toBe(true);
+ unsubscribe();
+ });
+
+ it('keys reset 시 모든 키 타이머를 취소한다', () => {
+ const transitions: boolean[] = [];
+ const unsubscribe = getKeySignal('KeyK').subscribe((value) => {
+ transitions.push(value);
+ });
+ transitions.length = 0;
+
+ emitKeyState('DOWN');
+ expect(vi.getTimerCount()).toBe(1);
+
+ act(() => {
+ mocks.keysResetListener?.({ reason: 'hook_restart' });
+ });
+
+ expect(vi.getTimerCount()).toBe(0);
+ expect(transitions).not.toContain(true);
+ vi.advanceTimersByTime(30000);
+ expect(getKeySignal('KeyK').value).toBe(false);
+ unsubscribe();
+ });
+
+ it('unmount 시 모든 키 타이머를 취소한다', () => {
+ const transitions: boolean[] = [];
+ const unsubscribe = getKeySignal('KeyK').subscribe((value) => {
+ transitions.push(value);
+ });
+ transitions.length = 0;
+
+ emitKeyState('DOWN');
+ expect(vi.getTimerCount()).toBe(1);
+
+ act(() => root.unmount());
+ mounted = false;
+
+ expect(vi.getTimerCount()).toBe(0);
+ expect(transitions).not.toContain(true);
+ vi.advanceTimersByTime(30000);
+ expect(getKeySignal('KeyK').value).toBe(false);
+ unsubscribe();
+ });
+});
diff --git a/src/renderer/windows/overlay/App.tsx b/src/renderer/windows/overlay/App.tsx
index 78515149..a9830649 100644
--- a/src/renderer/windows/overlay/App.tsx
+++ b/src/renderer/windows/overlay/App.tsx
@@ -29,7 +29,35 @@ import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayEle
import OverlayScene from '@components/shared/OverlayScene';
import { computeLayout } from '@hooks/shared/useLayoutComputation';
-type KeyDelayTimerEntry = { timers: Set> };
+type KeyDelayTimerHandle = ReturnType;
+type KeyDelayTimerEntry = {
+ timers: Map void>;
+};
+
+const cancelKeyDelayTimers = (
+ entries: Map,
+ pendingTimers: Map void>,
+) => {
+ pendingTimers.forEach((_apply, timer) => clearTimeout(timer));
+ pendingTimers.clear();
+ entries.forEach((entry) => entry.timers.clear());
+ entries.clear();
+};
+
+const flushKeyDelayTimers = (
+ entries: Map,
+ pendingTimers: Map void>,
+) => {
+ const pending = [...pendingTimers.entries()];
+ pendingTimers.clear();
+ entries.forEach((entry) => entry.timers.clear());
+ entries.clear();
+
+ pending.forEach(([timer, apply]) => {
+ clearTimeout(timer);
+ apply();
+ });
+};
// 입력 시각 보정용 age 상한(ms). 백엔드 stall/클럭 이상으로 비정상적으로 큰
// 값이 와도 노트가 화면 위로 튀지 않도록 제한
@@ -344,15 +372,24 @@ export default function App() {
// 키 딜레이 설정
const keyDisplayDelayMs = Number(noteSettings?.keyDisplayDelayMs ?? 0);
+ // 키 딜레이 타이머 관리 (down/up 별도 관리)
+ const keyDelayTimersRef = useRef