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 }) => ( +
{children}
+ ), +})); +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>(new Map()); + const pendingKeyDelayTimersRef = useRef void>>( + new Map(), + ); + // 키 딜레이 값을 ref로 관리하여 클로저 문제 방지 const keyDisplayDelayMsRef = useRef(keyDisplayDelayMs); useEffect(() => { + if (keyDisplayDelayMsRef.current !== keyDisplayDelayMs) { + flushKeyDelayTimers( + keyDelayTimersRef.current, + pendingKeyDelayTimersRef.current, + ); + } keyDisplayDelayMsRef.current = keyDisplayDelayMs; }, [keyDisplayDelayMs]); - // 키 딜레이 타이머 관리 (down/up 별도 관리) - const keyDelayTimersRef = useRef>(new Map()); - // 키 활성 상태는 signals로 관리하여 App 리렌더를 방지 const [_layoutVersion, setLayoutVersion] = useState(0); @@ -414,22 +451,36 @@ export default function App() { let timerEntry = keyDelayTimersRef.current.get(key); if (!timerEntry) { - timerEntry = { timers: new Set() }; + timerEntry = { timers: new Map() }; keyDelayTimersRef.current.set(key, timerEntry); } if (delayMs <= 0) { - timerEntry.timers.forEach((timer) => clearTimeout(timer)); + timerEntry.timers.forEach((_apply, timer) => { + clearTimeout(timer); + pendingKeyDelayTimersRef.current.delete(timer); + }); timerEntry.timers.clear(); + keyDelayTimersRef.current.delete(key); setKeyActiveSignal(key, isDown); return; } + const apply = () => setKeyActiveSignal(key, isDown); const timer = setTimeout(() => { - setKeyActiveSignal(key, isDown); - timerEntry?.timers.delete(timer); + const pendingApply = pendingKeyDelayTimersRef.current.get(timer); + if (!pendingApply) return; + + pendingKeyDelayTimersRef.current.delete(timer); + const currentEntry = keyDelayTimersRef.current.get(key); + currentEntry?.timers.delete(timer); + if (currentEntry?.timers.size === 0) { + keyDelayTimersRef.current.delete(key); + } + pendingApply(); }, delayMs); - timerEntry.timers.add(timer); + timerEntry.timers.set(timer, apply); + pendingKeyDelayTimersRef.current.set(timer, apply); }; // HID 축 이벤트 버스 초기화 (input:axis 구독 → axisSignals 누적) @@ -590,11 +641,10 @@ export default function App() { reconcileGenerationRef.current += 1; const { finalizeAllActive } = keyEventContextRef.current; finalizeAllActive(); - keyDelayTimersRef.current.forEach((timerEntry) => { - timerEntry.timers.forEach((timer) => clearTimeout(timer)); - timerEntry.timers.clear(); - }); - keyDelayTimersRef.current.clear(); + cancelKeyDelayTimers( + keyDelayTimersRef.current, + pendingKeyDelayTimersRef.current, + ); const seen = new Set(); seenSinceResetRef.current = seen; resetAllKeySignals(); @@ -619,6 +669,7 @@ export default function App() { }); const keyDelayTimers = keyDelayTimersRef.current; + const pendingKeyDelayTimers = pendingKeyDelayTimersRef.current; return () => { hydrationCancelled = true; @@ -634,11 +685,7 @@ export default function App() { }) .catch(() => undefined); // 키 딜레이 타이머 정리 - keyDelayTimers.forEach((timerEntry) => { - timerEntry.timers.forEach((timer) => clearTimeout(timer)); - timerEntry.timers.clear(); - }); - keyDelayTimers.clear(); + cancelKeyDelayTimers(keyDelayTimers, pendingKeyDelayTimers); // 창 단위 정리 - 마운트 1회 구독이므로 여기는 실제 언마운트에서만 실행됨 resetAllKeySignals(); }; @@ -660,11 +707,10 @@ export default function App() { // 탭 전환은 진행 중 대조의 스냅샷을 낡게 만든다 reconcileGenerationRef.current += 1; let cancelled = false; - keyDelayTimersRef.current.forEach((timerEntry) => { - timerEntry.timers.forEach((timer) => clearTimeout(timer)); - timerEntry.timers.clear(); - }); - keyDelayTimersRef.current.clear(); + cancelKeyDelayTimers( + keyDelayTimersRef.current, + pendingKeyDelayTimersRef.current, + ); const seen = new Set(); const validKeys = new Set(JSON.parse(currentValidKeySignature)); seenSinceResetRef.current = seen; diff --git a/src/types/settings/noteSettingsConstraints.ts b/src/types/settings/noteSettingsConstraints.ts index 4edb6c81..eb31f69a 100644 --- a/src/types/settings/noteSettingsConstraints.ts +++ b/src/types/settings/noteSettingsConstraints.ts @@ -36,7 +36,7 @@ export const NOTE_SETTINGS_CONSTRAINTS = { }, keyDisplayDelayMs: { min: 0, - max: 9999, + max: 30000, default: 0, }, fadeTopPx: {