From 9aa8455659ae07aa39a4216c422154ff6ed2c653 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 06:04:38 +0900 Subject: [PATCH 001/245] =?UTF-8?q?refactor:=20=ED=94=84=EB=A1=9C=ED=8D=BC?= =?UTF-8?q?=ED=8B=B0=20=ED=8C=A8=EB=84=90=20=EA=B4=80=EC=8B=AC=EC=82=AC=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/main/Grid/PropertiesPanel.tsx | 1796 ++--------------- .../PropertiesPanel/PluginSettingsForm.tsx | 249 +++ .../PropertiesPanel/batchSelectionModel.ts | 285 +++ .../propertyPanelAdapters.test.ts | 88 + .../PropertiesPanel/propertyPanelAdapters.ts | 215 ++ .../singleSelectionHandlers.ts | 494 +++++ .../PropertiesPanel/usePanelNavigation.ts | 148 ++ .../usePropertiesPanelSelection.ts | 348 ++++ 8 files changed, 1975 insertions(+), 1648 deletions(-) create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/PluginSettingsForm.tsx create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/batchSelectionModel.ts create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/propertyPanelAdapters.test.ts create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/propertyPanelAdapters.ts create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/singleSelectionHandlers.ts create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/usePanelNavigation.ts create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/usePropertiesPanelSelection.ts diff --git a/src/renderer/components/main/Grid/PropertiesPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel.tsx index 816b43d5c..50c9dd72b 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel.tsx @@ -1,100 +1,33 @@ -import React, { - useCallback, - useEffect, - useState, - useRef, - useSyncExternalStore, -} from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import { useTranslation } from '@contexts/useTranslation'; import { usePanelHost } from '@contexts/PanelHostContext'; -import { isHTMLElementNode } from '@utils/dom/isElementNode'; import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; -import { useKeyStore } from '@stores/data/useKeyStore'; -import { useStatItemStore } from '@stores/data/useStatItemStore'; -import { useGraphItemStore } from '@stores/data/useGraphItemStore'; -import { useKnobItemStore } from '@stores/data/useKnobItemStore'; import { useSettingsStore } from '@stores/useSettingsStore'; -import { - selectPropertyPanelPluginElements, - usePluginDisplayElementStore, -} from '@stores/plugin/usePluginDisplayElementStore'; import { usePropertiesPanelStore } from '@stores/grid/usePropertiesPanelStore'; import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; -import { getKeyInfoByGlobalKey } from '@utils/core/KeyMaps'; -import { translatePluginMessage } from '@utils/plugin/pluginI18n'; import { getDefaultSettings, - normalizeSettingsSections, omitLayoutSettingValues, type SettingsNormalizationErrorKind, } from '@plugins/runtime/settingsSections'; import { updatePluginElement } from '@plugins/runtime/displayElement/pluginElementActions'; -import type { NativeLayerBoundsTarget } from '@plugins/runtime/displayElement/pluginElementActions'; -import type { ImageFit, KeyPosition } from '@src/types/key/keys'; -import type { StatItemPosition, StatItemType } from '@src/types/key/statItems'; +import type { KeyPosition } from '@src/types/key/keys'; import type { GraphItemPosition } from '@src/types/key/graphItems'; import type { KnobItemPosition } from '@src/types/key/knobs'; import type { BatchElementPropertyUpdate } from './PropertiesPanel/types'; import type { - EditorFontStylePropertyPatchV1, - EditorFontFamilyPropertyPatchV1, - EditorGraphRuntimePropertyPatchV1, - EditorKnobRuntimePropertyPatchV1, - EditorNotePropertyPatchV1, EditorElementTypeV1, - EditorCounterAnimationPresetIntentV1, - EditorCounterLayoutPropertyPatchV1, - EditorCounterTypographyPropertyPatchV1, EditorCounterFillPropertyPatchV1, - EditorPreviewStylePropertyPatchV1, - EditorStylePropertyPreviewPatchV1, - EditorPaintPropertyPatchV1, - EditorShadowPropertyPatchV1, - EditorNotePaintPropertyPatchV1, - EditorElementPropertyPatchV1, - CanonicalKeyPosition, - CanonicalKnobItemPosition, } from '@src/types/editor'; -import type { - PluginSettingSchema, - PluginMessages, -} from '@src/types/plugin/api'; import { normalizeCounterSettings } from '@src/types/key/keys'; -import { slotCanonical, slotDisplayName } from '@utils/keySlot'; import { useLenis } from '@hooks/useLenis'; import { usePluginGeometryGesture } from '@hooks/Grid/usePluginGeometryGesture'; import { editGestureController } from '@src/renderer/editor/runtime/editGestureController'; -import { isHistoryEditorFlushLocked } from '@src/renderer/editor/runtime/historyEditorFlushLock'; -import { - composePreviewPositions, - getPreviewOverlayVersion, - subscribePreviewOverlay, -} from '@src/renderer/editor/runtime/previewOverlay'; import { commitBatchGeometryByIds, patchElementLayerNameById, renameLayerGroupById, - commitElementGeometryById, - patchActiveImageById, - patchActiveImageFitById, - patchActiveTransparentById, patchFontFamilyByTargets, - patchStylePropertyById, - patchPaintById, - patchShadowById, - patchNotePaintById, - patchInactiveImageById, - patchIdleImageFitById, - patchIdleTransparentById, - patchSoundPathById, - patchSoundEnabledById, - patchSoundVolumeById, - patchCounterAnimationEnabledById, - patchCounterAnimationPresetById, - patchCounterEnabledById, - patchCounterLayoutById, - patchCounterTypographyById, - patchCounterFillById, patchFontStyleByTargets, patchGraphColorsByIds, patchGraphPropertiesByIds, @@ -102,29 +35,16 @@ import { patchKnobPropertiesByIds, patchNotePropertiesByIds, patchUseInlineStylesByTargets, - patchElementPropertyById, -} from '@src/renderer/editor/runtime/elementOps'; -import type { GeometryField } from '@src/renderer/editor/runtime/elementOps'; -import type { - BatchGeometryDescriptor, - BatchGeometryTarget, } from '@src/renderer/editor/runtime/elementOps'; +import type { BatchGeometryDescriptor } from '@src/renderer/editor/runtime/elementOps'; import { resolveElementById } from '@src/renderer/editor/model/elementIdMap'; import { isNativeElementId } from '@src/renderer/editor/model/elementId'; -import { captureEditorDocument } from '@src/renderer/editor/runtime/editorStateCoordinator'; import { computeBatchGeometryPlan } from '@src/renderer/editor/runtime/batchGeometryPlan'; import { commitMixedBatchGeometry } from '@src/renderer/editor/runtime/mixedBatchGeometry'; -import { isPluginVisibleInMode } from '@utils/layerGroupUtils'; -import { resolveResizablePluginElementSize } from '@utils/plugin/pluginElementMeasurement'; // 분리된 컴포넌트들 및 훅 import { TABS, - PropertyRow, - PropertySection, - NumberInput, - ColorInput, - TextInput, LayerPanel, PluginSelectionPanel, SingleGraphPanel, @@ -146,224 +66,25 @@ import { resolveSelectionPanelRoute } from './PropertiesPanel/selectionPanelRout import { PanelNavProvider } from './PropertiesPanel/PanelNavContext'; import PanelHeaderActions from './PropertiesPanel/PanelHeaderActions'; import PanelToggleButton from './PropertiesPanel/PanelToggleButton'; -import Checkbox from '@components/main/common/Checkbox'; -import Dropdown from '@components/main/common/Dropdown'; import type { NoteColor } from '@src/types/key/keys'; import { EditSessionScope } from '@src/renderer/contexts/EditSessionScope'; -import { projectNotePaintPatch } from '@src/types/key/notePaint'; -import { - previewSingleGraphColor, - previewSinglePaint, - previewSingleStyleProperty, -} from './PropertiesPanel/previewPatchForwarders'; +import { previewSingleGraphColor } from './PropertiesPanel/previewPatchForwarders'; import { reportElementOpSkipped } from '@src/renderer/editor/runtime/elementIntent'; - -const getStatTypeLabel = (statType?: StatItemType | null): string => { - switch (statType) { - case 'kpsAvg': - return 'AVG'; - case 'kpsMax': - return 'MAX'; - case 'total': - return 'Total'; - case 'kps': - default: - return 'KPS'; - } -}; - -const geometryAxisPatch = ( - field: GeometryField, - value: number, -): NativeLayerBoundsTarget['patch'] => { - switch (field) { - case 'dx': - return { dx: value }; - case 'dy': - return { dy: value }; - case 'width': - return { width: value }; - case 'height': - return { height: value }; - } -}; - -const shouldNormalizePropertyTabToStyle = ( - elements: Array<{ type: string }>, - activeTab: (typeof TABS)[keyof typeof TABS], -): boolean => { - if (activeTab === TABS.STYLE) return false; - const hasKey = elements.some((element) => element.type === 'key'); - const hasStat = elements.some((element) => element.type === 'stat'); - const hasGraph = elements.some((element) => element.type === 'graph'); - - // 플러그인 혼합도 native 구성 기준으로 정규화 - stat+plugin에서 NOTE 탭이 - // 남으면 배치 패널 본문이 비어 보인다 - if (activeTab === TABS.NOTE && hasStat && !hasKey) { - return true; - } - return hasGraph && !hasKey && !hasStat; -}; - -// 서브 페이지 exit 전환 시간 — --ui-duration-page와 동기 -const PAGE_EXIT_MS = 250; - -const getGraphRuntimePropertyPatch = ( - updates: Partial, -): EditorGraphRuntimePropertyPatchV1 | null => { - const keys = Object.keys(updates); - if (keys.length !== 1) return null; - if (keys[0] === 'showAvgLine' && typeof updates.showAvgLine === 'boolean') { - return { property: 'showAvgLine', value: updates.showAvgLine }; - } - if ( - keys[0] === 'graphAnimationEnabled' && - typeof updates.graphAnimationEnabled === 'boolean' - ) { - return { - property: 'graphAnimationEnabled', - value: updates.graphAnimationEnabled, - }; - } - if ( - keys[0] === 'graphSpeed' && - Number.isSafeInteger(updates.graphSpeed) && - (updates.graphSpeed as number) >= 0 && - (updates.graphSpeed as number) <= 4_294_967_295 - ) { - return { property: 'graphSpeed', value: updates.graphSpeed as number }; - } - return null; -}; - -const getKnobRuntimePropertyPatch = ( - updates: Partial, -): EditorKnobRuntimePropertyPatchV1 | null => { - const keys = Object.keys(updates); - if (keys.length !== 1) return null; - if (keys[0] === 'reverse' && typeof updates.reverse === 'boolean') { - return { property: 'reverse', value: updates.reverse }; - } - if ( - keys[0] === 'sensitivity' && - typeof updates.sensitivity === 'number' && - Number.isFinite(updates.sensitivity) - ) { - return { property: 'sensitivity', value: updates.sensitivity }; - } - return null; -}; - -const getUseInlineStylesPatch = (updates: object): boolean | null => { - const values = updates as Record; - const keys = Object.keys(updates); - return keys.length === 1 && - keys[0] === 'useInlineStyles' && - typeof values.useInlineStyles === 'boolean' - ? values.useInlineStyles - : null; -}; - -const getFontStylePatch = ( - updates: object, -): EditorFontStylePropertyPatchV1 | null => { - const values = updates as Record; - const keys = Object.keys(updates); - if (keys.length !== 1) return null; - if ( - keys[0] === 'fontWeight' && - Number.isSafeInteger(values.fontWeight) && - (values.fontWeight as number) >= 0 && - (values.fontWeight as number) <= 4_294_967_295 - ) { - return { property: 'fontWeight', value: values.fontWeight as number }; - } - if (keys[0] === 'fontBold' && typeof values.fontBold === 'boolean') { - return { property: 'fontBold', value: values.fontBold }; - } - if (keys[0] === 'fontItalic' && typeof values.fontItalic === 'boolean') { - return { property: 'fontItalic', value: values.fontItalic }; - } - if ( - keys[0] === 'fontUnderline' && - typeof values.fontUnderline === 'boolean' - ) { - return { property: 'fontUnderline', value: values.fontUnderline }; - } - if ( - keys[0] === 'fontStrikethrough' && - typeof values.fontStrikethrough === 'boolean' - ) { - return { property: 'fontStrikethrough', value: values.fontStrikethrough }; - } - return null; -}; - -const getFontFamilyPatch = ( - updates: object, -): EditorFontFamilyPropertyPatchV1 | null => { - const values = updates as Record; - const keys = Object.keys(updates); - return keys.length === 1 && - keys[0] === 'fontFamily' && - typeof values.fontFamily === 'string' - ? { property: 'fontFamily', value: values.fontFamily } - : null; -}; - -const getNotePropertyPatch = ( - updates: object, -): EditorNotePropertyPatchV1 | null => { - const values = updates as Record; - const keys = Object.keys(updates); - if (keys.length !== 1) return null; - if ( - keys[0] === 'noteEffectEnabled' && - typeof values.noteEffectEnabled === 'boolean' - ) { - return { property: 'noteEffectEnabled', value: values.noteEffectEnabled }; - } - if ( - keys[0] === 'noteAutoYCorrection' && - typeof values.noteAutoYCorrection === 'boolean' - ) { - return { - property: 'noteAutoYCorrection', - value: values.noteAutoYCorrection, - }; - } - if ( - keys[0] === 'noteGlowEnabled' && - typeof values.noteGlowEnabled === 'boolean' - ) { - return { property: 'noteGlowEnabled', value: values.noteGlowEnabled }; - } - if ( - keys[0] === 'noteGlowSyncPaint' && - typeof values.noteGlowSyncPaint === 'boolean' - ) { - return { property: 'noteGlowSyncPaint', value: values.noteGlowSyncPaint }; - } - if ( - keys[0] === 'noteAlignment' && - ['left', 'center', 'right'].includes(values.noteAlignment as string) - ) { - return { - property: 'noteAlignment', - value: values.noteAlignment as 'left' | 'center' | 'right', - }; - } - if ( - keys[0] === 'noteBorderSide' && - ['all', 'vertical', 'horizontal'].includes(values.noteBorderSide as string) - ) { - return { - property: 'noteBorderSide', - value: values.noteBorderSide as 'all' | 'vertical' | 'horizontal', - }; - } - return null; -}; +import PluginSettingsForm from './PropertiesPanel/PluginSettingsForm'; +import { usePropertiesPanelSelection } from './PropertiesPanel/usePropertiesPanelSelection'; +import { usePanelNavigation } from './PropertiesPanel/usePanelNavigation'; +import { createBatchSelectionModel } from './PropertiesPanel/batchSelectionModel'; +import { singleSelectionHandlers } from './PropertiesPanel/singleSelectionHandlers'; +import { + getFontFamilyPatch, + getFontStylePatch, + getGraphRuntimePropertyPatch, + getKnobRuntimePropertyPatch, + getNotePropertyPatch, + getStatTypeLabel, + getUseInlineStylesPatch, + shouldNormalizePropertyTabToStyle, +} from './PropertiesPanel/propertyPanelAdapters'; // ============================================================================ // 메인 컴포넌트 Props @@ -391,46 +112,46 @@ const PropertiesPanel: React.FC = ({ const { t, i18n } = useTranslation(); // 패널이 분리 창에 있으면 키·마우스 이벤트는 그 창의 document로 온다 const { document: hostDocument } = usePanelHost(); - const selectedElements = useGridSelectionStore( - (state) => state.selectedElements, - ); - const selectedKeyType = useKeyStore((state) => state.selectedKeyType); - const positions = useKeyStore((state) => state.positions); - const canonicalPositions = useKeyStore((state) => state.canonicalPositions); - const keyMappings = useKeyStore((state) => state.keyMappings); - const canonicalStatItemPositions = useStatItemStore( - (state) => state.positions, - ); - const canonicalGraphItemPositions = useGraphItemStore( - (state) => state.positions, - ); - const canonicalKnobItemPositions = useKnobItemStore( - (state) => state.positions, - ); - useSyncExternalStore( - subscribePreviewOverlay, - getPreviewOverlayVersion, - getPreviewOverlayVersion, - ); - const statItemPositions = composePreviewPositions( - 'statPosition', - canonicalStatItemPositions, - ); - const graphItemPositions = composePreviewPositions( - 'graphPosition', - canonicalGraphItemPositions, - ); - const knobItemPositions = composePreviewPositions( - 'knobPosition', - canonicalKnobItemPositions, - ); + const { + selectedElements, + selectedKeyType, + positions, + canonicalPositions, + keyMappings, + statItemPositions, + graphItemPositions, + knobItemPositions, + selectedKeyElements, + selectedStatElements, + selectedGraphElements, + selectedKnobElements, + selectedKeyLikeElements, + selectedBatchStyleElements, + selectedPluginElements, + selectedPluginElement, + stableBatchGeometryTargets, + stablePluginGeometryElements, + stablePluginGeometryTargets, + selectedPluginDefinition, + hasSinglePluginSelection, + showModalHint, + showSettings, + isPluginResizable, + pluginDisplaySize, + singleKeyIndex, + singleKeyPosition, + singleCanonicalKeyPosition, + singleKeySlot, + singleKeyCode, + singleKeyInfo, + singleStatIndex, + singleStatPosition, + singleGraphIndex, + singleGraphPosition, + singleKnobPosition, + selectedGroupInfo, + } = usePropertiesPanelSelection(); const { useCustomCSS } = useSettingsStore(); - const pluginElements = usePluginDisplayElementStore( - selectPropertyPanelPluginElements, - ); - const pluginDefinitions = usePluginDisplayElementStore( - (state) => state.definitions, - ); const pluginSettingsPanel = usePropertiesPanelStore( (state) => state.pluginSettingsPanel, ); @@ -451,247 +172,6 @@ const PropertiesPanel: React.FC = ({ ); const locale = i18n.language; - // 선택된 키 요소 필터링 - const selectedKeyElements = selectedElements.filter( - (el) => el.type === 'key', - ); - const selectedStatElements = selectedElements.filter( - (el) => el.type === 'stat', - ); - const selectedGraphElements = selectedElements.filter( - (el) => el.type === 'graph', - ); - const selectedKnobElements = selectedElements.filter( - (el) => el.type === 'knob', - ); - const selectedKeyLikeElements = selectedElements.filter( - (el) => el.type === 'key' || el.type === 'stat', - ); - const selectedBatchStyleElements = selectedElements.filter( - (el) => - el.type === 'key' || - el.type === 'stat' || - el.type === 'graph' || - el.type === 'knob', - ); - const selectedPluginElements = selectedElements.filter( - (el) => el.type === 'plugin', - ); - - const selectedPluginElement = (() => { - if (selectedPluginElements.length !== 1) return null; - return ( - pluginElements.find((el) => el.fullId === selectedPluginElements[0].id) || - null - ); - })(); - // plugin 단독 배치는 native 대상 0개(빈 배열)로 성립 - invalid ID가 - // 하나라도 섞이면 null (fail-closed) - const stableBatchGeometryTargets: BatchGeometryTarget[] | null = - selectedBatchStyleElements.every( - (element) => element.id.length > 0 && isNativeElementId(element.id), - ) - ? selectedBatchStyleElements.map((element) => ({ - type: element.type as EditorElementTypeV1, - id: element.id, - })) - : null; - // 혼합 선택의 plugin 기하 대상 bounds - 소실·모드 이탈이 하나라도 있으면 - // null (fail-closed). 크기는 캔버스 렌더와 동일한 measured/estimated 폴백 - const stablePluginGeometryElements = (() => { - const resolved: Array<{ - fullId: string; - x: number; - y: number; - width: number; - height: number; - }> = []; - for (const selected of selectedPluginElements) { - const element = pluginElements.find( - (candidate) => candidate.fullId === selected.id, - ); - if (!element || !isPluginVisibleInMode(element, selectedKeyType)) { - return null; - } - const size = resolveResizablePluginElementSize(element); - resolved.push({ - fullId: element.fullId, - x: element.position.x, - y: element.position.y, - width: size.width, - height: size.height, - }); - } - return resolved; - })(); - const stablePluginGeometryTargets: string[] | null = - stablePluginGeometryElements?.map((element) => element.fullId) ?? null; - - const selectedPluginDefinition = selectedPluginElement?.definitionId - ? pluginDefinitions.get(selectedPluginElement.definitionId) ?? null - : null; - - const pluginSettingsUI = selectedPluginDefinition?.settingsUI ?? 'panel'; - const hasSinglePluginSelection = - selectedPluginElements.length === 1 && !!selectedPluginElement; - const showModalHint = - hasSinglePluginSelection && pluginSettingsUI === 'modal'; - const showSettings = hasSinglePluginSelection && pluginSettingsUI !== 'modal'; - const isPluginResizable = - hasSinglePluginSelection && !!selectedPluginDefinition?.resizable; - - const pluginDisplaySize = (() => { - const measured = selectedPluginElement?.measuredSize; - const estimated = selectedPluginElement?.estimatedSize; - return { - width: measured?.width ?? estimated?.width ?? 200, - height: measured?.height ?? estimated?.height ?? 150, - }; - })(); - - // 단일 키 선택인 경우의 데이터 - const singleKeyId = - selectedKeyElements.length === 1 ? selectedKeyElements[0].id : null; - const singleKeyIndex = singleKeyId - ? (positions[selectedKeyType] ?? []).findIndex( - (position) => position.id === singleKeyId, - ) - : -1; - const singleKeyPosition = - singleKeyIndex >= 0 ? positions[selectedKeyType]?.[singleKeyIndex] : null; - const singleCanonicalKeyPosition = singleKeyId - ? (canonicalPositions[selectedKeyType] ?? []).find( - (position) => position.id === singleKeyId, - ) ?? null - : null; - const singleKeySlot = - singleKeyIndex >= 0 - ? keyMappings[selectedKeyType]?.[singleKeyIndex] ?? null - : null; - const singleKeyCode = - singleKeySlot != null ? slotCanonical(singleKeySlot) : null; - const singleKeyInfo = - singleKeySlot != null && singleKeyCode - ? typeof singleKeySlot === 'string' - ? getKeyInfoByGlobalKey(singleKeySlot) - : { - browserKey: singleKeyCode, - globalKey: singleKeyCode, - displayName: slotDisplayName(singleKeySlot), - } - : null; - - // 단일 통계 요소 선택인 경우의 데이터 - const singleStatId = - selectedStatElements.length === 1 ? selectedStatElements[0].id : null; - const singleStatIndex = singleStatId - ? (statItemPositions[selectedKeyType] ?? []).findIndex( - (position) => position.id === singleStatId, - ) - : -1; - const singleStatPosition: StatItemPosition | null = - singleStatIndex >= 0 - ? statItemPositions[selectedKeyType]?.[singleStatIndex] ?? null - : null; - const singleGraphId = - selectedGraphElements.length === 1 ? selectedGraphElements[0].id : null; - const singleGraphIndex = singleGraphId - ? (graphItemPositions[selectedKeyType] ?? []).findIndex( - (position) => position.id === singleGraphId, - ) - : -1; - const singleGraphPosition: GraphItemPosition | null = - singleGraphIndex >= 0 - ? graphItemPositions[selectedKeyType]?.[singleGraphIndex] ?? null - : null; - const singleKnobId = - selectedKnobElements.length === 1 ? selectedKnobElements[0].id : null; - const singleKnobIndex = singleKnobId - ? (knobItemPositions[selectedKeyType] ?? []).findIndex( - (position) => position.id === singleKnobId, - ) - : -1; - const singleKnobPosition: KnobItemPosition | null = - singleKnobIndex >= 0 - ? knobItemPositions[selectedKeyType]?.[singleKnobIndex] ?? null - : null; - const allLayerGroups = useLayerGroupStore((state) => state.layerGroups); - const layerGroupsForMode = allLayerGroups[selectedKeyType] || []; - const selectedGroupInfo = (() => { - if (selectedElements.length < 2) { - return null; - } - - const keyModePositions = positions[selectedKeyType] || []; - const statModePositions = statItemPositions[selectedKeyType] || []; - const graphModePositions = graphItemPositions[selectedKeyType] || []; - const knobModePositions = knobItemPositions[selectedKeyType] || []; - // 플러그인 소속은 현재 모드에 def가 있는 groupId만 유효 (레이어 패널과 - // 동일 규칙) - const modeGroupIds = new Set(layerGroupsForMode.map((group) => group.id)); - - let groupId: string | undefined; - - for (const element of selectedElements) { - let currentGroupId: string | undefined; - if (element.type === 'key') { - currentGroupId = keyModePositions.find( - (position) => position.id === element.id, - )?.groupId; - } else if (element.type === 'stat') { - currentGroupId = statModePositions.find( - (position) => position.id === element.id, - )?.groupId; - } else if (element.type === 'graph') { - currentGroupId = graphModePositions.find( - (position) => position.id === element.id, - )?.groupId; - } else if (element.type === 'knob') { - currentGroupId = knobModePositions.find( - (position) => position.id === element.id, - )?.groupId; - } else if (element.type === 'plugin') { - const pluginGroupId = pluginElements.find( - (candidate) => candidate.fullId === element.id, - )?.groupId; - currentGroupId = - pluginGroupId && modeGroupIds.has(pluginGroupId) - ? pluginGroupId - : undefined; - } else { - return null; - } - - if (!currentGroupId) return null; - if (!groupId) { - groupId = currentGroupId; - } else if (groupId !== currentGroupId) { - return null; - } - } - - if (!groupId) return null; - - const totalMembers = - keyModePositions.filter((pos) => pos?.groupId === groupId).length + - statModePositions.filter((pos) => pos?.groupId === groupId).length + - graphModePositions.filter((pos) => pos?.groupId === groupId).length + - knobModePositions.filter((pos) => pos?.groupId === groupId).length + - pluginElements.filter( - (el) => - isPluginVisibleInMode(el, selectedKeyType) && el.groupId === groupId, - ).length; - - if (totalMembers < 2 || totalMembers !== selectedElements.length) { - return null; - } - - const groupDef = layerGroupsForMode.find((group) => group.id === groupId); - if (!groupDef) return null; - - return { id: groupDef.id, name: groupDef.name, memberCount: totalMembers }; - })(); - // 로컬 상태 (실시간 편집용) const [localState, setLocalState] = useState< Partial & { dx?: number; dy?: number } @@ -773,49 +253,7 @@ const PropertiesPanel: React.FC = ({ (state) => state.setPropertyPanelActiveTab, ); - // 인-패널 내비게이션 — 피커 서브 페이지 (키는 트리거 사이트별 유니크) - // activePageKey는 애니메이션 상태, renderPageKey는 마운트 상태 — - // exit 전환이 끝날 때까지 콘텐츠를 유지해 빈 페이지 슬라이드 방지 - const [activePageKey, setActivePageKey] = useState(null); - const [renderPageKey, setRenderPageKey] = useState(null); - const [pageHost, setPageHost] = useState(null); - const pageExitTimerRef = useRef(null); - - const openPage = useCallback((key: string) => { - if (pageExitTimerRef.current !== null) { - window.clearTimeout(pageExitTimerRef.current); - pageExitTimerRef.current = null; - } - setActivePageKey(key); - setRenderPageKey(key); - }, []); - - const closePage = useCallback(() => { - setActivePageKey(null); - if (pageExitTimerRef.current !== null) { - window.clearTimeout(pageExitTimerRef.current); - } - pageExitTimerRef.current = window.setTimeout(() => { - pageExitTimerRef.current = null; - setRenderPageKey(null); - }, PAGE_EXIT_MS); - }, []); - - useEffect(() => { - return () => { - if (pageExitTimerRef.current !== null) { - window.clearTimeout(pageExitTimerRef.current); - } - }; - }, []); - - // 탭/모드/패널 표시 전환 시 서브 페이지 닫기 - useEffect(() => { - closePage(); - }, [activeTab, panelMode, isPanelVisible, selectedKeyType, closePage]); - - // 패널 본문 종류(단일/배치/플러그인 등)가 바뀌면 트리거 사이트가 함께 - // 사라지므로 서브 페이지 무효화 — 선택 이펙트의 early return 경로 보완 + const handlePluginSettingsPanelCancelImpl = useRef<() => void>(() => {}); const panelScopeKey = [ pluginSettingsPanel ? 'plugin-settings' : 'grid', selectedKeyElements.length, @@ -824,78 +262,23 @@ const PropertiesPanel: React.FC = ({ selectedGraphElements.length, selectedKnobElements.length, ].join('|'); - useEffect(() => { - closePage(); - }, [panelScopeKey, closePage]); - - // Escape로 서브 페이지 닫기 — 입력 필드 편집·상위 레이어와 경합 방지 - useEffect(() => { - if (!activePageKey) return; - const onKey = (event: KeyboardEvent) => { - if (isHistoryEditorFlushLocked()) return; - if (event.key !== 'Escape' || event.defaultPrevented) return; - const target = event.target; - if ( - isHTMLElementNode(target) && - (target.tagName === 'INPUT' || - target.tagName === 'TEXTAREA' || - target.isContentEditable) - ) { - return; - } - // 모달·포털 메뉴 등 상위 레이어가 열려 있으면 그쪽이 Escape를 소유 - // 모달은 메인 문서에, 팝업 레이어는 패널이 있는 문서에 뜬다 - 둘 다 본다 - if ( - document.querySelector( - '[data-dmn-modal-backdrop="true"], [data-dmn-popup-layer="true"]', - ) || - (hostDocument !== document && - hostDocument.querySelector('[data-dmn-popup-layer="true"]')) - ) { - return; - } - // 이 레이어가 소비 — 그리드의 선택 해제까지 내려가지 않게 - event.preventDefault(); - event.stopPropagation(); - closePage(); - }; - hostDocument.addEventListener('keydown', onKey, true); - return () => hostDocument.removeEventListener('keydown', onKey, true); - }, [activePageKey, closePage, hostDocument]); - - // Escape로 플러그인 설정 세션 취소 - 서브 페이지·모달이 없을 때만 - useEffect(() => { - if (!pluginSettingsPanel || activePageKey) return; - const onKey = (event: KeyboardEvent) => { - if (isHistoryEditorFlushLocked()) return; - if (event.key !== 'Escape' || event.defaultPrevented) return; - const target = event.target; - if ( - isHTMLElementNode(target) && - (target.tagName === 'INPUT' || - target.tagName === 'TEXTAREA' || - target.isContentEditable) - ) { - return; - } - // 모달은 메인 문서에, 팝업 레이어는 패널이 있는 문서에 뜬다 - 둘 다 본다 - if ( - document.querySelector( - '[data-dmn-modal-backdrop="true"], [data-dmn-popup-layer="true"]', - ) || - (hostDocument !== document && - hostDocument.querySelector('[data-dmn-popup-layer="true"]')) - ) { - return; - } - event.preventDefault(); - event.stopPropagation(); - handlePluginSettingsPanelCancelImpl.current(); - }; - hostDocument.addEventListener('keydown', onKey, true); - return () => hostDocument.removeEventListener('keydown', onKey, true); - }, [pluginSettingsPanel, activePageKey, hostDocument]); - + const { + activePageKey, + renderPageKey, + pageHost, + setPageHost, + openPage, + closePage, + } = usePanelNavigation({ + hostDocument, + activeTab, + panelMode, + isPanelVisible, + selectedKeyType, + panelScopeKey, + hasPluginSettingsPanel: !!pluginSettingsPanel, + pluginSettingsCancelRef: handlePluginSettingsPanelCancelImpl, + }); // 이전 렌더의 effect가 최신 탭을 덮지 않도록 커밋 직전 재확인 useEffect(() => { const latestTab = usePropertiesPanelStore.getState().propertyPanelActiveTab; @@ -1353,7 +736,6 @@ const PropertiesPanel: React.FC = ({ } }; - const handlePluginSettingsPanelCancelImpl = useRef<() => void>(() => {}); handlePluginSettingsPanelCancelImpl.current = () => { if (!pluginSettingsPanel || pluginSettingsSavingRef.current) return; try { @@ -1382,688 +764,62 @@ const PropertiesPanel: React.FC = ({ handleTogglePanel(); }); - const commitSingleGeometry = ( - type: EditorElementTypeV1, - id: string, - field: GeometryField, - value: number, - ) => { - const patch = geometryAxisPatch(field, value); - // 네 종류 모두 숫자 입력이 preview 게스처를 열므로 커밋이 그 게스처를 정산한다 - const gestureId = editGestureController.activeGestureId() ?? undefined; - const persisted = commitElementGeometryById(type, id, patch, { gestureId }); - editGestureController.settleCommit(persisted); - void persisted.catch((error) => { - console.error('Failed to update element geometry', error); - }); - }; - - const stableGeometryHandler = ( - type: EditorElementTypeV1, - id: string | undefined, - ) => - id && isNativeElementId(id) - ? (field: GeometryField, value: number) => - commitSingleGeometry(type, id, field, value) - : undefined; - - const stableGeometryPreviewHandler = ( - type: EditorElementTypeV1, - id: string | undefined, - ) => - id && isNativeElementId(id) - ? (field: GeometryField, value: number) => { - const locator = resolveElementById(type, id); - if (!locator) return; - editGestureController.preview( - locator.mode, - [ - { - id, - patch: geometryAxisPatch(field, value), - }, - ], - { - domain: - type === 'key' - ? 'keyPosition' - : type === 'stat' - ? 'statPosition' - : type === 'graph' - ? 'graphPosition' - : 'knobPosition', - }, - ); - } - : undefined; - - const stableElementPropertyCommitHandler = ( - type: EditorElementTypeV1, - id: string | undefined, - ) => - id && isNativeElementId(id) - ? ( - patch: EditorElementPropertyPatchV1, - options?: { gestureId?: string }, - ) => { - // 분리 창도 즉시 반영을 거친다 - RPC 왕복 전에 값이 되돌아가는 깜빡임 방지 - // 그래프 색과 키 이미지 변환은 preview 게스처를 정산하고, - // 그 외는 호출부가 준 gestureId만 공유 - const settlesGesture = - (type === 'graph' && patch.property === 'graphColor') || - (type === 'key' && - (patch.property === 'idleImageTransform' || - patch.property === 'activeImageTransform')); - const gestureId = - options?.gestureId ?? - (settlesGesture - ? editGestureController.activeGestureId() ?? undefined - : undefined); - const persisted = patchElementPropertyById( - type, - id, - patch, - gestureId ? { gestureId } : {}, - ); - if (settlesGesture) { - editGestureController.settleCommit(persisted); - } - void persisted.catch((error) => { - console.error('Failed to update element property', error); - }); - } - : undefined; - - const stableInactiveImageHandler = ( - type: EditorElementTypeV1, - id: string | undefined, - ) => - id && isNativeElementId(id) - ? (inactiveImage: string) => { - const persisted = patchInactiveImageById(type, id, inactiveImage); - void persisted.catch((error) => { - console.error('Failed to update inactive image', error); - }); - } - : undefined; - - const stableActiveImageHandler = ( - type: 'key' | 'knob', - id: string | undefined, - ) => - id && isNativeElementId(id) - ? (activeImage: string) => { - const persisted = patchActiveImageById(type, id, activeImage); - void persisted.catch((error) => { - console.error('Failed to update active image', error); - }); - } - : undefined; - - const stableIdleTransparentHandler = ( - type: EditorElementTypeV1, - id: string | undefined, - ) => - id && isNativeElementId(id) - ? (idleTransparent: boolean) => { - const persisted = patchIdleTransparentById(type, id, idleTransparent); - void persisted.catch((error) => { - console.error('Failed to update idle transparency', error); - }); - } - : undefined; - - const stableActiveTransparentHandler = ( - type: 'key' | 'knob', - id: string | undefined, - ) => - id && isNativeElementId(id) - ? (activeTransparent: boolean) => { - const persisted = patchActiveTransparentById( - type, - id, - activeTransparent, - ); - void persisted.catch((error) => { - console.error('Failed to update active transparency', error); - }); - } - : undefined; - - const stableIdleImageFitHandler = ( - type: EditorElementTypeV1, - id: string | undefined, - ) => - id && isNativeElementId(id) - ? (idleImageFit: ImageFit) => { - const persisted = patchIdleImageFitById(type, id, idleImageFit); - void persisted.catch((error) => { - console.error('Failed to update idle image fit', error); - }); - } - : undefined; - - const stableActiveImageFitHandler = ( - type: 'key' | 'knob', - id: string | undefined, - ) => - id && isNativeElementId(id) - ? (activeImageFit: ImageFit) => { - const persisted = patchActiveImageFitById(type, id, activeImageFit); - void persisted.catch((error) => { - console.error('Failed to update active image fit', error); - }); - } - : undefined; - - const stableSoundPathHandler = (id: string | undefined) => - id && isNativeElementId(id) - ? (soundPath: string) => { - const persisted = patchSoundPathById(id, soundPath); - void persisted.catch((error) => { - console.error('Failed to update sound path', error); - }); - } - : undefined; - - const stableSoundEnabledHandler = (id: string | undefined) => - id && isNativeElementId(id) - ? (soundEnabled: boolean) => { - const persisted = patchSoundEnabledById(id, soundEnabled); - void persisted.catch((error) => { - console.error('Failed to update sound enabled', error); - }); - } - : undefined; - - const stableSoundVolumeHandler = (id: string | undefined) => - id && isNativeElementId(id) - ? (soundVolume: number) => { - const gestureId = - editGestureController.activeGestureId() ?? undefined; - const persisted = patchSoundVolumeById(id, soundVolume, { - gestureId, - }); - editGestureController.settleCommit(persisted); - void persisted.catch((error) => { - console.error('Failed to update sound volume', error); - }); - } - : undefined; - - const stableStylePropertyPreviewHandler = ( - type: EditorElementTypeV1, - id: string | undefined, - ) => - id && isNativeElementId(id) - ? (patch: EditorStylePropertyPreviewPatchV1) => - previewSingleStyleProperty(type, id, patch) - : undefined; - - const stableStylePropertyCommitHandler = ( - type: EditorElementTypeV1, - id: string | undefined, - options: { settleGesture?: boolean } = {}, - ) => - id && isNativeElementId(id) - ? (patch: EditorPreviewStylePropertyPatchV1) => { - const gestureId = options.settleGesture - ? editGestureController.activeGestureId() ?? undefined - : undefined; - const persisted = patchStylePropertyById(type, id, patch, { - gestureId, - }); - if (options.settleGesture) { - editGestureController.settleCommit(persisted); - } - void persisted.catch((error) => { - console.error('Failed to update style property', error); - }); - } - : undefined; - - const stablePaintCommitHandler = ( - type: EditorElementTypeV1, - id: string | undefined, - ) => - id && isNativeElementId(id) - ? (patch: EditorPaintPropertyPatchV1) => { - const gestureId = - editGestureController.activeGestureId() ?? undefined; - const persisted = patchPaintById(type, id, patch, { gestureId }); - editGestureController.settleCommit(persisted); - void persisted.catch((error) => { - console.error('Failed to update paint', error); - }); - } - : undefined; - - const stablePaintPreviewHandler = ( - type: EditorElementTypeV1, - id: string | undefined, - ) => - id && isNativeElementId(id) - ? (patch: EditorPaintPropertyPatchV1) => - previewSinglePaint(type, id, patch) - : undefined; - - const stableShadowCommitHandler = ( - type: 'key' | 'stat' | 'knob', - id: string | undefined, - ) => - id && isNativeElementId(id) - ? (patch: EditorShadowPropertyPatchV1) => { - // 스크럽·타이핑 preview 게스처를 이 커밋으로 정산 - 실패 시 lifecycle까지 폐기되게 id 동반 - const gestureId = - editGestureController.activeGestureId() ?? undefined; - const persisted = patchShadowById(type, id, patch, { gestureId }); - editGestureController.settleCommit(persisted); - void persisted.catch((error) => { - console.error('Failed to update shadow', error); - }); - } - : undefined; - - const stableNotePaintCommitHandler = (id: string | undefined) => - id && isNativeElementId(id) - ? (patch: EditorNotePaintPropertyPatchV1) => { - const gestureId = - editGestureController.activeGestureId() ?? undefined; - const persisted = patchNotePaintById(id, patch, { gestureId }); - editGestureController.settleCommit(persisted); - void persisted - .then((applied) => { - // 가드 거부(false)는 rejection이 아니라서 별도 로그가 없으면 무음 - if (!applied) { - reportElementOpSkipped('single note paint'); - } - }) - .catch((error) => { - console.error('Failed to update note paint', error); - }); - } - : undefined; - - const stableNotePaintPreviewHandler = (id: string | undefined) => - id && isNativeElementId(id) - ? (patch: EditorNotePaintPropertyPatchV1) => { - const locator = resolveElementById('key', id); - if (!locator) return; - // canonical 전달 - 동기화 켜진 키의 글로우 미러가 낙관 적용과 같은 규칙 - const current = - captureEditorDocument().keyPositions[locator.mode]?.[locator.index]; - editGestureController.preview( - locator.mode, - [ - { - id, - patch: projectNotePaintPatch( - patch, - current?.id === id ? current : undefined, - ), - }, - ], - { domain: 'keyPosition' }, - ); - } - : undefined; - - const stableCounterAnimationPresetHandler = ( - elementType: 'key' | 'stat', - id: string | undefined, - ) => - id && isNativeElementId(id) - ? (intent: EditorCounterAnimationPresetIntentV1) => { - const persisted = patchCounterAnimationPresetById( - elementType, - id, - intent, - ); - void persisted.catch((error) => { - console.error('Failed to update counter animation preset', error); - }); - } - : undefined; - - const stableCounterEnabledHandler = ( - elementType: 'key' | 'stat', - id: string | undefined, - ) => - id && isNativeElementId(id) - ? (enabled: boolean) => { - // 분리 창도 즉시 반영을 거친다 - 배치 경로와 같은 래퍼를 대상 하나로 재사용 - const persisted = patchCounterEnabledById(elementType, id, enabled); - void persisted.catch((error) => { - console.error('Failed to update counter enabled', error); - }); - } - : undefined; - - const stableCounterAnimationEnabledHandler = ( - elementType: 'key' | 'stat', - id: string | undefined, - ) => - id && isNativeElementId(id) - ? (enabled: boolean) => { - const persisted = patchCounterAnimationEnabledById( - elementType, - id, - enabled, - ); - void persisted.catch((error) => { - console.error('Failed to update counter animation enabled', error); - }); - } - : undefined; - - const stableCounterLayoutHandler = ( - elementType: 'key' | 'stat', - id: string | undefined, - ) => - id && isNativeElementId(id) - ? (patch: EditorCounterLayoutPropertyPatchV1) => { - const persisted = patchCounterLayoutById(elementType, id, patch); - void persisted.catch((error) => { - console.error('Failed to update counter layout', error); - }); - } - : undefined; - - const stableCounterTypographyHandler = ( - elementType: 'key' | 'stat', - id: string | undefined, - ) => - id && isNativeElementId(id) - ? ( - patch: EditorCounterTypographyPropertyPatchV1, - options?: { gestureId?: string }, - ) => { - const persisted = options?.gestureId - ? patchCounterTypographyById(elementType, id, patch, { - gestureId: options.gestureId, - }) - : patchCounterTypographyById(elementType, id, patch); - void persisted.catch((error) => { - console.error('Failed to update counter typography', error); - }); - } - : undefined; - - const stableCounterFillHandler = ( - elementType: 'key' | 'stat', - id: string | undefined, - ) => - id && isNativeElementId(id) - ? (patch: EditorCounterFillPropertyPatchV1) => { - const persisted = patchCounterFillById(elementType, id, patch); - void persisted.catch((error) => { - console.error('Failed to update counter fill', error); - }); - } - : undefined; - - // ============================================================================ - // 다중 선택 헬퍼 함수들 - // ============================================================================ - - const getSelectedKeysData = () => { - return selectedKeyLikeElements - .map((el) => { - if (el.type === 'key') { - const index = (positions[selectedKeyType] ?? []).findIndex( - (position) => position.id === el.id, - ); - if (index < 0) return null; - const position = positions[selectedKeyType]?.[index]; - const slot = keyMappings[selectedKeyType]?.[index] ?? null; - const keyCode = slot != null ? slotCanonical(slot) : null; - const keyInfo = - slot != null && keyCode - ? typeof slot === 'string' - ? getKeyInfoByGlobalKey(slot) - : { - browserKey: keyCode, - globalKey: keyCode, - displayName: slotDisplayName(slot), - } - : null; - return { index, position, keyCode, keyInfo }; - } - const index = (statItemPositions[selectedKeyType] ?? []).findIndex( - (position) => position.id === el.id, - ); - if (index < 0) return null; - const position = statItemPositions[selectedKeyType]?.[index]; - const statLabel = - (position?.displayText || '').trim() || - getStatTypeLabel(position?.statType ?? null); - const keyInfo = { globalKey: statLabel, displayName: statLabel }; - return { index, position, keyCode: null, keyInfo }; - }) - .filter( - (data): data is NonNullable => - data !== null && data.position !== undefined, - ); - }; - - const getSelectedGraphsData = () => { - return selectedGraphElements - .map((el) => { - const index = (graphItemPositions[selectedKeyType] ?? []).findIndex( - (position) => position.id === el.id, - ); - if (index < 0) return null; - const position = graphItemPositions[selectedKeyType]?.[index]; - const graphLabel = `${getStatTypeLabel( - position?.statType ?? null, - )} Graph`; - const keyInfo = { globalKey: graphLabel, displayName: graphLabel }; - return { index, position, keyCode: null, keyInfo }; - }) - .filter( - (data): data is NonNullable => - data !== null && data.position !== undefined, - ); - }; - - const getSelectedKnobsData = () => { - return selectedKnobElements - .map((el) => { - const index = (knobItemPositions[selectedKeyType] ?? []).findIndex( - (position) => position.id === el.id, - ); - if (index < 0) return null; - const position = knobItemPositions[selectedKeyType]?.[index]; - const knobLabel = (position?.displayText || '').trim() || 'Knob'; - const keyInfo = { globalKey: knobLabel, displayName: knobLabel }; - return { index, position, keyCode: null, keyInfo }; - }) - .filter( - (data): data is NonNullable => - data !== null && data.position !== undefined, - ); - }; - - const getSelectedBatchStyleData = () => { - return selectedBatchStyleElements - .map((el) => { - if (el.type === 'key') { - const index = (positions[selectedKeyType] ?? []).findIndex( - (position) => position.id === el.id, - ); - if (index < 0) return null; - const position = positions[selectedKeyType]?.[index]; - const slot = keyMappings[selectedKeyType]?.[index] ?? null; - const keyCode = slot != null ? slotCanonical(slot) : null; - const keyInfo = - slot != null && keyCode - ? typeof slot === 'string' - ? getKeyInfoByGlobalKey(slot) - : { - browserKey: keyCode, - globalKey: keyCode, - displayName: slotDisplayName(slot), - } - : null; - return { index, position, keyCode, keyInfo }; - } - if (el.type === 'stat') { - const index = (statItemPositions[selectedKeyType] ?? []).findIndex( - (position) => position.id === el.id, - ); - if (index < 0) return null; - const position = statItemPositions[selectedKeyType]?.[index]; - const statLabel = - (position?.displayText || '').trim() || - getStatTypeLabel(position?.statType ?? null); - const keyInfo = { globalKey: statLabel, displayName: statLabel }; - return { index, position, keyCode: null, keyInfo }; - } - if (el.type === 'knob') { - const index = (knobItemPositions[selectedKeyType] ?? []).findIndex( - (position) => position.id === el.id, - ); - if (index < 0) return null; - const position = knobItemPositions[selectedKeyType]?.[index]; - const knobLabel = (position?.displayText || '').trim() || 'Knob'; - const keyInfo = { globalKey: knobLabel, displayName: knobLabel }; - return { index, position, keyCode: null, keyInfo }; - } - const index = (graphItemPositions[selectedKeyType] ?? []).findIndex( - (position) => position.id === el.id, - ); - if (index < 0) return null; - const position = graphItemPositions[selectedKeyType]?.[index]; - const graphLabel = `${getStatTypeLabel( - position?.statType ?? null, - )} Graph`; - const keyInfo = { globalKey: graphLabel, displayName: graphLabel }; - return { index, position, keyCode: null, keyInfo }; - }) - .filter( - (data): data is NonNullable => - data !== null && data.position !== undefined, - ); - }; - - const getMixedValue = ( - getter: (pos: KeyPosition) => T | undefined, - defaultValue: T, - ): { isMixed: boolean; value: T } => { - const keysData = getSelectedKeysData(); - if (keysData.length === 0) return { isMixed: false, value: defaultValue }; - - const firstValue = getter(keysData[0].position!) ?? defaultValue; - const isMixed = keysData.some((data) => { - const val = getter(data.position!) ?? defaultValue; - if (typeof val === 'object' && typeof firstValue === 'object') { - return JSON.stringify(val) !== JSON.stringify(firstValue); - } - return val !== firstValue; - }); - - return { isMixed, value: firstValue }; - }; - - // 게스처를 취소한 직후 로컬 대표값을 되돌릴 때 쓴다. positions는 프리뷰가 합성된 - // 렌더 상태라 취소 직전 클로저로는 되돌아간 값을 읽을 수 없다 - const getMixedValueCanonical = ( - getter: (pos: KeyPosition) => T | undefined, - defaultValue: T, - ): { isMixed: boolean; value: T } => { - const modePositions = canonicalPositions[selectedKeyType] ?? []; - const selected = selectedKeyLikeElements.flatMap((el) => - el.type === 'key' - ? modePositions.filter((position) => position.id === el.id) - : [], - ); - if (selected.length === 0) return { isMixed: false, value: defaultValue }; - - const firstValue = getter(selected[0]) ?? defaultValue; - const isMixed = selected.some((position) => { - const val = getter(position) ?? defaultValue; - if (typeof val === 'object' && typeof firstValue === 'object') { - return JSON.stringify(val) !== JSON.stringify(firstValue); - } - return val !== firstValue; - }); - - return { isMixed, value: firstValue }; - }; - - const getMixedValueGraphs = ( - getter: (pos: GraphItemPosition) => T | undefined, - defaultValue: T, - ): { isMixed: boolean; value: T } => { - const graphsData = getSelectedGraphsData(); - if (graphsData.length === 0) return { isMixed: false, value: defaultValue }; - - const firstValue = getter(graphsData[0].position!) ?? defaultValue; - const isMixed = graphsData.some((data) => { - const val = getter(data.position!) ?? defaultValue; - if (typeof val === 'object' && typeof firstValue === 'object') { - return JSON.stringify(val) !== JSON.stringify(firstValue); - } - return val !== firstValue; - }); - - return { isMixed, value: firstValue }; - }; - - const getMixedValueGraphsAsKey = ( - getter: (pos: KeyPosition) => T | undefined, - defaultValue: T, - ): { isMixed: boolean; value: T } => { - return getMixedValueGraphs((pos) => getter(pos), defaultValue); - }; - - const getMixedValueKnobs = ( - getter: (pos: KnobItemPosition) => T | undefined, - defaultValue: T, - ): { isMixed: boolean; value: T } => { - const knobsData = getSelectedKnobsData(); - if (knobsData.length === 0) return { isMixed: false, value: defaultValue }; - - const firstValue = getter(knobsData[0].position!) ?? defaultValue; - const isMixed = knobsData.some((data) => { - const val = getter(data.position!) ?? defaultValue; - if (typeof val === 'object' && typeof firstValue === 'object') { - return JSON.stringify(val) !== JSON.stringify(firstValue); - } - return val !== firstValue; - }); - - return { isMixed, value: firstValue }; - }; - - const getMixedValueKnobsAsKey = ( - getter: (pos: KeyPosition) => T | undefined, - defaultValue: T, - ): { isMixed: boolean; value: T } => { - return getMixedValueKnobs((pos) => getter(pos), defaultValue); - }; - - const getMixedValueBatch = ( - getter: (pos: KeyPosition) => T | undefined, - defaultValue: T, - ): { isMixed: boolean; value: T } => { - const batchData = getSelectedBatchStyleData(); - if (batchData.length === 0) return { isMixed: false, value: defaultValue }; - - const firstValue = - getter(batchData[0].position as KeyPosition) ?? defaultValue; - const isMixed = batchData.some((data) => { - const val = getter(data.position as KeyPosition) ?? defaultValue; - if (typeof val === 'object' && typeof firstValue === 'object') { - return JSON.stringify(val) !== JSON.stringify(firstValue); - } - return val !== firstValue; - }); - - return { isMixed, value: firstValue }; - }; - - // ============================================================================ + const { + stableGeometryHandler, + stableGeometryPreviewHandler, + stableElementPropertyCommitHandler, + stableInactiveImageHandler, + stableActiveImageHandler, + stableIdleTransparentHandler, + stableActiveTransparentHandler, + stableIdleImageFitHandler, + stableActiveImageFitHandler, + stableSoundPathHandler, + stableSoundEnabledHandler, + stableSoundVolumeHandler, + stableStylePropertyPreviewHandler, + stableStylePropertyCommitHandler, + stablePaintCommitHandler, + stablePaintPreviewHandler, + stableShadowCommitHandler, + stableNotePaintCommitHandler, + stableNotePaintPreviewHandler, + stableCounterAnimationPresetHandler, + stableCounterEnabledHandler, + stableCounterAnimationEnabledHandler, + stableCounterLayoutHandler, + stableCounterTypographyHandler, + stableCounterFillHandler, + } = singleSelectionHandlers; + const { + getSelectedKeysData, + getSelectedGraphsData, + getSelectedKnobsData, + getSelectedBatchStyleData, + getSelectedKeyOnlyPositions, + getMixedValue, + getMixedValueCanonical, + getMixedValueGraphs, + getMixedValueGraphsAsKey, + getMixedValueKnobs, + getMixedValueKnobsAsKey, + getMixedValueBatch, + getMixedValueActiveCapable, + getMixedValueKeysOnly, + } = createBatchSelectionModel({ + selectedKeyType, + positions, + canonicalPositions, + keyMappings, + statItemPositions, + graphItemPositions, + knobItemPositions, + selectedKeyElements, + selectedKeyLikeElements, + selectedGraphElements, + selectedKnobElements, + selectedBatchStyleElements, + }); // 다중 선택 일괄 편집 핸들러 (훅 사용) // ============================================================================ @@ -2250,79 +1006,6 @@ const PropertiesPanel: React.FC = ({ }); }; - // NOTE 탭은 "키"에만 적용되어야 함 - const getSelectedKeyOnlyPositions = () => { - return selectedKeyElements - .map((el) => { - const index = (positions[selectedKeyType] ?? []).findIndex( - (position) => position.id === el.id, - ); - const position = positions[selectedKeyType]?.[index]; - return position ? { index, position } : null; - }) - .filter( - ( - v, - ): v is { - index: number; - position: CanonicalKeyPosition; - } => v !== null, - ); - }; - - // 눌림 가능(키·노브) 집계 — active 상태 표시가 통계만 제외하고 노브는 포함 - const getSelectedActiveCapablePositions = (): KeyPosition[] => { - const keyData = getSelectedKeyOnlyPositions().map( - ({ position }) => position, - ); - const knobData = selectedKnobElements - .map((el) => - knobItemPositions?.[selectedKeyType]?.find( - (position) => position.id === el.id, - ), - ) - .filter((v): v is CanonicalKnobItemPosition => v != null); - return [...keyData, ...knobData]; - }; - - const getMixedValueActiveCapable = ( - getter: (pos: KeyPosition) => T | undefined, - defaultValue: T, - ): { isMixed: boolean; value: T } => { - const data = getSelectedActiveCapablePositions(); - if (data.length === 0) return { isMixed: false, value: defaultValue }; - - const firstValue = getter(data[0]) ?? defaultValue; - const isMixed = data.some((position) => { - const val = getter(position) ?? defaultValue; - if (typeof val === 'object' && typeof firstValue === 'object') { - return JSON.stringify(val) !== JSON.stringify(firstValue); - } - return val !== firstValue; - }); - - return { isMixed, value: firstValue }; - }; - - const getMixedValueKeysOnly = ( - getter: (pos: KeyPosition) => T | undefined, - defaultValue: T, - ): { isMixed: boolean; value: T } => { - const data = getSelectedKeyOnlyPositions(); - if (data.length === 0) return { isMixed: false, value: defaultValue }; - - const firstValue = getter(data[0].position) ?? defaultValue; - const isMixed = data.some(({ position }) => { - const val = getter(position) ?? defaultValue; - if (typeof val === 'object' && typeof firstValue === 'object') { - return JSON.stringify(val) !== JSON.stringify(firstValue); - } - return val !== firstValue; - }); - - return { isMixed, value: firstValue }; - }; - const handleGraphBatchSharedSetting = ( updates: Partial, ) => { @@ -2415,209 +1098,26 @@ const PropertiesPanel: React.FC = ({ }; const renderPluginSettingsForm = ( - schema: Record | undefined, + schema: Parameters[0]['schema'], values: Record, - messages: PluginMessages | undefined, + messages: Parameters[0]['messages'], pluginId: string, colorIdPrefix: string, onChange: (key: string, value: unknown) => void, - ) => { - const sections = normalizeSettingsSections( - schema, - values, - (key, error, kind) => - reportPluginNormalizationError(pluginId, key, error, kind), - ); - if (!sections.some((section) => section.renderVisible)) { - return ( -

- {t('propertiesPanel.pluginNoSettings') || '설정할 항목이 없습니다.'} -

- ); - } - - const translate = (key?: string, fallback?: string) => { - if (!key) return fallback || ''; - return translatePluginMessage({ - messages, - locale, - key, - fallback, - }); - }; - - const getPluginInputWidth = ( - type: 'string' | 'number', - value: unknown, - ): string => { - if (type === 'number') { - return '60px'; - } - const strVal = String(value ?? ''); - if (strVal.length <= 4) return '60px'; - if (strVal.length <= 10) return '100px'; - return '200px'; - }; - - const renderEntry = ( - key: string, - schemaValue: Exclude, - renderVisible: boolean, - ) => { - if (!renderVisible) return null; - const rawValue = - values[key] !== undefined ? values[key] : schemaValue.default; - const labelText = translate(schemaValue.label, schemaValue.label); - const placeholderText = - typeof schemaValue.placeholder === 'string' - ? translate(schemaValue.placeholder, schemaValue.placeholder) - : schemaValue.placeholder; - - let control: React.ReactNode = null; - - if (schemaValue.type === 'boolean') { - const checked = !!rawValue; - control = ( - onChange(key, !checked)} - /> - ); - } else if (schemaValue.type === 'color') { - const colorValue = - typeof rawValue === 'string' - ? rawValue - : (schemaValue.default as string) || '#FFFFFF'; - control = ( - onChange(key, color)} - colorId={`${colorIdPrefix}-${key}`} - panelElement={panelElement} - solidOnly={true} - /> - ); - } else if (schemaValue.type === 'number') { - const numericValue = Number(rawValue); - const normalizedValue = Number.isFinite(numericValue) - ? numericValue - : typeof schemaValue.default === 'number' - ? schemaValue.default - : 0; - // step 값에서 소수 자릿수 자동 추론 - const stepStr = - schemaValue.step != null ? String(schemaValue.step) : ''; - const dotIdx = stepStr.indexOf('.'); - const hasDecimal = dotIdx !== -1; - const decimalScale = hasDecimal ? stepStr.length - dotIdx - 1 : 0; - control = ( - onChange(key, nextValue)} - width={getPluginInputWidth('number', rawValue)} - /> - ); - } else if (schemaValue.type === 'string') { - const stringValue = - rawValue === undefined || rawValue === null ? '' : String(rawValue); - control = ( - onChange(key, nextValue)} - placeholder={ - typeof placeholderText === 'string' ? placeholderText : undefined - } - width={getPluginInputWidth('string', stringValue)} - /> - ); - } else if (schemaValue.type === 'select') { - const options = (schemaValue.options || []).map((option) => ({ - label: translate(option.label, option.label), - value: String(option.value), - })); - const optionMap = new Map( - (schemaValue.options || []).map((option) => [ - String(option.value), - option.value, - ]), - ); - const selectedValue = optionMap.has(String(rawValue)) - ? String(rawValue) - : String(schemaValue.default ?? ''); - control = ( - 0 - ? placeholderText - : undefined - } - onChange={(nextValue) => - onChange(key, optionMap.get(nextValue) ?? nextValue) - } - /> - ); - } - - if (schemaValue.type === 'boolean') { - return ( -
-

{labelText}

-
{control}
-
- ); - } - - return ( - - {control} - - ); - }; - - return ( - // 대상이 바뀌면 폼을 통째로 새로 만든다. 설정 key만으로 묶으면 같은 스키마를 가진 - // 다른 요소로 선택이 옮겨가도 입력 인스턴스가 살아남아, 편집 중이던 값이 - // 새 대상에 확정되거나 취소가 옛 값을 새 대상에 쓴다. - // 포커스를 유지한 채 선택만 바뀌는 경로가 분리 패널 selection sync에 있다 -
- {sections.map((section) => { - if (!section.renderVisible) return null; - const sectionLabel = translate(section.label, section.label); - return ( -
- {section.label && ( -

- {sectionLabel} -

- )} - - {section.entries.map((entry) => - renderEntry(entry.key, entry.schema, entry.renderVisible), - )} - -
- ); - })} -
- ); - }; - + ) => ( + + ); // 배치 편집용 interactiveRefs const batchColorPickerInteractiveRefs = [ batchNoteColorButtonRef, diff --git a/src/renderer/components/main/Grid/PropertiesPanel/PluginSettingsForm.tsx b/src/renderer/components/main/Grid/PropertiesPanel/PluginSettingsForm.tsx new file mode 100644 index 000000000..f425c05e7 --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/PluginSettingsForm.tsx @@ -0,0 +1,249 @@ +import React from 'react'; +import Checkbox from '@components/main/common/Checkbox'; +import Dropdown from '@components/main/common/Dropdown'; +import { + normalizeSettingsSections, + type SettingsNormalizationErrorKind, +} from '@plugins/runtime/settingsSections'; +import type { + PluginMessages, + PluginSettingSchema, +} from '@src/types/plugin/api'; +import { translatePluginMessage } from '@utils/plugin/pluginI18n'; +import { + ColorInput, + NumberInput, + PropertyRow, + PropertySection, + TextInput, +} from './PropertyInputs'; + +export type PluginSettingsFormRenderer = ( + schema: Record | undefined, + values: Record, + messages: PluginMessages | undefined, + pluginId: string, + colorIdPrefix: string, + onChange: (key: string, value: unknown) => void, +) => React.ReactNode; + +interface PluginSettingsFormProps { + schema: Record | undefined; + values: Record; + messages: PluginMessages | undefined; + pluginId: string; + colorIdPrefix: string; + onChange: (key: string, value: unknown) => void; + locale: string; + panelElement: HTMLDivElement | null; + reportNormalizationError: ( + pluginId: string, + key: string, + error: unknown, + kind: SettingsNormalizationErrorKind, + ) => void; + t: (key: string) => string | undefined; +} + +const getPluginInputWidth = ( + type: 'string' | 'number', + value: unknown, +): string => { + if (type === 'number') return '60px'; + const stringValue = String(value ?? ''); + if (stringValue.length <= 4) return '60px'; + if (stringValue.length <= 10) return '100px'; + return '200px'; +}; + +const PluginSettingsForm = ({ + schema, + values, + messages, + pluginId, + colorIdPrefix, + onChange, + locale, + panelElement, + reportNormalizationError, + t, +}: PluginSettingsFormProps) => { + const sections = normalizeSettingsSections( + schema, + values, + (key, error, kind) => reportNormalizationError(pluginId, key, error, kind), + ); + if (!sections.some((section) => section.renderVisible)) { + return ( +

+ {t('propertiesPanel.pluginNoSettings') || '설정할 항목이 없습니다.'} +

+ ); + } + + const translate = (key?: string, fallback?: string) => { + if (!key) return fallback || ''; + return translatePluginMessage({ + messages, + locale, + key, + fallback, + }); + }; + + const renderEntry = ( + key: string, + schemaValue: Exclude, + renderVisible: boolean, + ) => { + if (!renderVisible) return null; + const rawValue = + values[key] !== undefined ? values[key] : schemaValue.default; + const labelText = translate(schemaValue.label, schemaValue.label); + const placeholderText = + typeof schemaValue.placeholder === 'string' + ? translate(schemaValue.placeholder, schemaValue.placeholder) + : schemaValue.placeholder; + + let control: React.ReactNode = null; + + if (schemaValue.type === 'boolean') { + const checked = !!rawValue; + control = ( + onChange(key, !checked)} + /> + ); + } else if (schemaValue.type === 'color') { + const colorValue = + typeof rawValue === 'string' + ? rawValue + : (schemaValue.default as string) || '#FFFFFF'; + control = ( + onChange(key, color)} + colorId={`${colorIdPrefix}-${key}`} + panelElement={panelElement} + solidOnly={true} + /> + ); + } else if (schemaValue.type === 'number') { + const numericValue = Number(rawValue); + const normalizedValue = Number.isFinite(numericValue) + ? numericValue + : typeof schemaValue.default === 'number' + ? schemaValue.default + : 0; + const stepString = + schemaValue.step != null ? String(schemaValue.step) : ''; + const dotIndex = stepString.indexOf('.'); + const hasDecimal = dotIndex !== -1; + const decimalScale = hasDecimal ? stepString.length - dotIndex - 1 : 0; + control = ( + onChange(key, nextValue)} + width={getPluginInputWidth('number', rawValue)} + /> + ); + } else if (schemaValue.type === 'string') { + const stringValue = + rawValue === undefined || rawValue === null ? '' : String(rawValue); + control = ( + onChange(key, nextValue)} + placeholder={ + typeof placeholderText === 'string' ? placeholderText : undefined + } + width={getPluginInputWidth('string', stringValue)} + /> + ); + } else if (schemaValue.type === 'select') { + const options = (schemaValue.options || []).map((option) => ({ + label: translate(option.label, option.label), + value: String(option.value), + })); + const optionMap = new Map( + (schemaValue.options || []).map((option) => [ + String(option.value), + option.value, + ]), + ); + const selectedValue = optionMap.has(String(rawValue)) + ? String(rawValue) + : String(schemaValue.default ?? ''); + control = ( + 0 + ? placeholderText + : undefined + } + onChange={(nextValue) => + onChange(key, optionMap.get(nextValue) ?? nextValue) + } + /> + ); + } + + if (schemaValue.type === 'boolean') { + return ( +
+

{labelText}

+
{control}
+
+ ); + } + + return ( + + {control} + + ); + }; + + return ( + // 대상 전환 시 입력 인스턴스도 교체해 이전 편집값의 오확정을 방지 +
+ {sections.map((section) => { + if (!section.renderVisible) return null; + const sectionLabel = translate(section.label, section.label); + return ( +
+ {section.label && ( +

+ {sectionLabel} +

+ )} + + {section.entries.map((entry) => + renderEntry(entry.key, entry.schema, entry.renderVisible), + )} + +
+ ); + })} +
+ ); +}; + +export default PluginSettingsForm; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batchSelectionModel.ts b/src/renderer/components/main/Grid/PropertiesPanel/batchSelectionModel.ts new file mode 100644 index 000000000..d903e54d6 --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/batchSelectionModel.ts @@ -0,0 +1,285 @@ +import type { CanonicalKnobItemPosition } from '@src/types/editor'; +import type { GraphItemPosition } from '@src/types/key/graphItems'; +import type { KeyPosition } from '@src/types/key/keys'; +import type { KnobItemPosition } from '@src/types/key/knobs'; +import { getKeyInfoByGlobalKey } from '@utils/core/KeyMaps'; +import { slotCanonical, slotDisplayName } from '@utils/keySlot'; +import { getStatTypeLabel } from './propertyPanelAdapters'; +import type { usePropertiesPanelSelection } from './usePropertiesPanelSelection'; + +type PropertiesPanelSelection = ReturnType; + +type BatchSelectionModelInput = Pick< + PropertiesPanelSelection, + | 'selectedKeyType' + | 'positions' + | 'canonicalPositions' + | 'keyMappings' + | 'statItemPositions' + | 'graphItemPositions' + | 'knobItemPositions' + | 'selectedKeyElements' + | 'selectedKeyLikeElements' + | 'selectedGraphElements' + | 'selectedKnobElements' + | 'selectedBatchStyleElements' +>; + +const aggregateMixedValue = ( + positions: Position[], + getter: (position: Position) => Value | undefined, + defaultValue: Value, +): { isMixed: boolean; value: Value } => { + if (positions.length === 0) return { isMixed: false, value: defaultValue }; + + const firstValue = getter(positions[0]) ?? defaultValue; + const isMixed = positions.some((position) => { + const value = getter(position) ?? defaultValue; + if (typeof value === 'object' && typeof firstValue === 'object') { + return JSON.stringify(value) !== JSON.stringify(firstValue); + } + return value !== firstValue; + }); + return { isMixed, value: firstValue }; +}; + +export const createBatchSelectionModel = ({ + selectedKeyType, + positions, + canonicalPositions, + keyMappings, + statItemPositions, + graphItemPositions, + knobItemPositions, + selectedKeyElements, + selectedKeyLikeElements, + selectedGraphElements, + selectedKnobElements, + selectedBatchStyleElements, +}: BatchSelectionModelInput) => { + const getSelectedKeysData = () => + selectedKeyLikeElements + .map((element) => { + if (element.type === 'key') { + const index = (positions[selectedKeyType] ?? []).findIndex( + (position) => position.id === element.id, + ); + if (index < 0) return null; + const position = positions[selectedKeyType]?.[index]; + const slot = keyMappings[selectedKeyType]?.[index] ?? null; + const keyCode = slot != null ? slotCanonical(slot) : null; + const keyInfo = + slot != null && keyCode + ? typeof slot === 'string' + ? getKeyInfoByGlobalKey(slot) + : { + browserKey: keyCode, + globalKey: keyCode, + displayName: slotDisplayName(slot), + } + : null; + return { index, position, keyCode, keyInfo }; + } + const index = (statItemPositions[selectedKeyType] ?? []).findIndex( + (position) => position.id === element.id, + ); + if (index < 0) return null; + const position = statItemPositions[selectedKeyType]?.[index]; + const statLabel = + (position?.displayText || '').trim() || + getStatTypeLabel(position?.statType ?? null); + return { + index, + position, + keyCode: null, + keyInfo: { globalKey: statLabel, displayName: statLabel }, + }; + }) + .filter( + (data): data is NonNullable => + data !== null && data.position !== undefined, + ); + + const getSelectedGraphsData = () => + selectedGraphElements + .map((element) => { + const index = (graphItemPositions[selectedKeyType] ?? []).findIndex( + (position) => position.id === element.id, + ); + if (index < 0) return null; + const position = graphItemPositions[selectedKeyType]?.[index]; + const graphLabel = `${getStatTypeLabel( + position?.statType ?? null, + )} Graph`; + return { + index, + position, + keyCode: null, + keyInfo: { globalKey: graphLabel, displayName: graphLabel }, + }; + }) + .filter( + (data): data is NonNullable => + data !== null && data.position !== undefined, + ); + + const getSelectedKnobsData = () => + selectedKnobElements + .map((element) => { + const index = (knobItemPositions[selectedKeyType] ?? []).findIndex( + (position) => position.id === element.id, + ); + if (index < 0) return null; + const position = knobItemPositions[selectedKeyType]?.[index]; + const knobLabel = (position?.displayText || '').trim() || 'Knob'; + return { + index, + position, + keyCode: null, + keyInfo: { globalKey: knobLabel, displayName: knobLabel }, + }; + }) + .filter( + (data): data is NonNullable => + data !== null && data.position !== undefined, + ); + + const getSelectedBatchStyleData = () => { + const keyLikeData = getSelectedKeysData(); + const graphData = getSelectedGraphsData(); + const knobData = getSelectedKnobsData(); + return selectedBatchStyleElements + .map((element) => { + if (element.type === 'key') { + return keyLikeData.find((data) => data.position.id === element.id); + } + if (element.type === 'stat') { + return keyLikeData.find((data) => data.position.id === element.id); + } + if (element.type === 'graph') { + return graphData.find((data) => data.position.id === element.id); + } + return knobData.find((data) => data.position.id === element.id); + }) + .filter((data): data is NonNullable => data !== undefined); + }; + + const getSelectedKeyOnlyPositions = () => + selectedKeyElements + .map((element) => { + const index = (positions[selectedKeyType] ?? []).findIndex( + (position) => position.id === element.id, + ); + const position = positions[selectedKeyType]?.[index]; + return position ? { index, position } : null; + }) + .filter((value): value is NonNullable => value !== null); + + const getSelectedActiveCapablePositions = (): KeyPosition[] => { + const keyData = getSelectedKeyOnlyPositions().map( + ({ position }) => position, + ); + const knobData = selectedKnobElements + .map((element) => + knobItemPositions[selectedKeyType]?.find( + (position) => position.id === element.id, + ), + ) + .filter( + (position): position is CanonicalKnobItemPosition => position != null, + ); + return [...keyData, ...knobData]; + }; + + return { + getSelectedKeysData, + getSelectedGraphsData, + getSelectedKnobsData, + getSelectedBatchStyleData, + getSelectedKeyOnlyPositions, + getMixedValue: ( + getter: (position: KeyPosition) => Value | undefined, + defaultValue: Value, + ) => + aggregateMixedValue( + getSelectedKeysData().map((data) => data.position), + getter, + defaultValue, + ), + getMixedValueCanonical: ( + getter: (position: KeyPosition) => Value | undefined, + defaultValue: Value, + ) => { + const modePositions = canonicalPositions[selectedKeyType] ?? []; + const selected = selectedKeyLikeElements.flatMap((element) => + element.type === 'key' + ? modePositions.filter((position) => position.id === element.id) + : [], + ); + return aggregateMixedValue(selected, getter, defaultValue); + }, + getMixedValueGraphs: ( + getter: (position: GraphItemPosition) => Value | undefined, + defaultValue: Value, + ) => + aggregateMixedValue( + getSelectedGraphsData().map((data) => data.position), + getter, + defaultValue, + ), + getMixedValueGraphsAsKey: ( + getter: (position: KeyPosition) => Value | undefined, + defaultValue: Value, + ) => + aggregateMixedValue( + getSelectedGraphsData().map((data) => data.position), + getter, + defaultValue, + ), + getMixedValueKnobs: ( + getter: (position: KnobItemPosition) => Value | undefined, + defaultValue: Value, + ) => + aggregateMixedValue( + getSelectedKnobsData().map((data) => data.position), + getter, + defaultValue, + ), + getMixedValueKnobsAsKey: ( + getter: (position: KeyPosition) => Value | undefined, + defaultValue: Value, + ) => + aggregateMixedValue( + getSelectedKnobsData().map((data) => data.position), + getter, + defaultValue, + ), + getMixedValueBatch: ( + getter: (position: KeyPosition) => Value | undefined, + defaultValue: Value, + ) => + aggregateMixedValue( + getSelectedBatchStyleData().map((data) => data.position as KeyPosition), + getter, + defaultValue, + ), + getMixedValueActiveCapable: ( + getter: (position: KeyPosition) => Value | undefined, + defaultValue: Value, + ) => + aggregateMixedValue( + getSelectedActiveCapablePositions(), + getter, + defaultValue, + ), + getMixedValueKeysOnly: ( + getter: (position: KeyPosition) => Value | undefined, + defaultValue: Value, + ) => + aggregateMixedValue( + getSelectedKeyOnlyPositions().map(({ position }) => position), + getter, + defaultValue, + ), + }; +}; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/propertyPanelAdapters.test.ts b/src/renderer/components/main/Grid/PropertiesPanel/propertyPanelAdapters.test.ts new file mode 100644 index 000000000..482e0ed79 --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/propertyPanelAdapters.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; +import { TABS } from './types'; +import { + geometryAxisPatch, + getFontFamilyPatch, + getFontStylePatch, + getGraphRuntimePropertyPatch, + getKnobRuntimePropertyPatch, + getNotePropertyPatch, + getStatTypeLabel, + getUseInlineStylesPatch, + shouldNormalizePropertyTabToStyle, +} from './propertyPanelAdapters'; + +describe('property panel adapters', () => { + it('통계 유형과 기하 축을 UI 표현으로 변환한다', () => { + expect(getStatTypeLabel('kpsAvg')).toBe('AVG'); + expect(getStatTypeLabel('kpsMax')).toBe('MAX'); + expect(getStatTypeLabel('total')).toBe('Total'); + expect(getStatTypeLabel(null)).toBe('KPS'); + expect(geometryAxisPatch('dx', 12)).toEqual({ dx: 12 }); + expect(geometryAxisPatch('height', 48)).toEqual({ height: 48 }); + }); + + it('선택 구성에 맞지 않는 탭만 스타일 탭으로 정규화한다', () => { + expect( + shouldNormalizePropertyTabToStyle([{ type: 'stat' }], TABS.NOTE), + ).toBe(true); + expect( + shouldNormalizePropertyTabToStyle([{ type: 'graph' }], TABS.COUNTER), + ).toBe(true); + expect( + shouldNormalizePropertyTabToStyle([{ type: 'key' }], TABS.NOTE), + ).toBe(false); + expect( + shouldNormalizePropertyTabToStyle([{ type: 'graph' }], TABS.STYLE), + ).toBe(false); + }); + + it('graph와 knob 런타임 패치를 단일 허용 필드로 제한한다', () => { + expect(getGraphRuntimePropertyPatch({ showAvgLine: true })).toEqual({ + property: 'showAvgLine', + value: true, + }); + expect(getGraphRuntimePropertyPatch({ graphSpeed: 120 })).toEqual({ + property: 'graphSpeed', + value: 120, + }); + expect(getGraphRuntimePropertyPatch({ graphSpeed: -1 })).toBeNull(); + expect( + getGraphRuntimePropertyPatch({ showAvgLine: true, graphSpeed: 1 }), + ).toBeNull(); + expect(getKnobRuntimePropertyPatch({ sensitivity: 1.5 })).toEqual({ + property: 'sensitivity', + value: 1.5, + }); + expect(getKnobRuntimePropertyPatch({ sensitivity: Number.NaN })).toBeNull(); + }); + + it('공통 스타일 패치를 wire 태그 유니온으로 변환한다', () => { + expect(getUseInlineStylesPatch({ useInlineStyles: false })).toBe(false); + expect(getFontFamilyPatch({ fontFamily: 'Pretendard' })).toEqual({ + property: 'fontFamily', + value: 'Pretendard', + }); + expect(getFontStylePatch({ fontWeight: 700 })).toEqual({ + property: 'fontWeight', + value: 700, + }); + expect(getFontStylePatch({ fontWeight: -1 })).toBeNull(); + expect(getFontStylePatch({ fontBold: true, fontItalic: true })).toBeNull(); + }); + + it('노트 패치의 허용 값과 단일 필드 불변식을 검증한다', () => { + expect(getNotePropertyPatch({ noteAlignment: 'center' })).toEqual({ + property: 'noteAlignment', + value: 'center', + }); + expect(getNotePropertyPatch({ noteBorderSide: 'vertical' })).toEqual({ + property: 'noteBorderSide', + value: 'vertical', + }); + expect(getNotePropertyPatch({ noteAlignment: 'middle' })).toBeNull(); + expect( + getNotePropertyPatch({ noteGlowEnabled: true, noteGlowSyncPaint: true }), + ).toBeNull(); + }); +}); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/propertyPanelAdapters.ts b/src/renderer/components/main/Grid/PropertiesPanel/propertyPanelAdapters.ts new file mode 100644 index 000000000..859e8a69b --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/propertyPanelAdapters.ts @@ -0,0 +1,215 @@ +import type { NativeLayerBoundsTarget } from '@plugins/runtime/displayElement/pluginElementActions'; +import type { GraphItemPosition } from '@src/types/key/graphItems'; +import type { KnobItemPosition } from '@src/types/key/knobs'; +import type { StatItemType } from '@src/types/key/statItems'; +import type { + EditorFontFamilyPropertyPatchV1, + EditorFontStylePropertyPatchV1, + EditorGraphRuntimePropertyPatchV1, + EditorKnobRuntimePropertyPatchV1, + EditorNotePropertyPatchV1, +} from '@src/types/editor'; +import { TABS } from './types'; + +export const getStatTypeLabel = (statType?: StatItemType | null): string => { + switch (statType) { + case 'kpsAvg': + return 'AVG'; + case 'kpsMax': + return 'MAX'; + case 'total': + return 'Total'; + case 'kps': + default: + return 'KPS'; + } +}; + +export const geometryAxisPatch = ( + field: 'dx' | 'dy' | 'width' | 'height', + value: number, +): NativeLayerBoundsTarget['patch'] => { + switch (field) { + case 'dx': + return { dx: value }; + case 'dy': + return { dy: value }; + case 'width': + return { width: value }; + case 'height': + return { height: value }; + } +}; + +export const shouldNormalizePropertyTabToStyle = ( + elements: Array<{ type: string }>, + activeTab: (typeof TABS)[keyof typeof TABS], +): boolean => { + if (activeTab === TABS.STYLE) return false; + const hasKey = elements.some((element) => element.type === 'key'); + const hasStat = elements.some((element) => element.type === 'stat'); + const hasGraph = elements.some((element) => element.type === 'graph'); + + // 플러그인 혼합도 native 구성 기준으로 정규화 + if (activeTab === TABS.NOTE && hasStat && !hasKey) { + return true; + } + return hasGraph && !hasKey && !hasStat; +}; + +export const getGraphRuntimePropertyPatch = ( + updates: Partial, +): EditorGraphRuntimePropertyPatchV1 | null => { + const keys = Object.keys(updates); + if (keys.length !== 1) return null; + if (keys[0] === 'showAvgLine' && typeof updates.showAvgLine === 'boolean') { + return { property: 'showAvgLine', value: updates.showAvgLine }; + } + if ( + keys[0] === 'graphAnimationEnabled' && + typeof updates.graphAnimationEnabled === 'boolean' + ) { + return { + property: 'graphAnimationEnabled', + value: updates.graphAnimationEnabled, + }; + } + if ( + keys[0] === 'graphSpeed' && + Number.isSafeInteger(updates.graphSpeed) && + (updates.graphSpeed as number) >= 0 && + (updates.graphSpeed as number) <= 4_294_967_295 + ) { + return { property: 'graphSpeed', value: updates.graphSpeed as number }; + } + return null; +}; + +export const getKnobRuntimePropertyPatch = ( + updates: Partial, +): EditorKnobRuntimePropertyPatchV1 | null => { + const keys = Object.keys(updates); + if (keys.length !== 1) return null; + if (keys[0] === 'reverse' && typeof updates.reverse === 'boolean') { + return { property: 'reverse', value: updates.reverse }; + } + if ( + keys[0] === 'sensitivity' && + typeof updates.sensitivity === 'number' && + Number.isFinite(updates.sensitivity) + ) { + return { property: 'sensitivity', value: updates.sensitivity }; + } + return null; +}; + +export const getUseInlineStylesPatch = (updates: object): boolean | null => { + const values = updates as Record; + const keys = Object.keys(updates); + return keys.length === 1 && + keys[0] === 'useInlineStyles' && + typeof values.useInlineStyles === 'boolean' + ? values.useInlineStyles + : null; +}; + +export const getFontStylePatch = ( + updates: object, +): EditorFontStylePropertyPatchV1 | null => { + const values = updates as Record; + const keys = Object.keys(updates); + if (keys.length !== 1) return null; + if ( + keys[0] === 'fontWeight' && + Number.isSafeInteger(values.fontWeight) && + (values.fontWeight as number) >= 0 && + (values.fontWeight as number) <= 4_294_967_295 + ) { + return { property: 'fontWeight', value: values.fontWeight as number }; + } + if (keys[0] === 'fontBold' && typeof values.fontBold === 'boolean') { + return { property: 'fontBold', value: values.fontBold }; + } + if (keys[0] === 'fontItalic' && typeof values.fontItalic === 'boolean') { + return { property: 'fontItalic', value: values.fontItalic }; + } + if ( + keys[0] === 'fontUnderline' && + typeof values.fontUnderline === 'boolean' + ) { + return { property: 'fontUnderline', value: values.fontUnderline }; + } + if ( + keys[0] === 'fontStrikethrough' && + typeof values.fontStrikethrough === 'boolean' + ) { + return { property: 'fontStrikethrough', value: values.fontStrikethrough }; + } + return null; +}; + +export const getFontFamilyPatch = ( + updates: object, +): EditorFontFamilyPropertyPatchV1 | null => { + const values = updates as Record; + const keys = Object.keys(updates); + return keys.length === 1 && + keys[0] === 'fontFamily' && + typeof values.fontFamily === 'string' + ? { property: 'fontFamily', value: values.fontFamily } + : null; +}; + +export const getNotePropertyPatch = ( + updates: object, +): EditorNotePropertyPatchV1 | null => { + const values = updates as Record; + const keys = Object.keys(updates); + if (keys.length !== 1) return null; + if ( + keys[0] === 'noteEffectEnabled' && + typeof values.noteEffectEnabled === 'boolean' + ) { + return { property: 'noteEffectEnabled', value: values.noteEffectEnabled }; + } + if ( + keys[0] === 'noteAutoYCorrection' && + typeof values.noteAutoYCorrection === 'boolean' + ) { + return { + property: 'noteAutoYCorrection', + value: values.noteAutoYCorrection, + }; + } + if ( + keys[0] === 'noteGlowEnabled' && + typeof values.noteGlowEnabled === 'boolean' + ) { + return { property: 'noteGlowEnabled', value: values.noteGlowEnabled }; + } + if ( + keys[0] === 'noteGlowSyncPaint' && + typeof values.noteGlowSyncPaint === 'boolean' + ) { + return { property: 'noteGlowSyncPaint', value: values.noteGlowSyncPaint }; + } + if ( + keys[0] === 'noteAlignment' && + ['left', 'center', 'right'].includes(values.noteAlignment as string) + ) { + return { + property: 'noteAlignment', + value: values.noteAlignment as 'left' | 'center' | 'right', + }; + } + if ( + keys[0] === 'noteBorderSide' && + ['all', 'vertical', 'horizontal'].includes(values.noteBorderSide as string) + ) { + return { + property: 'noteBorderSide', + value: values.noteBorderSide as 'all' | 'vertical' | 'horizontal', + }; + } + return null; +}; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/singleSelectionHandlers.ts b/src/renderer/components/main/Grid/PropertiesPanel/singleSelectionHandlers.ts new file mode 100644 index 000000000..f99149505 --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/singleSelectionHandlers.ts @@ -0,0 +1,494 @@ +import { isNativeElementId } from '@src/renderer/editor/model/elementId'; +import { resolveElementById } from '@src/renderer/editor/model/elementIdMap'; +import { captureEditorDocument } from '@src/renderer/editor/runtime/editorStateCoordinator'; +import { editGestureController } from '@src/renderer/editor/runtime/editGestureController'; +import { reportElementOpSkipped } from '@src/renderer/editor/runtime/elementIntent'; +import { + commitElementGeometryById, + patchActiveImageById, + patchActiveImageFitById, + patchActiveTransparentById, + patchCounterAnimationEnabledById, + patchCounterAnimationPresetById, + patchCounterEnabledById, + patchCounterFillById, + patchCounterLayoutById, + patchCounterTypographyById, + patchElementPropertyById, + patchIdleImageFitById, + patchIdleTransparentById, + patchInactiveImageById, + patchNotePaintById, + patchPaintById, + patchShadowById, + patchSoundEnabledById, + patchSoundPathById, + patchSoundVolumeById, + patchStylePropertyById, + type GeometryField, +} from '@src/renderer/editor/runtime/elementOps'; +import type { + EditorCounterAnimationPresetIntentV1, + EditorCounterFillPropertyPatchV1, + EditorCounterLayoutPropertyPatchV1, + EditorCounterTypographyPropertyPatchV1, + EditorElementPropertyPatchV1, + EditorElementTypeV1, + EditorNotePaintPropertyPatchV1, + EditorPaintPropertyPatchV1, + EditorPreviewStylePropertyPatchV1, + EditorShadowPropertyPatchV1, + EditorStylePropertyPreviewPatchV1, +} from '@src/types/editor'; +import type { ImageFit } from '@src/types/key/keys'; +import { projectNotePaintPatch } from '@src/types/key/notePaint'; +import { + previewSinglePaint, + previewSingleStyleProperty, +} from './previewPatchForwarders'; +import { geometryAxisPatch } from './propertyPanelAdapters'; + +const commitSingleGeometry = ( + type: EditorElementTypeV1, + id: string, + field: GeometryField, + value: number, +) => { + const patch = geometryAxisPatch(field, value); + // 네 종류 모두 숫자 입력이 preview 게스처를 열므로 커밋이 그 게스처를 정산한다 + const gestureId = editGestureController.activeGestureId() ?? undefined; + const persisted = commitElementGeometryById(type, id, patch, { gestureId }); + editGestureController.settleCommit(persisted); + void persisted.catch((error) => { + console.error('Failed to update element geometry', error); + }); +}; + +const stableGeometryHandler = ( + type: EditorElementTypeV1, + id: string | undefined, +) => + id && isNativeElementId(id) + ? (field: GeometryField, value: number) => + commitSingleGeometry(type, id, field, value) + : undefined; + +const stableGeometryPreviewHandler = ( + type: EditorElementTypeV1, + id: string | undefined, +) => + id && isNativeElementId(id) + ? (field: GeometryField, value: number) => { + const locator = resolveElementById(type, id); + if (!locator) return; + editGestureController.preview( + locator.mode, + [ + { + id, + patch: geometryAxisPatch(field, value), + }, + ], + { + domain: + type === 'key' + ? 'keyPosition' + : type === 'stat' + ? 'statPosition' + : type === 'graph' + ? 'graphPosition' + : 'knobPosition', + }, + ); + } + : undefined; + +const stableElementPropertyCommitHandler = ( + type: EditorElementTypeV1, + id: string | undefined, +) => + id && isNativeElementId(id) + ? ( + patch: EditorElementPropertyPatchV1, + options?: { gestureId?: string }, + ) => { + // 분리 창도 즉시 반영을 거친다 - RPC 왕복 전에 값이 되돌아가는 깜빡임 방지 + // 그래프 색과 키 이미지 변환은 preview 게스처를 정산하고, + // 그 외는 호출부가 준 gestureId만 공유 + const settlesGesture = + (type === 'graph' && patch.property === 'graphColor') || + (type === 'key' && + (patch.property === 'idleImageTransform' || + patch.property === 'activeImageTransform')); + const gestureId = + options?.gestureId ?? + (settlesGesture + ? editGestureController.activeGestureId() ?? undefined + : undefined); + const persisted = patchElementPropertyById( + type, + id, + patch, + gestureId ? { gestureId } : {}, + ); + if (settlesGesture) { + editGestureController.settleCommit(persisted); + } + void persisted.catch((error) => { + console.error('Failed to update element property', error); + }); + } + : undefined; + +const stableInactiveImageHandler = ( + type: EditorElementTypeV1, + id: string | undefined, +) => + id && isNativeElementId(id) + ? (inactiveImage: string) => { + const persisted = patchInactiveImageById(type, id, inactiveImage); + void persisted.catch((error) => { + console.error('Failed to update inactive image', error); + }); + } + : undefined; + +const stableActiveImageHandler = ( + type: 'key' | 'knob', + id: string | undefined, +) => + id && isNativeElementId(id) + ? (activeImage: string) => { + const persisted = patchActiveImageById(type, id, activeImage); + void persisted.catch((error) => { + console.error('Failed to update active image', error); + }); + } + : undefined; + +const stableIdleTransparentHandler = ( + type: EditorElementTypeV1, + id: string | undefined, +) => + id && isNativeElementId(id) + ? (idleTransparent: boolean) => { + const persisted = patchIdleTransparentById(type, id, idleTransparent); + void persisted.catch((error) => { + console.error('Failed to update idle transparency', error); + }); + } + : undefined; + +const stableActiveTransparentHandler = ( + type: 'key' | 'knob', + id: string | undefined, +) => + id && isNativeElementId(id) + ? (activeTransparent: boolean) => { + const persisted = patchActiveTransparentById( + type, + id, + activeTransparent, + ); + void persisted.catch((error) => { + console.error('Failed to update active transparency', error); + }); + } + : undefined; + +const stableIdleImageFitHandler = ( + type: EditorElementTypeV1, + id: string | undefined, +) => + id && isNativeElementId(id) + ? (idleImageFit: ImageFit) => { + const persisted = patchIdleImageFitById(type, id, idleImageFit); + void persisted.catch((error) => { + console.error('Failed to update idle image fit', error); + }); + } + : undefined; + +const stableActiveImageFitHandler = ( + type: 'key' | 'knob', + id: string | undefined, +) => + id && isNativeElementId(id) + ? (activeImageFit: ImageFit) => { + const persisted = patchActiveImageFitById(type, id, activeImageFit); + void persisted.catch((error) => { + console.error('Failed to update active image fit', error); + }); + } + : undefined; + +const stableSoundPathHandler = (id: string | undefined) => + id && isNativeElementId(id) + ? (soundPath: string) => { + const persisted = patchSoundPathById(id, soundPath); + void persisted.catch((error) => { + console.error('Failed to update sound path', error); + }); + } + : undefined; + +const stableSoundEnabledHandler = (id: string | undefined) => + id && isNativeElementId(id) + ? (soundEnabled: boolean) => { + const persisted = patchSoundEnabledById(id, soundEnabled); + void persisted.catch((error) => { + console.error('Failed to update sound enabled', error); + }); + } + : undefined; + +const stableSoundVolumeHandler = (id: string | undefined) => + id && isNativeElementId(id) + ? (soundVolume: number) => { + const gestureId = editGestureController.activeGestureId() ?? undefined; + const persisted = patchSoundVolumeById(id, soundVolume, { + gestureId, + }); + editGestureController.settleCommit(persisted); + void persisted.catch((error) => { + console.error('Failed to update sound volume', error); + }); + } + : undefined; + +const stableStylePropertyPreviewHandler = ( + type: EditorElementTypeV1, + id: string | undefined, +) => + id && isNativeElementId(id) + ? (patch: EditorStylePropertyPreviewPatchV1) => + previewSingleStyleProperty(type, id, patch) + : undefined; + +const stableStylePropertyCommitHandler = ( + type: EditorElementTypeV1, + id: string | undefined, + options: { settleGesture?: boolean } = {}, +) => + id && isNativeElementId(id) + ? (patch: EditorPreviewStylePropertyPatchV1) => { + const gestureId = options.settleGesture + ? editGestureController.activeGestureId() ?? undefined + : undefined; + const persisted = patchStylePropertyById(type, id, patch, { + gestureId, + }); + if (options.settleGesture) { + editGestureController.settleCommit(persisted); + } + void persisted.catch((error) => { + console.error('Failed to update style property', error); + }); + } + : undefined; + +const stablePaintCommitHandler = ( + type: EditorElementTypeV1, + id: string | undefined, +) => + id && isNativeElementId(id) + ? (patch: EditorPaintPropertyPatchV1) => { + const gestureId = editGestureController.activeGestureId() ?? undefined; + const persisted = patchPaintById(type, id, patch, { gestureId }); + editGestureController.settleCommit(persisted); + void persisted.catch((error) => { + console.error('Failed to update paint', error); + }); + } + : undefined; + +const stablePaintPreviewHandler = ( + type: EditorElementTypeV1, + id: string | undefined, +) => + id && isNativeElementId(id) + ? (patch: EditorPaintPropertyPatchV1) => previewSinglePaint(type, id, patch) + : undefined; + +const stableShadowCommitHandler = ( + type: 'key' | 'stat' | 'knob', + id: string | undefined, +) => + id && isNativeElementId(id) + ? (patch: EditorShadowPropertyPatchV1) => { + // 스크럽·타이핑 preview 게스처를 이 커밋으로 정산 - 실패 시 lifecycle까지 폐기되게 id 동반 + const gestureId = editGestureController.activeGestureId() ?? undefined; + const persisted = patchShadowById(type, id, patch, { gestureId }); + editGestureController.settleCommit(persisted); + void persisted.catch((error) => { + console.error('Failed to update shadow', error); + }); + } + : undefined; + +const stableNotePaintCommitHandler = (id: string | undefined) => + id && isNativeElementId(id) + ? (patch: EditorNotePaintPropertyPatchV1) => { + const gestureId = editGestureController.activeGestureId() ?? undefined; + const persisted = patchNotePaintById(id, patch, { gestureId }); + editGestureController.settleCommit(persisted); + void persisted + .then((applied) => { + // 가드 거부(false)는 rejection이 아니라서 별도 로그가 없으면 무음 + if (!applied) { + reportElementOpSkipped('single note paint'); + } + }) + .catch((error) => { + console.error('Failed to update note paint', error); + }); + } + : undefined; + +const stableNotePaintPreviewHandler = (id: string | undefined) => + id && isNativeElementId(id) + ? (patch: EditorNotePaintPropertyPatchV1) => { + const locator = resolveElementById('key', id); + if (!locator) return; + // canonical 전달 - 동기화 켜진 키의 글로우 미러가 낙관 적용과 같은 규칙 + const current = + captureEditorDocument().keyPositions[locator.mode]?.[locator.index]; + editGestureController.preview( + locator.mode, + [ + { + id, + patch: projectNotePaintPatch( + patch, + current?.id === id ? current : undefined, + ), + }, + ], + { domain: 'keyPosition' }, + ); + } + : undefined; + +const stableCounterAnimationPresetHandler = ( + elementType: 'key' | 'stat', + id: string | undefined, +) => + id && isNativeElementId(id) + ? (intent: EditorCounterAnimationPresetIntentV1) => { + const persisted = patchCounterAnimationPresetById( + elementType, + id, + intent, + ); + void persisted.catch((error) => { + console.error('Failed to update counter animation preset', error); + }); + } + : undefined; + +const stableCounterEnabledHandler = ( + elementType: 'key' | 'stat', + id: string | undefined, +) => + id && isNativeElementId(id) + ? (enabled: boolean) => { + // 분리 창도 즉시 반영을 거친다 - 배치 경로와 같은 래퍼를 대상 하나로 재사용 + const persisted = patchCounterEnabledById(elementType, id, enabled); + void persisted.catch((error) => { + console.error('Failed to update counter enabled', error); + }); + } + : undefined; + +const stableCounterAnimationEnabledHandler = ( + elementType: 'key' | 'stat', + id: string | undefined, +) => + id && isNativeElementId(id) + ? (enabled: boolean) => { + const persisted = patchCounterAnimationEnabledById( + elementType, + id, + enabled, + ); + void persisted.catch((error) => { + console.error('Failed to update counter animation enabled', error); + }); + } + : undefined; + +const stableCounterLayoutHandler = ( + elementType: 'key' | 'stat', + id: string | undefined, +) => + id && isNativeElementId(id) + ? (patch: EditorCounterLayoutPropertyPatchV1) => { + const persisted = patchCounterLayoutById(elementType, id, patch); + void persisted.catch((error) => { + console.error('Failed to update counter layout', error); + }); + } + : undefined; + +const stableCounterTypographyHandler = ( + elementType: 'key' | 'stat', + id: string | undefined, +) => + id && isNativeElementId(id) + ? ( + patch: EditorCounterTypographyPropertyPatchV1, + options?: { gestureId?: string }, + ) => { + const persisted = options?.gestureId + ? patchCounterTypographyById(elementType, id, patch, { + gestureId: options.gestureId, + }) + : patchCounterTypographyById(elementType, id, patch); + void persisted.catch((error) => { + console.error('Failed to update counter typography', error); + }); + } + : undefined; + +const stableCounterFillHandler = ( + elementType: 'key' | 'stat', + id: string | undefined, +) => + id && isNativeElementId(id) + ? (patch: EditorCounterFillPropertyPatchV1) => { + const persisted = patchCounterFillById(elementType, id, patch); + void persisted.catch((error) => { + console.error('Failed to update counter fill', error); + }); + } + : undefined; + +// ============================================================================ + +export const singleSelectionHandlers = { + stableGeometryHandler, + stableGeometryPreviewHandler, + stableElementPropertyCommitHandler, + stableInactiveImageHandler, + stableActiveImageHandler, + stableIdleTransparentHandler, + stableActiveTransparentHandler, + stableIdleImageFitHandler, + stableActiveImageFitHandler, + stableSoundPathHandler, + stableSoundEnabledHandler, + stableSoundVolumeHandler, + stableStylePropertyPreviewHandler, + stableStylePropertyCommitHandler, + stablePaintCommitHandler, + stablePaintPreviewHandler, + stableShadowCommitHandler, + stableNotePaintCommitHandler, + stableNotePaintPreviewHandler, + stableCounterAnimationPresetHandler, + stableCounterEnabledHandler, + stableCounterAnimationEnabledHandler, + stableCounterLayoutHandler, + stableCounterTypographyHandler, + stableCounterFillHandler, +}; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/usePanelNavigation.ts b/src/renderer/components/main/Grid/PropertiesPanel/usePanelNavigation.ts new file mode 100644 index 000000000..c126eaa43 --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/usePanelNavigation.ts @@ -0,0 +1,148 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { RefObject } from 'react'; +import { isHistoryEditorFlushLocked } from '@src/renderer/editor/runtime/historyEditorFlushLock'; +import { isHTMLElementNode } from '@utils/dom/isElementNode'; + +// --ui-duration-page와 동기 +const PAGE_EXIT_MS = 250; + +interface UsePanelNavigationOptions { + hostDocument: Document; + activeTab: string; + panelMode: string; + isPanelVisible: boolean; + selectedKeyType: string; + panelScopeKey: string; + hasPluginSettingsPanel: boolean; + pluginSettingsCancelRef: RefObject<() => void>; +} + +export const usePanelNavigation = ({ + hostDocument, + activeTab, + panelMode, + isPanelVisible, + selectedKeyType, + panelScopeKey, + hasPluginSettingsPanel, + pluginSettingsCancelRef, +}: UsePanelNavigationOptions) => { + const [activePageKey, setActivePageKey] = useState(null); + const [renderPageKey, setRenderPageKey] = useState(null); + const [pageHost, setPageHost] = useState(null); + const pageExitTimerRef = useRef(null); + + const openPage = useCallback((key: string) => { + if (pageExitTimerRef.current !== null) { + window.clearTimeout(pageExitTimerRef.current); + pageExitTimerRef.current = null; + } + setActivePageKey(key); + setRenderPageKey(key); + }, []); + + const closePage = useCallback(() => { + setActivePageKey(null); + if (pageExitTimerRef.current !== null) { + window.clearTimeout(pageExitTimerRef.current); + } + pageExitTimerRef.current = window.setTimeout(() => { + pageExitTimerRef.current = null; + setRenderPageKey(null); + }, PAGE_EXIT_MS); + }, []); + + useEffect(() => { + return () => { + if (pageExitTimerRef.current !== null) { + window.clearTimeout(pageExitTimerRef.current); + } + }; + }, []); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- 뷰 경계 변경 시 열린 서브 페이지 무효화 + closePage(); + }, [activeTab, panelMode, isPanelVisible, selectedKeyType, closePage]); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- 선택 라우트 변경 시 트리거와 페이지 수명 동기화 + closePage(); + }, [panelScopeKey, closePage]); + + useEffect(() => { + if (!activePageKey) return; + const onKey = (event: KeyboardEvent) => { + if (isHistoryEditorFlushLocked()) return; + if (event.key !== 'Escape' || event.defaultPrevented) return; + const target = event.target; + if ( + isHTMLElementNode(target) && + (target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.isContentEditable) + ) { + return; + } + if ( + document.querySelector( + '[data-dmn-modal-backdrop="true"], [data-dmn-popup-layer="true"]', + ) || + (hostDocument !== document && + hostDocument.querySelector('[data-dmn-popup-layer="true"]')) + ) { + return; + } + event.preventDefault(); + event.stopPropagation(); + closePage(); + }; + hostDocument.addEventListener('keydown', onKey, true); + return () => hostDocument.removeEventListener('keydown', onKey, true); + }, [activePageKey, closePage, hostDocument]); + + useEffect(() => { + if (!hasPluginSettingsPanel || activePageKey) return; + const onKey = (event: KeyboardEvent) => { + if (isHistoryEditorFlushLocked()) return; + if (event.key !== 'Escape' || event.defaultPrevented) return; + const target = event.target; + if ( + isHTMLElementNode(target) && + (target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.isContentEditable) + ) { + return; + } + if ( + document.querySelector( + '[data-dmn-modal-backdrop="true"], [data-dmn-popup-layer="true"]', + ) || + (hostDocument !== document && + hostDocument.querySelector('[data-dmn-popup-layer="true"]')) + ) { + return; + } + event.preventDefault(); + event.stopPropagation(); + pluginSettingsCancelRef.current(); + }; + hostDocument.addEventListener('keydown', onKey, true); + return () => hostDocument.removeEventListener('keydown', onKey, true); + }, [ + hasPluginSettingsPanel, + activePageKey, + hostDocument, + pluginSettingsCancelRef, + ]); + + return { + activePageKey, + renderPageKey, + pageHost, + setPageHost, + openPage, + closePage, + }; +}; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/usePropertiesPanelSelection.ts b/src/renderer/components/main/Grid/PropertiesPanel/usePropertiesPanelSelection.ts new file mode 100644 index 000000000..3c1b7fab0 --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/usePropertiesPanelSelection.ts @@ -0,0 +1,348 @@ +import { useSyncExternalStore } from 'react'; +import { useGraphItemStore } from '@stores/data/useGraphItemStore'; +import { useKeyStore } from '@stores/data/useKeyStore'; +import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; +import { useStatItemStore } from '@stores/data/useStatItemStore'; +import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; +import { + selectPropertyPanelPluginElements, + usePluginDisplayElementStore, +} from '@stores/plugin/usePluginDisplayElementStore'; +import { isNativeElementId } from '@src/renderer/editor/model/elementId'; +import { + composePreviewPositions, + getPreviewOverlayVersion, + subscribePreviewOverlay, +} from '@src/renderer/editor/runtime/previewOverlay'; +import type { EditorElementTypeV1 } from '@src/types/editor'; +import type { BatchGeometryTarget } from '@src/renderer/editor/runtime/elementOps'; +import type { GraphItemPosition } from '@src/types/key/graphItems'; +import type { KnobItemPosition } from '@src/types/key/knobs'; +import type { StatItemPosition } from '@src/types/key/statItems'; +import { getKeyInfoByGlobalKey } from '@utils/core/KeyMaps'; +import { slotCanonical, slotDisplayName } from '@utils/keySlot'; +import { isPluginVisibleInMode } from '@utils/layerGroupUtils'; +import { resolveResizablePluginElementSize } from '@utils/plugin/pluginElementMeasurement'; + +export const usePropertiesPanelSelection = () => { + const selectedElements = useGridSelectionStore( + (state) => state.selectedElements, + ); + const selectedKeyType = useKeyStore((state) => state.selectedKeyType); + const positions = useKeyStore((state) => state.positions); + const canonicalPositions = useKeyStore((state) => state.canonicalPositions); + const keyMappings = useKeyStore((state) => state.keyMappings); + const canonicalStatItemPositions = useStatItemStore( + (state) => state.positions, + ); + const canonicalGraphItemPositions = useGraphItemStore( + (state) => state.positions, + ); + const canonicalKnobItemPositions = useKnobItemStore( + (state) => state.positions, + ); + useSyncExternalStore( + subscribePreviewOverlay, + getPreviewOverlayVersion, + getPreviewOverlayVersion, + ); + + const statItemPositions = composePreviewPositions( + 'statPosition', + canonicalStatItemPositions, + ); + const graphItemPositions = composePreviewPositions( + 'graphPosition', + canonicalGraphItemPositions, + ); + const knobItemPositions = composePreviewPositions( + 'knobPosition', + canonicalKnobItemPositions, + ); + const pluginElements = usePluginDisplayElementStore( + selectPropertyPanelPluginElements, + ); + const pluginDefinitions = usePluginDisplayElementStore( + (state) => state.definitions, + ); + + const selectedKeyElements = selectedElements.filter( + (element) => element.type === 'key', + ); + const selectedStatElements = selectedElements.filter( + (element) => element.type === 'stat', + ); + const selectedGraphElements = selectedElements.filter( + (element) => element.type === 'graph', + ); + const selectedKnobElements = selectedElements.filter( + (element) => element.type === 'knob', + ); + const selectedKeyLikeElements = selectedElements.filter( + (element) => element.type === 'key' || element.type === 'stat', + ); + const selectedBatchStyleElements = selectedElements.filter( + (element) => + element.type === 'key' || + element.type === 'stat' || + element.type === 'graph' || + element.type === 'knob', + ); + const selectedPluginElements = selectedElements.filter( + (element) => element.type === 'plugin', + ); + + const selectedPluginElement = (() => { + if (selectedPluginElements.length !== 1) return null; + return ( + pluginElements.find( + (element) => element.fullId === selectedPluginElements[0].id, + ) ?? null + ); + })(); + + // invalid native ID 또는 모드에서 사라진 plugin이 섞이면 fail-closed + const stableBatchGeometryTargets: BatchGeometryTarget[] | null = + selectedBatchStyleElements.every( + (element) => element.id.length > 0 && isNativeElementId(element.id), + ) + ? selectedBatchStyleElements.map((element) => ({ + type: element.type as EditorElementTypeV1, + id: element.id, + })) + : null; + const stablePluginGeometryElements = (() => { + const resolved: Array<{ + fullId: string; + x: number; + y: number; + width: number; + height: number; + }> = []; + for (const selected of selectedPluginElements) { + const element = pluginElements.find( + (candidate) => candidate.fullId === selected.id, + ); + if (!element || !isPluginVisibleInMode(element, selectedKeyType)) { + return null; + } + const size = resolveResizablePluginElementSize(element); + resolved.push({ + fullId: element.fullId, + x: element.position.x, + y: element.position.y, + width: size.width, + height: size.height, + }); + } + return resolved; + })(); + const stablePluginGeometryTargets = + stablePluginGeometryElements?.map((element) => element.fullId) ?? null; + + const selectedPluginDefinition = selectedPluginElement?.definitionId + ? pluginDefinitions.get(selectedPluginElement.definitionId) ?? null + : null; + const pluginSettingsUI = selectedPluginDefinition?.settingsUI ?? 'panel'; + const hasSinglePluginSelection = + selectedPluginElements.length === 1 && !!selectedPluginElement; + const showModalHint = + hasSinglePluginSelection && pluginSettingsUI === 'modal'; + const showSettings = hasSinglePluginSelection && pluginSettingsUI !== 'modal'; + const isPluginResizable = + hasSinglePluginSelection && !!selectedPluginDefinition?.resizable; + const pluginDisplaySize = { + width: + selectedPluginElement?.measuredSize?.width ?? + selectedPluginElement?.estimatedSize?.width ?? + 200, + height: + selectedPluginElement?.measuredSize?.height ?? + selectedPluginElement?.estimatedSize?.height ?? + 150, + }; + + const singleKeyId = + selectedKeyElements.length === 1 ? selectedKeyElements[0].id : null; + const singleKeyIndex = singleKeyId + ? (positions[selectedKeyType] ?? []).findIndex( + (position) => position.id === singleKeyId, + ) + : -1; + const singleKeyPosition = + singleKeyIndex >= 0 ? positions[selectedKeyType]?.[singleKeyIndex] : null; + const singleCanonicalKeyPosition = singleKeyId + ? (canonicalPositions[selectedKeyType] ?? []).find( + (position) => position.id === singleKeyId, + ) ?? null + : null; + const singleKeySlot = + singleKeyIndex >= 0 + ? keyMappings[selectedKeyType]?.[singleKeyIndex] ?? null + : null; + const singleKeyCode = + singleKeySlot != null ? slotCanonical(singleKeySlot) : null; + const singleKeyInfo = + singleKeySlot != null && singleKeyCode + ? typeof singleKeySlot === 'string' + ? getKeyInfoByGlobalKey(singleKeySlot) + : { + browserKey: singleKeyCode, + globalKey: singleKeyCode, + displayName: slotDisplayName(singleKeySlot), + } + : null; + + const singleStatId = + selectedStatElements.length === 1 ? selectedStatElements[0].id : null; + const singleStatIndex = singleStatId + ? (statItemPositions[selectedKeyType] ?? []).findIndex( + (position) => position.id === singleStatId, + ) + : -1; + const singleStatPosition: StatItemPosition | null = + singleStatIndex >= 0 + ? statItemPositions[selectedKeyType]?.[singleStatIndex] ?? null + : null; + const singleGraphId = + selectedGraphElements.length === 1 ? selectedGraphElements[0].id : null; + const singleGraphIndex = singleGraphId + ? (graphItemPositions[selectedKeyType] ?? []).findIndex( + (position) => position.id === singleGraphId, + ) + : -1; + const singleGraphPosition: GraphItemPosition | null = + singleGraphIndex >= 0 + ? graphItemPositions[selectedKeyType]?.[singleGraphIndex] ?? null + : null; + const singleKnobId = + selectedKnobElements.length === 1 ? selectedKnobElements[0].id : null; + const singleKnobIndex = singleKnobId + ? (knobItemPositions[selectedKeyType] ?? []).findIndex( + (position) => position.id === singleKnobId, + ) + : -1; + const singleKnobPosition: KnobItemPosition | null = + singleKnobIndex >= 0 + ? knobItemPositions[selectedKeyType]?.[singleKnobIndex] ?? null + : null; + + const allLayerGroups = useLayerGroupStore((state) => state.layerGroups); + const layerGroupsForMode = allLayerGroups[selectedKeyType] || []; + const selectedGroupInfo = (() => { + if (selectedElements.length < 2) return null; + + const keyModePositions = positions[selectedKeyType] || []; + const statModePositions = statItemPositions[selectedKeyType] || []; + const graphModePositions = graphItemPositions[selectedKeyType] || []; + const knobModePositions = knobItemPositions[selectedKeyType] || []; + const modeGroupIds = new Set(layerGroupsForMode.map((group) => group.id)); + let groupId: string | undefined; + + for (const element of selectedElements) { + let currentGroupId: string | undefined; + if (element.type === 'key') { + currentGroupId = keyModePositions.find( + (position) => position.id === element.id, + )?.groupId; + } else if (element.type === 'stat') { + currentGroupId = statModePositions.find( + (position) => position.id === element.id, + )?.groupId; + } else if (element.type === 'graph') { + currentGroupId = graphModePositions.find( + (position) => position.id === element.id, + )?.groupId; + } else if (element.type === 'knob') { + currentGroupId = knobModePositions.find( + (position) => position.id === element.id, + )?.groupId; + } else if (element.type === 'plugin') { + const pluginGroupId = pluginElements.find( + (candidate) => candidate.fullId === element.id, + )?.groupId; + currentGroupId = + pluginGroupId && modeGroupIds.has(pluginGroupId) + ? pluginGroupId + : undefined; + } else { + return null; + } + + if (!currentGroupId) return null; + if (!groupId) groupId = currentGroupId; + else if (groupId !== currentGroupId) return null; + } + if (!groupId) return null; + + const totalMembers = + keyModePositions.filter((position) => position?.groupId === groupId) + .length + + statModePositions.filter((position) => position?.groupId === groupId) + .length + + graphModePositions.filter((position) => position?.groupId === groupId) + .length + + knobModePositions.filter((position) => position?.groupId === groupId) + .length + + pluginElements.filter( + (element) => + isPluginVisibleInMode(element, selectedKeyType) && + element.groupId === groupId, + ).length; + if (totalMembers < 2 || totalMembers !== selectedElements.length) { + return null; + } + + const groupDefinition = layerGroupsForMode.find( + (group) => group.id === groupId, + ); + if (!groupDefinition) return null; + return { + id: groupDefinition.id, + name: groupDefinition.name, + memberCount: totalMembers, + }; + })(); + + return { + selectedElements, + selectedKeyType, + positions, + canonicalPositions, + keyMappings, + statItemPositions, + graphItemPositions, + knobItemPositions, + pluginElements, + selectedKeyElements, + selectedStatElements, + selectedGraphElements, + selectedKnobElements, + selectedKeyLikeElements, + selectedBatchStyleElements, + selectedPluginElements, + selectedPluginElement, + stableBatchGeometryTargets, + stablePluginGeometryElements, + stablePluginGeometryTargets, + selectedPluginDefinition, + hasSinglePluginSelection, + showModalHint, + showSettings, + isPluginResizable, + pluginDisplaySize, + singleKeyIndex, + singleKeyPosition, + singleCanonicalKeyPosition, + singleKeySlot, + singleKeyCode, + singleKeyInfo, + singleStatIndex, + singleStatPosition, + singleGraphIndex, + singleGraphPosition, + singleKnobPosition, + selectedGroupInfo, + }; +}; From 271645c98f434ff198542160b3d45452cd539a90 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 06:09:58 +0900 Subject: [PATCH 002/245] =?UTF-8?q?refactor:=20=EC=8A=A4=ED=86=A0=EC=96=B4?= =?UTF-8?q?=20=EC=98=81=EC=86=8D=ED=99=94=EC=99=80=20=EC=9E=90=EC=82=B0=20?= =?UTF-8?q?=EC=B0=B8=EC=A1=B0=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/state/store.rs | 18654 +--------------- src-tauri/src/state/store/asset_references.rs | 268 + src-tauri/src/state/store/persistence.rs | 237 + src-tauri/src/state/store/tests.rs | 18103 +++++++++++++++ 4 files changed, 18630 insertions(+), 18632 deletions(-) create mode 100644 src-tauri/src/state/store/asset_references.rs create mode 100644 src-tauri/src/state/store/persistence.rs create mode 100644 src-tauri/src/state/store/tests.rs diff --git a/src-tauri/src/state/store.rs b/src-tauri/src/state/store.rs index c9e5a2486..0107d044a 100644 --- a/src-tauri/src/state/store.rs +++ b/src-tauri/src/state/store.rs @@ -4,9 +4,8 @@ use std::{ path::{Component, Path, PathBuf}, sync::{ atomic::{AtomicU64, Ordering}, - mpsc, Arc, + Arc, }, - thread::{self, JoinHandle}, time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; @@ -15,10 +14,10 @@ use crate::models::{ key_mappings_contain_multi, normalize_key_mappings, AppStoreData, CommittedEditorChange, CustomCssPatch, CustomJsPatch, EditorCommitOrigin, EditorCommitRequest, EditorCommitResult, EditorCommittedV1, EditorDocumentV1, EditorField, EditorGetResult, EditorOpResultStatusV1, - EditorOpResultV1, EditorTransactionResult, FontType, GestureCommitRequest, GestureCommitResult, - HistoryStatus, KeyCounters, KeyMappings, KeyPosition, NoteSettingsPatch, - PluginInstancesChangedPayload, PluginInstancesCommitRequest, PluginInstancesReconcileRequest, - SavedPluginInstance, SettingsDiff, SettingsPatchInput, SettingsState, EDITOR_SCHEMA_VERSION, + EditorOpResultV1, EditorTransactionResult, GestureCommitRequest, GestureCommitResult, + HistoryStatus, KeyCounters, KeyMappings, NoteSettingsPatch, PluginInstancesChangedPayload, + PluginInstancesCommitRequest, PluginInstancesReconcileRequest, SavedPluginInstance, + SettingsDiff, SettingsPatchInput, SettingsState, EDITOR_SCHEMA_VERSION, }; use anyhow::{anyhow, Context, Result}; use parking_lot::{Mutex, RwLock, RwLockWriteGuard}; @@ -46,13 +45,10 @@ use super::history::{ HISTORY_ENTRY_TOO_LARGE, HISTORY_INVALID_OPPOSITE_ENTRY, HISTORY_IN_PROGRESS, HISTORY_SCOPE_MISMATCH, HISTORY_TARGET_ALREADY_APPLIED, INVALID_HISTORY_OPERATION_ID, }; -use super::local_asset_path::{ - file_url_to_path, path_identity_key, paths_have_same_identity, FileUrlPath, -}; +use super::local_asset_path::path_identity_key; use super::migration::{ - find_legacy_store_file, is_foreign_portable_asset_reference, load_store_from_path, - migrate_key_images_to_app_data, migrate_local_fonts_to_app_data, normalize_state, - rehome_foreign_asset_references, + find_legacy_store_file, load_store_from_path, migrate_key_images_to_app_data, + migrate_local_fonts_to_app_data, normalize_state, rehome_foreign_asset_references, }; use super::plugin::{ add_plugin_group_refs, decode_plugin_instance_entries, decode_plugin_instances_lenient, @@ -64,6 +60,19 @@ use super::plugin::{ StoredPluginInstanceEntry, PLUGIN_DATA_KEY_PREFIX, }; +mod asset_references; +mod persistence; + +use asset_references::{ + collect_local_font_paths, collect_local_image_paths, collect_local_sound_paths, + warn_unresolved_asset_references, +}; +#[cfg(test)] +use asset_references::{ + collect_local_image_path_keys, collect_local_sound_path_keys, iter_all_positions, +}; +use persistence::StoreWriter; + const TRASH_RETENTION: Duration = Duration::from_secs(30 * 24 * 60 * 60); const SOUND_DELETE_BACKUP_MARKER: &str = ".delete-backup-"; const KEY_COUNTER_BASELINE_REQUIRED: &str = "KEY_COUNTER_BASELINE_REQUIRED"; @@ -267,38 +276,6 @@ pub(crate) struct AuxEditorResetTransactionOptions<'a> { pub(crate) plugin_instances_reset: PluginInstancesResetScope, } -struct PersistTicket { - revision: u64, - completion_rx: mpsc::Receiver, -} - -struct PersistCompletion { - revision: u64, - result: std::result::Result<(), String>, -} - -enum WriterMessage { - Persist { - revision: u64, - snapshot: Box, - completion_tx: mpsc::Sender, - #[cfg(test)] - force_failure: bool, - }, - Shutdown { - completion_tx: mpsc::Sender<()>, - }, -} - -struct StoreWriter { - sender: Mutex>>, - handle: Mutex>>, - #[cfg(test)] - fail_next_persist: std::sync::atomic::AtomicBool, - #[cfg(test)] - persist_count: std::sync::atomic::AtomicUsize, -} - impl AppStore { pub fn initialize(resolver: &PathResolver) -> Result { let dir = resolver @@ -3175,199 +3152,6 @@ impl Drop for AppStore { } } -impl PersistTicket { - fn wait(self) -> Result<()> { - let completion = self - .completion_rx - .recv() - .with_context(|| format!("store writer stopped before revision {}", self.revision))?; - if completion.revision != self.revision { - return Err(anyhow!( - "store writer returned revision {} for requested revision {}", - completion.revision, - self.revision - )); - } - completion.result.map_err(anyhow::Error::msg) - } -} - -impl StoreWriter { - fn start(path: PathBuf) -> Result { - let (sender, receiver) = mpsc::channel(); - let handle = thread::Builder::new() - .name("dmnote-store-writer".to_string()) - .spawn(move || run_store_writer(&path, receiver)) - .context("failed to start store writer")?; - Ok(Self { - sender: Mutex::new(Some(sender)), - handle: Mutex::new(Some(handle)), - #[cfg(test)] - fail_next_persist: std::sync::atomic::AtomicBool::new(false), - #[cfg(test)] - persist_count: std::sync::atomic::AtomicUsize::new(0), - }) - } - - fn enqueue(&self, revision: u64, snapshot: AppStoreData) -> Result { - let (completion_tx, completion_rx) = mpsc::channel(); - let guard = self.sender.lock(); - let sender = guard.as_ref().context("store writer is shut down")?; - #[cfg(test)] - let force_failure = self - .fail_next_persist - .swap(false, std::sync::atomic::Ordering::SeqCst); - sender - .send(WriterMessage::Persist { - revision, - snapshot: Box::new(snapshot), - completion_tx, - #[cfg(test)] - force_failure, - }) - .with_context(|| format!("failed to enqueue store revision {revision}"))?; - #[cfg(test)] - self.persist_count - .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - Ok(PersistTicket { - revision, - completion_rx, - }) - } - - #[cfg(test)] - fn fail_next_persist(&self) { - self.fail_next_persist - .store(true, std::sync::atomic::Ordering::SeqCst); - } - - #[cfg(test)] - fn persist_count(&self) -> usize { - self.persist_count.load(std::sync::atomic::Ordering::SeqCst) - } - - fn shutdown(&self) -> Result<()> { - let sender = self.sender.lock().take(); - let Some(sender) = sender else { - return Ok(()); - }; - - let (completion_tx, completion_rx) = mpsc::channel(); - if let Err(err) = sender.send(WriterMessage::Shutdown { completion_tx }) { - drop(sender); - let join_result = self - .handle - .lock() - .take() - .map(|handle| { - handle - .join() - .map_err(|_| anyhow!("store writer thread panicked")) - }) - .transpose(); - join_result?; - return Err(anyhow!("failed to enqueue store writer shutdown: {err}")); - } - drop(sender); - let completion_result = completion_rx - .recv() - .context("store writer stopped before shutdown flush completed"); - let join_result = self - .handle - .lock() - .take() - .map(|handle| { - handle - .join() - .map_err(|_| anyhow!("store writer thread panicked")) - }) - .transpose(); - - completion_result?; - join_result?; - Ok(()) - } -} - -fn run_store_writer(path: &Path, receiver: mpsc::Receiver) { - while let Ok(message) = receiver.recv() { - match message { - WriterMessage::Persist { - revision, - snapshot, - completion_tx, - #[cfg(test)] - force_failure, - } => { - #[cfg(test)] - let result = if force_failure { - Err("injected store writer failure".to_string()) - } else { - write_store_snapshot(path, revision, snapshot.as_ref()) - .map_err(|err| format!("{err:#}")) - }; - #[cfg(not(test))] - let result = write_store_snapshot(path, revision, snapshot.as_ref()) - .map_err(|err| format!("{err:#}")); - let _ = completion_tx.send(PersistCompletion { revision, result }); - } - WriterMessage::Shutdown { completion_tx } => { - let _ = completion_tx.send(()); - break; - } - } - } -} - -fn write_store_snapshot(path: &Path, revision: u64, state: &AppStoreData) -> Result<()> { - let json = serialize_store(state)?; - atomic_replace(path, json.as_bytes(), &format!("revision-{revision}")) -} - -fn serialize_store(state: &AppStoreData) -> Result { - use serde_json::{to_value, Map, Value}; - - let mut root = to_value(state)?; - if let Value::Object(ref mut obj) = root { - let reorder = |value: &mut Value| { - if let Value::Object(current) = value { - let desired = ["4key", "5key", "6key", "8key"]; - let mut next = Map::new(); - for key in desired.iter() { - if let Some(value) = current.get(*key) { - next.insert((*key).to_string(), value.clone()); - } - } - let mut rest: Vec<(String, Value)> = current - .iter() - .filter(|(key, _)| !desired.contains(&key.as_str())) - .map(|(key, value)| (key.clone(), value.clone())) - .collect(); - rest.sort_by(|left, right| left.0.cmp(&right.0)); - for (key, value) in rest { - next.insert(key, value); - } - *value = Value::Object(next); - } - }; - - for field in [ - "keys", - "keyPositions", - "statPositions", - "graphPositions", - "knobPositions", - "keyCounters", - ] { - if let Some(value) = obj.get_mut(field) { - reorder(value); - } - } - } - - serde_json::to_string_pretty(&root).context("failed to serialize store") -} - fn backup_store_file(path: &Path) -> Result<()> { let content = fs::read(path) .with_context(|| format!("failed to read store backup source at {}", path.display()))?; @@ -3770,244 +3554,6 @@ fn insert_gesture_mutation_ack( }); } -struct AssetReferencePaths { - keys: HashSet, - complete: bool, - unresolved_count: usize, -} - -impl AssetReferencePaths { - fn new() -> Self { - Self { - keys: HashSet::new(), - complete: true, - unresolved_count: 0, - } - } - - fn collect(&mut self, app_data_dir: &Path, value: Option<&String>) { - let Some(path) = value else { - return; - }; - match resolve_local_asset_path(app_data_dir, path) { - LocalAssetPathResolution::Path(path) => { - self.keys.insert(path_identity_key(&path)); - } - LocalAssetPathResolution::Unresolved => { - self.complete = false; - self.unresolved_count += 1; - } - LocalAssetPathResolution::Ignored => {} - } - } -} - -fn collect_plugin_managed_asset_paths( - app_data_dir: &Path, - data: &AppStoreData, - directory_name: &str, - paths: &mut AssetReferencePaths, -) { - let managed_dir = app_data_dir.join(directory_name); - for value in data.plugin_data.values() { - collect_plugin_managed_asset_value(app_data_dir, &managed_dir, value, paths); - } -} - -fn collect_plugin_managed_asset_value( - app_data_dir: &Path, - managed_dir: &Path, - value: &Value, - paths: &mut AssetReferencePaths, -) { - match value { - Value::String(raw) => { - let LocalAssetPathResolution::Path(path) = resolve_local_asset_path(app_data_dir, raw) - else { - return; - }; - if path - .parent() - .is_some_and(|parent| paths_have_same_identity(parent, managed_dir)) - { - paths.keys.insert(path_identity_key(&path)); - } - } - Value::Array(values) => { - for value in values { - collect_plugin_managed_asset_value(app_data_dir, managed_dir, value, paths); - } - } - Value::Object(values) => { - for value in values.values() { - collect_plugin_managed_asset_value(app_data_dir, managed_dir, value, paths); - } - } - Value::Null | Value::Bool(_) | Value::Number(_) => {} - } -} - -fn warn_unresolved_asset_references(app_data_dir: &Path, data: &AppStoreData) { - for (category, count) in [ - ( - "fonts", - collect_local_font_paths(app_data_dir, data).unresolved_count, - ), - ( - "images", - collect_local_image_paths(app_data_dir, data).unresolved_count, - ), - ( - "sounds", - collect_local_sound_paths(app_data_dir, data).unresolved_count, - ), - ] { - if count > 0 { - log::warn!("[Assets] {category} sweep 보류: 해석 불가 참조 {count}건"); - } - } -} - -fn collect_local_font_paths(app_data_dir: &Path, data: &AppStoreData) -> AssetReferencePaths { - let mut paths = AssetReferencePaths::new(); - - for font in data - .font_settings - .custom_fonts - .iter() - .filter(|font| font.font_type == FontType::Local) - { - paths.collect(app_data_dir, font.local_path.as_ref()); - } - - collect_plugin_managed_asset_paths(app_data_dir, data, "fonts", &mut paths); - - paths -} - -fn collect_local_image_paths(app_data_dir: &Path, data: &AppStoreData) -> AssetReferencePaths { - let mut paths = AssetReferencePaths::new(); - - for position in iter_all_positions(data) { - paths.collect(app_data_dir, position.active_image.as_ref()); - paths.collect(app_data_dir, position.inactive_image.as_ref()); - } - - collect_plugin_managed_asset_paths(app_data_dir, data, "images", &mut paths); - - paths -} - -#[cfg(test)] -fn collect_local_image_path_keys(app_data_dir: &Path, data: &AppStoreData) -> HashSet { - collect_local_image_paths(app_data_dir, data).keys -} - -fn collect_local_sound_paths(app_data_dir: &Path, data: &AppStoreData) -> AssetReferencePaths { - let mut paths = AssetReferencePaths::new(); - - for position in iter_all_positions(data) { - paths.collect(app_data_dir, position.sound_path.as_ref()); - } - - // 사운드 라이브러리에 등록된 파일도 보호 (키에 할당 안 되어도 유지) - for key in data.sound_library.keys() { - paths.collect(app_data_dir, Some(key)); - } - - paths -} - -#[cfg(test)] -fn collect_local_sound_path_keys(app_data_dir: &Path, data: &AppStoreData) -> HashSet { - collect_local_sound_paths(app_data_dir, data).keys -} - -fn iter_all_positions(data: &AppStoreData) -> impl Iterator { - data.key_positions - .values() - .flatten() - .chain( - data.stat_positions - .values() - .flatten() - .map(|position| &position.position), - ) - .chain( - data.graph_positions - .values() - .flatten() - .map(|position| &position.position), - ) - .chain( - data.knob_positions - .values() - .flatten() - .map(|position| &position.position), - ) -} - -enum LocalAssetPathResolution { - Path(PathBuf), - Unresolved, - Ignored, -} - -fn resolve_local_asset_path(app_data_dir: &Path, path: &str) -> LocalAssetPathResolution { - let trimmed = path.trim(); - if trimmed.is_empty() { - return LocalAssetPathResolution::Ignored; - } - - let lower = trimmed.to_ascii_lowercase(); - if lower.starts_with("http://") - || lower.starts_with("https://") - || lower.starts_with("data:") - || lower.starts_with("blob:") - || lower.starts_with("asset:") - || lower.starts_with("tauri:") - { - return LocalAssetPathResolution::Ignored; - } - - match file_url_to_path(trimmed) { - FileUrlPath::Path(path) => classify_absolute_asset_path(app_data_dir, trimmed, path), - FileUrlPath::Invalid => LocalAssetPathResolution::Unresolved, - FileUrlPath::NotFileUrl => { - let path = PathBuf::from(trimmed); - if path.is_absolute() { - classify_absolute_asset_path(app_data_dir, trimmed, path) - } else if looks_like_unresolved_local_path(trimmed) { - LocalAssetPathResolution::Unresolved - } else { - LocalAssetPathResolution::Ignored - } - } - } -} - -// 실존하지 않는 외래 참조는 해석 실패로 취급, sweep 보류 fail-safe 유도 -fn classify_absolute_asset_path( - app_data_dir: &Path, - raw: &str, - path: PathBuf, -) -> LocalAssetPathResolution { - if !path.exists() && is_foreign_portable_asset_reference(app_data_dir, raw) { - return LocalAssetPathResolution::Unresolved; - } - LocalAssetPathResolution::Path(path) -} - -fn looks_like_unresolved_local_path(value: &str) -> bool { - let bytes = value.as_bytes(); - let has_windows_drive = bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'; - has_windows_drive - || value.starts_with('\\') - || value.contains('/') - || value.contains('\\') - || Path::new(value).extension().is_some() -} - fn validate_pending_processed_wav_target(sounds_dir: &Path, value: &str) -> Result { let target = PathBuf::from(value); if !target.is_absolute() @@ -4819,18160 +4365,4 @@ fn sweep_unreferenced_asset_files( mod gradient_real_data_simulation; #[cfg(test)] -mod tests { - use super::{ - collect_local_font_paths, collect_local_image_path_keys, collect_local_image_paths, - collect_local_sound_path_keys, collect_local_sound_paths, initialize_default_state, - plugin_instances_storage_key, project_history_key_counters, - purge_expired_trash_sessions_at, recover_interrupted_processed_wav_replacements_with, - recover_interrupted_sound_deletions_with, stage_sound_files_for_deletion, - sweep_unreferenced_asset_files, system_time_millis, AppStore, - AuxEditorResetTransactionOptions, AuxEditorTransactionOptions, HistoryAuxChange, - PluginInstancesResetScope, TrashSession, PROCESSED_WAV_TRANSACTION_LOCK, TRASH_RETENTION, - }; - use crate::{ - commands::keys::keys::reset_mode_data_for_test, - custom_css::ValidatedCssFile, - defaults::{default_keys, default_positions}, - errors::{EditorCommitError, EditorCommitErrorCode}, - keyboard::KeyboardManager, - models::{ - AppStoreData, CommittedEditorChange, CounterAnimationPreset, CounterAnimationSource, - CustomCss, CustomFont, CustomTab, EditorBoundsV1, EditorCommitOrigin, - EditorCommitRequest, EditorDocumentV1, EditorElementGroupTargetV1, - EditorElementPropertyPatchV1, EditorElementTypeV1, EditorField, EditorFrozenElementV1, - EditorFrozenKeySlotV1, EditorOpResultStatusV1, EditorOpResultV1, EditorOpV1, - EditorPatchV1, EditorTargetGroupV1, EditorZUpdateV1, FontSettings, FontType, - GestureCommitRequest, GesturePluginInstancesChange, GraphPosition, GraphStatType, - GraphType, JsPlugin, KeyCounters, KeyPosition, KeySlot, KnobPosition, LayerGroupDef, - NoteColor, OverlayBounds, PanelBounds, PendingProcessedWavReplacement, - PluginInstancesCommitRequest, PluginInstancesReconcileRequest, PluginPoint, - SavedPluginInstance, SettingsPatchInput, SlotMatch, SoundLibraryEntry, SoundSource, - StatPosition, StatType, TabCss, TabNoteSettings, EDITOR_COMMIT_SCHEMA_VERSION_V2, - EDITOR_OPS_VERSION, EDITOR_SCHEMA_VERSION, - }, - services::{css_watcher::commit_css_reload, settings::apply_patch_to_store}, - state::{ - app_state::KeyCounterEventEmitter, - history::{ - HistoryDirection, HistoryScope, PresetFullHistorySnapshot, HISTORY_ENTRY_TOO_LARGE, - HISTORY_IN_PROGRESS, - }, - local_asset_path::path_identity_key, - AppState, - }, - }; - use serde_json::{json, Value}; - use std::{ - collections::{HashMap, HashSet}, - path::Path, - sync::{ - atomic::{AtomicBool, Ordering}, - mpsc, Arc, Barrier, Mutex, - }, - thread, - time::{Duration, Instant, SystemTime, UNIX_EPOCH}, - }; - - fn test_directory(label: &str) -> std::path::PathBuf { - std::env::temp_dir().join(format!("dmnote-{label}-{}", uuid::Uuid::new_v4())) - } - - fn regular_file_count(directory: &Path) -> usize { - std::fs::read_dir(directory) - .unwrap() - .filter_map(Result::ok) - .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_file())) - .count() - } - - fn initialize_neutral_editor_store(dir: &Path) -> AppStore { - std::fs::create_dir_all(dir).unwrap(); - let keys = default_keys() - .iter() - .map(|(mode, slots)| { - ( - mode.clone(), - slots - .iter() - .map(|slot| KeySlot::from(slot.canonical())) - .collect(), - ) - }) - .collect::(); - let key_positions = keys - .iter() - .map(|(mode, slots)| (mode.clone(), vec![KeyPosition::default(); slots.len()])) - .collect(); - let mut data = crate::state::migration::normalize_state(AppStoreData { - keys, - key_positions, - ..AppStoreData::default() - }); - crate::state::native_element_id::backfill_store_element_ids(&mut data); - let store = AppStore::new(dir.join("store.json"), data, false).unwrap(); - store.persist_current().unwrap(); - store - } - - #[test] - fn first_initialize_migrates_default_images_and_reopen_is_idempotent() { - let dir = test_directory("default-image-initialize"); - let images_dir = dir.join("images"); - - let first = AppStore::initialize_in_dir(&dir).unwrap(); - let first_snapshot = first.snapshot(); - let image_references = super::iter_all_positions(&first_snapshot) - .flat_map(|position| [&position.active_image, &position.inactive_image]) - .flatten() - .filter(|image| !image.is_empty()) - .collect::>(); - - assert!(!image_references.is_empty()); - assert!(image_references - .iter() - .all(|image| !image.starts_with("data:"))); - assert!(image_references - .iter() - .all(|image| Path::new(image.as_str()).is_file())); - for mode in first_snapshot.keys.keys() { - let stats = first_snapshot - .stat_positions - .get(mode) - .unwrap_or_else(|| panic!("missing initialized stats for {mode}")); - assert!(!stats.is_empty()); - assert!(stats.iter().all(|stat| { - crate::state::native_element_id::is_valid_element_id(&stat.position.id) - })); - } - let first_image_count = regular_file_count(&images_dir); - assert!(first_image_count > 0); - first.flush_and_shutdown().unwrap(); - drop(first); - - let second = AppStore::initialize_in_dir(&dir).unwrap(); - assert_eq!(regular_file_count(&images_dir), first_image_count); - assert!(super::iter_all_positions(&second.snapshot()) - .flat_map(|position| [&position.active_image, &position.inactive_image]) - .flatten() - .all(|image| !image.starts_with("data:"))); - second.flush_and_shutdown().unwrap(); - drop(second); - - std::fs::remove_dir_all(dir).unwrap(); - } - - #[test] - fn runtime_publication_generation_mirror_tracks_committed_revision() { - let directory = test_directory("runtime-publication-generation-mirror"); - std::fs::create_dir_all(&directory).unwrap(); - let store = AppStore::initialize_for_test(&directory).unwrap(); - - assert_eq!( - store.runtime_publication_generation(), - store.state.read().revision - ); - let previous_revision = store.runtime_publication_generation(); - store - .update(|data| data.language = "generation-mirror-test".to_string()) - .unwrap(); - let committed_revision = store.state.read().revision; - assert!(committed_revision > previous_revision); - assert_eq!(store.runtime_publication_generation(), committed_revision); - - store.flush_and_shutdown().unwrap(); - drop(store); - std::fs::remove_dir_all(directory).unwrap(); - } - - struct TestCounterEmitter { - events: Arc>>, - mode: String, - key: String, - snapshot_emitted: Option>, - release_snapshot: Option>>, - } - - impl TestCounterEmitter { - fn new(events: Arc>>, mode: String, key: String) -> Self { - Self { - events, - mode, - key, - snapshot_emitted: None, - release_snapshot: None, - } - } - - fn blocking_snapshot( - events: Arc>>, - mode: String, - key: String, - snapshot_emitted: mpsc::Sender<()>, - release_snapshot: mpsc::Receiver<()>, - ) -> Self { - Self { - events, - mode, - key, - snapshot_emitted: Some(snapshot_emitted), - release_snapshot: Some(Mutex::new(release_snapshot)), - } - } - } - - impl KeyCounterEventEmitter for TestCounterEmitter { - fn emit_key_counters( - &self, - counters: &KeyCounters, - _session_id: &str, - _revision: u64, - ) -> anyhow::Result<()> { - let count = counters[&self.mode][&self.key]; - self.events - .lock() - .unwrap() - .push(format!("snapshot:{count}")); - if let Some(snapshot_emitted) = &self.snapshot_emitted { - snapshot_emitted.send(()).unwrap(); - } - if let Some(release_snapshot) = &self.release_snapshot { - release_snapshot.lock().unwrap().recv().unwrap(); - } - Ok(()) - } - - fn emit_key_counter( - &self, - _mode: &str, - _key: &str, - count: u32, - _session_id: &str, - _revision: u64, - ) -> anyhow::Result<()> { - self.events.lock().unwrap().push(format!("counter:{count}")); - Ok(()) - } - } - - fn editor_request( - base_revision: u64, - mutation_id: impl Into, - changes: EditorPatchV1, - ) -> EditorCommitRequest { - EditorCommitRequest { - base_revision, - mutation_id: mutation_id.into(), - multi_key: false, - gesture_id: None, - gesture_ids: Vec::new(), - changes: Some(changes), - ops_version: None, - ops: None, - } - } - - fn editor_ops_request( - base_revision: u64, - mutation_id: impl Into, - ops: Vec, - ) -> EditorCommitRequest { - EditorCommitRequest { - base_revision, - mutation_id: mutation_id.into(), - multi_key: false, - gesture_id: None, - gesture_ids: Vec::new(), - changes: None, - ops_version: Some(EDITOR_OPS_VERSION), - ops: Some(ops), - } - } - - fn bounds(position: &KeyPosition) -> EditorBoundsV1 { - EditorBoundsV1 { - dx: position.dx, - dy: position.dy, - width: position.width, - height: position.height, - } - } - - fn set_bounds_op( - element_type: EditorElementTypeV1, - id: impl Into, - bounds: EditorBoundsV1, - ) -> EditorOpV1 { - EditorOpV1::SetBounds { - element_type, - id: id.into(), - bounds, - } - } - - fn delete_element_op(element_type: EditorElementTypeV1, id: impl Into) -> EditorOpV1 { - EditorOpV1::DeleteElement { - element_type, - id: id.into(), - } - } - - fn patch_hidden_op( - element_type: EditorElementTypeV1, - id: impl Into, - hidden: bool, - ) -> EditorOpV1 { - EditorOpV1::PatchElement { - element_type, - id: id.into(), - patch: EditorElementPropertyPatchV1::Hidden(hidden), - } - } - - fn patch_layer_name_op( - element_type: EditorElementTypeV1, - id: impl Into, - layer_name: Option<&str>, - ) -> EditorOpV1 { - EditorOpV1::PatchElement { - element_type, - id: id.into(), - patch: EditorElementPropertyPatchV1::LayerName(layer_name.map(str::to_string)), - } - } - - fn patch_graph_type_op(id: impl Into, graph_type: GraphType) -> EditorOpV1 { - EditorOpV1::PatchElement { - element_type: EditorElementTypeV1::Graph, - id: id.into(), - patch: EditorElementPropertyPatchV1::GraphType(graph_type), - } - } - - fn patch_graph_color_op(id: impl Into, graph_color: impl Into) -> EditorOpV1 { - EditorOpV1::PatchElement { - element_type: EditorElementTypeV1::Graph, - id: id.into(), - patch: EditorElementPropertyPatchV1::GraphColor(graph_color.into()), - } - } - - fn patch_property_op( - element_type: EditorElementTypeV1, - id: impl Into, - patch: EditorElementPropertyPatchV1, - ) -> EditorOpV1 { - EditorOpV1::PatchElement { - element_type, - id: id.into(), - patch, - } - } - - fn paint_descriptor( - color: &str, - gradient: Option<(f64, &[(&str, f64)])>, - ) -> crate::models::EditorPaintDescriptorV1 { - crate::models::EditorPaintDescriptorV1 { - color: color.to_string(), - gradient: gradient.map(|(angle, stops)| crate::models::EditorPaintGradientV1 { - angle, - stops: stops - .iter() - .map(|(color, pos)| crate::models::EditorPaintGradientStopV1 { - color: (*color).to_string(), - pos: *pos, - }) - .collect(), - }), - } - } - - fn counter_fill_solid(color: &str) -> crate::models::EditorCounterFillIntentV1 { - crate::models::EditorCounterFillIntentV1::Solid( - crate::models::EditorCounterFillSolidIntentV1 { - color: color.to_string(), - }, - ) - } - - fn counter_fill_gradient( - color: &str, - angle: f64, - stops: &[(&str, f64)], - ) -> crate::models::EditorCounterFillIntentV1 { - crate::models::EditorCounterFillIntentV1::Gradient( - crate::models::EditorCounterFillGradientIntentV1 { - color: color.to_string(), - gradient: crate::models::EditorPaintGradientV1 { - angle, - stops: stops - .iter() - .map(|(color, pos)| crate::models::EditorPaintGradientStopV1 { - color: (*color).to_string(), - pos: *pos, - }) - .collect(), - }, - }, - ) - } - - fn shadow_leaf_blur(blur: f64) -> crate::models::EditorShadowLeafPatchV1 { - crate::models::EditorShadowLeafPatchV1::Blur(blur) - } - - fn frozen_key_insert_op(id: impl Into) -> EditorOpV1 { - EditorOpV1::InsertFrozenElements { - mode: "4key".to_string(), - elements: vec![EditorFrozenElementV1::Key { - slot: EditorFrozenKeySlotV1::Single("FROZEN".to_string()), - position: KeyPosition { - id: id.into(), - dx: 111.0, - dy: 222.0, - z_index: Some(100), - ..KeyPosition::default() - }, - }], - groups: Vec::new(), - z_updates: Vec::new(), - } - } - - fn complete_key_reorder_op(document: &EditorDocumentV1) -> EditorOpV1 { - EditorOpV1::ReorderElements { - mode: "4key".to_string(), - complete_mode_order: true, - z_updates: document.key_positions["4key"] - .iter() - .enumerate() - .map(|(index, position)| EditorZUpdateV1 { - element_type: EditorElementTypeV1::Key, - id: position.id.clone(), - z_index: 100 - index as i32, - }) - .collect(), - group_updates: Vec::new(), - } - } - - fn position_patch(store: &AppStore, dx: f64) -> EditorPatchV1 { - let mut positions = store.editor_get().document.key_positions; - positions.get_mut("4key").unwrap()[0].dx = dx; - EditorPatchV1 { - key_positions: Some(positions), - ..EditorPatchV1::default() - } - } - - fn saved_plugin_instance(x: f64) -> SavedPluginInstance { - SavedPluginInstance { - // x 파생 결정적 ID - 같은 x는 같은 인스턴스라는 동등성 단언 유지 - instance_id: Some(format!( - "00000000-0000-4000-8000-{:012x}", - (x * 1000.0) as u64 - )), - position: PluginPoint { x, y: 20.0 }, - settings: None, - measured_size: None, - tab_id: Some("4key".to_string()), - hidden: false, - z_index: None, - group_id: None, - } - } - - fn plugin_instances_request( - plugin_id: &str, - instances: Vec, - mutation_id: String, - gesture_id: Option, - expected_model_revision: Option, - ) -> PluginInstancesCommitRequest { - PluginInstancesCommitRequest { - plugin_id: plugin_id.to_string(), - instances, - mutation_id, - gesture_id, - observed_history_epoch: None, - expected_model_revision, - authority_generation: 1, - } - } - - fn plugin_instances_reconcile_request( - plugin_id: &str, - valid_tab_ids: &[&str], - mutation_id: String, - ) -> PluginInstancesReconcileRequest { - PluginInstancesReconcileRequest { - plugin_id: plugin_id.to_string(), - valid_tab_ids: valid_tab_ids - .iter() - .map(|tab_id| (*tab_id).to_string()) - .collect(), - mutation_id, - observed_history_epoch: None, - authority_generation: 1, - } - } - - #[test] - fn editor_reset_all_atomically_clears_only_plugin_instances() { - let dir = test_directory("editor-reset-all-plugin-instances-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - store - .commit_plugin_instances(plugin_instances_request( - "plugin-a", - vec![saved_plugin_instance(1.0)], - uuid::Uuid::new_v4().to_string(), - None, - Some(store.plugin_model_revision()), - )) - .unwrap(); - store - .set_plugin_data("plugin_data_plugin-a/settings", json!({ "theme": "dark" })) - .unwrap(); - store - .set_plugin_data("plugin_data_plugin-a/total", json!(37)) - .unwrap(); - let before_revision = store.plugin_model_revision(); - let before_epoch = store.history_status().history_epoch; - - let admission = store.admit_editor_mutation().unwrap(); - let transaction = store - .commit_legacy_editor_reset_transaction_with_admission( - EditorCommitOrigin::LegacyAdapter("reset-all-test".to_string()), - &[], - PluginInstancesResetScope::All, - admission, - |_| Ok(()), - ) - .unwrap(); - - assert!(store.plugin_instances_get("plugin-a").unwrap().0.is_empty()); - assert_eq!( - store - .get_plugin_data("plugin_data_plugin-a/settings") - .unwrap(), - Some(json!({ "theme": "dark" })) - ); - assert_eq!(store.plugin_model_revision(), before_revision + 1); - assert_eq!(store.history_status().history_epoch, before_epoch + 1); - assert_eq!( - transaction.change.plugin_instances_changes, - vec![crate::models::PluginInstancesChangedPayload { - plugin_id: "plugin-a".to_string(), - revision: before_revision + 1, - origin_mutation_id: None, - }] - ); - assert!(!transaction.change.history_status.as_ref().unwrap().can_undo); - assert_eq!( - store.get_plugin_data("plugin_data_plugin-a/total").unwrap(), - Some(json!(37)) - ); - assert_eq!( - store - .get_plugin_data("plugin_data_plugin-a/instances") - .unwrap(), - None - ); - - drop(transaction); - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn editor_reset_mode_filters_loaded_and_unloaded_plugin_instances_once() { - let dir = test_directory("editor-reset-mode-plugin-instances-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let four_key = saved_plugin_instance(1.0); - let mut five_key = saved_plugin_instance(2.0); - five_key.tab_id = Some("5key".to_string()); - for (plugin_id, instances) in [ - ("plugin-a", vec![four_key, five_key.clone()]), - ("plugin-b", vec![saved_plugin_instance(3.0)]), - ] { - store - .commit_plugin_instances(plugin_instances_request( - plugin_id, - instances, - uuid::Uuid::new_v4().to_string(), - None, - Some(store.plugin_model_revision()), - )) - .unwrap(); - } - let before_revision = store.plugin_model_revision(); - let before_epoch = store.history_status().history_epoch; - - let admission = store.admit_editor_mutation().unwrap(); - let transaction = store - .commit_legacy_editor_reset_transaction_with_admission( - EditorCommitOrigin::LegacyAdapter("reset-mode-test".to_string()), - &[ - EditorField::Keys, - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - EditorField::LayerGroups, - ], - PluginInstancesResetScope::Mode("4key".to_string()), - admission, - |data| { - reset_mode_data_for_test(data, "4key"); - Ok(()) - }, - ) - .unwrap(); - - assert_eq!( - store.plugin_instances_get("plugin-a").unwrap().0, - vec![five_key] - ); - assert!(store.plugin_instances_get("plugin-b").unwrap().0.is_empty()); - assert_eq!(store.plugin_model_revision(), before_revision + 1); - assert_eq!(store.history_status().history_epoch, before_epoch + 1); - assert!(!store.history_status().can_undo); - assert_eq!(transaction.change.plugin_instances_changes.len(), 2); - assert!(transaction - .change - .plugin_instances_changes - .iter() - .all(|change| change.revision == before_revision + 1)); - - drop(transaction); - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn editor_reset_mode_preserves_malformed_entries_and_skips_non_array_buckets() { - let dir = test_directory("editor-reset-mode-mixed-plugin-instances-test"); - std::fs::create_dir_all(&dir).unwrap(); - let mut data = initialize_default_state(); - let target_with_id = saved_plugin_instance(1.0); - let mut target_without_id = saved_plugin_instance(2.0); - target_without_id.instance_id = None; - let mut retained = saved_plugin_instance(3.0); - retained.tab_id = Some("5key".to_string()); - let retained_value = serde_json::to_value(&retained).unwrap(); - let malformed = json!({ "position": "broken", "keep": true }); - let non_array = json!({ "keep": "unchanged" }); - let key = super::plugin_instances_storage_key("plugin-a"); - let non_array_key = super::plugin_instances_storage_key("broken"); - data.plugin_data.insert( - key.clone(), - json!([ - serde_json::to_value(target_with_id).unwrap(), - serde_json::to_value(target_without_id).unwrap(), - retained_value.clone(), - malformed.clone() - ]), - ); - data.plugin_data - .insert(non_array_key.clone(), non_array.clone()); - let store = AppStore::new(dir.join("store.json"), data, false).unwrap(); - - let admission = store.admit_editor_mutation().unwrap(); - let transaction = store - .commit_legacy_editor_reset_transaction_with_admission( - EditorCommitOrigin::LegacyAdapter("reset-mode-mixed-test".to_string()), - &[], - PluginInstancesResetScope::Mode("4key".to_string()), - admission, - |_| Ok(()), - ) - .unwrap(); - - assert_eq!( - store.get_plugin_data(&key).unwrap(), - Some(json!([retained_value.clone(), malformed.clone()])) - ); - assert_eq!( - store.get_plugin_data(&non_array_key).unwrap(), - Some(non_array.clone()) - ); - assert_eq!( - store.plugin_instances_get("plugin-a").unwrap().0, - vec![retained] - ); - assert_eq!( - transaction - .change - .plugin_instances_changes - .iter() - .map(|change| change.plugin_id.as_str()) - .collect::>(), - vec!["plugin-a"] - ); - - drop(transaction); - store.flush_and_shutdown().unwrap(); - let reloaded = AppStore::initialize_in_dir(&dir).unwrap(); - assert_eq!( - reloaded.get_plugin_data(&key).unwrap(), - Some(json!([retained_value, malformed])) - ); - assert_eq!( - reloaded.get_plugin_data(&non_array_key).unwrap(), - Some(non_array) - ); - reloaded.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn editor_reset_missing_mode_does_not_touch_plugin_instances() { - let dir = test_directory("editor-reset-missing-mode-plugin-instances-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let mut ghost = saved_plugin_instance(1.0); - ghost.tab_id = Some("ghost".to_string()); - store - .commit_plugin_instances(plugin_instances_request( - "plugin-a", - vec![ghost.clone()], - uuid::Uuid::new_v4().to_string(), - None, - Some(store.plugin_model_revision()), - )) - .unwrap(); - let before_revision = store.plugin_model_revision(); - let before_epoch = store.history_status().history_epoch; - - let admission = store.admit_editor_mutation().unwrap(); - let transaction = store - .commit_legacy_editor_reset_transaction_with_admission( - EditorCommitOrigin::LegacyAdapter("reset-missing-mode-test".to_string()), - &[], - PluginInstancesResetScope::Mode("ghost".to_string()), - admission, - |_| Ok(None::<()>), - ) - .unwrap(); - - assert_eq!(transaction.value, None); - assert_eq!( - store.plugin_instances_get("plugin-a").unwrap().0, - vec![ghost] - ); - assert_eq!(store.plugin_model_revision(), before_revision); - assert_eq!(store.history_status().history_epoch, before_epoch); - assert!(transaction.change.plugin_instances_changes.is_empty()); - - drop(transaction); - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn editor_reset_noop_advances_epoch_without_erasing_unrelated_history() { - let dir = test_directory("editor-reset-noop-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let mut five_key = saved_plugin_instance(1.0); - five_key.tab_id = Some("5key".to_string()); - store - .commit_plugin_instances(plugin_instances_request( - "plugin-a", - vec![five_key.clone()], - uuid::Uuid::new_v4().to_string(), - None, - Some(store.plugin_model_revision()), - )) - .unwrap(); - let before = store.history_status(); - assert!(before.can_undo); - - let admission = store.admit_editor_mutation().unwrap(); - let transaction = store - .commit_legacy_editor_reset_transaction_with_admission( - EditorCommitOrigin::LegacyAdapter("reset-noop-test".to_string()), - &[ - EditorField::Keys, - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - EditorField::LayerGroups, - ], - PluginInstancesResetScope::Mode("4key".to_string()), - admission, - |data| { - assert!(!reset_mode_data_for_test(data, "4key")); - Ok(()) - }, - ) - .unwrap(); - - let after = transaction.change.history_status.unwrap(); - assert!(after.can_undo); - assert_eq!(after.history_epoch, before.history_epoch + 1); - assert_eq!( - store.plugin_instances_get("plugin-a").unwrap().0, - vec![five_key] - ); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn editor_reset_rejects_an_instance_save_captured_before_the_reset() { - let dir = test_directory("editor-reset-stale-plugin-save-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let instance = saved_plugin_instance(1.0); - store - .commit_plugin_instances(plugin_instances_request( - "plugin-a", - vec![instance.clone()], - uuid::Uuid::new_v4().to_string(), - None, - Some(store.plugin_model_revision()), - )) - .unwrap(); - let stale_epoch = store.history_status().history_epoch; - let mut stale_request = plugin_instances_request( - "plugin-a", - vec![instance], - uuid::Uuid::new_v4().to_string(), - None, - None, - ); - stale_request.observed_history_epoch = Some(stale_epoch); - - let admission = store.admit_editor_mutation().unwrap(); - store - .commit_legacy_editor_reset_transaction_with_admission( - EditorCommitOrigin::LegacyAdapter("reset-race-test".to_string()), - &[], - PluginInstancesResetScope::All, - admission, - |_| Ok(()), - ) - .unwrap(); - - assert_eq!( - store.commit_plugin_instances(stale_request).unwrap_err(), - "HISTORY_EPOCH_CONFLICT" - ); - assert!(store.plugin_instances_get("plugin-a").unwrap().0.is_empty()); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn canonical_plugin_bucket_preserves_legacy_outlier_while_editing_sibling() { - let dir = test_directory("legacy-plugin-outlier-canonical-test"); - std::fs::create_dir_all(&dir).unwrap(); - let mut data = initialize_default_state(); - let mut legacy = saved_plugin_instance(40_000.0); - let sibling = saved_plugin_instance(10.0); - data.plugin_data.insert( - super::plugin_instances_storage_key("plugin-a"), - serde_json::to_value(vec![legacy.clone(), sibling.clone()]).unwrap(), - ); - let store = AppStore::new(dir.join("store.json"), data, false).unwrap(); - - assert_eq!( - store.plugin_instances_get("plugin-a").unwrap().0, - vec![legacy.clone(), sibling.clone()] - ); - let mut edited_sibling = sibling; - edited_sibling.hidden = true; - store - .commit_plugin_instances(plugin_instances_request( - "plugin-a", - vec![legacy.clone(), edited_sibling.clone()], - uuid::Uuid::new_v4().to_string(), - None, - Some(store.plugin_model_revision()), - )) - .unwrap(); - assert_eq!( - store.plugin_instances_get("plugin-a").unwrap().0, - vec![legacy.clone(), edited_sibling] - ); - - legacy.position.x = 41_000.0; - assert_eq!( - store - .commit_plugin_instances(plugin_instances_request( - "plugin-a", - vec![legacy], - uuid::Uuid::new_v4().to_string(), - None, - Some(store.plugin_model_revision()), - )) - .unwrap_err(), - "INVALID_PLUGIN_INSTANCE_POSITION:0" - ); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn canonical_get_keeps_valid_instances_next_to_malformed_storage_entries() { - let dir = test_directory("lenient-canonical-plugin-read-test"); - std::fs::create_dir_all(&dir).unwrap(); - let mut data = initialize_default_state(); - let valid = saved_plugin_instance(10.0); - data.plugin_data.insert( - super::plugin_instances_storage_key("plugin-a"), - json!([ - serde_json::to_value(&valid).unwrap(), - { "position": "broken" } - ]), - ); - let store = AppStore::new(dir.join("store.json"), data, false).unwrap(); - - assert_eq!( - store.plugin_instances_get("plugin-a").unwrap().0, - vec![valid] - ); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - fn gesture_request( - store: &AppStore, - gesture_id: String, - editor_changes: EditorPatchV1, - plugin_id: &str, - instances: Vec, - ) -> GestureCommitRequest { - GestureCommitRequest { - gesture_id, - mutation_id: uuid::Uuid::new_v4().to_string(), - editor_base_revision: store.editor_get().revision, - plugin_base_revision: store.plugin_model_revision(), - observed_history_epoch: Some(store.state.read().history.history_epoch()), - authority_generation: 1, - editor_changes: Some(editor_changes), - editor_ops_version: None, - editor_ops: None, - plugin_changes: vec![GesturePluginInstancesChange { - plugin_id: plugin_id.to_string(), - instances, - }], - } - } - - fn gesture_ops_request( - store: &AppStore, - gesture_id: String, - mutation_id: String, - ops: Vec, - plugin_id: &str, - instances: Vec, - ) -> GestureCommitRequest { - GestureCommitRequest { - gesture_id, - mutation_id, - editor_base_revision: store.editor_get().revision, - plugin_base_revision: store.plugin_model_revision(), - observed_history_epoch: Some(store.state.read().history.history_epoch()), - authority_generation: 1, - editor_changes: None, - editor_ops_version: Some(EDITOR_OPS_VERSION), - editor_ops: Some(ops), - plugin_changes: vec![GesturePluginInstancesChange { - plugin_id: plugin_id.to_string(), - instances, - }], - } - } - - fn legacy_editor_commit( - store: &AppStore, - fields: &[EditorField], - updater: impl FnOnce(&mut AppStoreData), - ) -> std::result::Result { - store - .commit_legacy_editor_transaction( - EditorCommitOrigin::LegacyAdapter("test_adapter".to_string()), - fields, - |data| { - updater(data); - Ok(()) - }, - ) - .map(|transaction| transaction.change) - } - - fn assert_counter_baseline_required(error: &EditorCommitError) { - assert_eq!(error.error_code, EditorCommitErrorCode::ValidationFailed); - assert_eq!( - error - .details - .as_ref() - .and_then(|details| details.validation_code.as_deref()), - Some("KEY_COUNTER_BASELINE_REQUIRED") - ); - } - - #[test] - fn editor_admission_without_runtime_counters_rejects_key_changes() { - let dir = test_directory("editor-counter-baseline-required-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let mut keys = store.snapshot().keys; - keys.get_mut("4key").unwrap()[0] = KeySlot::from("CounterBaselineEditorKey"); - let mut request = editor_request( - store.editor_get().revision, - uuid::Uuid::new_v4().to_string(), - EditorPatchV1 { - keys: Some(keys), - ..EditorPatchV1::default() - }, - ); - request.multi_key = true; - let admission = store.admit_editor_mutation().unwrap(); - let error = store - .commit_editor_document_admitted(request, &admission) - .expect_err("key-changing editor commit must require live counters"); - assert_counter_baseline_required(&error); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn gesture_admission_without_runtime_counters_rejects_key_changes() { - let dir = test_directory("gesture-counter-baseline-required-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let mut keys = store.snapshot().keys; - keys.get_mut("4key").unwrap()[0] = KeySlot::from("CounterBaselineGestureKey"); - let request = gesture_request( - &store, - uuid::Uuid::new_v4().to_string(), - EditorPatchV1 { - keys: Some(keys), - ..EditorPatchV1::default() - }, - "demo-plugin", - Vec::new(), - ); - let admission = store.admit_editor_mutation().unwrap(); - let error = store - .commit_gesture_with_admission(request, admission) - .expect_err("key-changing gesture must require live counters"); - assert_counter_baseline_required(&error); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn aux_admission_without_runtime_counters_rejects_declared_keys() { - let dir = test_directory("aux-counter-baseline-required-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let admission = store.admit_editor_mutation().unwrap(); - let error = store - .commit_aux_editor_transaction_with_admission( - AuxEditorTransactionOptions { - scope: HistoryScope::CustomTabs, - observed_history_epoch: None, - origin: EditorCommitOrigin::LegacyAdapter( - "counter_baseline_required_test".to_string(), - ), - touched_fields: &[EditorField::Keys], - }, - admission, - |data| { - data.keys.get_mut("4key").unwrap()[0] = KeySlot::from("CounterBaselineAuxKey"); - Ok(()) - }, - ) - .expect_err("key-changing aux transaction must require live counters"); - assert_counter_baseline_required(&error); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn plugin_instances_commit_records_merges_and_skips_noop() { - let dir = test_directory("plugin-instances-commit-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial_revision = store.plugin_model_revision(); - let gesture_id = uuid::Uuid::new_v4().to_string(); - - let first_request = plugin_instances_request( - "demo-plugin", - vec![saved_plugin_instance(10.0)], - uuid::Uuid::new_v4().to_string(), - Some(gesture_id.clone()), - Some(initial_revision), - ); - let first = store.commit_plugin_instances(first_request).unwrap(); - let first_revision = first.outcome.model_revision; - assert!(first.outcome.changed); - assert_eq!( - first - .outcome - .history_status - .as_ref() - .unwrap() - .history_revision, - 1 - ); - drop(first); - - let second_request = plugin_instances_request( - "demo-plugin", - vec![saved_plugin_instance(30.0)], - uuid::Uuid::new_v4().to_string(), - Some(gesture_id), - Some(first_revision), - ); - let second = store.commit_plugin_instances(second_request).unwrap(); - let second_revision = second.outcome.model_revision; - assert_eq!( - second - .outcome - .history_status - .as_ref() - .unwrap() - .history_revision, - 1 - ); - drop(second); - - let noop = store - .commit_plugin_instances(plugin_instances_request( - "demo-plugin", - vec![saved_plugin_instance(30.0)], - uuid::Uuid::new_v4().to_string(), - None, - Some(second_revision), - )) - .unwrap(); - assert!(!noop.outcome.changed); - assert!(noop.outcome.history_status.is_none()); - drop(noop); - - let (instances, revision) = store.plugin_instances_get("demo-plugin").unwrap(); - assert_eq!(instances, vec![saved_plugin_instance(30.0)]); - assert_eq!(revision, second_revision); - assert_eq!(store.history_status().history_revision, 1); - - let gate = store.history_gate(); - let counters = store.snapshot().key_counters; - let undo_id = uuid::Uuid::new_v4().to_string(); - let undo_barrier = gate.close(&undo_id).unwrap(); - let undo = store - .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) - .unwrap(); - assert!(store - .plugin_instances_get("demo-plugin") - .unwrap() - .0 - .is_empty()); - assert!(undo.status.can_redo); - drop(undo_barrier); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let redo_barrier = gate.close(&redo_id).unwrap(); - let redo = store - .apply_history_operation(HistoryDirection::Redo, &redo_id, &counters, || {}) - .unwrap(); - assert_eq!( - store.plugin_instances_get("demo-plugin").unwrap().0, - vec![saved_plugin_instance(30.0)] - ); - assert!(redo.status.can_undo); - drop(redo_barrier); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn plugin_instance_visibility_and_z_index_survive_restart() { - let dir = test_directory("plugin-instance-layout-restart-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let mut expected = saved_plugin_instance(15.0); - expected.hidden = true; - expected.z_index = Some(-12.0); - let committed = store - .commit_plugin_instances(plugin_instances_request( - "demo-plugin", - vec![expected.clone()], - uuid::Uuid::new_v4().to_string(), - None, - Some(store.plugin_model_revision()), - )) - .unwrap(); - drop(committed); - store.flush_and_shutdown().unwrap(); - drop(store); - - let restored = AppStore::initialize_in_dir(&dir).unwrap(); - assert_eq!( - restored.plugin_instances_get("demo-plugin").unwrap().0, - vec![expected] - ); - - restored.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn plugin_instance_identity_fields_survive_commit_and_restart() { - let dir = test_directory("plugin-instance-identity-restart-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let mut expected = saved_plugin_instance(15.0); - expected.instance_id = Some(uuid::Uuid::new_v4().to_string()); - expected.group_id = Some("layer-group".to_string()); - let committed = store - .commit_plugin_instances(plugin_instances_request( - "demo-plugin", - vec![expected.clone()], - uuid::Uuid::new_v4().to_string(), - None, - Some(store.plugin_model_revision()), - )) - .unwrap(); - drop(committed); - assert_eq!( - store.plugin_instances_get("demo-plugin").unwrap().0, - vec![expected.clone()] - ); - store.flush_and_shutdown().unwrap(); - drop(store); - - let restored = AppStore::initialize_in_dir(&dir).unwrap(); - assert_eq!( - restored.plugin_instances_get("demo-plugin").unwrap().0, - vec![expected] - ); - - restored.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn plugin_instances_allow_same_instance_id_across_plugins() { - // instance_id 유일성은 플러그인 단위 네임스페이스 - fullId가 pluginId로 - // 접두되므로 서로 다른 플러그인 간 같은 ID는 충돌하지 않는다 - let dir = test_directory("plugin-instance-cross-plugin-id-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let mut instance = saved_plugin_instance(10.0); - instance.instance_id = Some(uuid::Uuid::new_v4().to_string()); - - for plugin_id in ["demo-plugin", "other-plugin"] { - let committed = store - .commit_plugin_instances(plugin_instances_request( - plugin_id, - vec![instance.clone()], - uuid::Uuid::new_v4().to_string(), - None, - Some(store.plugin_model_revision()), - )) - .unwrap(); - assert!(committed.outcome.changed); - drop(committed); - assert_eq!( - store.plugin_instances_get(plugin_id).unwrap().0, - vec![instance.clone()] - ); - } - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn plugin_instances_history_budget_truncates_after_successful_commit() { - let dir = test_directory("plugin-instances-budget-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - store - .state - .write() - .history - .set_limits_for_test(1, 32 * 1024 * 1024, 50); - let expected_revision = store.plugin_model_revision(); - - let committed = store - .commit_plugin_instances(plugin_instances_request( - "demo-plugin", - vec![saved_plugin_instance(10.0)], - uuid::Uuid::new_v4().to_string(), - None, - Some(expected_revision), - )) - .unwrap(); - let status = committed.outcome.history_status.as_ref().unwrap(); - assert!(committed.outcome.changed); - assert!(!status.can_undo); - assert!(!status.can_redo); - assert_eq!( - status.truncated.as_ref().unwrap().reason, - HISTORY_ENTRY_TOO_LARGE - ); - drop(committed); - assert_eq!( - store.plugin_instances_get("demo-plugin").unwrap().0, - vec![saved_plugin_instance(10.0)] - ); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn plugin_instances_undo_redo_restore_backend_without_runtime_projection() { - let dir = test_directory("plugin-instances-history-restore-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let expected_revision = store.plugin_model_revision(); - let committed = store - .commit_plugin_instances(plugin_instances_request( - "demo-plugin", - vec![saved_plugin_instance(42.0)], - uuid::Uuid::new_v4().to_string(), - None, - Some(expected_revision), - )) - .unwrap(); - drop(committed); - - let gate = store.history_gate(); - let undo_id = uuid::Uuid::new_v4().to_string(); - let undo_barrier = gate.close(&undo_id).unwrap(); - let counters = store.snapshot().key_counters; - let undo = store - .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) - .unwrap(); - let Some(HistoryAuxChange::PluginElements { - plugin_id, - revision: undo_revision, - }) = undo.aux_change - else { - panic!("plugin undo must expose its projection payload"); - }; - assert_eq!(plugin_id, "demo-plugin"); - assert_eq!(undo_revision, store.plugin_model_revision()); - assert!(store - .plugin_instances_get("demo-plugin") - .unwrap() - .0 - .is_empty()); - assert!(undo.status.can_redo); - drop(undo_barrier); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let redo_barrier = gate.close(&redo_id).unwrap(); - let redo = store - .apply_history_operation(HistoryDirection::Redo, &redo_id, &counters, || {}) - .unwrap(); - assert_eq!( - store.plugin_instances_get("demo-plugin").unwrap().0, - vec![saved_plugin_instance(42.0)] - ); - assert!(redo.status.can_undo); - drop(redo_barrier); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn mixed_editor_plugin_gesture_undoes_and_redoes_atomically() { - let dir = test_directory("compound-editor-plugin-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial_x = store.editor_get().document.key_positions["4key"][0].dx; - let gesture_id = uuid::Uuid::new_v4().to_string(); - - let mut editor = editor_request( - store.editor_get().revision, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, initial_x + 40.0), - ); - editor.gesture_id = Some(gesture_id.clone()); - store.commit_editor_document(editor).unwrap(); - let plugin = store - .commit_plugin_instances(plugin_instances_request( - "demo-plugin", - vec![saved_plugin_instance(42.0)], - uuid::Uuid::new_v4().to_string(), - Some(gesture_id.clone()), - Some(store.plugin_model_revision()), - )) - .unwrap(); - let first_plugin_revision = plugin.outcome.model_revision; - assert_eq!( - plugin - .outcome - .history_status - .as_ref() - .unwrap() - .history_revision, - 1 - ); - drop(plugin); - let second_plugin = store - .commit_plugin_instances(plugin_instances_request( - "second-plugin", - vec![saved_plugin_instance(84.0)], - uuid::Uuid::new_v4().to_string(), - Some(gesture_id), - Some(first_plugin_revision), - )) - .unwrap(); - assert_eq!( - second_plugin - .outcome - .history_status - .as_ref() - .unwrap() - .history_revision, - 1 - ); - drop(second_plugin); - - let gate = store.history_gate(); - let counters = store.snapshot().key_counters; - let undo_id = uuid::Uuid::new_v4().to_string(); - let undo_barrier = gate.close(&undo_id).unwrap(); - let undo = store - .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) - .unwrap(); - assert_eq!( - store.editor_get().document.key_positions["4key"][0].dx, - initial_x - ); - assert!(store - .plugin_instances_get("demo-plugin") - .unwrap() - .0 - .is_empty()); - assert!(store - .plugin_instances_get("second-plugin") - .unwrap() - .0 - .is_empty()); - assert!(!undo.status.can_undo); - assert!(undo.status.can_redo); - assert!(undo.change.is_some()); - assert!(matches!( - undo.aux_change, - Some(HistoryAuxChange::PluginElementsBatch { ref plugin_ids, .. }) - if plugin_ids == &["demo-plugin".to_string(), "second-plugin".to_string()] - )); - drop(undo_barrier); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let redo_barrier = gate.close(&redo_id).unwrap(); - let redo = store - .apply_history_operation(HistoryDirection::Redo, &redo_id, &counters, || {}) - .unwrap(); - assert_eq!( - store.editor_get().document.key_positions["4key"][0].dx, - initial_x + 40.0 - ); - assert_eq!( - store.plugin_instances_get("demo-plugin").unwrap().0, - vec![saved_plugin_instance(42.0)] - ); - assert_eq!( - store.plugin_instances_get("second-plugin").unwrap().0, - vec![saved_plugin_instance(84.0)] - ); - assert!(redo.status.can_undo); - drop(redo_barrier); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn compound_history_projects_live_and_historical_key_counters() { - let dir = test_directory("compound-history-counter-scope-test"); - let store = initialize_neutral_editor_store(&dir); - let before = store.snapshot(); - let old_key = before.keys["4key"][0].canonical(); - let preserved_key = before.keys["4key"][1].canonical(); - let mut historical_counters = before.key_counters.clone(); - historical_counters - .get_mut("4key") - .unwrap() - .insert(old_key.clone(), 12); - let new_key = "CompoundHistoryCounterScopeKey".to_string(); - let mut keys = before.keys; - keys.get_mut("4key").unwrap()[0] = new_key.clone().into(); - let gesture_id = uuid::Uuid::new_v4().to_string(); - let mut editor = editor_request( - 0, - uuid::Uuid::new_v4().to_string(), - EditorPatchV1 { - keys: Some(keys), - ..EditorPatchV1::default() - }, - ); - editor.gesture_id = Some(gesture_id.clone()); - let admission = store.admit_editor_mutation().unwrap(); - store - .commit_editor_document_with_runtime_counters_admitted( - editor, - &admission, - &historical_counters, - ) - .unwrap(); - drop(admission); - let plugin = store - .commit_plugin_instances(plugin_instances_request( - "demo-plugin", - vec![saved_plugin_instance(42.0)], - uuid::Uuid::new_v4().to_string(), - Some(gesture_id), - Some(store.plugin_model_revision()), - )) - .unwrap(); - drop(plugin); - - let gate = store.history_gate(); - let undo_id = uuid::Uuid::new_v4().to_string(); - let undo_barrier = gate.close(&undo_id).unwrap(); - let mut undo_counters = store.snapshot().key_counters; - undo_counters - .get_mut("4key") - .unwrap() - .insert(new_key.clone(), 9); - undo_counters - .get_mut("4key") - .unwrap() - .insert(preserved_key.clone(), 7); - let undo = store - .apply_history_operation(HistoryDirection::Undo, &undo_id, &undo_counters, || {}) - .unwrap(); - let restored = store.snapshot(); - assert_eq!(restored.keys["4key"][0].canonical(), old_key); - assert_eq!(restored.key_counters["4key"][&old_key], 12); - assert_eq!(restored.key_counters["4key"][&preserved_key], 7); - assert!(!restored.key_counters["4key"].contains_key(&new_key)); - assert!(store - .plugin_instances_get("demo-plugin") - .unwrap() - .0 - .is_empty()); - assert!(undo - .change - .as_ref() - .is_some_and(|change| change.result.changed_fields.contains(&EditorField::Keys))); - assert!(matches!( - undo.aux_change, - Some(HistoryAuxChange::PluginElementsBatch { .. }) - )); - drop(undo_barrier); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let redo_barrier = gate.close(&redo_id).unwrap(); - let mut redo_counters = restored.key_counters; - redo_counters - .get_mut("4key") - .unwrap() - .insert(old_key.clone(), 5); - redo_counters - .get_mut("4key") - .unwrap() - .insert(preserved_key.clone(), 8); - store - .apply_history_operation(HistoryDirection::Redo, &redo_id, &redo_counters, || {}) - .unwrap(); - let redone = store.snapshot(); - assert_eq!(redone.keys["4key"][0].canonical(), new_key); - assert_eq!(redone.key_counters["4key"][&new_key], 9); - assert_eq!(redone.key_counters["4key"][&preserved_key], 8); - assert!(!redone.key_counters["4key"].contains_key(&old_key)); - assert_eq!( - store.plugin_instances_get("demo-plugin").unwrap().0, - vec![saved_plugin_instance(42.0)] - ); - drop(redo_barrier); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn plugin_only_cross_plugin_gesture_merges_into_one_undo_step() { - let dir = test_directory("cross-plugin-gesture-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let gesture_id = uuid::Uuid::new_v4().to_string(); - - // editor 선행 커밋 없이 plugin 커밋 2건만 같은 gestureId로 순차 도착 - let first = store - .commit_plugin_instances(plugin_instances_request( - "demo-plugin", - vec![saved_plugin_instance(42.0)], - uuid::Uuid::new_v4().to_string(), - Some(gesture_id.clone()), - Some(store.plugin_model_revision()), - )) - .unwrap(); - let first_revision = first.outcome.model_revision; - assert_eq!( - first - .outcome - .history_status - .as_ref() - .unwrap() - .history_revision, - 1 - ); - drop(first); - let second = store - .commit_plugin_instances(plugin_instances_request( - "second-plugin", - vec![saved_plugin_instance(84.0)], - uuid::Uuid::new_v4().to_string(), - Some(gesture_id), - Some(first_revision), - )) - .unwrap(); - // 크로스 플러그인 병합 - 히스토리 엔트리는 한 줄 유지 - assert_eq!( - second - .outcome - .history_status - .as_ref() - .unwrap() - .history_revision, - 1 - ); - drop(second); - - let gate = store.history_gate(); - let counters = store.snapshot().key_counters; - let undo_id = uuid::Uuid::new_v4().to_string(); - let undo_barrier = gate.close(&undo_id).unwrap(); - let undo = store - .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) - .unwrap(); - assert!(store - .plugin_instances_get("demo-plugin") - .unwrap() - .0 - .is_empty()); - assert!(store - .plugin_instances_get("second-plugin") - .unwrap() - .0 - .is_empty()); - assert!(!undo.status.can_undo); - assert!(undo.status.can_redo); - assert!(matches!( - undo.aux_change, - Some(HistoryAuxChange::PluginElementsBatch { ref plugin_ids, .. }) - if plugin_ids == &["demo-plugin".to_string(), "second-plugin".to_string()] - )); - drop(undo_barrier); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let redo_barrier = gate.close(&redo_id).unwrap(); - let redo = store - .apply_history_operation(HistoryDirection::Redo, &redo_id, &counters, || {}) - .unwrap(); - assert_eq!( - store.plugin_instances_get("demo-plugin").unwrap().0, - vec![saved_plugin_instance(42.0)] - ); - assert_eq!( - store.plugin_instances_get("second-plugin").unwrap().0, - vec![saved_plugin_instance(84.0)] - ); - assert!(redo.status.can_undo); - drop(redo_barrier); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - fn empty_targets_group_create_op(group_id: &str, name: &str) -> EditorOpV1 { - EditorOpV1::SetElementGroups { - mode: "4key".to_string(), - targets: Vec::new(), - target_group: Some(crate::models::EditorTargetGroupV1::Create { - id: group_id.to_string(), - name: name.to_string(), - }), - } - } - - #[test] - fn plugin_only_group_survives_gesture_and_editor_normalize() { - let dir = test_directory("plugin-group-survival-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - - // gesture 동봉: 빈 native targets op가 def를 만들고, 생존 판정은 - // 요청 동봉 plugin_changes의 group_id를 쓴다 (store 값은 아직 무소속) - let mut grouped = saved_plugin_instance(10.0); - grouped.group_id = Some("plugin-group".to_string()); - let committed = store - .commit_gesture(gesture_ops_request( - &store, - uuid::Uuid::new_v4().to_string(), - uuid::Uuid::new_v4().to_string(), - vec![empty_targets_group_create_op( - "plugin-group", - "Plugin Group", - )], - "demo-plugin", - vec![grouped.clone()], - )) - .unwrap(); - assert_eq!( - committed.outcome.result.changed_fields, - vec![EditorField::LayerGroups] - ); - drop(committed); - assert_eq!( - store.editor_get().document.layer_groups["4key"], - vec![crate::models::LayerGroupDef { - id: "plugin-group".to_string(), - name: "Plugin Group".to_string(), - }] - ); - assert_eq!( - store.plugin_instances_get("demo-plugin").unwrap().0, - vec![grouped.clone()] - ); - - // editor 단독 commit: normalize가 store 인스턴스 decode의 group_id - // 참조로 def를 보존한다 (플러그인만 남은 그룹 생존) - let victim_id = store.editor_get().document.key_positions["4key"][0] - .id - .clone(); - store - .commit_editor_document(editor_ops_request( - store.editor_get().revision, - uuid::Uuid::new_v4().to_string(), - vec![EditorOpV1::DeleteElement { - element_type: EditorElementTypeV1::Key, - id: victim_id, - }], - )) - .unwrap(); - assert_eq!( - store.editor_get().document.layer_groups["4key"], - vec![crate::models::LayerGroupDef { - id: "plugin-group".to_string(), - name: "Plugin Group".to_string(), - }] - ); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn gesture_group_membership_undo_restores_editor_and_plugin_atomically() { - let dir = test_directory("plugin-group-compound-undo-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - - // 선행: 무소속 인스턴스 저장 (히스토리 1) - let seeded = store - .commit_plugin_instances(plugin_instances_request( - "demo-plugin", - vec![saved_plugin_instance(10.0)], - uuid::Uuid::new_v4().to_string(), - None, - Some(store.plugin_model_revision()), - )) - .unwrap(); - drop(seeded); - - // 그룹화: def 생성 + 플러그인 소속 부여를 같은 gestureId 커밋에 결합 - let mut grouped = saved_plugin_instance(10.0); - grouped.group_id = Some("plugin-group".to_string()); - let committed = store - .commit_gesture(gesture_ops_request( - &store, - uuid::Uuid::new_v4().to_string(), - uuid::Uuid::new_v4().to_string(), - vec![empty_targets_group_create_op( - "plugin-group", - "Plugin Group", - )], - "demo-plugin", - vec![grouped.clone()], - )) - .unwrap(); - drop(committed); - assert_eq!( - store.plugin_instances_get("demo-plugin").unwrap().0, - vec![grouped.clone()] - ); - - // undo 1회로 layerGroups와 plugin group_id가 원자 복원 - let gate = store.history_gate(); - let counters = store.snapshot().key_counters; - let undo_id = uuid::Uuid::new_v4().to_string(); - let undo_barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) - .unwrap(); - drop(undo_barrier); - assert!(store - .editor_get() - .document - .layer_groups - .get("4key") - .is_none_or(|groups| groups.is_empty())); - assert_eq!( - store.plugin_instances_get("demo-plugin").unwrap().0, - vec![saved_plugin_instance(10.0)] - ); - - // redo 1회로 동반 복귀 - let redo_id = uuid::Uuid::new_v4().to_string(); - let redo_barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation(HistoryDirection::Redo, &redo_id, &counters, || {}) - .unwrap(); - drop(redo_barrier); - assert_eq!( - store.editor_get().document.layer_groups["4key"], - vec![crate::models::LayerGroupDef { - id: "plugin-group".to_string(), - name: "Plugin Group".to_string(), - }] - ); - assert_eq!( - store.plugin_instances_get("demo-plugin").unwrap().0, - vec![grouped] - ); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn dangling_plugin_group_id_neither_blocks_commits_nor_resurrects_groups() { - let dir = test_directory("plugin-group-dangling-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - - // def 없는 group_id 저장 수용 (그룹 존재 검증은 도메인 결합 회피로 미수행) - let mut dangling = saved_plugin_instance(10.0); - dangling.group_id = Some("missing-group".to_string()); - let committed = store - .commit_plugin_instances(plugin_instances_request( - "demo-plugin", - vec![dangling.clone()], - uuid::Uuid::new_v4().to_string(), - None, - Some(store.plugin_model_revision()), - )) - .unwrap(); - drop(committed); - - // normalize 경유 editor 커밋이 막히지 않고, 없는 def를 만들지도 않는다 - let victim_id = store.editor_get().document.key_positions["4key"][0] - .id - .clone(); - store - .commit_editor_document(editor_ops_request( - store.editor_get().revision, - uuid::Uuid::new_v4().to_string(), - vec![EditorOpV1::DeleteElement { - element_type: EditorElementTypeV1::Key, - id: victim_id, - }], - )) - .unwrap(); - assert!(store - .editor_get() - .document - .layer_groups - .get("4key") - .is_none_or(|groups| groups.is_empty())); - // dangling group_id는 읽기 가드가 무해화 - 저장 값은 보존 - assert_eq!( - store.plugin_instances_get("demo-plugin").unwrap().0, - vec![dangling] - ); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn plugin_group_refs_by_plugin_includes_unloaded_data_and_skips_broken_keys() { - let dir = test_directory("plugin-group-refs-by-plugin-test"); - std::fs::create_dir_all(&dir).unwrap(); - let mut data = initialize_default_state(); - - // 미로드 플러그인 시나리오 - 런타임 등록 없이 저장 데이터만 존재. - // tab 미지정 인스턴스는 4key로 normalize - let mut first = saved_plugin_instance(10.0); - first.group_id = Some("group-a".to_string()); - let mut second = saved_plugin_instance(20.0); - second.tab_id = None; - second.group_id = Some("group-b".to_string()); - let mut third = saved_plugin_instance(30.0); - third.tab_id = Some("6key".to_string()); - third.group_id = Some("group-c".to_string()); - let ungrouped = saved_plugin_instance(40.0); - data.plugin_data.insert( - super::plugin_instances_storage_key("grouped-plugin"), - serde_json::to_value(vec![first, second, third, ungrouped]).unwrap(), - ); - // 그룹 참조 없는 플러그인은 결과에서 제외 - data.plugin_data.insert( - super::plugin_instances_storage_key("plain-plugin"), - serde_json::to_value(vec![saved_plugin_instance(50.0)]).unwrap(), - ); - // 형태가 깨진 인스턴스 키는 집계만 skip - data.plugin_data.insert( - super::plugin_instances_storage_key("broken-plugin"), - json!({ "not": "an array" }), - ); - // 인스턴스 예약 키가 아닌 plugin_data는 무시 - data.plugin_data.insert( - "plugin_data_grouped-plugin/settings".to_string(), - json!({ "groupId": "not-a-ref" }), - ); - let store = AppStore::new(dir.join("store.json"), data, false).unwrap(); - - let (refs, model_revision) = store.plugin_group_refs_by_plugin(); - assert_eq!(model_revision, store.plugin_model_revision()); - assert_eq!(refs.len(), 1); - let grouped = &refs["grouped-plugin"]; - assert_eq!( - grouped["4key"], - HashSet::from(["group-a".to_string(), "group-b".to_string()]) - ); - assert_eq!(grouped["6key"], HashSet::from(["group-c".to_string()])); - - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn gesture_transactions_keep_rejoined_actions_atomic_and_ordered() { - let dir = test_directory("gesture-transaction-rejoined-actions-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial_x = store.editor_get().document.key_positions["4key"][0].dx; - let persist_count = store.writer.persist_count(); - - let first = store - .commit_gesture(gesture_request( - &store, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, initial_x + 10.0), - "demo-plugin", - vec![saved_plugin_instance(10.0)], - )) - .unwrap(); - assert_eq!(first.outcome.result.editor_revision, 1); - assert_eq!(first.outcome.result.plugin_model_revision, 1); - assert!(first.outcome.result.editor_op_results.is_none()); - drop(first); - - let second = store - .commit_gesture(gesture_request( - &store, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, initial_x + 20.0), - "demo-plugin", - vec![saved_plugin_instance(20.0)], - )) - .unwrap(); - assert_eq!(second.outcome.result.editor_revision, 2); - assert_eq!(second.outcome.result.plugin_model_revision, 2); - assert_eq!(store.writer.persist_count(), persist_count + 2); - assert_eq!(store.history_status().history_revision, 2); - drop(second); - - let gate = store.history_gate(); - let counters = store.snapshot().key_counters; - for (expected_editor_x, expected_plugin_x) in - [(initial_x + 10.0, Some(10.0)), (initial_x, None)] - { - let operation_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&operation_id).unwrap(); - store - .apply_history_operation(HistoryDirection::Undo, &operation_id, &counters, || {}) - .unwrap(); - drop(barrier); - assert_eq!( - store.editor_get().document.key_positions["4key"][0].dx, - expected_editor_x - ); - let plugin = store.plugin_instances_get("demo-plugin").unwrap().0; - assert_eq!( - plugin.first().map(|instance| instance.position.x), - expected_plugin_x - ); - } - - for (expected_editor_x, expected_plugin_x) in - [(initial_x + 10.0, 10.0), (initial_x + 20.0, 20.0)] - { - let operation_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&operation_id).unwrap(); - store - .apply_history_operation(HistoryDirection::Redo, &operation_id, &counters, || {}) - .unwrap(); - drop(barrier); - assert_eq!( - store.editor_get().document.key_positions["4key"][0].dx, - expected_editor_x - ); - assert_eq!( - store.plugin_instances_get("demo-plugin").unwrap().0[0] - .position - .x, - expected_plugin_x - ); - } - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn plugin_only_gesture_keeps_the_existing_editor_neutral_contract() { - let dir = test_directory("gesture-plugin-only-contract-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let editor_before = store.editor_get(); - let mut request = gesture_request( - &store, - uuid::Uuid::new_v4().to_string(), - EditorPatchV1::default(), - "demo-plugin", - vec![saved_plugin_instance(15.0)], - ); - request.editor_changes = None; - - let committed = store.commit_gesture(request).unwrap(); - assert_eq!( - committed.outcome.result.editor_revision, - editor_before.revision - ); - assert!(committed.outcome.result.changed_fields.is_empty()); - assert!(committed.outcome.result.editor_op_results.is_none()); - assert!(committed.outcome.change.is_none()); - assert_eq!(committed.outcome.result.plugin_model_revision, 1); - assert_eq!(committed.outcome.result.changed_plugin_ids, ["demo-plugin"]); - drop(committed); - assert_eq!(store.editor_get(), editor_before); - assert_eq!( - store.plugin_instances_get("demo-plugin").unwrap().0, - vec![saved_plugin_instance(15.0)] - ); - assert_eq!(store.history_status().history_revision, 1); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn mixed_editor_op_and_plugin_gesture_replays_and_round_trips_atomically() { - let dir = test_directory("gesture-editor-op-plugin-atomic-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let target = store.editor_get().document.key_positions["4key"][0].clone(); - let initial_bounds = bounds(&target); - let changed_bounds = EditorBoundsV1 { - dx: initial_bounds.dx + 40.0, - ..initial_bounds - }; - let mutation_id = uuid::Uuid::new_v4().to_string(); - let request = gesture_ops_request( - &store, - uuid::Uuid::new_v4().to_string(), - mutation_id.clone(), - vec![set_bounds_op( - EditorElementTypeV1::Key, - &target.id, - changed_bounds, - )], - "demo-plugin", - vec![saved_plugin_instance(42.0)], - ); - let persist_count = store.writer.persist_count(); - - let committed = store.commit_gesture(request.clone()).unwrap(); - assert_eq!(committed.outcome.result.editor_revision, 1); - assert_eq!(committed.outcome.result.plugin_model_revision, 1); - assert_eq!( - committed.outcome.result.editor_op_results, - Some(vec![EditorOpResultV1 { - status: EditorOpResultStatusV1::Applied, - bounds: Some(changed_bounds), - }]) - ); - let change = committed.outcome.change.as_ref().unwrap(); - assert_eq!( - change.result.op_results, - committed.outcome.result.editor_op_results - ); - assert_eq!( - change.event.as_ref().unwrap().patch.schema_version, - EDITOR_SCHEMA_VERSION - ); - assert!(change.event.as_ref().unwrap().patch.key_positions.is_some()); - assert_eq!(store.writer.persist_count(), persist_count + 1); - assert_eq!(store.history_status().history_revision, 1); - drop(committed); - assert_eq!( - bounds( - store.editor_get().document.key_positions["4key"] - .iter() - .find(|position| position.id == target.id) - .unwrap() - ), - changed_bounds - ); - assert_eq!( - store.plugin_instances_get("demo-plugin").unwrap().0, - vec![saved_plugin_instance(42.0)] - ); - - let replay = store.commit_gesture(request.clone()).unwrap(); - assert!(replay.outcome.replayed); - assert_eq!( - replay.outcome.result.editor_op_results, - Some(vec![EditorOpResultV1 { - status: EditorOpResultStatusV1::Applied, - bounds: Some(changed_bounds), - }]) - ); - assert!(replay.outcome.change.is_none()); - assert_eq!(store.writer.persist_count(), persist_count + 1); - drop(replay); - - let mut reused = request; - reused.editor_ops = Some(vec![set_bounds_op( - EditorElementTypeV1::Key, - &target.id, - EditorBoundsV1 { - dx: changed_bounds.dx + 1.0, - ..changed_bounds - }, - )]); - assert_eq!( - store.commit_gesture(reused).unwrap_err().error_code, - EditorCommitErrorCode::MutationIdReused - ); - - let gate = store.history_gate(); - let counters = store.snapshot().key_counters; - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) - .unwrap(); - drop(barrier); - assert_eq!( - bounds( - store.editor_get().document.key_positions["4key"] - .iter() - .find(|position| position.id == target.id) - .unwrap() - ), - initial_bounds - ); - assert!(store - .plugin_instances_get("demo-plugin") - .unwrap() - .0 - .is_empty()); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation(HistoryDirection::Redo, &redo_id, &counters, || {}) - .unwrap(); - drop(barrier); - assert_eq!( - bounds( - store.editor_get().document.key_positions["4key"] - .iter() - .find(|position| position.id == target.id) - .unwrap() - ), - changed_bounds - ); - assert_eq!( - store.plugin_instances_get("demo-plugin").unwrap().0, - vec![saved_plugin_instance(42.0)] - ); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn mixed_editor_op_validation_and_persist_failures_leave_both_scopes_unchanged() { - let dir = test_directory("gesture-editor-op-plugin-failure-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let target = store.editor_get().document.key_positions["4key"][0].clone(); - let changed_bounds = EditorBoundsV1 { - dx: target.dx + 25.0, - ..bounds(&target) - }; - let before = store.snapshot(); - let before_plugin_revision = store.plugin_model_revision(); - let before_history_revision = store.history_status().history_revision; - let mut invalid_plugin = saved_plugin_instance(25.0); - invalid_plugin.position.x = f64::NAN; - - let error = store - .commit_gesture(gesture_ops_request( - &store, - uuid::Uuid::new_v4().to_string(), - uuid::Uuid::new_v4().to_string(), - vec![set_bounds_op( - EditorElementTypeV1::Stat, - &target.id, - changed_bounds, - )], - "demo-plugin", - vec![saved_plugin_instance(25.0)], - )) - .unwrap_err(); - assert_eq!(error.error_code, EditorCommitErrorCode::ValidationFailed); - assert_eq!( - error - .details - .and_then(|details| details.validation_code) - .as_deref(), - Some("ELEMENT_TYPE_MISMATCH") - ); - assert_eq!(store.snapshot(), before); - - let error = store - .commit_gesture(gesture_ops_request( - &store, - uuid::Uuid::new_v4().to_string(), - uuid::Uuid::new_v4().to_string(), - vec![set_bounds_op( - EditorElementTypeV1::Key, - &target.id, - changed_bounds, - )], - "demo-plugin", - vec![invalid_plugin], - )) - .unwrap_err(); - assert_eq!(error.error_code, EditorCommitErrorCode::ValidationFailed); - assert_eq!(store.snapshot(), before); - - store.writer.fail_next_persist(); - let error = store - .commit_gesture(gesture_ops_request( - &store, - uuid::Uuid::new_v4().to_string(), - uuid::Uuid::new_v4().to_string(), - vec![set_bounds_op( - EditorElementTypeV1::Key, - &target.id, - changed_bounds, - )], - "demo-plugin", - vec![saved_plugin_instance(25.0)], - )) - .unwrap_err(); - assert_eq!(error.error_code, EditorCommitErrorCode::IoError); - assert_eq!(store.snapshot(), before); - assert_eq!(store.plugin_model_revision(), before_plugin_revision); - assert_eq!( - store.history_status().history_revision, - before_history_revision - ); - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn frozen_insert_commits_replays_and_round_trips_with_snapshot_history() { - let dir = test_directory("frozen-insert-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let before = store.editor_get().document; - let inserted_id = uuid::Uuid::new_v4().to_string(); - let mutation_id = uuid::Uuid::new_v4().to_string(); - let request = editor_ops_request( - 0, - mutation_id.clone(), - vec![frozen_key_insert_op(&inserted_id)], - ); - let persist_count = store.writer.persist_count(); - - let committed = store.commit_editor_document(request.clone()).unwrap(); - assert_eq!( - committed.result.op_results, - Some(vec![EditorOpResultV1 { - status: EditorOpResultStatusV1::Applied, - bounds: None, - }]) - ); - assert_eq!( - committed.result.changed_fields, - [EditorField::Keys, EditorField::KeyPositions] - ); - assert_eq!(store.writer.persist_count(), persist_count + 1); - assert_eq!(store.history_status().history_revision, 1); - drop(committed); - - let replay = store.commit_editor_document(request).unwrap(); - assert!(replay.replayed); - assert_eq!(store.writer.persist_count(), persist_count + 1); - assert_eq!( - replay.result.op_results.unwrap()[0].status, - EditorOpResultStatusV1::Applied - ); - - let counters = store.snapshot().key_counters; - let gate = store.history_gate(); - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) - .unwrap(); - drop(barrier); - assert_eq!(store.editor_get().document, before); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation(HistoryDirection::Redo, &redo_id, &counters, || {}) - .unwrap(); - drop(barrier); - assert!(store.editor_get().document.key_positions["4key"] - .iter() - .any(|position| position.id == inserted_id)); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn mixed_frozen_insert_and_plugin_change_are_atomic_on_persist_failure() { - let dir = test_directory("mixed-frozen-insert-plugin-atomic-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let before = store.snapshot(); - let plugin_revision = store.plugin_model_revision(); - let history_revision = store.history_status().history_revision; - let inserted_id = uuid::Uuid::new_v4().to_string(); - let request = gesture_ops_request( - &store, - uuid::Uuid::new_v4().to_string(), - uuid::Uuid::new_v4().to_string(), - vec![frozen_key_insert_op(&inserted_id)], - "demo-plugin", - vec![saved_plugin_instance(55.0)], - ); - - store.writer.fail_next_persist(); - assert_eq!( - store - .commit_gesture(request.clone()) - .unwrap_err() - .error_code, - EditorCommitErrorCode::IoError - ); - assert_eq!(store.snapshot(), before); - assert_eq!(store.plugin_model_revision(), plugin_revision); - assert_eq!(store.history_status().history_revision, history_revision); - - let committed = store.commit_gesture(request).unwrap(); - assert_eq!(committed.outcome.result.editor_revision, 1); - assert_eq!(committed.outcome.result.plugin_model_revision, 1); - assert_eq!(committed.outcome.result.changed_plugin_ids, ["demo-plugin"]); - assert_eq!(store.history_status().history_revision, 1); - drop(committed); - assert!(store.editor_get().document.key_positions["4key"] - .iter() - .any(|position| position.id == inserted_id)); - assert_eq!( - store.plugin_instances_get("demo-plugin").unwrap().0, - vec![saved_plugin_instance(55.0)] - ); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn frozen_insert_z_only_no_change_keeps_revision_history_and_persist_count() { - let dir = test_directory("frozen-insert-z-no-change-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let target = store.editor_get().document.key_positions["4key"][0].clone(); - let request = editor_ops_request( - 0, - uuid::Uuid::new_v4().to_string(), - vec![EditorOpV1::InsertFrozenElements { - mode: "4key".to_string(), - elements: Vec::new(), - groups: Vec::new(), - z_updates: vec![crate::models::EditorZUpdateV1 { - element_type: EditorElementTypeV1::Key, - id: target.id, - z_index: target.z_index.unwrap_or_default(), - }], - }], - ); - let persist_count = store.writer.persist_count(); - - let result = store.commit_editor_document(request).unwrap(); - assert_eq!(result.result.revision, 0); - assert!(result.result.changed_fields.is_empty()); - assert_eq!( - result.result.op_results.unwrap()[0].status, - EditorOpResultStatusV1::NoChange - ); - assert_eq!(store.writer.persist_count(), persist_count); - assert_eq!(store.history_status().history_revision, 0); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn reorder_commits_replays_and_round_trips_with_snapshot_history() { - let dir = test_directory("reorder-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = initialize_neutral_editor_store(&dir); - let initial = store.editor_get(); - let before_revision = initial.revision; - let before = initial.document; - let op = complete_key_reorder_op(&before); - let request = editor_ops_request( - before_revision, - uuid::Uuid::new_v4().to_string(), - vec![op.clone()], - ); - let persist_count = store.writer.persist_count(); - - let committed = store.commit_editor_document(request.clone()).unwrap(); - assert_eq!( - committed.result.op_results, - Some(vec![EditorOpResultV1 { - status: EditorOpResultStatusV1::Applied, - bounds: None, - }]) - ); - assert_eq!(committed.result.changed_fields, [EditorField::KeyPositions]); - assert_eq!( - committed - .event - .as_ref() - .unwrap() - .patch - .key_positions - .as_ref() - .unwrap()["4key"][0] - .z_index, - Some(100) - ); - assert_eq!(store.writer.persist_count(), persist_count + 1); - assert_eq!(store.history_status().history_revision, 1); - let after = committed.document.clone(); - drop(committed); - - let replay = store.commit_editor_document(request.clone()).unwrap(); - assert!(replay.replayed); - assert_eq!( - replay.result.op_results, - Some(vec![EditorOpResultV1 { - status: EditorOpResultStatusV1::Applied, - bounds: None, - }]) - ); - assert_eq!(store.writer.persist_count(), persist_count + 1); - - let mut reused = request; - let Some(EditorOpV1::ReorderElements { z_updates, .. }) = - reused.ops.as_mut().and_then(|ops| ops.first_mut()) - else { - unreachable!(); - }; - z_updates[0].z_index += 1; - assert_eq!( - store.commit_editor_document(reused).unwrap_err().error_code, - EditorCommitErrorCode::MutationIdReused - ); - - let no_change = store - .commit_editor_document(editor_ops_request( - replay.result.revision, - uuid::Uuid::new_v4().to_string(), - vec![op], - )) - .unwrap(); - assert_eq!( - no_change.result.op_results, - Some(vec![EditorOpResultV1 { - status: EditorOpResultStatusV1::NoChange, - bounds: None, - }]) - ); - assert_eq!(no_change.result.revision, replay.result.revision); - assert_eq!(store.writer.persist_count(), persist_count + 1); - assert_eq!(store.history_status().history_revision, 1); - - let counters = store.snapshot().key_counters; - let gate = store.history_gate(); - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) - .unwrap(); - drop(barrier); - assert_eq!(store.editor_get().document, before); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation(HistoryDirection::Redo, &redo_id, &counters, || {}) - .unwrap(); - drop(barrier); - assert_eq!(store.editor_get().document, after); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn group_structural_ops_replay_reuse_and_round_trip_snapshot_history() { - let dir = test_directory("group-structural-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let setup = legacy_editor_commit( - &store, - &[ - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - EditorField::LayerGroups, - ], - |data| { - data.key_positions.get_mut("4key").unwrap()[0].group_id = - Some("source-group".to_string()); - data.key_positions.get_mut("4key").unwrap()[1].group_id = - Some("source-group".to_string()); - data.stat_positions.insert( - "4key".to_string(), - vec![StatPosition { - stat_type: StatType::Kps, - position: KeyPosition { - id: uuid::Uuid::new_v4().to_string(), - group_id: Some("source-group".to_string()), - ..KeyPosition::default() - }, - }], - ); - data.graph_positions.insert( - "4key".to_string(), - vec![GraphPosition { - stat_type: GraphStatType::Kps, - graph_type: GraphType::Line, - graph_speed: 100, - graph_color: "#123456".to_string(), - show_avg_line: false, - position: KeyPosition { - id: uuid::Uuid::new_v4().to_string(), - group_id: Some("source-group".to_string()), - ..KeyPosition::default() - }, - }], - ); - data.knob_positions.insert( - "4key".to_string(), - vec![KnobPosition { - axis_id: "axis".to_string(), - sensitivity: 1.0, - reverse: false, - position: KeyPosition { - id: uuid::Uuid::new_v4().to_string(), - group_id: Some("source-group".to_string()), - ..KeyPosition::default() - }, - }], - ); - data.layer_groups.insert( - "4key".to_string(), - vec![LayerGroupDef { - id: "source-group".to_string(), - name: "Source".to_string(), - }], - ); - }, - ) - .unwrap(); - let before = setup.document; - let targets = vec![ - EditorElementGroupTargetV1 { - element_type: EditorElementTypeV1::Key, - id: before.key_positions["4key"][0].id.clone(), - }, - EditorElementGroupTargetV1 { - element_type: EditorElementTypeV1::Stat, - id: before.stat_positions["4key"][0].position.id.clone(), - }, - EditorElementGroupTargetV1 { - element_type: EditorElementTypeV1::Graph, - id: before.graph_positions["4key"][0].position.id.clone(), - }, - EditorElementGroupTargetV1 { - element_type: EditorElementTypeV1::Knob, - id: before.knob_positions["4key"][0].position.id.clone(), - }, - ]; - let create = EditorOpV1::SetElementGroups { - mode: "4key".to_string(), - targets: targets.clone(), - target_group: Some(EditorTargetGroupV1::Create { - id: "target-group".to_string(), - name: " Target ".to_string(), - }), - }; - let mutation_id = uuid::Uuid::new_v4().to_string(); - let request = editor_ops_request( - store.editor_get().revision, - &mutation_id, - vec![create.clone()], - ); - let history_before = store.history_status().history_revision; - let committed = store.commit_editor_document(request.clone()).unwrap(); - assert_eq!( - committed.result.op_results, - Some(vec![EditorOpResultV1 { - status: EditorOpResultStatusV1::Applied, - bounds: None, - }]) - ); - assert_eq!( - committed.result.changed_fields, - [ - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - EditorField::LayerGroups, - ] - ); - assert_eq!(store.history_status().history_revision, history_before + 1); - let after_set = committed.document.clone(); - assert_eq!( - after_set.key_positions["4key"][1].group_id.as_deref(), - Some("source-group") - ); - assert!(after_set.layer_groups["4key"] - .iter() - .any(|group| group.id == "source-group")); - assert!(after_set.layer_groups["4key"] - .iter() - .any(|group| group.id == "target-group" && group.name == " Target ")); - - let replay = store.commit_editor_document(request.clone()).unwrap(); - assert!(replay.replayed); - assert_eq!(replay.result, committed.result); - - let mut reused = request; - reused.ops = Some(vec![EditorOpV1::SetElementGroups { - mode: "4key".to_string(), - targets: targets.clone(), - target_group: None, - }]); - assert_eq!( - store.commit_editor_document(reused).unwrap_err().error_code, - EditorCommitErrorCode::MutationIdReused - ); - - let missing_revision = store.editor_get().revision; - let missing_history = store.history_status().history_revision; - let missing = store - .commit_editor_document(editor_ops_request( - missing_revision, - uuid::Uuid::new_v4().to_string(), - vec![EditorOpV1::SetElementGroups { - mode: "4key".to_string(), - targets: vec![ - targets[0].clone(), - EditorElementGroupTargetV1 { - element_type: EditorElementTypeV1::Key, - id: uuid::Uuid::new_v4().to_string(), - }, - ], - target_group: None, - }], - )) - .unwrap(); - assert_eq!(missing.result.revision, missing_revision); - assert_eq!( - missing.result.op_results.unwrap()[0].status, - EditorOpResultStatusV1::TargetMissing - ); - assert!(missing.event.is_none()); - assert_eq!(store.history_status().history_revision, missing_history); - - let existing = EditorOpV1::SetElementGroups { - mode: "4key".to_string(), - targets: targets.clone(), - target_group: Some(EditorTargetGroupV1::Existing { - id: "target-group".to_string(), - }), - }; - let no_change = store - .commit_editor_document(editor_ops_request( - store.editor_get().revision, - uuid::Uuid::new_v4().to_string(), - vec![existing], - )) - .unwrap(); - assert_eq!( - no_change.result.op_results.unwrap()[0].status, - EditorOpResultStatusV1::NoChange - ); - assert!(no_change.result.changed_fields.is_empty()); - assert!(no_change.event.is_none()); - assert_eq!(store.history_status().history_revision, history_before + 1); - - let counters = store.snapshot().key_counters; - let gate = store.history_gate(); - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) - .unwrap(); - drop(barrier); - assert_eq!(store.editor_get().document, before); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation(HistoryDirection::Redo, &redo_id, &counters, || {}) - .unwrap(); - drop(barrier); - assert_eq!(store.editor_get().document, after_set); - - let rename = EditorOpV1::RenameLayerGroup { - mode: "4key".to_string(), - group_id: "target-group".to_string(), - name: " Renamed ".to_string(), - }; - let rename_request = editor_ops_request( - store.editor_get().revision, - uuid::Uuid::new_v4().to_string(), - vec![rename.clone()], - ); - let renamed = store - .commit_editor_document(rename_request.clone()) - .unwrap(); - assert_eq!(renamed.result.changed_fields, [EditorField::LayerGroups]); - assert_eq!( - renamed.result.op_results.as_ref().unwrap()[0].status, - EditorOpResultStatusV1::Applied - ); - let after_rename = renamed.document.clone(); - assert!(after_rename.layer_groups["4key"] - .iter() - .any(|group| group.id == "target-group" && group.name == " Renamed ")); - let rename_replay = store.commit_editor_document(rename_request).unwrap(); - assert!(rename_replay.replayed); - assert_eq!(rename_replay.result, renamed.result); - - let rename_no_change = store - .commit_editor_document(editor_ops_request( - store.editor_get().revision, - uuid::Uuid::new_v4().to_string(), - vec![rename], - )) - .unwrap(); - assert_eq!( - rename_no_change.result.op_results.unwrap()[0].status, - EditorOpResultStatusV1::NoChange - ); - assert!(rename_no_change.event.is_none()); - - let undo_rename_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_rename_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Undo, - &undo_rename_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - assert_eq!(store.editor_get().document, after_set); - - let redo_rename_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_rename_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Redo, - &redo_rename_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - assert_eq!(store.editor_get().document, after_rename); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn mixed_reorder_and_plugin_change_are_atomic_on_validation_and_persist_failure() { - let dir = test_directory("mixed-reorder-plugin-atomic-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = initialize_neutral_editor_store(&dir); - let before = store.snapshot(); - let before_plugin_revision = store.plugin_model_revision(); - let before_history_revision = store.history_status().history_revision; - let valid_op = complete_key_reorder_op(&store.editor_get().document); - - let mut missing = valid_op.clone(); - let EditorOpV1::ReorderElements { z_updates, .. } = &mut missing else { - unreachable!(); - }; - z_updates[0].id = uuid::Uuid::new_v4().to_string(); - let error = store - .commit_gesture(gesture_ops_request( - &store, - uuid::Uuid::new_v4().to_string(), - uuid::Uuid::new_v4().to_string(), - vec![missing], - "demo-plugin", - vec![saved_plugin_instance(50.0)], - )) - .unwrap_err(); - assert_eq!(error.error_code, EditorCommitErrorCode::ValidationFailed); - assert_eq!( - error - .details - .and_then(|details| details.validation_code) - .as_deref(), - Some("REORDER_TARGET_MISSING") - ); - assert_eq!(store.snapshot(), before); - assert!(store - .plugin_instances_get("demo-plugin") - .unwrap() - .0 - .is_empty()); - - store.writer.fail_next_persist(); - let request = gesture_ops_request( - &store, - uuid::Uuid::new_v4().to_string(), - uuid::Uuid::new_v4().to_string(), - vec![valid_op], - "demo-plugin", - vec![saved_plugin_instance(50.0)], - ); - assert_eq!( - store - .commit_gesture(request.clone()) - .unwrap_err() - .error_code, - EditorCommitErrorCode::IoError - ); - assert_eq!(store.snapshot(), before); - assert_eq!(store.plugin_model_revision(), before_plugin_revision); - assert_eq!( - store.history_status().history_revision, - before_history_revision - ); - - let committed = store.commit_gesture(request).unwrap(); - assert_eq!( - committed.outcome.result.editor_op_results, - Some(vec![EditorOpResultV1 { - status: EditorOpResultStatusV1::Applied, - bounds: None, - }]) - ); - assert_eq!(committed.outcome.result.changed_plugin_ids, ["demo-plugin"]); - assert_eq!(store.history_status().history_revision, 1); - drop(committed); - assert_eq!( - store.plugin_instances_get("demo-plugin").unwrap().0, - vec![saved_plugin_instance(50.0)] - ); - - let gate = store.history_gate(); - let counters = store.snapshot().key_counters; - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) - .unwrap(); - drop(barrier); - assert_eq!( - store.editor_get().document.key_positions, - before.key_positions - ); - assert!(store - .plugin_instances_get("demo-plugin") - .unwrap() - .0 - .is_empty()); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation(HistoryDirection::Redo, &redo_id, &counters, || {}) - .unwrap(); - drop(barrier); - assert_eq!( - store.editor_get().document.key_positions["4key"][0].z_index, - Some(100) - ); - assert_eq!( - store.plugin_instances_get("demo-plugin").unwrap().0, - vec![saved_plugin_instance(50.0)] - ); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn missing_editor_op_does_not_block_the_plugin_half_of_a_gesture() { - let dir = test_directory("gesture-missing-editor-op-plugin-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let before_editor_revision = store.editor_get().revision; - - let committed = store - .commit_gesture(gesture_ops_request( - &store, - uuid::Uuid::new_v4().to_string(), - uuid::Uuid::new_v4().to_string(), - vec![set_bounds_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - EditorBoundsV1 { - dx: 1.0, - dy: 2.0, - width: 3.0, - height: 4.0, - }, - )], - "demo-plugin", - vec![saved_plugin_instance(10.0)], - )) - .unwrap(); - - assert_eq!( - committed.outcome.result.editor_revision, - before_editor_revision - ); - assert_eq!( - committed.outcome.result.editor_op_results, - Some(vec![EditorOpResultV1 { - status: EditorOpResultStatusV1::TargetMissing, - bounds: None, - }]) - ); - assert!(committed.outcome.result.changed_fields.is_empty()); - assert!(committed.outcome.change.is_none()); - assert_eq!(committed.outcome.result.changed_plugin_ids, ["demo-plugin"]); - drop(committed); - assert_eq!( - store.plugin_instances_get("demo-plugin").unwrap().0, - vec![saved_plugin_instance(10.0)] - ); - assert_eq!(store.history_status().history_revision, 1); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn delete_op_and_plugin_change_commit_and_restore_as_one_gesture() { - let dir = test_directory("gesture-delete-op-plugin-atomic-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let before = store.editor_get().document; - let deleted_id = before.key_positions["4key"][0].id.clone(); - let request = gesture_ops_request( - &store, - uuid::Uuid::new_v4().to_string(), - uuid::Uuid::new_v4().to_string(), - vec![delete_element_op(EditorElementTypeV1::Key, &deleted_id)], - "demo-plugin", - vec![saved_plugin_instance(10.0)], - ); - - let committed = store.commit_gesture(request.clone()).unwrap(); - assert_eq!( - committed.outcome.result.editor_op_results, - Some(vec![EditorOpResultV1 { - status: EditorOpResultStatusV1::Applied, - bounds: None, - }]) - ); - assert_eq!( - committed.outcome.result.changed_fields, - [EditorField::Keys, EditorField::KeyPositions] - ); - assert_eq!(committed.outcome.result.changed_plugin_ids, ["demo-plugin"]); - assert_eq!(store.history_status().history_revision, 1); - drop(committed); - assert!(!store.editor_get().document.key_positions["4key"] - .iter() - .any(|position| position.id == deleted_id)); - assert_eq!( - store.plugin_instances_get("demo-plugin").unwrap().0, - vec![saved_plugin_instance(10.0)] - ); - let replay = store.commit_gesture(request).unwrap(); - assert!(replay.outcome.replayed); - assert_eq!( - replay.outcome.result.editor_op_results, - Some(vec![EditorOpResultV1 { - status: EditorOpResultStatusV1::Applied, - bounds: None, - }]) - ); - drop(replay); - - let gate = store.history_gate(); - let counters = store.snapshot().key_counters; - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) - .unwrap(); - drop(barrier); - assert_eq!(store.editor_get().document, before); - assert!(store - .plugin_instances_get("demo-plugin") - .unwrap() - .0 - .is_empty()); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation(HistoryDirection::Redo, &redo_id, &counters, || {}) - .unwrap(); - drop(barrier); - assert!(!store.editor_get().document.key_positions["4key"] - .iter() - .any(|position| position.id == deleted_id)); - assert_eq!( - store.plugin_instances_get("demo-plugin").unwrap().0, - vec![saved_plugin_instance(10.0)] - ); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn gesture_plugin_validation_failure_leaves_editor_and_store_unchanged() { - let dir = test_directory("gesture-transaction-validation-failure-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let before = store.snapshot(); - let before_plugin_revision = store.plugin_model_revision(); - let before_history_revision = store.history_status().history_revision; - let persist_count = store.writer.persist_count(); - let mut invalid = saved_plugin_instance(10.0); - invalid.position.x = f64::NAN; - - let error = store - .commit_gesture(gesture_request( - &store, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, before.key_positions["4key"][0].dx + 25.0), - "demo-plugin", - vec![invalid], - )) - .unwrap_err(); - - assert_eq!(error.error_code, EditorCommitErrorCode::ValidationFailed); - assert_eq!(store.snapshot(), before); - assert_eq!(store.plugin_model_revision(), before_plugin_revision); - assert_eq!( - store.history_status().history_revision, - before_history_revision - ); - assert_eq!(store.writer.persist_count(), persist_count); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn gesture_plugin_transition_rejects_new_and_worsened_legacy_outliers_atomically() { - let dir = test_directory("gesture-plugin-legacy-outlier-transition-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - - let before = store.snapshot(); - let new_outlier = saved_plugin_instance(40_000.0); - let error = store - .commit_gesture(gesture_request( - &store, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, before.key_positions["4key"][0].dx + 25.0), - "demo-plugin", - vec![new_outlier.clone()], - )) - .unwrap_err(); - assert_eq!( - error.details.and_then(|details| details.validation_code), - Some("INVALID_PLUGIN_INSTANCE_POSITION:0".to_string()) - ); - assert_eq!(store.snapshot(), before); - - let key = super::plugin_instances_storage_key("demo-plugin"); - store.state.write().data.plugin_data.insert( - key, - serde_json::to_value(vec![new_outlier.clone()]).unwrap(), - ); - let legacy_before = store.snapshot(); - let mut worsened = new_outlier; - worsened.position.x = 41_000.0; - let error = store - .commit_gesture(gesture_request( - &store, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, legacy_before.key_positions["4key"][0].dx + 30.0), - "demo-plugin", - vec![worsened], - )) - .unwrap_err(); - assert_eq!( - error.details.and_then(|details| details.validation_code), - Some("INVALID_PLUGIN_INSTANCE_POSITION:0".to_string()) - ); - assert_eq!(store.snapshot(), legacy_before); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn gesture_plugin_transition_allows_legacy_outlier_sibling_edit_and_improvement() { - let dir = test_directory("gesture-plugin-legacy-outlier-compatible-edit-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let legacy = saved_plugin_instance(40_000.0); - store.state.write().data.plugin_data.insert( - super::plugin_instances_storage_key("demo-plugin"), - serde_json::to_value(vec![legacy.clone()]).unwrap(), - ); - - let mut sibling_edit = legacy.clone(); - sibling_edit.hidden = true; - drop( - store - .commit_gesture(gesture_request( - &store, - uuid::Uuid::new_v4().to_string(), - EditorPatchV1::default(), - "demo-plugin", - vec![sibling_edit.clone()], - )) - .unwrap(), - ); - assert_eq!( - store.plugin_instances_get("demo-plugin").unwrap().0, - vec![sibling_edit.clone()] - ); - - let mut improved = sibling_edit; - improved.position.x = 39_000.0; - drop( - store - .commit_gesture(gesture_request( - &store, - uuid::Uuid::new_v4().to_string(), - EditorPatchV1::default(), - "demo-plugin", - vec![improved.clone()], - )) - .unwrap(), - ); - assert_eq!( - store.plugin_instances_get("demo-plugin").unwrap().0, - vec![improved] - ); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn gesture_persist_failure_does_not_publish_partial_state() { - let dir = test_directory("gesture-transaction-persist-failure-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let before = store.snapshot(); - let before_plugin_revision = store.plugin_model_revision(); - let before_history_revision = store.history_status().history_revision; - store.writer.fail_next_persist(); - - let error = store - .commit_gesture(gesture_request( - &store, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, before.key_positions["4key"][0].dx + 25.0), - "demo-plugin", - vec![saved_plugin_instance(25.0)], - )) - .unwrap_err(); - - assert_eq!(error.error_code, EditorCommitErrorCode::IoError); - assert_eq!(store.snapshot(), before); - assert_eq!(store.plugin_model_revision(), before_plugin_revision); - assert_eq!( - store.history_status().history_revision, - before_history_revision - ); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn gesture_oversized_history_rejects_the_whole_transaction() { - let dir = test_directory("gesture-transaction-history-size-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - store - .state - .write() - .history - .set_limits_for_test(1, 32 * 1024 * 1024, 50); - let before = store.snapshot(); - let before_plugin_revision = store.plugin_model_revision(); - let before_history_revision = store.history_status().history_revision; - let persist_count = store.writer.persist_count(); - - let error = store - .commit_gesture(gesture_request( - &store, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, before.key_positions["4key"][0].dx + 25.0), - "demo-plugin", - vec![saved_plugin_instance(25.0)], - )) - .unwrap_err(); - - assert_eq!(error.error_code, EditorCommitErrorCode::ValidationFailed); - assert_eq!( - error.details.unwrap().validation_code.as_deref(), - Some(HISTORY_ENTRY_TOO_LARGE) - ); - assert_eq!(store.snapshot(), before); - assert_eq!(store.plugin_model_revision(), before_plugin_revision); - assert_eq!( - store.history_status().history_revision, - before_history_revision - ); - assert_eq!(store.writer.persist_count(), persist_count); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn merged_editor_gesture_aliases_only_join_the_top_plugin_entry() { - let dir = test_directory("merged-editor-gesture-alias-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial_x = store.editor_get().document.key_positions["4key"][0].dx; - let first_gesture = uuid::Uuid::new_v4().to_string(); - let second_gesture = uuid::Uuid::new_v4().to_string(); - - for (plugin_id, x, gesture_id) in [ - ("plugin-a", 41.0, first_gesture.clone()), - ("plugin-b", 82.0, second_gesture.clone()), - ] { - let plugin = store - .commit_plugin_instances(plugin_instances_request( - plugin_id, - vec![saved_plugin_instance(x)], - uuid::Uuid::new_v4().to_string(), - Some(gesture_id), - Some(store.plugin_model_revision()), - )) - .unwrap(); - drop(plugin); - } - - let mut editor = editor_request( - store.editor_get().revision, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, initial_x + 60.0), - ); - editor.gesture_id = Some(second_gesture.clone()); - editor.gesture_ids = vec![first_gesture, second_gesture]; - store.commit_editor_document(editor).unwrap(); - - let gate = store.history_gate(); - let counters = store.snapshot().key_counters; - let first_undo_id = uuid::Uuid::new_v4().to_string(); - let first_barrier = gate.close(&first_undo_id).unwrap(); - let first_undo = store - .apply_history_operation(HistoryDirection::Undo, &first_undo_id, &counters, || {}) - .unwrap(); - assert_eq!( - store.editor_get().document.key_positions["4key"][0].dx, - initial_x - ); - assert_eq!( - store.plugin_instances_get("plugin-a").unwrap().0, - vec![saved_plugin_instance(41.0)] - ); - assert!(store.plugin_instances_get("plugin-b").unwrap().0.is_empty()); - assert!(first_undo.status.can_undo); - assert!(first_undo.status.can_redo); - drop(first_barrier); - - let second_undo_id = uuid::Uuid::new_v4().to_string(); - let second_barrier = gate.close(&second_undo_id).unwrap(); - let second_undo = store - .apply_history_operation(HistoryDirection::Undo, &second_undo_id, &counters, || {}) - .unwrap(); - assert!(store.plugin_instances_get("plugin-a").unwrap().0.is_empty()); - assert!(store.plugin_instances_get("plugin-b").unwrap().0.is_empty()); - assert!(!second_undo.status.can_undo); - assert!(second_undo.status.can_redo); - drop(second_barrier); - - let first_redo_id = uuid::Uuid::new_v4().to_string(); - let first_redo_barrier = gate.close(&first_redo_id).unwrap(); - store - .apply_history_operation(HistoryDirection::Redo, &first_redo_id, &counters, || {}) - .unwrap(); - assert_eq!( - store.plugin_instances_get("plugin-a").unwrap().0, - vec![saved_plugin_instance(41.0)] - ); - assert!(store.plugin_instances_get("plugin-b").unwrap().0.is_empty()); - assert_eq!( - store.editor_get().document.key_positions["4key"][0].dx, - initial_x - ); - drop(first_redo_barrier); - - let second_redo_id = uuid::Uuid::new_v4().to_string(); - let second_redo_barrier = gate.close(&second_redo_id).unwrap(); - let second_redo = store - .apply_history_operation(HistoryDirection::Redo, &second_redo_id, &counters, || {}) - .unwrap(); - assert_eq!( - store.plugin_instances_get("plugin-a").unwrap().0, - vec![saved_plugin_instance(41.0)] - ); - assert_eq!( - store.plugin_instances_get("plugin-b").unwrap().0, - vec![saved_plugin_instance(82.0)] - ); - assert_eq!( - store.editor_get().document.key_positions["4key"][0].dx, - initial_x + 60.0 - ); - assert!(second_redo.status.can_undo); - assert!(!second_redo.status.can_redo); - drop(second_redo_barrier); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn multi_alias_editor_commit_stays_above_a_later_editor_commit() { - let dir = test_directory("multi-alias-editor-order-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial_x = store.editor_get().document.key_positions["4key"][0].dx; - let first_gesture = uuid::Uuid::new_v4().to_string(); - let second_gesture = uuid::Uuid::new_v4().to_string(); - - for (plugin_id, x, gesture_id) in [ - ("plugin-a", 41.0, first_gesture.clone()), - ("plugin-b", 82.0, second_gesture.clone()), - ] { - let plugin = store - .commit_plugin_instances(plugin_instances_request( - plugin_id, - vec![saved_plugin_instance(x)], - uuid::Uuid::new_v4().to_string(), - Some(gesture_id), - Some(store.plugin_model_revision()), - )) - .unwrap(); - drop(plugin); - } - - let mut intervening_editor = editor_request( - store.editor_get().revision, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, initial_x + 20.0), - ); - intervening_editor.gesture_id = Some(uuid::Uuid::new_v4().to_string()); - store.commit_editor_document(intervening_editor).unwrap(); - - let mut latest_editor = editor_request( - store.editor_get().revision, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, initial_x + 40.0), - ); - latest_editor.gesture_id = Some(second_gesture.clone()); - latest_editor.gesture_ids = vec![first_gesture, second_gesture]; - store.commit_editor_document(latest_editor).unwrap(); - - let gate = store.history_gate(); - let counters = store.snapshot().key_counters; - for (expected_x, expected_a, expected_b) in [ - (initial_x + 20.0, Some(41.0), Some(82.0)), - (initial_x, Some(41.0), Some(82.0)), - (initial_x, Some(41.0), None), - (initial_x, None, None), - ] { - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) - .unwrap(); - assert_eq!( - store.editor_get().document.key_positions["4key"][0].dx, - expected_x - ); - assert_eq!( - store.plugin_instances_get("plugin-a").unwrap().0, - expected_a - .map(|x| vec![saved_plugin_instance(x)]) - .unwrap_or_default() - ); - assert_eq!( - store.plugin_instances_get("plugin-b").unwrap().0, - expected_b - .map(|x| vec![saved_plugin_instance(x)]) - .unwrap_or_default() - ); - drop(barrier); - } - assert!(!store.history_status().can_undo); - - for (expected_x, expected_a, expected_b) in [ - (initial_x, Some(41.0), None), - (initial_x, Some(41.0), Some(82.0)), - (initial_x + 20.0, Some(41.0), Some(82.0)), - (initial_x + 40.0, Some(41.0), Some(82.0)), - ] { - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation(HistoryDirection::Redo, &redo_id, &counters, || {}) - .unwrap(); - assert_eq!( - store.editor_get().document.key_positions["4key"][0].dx, - expected_x - ); - assert_eq!( - store.plugin_instances_get("plugin-a").unwrap().0, - expected_a - .map(|x| vec![saved_plugin_instance(x)]) - .unwrap_or_default() - ); - assert_eq!( - store.plugin_instances_get("plugin-b").unwrap().0, - expected_b - .map(|x| vec![saved_plugin_instance(x)]) - .unwrap_or_default() - ); - drop(barrier); - } - assert!(!store.history_status().can_redo); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn merged_aliases_do_not_cross_an_intervening_same_plugin_edit() { - let dir = test_directory("intervening-same-plugin-alias-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial_x = store.editor_get().document.key_positions["4key"][0].dx; - let first_gesture = uuid::Uuid::new_v4().to_string(); - let intervening_gesture = uuid::Uuid::new_v4().to_string(); - let last_gesture = uuid::Uuid::new_v4().to_string(); - - for (x, gesture_id) in [ - (10.0, first_gesture.clone()), - (20.0, intervening_gesture), - (30.0, last_gesture.clone()), - ] { - let plugin = store - .commit_plugin_instances(plugin_instances_request( - "demo-plugin", - vec![saved_plugin_instance(x)], - uuid::Uuid::new_v4().to_string(), - Some(gesture_id), - Some(store.plugin_model_revision()), - )) - .unwrap(); - drop(plugin); - } - - let mut editor = editor_request( - store.editor_get().revision, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, initial_x + 60.0), - ); - editor.gesture_id = Some(last_gesture.clone()); - editor.gesture_ids = vec![first_gesture, last_gesture]; - store.commit_editor_document(editor).unwrap(); - - let gate = store.history_gate(); - let counters = store.snapshot().key_counters; - for expected_plugin_x in [Some(20.0), Some(10.0), None] { - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) - .unwrap(); - let expected = expected_plugin_x - .map(|x| vec![saved_plugin_instance(x)]) - .unwrap_or_default(); - assert_eq!( - store.plugin_instances_get("demo-plugin").unwrap().0, - expected - ); - drop(barrier); - } - assert_eq!( - store.editor_get().document.key_positions["4key"][0].dx, - initial_x - ); - assert!(!store.history_status().can_undo); - - for expected_plugin_x in [10.0, 20.0, 30.0] { - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation(HistoryDirection::Redo, &redo_id, &counters, || {}) - .unwrap(); - assert_eq!( - store.plugin_instances_get("demo-plugin").unwrap().0, - vec![saved_plugin_instance(expected_plugin_x)] - ); - drop(barrier); - } - assert_eq!( - store.editor_get().document.key_positions["4key"][0].dx, - initial_x + 60.0 - ); - assert!(!store.history_status().can_redo); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn delayed_plugin_commit_preserves_later_editor_undo_order() { - let dir = test_directory("delayed-compound-history-order-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial_x = store.editor_get().document.key_positions["4key"][0].dx; - let gesture_id = uuid::Uuid::new_v4().to_string(); - - let mut mixed_editor = editor_request( - store.editor_get().revision, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, initial_x + 40.0), - ); - mixed_editor.gesture_id = Some(gesture_id.clone()); - store.commit_editor_document(mixed_editor).unwrap(); - - let later_editor = editor_request( - store.editor_get().revision, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, initial_x + 80.0), - ); - store.commit_editor_document(later_editor).unwrap(); - - let plugin = store - .commit_plugin_instances(plugin_instances_request( - "demo-plugin", - vec![saved_plugin_instance(42.0)], - uuid::Uuid::new_v4().to_string(), - Some(gesture_id), - Some(store.plugin_model_revision()), - )) - .unwrap(); - drop(plugin); - - let gate = store.history_gate(); - let counters = store.snapshot().key_counters; - let first_undo_id = uuid::Uuid::new_v4().to_string(); - let first_barrier = gate.close(&first_undo_id).unwrap(); - store - .apply_history_operation(HistoryDirection::Undo, &first_undo_id, &counters, || {}) - .unwrap(); - assert_eq!( - store.editor_get().document.key_positions["4key"][0].dx, - initial_x + 40.0 - ); - assert_eq!( - store.plugin_instances_get("demo-plugin").unwrap().0, - vec![saved_plugin_instance(42.0)] - ); - drop(first_barrier); - - let second_undo_id = uuid::Uuid::new_v4().to_string(); - let second_barrier = gate.close(&second_undo_id).unwrap(); - store - .apply_history_operation(HistoryDirection::Undo, &second_undo_id, &counters, || {}) - .unwrap(); - assert_eq!( - store.editor_get().document.key_positions["4key"][0].dx, - initial_x - ); - assert!(store - .plugin_instances_get("demo-plugin") - .unwrap() - .0 - .is_empty()); - drop(second_barrier); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn repeated_editor_gesture_keeps_an_intervening_plugin_undo_in_order() { - let dir = test_directory("repeated-editor-gesture-order-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial_x = store.editor_get().document.key_positions["4key"][0].dx; - let gesture_id = uuid::Uuid::new_v4().to_string(); - - let mut first_editor = editor_request( - store.editor_get().revision, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, initial_x + 40.0), - ); - first_editor.gesture_id = Some(gesture_id.clone()); - store.commit_editor_document(first_editor).unwrap(); - let plugin = store - .commit_plugin_instances(plugin_instances_request( - "demo-plugin", - vec![saved_plugin_instance(42.0)], - uuid::Uuid::new_v4().to_string(), - None, - Some(store.plugin_model_revision()), - )) - .unwrap(); - drop(plugin); - let mut repeated_editor = editor_request( - store.editor_get().revision, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, initial_x + 80.0), - ); - repeated_editor.gesture_id = Some(gesture_id); - store.commit_editor_document(repeated_editor).unwrap(); - - let gate = store.history_gate(); - let counters = store.snapshot().key_counters; - let first_undo_id = uuid::Uuid::new_v4().to_string(); - let first_barrier = gate.close(&first_undo_id).unwrap(); - store - .apply_history_operation(HistoryDirection::Undo, &first_undo_id, &counters, || {}) - .unwrap(); - assert_eq!( - store.editor_get().document.key_positions["4key"][0].dx, - initial_x + 40.0 - ); - assert_eq!( - store.plugin_instances_get("demo-plugin").unwrap().0, - vec![saved_plugin_instance(42.0)] - ); - drop(first_barrier); - - let second_undo_id = uuid::Uuid::new_v4().to_string(); - let second_barrier = gate.close(&second_undo_id).unwrap(); - store - .apply_history_operation(HistoryDirection::Undo, &second_undo_id, &counters, || {}) - .unwrap(); - assert_eq!( - store.editor_get().document.key_positions["4key"][0].dx, - initial_x + 40.0 - ); - assert!(store - .plugin_instances_get("demo-plugin") - .unwrap() - .0 - .is_empty()); - drop(second_barrier); - - let third_undo_id = uuid::Uuid::new_v4().to_string(); - let third_barrier = gate.close(&third_undo_id).unwrap(); - store - .apply_history_operation(HistoryDirection::Undo, &third_undo_id, &counters, || {}) - .unwrap(); - assert_eq!( - store.editor_get().document.key_positions["4key"][0].dx, - initial_x - ); - drop(third_barrier); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn plugin_first_compound_undo_restores_both_scopes() { - let dir = test_directory("plugin-first-compound-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial_x = store.editor_get().document.key_positions["4key"][0].dx; - let gesture_id = uuid::Uuid::new_v4().to_string(); - - let plugin = store - .commit_plugin_instances(plugin_instances_request( - "demo-plugin", - vec![saved_plugin_instance(91.0)], - uuid::Uuid::new_v4().to_string(), - Some(gesture_id.clone()), - Some(store.plugin_model_revision()), - )) - .unwrap(); - drop(plugin); - let mut editor = editor_request( - store.editor_get().revision, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, initial_x + 35.0), - ); - editor.gesture_id = Some(gesture_id); - store.commit_editor_document(editor).unwrap(); - - let gate = store.history_gate(); - let counters = store.snapshot().key_counters; - let undo_id = uuid::Uuid::new_v4().to_string(); - let undo_barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) - .unwrap(); - assert_eq!( - store.editor_get().document.key_positions["4key"][0].dx, - initial_x - ); - assert!(store - .plugin_instances_get("demo-plugin") - .unwrap() - .0 - .is_empty()); - drop(undo_barrier); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn early_plugin_commit_and_gesture_commit_share_one_undo_step() { - let dir = test_directory("early-plugin-gesture-merge-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial_x = store.editor_get().document.key_positions["4key"][0].dx; - let gesture_id = uuid::Uuid::new_v4().to_string(); - - // 디바운스 plugin 커밋이 gesture 커밋보다 먼저 착지한 순서 - let plugin = store - .commit_plugin_instances(plugin_instances_request( - "demo-plugin", - vec![saved_plugin_instance(10.0)], - uuid::Uuid::new_v4().to_string(), - Some(gesture_id.clone()), - Some(store.plugin_model_revision()), - )) - .unwrap(); - drop(plugin); - let committed = store - .commit_gesture(gesture_request( - &store, - gesture_id, - position_patch(&store, initial_x + 40.0), - "demo-plugin", - vec![saved_plugin_instance(10.0)], - )) - .unwrap(); - drop(committed); - assert_eq!( - store.editor_get().document.key_positions["4key"][0].dx, - initial_x + 40.0 - ); - assert_eq!( - store.plugin_instances_get("demo-plugin").unwrap().0, - vec![saved_plugin_instance(10.0)] - ); - - let gate = store.history_gate(); - let counters = store.snapshot().key_counters; - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) - .unwrap(); - drop(barrier); - assert_eq!( - store.editor_get().document.key_positions["4key"][0].dx, - initial_x - ); - assert!(store - .plugin_instances_get("demo-plugin") - .unwrap() - .0 - .is_empty()); - assert!(!store.history_status().can_undo); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation(HistoryDirection::Redo, &redo_id, &counters, || {}) - .unwrap(); - drop(barrier); - assert_eq!( - store.editor_get().document.key_positions["4key"][0].dx, - initial_x + 40.0 - ); - assert_eq!( - store.plugin_instances_get("demo-plugin").unwrap().0, - vec![saved_plugin_instance(10.0)] - ); - assert!(!store.history_status().can_redo); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn early_plugin_commit_with_a_different_gesture_stays_a_separate_undo_step() { - let dir = test_directory("early-plugin-other-gesture-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial_x = store.editor_get().document.key_positions["4key"][0].dx; - - let plugin = store - .commit_plugin_instances(plugin_instances_request( - "demo-plugin", - vec![saved_plugin_instance(10.0)], - uuid::Uuid::new_v4().to_string(), - Some(uuid::Uuid::new_v4().to_string()), - Some(store.plugin_model_revision()), - )) - .unwrap(); - drop(plugin); - let committed = store - .commit_gesture(gesture_request( - &store, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, initial_x + 40.0), - "demo-plugin", - vec![saved_plugin_instance(10.0)], - )) - .unwrap(); - drop(committed); - - let gate = store.history_gate(); - let counters = store.snapshot().key_counters; - let first_undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&first_undo_id).unwrap(); - store - .apply_history_operation(HistoryDirection::Undo, &first_undo_id, &counters, || {}) - .unwrap(); - drop(barrier); - assert_eq!( - store.editor_get().document.key_positions["4key"][0].dx, - initial_x - ); - assert_eq!( - store.plugin_instances_get("demo-plugin").unwrap().0, - vec![saved_plugin_instance(10.0)] - ); - - let second_undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&second_undo_id).unwrap(); - store - .apply_history_operation(HistoryDirection::Undo, &second_undo_id, &counters, || {}) - .unwrap(); - drop(barrier); - assert!(store - .plugin_instances_get("demo-plugin") - .unwrap() - .0 - .is_empty()); - assert!(!store.history_status().can_undo); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn gesture_merge_with_a_reverted_plugin_state_keeps_one_undo_step() { - let dir = test_directory("gesture-merge-reverted-plugin-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial_x = store.editor_get().document.key_positions["4key"][0].dx; - let gesture_id = uuid::Uuid::new_v4().to_string(); - - let plugin = store - .commit_plugin_instances(plugin_instances_request( - "demo-plugin", - vec![saved_plugin_instance(10.0)], - uuid::Uuid::new_v4().to_string(), - Some(gesture_id.clone()), - Some(store.plugin_model_revision()), - )) - .unwrap(); - drop(plugin); - // gesture 커밋이 plugin을 선행 커밋 이전 상태로 되돌리는 net-zero 조합 - let committed = store - .commit_gesture(gesture_request( - &store, - gesture_id, - position_patch(&store, initial_x + 40.0), - "demo-plugin", - Vec::new(), - )) - .unwrap(); - drop(committed); - assert!(store - .plugin_instances_get("demo-plugin") - .unwrap() - .0 - .is_empty()); - - let gate = store.history_gate(); - let counters = store.snapshot().key_counters; - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) - .unwrap(); - drop(barrier); - assert_eq!( - store.editor_get().document.key_positions["4key"][0].dx, - initial_x - ); - assert!(store - .plugin_instances_get("demo-plugin") - .unwrap() - .0 - .is_empty()); - assert!(!store.history_status().can_undo); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn compound_history_persist_failure_changes_neither_scope() { - let dir = test_directory("compound-history-persist-failure-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial_x = store.editor_get().document.key_positions["4key"][0].dx; - let changed_x = initial_x + 55.0; - let gesture_id = uuid::Uuid::new_v4().to_string(); - - let mut editor = editor_request( - store.editor_get().revision, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, changed_x), - ); - editor.gesture_id = Some(gesture_id.clone()); - store.commit_editor_document(editor).unwrap(); - let plugin = store - .commit_plugin_instances(plugin_instances_request( - "demo-plugin", - vec![saved_plugin_instance(66.0)], - uuid::Uuid::new_v4().to_string(), - Some(gesture_id), - Some(store.plugin_model_revision()), - )) - .unwrap(); - drop(plugin); - - let gate = store.history_gate(); - let counters = store.snapshot().key_counters; - let undo_id = uuid::Uuid::new_v4().to_string(); - let undo_barrier = gate.close(&undo_id).unwrap(); - store.writer.fail_next_persist(); - assert!(store - .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) - .is_err()); - assert_eq!( - store.editor_get().document.key_positions["4key"][0].dx, - changed_x - ); - assert_eq!( - store.plugin_instances_get("demo-plugin").unwrap().0, - vec![saved_plugin_instance(66.0)] - ); - assert!(store.history_status().can_undo); - drop(undo_barrier); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn plugin_instances_commit_revalidates_admission_after_store_lock_wait() { - let dir = test_directory("plugin-instances-admission-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = Arc::new(AppStore::initialize_in_dir(&dir).unwrap()); - let expected_revision = store.plugin_model_revision(); - let request = plugin_instances_request( - "demo-plugin", - vec![saved_plugin_instance(10.0)], - uuid::Uuid::new_v4().to_string(), - None, - Some(expected_revision), - ); - let store_guard = store.state.write(); - let commit_store = Arc::clone(&store); - let commit = thread::spawn(move || commit_store.commit_plugin_instances(request)); - let gate = store.history_gate(); - let deadline = Instant::now() + Duration::from_secs(2); - while gate.active_mutations() == 0 { - assert!( - Instant::now() < deadline, - "plugin mutation was not admitted" - ); - thread::yield_now(); - } - - let barrier_gate = Arc::clone(&gate); - let operation_id = uuid::Uuid::new_v4().to_string(); - let (barrier_ready_tx, barrier_ready_rx) = mpsc::channel(); - let (release_barrier_tx, release_barrier_rx) = mpsc::channel(); - let barrier = thread::spawn(move || { - let lease = barrier_gate.close(&operation_id).unwrap(); - barrier_ready_tx.send(()).unwrap(); - release_barrier_rx.recv().unwrap(); - drop(lease); - }); - let deadline = Instant::now() + Duration::from_secs(2); - while !gate.is_closed() { - assert!(Instant::now() < deadline, "history gate did not close"); - thread::yield_now(); - } - - drop(store_guard); - assert_eq!(commit.join().unwrap().unwrap_err(), HISTORY_IN_PROGRESS); - barrier_ready_rx - .recv_timeout(Duration::from_secs(2)) - .unwrap(); - release_barrier_tx.send(()).unwrap(); - barrier.join().unwrap(); - assert!(store - .plugin_instances_get("demo-plugin") - .unwrap() - .0 - .is_empty()); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn plugin_instances_net_zero_gesture_removes_merged_entry() { - let dir = test_directory("plugin-instances-net-zero-gesture-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let gesture_id = uuid::Uuid::new_v4().to_string(); - let expected_revision = store.plugin_model_revision(); - let first = store - .commit_plugin_instances(plugin_instances_request( - "demo-plugin", - vec![saved_plugin_instance(10.0)], - uuid::Uuid::new_v4().to_string(), - Some(gesture_id.clone()), - Some(expected_revision), - )) - .unwrap(); - let next_revision = first.outcome.model_revision; - drop(first); - - let second = store - .commit_plugin_instances(plugin_instances_request( - "demo-plugin", - Vec::new(), - uuid::Uuid::new_v4().to_string(), - Some(gesture_id), - Some(next_revision), - )) - .unwrap(); - assert!(!second.outcome.history_status.as_ref().unwrap().can_undo); - drop(second); - assert!(store - .plugin_instances_get("demo-plugin") - .unwrap() - .0 - .is_empty()); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn mixed_net_zero_gesture_removes_compound_entry() { - let dir = test_directory("compound-net-zero-gesture-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial_x = store.editor_get().document.key_positions["4key"][0].dx; - let gesture_id = uuid::Uuid::new_v4().to_string(); - - let mut editor_out = editor_request( - store.editor_get().revision, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, initial_x + 25.0), - ); - editor_out.gesture_id = Some(gesture_id.clone()); - store.commit_editor_document(editor_out).unwrap(); - let plugin_out = store - .commit_plugin_instances(plugin_instances_request( - "demo-plugin", - vec![saved_plugin_instance(25.0)], - uuid::Uuid::new_v4().to_string(), - Some(gesture_id.clone()), - Some(store.plugin_model_revision()), - )) - .unwrap(); - drop(plugin_out); - - let mut editor_back = editor_request( - store.editor_get().revision, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, initial_x), - ); - editor_back.gesture_id = Some(gesture_id.clone()); - store.commit_editor_document(editor_back).unwrap(); - let plugin_back = store - .commit_plugin_instances(plugin_instances_request( - "demo-plugin", - Vec::new(), - uuid::Uuid::new_v4().to_string(), - Some(gesture_id), - Some(store.plugin_model_revision()), - )) - .unwrap(); - assert!( - !plugin_back - .outcome - .history_status - .as_ref() - .unwrap() - .can_undo - ); - drop(plugin_back); - - assert_eq!( - store.editor_get().document.key_positions["4key"][0].dx, - initial_x - ); - assert!(store - .plugin_instances_get("demo-plugin") - .unwrap() - .0 - .is_empty()); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn plugin_instances_reconcile_is_noop_when_canonical_tabs_are_valid() { - let dir = test_directory("plugin-instances-reconcile-noop-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let committed = store - .commit_plugin_instances(plugin_instances_request( - "demo-plugin", - vec![saved_plugin_instance(10.0)], - uuid::Uuid::new_v4().to_string(), - None, - Some(store.plugin_model_revision()), - )) - .unwrap(); - drop(committed); - let revision = store.plugin_model_revision(); - let history_revision = store.history_status().history_revision; - - let reconciled = store - .reconcile_plugin_instances(plugin_instances_reconcile_request( - "demo-plugin", - &["4key", "5key"], - uuid::Uuid::new_v4().to_string(), - )) - .unwrap(); - - assert!(!reconciled.outcome.changed); - assert!(reconciled.outcome.history_status.is_none()); - assert_eq!(reconciled.outcome.model_revision, revision); - drop(reconciled); - assert_eq!(store.history_status().history_revision, history_revision); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn plugin_instances_reconcile_records_and_round_trips_history() { - let dir = test_directory("plugin-instances-reconcile-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let mut default_tab = saved_plugin_instance(10.0); - default_tab.tab_id = None; - default_tab.hidden = true; - default_tab.z_index = Some(17.0); - let mut stale_tab = saved_plugin_instance(30.0); - stale_tab.tab_id = Some("deleted-tab".to_string()); - let before = vec![default_tab, stale_tab]; - let committed = store - .commit_plugin_instances(plugin_instances_request( - "demo-plugin", - before.clone(), - uuid::Uuid::new_v4().to_string(), - None, - Some(store.plugin_model_revision()), - )) - .unwrap(); - drop(committed); - - let request = plugin_instances_reconcile_request( - "demo-plugin", - &["4key"], - uuid::Uuid::new_v4().to_string(), - ); - let reconciled = store.reconcile_plugin_instances(request).unwrap(); - assert!(reconciled.outcome.changed); - assert!(reconciled.outcome.history_status.is_some()); - drop(reconciled); - let mut normalized = saved_plugin_instance(10.0); - normalized.tab_id = Some("4key".to_string()); - normalized.hidden = true; - normalized.z_index = Some(17.0); - assert_eq!( - store.plugin_instances_get("demo-plugin").unwrap().0, - vec![normalized.clone()] - ); - - let gate = store.history_gate(); - let counters = store.snapshot().key_counters; - let undo_id = uuid::Uuid::new_v4().to_string(); - let undo_barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) - .unwrap(); - drop(undo_barrier); - assert_eq!(store.plugin_instances_get("demo-plugin").unwrap().0, before); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let redo_barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation(HistoryDirection::Redo, &redo_id, &counters, || {}) - .unwrap(); - drop(redo_barrier); - assert_eq!( - store.plugin_instances_get("demo-plugin").unwrap().0, - vec![normalized] - ); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn plugin_instances_reconcile_rejects_stale_history_epoch_after_undo() { - let dir = test_directory("plugin-instances-reconcile-history-epoch-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let restored_instances = vec![saved_plugin_instance(10.0)]; - let first = store - .commit_plugin_instances(plugin_instances_request( - "demo-plugin", - restored_instances.clone(), - uuid::Uuid::new_v4().to_string(), - None, - Some(store.plugin_model_revision()), - )) - .unwrap(); - drop(first); - - let mut replacement = saved_plugin_instance(30.0); - replacement.tab_id = Some("5key".to_string()); - let second = store - .commit_plugin_instances(plugin_instances_request( - "demo-plugin", - vec![replacement], - uuid::Uuid::new_v4().to_string(), - None, - Some(store.plugin_model_revision()), - )) - .unwrap(); - drop(second); - let stale_epoch = store.history_status().history_epoch; - - let operation_id = uuid::Uuid::new_v4().to_string(); - let gate = store.history_gate(); - let barrier = gate.close(&operation_id).unwrap(); - let counters = store.snapshot().key_counters; - let undo = store - .apply_history_operation(HistoryDirection::Undo, &operation_id, &counters, || {}) - .unwrap(); - drop(barrier); - assert_ne!(undo.status.history_epoch, stale_epoch); - assert_eq!( - store.plugin_instances_get("demo-plugin").unwrap().0, - restored_instances - ); - - let before_revision = store.plugin_model_revision(); - let before_status = store.history_status(); - let mutation_id = uuid::Uuid::new_v4().to_string(); - let mut request = plugin_instances_reconcile_request("demo-plugin", &["5key"], mutation_id); - request.observed_history_epoch = Some(stale_epoch); - assert_eq!( - store.reconcile_plugin_instances(request).unwrap_err(), - "HISTORY_EPOCH_CONFLICT" - ); - assert_eq!(store.plugin_model_revision(), before_revision); - let after_status = store.history_status(); - assert_eq!( - after_status.history_revision, - before_status.history_revision - ); - assert_eq!(after_status.history_epoch, before_status.history_epoch); - assert_eq!(after_status.can_undo, before_status.can_undo); - assert_eq!(after_status.can_redo, before_status.can_redo); - assert_eq!(after_status.busy, before_status.busy); - assert_eq!(after_status.truncated, before_status.truncated); - assert!(after_status.status_seq > before_status.status_seq); - assert_eq!( - store.plugin_instances_get("demo-plugin").unwrap().0, - restored_instances - ); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn plugin_instances_reconcile_cannot_resurrect_after_bulk_clear() { - let dir = test_directory("plugin-instances-reconcile-clear-race-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = Arc::new(AppStore::initialize_in_dir(&dir).unwrap()); - let committed = store - .commit_plugin_instances(plugin_instances_request( - "demo-plugin", - vec![saved_plugin_instance(10.0)], - uuid::Uuid::new_v4().to_string(), - None, - Some(store.plugin_model_revision()), - )) - .unwrap(); - drop(committed); - let stale_snapshot = store.plugin_instances_get("demo-plugin").unwrap().0; - assert!(!stale_snapshot.is_empty()); - - let store_guard = store.state.write(); - let reconcile_store = Arc::clone(&store); - let reconcile = thread::spawn(move || { - let result = - reconcile_store.reconcile_plugin_instances(plugin_instances_reconcile_request( - "demo-plugin", - &["4key"], - uuid::Uuid::new_v4().to_string(), - )); - result.map(|outcome| outcome.outcome.changed) - }); - let clear_store = Arc::clone(&store); - let clear = thread::spawn(move || { - let result = clear_store.remove_plugin_data_by_prefix("plugin_data_demo-plugin/"); - result.map(|outcome| outcome.value) - }); - - let gate = store.history_gate(); - let deadline = Instant::now() + Duration::from_secs(2); - while gate.active_mutations() < 2 { - assert!( - Instant::now() < deadline, - "plugin mutations were not admitted" - ); - thread::yield_now(); - } - drop(store_guard); - - reconcile.join().unwrap().unwrap(); - assert_eq!(clear.join().unwrap().unwrap(), 1); - assert!(store - .plugin_instances_get("demo-plugin") - .unwrap() - .0 - .is_empty()); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn generic_instance_storage_set_and_remove_are_reserved() { - let dir = test_directory("plugin-instances-reserved-key-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let committed = store - .commit_plugin_instances(plugin_instances_request( - "demo-plugin", - vec![saved_plugin_instance(10.0)], - uuid::Uuid::new_v4().to_string(), - None, - Some(store.plugin_model_revision()), - )) - .unwrap(); - drop(committed); - - let gate = store.history_gate(); - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - let counters = store.snapshot().key_counters; - store - .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) - .unwrap(); - drop(barrier); - assert!(store.history_status().can_redo); - let revision = store.plugin_model_revision(); - - let set_error = store - .set_plugin_data( - "plugin_data_demo-plugin/instances", - serde_json::to_value(vec![saved_plugin_instance(90.0)]).unwrap(), - ) - .unwrap_err(); - let remove_error = store - .remove_plugin_data("plugin_data_demo-plugin/instances") - .unwrap_err(); - - assert_eq!(set_error.to_string(), "PLUGIN_INSTANCES_KEY_RESERVED"); - assert_eq!(remove_error.to_string(), "PLUGIN_INSTANCES_KEY_RESERVED"); - assert_eq!(store.plugin_model_revision(), revision); - assert!(store.history_status().can_redo); - assert!(store - .plugin_instances_get("demo-plugin") - .unwrap() - .0 - .is_empty()); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn plugin_namespace_clear_invalidates_history_after_instances_are_already_empty() { - let dir = test_directory("plugin-empty-instances-history-clear-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - - for plugin_id in ["alpha", "beta"] { - drop( - store - .commit_plugin_instances(plugin_instances_request( - plugin_id, - vec![saved_plugin_instance(10.0)], - uuid::Uuid::new_v4().to_string(), - None, - Some(store.plugin_model_revision()), - )) - .unwrap(), - ); - drop( - store - .commit_plugin_instances(plugin_instances_request( - plugin_id, - Vec::new(), - uuid::Uuid::new_v4().to_string(), - None, - Some(store.plugin_model_revision()), - )) - .unwrap(), - ); - assert!(store.history_status().can_undo); - assert!(store.plugin_instances_get(plugin_id).unwrap().0.is_empty()); - - if plugin_id == "alpha" { - drop( - store - .set_plugin_data("plugin_data_alpha/cache/item", json!(true)) - .unwrap(), - ); - let nested = store - .remove_plugin_data_by_prefix("plugin_data_alpha/cache/") - .unwrap(); - assert_eq!(nested.value, 1); - assert!(nested.history_status.is_none()); - assert!(store.history_status().can_undo); - - drop( - store - .set_plugin_data("plugin_data_alpha/preferences", json!(true)) - .unwrap(), - ); - let namespace = store - .remove_plugin_data_by_prefix("plugin_data_alpha/") - .unwrap(); - assert_eq!(namespace.value, 1); - let status = namespace.history_status.unwrap(); - assert!(!status.can_undo); - assert!(!status.can_redo); - } else { - assert!(store.snapshot().plugin_data.is_empty()); - let clear = store.clear_all_plugin_data().unwrap(); - let status = clear.history_status.unwrap(); - assert!(!status.can_undo); - assert!(!status.can_redo); - } - } - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn bulk_plugin_storage_deletion_reports_each_instance_revision() { - let dir = test_directory("plugin-instances-bulk-delete-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - - for plugin_id in ["alpha", "beta"] { - let committed = store - .commit_plugin_instances(plugin_instances_request( - plugin_id, - vec![saved_plugin_instance(10.0)], - uuid::Uuid::new_v4().to_string(), - None, - Some(store.plugin_model_revision()), - )) - .unwrap(); - drop(committed); - } - drop( - store - .set_plugin_data("plugin_data_alpha/preferences", json!({ "enabled": true })) - .unwrap(), - ); - - let gate = store.history_gate(); - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - let counters = store.snapshot().key_counters; - store - .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) - .unwrap(); - drop(barrier); - assert!(store.history_status().can_redo); - - let prefix_revision = store.plugin_model_revision() + 1; - let prefix_delete = store - .remove_plugin_data_by_prefix("plugin_data_alpha/") - .unwrap(); - assert_eq!(prefix_delete.value, 2); - assert!(!prefix_delete.history_status.as_ref().unwrap().can_undo); - assert!(!prefix_delete.history_status.as_ref().unwrap().can_redo); - assert_eq!( - prefix_delete.plugin_instances_changes, - vec![super::PluginInstancesStorageChange { - plugin_id: "alpha".to_string(), - revision: prefix_revision, - }] - ); - drop(prefix_delete); - assert!(store.plugin_instances_get("alpha").unwrap().0.is_empty()); - assert!(store.plugin_instances_get("beta").unwrap().0.is_empty()); - - for plugin_id in ["beta", "gamma"] { - let committed = store - .commit_plugin_instances(plugin_instances_request( - plugin_id, - vec![saved_plugin_instance(20.0)], - uuid::Uuid::new_v4().to_string(), - None, - Some(store.plugin_model_revision()), - )) - .unwrap(); - drop(committed); - } - - let clear_revision = store.plugin_model_revision() + 1; - let clear = store.clear_all_plugin_data().unwrap(); - assert!(!clear.history_status.as_ref().unwrap().can_undo); - assert!(!clear.history_status.as_ref().unwrap().can_redo); - assert_eq!( - clear.plugin_instances_changes, - vec![ - super::PluginInstancesStorageChange { - plugin_id: "beta".to_string(), - revision: clear_revision, - }, - super::PluginInstancesStorageChange { - plugin_id: "gamma".to_string(), - revision: clear_revision, - }, - ] - ); - drop(clear); - assert_eq!(store.plugin_model_revision(), clear_revision); - assert!(store.plugin_instances_get("beta").unwrap().0.is_empty()); - assert!(store.plugin_instances_get("gamma").unwrap().0.is_empty()); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn strict_editor_commit_persists_once_and_synchronizes_key_counters() { - let dir = test_directory("strict-editor-success-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = initialize_neutral_editor_store(&dir); - let persist_count = store.writer.persist_count(); - let before = store.snapshot(); - let old_key = before.keys["4key"][0].canonical(); - let mut keys = before.keys.clone(); - keys.get_mut("4key").unwrap()[0] = "StrictKey".into(); - let first_gesture_id = uuid::Uuid::new_v4().to_string(); - let gesture_id = uuid::Uuid::new_v4().to_string(); - let mut request = editor_request( - 0, - uuid::Uuid::new_v4().to_string(), - EditorPatchV1 { - keys: Some(keys), - ..EditorPatchV1::default() - }, - ); - request.gesture_id = Some(gesture_id.clone()); - request.gesture_ids = vec![ - first_gesture_id.clone(), - first_gesture_id.clone(), - gesture_id.clone(), - ]; - - let change = store.commit_editor_document(request).unwrap(); - - assert_eq!(change.result.revision, 1); - assert_eq!(change.result.changed_fields, vec![EditorField::Keys]); - assert_eq!(change.event.as_ref().unwrap().origin, "editorCommit"); - assert_eq!( - change.event.as_ref().unwrap().gesture_id.as_deref(), - Some(gesture_id.as_str()) - ); - assert_eq!( - change.event.as_ref().unwrap().gesture_ids, - vec![first_gesture_id, gesture_id] - ); - assert_eq!(store.writer.persist_count(), persist_count + 1); - let snapshot = store.snapshot(); - assert_eq!(snapshot.editor_revision, 1); - assert_eq!(snapshot.keys["4key"][0], KeySlot::from("StrictKey")); - assert_eq!(snapshot.key_counters["4key"]["StrictKey"], 0); - assert!(!snapshot.key_counters["4key"].contains_key(&old_key)); - assert_eq!(change.history_status.unwrap().history_revision, 1); - assert!(store.history_status().can_undo); - - store.flush_and_shutdown().unwrap(); - drop(store); - let reloaded = crate::state::migration::load_store_from_path(&dir.join("store.json")) - .unwrap() - .data; - assert_eq!(reloaded.editor_revision, 1); - assert_eq!(reloaded.keys["4key"][0], KeySlot::from("StrictKey")); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn fresh_install_has_globally_unique_native_element_ids() { - let dir = test_directory("fresh-native-element-ids-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let document = store.editor_get().document; - - crate::state::native_element_id::validate_document_element_ids(&document).unwrap(); - assert!(document - .key_positions - .values() - .flatten() - .all(|position| uuid::Uuid::parse_str(&position.id) - .is_ok_and(|id| id.get_version_num() == 4))); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn legacy_position_replacements_reject_noncanonical_ids_atomically() { - enum PositionReplacement { - Stat(crate::models::StatPositions), - Graph(crate::models::GraphPositions), - Knob(crate::models::KnobPositions), - } - - fn with_raw_position_id( - mut payload: serde_json::Value, - id: Option<&str>, - ) -> serde_json::Value { - let position = payload["4key"][0] - .as_object_mut() - .expect("serialized position object"); - match id { - Some(id) => { - position.insert("id".to_string(), serde_json::json!(id)); - } - None => { - position.remove("id"); - } - } - payload - } - - let dir = test_directory("legacy-position-id-validation-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let baseline = store.editor_get(); - let baseline_snapshot = store.snapshot(); - let baseline_persist_count = store.writer.persist_count(); - let duplicate_id = baseline.document.key_positions["4key"][0].id.clone(); - - let stat_payload = serde_json::to_value(HashMap::from([( - "4key".to_string(), - vec![StatPosition { - stat_type: StatType::Kps, - position: KeyPosition { - id: uuid::Uuid::new_v4().to_string(), - ..KeyPosition::default() - }, - }], - )])) - .unwrap(); - let graph_payload = serde_json::to_value(HashMap::from([( - "4key".to_string(), - vec![GraphPosition { - stat_type: GraphStatType::Kps, - graph_type: GraphType::Line, - graph_speed: 1000, - graph_color: "#ffffff".to_string(), - show_avg_line: true, - position: KeyPosition { - id: uuid::Uuid::new_v4().to_string(), - ..KeyPosition::default() - }, - }], - )])) - .unwrap(); - let knob_payload = serde_json::to_value(HashMap::from([( - "4key".to_string(), - vec![KnobPosition { - axis_id: String::new(), - sensitivity: 1.0, - reverse: false, - position: KeyPosition { - id: uuid::Uuid::new_v4().to_string(), - ..KeyPosition::default() - }, - }], - )])) - .unwrap(); - - let invalid_cases = vec![ - ( - EditorField::StatPositions, - "MISSING_ELEMENT_ID", - PositionReplacement::Stat( - serde_json::from_value(with_raw_position_id(stat_payload.clone(), None)) - .unwrap(), - ), - ), - ( - EditorField::GraphPositions, - "INVALID_ELEMENT_ID", - PositionReplacement::Graph( - serde_json::from_value(with_raw_position_id( - graph_payload.clone(), - Some("not-a-uuid"), - )) - .unwrap(), - ), - ), - ( - EditorField::KnobPositions, - "INVALID_ELEMENT_ID", - PositionReplacement::Knob( - serde_json::from_value(with_raw_position_id( - knob_payload, - Some(&uuid::Uuid::nil().to_string()), - )) - .unwrap(), - ), - ), - ( - EditorField::StatPositions, - "DUPLICATE_ELEMENT_ID", - PositionReplacement::Stat( - serde_json::from_value(with_raw_position_id(stat_payload, Some(&duplicate_id))) - .unwrap(), - ), - ), - ]; - - for (field, expected_code, replacement) in invalid_cases { - let error = legacy_editor_commit(&store, &[field], move |data| match replacement { - PositionReplacement::Stat(positions) => data.stat_positions = positions, - PositionReplacement::Graph(positions) => data.graph_positions = positions, - PositionReplacement::Knob(positions) => data.knob_positions = positions, - }) - .unwrap_err(); - assert_eq!(error.error_code, EditorCommitErrorCode::ValidationFailed); - assert!(!error.retryable); - assert_eq!( - error.details.unwrap().validation_code.as_deref(), - Some(expected_code) - ); - assert_eq!(store.editor_get(), baseline); - assert_eq!(store.snapshot(), baseline_snapshot); - assert_eq!(store.writer.persist_count(), baseline_persist_count); - } - - let valid_stat_id = uuid::Uuid::new_v4().to_string(); - let valid_stat = legacy_editor_commit(&store, &[EditorField::StatPositions], |data| { - data.stat_positions.insert( - "4key".to_string(), - vec![StatPosition { - stat_type: StatType::Kps, - position: KeyPosition { - id: valid_stat_id.clone(), - ..KeyPosition::default() - }, - }], - ); - }) - .unwrap(); - assert_eq!( - valid_stat.result.changed_fields, - vec![EditorField::StatPositions] - ); - assert_eq!( - valid_stat.document.stat_positions["4key"][0].position.id, - valid_stat_id - ); - - let valid_graph_id = uuid::Uuid::new_v4().to_string(); - let valid_graph = legacy_editor_commit(&store, &[EditorField::GraphPositions], |data| { - data.graph_positions.insert( - "4key".to_string(), - vec![GraphPosition { - stat_type: GraphStatType::Kps, - graph_type: GraphType::Line, - graph_speed: 1000, - graph_color: "#ffffff".to_string(), - show_avg_line: true, - position: KeyPosition { - id: valid_graph_id.clone(), - ..KeyPosition::default() - }, - }], - ); - }) - .unwrap(); - assert_eq!( - valid_graph.result.changed_fields, - vec![EditorField::GraphPositions] - ); - assert_eq!( - valid_graph.document.graph_positions["4key"][0].position.id, - valid_graph_id - ); - - let valid_knob_id = uuid::Uuid::new_v4().to_string(); - let valid_knob = legacy_editor_commit(&store, &[EditorField::KnobPositions], |data| { - data.knob_positions.insert( - "4key".to_string(), - vec![KnobPosition { - axis_id: String::new(), - sensitivity: 1.0, - reverse: false, - position: KeyPosition { - id: valid_knob_id.clone(), - ..KeyPosition::default() - }, - }], - ); - }) - .unwrap(); - assert_eq!( - valid_knob.result.changed_fields, - vec![EditorField::KnobPositions] - ); - assert_eq!( - valid_knob.document.knob_positions["4key"][0].position.id, - valid_knob_id - ); - - crate::state::native_element_id::validate_document_element_ids( - &store.editor_get().document, - ) - .unwrap(); - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn alternate_uuid_spellings_remain_raw_distinct_through_load_bootstrap_and_event() { - let dir = test_directory("alternate-native-element-id-spellings-test"); - std::fs::create_dir_all(&dir).unwrap(); - let uuid = uuid::Uuid::new_v4(); - let ids = vec![ - uuid.simple().to_string(), - uuid.braced().to_string(), - uuid.urn().to_string(), - uuid.hyphenated().to_string().to_uppercase(), - ]; - let mut data = initialize_default_state(); - for (position, id) in data - .key_positions - .get_mut("4key") - .unwrap() - .iter_mut() - .zip(&ids) - { - position.id.clone_from(id); - position.hidden = false; - } - std::fs::write( - dir.join("store.json"), - serde_json::to_vec_pretty(&data).unwrap(), - ) - .unwrap(); - - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let loaded = store.editor_get(); - let loaded_ids = loaded.document.key_positions["4key"] - .iter() - .take(ids.len()) - .map(|position| position.id.clone()) - .collect::>(); - assert_eq!(loaded_ids, ids); - crate::state::native_element_id::validate_document_element_ids(&loaded.document).unwrap(); - - let state = AppState::initialize(store).unwrap(); - let bootstrap = state.bootstrap_payload(); - assert_eq!( - bootstrap.positions["4key"] - .iter() - .take(ids.len()) - .map(|position| position.id.clone()) - .collect::>(), - ids - ); - - let change = state - .store - .commit_editor_document(editor_ops_request( - bootstrap.editor_revision, - uuid::Uuid::new_v4().to_string(), - vec![patch_hidden_op(EditorElementTypeV1::Key, &ids[0], true)], - )) - .unwrap(); - assert_eq!( - change.document.key_positions["4key"] - .iter() - .take(ids.len()) - .map(|position| position.id.clone()) - .collect::>(), - ids - ); - assert!(change.document.key_positions["4key"][0].hidden); - assert!(change.document.key_positions["4key"][1..ids.len()] - .iter() - .all(|position| !position.hidden)); - let event_positions = change - .event - .as_ref() - .unwrap() - .patch - .key_positions - .as_ref() - .unwrap(); - assert_eq!( - event_positions["4key"] - .iter() - .take(ids.len()) - .map(|position| position.id.clone()) - .collect::>(), - ids - ); - - state.shutdown(); - drop(state); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn v2_editor_commit_rejects_invalid_ids_atomically_and_keeps_read_event_v1() { - let dir = test_directory("v2-native-element-id-commit-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - legacy_editor_commit(&store, &[EditorField::StatPositions], |data| { - data.stat_positions.insert( - "4key".to_string(), - vec![StatPosition { - stat_type: StatType::Kps, - position: KeyPosition { - id: uuid::Uuid::new_v4().to_string(), - ..KeyPosition::default() - }, - }], - ); - }) - .unwrap(); - let baseline = store.editor_get(); - let persist_count = store.writer.persist_count(); - - let mut invalid_patches = Vec::new(); - let mut missing = baseline.document.key_positions.clone(); - missing.get_mut("4key").unwrap()[0].id.clear(); - invalid_patches.push((missing, "MISSING_ELEMENT_ID")); - - let mut malformed = baseline.document.key_positions.clone(); - malformed.get_mut("4key").unwrap()[0].id = "not-a-uuid".to_string(); - invalid_patches.push((malformed, "INVALID_ELEMENT_ID")); - - let mut nil = baseline.document.key_positions.clone(); - nil.get_mut("4key").unwrap()[0].id = uuid::Uuid::nil().to_string(); - invalid_patches.push((nil, "INVALID_ELEMENT_ID")); - - let mut merged_duplicate = baseline.document.key_positions.clone(); - merged_duplicate.get_mut("4key").unwrap()[0].id = baseline.document.stat_positions["4key"] - [0] - .position - .id - .clone(); - invalid_patches.push((merged_duplicate, "DUPLICATE_ELEMENT_ID")); - - for (positions, expected_code) in invalid_patches { - let error = store - .commit_editor_document(editor_request( - baseline.revision, - uuid::Uuid::new_v4().to_string(), - EditorPatchV1 { - schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, - key_positions: Some(positions), - ..EditorPatchV1::default() - }, - )) - .unwrap_err(); - assert_eq!(error.error_code, EditorCommitErrorCode::ValidationFailed); - assert!(!error.retryable); - assert_eq!( - error.details.unwrap().validation_code.as_deref(), - Some(expected_code) - ); - assert_eq!(store.editor_get(), baseline); - assert_eq!(store.writer.persist_count(), persist_count); - } - - let mut valid = baseline.document.key_positions.clone(); - valid.get_mut("4key").unwrap()[0].dx += 1.0; - let change = store - .commit_editor_document(editor_request( - baseline.revision, - uuid::Uuid::new_v4().to_string(), - EditorPatchV1 { - schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, - key_positions: Some(valid), - ..EditorPatchV1::default() - }, - )) - .unwrap(); - - assert_eq!(change.document.schema_version, EDITOR_SCHEMA_VERSION); - assert_eq!( - change.event.as_ref().unwrap().schema_version, - EDITOR_SCHEMA_VERSION - ); - assert_eq!( - change.event.as_ref().unwrap().patch.schema_version, - EDITOR_SCHEMA_VERSION - ); - assert!(change - .document - .key_positions - .values() - .flatten() - .all(|position| !position.id.is_empty())); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn v1_stale_snapshot_commit_rekeys_deleted_element_instead_of_reviving_it() { - let dir = test_directory("v1-stale-native-element-id-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = initialize_neutral_editor_store(&dir); - let initial = store.editor_get(); - let stale_keys = initial.document.keys.clone(); - let stale_positions = initial.document.key_positions.clone(); - let deleted_id = stale_positions["4key"][0].id.clone(); - - let mut deleted_keys = stale_keys.clone(); - let mut deleted_positions = stale_positions.clone(); - deleted_keys.get_mut("4key").unwrap().remove(0); - deleted_positions.get_mut("4key").unwrap().remove(0); - store - .commit_editor_document(editor_request( - initial.revision, - uuid::Uuid::new_v4().to_string(), - EditorPatchV1 { - keys: Some(deleted_keys), - key_positions: Some(deleted_positions), - ..EditorPatchV1::default() - }, - )) - .unwrap(); - - let after_delete = store.editor_get(); - let restored = store - .commit_editor_document(editor_request( - after_delete.revision, - uuid::Uuid::new_v4().to_string(), - EditorPatchV1 { - keys: Some(stale_keys), - key_positions: Some(stale_positions), - ..EditorPatchV1::default() - }, - )) - .unwrap(); - - assert_ne!(restored.document.key_positions["4key"][0].id, deleted_id); - assert!(crate::state::native_element_id::is_valid_element_id( - &restored.document.key_positions["4key"][0].id - )); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn strict_editor_commit_rechecks_multi_key_capability_under_store_lock() { - let dir = test_directory("multi-key-capability-gate-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial = store.editor_get().document; - - let mut legacy_keys = initial.keys.clone(); - legacy_keys.get_mut("4key").unwrap()[0] = KeySlot::Single("Legacy".to_string()); - let legacy_request = editor_request( - 1, - uuid::Uuid::new_v4().to_string(), - EditorPatchV1 { - keys: Some(legacy_keys), - ..EditorPatchV1::default() - }, - ); - - let mut multi_keys = initial.keys; - multi_keys.get_mut("4key").unwrap()[0] = KeySlot::Multi { - keys: vec!["A".to_string(), "B".to_string()], - match_mode: SlotMatch::Any, - }; - let mut capable_request = editor_request( - 0, - uuid::Uuid::new_v4().to_string(), - EditorPatchV1 { - keys: Some(multi_keys), - ..EditorPatchV1::default() - }, - ); - capable_request.multi_key = true; - store.commit_editor_document(capable_request).unwrap(); - let committed_keys = serde_json::to_vec(&store.snapshot().keys).unwrap(); - let committed_disk = std::fs::read(dir.join("store.json")).unwrap(); - let persist_count = store.writer.persist_count(); - - let error = store.commit_editor_document(legacy_request).unwrap_err(); - - assert_eq!(error.error_code, EditorCommitErrorCode::MultiKeyUnsupported); - assert_eq!( - serde_json::to_value(&error).unwrap()["errorCode"], - "MULTI_KEY_UNSUPPORTED" - ); - assert!(!error.retryable); - assert_eq!(store.snapshot().editor_revision, 1); - assert_eq!( - serde_json::to_vec(&store.snapshot().keys).unwrap(), - committed_keys - ); - assert_eq!(store.writer.persist_count(), persist_count); - assert_eq!( - std::fs::read(dir.join("store.json")).unwrap(), - committed_disk - ); - - let position_change = store - .commit_editor_document(editor_request( - 1, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, 321.0), - )) - .unwrap(); - assert_eq!(position_change.result.revision, 2); - assert_eq!( - serde_json::to_vec(&store.snapshot().keys).unwrap(), - committed_keys - ); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn editor_commit_history_skips_no_op_and_deduplicates_mutation() { - let dir = test_directory("editor-history-recording-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial_dx = store.editor_get().document.key_positions["4key"][0].dx; - - let no_op = editor_request( - 0, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, initial_dx), - ); - let no_op_change = store.commit_editor_document(no_op).unwrap(); - assert!(no_op_change.result.changed_fields.is_empty()); - assert_eq!(store.history_status().history_revision, 0); - - let mutation_id = uuid::Uuid::new_v4().to_string(); - let request = editor_request(0, mutation_id, position_patch(&store, initial_dx + 12.0)); - let committed = store.commit_editor_document(request.clone()).unwrap(); - assert_eq!(committed.history_status.unwrap().history_revision, 1); - - let replayed = store.commit_editor_document(request).unwrap(); - assert!(replayed.replayed); - assert!(replayed.history_status.is_none()); - assert_eq!(store.history_status().history_revision, 1); - - let repeated_value = editor_request( - 1, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, initial_dx + 12.0), - ); - let repeated_value = store.commit_editor_document(repeated_value).unwrap(); - assert!(repeated_value.result.changed_fields.is_empty()); - assert_eq!(store.history_status().history_revision, 1); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn editor_ops_follow_stable_id_and_restore_the_latest_snapshot_on_undo() { - let dir = test_directory("editor-ops-stable-id-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = initialize_neutral_editor_store(&dir); - let initial = store.editor_get().document; - let target = initial.key_positions["4key"][0].clone(); - let initial_bounds = bounds(&target); - - let mut reordered_keys = initial.keys.clone(); - reordered_keys.get_mut("4key").unwrap().swap(0, 1); - let mut reordered_positions = initial.key_positions.clone(); - reordered_positions.get_mut("4key").unwrap().swap(0, 1); - store - .commit_editor_document(editor_request( - 0, - uuid::Uuid::new_v4().to_string(), - EditorPatchV1 { - keys: Some(reordered_keys), - key_positions: Some(reordered_positions), - ..EditorPatchV1::default() - }, - )) - .unwrap(); - - let changed_bounds = EditorBoundsV1 { - dx: initial_bounds.dx + 17.0, - dy: initial_bounds.dy + 19.0, - width: initial_bounds.width + 23.0, - height: initial_bounds.height + 29.0, - }; - let change = store - .commit_editor_document(editor_ops_request( - 1, - uuid::Uuid::new_v4().to_string(), - vec![set_bounds_op( - EditorElementTypeV1::Key, - &target.id, - changed_bounds, - )], - )) - .unwrap(); - - assert_eq!(change.result.revision, 2); - assert_eq!( - change.result.changed_fields, - vec![EditorField::KeyPositions] - ); - assert_eq!( - change.result.op_results.as_ref().unwrap()[0].status, - EditorOpResultStatusV1::Applied - ); - assert_eq!( - change.result.op_results.as_ref().unwrap()[0].bounds, - Some(changed_bounds) - ); - assert_eq!( - change.event.as_ref().unwrap().patch.schema_version, - EDITOR_SCHEMA_VERSION - ); - assert!(change.event.as_ref().unwrap().patch.key_positions.is_some()); - assert!(change.event.as_ref().unwrap().patch.keys.is_none()); - assert_eq!(change.document.key_positions["4key"][1].id, target.id); - assert_eq!( - bounds(&change.document.key_positions["4key"][1]), - changed_bounds - ); - - let undo_id = uuid::Uuid::new_v4().to_string(); - let gate = store.history_gate(); - let barrier = gate.close(&undo_id).unwrap(); - let current_counters = store.snapshot().key_counters; - store - .apply_history_operation(HistoryDirection::Undo, &undo_id, ¤t_counters, || {}) - .unwrap(); - drop(barrier); - - let after_undo = store.editor_get().document; - assert_eq!(after_undo.key_positions["4key"][1].id, target.id); - assert_eq!(bounds(&after_undo.key_positions["4key"][1]), initial_bounds); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - let current_counters = store.snapshot().key_counters; - store - .apply_history_operation(HistoryDirection::Redo, &redo_id, ¤t_counters, || {}) - .unwrap(); - drop(barrier); - let after_redo = store.editor_get().document; - assert_eq!(after_redo.key_positions["4key"][1].id, target.id); - assert_eq!(bounds(&after_redo.key_positions["4key"][1]), changed_bounds); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn property_and_key_slot_ops_replay_exact_results_and_round_trip_history() { - let dir = test_directory("editor-property-slot-ops-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial = store.editor_get(); - let property_id = initial.document.key_positions["4key"][0].id.clone(); - let slot_id = initial.document.key_positions["4key"][1].id.clone(); - let layer_name_id = initial.document.key_positions["4key"][2].id.clone(); - let initial_hidden = initial.document.key_positions["4key"][0].hidden; - let initial_slot = initial.document.keys["4key"][1].clone(); - let initial_layer_name = initial.document.key_positions["4key"][2].layer_name.clone(); - let initial_slot_canonical = initial_slot.canonical(); - let missing_id = uuid::Uuid::new_v4().to_string(); - let mutation_id = uuid::Uuid::new_v4().to_string(); - let request = editor_ops_request( - initial.revision, - &mutation_id, - vec![ - patch_hidden_op(EditorElementTypeV1::Key, &property_id, !initial_hidden), - EditorOpV1::SetKeySlot { - id: slot_id.clone(), - slot: EditorFrozenKeySlotV1::Multi(crate::models::EditorFrozenMultiKeySlotV1 { - keys: vec!["A".to_string(), "B".to_string()], - match_mode: SlotMatch::All, - }), - }, - patch_layer_name_op( - EditorElementTypeV1::Key, - &layer_name_id, - Some("Named layer"), - ), - patch_hidden_op(EditorElementTypeV1::Graph, missing_id, true), - ], - ); - - let committed = store.commit_editor_document(request.clone()).unwrap(); - assert_eq!( - committed.result.changed_fields, - [EditorField::Keys, EditorField::KeyPositions] - ); - assert_eq!( - committed - .result - .op_results - .as_ref() - .unwrap() - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - assert_eq!(store.history_status().history_revision, 1); - let committed_store = store.snapshot(); - assert!(committed_store.key_counters["4key"].contains_key("A+B")); - assert!(!committed_store.key_counters["4key"].contains_key(&initial_slot_canonical)); - - let replay = store.commit_editor_document(request).unwrap(); - assert!(replay.replayed); - assert_eq!(replay.result, committed.result); - assert_eq!(store.history_status().history_revision, 1); - - let reused = store - .commit_editor_document(editor_ops_request( - initial.revision, - mutation_id, - vec![patch_hidden_op( - EditorElementTypeV1::Key, - &property_id, - initial_hidden, - )], - )) - .unwrap_err(); - assert_eq!(reused.error_code, EditorCommitErrorCode::MutationIdReused); - - let gate = store.history_gate(); - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Undo, - &undo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let after_undo = store.editor_get().document; - assert_eq!(after_undo.key_positions["4key"][0].hidden, initial_hidden); - assert_eq!(after_undo.keys["4key"][1], initial_slot); - assert_eq!( - after_undo.key_positions["4key"][2].layer_name, - initial_layer_name - ); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Redo, - &redo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let after_redo = store.editor_get().document; - assert_eq!(after_redo.key_positions["4key"][0].hidden, !initial_hidden); - assert_eq!( - after_redo.key_positions["4key"][2].layer_name.as_deref(), - Some("Named layer") - ); - assert_eq!( - after_redo.keys["4key"][1], - KeySlot::Multi { - keys: vec!["A".to_string(), "B".to_string()], - match_mode: SlotMatch::All, - } - ); - - let no_change = store - .commit_editor_document(editor_ops_request( - store.editor_get().revision, - uuid::Uuid::new_v4().to_string(), - vec![ - patch_hidden_op(EditorElementTypeV1::Key, property_id, !initial_hidden), - EditorOpV1::SetKeySlot { - id: slot_id, - slot: EditorFrozenKeySlotV1::Multi( - crate::models::EditorFrozenMultiKeySlotV1 { - keys: vec!["A".to_string(), "B".to_string()], - match_mode: SlotMatch::All, - }, - ), - }, - patch_layer_name_op( - EditorElementTypeV1::Key, - layer_name_id, - Some("Named layer"), - ), - ], - )) - .unwrap(); - assert!(no_change.result.changed_fields.is_empty()); - assert!(no_change.event.is_none()); - assert_eq!( - no_change - .result - .op_results - .unwrap() - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange - ] - ); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn layer_name_null_clear_replays_and_round_trips_history() { - let dir = test_directory("editor-layer-name-clear-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial = store.editor_get(); - let id = initial.document.key_positions["4key"][0].id.clone(); - - store - .commit_editor_document(editor_ops_request( - initial.revision, - uuid::Uuid::new_v4().to_string(), - vec![patch_layer_name_op( - EditorElementTypeV1::Key, - &id, - Some("Named layer"), - )], - )) - .unwrap(); - - let clear_mutation_id = uuid::Uuid::new_v4().to_string(); - let clear_request = editor_ops_request( - store.editor_get().revision, - &clear_mutation_id, - vec![patch_layer_name_op(EditorElementTypeV1::Key, &id, None)], - ); - let cleared = store.commit_editor_document(clear_request.clone()).unwrap(); - assert_eq!( - cleared.result.op_results.as_ref().unwrap()[0].status, - EditorOpResultStatusV1::Applied - ); - assert_eq!( - store.editor_get().document.key_positions["4key"][0].layer_name, - None - ); - assert_eq!(store.history_status().history_revision, 2); - - let replay = store.commit_editor_document(clear_request).unwrap(); - assert!(replay.replayed); - assert_eq!(replay.result, cleared.result); - assert_eq!(store.history_status().history_revision, 2); - - let gate = store.history_gate(); - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Undo, - &undo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - assert_eq!( - store.editor_get().document.key_positions["4key"][0] - .layer_name - .as_deref(), - Some("Named layer") - ); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Redo, - &redo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - assert_eq!( - store.editor_get().document.key_positions["4key"][0].layer_name, - None - ); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn graph_type_batch_replays_and_round_trips_one_history_entry() { - let dir = test_directory("editor-graph-type-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let mut document = store.editor_get().document; - let template = document.key_positions["4key"][0].clone(); - let first_id = uuid::Uuid::new_v4().to_string(); - let second_id = uuid::Uuid::new_v4().to_string(); - document.graph_positions.insert( - "4key".to_string(), - vec![ - GraphPosition { - stat_type: GraphStatType::Kps, - graph_type: GraphType::Line, - graph_speed: 1000, - graph_color: "#111111".to_string(), - show_avg_line: true, - position: KeyPosition { - id: first_id.clone(), - ..template.clone() - }, - }, - GraphPosition { - stat_type: GraphStatType::Total, - graph_type: GraphType::Line, - graph_speed: 2000, - graph_color: "#222222".to_string(), - show_avg_line: false, - position: KeyPosition { - id: second_id.clone(), - ..template - }, - }, - ], - ); - let setup = store - .commit_editor_document(editor_request( - 0, - uuid::Uuid::new_v4().to_string(), - EditorPatchV1 { - schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, - graph_positions: Some(document.graph_positions), - ..EditorPatchV1::default() - }, - )) - .unwrap(); - let missing_id = uuid::Uuid::new_v4().to_string(); - let mutation_id = uuid::Uuid::new_v4().to_string(); - let request = editor_ops_request( - setup.result.revision, - &mutation_id, - vec![ - patch_graph_type_op(&first_id, GraphType::Bar), - patch_graph_type_op(&second_id, GraphType::Bar), - patch_graph_type_op(missing_id, GraphType::Bar), - ], - ); - - let changed = store.commit_editor_document(request.clone()).unwrap(); - assert_eq!(changed.result.changed_fields, [EditorField::GraphPositions]); - assert_eq!( - changed - .result - .op_results - .as_ref() - .unwrap() - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - assert!(changed.document.graph_positions["4key"] - .iter() - .all(|graph| graph.graph_type == GraphType::Bar)); - assert_eq!(store.history_status().history_revision, 2); - - let replay = store.commit_editor_document(request.clone()).unwrap(); - assert!(replay.replayed); - assert_eq!(replay.result, changed.result); - assert_eq!(store.history_status().history_revision, 2); - - let mut reused = request; - reused.ops = Some(vec![patch_graph_type_op(&first_id, GraphType::Line)]); - assert_eq!( - store.commit_editor_document(reused).unwrap_err().error_code, - EditorCommitErrorCode::MutationIdReused - ); - - let no_change = store - .commit_editor_document(editor_ops_request( - changed.result.revision, - uuid::Uuid::new_v4().to_string(), - vec![ - patch_graph_type_op(&first_id, GraphType::Bar), - patch_graph_type_op(&second_id, GraphType::Bar), - ], - )) - .unwrap(); - assert!(no_change.result.changed_fields.is_empty()); - assert!(no_change.event.is_none()); - assert_eq!(store.history_status().history_revision, 2); - - let gate = store.history_gate(); - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Undo, - &undo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - assert!(store.editor_get().document.graph_positions["4key"] - .iter() - .all(|graph| graph.graph_type == GraphType::Line)); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Redo, - &redo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - assert!(store.editor_get().document.graph_positions["4key"] - .iter() - .all(|graph| graph.graph_type == GraphType::Bar)); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn graph_color_batch_replays_raw_strings_and_round_trips_history() { - let dir = test_directory("editor-graph-color-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let mut document = store.editor_get().document; - let template = document.key_positions["4key"][0].clone(); - let first_id = uuid::Uuid::new_v4().to_string(); - let second_id = uuid::Uuid::new_v4().to_string(); - document.graph_positions.insert( - "4key".to_string(), - vec![ - GraphPosition { - stat_type: GraphStatType::Kps, - graph_type: GraphType::Line, - graph_speed: 1000, - graph_color: "first".to_string(), - show_avg_line: true, - position: KeyPosition { - id: first_id.clone(), - ..template.clone() - }, - }, - GraphPosition { - stat_type: GraphStatType::Total, - graph_type: GraphType::Bar, - graph_speed: 2000, - graph_color: "second".to_string(), - show_avg_line: false, - position: KeyPosition { - id: second_id.clone(), - ..template - }, - }, - ], - ); - let setup = store - .commit_editor_document(editor_request( - 0, - uuid::Uuid::new_v4().to_string(), - EditorPatchV1 { - schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, - graph_positions: Some(document.graph_positions), - ..EditorPatchV1::default() - }, - )) - .unwrap(); - let raw_color = "color(display-p3 1 0 0 / .5)"; - let mutation_id = uuid::Uuid::new_v4().to_string(); - let request = editor_ops_request( - setup.result.revision, - &mutation_id, - vec![ - patch_graph_color_op(&first_id, raw_color), - patch_graph_color_op(&second_id, raw_color), - patch_graph_color_op(uuid::Uuid::new_v4().to_string(), ""), - ], - ); - - let changed = store.commit_editor_document(request.clone()).unwrap(); - assert_eq!(changed.result.changed_fields, [EditorField::GraphPositions]); - assert_eq!( - changed - .result - .op_results - .as_ref() - .unwrap() - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - assert!(changed.document.graph_positions["4key"] - .iter() - .all(|graph| graph.graph_color == raw_color)); - assert_eq!(store.history_status().history_revision, 2); - - let replay = store.commit_editor_document(request.clone()).unwrap(); - assert!(replay.replayed); - assert_eq!(replay.result, changed.result); - - let mut reused = request; - reused.ops = Some(vec![patch_graph_color_op(&first_id, "different")]); - assert_eq!( - store.commit_editor_document(reused).unwrap_err().error_code, - EditorCommitErrorCode::MutationIdReused - ); - - let no_change = store - .commit_editor_document(editor_ops_request( - changed.result.revision, - uuid::Uuid::new_v4().to_string(), - vec![ - patch_graph_color_op(&first_id, raw_color), - patch_graph_color_op(&second_id, raw_color), - ], - )) - .unwrap(); - assert!(no_change.result.changed_fields.is_empty()); - assert!(no_change.event.is_none()); - assert_eq!(store.history_status().history_revision, 2); - - let gate = store.history_gate(); - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Undo, - &undo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - assert_eq!( - store.editor_get().document.graph_positions["4key"] - .iter() - .map(|graph| graph.graph_color.as_str()) - .collect::>(), - ["first", "second"] - ); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Redo, - &redo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - assert!(store.editor_get().document.graph_positions["4key"] - .iter() - .all(|graph| graph.graph_color == raw_color)); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn graph_and_knob_literal_batch_replays_and_round_trips_one_history_entry() { - let dir = test_directory("editor-graph-knob-literal-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let mut document = store.editor_get().document; - let template = document.key_positions["4key"][0].clone(); - let graph_ids = (0..3) - .map(|_| uuid::Uuid::new_v4().to_string()) - .collect::>(); - let knob_ids = (0..3) - .map(|_| uuid::Uuid::new_v4().to_string()) - .collect::>(); - document.graph_positions.insert( - "4key".to_string(), - graph_ids - .iter() - .map(|id| GraphPosition { - stat_type: GraphStatType::Kps, - graph_type: GraphType::Line, - graph_speed: 1000, - graph_color: "raw".to_string(), - show_avg_line: true, - position: KeyPosition { - id: id.clone(), - graph_animation_enabled: None, - ..template.clone() - }, - }) - .collect(), - ); - document.knob_positions.insert( - "4key".to_string(), - knob_ids - .iter() - .map(|id| KnobPosition { - axis_id: "axis".to_string(), - sensitivity: 1.0, - reverse: false, - position: KeyPosition { - id: id.clone(), - ..template.clone() - }, - }) - .collect(), - ); - let setup = store - .commit_editor_document(editor_request( - 0, - uuid::Uuid::new_v4().to_string(), - EditorPatchV1 { - schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, - graph_positions: Some(document.graph_positions), - knob_positions: Some(document.knob_positions), - ..EditorPatchV1::default() - }, - )) - .unwrap(); - let missing_id = uuid::Uuid::new_v4().to_string(); - let ops = vec![ - patch_property_op( - EditorElementTypeV1::Graph, - &graph_ids[0], - EditorElementPropertyPatchV1::ShowAvgLine(false), - ), - patch_property_op( - EditorElementTypeV1::Graph, - &graph_ids[1], - EditorElementPropertyPatchV1::GraphAnimationEnabled(true), - ), - patch_property_op( - EditorElementTypeV1::Graph, - &graph_ids[2], - EditorElementPropertyPatchV1::GraphSpeed(u32::MAX), - ), - patch_property_op( - EditorElementTypeV1::Knob, - &knob_ids[0], - EditorElementPropertyPatchV1::Reverse(true), - ), - patch_property_op( - EditorElementTypeV1::Knob, - &knob_ids[1], - EditorElementPropertyPatchV1::Sensitivity(-7.25), - ), - patch_property_op( - EditorElementTypeV1::Knob, - &knob_ids[2], - EditorElementPropertyPatchV1::AxisId(" HIDA:raw ".to_string()), - ), - patch_property_op( - EditorElementTypeV1::Knob, - missing_id, - EditorElementPropertyPatchV1::AxisId("missing-axis".to_string()), - ), - ]; - let mutation_id = uuid::Uuid::new_v4().to_string(); - let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); - - let changed = store.commit_editor_document(request.clone()).unwrap(); - assert_eq!( - changed.result.changed_fields, - [EditorField::GraphPositions, EditorField::KnobPositions] - ); - assert_eq!( - changed - .result - .op_results - .as_ref() - .unwrap() - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - let graphs = &changed.document.graph_positions["4key"]; - let knobs = &changed.document.knob_positions["4key"]; - assert!(!graphs[0].show_avg_line); - assert_eq!(graphs[1].position.graph_animation_enabled, Some(true)); - assert_eq!(graphs[2].graph_speed, u32::MAX); - assert!(knobs[0].reverse); - assert_eq!(knobs[1].sensitivity, -7.25); - assert_eq!(knobs[2].axis_id, " HIDA:raw "); - assert!(!knobs[2].reverse); - assert_eq!(knobs[2].sensitivity, 1.0); - assert_eq!(store.history_status().history_revision, 2); - - let replay = store.commit_editor_document(request.clone()).unwrap(); - assert!(replay.replayed); - assert_eq!(replay.result, changed.result); - assert_eq!(store.history_status().history_revision, 2); - - let mut reused = request; - reused.ops = Some(vec![patch_property_op( - EditorElementTypeV1::Knob, - &knob_ids[2], - EditorElementPropertyPatchV1::AxisId("different-axis".to_string()), - )]); - assert_eq!( - store.commit_editor_document(reused).unwrap_err().error_code, - EditorCommitErrorCode::MutationIdReused - ); - - let no_change = store - .commit_editor_document(editor_ops_request( - changed.result.revision, - uuid::Uuid::new_v4().to_string(), - ops[..6].to_vec(), - )) - .unwrap(); - assert!(no_change.result.changed_fields.is_empty()); - assert!(no_change.event.is_none()); - assert_eq!(store.history_status().history_revision, 2); - - let gate = store.history_gate(); - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Undo, - &undo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let undone = store.editor_get().document; - assert!(undone.graph_positions["4key"] - .iter() - .all(|graph| graph.show_avg_line - && graph.graph_speed == 1000 - && graph.position.graph_animation_enabled.is_none())); - assert!(undone.knob_positions["4key"] - .iter() - .all(|knob| !knob.reverse && knob.sensitivity == 1.0 && knob.axis_id == "axis")); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Redo, - &redo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let redone = store.editor_get().document; - assert!(!redone.graph_positions["4key"][0].show_avg_line); - assert_eq!( - redone.graph_positions["4key"][1] - .position - .graph_animation_enabled, - Some(true) - ); - assert_eq!(redone.graph_positions["4key"][2].graph_speed, u32::MAX); - assert!(redone.knob_positions["4key"][0].reverse); - assert_eq!(redone.knob_positions["4key"][1].sensitivity, -7.25); - assert_eq!(redone.knob_positions["4key"][2].axis_id, " HIDA:raw "); - assert!(!redone.knob_positions["4key"][2].reverse); - assert_eq!(redone.knob_positions["4key"][2].sensitivity, 1.0); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn common_option_batches_replay_and_round_trip_all_native_types() { - let dir = test_directory("editor-inline-style-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = initialize_neutral_editor_store(&dir); - let mut document = store.editor_get().document; - let mut template = document.key_positions["4key"][0].clone(); - template.counter.font_italic = true; - template.counter.font_underline = true; - template.counter.font_strikethrough = true; - template.counter.font_family = Some("counter-font".to_string()); - document.key_positions.get_mut("4key").unwrap()[0] - .counter - .font_family = Some("counter-font".to_string()); - let key_id = template.id.clone(); - let stat_id = uuid::Uuid::new_v4().to_string(); - let graph_id = uuid::Uuid::new_v4().to_string(); - let knob_id = uuid::Uuid::new_v4().to_string(); - document.stat_positions.insert( - "4key".to_string(), - vec![StatPosition { - stat_type: StatType::Kps, - position: KeyPosition { - id: stat_id.clone(), - ..template.clone() - }, - }], - ); - document.graph_positions.insert( - "4key".to_string(), - vec![GraphPosition { - stat_type: GraphStatType::Kps, - graph_type: GraphType::Line, - graph_speed: 1000, - graph_color: "raw".to_string(), - show_avg_line: true, - position: KeyPosition { - id: graph_id.clone(), - ..template.clone() - }, - }], - ); - document.knob_positions.insert( - "4key".to_string(), - vec![KnobPosition { - axis_id: "axis".to_string(), - sensitivity: 1.0, - reverse: false, - position: KeyPosition { - id: knob_id.clone(), - ..template - }, - }], - ); - let setup = store - .commit_editor_document(editor_request( - 0, - uuid::Uuid::new_v4().to_string(), - EditorPatchV1 { - schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, - keys: Some(document.keys), - key_positions: Some(document.key_positions), - stat_positions: Some(document.stat_positions), - graph_positions: Some(document.graph_positions), - knob_positions: Some(document.knob_positions), - ..EditorPatchV1::default() - }, - )) - .unwrap(); - let patch = EditorElementPropertyPatchV1::UseInlineStyles(false); - let ops = [ - (EditorElementTypeV1::Key, key_id), - (EditorElementTypeV1::Stat, stat_id), - (EditorElementTypeV1::Graph, graph_id), - (EditorElementTypeV1::Knob, knob_id), - ] - .map(|(element_type, id)| patch_property_op(element_type, id, patch.clone())) - .into_iter() - .chain(std::iter::once(patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - patch, - ))) - .collect::>(); - let mutation_id = uuid::Uuid::new_v4().to_string(); - let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); - - let changed = store.commit_editor_document(request.clone()).unwrap(); - assert_eq!( - changed.result.changed_fields, - [ - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - ] - ); - assert_eq!( - changed - .result - .op_results - .as_ref() - .unwrap() - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - assert_eq!( - changed.document.key_positions["4key"][0].use_inline_styles, - Some(false) - ); - assert_eq!( - changed.document.stat_positions["4key"][0] - .position - .use_inline_styles, - Some(false) - ); - assert_eq!( - changed.document.graph_positions["4key"][0] - .position - .use_inline_styles, - Some(false) - ); - assert_eq!( - changed.document.knob_positions["4key"][0] - .position - .use_inline_styles, - Some(false) - ); - assert_eq!(store.history_status().history_revision, 2); - - let replay = store.commit_editor_document(request.clone()).unwrap(); - assert!(replay.replayed); - assert_eq!(replay.result, changed.result); - - let mut reused = request; - reused.ops = Some(vec![patch_property_op( - EditorElementTypeV1::Key, - &changed.document.key_positions["4key"][0].id, - EditorElementPropertyPatchV1::UseInlineStyles(true), - )]); - assert_eq!( - store.commit_editor_document(reused).unwrap_err().error_code, - EditorCommitErrorCode::MutationIdReused - ); - - let no_change = store - .commit_editor_document(editor_ops_request( - changed.result.revision, - uuid::Uuid::new_v4().to_string(), - ops[..4].to_vec(), - )) - .unwrap(); - assert!(no_change.result.changed_fields.is_empty()); - assert!(no_change.event.is_none()); - assert_eq!(store.history_status().history_revision, 2); - - let gate = store.history_gate(); - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Undo, - &undo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let undone = store.editor_get().document; - assert_eq!(undone.key_positions["4key"][0].use_inline_styles, None); - assert_eq!( - undone.stat_positions["4key"][0].position.use_inline_styles, - None - ); - assert_eq!( - undone.graph_positions["4key"][0].position.use_inline_styles, - None - ); - assert_eq!( - undone.knob_positions["4key"][0].position.use_inline_styles, - None - ); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Redo, - &redo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let redone = store.editor_get().document; - assert_eq!( - redone.key_positions["4key"][0].use_inline_styles, - Some(false) - ); - assert_eq!( - redone.stat_positions["4key"][0].position.use_inline_styles, - Some(false) - ); - assert_eq!( - redone.graph_positions["4key"][0].position.use_inline_styles, - Some(false) - ); - assert_eq!( - redone.knob_positions["4key"][0].position.use_inline_styles, - Some(false) - ); - - let counter_font_weight = redone.key_positions["4key"][0].counter.font_weight; - let original_font_weight = redone.key_positions["4key"][0].font_weight; - let original_font_italic = redone.stat_positions["4key"][0].position.font_italic; - let original_font_underline = redone.graph_positions["4key"][0].position.font_underline; - let original_font_strikethrough = - redone.knob_positions["4key"][0].position.font_strikethrough; - let font_ops = vec![ - patch_property_op( - EditorElementTypeV1::Key, - &redone.key_positions["4key"][0].id, - EditorElementPropertyPatchV1::FontWeight(u32::MAX), - ), - patch_property_op( - EditorElementTypeV1::Stat, - &redone.stat_positions["4key"][0].position.id, - EditorElementPropertyPatchV1::FontItalic(false), - ), - patch_property_op( - EditorElementTypeV1::Graph, - &redone.graph_positions["4key"][0].position.id, - EditorElementPropertyPatchV1::FontUnderline(false), - ), - patch_property_op( - EditorElementTypeV1::Knob, - &redone.knob_positions["4key"][0].position.id, - EditorElementPropertyPatchV1::FontStrikethrough(false), - ), - patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - EditorElementPropertyPatchV1::FontWeight(u32::MAX), - ), - ]; - let font_mutation_id = uuid::Uuid::new_v4().to_string(); - let font_request = editor_ops_request( - store.editor_get().revision, - &font_mutation_id, - font_ops.clone(), - ); - let font_changed = store.commit_editor_document(font_request.clone()).unwrap(); - assert_eq!( - font_changed.result.changed_fields, - [ - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - ] - ); - assert_eq!( - font_changed - .result - .op_results - .as_ref() - .unwrap() - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - assert_eq!( - font_changed.document.key_positions["4key"][0].font_weight, - Some(u32::MAX) - ); - assert_eq!( - font_changed.document.stat_positions["4key"][0] - .position - .font_italic, - Some(false) - ); - assert_eq!( - font_changed.document.graph_positions["4key"][0] - .position - .font_underline, - Some(false) - ); - assert_eq!( - font_changed.document.knob_positions["4key"][0] - .position - .font_strikethrough, - Some(false) - ); - assert_eq!( - font_changed.document.key_positions["4key"][0] - .counter - .font_weight, - counter_font_weight - ); - assert!( - font_changed.document.stat_positions["4key"][0] - .position - .counter - .font_italic - ); - assert!( - font_changed.document.graph_positions["4key"][0] - .position - .counter - .font_underline - ); - assert!( - font_changed.document.knob_positions["4key"][0] - .position - .counter - .font_strikethrough - ); - - let font_replay = store.commit_editor_document(font_request.clone()).unwrap(); - assert!(font_replay.replayed); - assert_eq!(font_replay.result, font_changed.result); - - let mut font_reused = font_request; - font_reused.ops = Some(vec![patch_property_op( - EditorElementTypeV1::Key, - &font_changed.document.key_positions["4key"][0].id, - EditorElementPropertyPatchV1::FontWeight(700), - )]); - assert_eq!( - store - .commit_editor_document(font_reused) - .unwrap_err() - .error_code, - EditorCommitErrorCode::MutationIdReused - ); - - let font_no_change = store - .commit_editor_document(editor_ops_request( - font_changed.result.revision, - uuid::Uuid::new_v4().to_string(), - font_ops[..4].to_vec(), - )) - .unwrap(); - assert!(font_no_change.result.changed_fields.is_empty()); - assert!(font_no_change.event.is_none()); - - let font_undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&font_undo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Undo, - &font_undo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let font_undone = store.editor_get().document; - assert_eq!( - font_undone.key_positions["4key"][0].font_weight, - original_font_weight - ); - assert_eq!( - font_undone.stat_positions["4key"][0].position.font_italic, - original_font_italic - ); - assert_eq!( - font_undone.graph_positions["4key"][0] - .position - .font_underline, - original_font_underline - ); - assert_eq!( - font_undone.knob_positions["4key"][0] - .position - .font_strikethrough, - original_font_strikethrough - ); - assert_eq!( - font_undone.key_positions["4key"][0].counter.font_weight, - counter_font_weight - ); - assert!( - font_undone.stat_positions["4key"][0] - .position - .counter - .font_italic - ); - assert!( - font_undone.graph_positions["4key"][0] - .position - .counter - .font_underline - ); - assert!( - font_undone.knob_positions["4key"][0] - .position - .counter - .font_strikethrough - ); - - let font_redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&font_redo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Redo, - &font_redo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let font_redone = store.editor_get().document; - assert_eq!( - font_redone.key_positions["4key"][0].font_weight, - Some(u32::MAX) - ); - assert_eq!( - font_redone.stat_positions["4key"][0].position.font_italic, - Some(false) - ); - assert_eq!( - font_redone.graph_positions["4key"][0] - .position - .font_underline, - Some(false) - ); - assert_eq!( - font_redone.knob_positions["4key"][0] - .position - .font_strikethrough, - Some(false) - ); - assert_eq!( - font_redone.key_positions["4key"][0].counter.font_weight, - counter_font_weight - ); - assert!( - font_redone.stat_positions["4key"][0] - .position - .counter - .font_italic - ); - assert!( - font_redone.graph_positions["4key"][0] - .position - .counter - .font_underline - ); - assert!( - font_redone.knob_positions["4key"][0] - .position - .counter - .font_strikethrough - ); - - let family_patch = EditorElementPropertyPatchV1::FontFamily(" raw-family ".to_string()); - let family_ops = [ - ( - EditorElementTypeV1::Key, - font_redone.key_positions["4key"][0].id.clone(), - ), - ( - EditorElementTypeV1::Stat, - font_redone.stat_positions["4key"][0].position.id.clone(), - ), - ( - EditorElementTypeV1::Graph, - font_redone.graph_positions["4key"][0].position.id.clone(), - ), - ( - EditorElementTypeV1::Knob, - font_redone.knob_positions["4key"][0].position.id.clone(), - ), - ] - .map(|(element_type, id)| patch_property_op(element_type, id, family_patch.clone())) - .into_iter() - .chain(std::iter::once(patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - family_patch.clone(), - ))) - .collect::>(); - let family_mutation_id = uuid::Uuid::new_v4().to_string(); - let family_request = editor_ops_request( - store.editor_get().revision, - &family_mutation_id, - family_ops.clone(), - ); - let family_changed = store - .commit_editor_document(family_request.clone()) - .unwrap(); - assert_eq!( - family_changed.result.changed_fields, - [ - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - ] - ); - assert_eq!( - family_changed - .result - .op_results - .as_ref() - .unwrap() - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - assert_eq!( - family_changed.document.key_positions["4key"][0] - .font_family - .as_deref(), - Some(" raw-family ") - ); - assert_eq!( - family_changed.document.stat_positions["4key"][0] - .position - .font_family - .as_deref(), - Some(" raw-family ") - ); - assert_eq!( - family_changed.document.graph_positions["4key"][0] - .position - .font_family - .as_deref(), - Some(" raw-family ") - ); - assert_eq!( - family_changed.document.knob_positions["4key"][0] - .position - .font_family - .as_deref(), - Some(" raw-family ") - ); - for position in [ - &family_changed.document.key_positions["4key"][0], - &family_changed.document.stat_positions["4key"][0].position, - &family_changed.document.graph_positions["4key"][0].position, - &family_changed.document.knob_positions["4key"][0].position, - ] { - assert_eq!( - position.counter.font_family.as_deref(), - Some("counter-font") - ); - } - - let family_replay = store - .commit_editor_document(family_request.clone()) - .unwrap(); - assert!(family_replay.replayed); - assert_eq!(family_replay.result, family_changed.result); - - let mut family_reused = family_request; - family_reused.ops = Some(vec![patch_property_op( - EditorElementTypeV1::Key, - &family_changed.document.key_positions["4key"][0].id, - EditorElementPropertyPatchV1::FontFamily("different".to_string()), - )]); - assert_eq!( - store - .commit_editor_document(family_reused) - .unwrap_err() - .error_code, - EditorCommitErrorCode::MutationIdReused - ); - - let family_no_change = store - .commit_editor_document(editor_ops_request( - family_changed.result.revision, - uuid::Uuid::new_v4().to_string(), - family_ops[..4].to_vec(), - )) - .unwrap(); - assert!(family_no_change.result.changed_fields.is_empty()); - assert!(family_no_change.event.is_none()); - - let family_undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&family_undo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Undo, - &family_undo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let family_undone = store.editor_get().document; - for position in [ - &family_undone.key_positions["4key"][0], - &family_undone.stat_positions["4key"][0].position, - &family_undone.graph_positions["4key"][0].position, - &family_undone.knob_positions["4key"][0].position, - ] { - assert_eq!(position.font_family, None); - assert_eq!( - position.counter.font_family.as_deref(), - Some("counter-font") - ); - } - - let family_redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&family_redo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Redo, - &family_redo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let family_redone = store.editor_get().document; - for position in [ - &family_redone.key_positions["4key"][0], - &family_redone.stat_positions["4key"][0].position, - &family_redone.graph_positions["4key"][0].position, - &family_redone.knob_positions["4key"][0].position, - ] { - assert_eq!(position.font_family.as_deref(), Some(" raw-family ")); - assert_eq!( - position.counter.font_family.as_deref(), - Some("counter-font") - ); - } - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn display_text_batch_replays_raw_empty_and_round_trips_one_history_entry() { - let dir = test_directory("editor-display-text-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = initialize_neutral_editor_store(&dir); - let mut document = store.editor_get().document; - let mut template = document.key_positions["4key"][0].clone(); - template.display_text = None; - template.layer_name = Some("layer-sibling".to_string()); - template.font_family = Some("font-sibling".to_string()); - template.counter.font_family = Some("counter-font-sibling".to_string()); - template.inactive_image = Some("image-sibling".to_string()); - document.key_positions.get_mut("4key").unwrap()[0] = template.clone(); - let targets = [ - (EditorElementTypeV1::Key, template.id.clone(), ""), - ( - EditorElementTypeV1::Stat, - uuid::Uuid::new_v4().to_string(), - " raw stat ", - ), - ( - EditorElementTypeV1::Graph, - uuid::Uuid::new_v4().to_string(), - "raw graph", - ), - ( - EditorElementTypeV1::Knob, - uuid::Uuid::new_v4().to_string(), - "raw knob", - ), - ]; - document.stat_positions.insert( - "4key".to_string(), - vec![StatPosition { - stat_type: StatType::Kps, - position: KeyPosition { - id: targets[1].1.clone(), - ..template.clone() - }, - }], - ); - document.graph_positions.insert( - "4key".to_string(), - vec![GraphPosition { - stat_type: GraphStatType::Kps, - graph_type: GraphType::Line, - graph_speed: 1000, - graph_color: "graph-sibling".to_string(), - show_avg_line: true, - position: KeyPosition { - id: targets[2].1.clone(), - ..template.clone() - }, - }], - ); - document.knob_positions.insert( - "4key".to_string(), - vec![KnobPosition { - axis_id: "axis-sibling".to_string(), - sensitivity: 1.0, - reverse: false, - position: KeyPosition { - id: targets[3].1.clone(), - ..template - }, - }], - ); - let setup = store - .commit_editor_document(editor_request( - 0, - uuid::Uuid::new_v4().to_string(), - EditorPatchV1 { - schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, - keys: Some(document.keys), - key_positions: Some(document.key_positions), - stat_positions: Some(document.stat_positions), - graph_positions: Some(document.graph_positions), - knob_positions: Some(document.knob_positions), - ..EditorPatchV1::default() - }, - )) - .unwrap(); - let ops = targets - .iter() - .map(|(element_type, id, display_text)| { - patch_property_op( - *element_type, - id, - EditorElementPropertyPatchV1::DisplayText((*display_text).to_string()), - ) - }) - .chain(std::iter::once(patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - EditorElementPropertyPatchV1::DisplayText("missing".to_string()), - ))) - .collect::>(); - let mutation_id = uuid::Uuid::new_v4().to_string(); - let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); - - let changed = store.commit_editor_document(request.clone()).unwrap(); - assert_eq!( - changed.result.changed_fields, - [ - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - ] - ); - assert_eq!( - changed - .result - .op_results - .as_ref() - .unwrap() - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - let positions = [ - &changed.document.key_positions["4key"][0], - &changed.document.stat_positions["4key"][0].position, - &changed.document.graph_positions["4key"][0].position, - &changed.document.knob_positions["4key"][0].position, - ]; - for (position, expected) in positions - .into_iter() - .zip(targets.iter().map(|target| target.2)) - { - assert_eq!(position.display_text.as_deref(), Some(expected)); - assert_eq!(position.layer_name.as_deref(), Some("layer-sibling")); - assert_eq!(position.font_family.as_deref(), Some("font-sibling")); - assert_eq!( - position.counter.font_family.as_deref(), - Some("counter-font-sibling") - ); - assert_eq!(position.inactive_image.as_deref(), Some("image-sibling")); - } - let history_revision = store.history_status().history_revision; - - let replay = store.commit_editor_document(request.clone()).unwrap(); - assert!(replay.replayed); - assert_eq!(replay.result, changed.result); - assert_eq!(store.history_status().history_revision, history_revision); - - let mut reused = request; - reused.ops = Some(vec![patch_property_op( - EditorElementTypeV1::Key, - &targets[0].1, - EditorElementPropertyPatchV1::DisplayText("different".to_string()), - )]); - assert_eq!( - store.commit_editor_document(reused).unwrap_err().error_code, - EditorCommitErrorCode::MutationIdReused - ); - - let no_change = store - .commit_editor_document(editor_ops_request( - changed.result.revision, - uuid::Uuid::new_v4().to_string(), - ops[..4].to_vec(), - )) - .unwrap(); - assert!(no_change.result.changed_fields.is_empty()); - assert!(no_change.event.is_none()); - assert_eq!(store.history_status().history_revision, history_revision); - - let gate = store.history_gate(); - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Undo, - &undo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let undone = store.editor_get().document; - for position in [ - &undone.key_positions["4key"][0], - &undone.stat_positions["4key"][0].position, - &undone.graph_positions["4key"][0].position, - &undone.knob_positions["4key"][0].position, - ] { - assert_eq!(position.display_text, None); - assert_eq!(position.layer_name.as_deref(), Some("layer-sibling")); - } - - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Redo, - &redo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let redone = store.editor_get().document; - let positions = [ - &redone.key_positions["4key"][0], - &redone.stat_positions["4key"][0].position, - &redone.graph_positions["4key"][0].position, - &redone.knob_positions["4key"][0].position, - ]; - for (position, expected) in positions - .into_iter() - .zip(targets.iter().map(|target| target.2)) - { - assert_eq!(position.display_text.as_deref(), Some(expected)); - assert_eq!(position.layer_name.as_deref(), Some("layer-sibling")); - } - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn class_name_replays_raw_empty_and_round_trips_one_history_entry() { - let dir = test_directory("editor-class-name-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = initialize_neutral_editor_store(&dir); - let mut document = store.editor_get().document; - let target_id = document.key_positions["4key"][0].id.clone(); - { - let position = &mut document.key_positions.get_mut("4key").unwrap()[0]; - position.class_name = None; - position.display_text = Some("display-sibling".to_string()); - position.font_family = Some("font-sibling".to_string()); - position.counter.font_family = Some("counter-font-sibling".to_string()); - } - let setup = store - .commit_editor_document(editor_request( - 0, - uuid::Uuid::new_v4().to_string(), - EditorPatchV1 { - schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, - keys: Some(document.keys), - key_positions: Some(document.key_positions), - ..EditorPatchV1::default() - }, - )) - .unwrap(); - let ops = vec![ - patch_property_op( - EditorElementTypeV1::Key, - &target_id, - EditorElementPropertyPatchV1::ClassName(String::new()), - ), - patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - EditorElementPropertyPatchV1::ClassName("missing".to_string()), - ), - ]; - let mutation_id = uuid::Uuid::new_v4().to_string(); - let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); - - let changed = store.commit_editor_document(request.clone()).unwrap(); - assert_eq!(changed.result.changed_fields, [EditorField::KeyPositions]); - assert_eq!( - changed - .result - .op_results - .as_ref() - .unwrap() - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - let position = &changed.document.key_positions["4key"][0]; - assert_eq!(position.class_name.as_deref(), Some("")); - assert_eq!(position.display_text.as_deref(), Some("display-sibling")); - assert_eq!(position.font_family.as_deref(), Some("font-sibling")); - assert_eq!( - position.counter.font_family.as_deref(), - Some("counter-font-sibling") - ); - let history_revision = store.history_status().history_revision; - - let replay = store.commit_editor_document(request.clone()).unwrap(); - assert!(replay.replayed); - assert_eq!(replay.result, changed.result); - assert_eq!(store.history_status().history_revision, history_revision); - - let mut reused = request; - reused.ops = Some(vec![patch_property_op( - EditorElementTypeV1::Key, - &target_id, - EditorElementPropertyPatchV1::ClassName("different".to_string()), - )]); - assert_eq!( - store.commit_editor_document(reused).unwrap_err().error_code, - EditorCommitErrorCode::MutationIdReused - ); - - let no_change = store - .commit_editor_document(editor_ops_request( - changed.result.revision, - uuid::Uuid::new_v4().to_string(), - vec![ops[0].clone()], - )) - .unwrap(); - assert!(no_change.result.changed_fields.is_empty()); - assert!(no_change.event.is_none()); - assert_eq!(store.history_status().history_revision, history_revision); - - let gate = store.history_gate(); - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Undo, - &undo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let undone = store.editor_get().document; - let position = &undone.key_positions["4key"][0]; - assert_eq!(position.class_name, None); - assert_eq!(position.display_text.as_deref(), Some("display-sibling")); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Redo, - &redo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let redone = store.editor_get().document; - let position = &redone.key_positions["4key"][0]; - assert_eq!(position.class_name.as_deref(), Some("")); - assert_eq!(position.display_text.as_deref(), Some("display-sibling")); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn numeric_style_batch_replays_and_round_trips_one_history_entry() { - let dir = test_directory("editor-numeric-style-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = initialize_neutral_editor_store(&dir); - let mut document = store.editor_get().document; - let mut template = document.key_positions["4key"][0].clone(); - template.border_width = None; - template.border_radius = None; - template.font_size = None; - template.class_name = Some("class-sibling".to_string()); - template.display_text = Some("display-sibling".to_string()); - template.counter.font_size = 33; - document.key_positions.get_mut("4key").unwrap()[0] = template.clone(); - let targets = [ - (EditorElementTypeV1::Key, template.id.clone()), - (EditorElementTypeV1::Stat, uuid::Uuid::new_v4().to_string()), - (EditorElementTypeV1::Knob, uuid::Uuid::new_v4().to_string()), - ]; - document.stat_positions.insert( - "4key".to_string(), - vec![StatPosition { - stat_type: StatType::Kps, - position: KeyPosition { - id: targets[1].1.clone(), - ..template.clone() - }, - }], - ); - document.knob_positions.insert( - "4key".to_string(), - vec![KnobPosition { - axis_id: "axis-sibling".to_string(), - sensitivity: 1.0, - reverse: false, - position: KeyPosition { - id: targets[2].1.clone(), - ..template - }, - }], - ); - let setup = store - .commit_editor_document(editor_request( - 0, - uuid::Uuid::new_v4().to_string(), - EditorPatchV1 { - schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, - keys: Some(document.keys), - key_positions: Some(document.key_positions), - stat_positions: Some(document.stat_positions), - knob_positions: Some(document.knob_positions), - ..EditorPatchV1::default() - }, - )) - .unwrap(); - let ops = vec![ - patch_property_op( - EditorElementTypeV1::Key, - &targets[0].1, - EditorElementPropertyPatchV1::BorderWidth(0.5), - ), - patch_property_op( - EditorElementTypeV1::Stat, - &targets[1].1, - EditorElementPropertyPatchV1::BorderRadius(99.5), - ), - patch_property_op( - EditorElementTypeV1::Knob, - &targets[2].1, - EditorElementPropertyPatchV1::FontSize(8.5), - ), - patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - EditorElementPropertyPatchV1::BorderWidth(1.0), - ), - ]; - let mutation_id = uuid::Uuid::new_v4().to_string(); - let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); - - let changed = store.commit_editor_document(request.clone()).unwrap(); - assert_eq!( - changed.result.changed_fields, - [ - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::KnobPositions, - ] - ); - assert_eq!( - changed - .result - .op_results - .as_ref() - .unwrap() - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - let positions = [ - &changed.document.key_positions["4key"][0], - &changed.document.stat_positions["4key"][0].position, - &changed.document.knob_positions["4key"][0].position, - ]; - assert_eq!(positions[0].border_width, Some(0.5)); - assert_eq!(positions[1].border_radius, Some(99.5)); - assert_eq!(positions[2].font_size, Some(8.5)); - for position in positions { - assert_eq!(position.class_name.as_deref(), Some("class-sibling")); - assert_eq!(position.display_text.as_deref(), Some("display-sibling")); - assert_eq!(position.counter.font_size, 33); - } - let history_revision = store.history_status().history_revision; - - let replay = store.commit_editor_document(request.clone()).unwrap(); - assert!(replay.replayed); - assert_eq!(replay.result, changed.result); - assert_eq!(store.history_status().history_revision, history_revision); - - let mut reused = request; - reused.ops = Some(vec![patch_property_op( - EditorElementTypeV1::Key, - &targets[0].1, - EditorElementPropertyPatchV1::FontSize(9.0), - )]); - assert_eq!( - store.commit_editor_document(reused).unwrap_err().error_code, - EditorCommitErrorCode::MutationIdReused - ); - - let no_change = store - .commit_editor_document(editor_ops_request( - changed.result.revision, - uuid::Uuid::new_v4().to_string(), - ops[..3].to_vec(), - )) - .unwrap(); - assert!(no_change.result.changed_fields.is_empty()); - assert!(no_change.event.is_none()); - assert_eq!(store.history_status().history_revision, history_revision); - - let gate = store.history_gate(); - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Undo, - &undo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let undone = store.editor_get().document; - let positions = [ - &undone.key_positions["4key"][0], - &undone.stat_positions["4key"][0].position, - &undone.knob_positions["4key"][0].position, - ]; - for position in positions { - assert_eq!(position.border_width, None); - assert_eq!(position.border_radius, None); - assert_eq!(position.font_size, None); - assert_eq!(position.class_name.as_deref(), Some("class-sibling")); - } - - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Redo, - &redo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let redone = store.editor_get().document; - let positions = [ - &redone.key_positions["4key"][0], - &redone.stat_positions["4key"][0].position, - &redone.knob_positions["4key"][0].position, - ]; - assert_eq!(positions[0].border_width, Some(0.5)); - assert_eq!(positions[1].border_radius, Some(99.5)); - assert_eq!(positions[2].font_size, Some(8.5)); - for position in positions { - assert_eq!(position.class_name.as_deref(), Some("class-sibling")); - } - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn inactive_image_batch_replays_raw_empty_and_round_trips_one_history_entry() { - let dir = test_directory("editor-inactive-image-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = initialize_neutral_editor_store(&dir); - let mut document = store.editor_get().document; - let mut template = document.key_positions["4key"][0].clone(); - template.inactive_image = None; - template.active_image = Some("active-sibling.png".to_string()); - template.idle_image_fit = Some(crate::models::ImageFit::Contain); - template.idle_transparent = true; - template.counter.enabled = false; - document.key_positions.get_mut("4key").unwrap()[0] = template.clone(); - let targets = [ - (EditorElementTypeV1::Key, template.id.clone()), - (EditorElementTypeV1::Stat, uuid::Uuid::new_v4().to_string()), - (EditorElementTypeV1::Graph, uuid::Uuid::new_v4().to_string()), - (EditorElementTypeV1::Knob, uuid::Uuid::new_v4().to_string()), - ]; - document.stat_positions.insert( - "4key".to_string(), - vec![StatPosition { - stat_type: StatType::Kps, - position: KeyPosition { - id: targets[1].1.clone(), - ..template.clone() - }, - }], - ); - document.graph_positions.insert( - "4key".to_string(), - vec![GraphPosition { - stat_type: GraphStatType::Kps, - graph_type: GraphType::Line, - graph_speed: 1000, - graph_color: "graph-sibling".to_string(), - show_avg_line: true, - position: KeyPosition { - id: targets[2].1.clone(), - ..template.clone() - }, - }], - ); - document.knob_positions.insert( - "4key".to_string(), - vec![KnobPosition { - axis_id: "axis-sibling".to_string(), - sensitivity: 2.0, - reverse: true, - position: KeyPosition { - id: targets[3].1.clone(), - ..template - }, - }], - ); - let setup = store - .commit_editor_document(editor_request( - 0, - uuid::Uuid::new_v4().to_string(), - EditorPatchV1 { - schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, - keys: Some(document.keys), - key_positions: Some(document.key_positions), - stat_positions: Some(document.stat_positions), - graph_positions: Some(document.graph_positions), - knob_positions: Some(document.knob_positions), - ..EditorPatchV1::default() - }, - )) - .unwrap(); - let patch = EditorElementPropertyPatchV1::InactiveImage(String::new()); - let ops = targets - .iter() - .map(|(element_type, id)| patch_property_op(*element_type, id, patch.clone())) - .chain(std::iter::once(patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - patch.clone(), - ))) - .collect::>(); - let mutation_id = uuid::Uuid::new_v4().to_string(); - let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); - - let changed = store.commit_editor_document(request.clone()).unwrap(); - assert_eq!( - changed.result.changed_fields, - [ - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - ] - ); - assert_eq!( - changed - .result - .op_results - .as_ref() - .unwrap() - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - for position in [ - &changed.document.key_positions["4key"][0], - &changed.document.stat_positions["4key"][0].position, - &changed.document.graph_positions["4key"][0].position, - &changed.document.knob_positions["4key"][0].position, - ] { - assert_eq!(position.inactive_image.as_deref(), Some("")); - assert_eq!(position.active_image.as_deref(), Some("active-sibling.png")); - assert_eq!( - position.idle_image_fit, - Some(crate::models::ImageFit::Contain) - ); - assert!(position.idle_transparent); - assert!(!position.counter.enabled); - } - assert_eq!( - changed.document.graph_positions["4key"][0].graph_color, - "graph-sibling" - ); - assert_eq!( - changed.document.knob_positions["4key"][0].axis_id, - "axis-sibling" - ); - let history_revision = store.history_status().history_revision; - - let replay = store.commit_editor_document(request.clone()).unwrap(); - assert!(replay.replayed); - assert_eq!(replay.result, changed.result); - assert_eq!(store.history_status().history_revision, history_revision); - - let mut reused = request; - reused.ops = Some(vec![patch_property_op( - EditorElementTypeV1::Key, - &targets[0].1, - EditorElementPropertyPatchV1::InactiveImage("different.png".to_string()), - )]); - assert_eq!( - store.commit_editor_document(reused).unwrap_err().error_code, - EditorCommitErrorCode::MutationIdReused - ); - - let no_change = store - .commit_editor_document(editor_ops_request( - changed.result.revision, - uuid::Uuid::new_v4().to_string(), - ops[..4].to_vec(), - )) - .unwrap(); - assert!(no_change.result.changed_fields.is_empty()); - assert!(no_change.event.is_none()); - assert_eq!(store.history_status().history_revision, history_revision); - - let gate = store.history_gate(); - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Undo, - &undo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let undone = store.editor_get().document; - for position in [ - &undone.key_positions["4key"][0], - &undone.stat_positions["4key"][0].position, - &undone.graph_positions["4key"][0].position, - &undone.knob_positions["4key"][0].position, - ] { - assert_eq!(position.inactive_image, None); - assert_eq!(position.active_image.as_deref(), Some("active-sibling.png")); - } - - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Redo, - &redo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let redone = store.editor_get().document; - for position in [ - &redone.key_positions["4key"][0], - &redone.stat_positions["4key"][0].position, - &redone.graph_positions["4key"][0].position, - &redone.knob_positions["4key"][0].position, - ] { - assert_eq!(position.inactive_image.as_deref(), Some("")); - assert_eq!(position.active_image.as_deref(), Some("active-sibling.png")); - } - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn active_image_batch_replays_raw_empty_and_round_trips_one_history_entry() { - let dir = test_directory("editor-active-image-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = initialize_neutral_editor_store(&dir); - let mut document = store.editor_get().document; - let mut template = document.key_positions["4key"][0].clone(); - template.active_image = None; - template.inactive_image = Some("idle-sibling.png".to_string()); - template.active_image_fit = Some(crate::models::ImageFit::Contain); - template.active_transparent = true; - template.counter.enabled = false; - document.key_positions.get_mut("4key").unwrap()[0] = template.clone(); - let knob_id = uuid::Uuid::new_v4().to_string(); - document.knob_positions.insert( - "4key".to_string(), - vec![KnobPosition { - axis_id: "axis-sibling".to_string(), - sensitivity: 2.0, - reverse: true, - position: KeyPosition { - id: knob_id.clone(), - ..template.clone() - }, - }], - ); - let setup = store - .commit_editor_document(editor_request( - 0, - uuid::Uuid::new_v4().to_string(), - EditorPatchV1 { - schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, - keys: Some(document.keys), - key_positions: Some(document.key_positions), - knob_positions: Some(document.knob_positions), - ..EditorPatchV1::default() - }, - )) - .unwrap(); - let patch = EditorElementPropertyPatchV1::ActiveImage(String::new()); - let targets = [ - (EditorElementTypeV1::Key, template.id.clone()), - (EditorElementTypeV1::Knob, knob_id), - ]; - let ops = targets - .iter() - .map(|(element_type, id)| patch_property_op(*element_type, id, patch.clone())) - .chain(std::iter::once(patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - patch.clone(), - ))) - .collect::>(); - let mutation_id = uuid::Uuid::new_v4().to_string(); - let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); - - let changed = store.commit_editor_document(request.clone()).unwrap(); - assert_eq!( - changed.result.changed_fields, - [EditorField::KeyPositions, EditorField::KnobPositions] - ); - assert_eq!( - changed - .result - .op_results - .as_ref() - .unwrap() - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - for position in [ - &changed.document.key_positions["4key"][0], - &changed.document.knob_positions["4key"][0].position, - ] { - assert_eq!(position.active_image.as_deref(), Some("")); - assert_eq!(position.inactive_image.as_deref(), Some("idle-sibling.png")); - assert_eq!( - position.active_image_fit, - Some(crate::models::ImageFit::Contain) - ); - assert!(position.active_transparent); - assert!(!position.counter.enabled); - } - assert_eq!( - changed.document.knob_positions["4key"][0].axis_id, - "axis-sibling" - ); - let history_revision = store.history_status().history_revision; - - let replay = store.commit_editor_document(request.clone()).unwrap(); - assert!(replay.replayed); - assert_eq!(replay.result, changed.result); - assert_eq!(store.history_status().history_revision, history_revision); - - let mut reused = request; - reused.ops = Some(vec![patch_property_op( - EditorElementTypeV1::Key, - &targets[0].1, - EditorElementPropertyPatchV1::ActiveImage("different.png".to_string()), - )]); - assert_eq!( - store.commit_editor_document(reused).unwrap_err().error_code, - EditorCommitErrorCode::MutationIdReused - ); - - let no_change = store - .commit_editor_document(editor_ops_request( - changed.result.revision, - uuid::Uuid::new_v4().to_string(), - ops[..2].to_vec(), - )) - .unwrap(); - assert!(no_change.result.changed_fields.is_empty()); - assert!(no_change.event.is_none()); - assert_eq!(store.history_status().history_revision, history_revision); - - let gate = store.history_gate(); - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Undo, - &undo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let undone = store.editor_get().document; - for position in [ - &undone.key_positions["4key"][0], - &undone.knob_positions["4key"][0].position, - ] { - assert_eq!(position.active_image, None); - assert_eq!(position.inactive_image.as_deref(), Some("idle-sibling.png")); - } - - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Redo, - &redo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let redone = store.editor_get().document; - for position in [ - &redone.key_positions["4key"][0], - &redone.knob_positions["4key"][0].position, - ] { - assert_eq!(position.active_image.as_deref(), Some("")); - assert_eq!(position.inactive_image.as_deref(), Some("idle-sibling.png")); - } - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn image_transparency_batch_replays_and_round_trips_one_history_entry() { - let dir = test_directory("editor-image-transparency-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = initialize_neutral_editor_store(&dir); - let mut document = store.editor_get().document; - let mut template = document.key_positions["4key"][0].clone(); - template.idle_transparent = false; - template.active_transparent = false; - template.inactive_image = Some("idle-sibling.png".to_string()); - template.active_image = Some("active-sibling.png".to_string()); - template.image_fit = Some(crate::models::ImageFit::Fill); - template.idle_image_fit = Some(crate::models::ImageFit::Contain); - template.active_image_fit = Some(crate::models::ImageFit::None); - template.counter.enabled = true; - let active_key_id = document.key_positions["4key"][1].id.clone(); - document.key_positions.get_mut("4key").unwrap()[0] = template.clone(); - document.key_positions.get_mut("4key").unwrap()[1] = KeyPosition { - id: active_key_id.clone(), - ..template.clone() - }; - let targets = [ - (EditorElementTypeV1::Key, template.id.clone()), - (EditorElementTypeV1::Stat, uuid::Uuid::new_v4().to_string()), - (EditorElementTypeV1::Graph, uuid::Uuid::new_v4().to_string()), - (EditorElementTypeV1::Knob, uuid::Uuid::new_v4().to_string()), - ]; - document.stat_positions.insert( - "4key".to_string(), - vec![StatPosition { - stat_type: StatType::Kps, - position: KeyPosition { - id: targets[1].1.clone(), - ..template.clone() - }, - }], - ); - document.graph_positions.insert( - "4key".to_string(), - vec![GraphPosition { - stat_type: GraphStatType::Kps, - graph_type: GraphType::Line, - graph_speed: 1000, - graph_color: "graph-sibling".to_string(), - show_avg_line: true, - position: KeyPosition { - id: targets[2].1.clone(), - ..template.clone() - }, - }], - ); - let active_knob_id = uuid::Uuid::new_v4().to_string(); - document.knob_positions.insert( - "4key".to_string(), - vec![ - KnobPosition { - axis_id: "axis-sibling".to_string(), - sensitivity: 2.0, - reverse: true, - position: KeyPosition { - id: targets[3].1.clone(), - ..template.clone() - }, - }, - KnobPosition { - axis_id: "active-axis-sibling".to_string(), - sensitivity: 3.0, - reverse: false, - position: KeyPosition { - id: active_knob_id.clone(), - ..template - }, - }, - ], - ); - let setup = store - .commit_editor_document(editor_request( - 0, - uuid::Uuid::new_v4().to_string(), - EditorPatchV1 { - schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, - keys: Some(document.keys), - key_positions: Some(document.key_positions), - stat_positions: Some(document.stat_positions), - graph_positions: Some(document.graph_positions), - knob_positions: Some(document.knob_positions), - ..EditorPatchV1::default() - }, - )) - .unwrap(); - let idle_patch = EditorElementPropertyPatchV1::IdleTransparent(true); - let active_patch = EditorElementPropertyPatchV1::ActiveTransparent(true); - let mut ops = targets - .iter() - .map(|(element_type, id)| patch_property_op(*element_type, id, idle_patch.clone())) - .collect::>(); - let active_targets = [ - (EditorElementTypeV1::Key, active_key_id), - (EditorElementTypeV1::Knob, active_knob_id), - ]; - ops.extend([ - patch_property_op( - active_targets[0].0, - &active_targets[0].1, - active_patch.clone(), - ), - patch_property_op( - active_targets[1].0, - &active_targets[1].1, - active_patch.clone(), - ), - patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - active_patch.clone(), - ), - ]); - let mutation_id = uuid::Uuid::new_v4().to_string(); - let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); - - let changed = store.commit_editor_document(request.clone()).unwrap(); - assert_eq!( - changed.result.changed_fields, - [ - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - ] - ); - assert_eq!( - changed - .result - .op_results - .as_ref() - .unwrap() - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - for position in [ - &changed.document.key_positions["4key"][0], - &changed.document.stat_positions["4key"][0].position, - &changed.document.graph_positions["4key"][0].position, - &changed.document.knob_positions["4key"][0].position, - ] { - assert!(position.idle_transparent); - assert_eq!(position.inactive_image.as_deref(), Some("idle-sibling.png")); - assert_eq!(position.active_image.as_deref(), Some("active-sibling.png")); - assert_eq!(position.image_fit, Some(crate::models::ImageFit::Fill)); - assert_eq!( - position.idle_image_fit, - Some(crate::models::ImageFit::Contain) - ); - assert_eq!( - position.active_image_fit, - Some(crate::models::ImageFit::None) - ); - assert!(position.counter.enabled); - } - assert!(!changed.document.key_positions["4key"][0].active_transparent); - assert!( - !changed.document.knob_positions["4key"][0] - .position - .active_transparent - ); - assert!(changed.document.key_positions["4key"][1].active_transparent); - assert!( - changed.document.knob_positions["4key"][1] - .position - .active_transparent - ); - assert!( - !changed.document.stat_positions["4key"][0] - .position - .active_transparent - ); - assert!( - !changed.document.graph_positions["4key"][0] - .position - .active_transparent - ); - assert_eq!( - changed.document.graph_positions["4key"][0].graph_color, - "graph-sibling" - ); - assert_eq!( - changed.document.knob_positions["4key"][0].axis_id, - "axis-sibling" - ); - let history_revision = store.history_status().history_revision; - - let replay = store.commit_editor_document(request.clone()).unwrap(); - assert!(replay.replayed); - assert_eq!(replay.result, changed.result); - assert_eq!(store.history_status().history_revision, history_revision); - - let mut reused = request; - reused.ops = Some(vec![patch_property_op( - EditorElementTypeV1::Key, - &targets[0].1, - EditorElementPropertyPatchV1::IdleTransparent(false), - )]); - assert_eq!( - store.commit_editor_document(reused).unwrap_err().error_code, - EditorCommitErrorCode::MutationIdReused - ); - - let no_change = store - .commit_editor_document(editor_ops_request( - changed.result.revision, - uuid::Uuid::new_v4().to_string(), - ops[..6].to_vec(), - )) - .unwrap(); - assert!(no_change.result.changed_fields.is_empty()); - assert!(no_change.event.is_none()); - assert_eq!(store.history_status().history_revision, history_revision); - - let gate = store.history_gate(); - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Undo, - &undo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let undone = store.editor_get().document; - for position in [ - &undone.key_positions["4key"][0], - &undone.stat_positions["4key"][0].position, - &undone.graph_positions["4key"][0].position, - &undone.knob_positions["4key"][0].position, - ] { - assert!(!position.idle_transparent); - assert!(!position.active_transparent); - assert_eq!(position.image_fit, Some(crate::models::ImageFit::Fill)); - } - assert!(!undone.key_positions["4key"][1].active_transparent); - assert!(!undone.knob_positions["4key"][1].position.active_transparent); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Redo, - &redo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let redone = store.editor_get().document; - for position in [ - &redone.key_positions["4key"][0], - &redone.stat_positions["4key"][0].position, - &redone.graph_positions["4key"][0].position, - &redone.knob_positions["4key"][0].position, - ] { - assert!(position.idle_transparent); - assert_eq!(position.image_fit, Some(crate::models::ImageFit::Fill)); - } - assert!(!redone.key_positions["4key"][0].active_transparent); - assert!(!redone.knob_positions["4key"][0].position.active_transparent); - assert!(redone.key_positions["4key"][1].active_transparent); - assert!(redone.knob_positions["4key"][1].position.active_transparent); - assert!(!redone.stat_positions["4key"][0].position.active_transparent); - assert!( - !redone.graph_positions["4key"][0] - .position - .active_transparent - ); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn image_fit_patches_replay_and_round_trip_raw_option_history() { - let dir = test_directory("editor-image-fit-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = initialize_neutral_editor_store(&dir); - let mut document = store.editor_get().document; - let mut template = document.key_positions["4key"][0].clone(); - template.image_fit = Some(crate::models::ImageFit::Cover); - template.idle_image_fit = None; - template.active_image_fit = None; - template.idle_transparent = true; - template.active_transparent = true; - template.inactive_image = Some("idle-sibling.png".to_string()); - template.active_image = Some("active-sibling.png".to_string()); - template.counter.enabled = true; - document.key_positions.get_mut("4key").unwrap()[0] = template.clone(); - let knob_id = uuid::Uuid::new_v4().to_string(); - document.knob_positions.insert( - "4key".to_string(), - vec![KnobPosition { - axis_id: "axis-sibling".to_string(), - sensitivity: 2.0, - reverse: true, - position: KeyPosition { - id: knob_id.clone(), - ..template.clone() - }, - }], - ); - let setup = store - .commit_editor_document(editor_request( - 0, - uuid::Uuid::new_v4().to_string(), - EditorPatchV1 { - schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, - keys: Some(document.keys), - key_positions: Some(document.key_positions), - knob_positions: Some(document.knob_positions), - ..EditorPatchV1::default() - }, - )) - .unwrap(); - let idle_patch = EditorElementPropertyPatchV1::IdleImageFit(crate::models::ImageFit::Cover); - let active_patch = - EditorElementPropertyPatchV1::ActiveImageFit(crate::models::ImageFit::Cover); - let ops = vec![ - patch_property_op(EditorElementTypeV1::Key, &template.id, idle_patch.clone()), - patch_property_op(EditorElementTypeV1::Knob, &knob_id, active_patch.clone()), - patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - idle_patch.clone(), - ), - ]; - let mutation_id = uuid::Uuid::new_v4().to_string(); - let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); - - let changed = store.commit_editor_document(request.clone()).unwrap(); - assert_eq!( - changed.result.changed_fields, - [EditorField::KeyPositions, EditorField::KnobPositions] - ); - assert_eq!( - changed - .result - .op_results - .as_ref() - .unwrap() - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - let key = &changed.document.key_positions["4key"][0]; - let knob = &changed.document.knob_positions["4key"][0]; - assert_eq!(key.idle_image_fit, Some(crate::models::ImageFit::Cover)); - assert_eq!(key.active_image_fit, None); - assert_eq!( - knob.position.active_image_fit, - Some(crate::models::ImageFit::Cover) - ); - assert_eq!(knob.position.idle_image_fit, None); - for position in [key, &knob.position] { - assert_eq!(position.image_fit, Some(crate::models::ImageFit::Cover)); - assert!(position.idle_transparent); - assert!(position.active_transparent); - assert_eq!(position.inactive_image.as_deref(), Some("idle-sibling.png")); - assert_eq!(position.active_image.as_deref(), Some("active-sibling.png")); - assert!(position.counter.enabled); - } - assert_eq!(knob.axis_id, "axis-sibling"); - let history_revision = store.history_status().history_revision; - - let replay = store.commit_editor_document(request.clone()).unwrap(); - assert!(replay.replayed); - assert_eq!(replay.result, changed.result); - assert_eq!(store.history_status().history_revision, history_revision); - - let mut reused = request; - reused.ops = Some(vec![patch_property_op( - EditorElementTypeV1::Key, - &template.id, - EditorElementPropertyPatchV1::IdleImageFit(crate::models::ImageFit::Contain), - )]); - assert_eq!( - store.commit_editor_document(reused).unwrap_err().error_code, - EditorCommitErrorCode::MutationIdReused - ); - - let no_change = store - .commit_editor_document(editor_ops_request( - changed.result.revision, - uuid::Uuid::new_v4().to_string(), - ops[..2].to_vec(), - )) - .unwrap(); - assert!(no_change.result.changed_fields.is_empty()); - assert!(no_change.event.is_none()); - assert_eq!(store.history_status().history_revision, history_revision); - - let gate = store.history_gate(); - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Undo, - &undo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let undone = store.editor_get().document; - assert_eq!(undone.key_positions["4key"][0].idle_image_fit, None); - assert_eq!( - undone.knob_positions["4key"][0].position.active_image_fit, - None - ); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Redo, - &redo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let redone = store.editor_get().document; - assert_eq!( - redone.key_positions["4key"][0].idle_image_fit, - Some(crate::models::ImageFit::Cover) - ); - assert_eq!( - redone.knob_positions["4key"][0].position.active_image_fit, - Some(crate::models::ImageFit::Cover) - ); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn sound_path_batch_replays_raw_empty_and_round_trips_one_history_entry() { - let dir = test_directory("editor-sound-path-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = initialize_neutral_editor_store(&dir); - let mut document = store.editor_get().document; - let mut first = document.key_positions["4key"][0].clone(); - first.sound_path = None; - first.sound_enabled = Some(true); - first.sound_volume = Some(137.5); - first.inactive_image = Some("idle-sibling.png".to_string()); - first.active_image = Some("active-sibling.png".to_string()); - first.counter.enabled = false; - let second_id = document.key_positions["4key"][1].id.clone(); - document.key_positions.get_mut("4key").unwrap()[0] = first.clone(); - document.key_positions.get_mut("4key").unwrap()[1] = KeyPosition { - id: second_id.clone(), - ..first.clone() - }; - let setup = store - .commit_editor_document(editor_request( - 0, - uuid::Uuid::new_v4().to_string(), - EditorPatchV1 { - schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, - keys: Some(document.keys), - key_positions: Some(document.key_positions), - ..EditorPatchV1::default() - }, - )) - .unwrap(); - let patch = EditorElementPropertyPatchV1::SoundPath(String::new()); - let targets = [first.id, second_id]; - let ops = targets - .iter() - .map(|id| patch_property_op(EditorElementTypeV1::Key, id, patch.clone())) - .chain(std::iter::once(patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - patch.clone(), - ))) - .collect::>(); - let mutation_id = uuid::Uuid::new_v4().to_string(); - let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); - - let changed = store.commit_editor_document(request.clone()).unwrap(); - assert_eq!(changed.result.changed_fields, [EditorField::KeyPositions]); - assert_eq!( - changed - .result - .op_results - .as_ref() - .unwrap() - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - for position in changed.document.key_positions["4key"].iter().take(2) { - assert_eq!(position.sound_path.as_deref(), Some("")); - assert_eq!(position.sound_enabled, Some(true)); - assert_eq!(position.sound_volume, Some(137.5)); - assert_eq!(position.inactive_image.as_deref(), Some("idle-sibling.png")); - assert_eq!(position.active_image.as_deref(), Some("active-sibling.png")); - assert!(!position.counter.enabled); - } - let history_revision = store.history_status().history_revision; - - let replay = store.commit_editor_document(request.clone()).unwrap(); - assert!(replay.replayed); - assert_eq!(replay.result, changed.result); - assert_eq!(store.history_status().history_revision, history_revision); - - let mut reused = request; - reused.ops = Some(vec![patch_property_op( - EditorElementTypeV1::Key, - &targets[0], - EditorElementPropertyPatchV1::SoundPath("different.wav".to_string()), - )]); - assert_eq!( - store.commit_editor_document(reused).unwrap_err().error_code, - EditorCommitErrorCode::MutationIdReused - ); - - let no_change = store - .commit_editor_document(editor_ops_request( - changed.result.revision, - uuid::Uuid::new_v4().to_string(), - ops[..2].to_vec(), - )) - .unwrap(); - assert!(no_change.result.changed_fields.is_empty()); - assert!(no_change.event.is_none()); - assert_eq!(store.history_status().history_revision, history_revision); - - let gate = store.history_gate(); - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Undo, - &undo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let undone = store.editor_get().document; - for position in undone.key_positions["4key"].iter().take(2) { - assert_eq!(position.sound_path, None); - assert_eq!(position.sound_enabled, Some(true)); - assert_eq!(position.sound_volume, Some(137.5)); - } - - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Redo, - &redo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let redone = store.editor_get().document; - for position in redone.key_positions["4key"].iter().take(2) { - assert_eq!(position.sound_path.as_deref(), Some("")); - assert_eq!(position.sound_enabled, Some(true)); - assert_eq!(position.sound_volume, Some(137.5)); - } - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn sound_enabled_batch_replays_and_round_trips_one_history_entry() { - let dir = test_directory("editor-sound-enabled-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = initialize_neutral_editor_store(&dir); - let mut document = store.editor_get().document; - let mut first = document.key_positions["4key"][0].clone(); - first.sound_enabled = None; - first.sound_path = Some("sounds/sibling.wav".to_string()); - first.sound_volume = Some(137.5); - first.inactive_image = Some("idle-sibling.png".to_string()); - first.active_image = Some("active-sibling.png".to_string()); - first.counter.enabled = false; - let second_id = document.key_positions["4key"][1].id.clone(); - document.key_positions.get_mut("4key").unwrap()[0] = first.clone(); - document.key_positions.get_mut("4key").unwrap()[1] = KeyPosition { - id: second_id.clone(), - sound_enabled: Some(false), - ..first.clone() - }; - let setup = store - .commit_editor_document(editor_request( - 0, - uuid::Uuid::new_v4().to_string(), - EditorPatchV1 { - schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, - keys: Some(document.keys), - key_positions: Some(document.key_positions), - ..EditorPatchV1::default() - }, - )) - .unwrap(); - let patch = EditorElementPropertyPatchV1::SoundEnabled(false); - let targets = [first.id, second_id]; - let ops = targets - .iter() - .map(|id| patch_property_op(EditorElementTypeV1::Key, id, patch.clone())) - .chain(std::iter::once(patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - patch.clone(), - ))) - .collect::>(); - let mutation_id = uuid::Uuid::new_v4().to_string(); - let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); - - let changed = store.commit_editor_document(request.clone()).unwrap(); - assert_eq!(changed.result.changed_fields, [EditorField::KeyPositions]); - assert_eq!( - changed - .result - .op_results - .as_ref() - .unwrap() - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::TargetMissing, - ] - ); - for position in changed.document.key_positions["4key"].iter().take(2) { - assert_eq!(position.sound_enabled, Some(false)); - assert_eq!(position.sound_path.as_deref(), Some("sounds/sibling.wav")); - assert_eq!(position.sound_volume, Some(137.5)); - assert_eq!(position.inactive_image.as_deref(), Some("idle-sibling.png")); - assert_eq!(position.active_image.as_deref(), Some("active-sibling.png")); - assert!(!position.counter.enabled); - } - let history_revision = store.history_status().history_revision; - - let replay = store.commit_editor_document(request.clone()).unwrap(); - assert!(replay.replayed); - assert_eq!(replay.result, changed.result); - assert_eq!(store.history_status().history_revision, history_revision); - - let mut reused = request; - reused.ops = Some(vec![patch_property_op( - EditorElementTypeV1::Key, - &targets[0], - EditorElementPropertyPatchV1::SoundEnabled(true), - )]); - assert_eq!( - store.commit_editor_document(reused).unwrap_err().error_code, - EditorCommitErrorCode::MutationIdReused - ); - - let no_change = store - .commit_editor_document(editor_ops_request( - changed.result.revision, - uuid::Uuid::new_v4().to_string(), - ops[..2].to_vec(), - )) - .unwrap(); - assert!(no_change.result.changed_fields.is_empty()); - assert!(no_change.event.is_none()); - assert_eq!(store.history_status().history_revision, history_revision); - - let gate = store.history_gate(); - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Undo, - &undo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let undone = store.editor_get().document; - assert_eq!(undone.key_positions["4key"][0].sound_enabled, None); - assert_eq!(undone.key_positions["4key"][1].sound_enabled, Some(false)); - for position in undone.key_positions["4key"].iter().take(2) { - assert_eq!(position.sound_path.as_deref(), Some("sounds/sibling.wav")); - assert_eq!(position.sound_volume, Some(137.5)); - } - - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Redo, - &redo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let redone = store.editor_get().document; - for position in redone.key_positions["4key"].iter().take(2) { - assert_eq!(position.sound_enabled, Some(false)); - assert_eq!(position.sound_path.as_deref(), Some("sounds/sibling.wav")); - assert_eq!(position.sound_volume, Some(137.5)); - } - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn sound_volume_batch_replays_and_round_trips_one_history_entry() { - let dir = test_directory("editor-sound-volume-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = initialize_neutral_editor_store(&dir); - let mut document = store.editor_get().document; - let mut first = document.key_positions["4key"][0].clone(); - first.sound_volume = None; - first.sound_enabled = Some(true); - first.sound_path = Some("sounds/sibling.wav".to_string()); - first.inactive_image = Some("idle-sibling.png".to_string()); - first.active_image = Some("active-sibling.png".to_string()); - first.counter.enabled = false; - let second_id = document.key_positions["4key"][1].id.clone(); - document.key_positions.get_mut("4key").unwrap()[0] = first.clone(); - document.key_positions.get_mut("4key").unwrap()[1] = KeyPosition { - id: second_id.clone(), - sound_volume: Some(100.0), - ..first.clone() - }; - let setup = store - .commit_editor_document(editor_request( - 0, - uuid::Uuid::new_v4().to_string(), - EditorPatchV1 { - schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, - keys: Some(document.keys), - key_positions: Some(document.key_positions), - ..EditorPatchV1::default() - }, - )) - .unwrap(); - let patch = EditorElementPropertyPatchV1::SoundVolume(100.0); - let targets = [first.id, second_id]; - let ops = targets - .iter() - .map(|id| patch_property_op(EditorElementTypeV1::Key, id, patch.clone())) - .chain(std::iter::once(patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - patch.clone(), - ))) - .collect::>(); - let mutation_id = uuid::Uuid::new_v4().to_string(); - let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); - - let changed = store.commit_editor_document(request.clone()).unwrap(); - assert_eq!(changed.result.changed_fields, [EditorField::KeyPositions]); - assert_eq!( - changed - .result - .op_results - .as_ref() - .unwrap() - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::TargetMissing, - ] - ); - for position in changed.document.key_positions["4key"].iter().take(2) { - assert_eq!(position.sound_volume, Some(100.0)); - assert_eq!(position.sound_enabled, Some(true)); - assert_eq!(position.sound_path.as_deref(), Some("sounds/sibling.wav")); - assert_eq!(position.inactive_image.as_deref(), Some("idle-sibling.png")); - assert_eq!(position.active_image.as_deref(), Some("active-sibling.png")); - assert!(!position.counter.enabled); - } - let history_revision = store.history_status().history_revision; - - let replay = store.commit_editor_document(request.clone()).unwrap(); - assert!(replay.replayed); - assert_eq!(replay.result, changed.result); - assert_eq!(store.history_status().history_revision, history_revision); - - let mut reused = request; - reused.ops = Some(vec![patch_property_op( - EditorElementTypeV1::Key, - &targets[0], - EditorElementPropertyPatchV1::SoundVolume(50.0), - )]); - assert_eq!( - store.commit_editor_document(reused).unwrap_err().error_code, - EditorCommitErrorCode::MutationIdReused - ); - - let no_change = store - .commit_editor_document(editor_ops_request( - changed.result.revision, - uuid::Uuid::new_v4().to_string(), - ops[..2].to_vec(), - )) - .unwrap(); - assert!(no_change.result.changed_fields.is_empty()); - assert!(no_change.event.is_none()); - assert_eq!(store.history_status().history_revision, history_revision); - - let gate = store.history_gate(); - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Undo, - &undo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let undone = store.editor_get().document; - assert_eq!(undone.key_positions["4key"][0].sound_volume, None); - assert_eq!(undone.key_positions["4key"][1].sound_volume, Some(100.0)); - for position in undone.key_positions["4key"].iter().take(2) { - assert_eq!(position.sound_enabled, Some(true)); - assert_eq!(position.sound_path.as_deref(), Some("sounds/sibling.wav")); - } - - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Redo, - &redo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let redone = store.editor_get().document; - for position in redone.key_positions["4key"].iter().take(2) { - assert_eq!(position.sound_volume, Some(100.0)); - assert_eq!(position.sound_enabled, Some(true)); - assert_eq!(position.sound_path.as_deref(), Some("sounds/sibling.wav")); - } - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn counter_boolean_batch_replays_and_round_trips_one_history_entry() { - let dir = test_directory("editor-counter-boolean-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let key_id = store.editor_get().document.key_positions["4key"][0] - .id - .clone(); - let stat_id = uuid::Uuid::new_v4().to_string(); - let setup = legacy_editor_commit( - &store, - &[EditorField::KeyPositions, EditorField::StatPositions], - |data| { - let key = &mut data.key_positions.get_mut("4key").unwrap()[0]; - key.counter.enabled = true; - key.counter.placement = crate::models::KeyCounterPlacement::Outside; - key.counter.animation.enabled = true; - key.counter.animation.preset_id = Some("builtin-linear".to_string()); - key.counter.animation.scale = 1.75; - let mut stat_position = key.clone(); - stat_position.id = stat_id.clone(); - data.stat_positions.insert( - "4key".to_string(), - vec![StatPosition { - stat_type: StatType::Kps, - position: stat_position, - }], - ); - }, - ) - .unwrap(); - let counter_enabled = EditorElementPropertyPatchV1::CounterEnabled(false); - let animation_enabled = EditorElementPropertyPatchV1::CounterAnimationEnabled(false); - let ops = vec![ - patch_property_op(EditorElementTypeV1::Key, &key_id, counter_enabled.clone()), - patch_property_op( - EditorElementTypeV1::Stat, - &stat_id, - animation_enabled.clone(), - ), - patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - counter_enabled.clone(), - ), - ]; - let mutation_id = uuid::Uuid::new_v4().to_string(); - let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); - - let changed = store.commit_editor_document(request.clone()).unwrap(); - assert_eq!( - changed.result.changed_fields, - [EditorField::KeyPositions, EditorField::StatPositions] - ); - assert_eq!( - changed - .result - .op_results - .as_ref() - .unwrap() - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - let key_counter = &changed.document.key_positions["4key"][0].counter; - assert!(!key_counter.enabled); - assert!(key_counter.animation.enabled); - assert_eq!( - key_counter.animation.preset_id.as_deref(), - Some("builtin-linear") - ); - assert_eq!(key_counter.animation.scale, 1.75); - let stat_counter = &changed.document.stat_positions["4key"][0].position.counter; - assert!(stat_counter.enabled); - assert!(!stat_counter.animation.enabled); - assert_eq!( - stat_counter.animation.preset_id.as_deref(), - Some("builtin-linear") - ); - assert_eq!(stat_counter.animation.scale, 1.75); - let history_revision = store.history_status().history_revision; - - let replay = store.commit_editor_document(request.clone()).unwrap(); - assert!(replay.replayed); - assert_eq!(replay.result, changed.result); - assert_eq!(store.history_status().history_revision, history_revision); - - let mut reused = request; - reused.ops = Some(vec![patch_property_op( - EditorElementTypeV1::Key, - &key_id, - animation_enabled, - )]); - assert_eq!( - store.commit_editor_document(reused).unwrap_err().error_code, - EditorCommitErrorCode::MutationIdReused - ); - - let no_change = store - .commit_editor_document(editor_ops_request( - changed.result.revision, - uuid::Uuid::new_v4().to_string(), - ops[..2].to_vec(), - )) - .unwrap(); - assert!(no_change.result.changed_fields.is_empty()); - assert!(no_change.event.is_none()); - assert_eq!(store.history_status().history_revision, history_revision); - - let gate = store.history_gate(); - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Undo, - &undo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let undone = store.editor_get().document; - assert!(undone.key_positions["4key"][0].counter.enabled); - assert!( - undone.stat_positions["4key"][0] - .position - .counter - .animation - .enabled - ); - for counter in [ - &undone.key_positions["4key"][0].counter, - &undone.stat_positions["4key"][0].position.counter, - ] { - assert_eq!( - counter.animation.preset_id.as_deref(), - Some("builtin-linear") - ); - assert_eq!(counter.animation.scale, 1.75); - } - - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Redo, - &redo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let redone = store.editor_get().document; - assert!(!redone.key_positions["4key"][0].counter.enabled); - assert!( - !redone.stat_positions["4key"][0] - .position - .counter - .animation - .enabled - ); - assert_eq!( - redone.stat_positions["4key"][0] - .position - .counter - .animation - .preset_id - .as_deref(), - Some("builtin-linear") - ); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn counter_layout_batch_replays_and_round_trips_one_history_entry() { - let dir = test_directory("editor-counter-layout-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let stat_ids = [ - uuid::Uuid::new_v4().to_string(), - uuid::Uuid::new_v4().to_string(), - ]; - let setup = legacy_editor_commit( - &store, - &[EditorField::KeyPositions, EditorField::StatPositions], - |data| { - let key_positions = data.key_positions.get_mut("4key").unwrap(); - for position in key_positions.iter_mut().take(2) { - let counter = &mut position.counter; - counter.enabled = false; - counter.placement = crate::models::KeyCounterPlacement::Inside; - counter.align = crate::models::KeyCounterAlign::Bottom; - counter.align_mode = crate::models::KeyCounterAlignMode::Between; - counter.gap = 7; - counter.fill.idle = "fill-sibling".to_string(); - counter.fill.active = "active-fill-sibling".to_string(); - counter.font_family = Some("font-sibling".to_string()); - counter.animation.enabled = false; - counter.animation.preset_id = Some("builtin-linear".to_string()); - counter.animation.scale = 1.75; - } - let stat_positions = key_positions - .iter() - .take(2) - .zip(stat_ids.iter()) - .map(|(position, id)| { - let mut position = position.clone(); - position.id = id.clone(); - StatPosition { - stat_type: StatType::Kps, - position, - } - }) - .collect(); - data.stat_positions - .insert("4key".to_string(), stat_positions); - }, - ) - .unwrap(); - let before = store.editor_get().document; - let key_ids = before.key_positions["4key"] - .iter() - .take(2) - .map(|position| position.id.clone()) - .collect::>(); - let original_counters = [ - before.key_positions["4key"][0].counter.clone(), - before.key_positions["4key"][1].counter.clone(), - before.stat_positions["4key"][0].position.counter.clone(), - before.stat_positions["4key"][1].position.counter.clone(), - ]; - let ops = vec![ - patch_property_op( - EditorElementTypeV1::Key, - &key_ids[0], - EditorElementPropertyPatchV1::CounterPlacement( - crate::models::KeyCounterPlacement::Outside, - ), - ), - patch_property_op( - EditorElementTypeV1::Key, - &key_ids[1], - EditorElementPropertyPatchV1::CounterAlign(crate::models::KeyCounterAlign::Right), - ), - patch_property_op( - EditorElementTypeV1::Stat, - &stat_ids[0], - EditorElementPropertyPatchV1::CounterAlignMode( - crate::models::KeyCounterAlignMode::Center, - ), - ), - patch_property_op( - EditorElementTypeV1::Stat, - &stat_ids[1], - EditorElementPropertyPatchV1::CounterGap(u32::MAX), - ), - patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - EditorElementPropertyPatchV1::CounterGap(9), - ), - ]; - let mutation_id = uuid::Uuid::new_v4().to_string(); - let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); - - let changed = store.commit_editor_document(request.clone()).unwrap(); - assert_eq!( - changed.result.changed_fields, - [EditorField::KeyPositions, EditorField::StatPositions] - ); - assert_eq!( - changed - .result - .op_results - .as_ref() - .unwrap() - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - let changed_counters = [ - changed.document.key_positions["4key"][0].counter.clone(), - changed.document.key_positions["4key"][1].counter.clone(), - changed.document.stat_positions["4key"][0] - .position - .counter - .clone(), - changed.document.stat_positions["4key"][1] - .position - .counter - .clone(), - ]; - let mut expected_counters = original_counters.clone(); - expected_counters[0].placement = crate::models::KeyCounterPlacement::Outside; - expected_counters[1].align = crate::models::KeyCounterAlign::Right; - expected_counters[2].align_mode = crate::models::KeyCounterAlignMode::Center; - expected_counters[3].gap = u32::MAX; - assert_eq!(changed_counters, expected_counters); - assert!(matches!( - changed_counters[0].align_mode, - crate::models::KeyCounterAlignMode::Between - )); - let history_revision = store.history_status().history_revision; - - let replay = store.commit_editor_document(request.clone()).unwrap(); - assert!(replay.replayed); - assert_eq!(replay.result, changed.result); - assert_eq!(store.history_status().history_revision, history_revision); - - let mut reused = request; - reused.ops = Some(vec![patch_property_op( - EditorElementTypeV1::Key, - &key_ids[0], - EditorElementPropertyPatchV1::CounterGap(13), - )]); - assert_eq!( - store.commit_editor_document(reused).unwrap_err().error_code, - EditorCommitErrorCode::MutationIdReused - ); - - let no_change = store - .commit_editor_document(editor_ops_request( - changed.result.revision, - uuid::Uuid::new_v4().to_string(), - ops[..4].to_vec(), - )) - .unwrap(); - assert!(no_change.result.changed_fields.is_empty()); - assert!(no_change.event.is_none()); - assert_eq!(store.history_status().history_revision, history_revision); - - let gate = store.history_gate(); - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Undo, - &undo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let undone = store.editor_get().document; - assert_eq!( - [ - undone.key_positions["4key"][0].counter.clone(), - undone.key_positions["4key"][1].counter.clone(), - undone.stat_positions["4key"][0].position.counter.clone(), - undone.stat_positions["4key"][1].position.counter.clone(), - ], - original_counters - ); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Redo, - &redo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let redone = store.editor_get().document; - assert_eq!( - [ - redone.key_positions["4key"][0].counter.clone(), - redone.key_positions["4key"][1].counter.clone(), - redone.stat_positions["4key"][0].position.counter.clone(), - redone.stat_positions["4key"][1].position.counter.clone(), - ], - expected_counters - ); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn counter_typography_batch_replays_and_round_trips_one_history_entry() { - let dir = test_directory("editor-counter-typography-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let stat_ids = (0..4) - .map(|_| uuid::Uuid::new_v4().to_string()) - .collect::>(); - let setup = legacy_editor_commit( - &store, - &[EditorField::KeyPositions, EditorField::StatPositions], - |data| { - let key_positions = data.key_positions.get_mut("4key").unwrap(); - for position in key_positions.iter_mut().take(2) { - let counter = &mut position.counter; - counter.font_size = 16; - counter.font_weight = 400; - counter.font_italic = false; - counter.font_underline = false; - counter.font_strikethrough = false; - counter.font_family = Some("font-sibling".to_string()); - counter.fill.idle = "fill-sibling".to_string(); - counter.fill.active = "active-fill-sibling".to_string(); - counter.placement = crate::models::KeyCounterPlacement::Outside; - counter.animation.preset_id = Some("builtin-linear".to_string()); - } - let stat_positions = key_positions - .iter() - .cycle() - .zip(stat_ids.iter()) - .map(|(position, id)| { - let mut position = position.clone(); - position.id = id.clone(); - StatPosition { - stat_type: StatType::Kps, - position, - } - }) - .collect(); - data.stat_positions - .insert("4key".to_string(), stat_positions); - data.stat_positions.get_mut("4key").unwrap()[3] - .position - .counter - .font_family = None; - data.stat_positions.get_mut("4key").unwrap()[3] - .position - .font_family = Some("top-level-font-sibling".to_string()); - }, - ) - .unwrap(); - let before = store.editor_get().document; - let key_ids = before.key_positions["4key"] - .iter() - .take(2) - .map(|position| position.id.clone()) - .collect::>(); - let original_counters = [ - before.key_positions["4key"][0].counter.clone(), - before.key_positions["4key"][1].counter.clone(), - before.stat_positions["4key"][0].position.counter.clone(), - before.stat_positions["4key"][1].position.counter.clone(), - before.stat_positions["4key"][2].position.counter.clone(), - before.stat_positions["4key"][3].position.counter.clone(), - ]; - let ops = vec![ - patch_property_op( - EditorElementTypeV1::Key, - &key_ids[0], - EditorElementPropertyPatchV1::CounterFontSize(72), - ), - patch_property_op( - EditorElementTypeV1::Key, - &key_ids[1], - EditorElementPropertyPatchV1::CounterFontWeight(900), - ), - patch_property_op( - EditorElementTypeV1::Stat, - &stat_ids[0], - EditorElementPropertyPatchV1::CounterFontItalic(true), - ), - patch_property_op( - EditorElementTypeV1::Stat, - &stat_ids[1], - EditorElementPropertyPatchV1::CounterFontUnderline(true), - ), - patch_property_op( - EditorElementTypeV1::Stat, - &stat_ids[2], - EditorElementPropertyPatchV1::CounterFontStrikethrough(true), - ), - patch_property_op( - EditorElementTypeV1::Stat, - &stat_ids[3], - EditorElementPropertyPatchV1::CounterFontFamily(" raw-counter-font ".to_string()), - ), - patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - EditorElementPropertyPatchV1::CounterFontSize(72), - ), - ]; - let mutation_id = uuid::Uuid::new_v4().to_string(); - let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); - - let changed = store.commit_editor_document(request.clone()).unwrap(); - assert_eq!( - changed.result.changed_fields, - [EditorField::KeyPositions, EditorField::StatPositions] - ); - assert_eq!( - changed - .result - .op_results - .as_ref() - .unwrap() - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - let changed_counters = [ - changed.document.key_positions["4key"][0].counter.clone(), - changed.document.key_positions["4key"][1].counter.clone(), - changed.document.stat_positions["4key"][0] - .position - .counter - .clone(), - changed.document.stat_positions["4key"][1] - .position - .counter - .clone(), - changed.document.stat_positions["4key"][2] - .position - .counter - .clone(), - changed.document.stat_positions["4key"][3] - .position - .counter - .clone(), - ]; - let mut expected_counters = original_counters.clone(); - expected_counters[0].font_size = 72; - expected_counters[1].font_weight = 900; - expected_counters[2].font_italic = true; - expected_counters[3].font_underline = true; - expected_counters[4].font_strikethrough = true; - expected_counters[5].font_family = Some(" raw-counter-font ".to_string()); - assert_eq!(changed_counters, expected_counters); - assert_eq!( - changed.document.stat_positions["4key"][3] - .position - .font_family - .as_deref(), - Some("top-level-font-sibling") - ); - let history_revision = store.history_status().history_revision; - - let replay = store.commit_editor_document(request.clone()).unwrap(); - assert!(replay.replayed); - assert_eq!(replay.result, changed.result); - assert_eq!(store.history_status().history_revision, history_revision); - - let mut reused = request; - reused.ops = Some(vec![patch_property_op( - EditorElementTypeV1::Key, - &key_ids[0], - EditorElementPropertyPatchV1::CounterFontSize(24), - )]); - assert_eq!( - store.commit_editor_document(reused).unwrap_err().error_code, - EditorCommitErrorCode::MutationIdReused - ); - - let no_change = store - .commit_editor_document(editor_ops_request( - changed.result.revision, - uuid::Uuid::new_v4().to_string(), - ops[..6].to_vec(), - )) - .unwrap(); - assert!(no_change.result.changed_fields.is_empty()); - assert!(no_change.event.is_none()); - assert_eq!(store.history_status().history_revision, history_revision); - - let gate = store.history_gate(); - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Undo, - &undo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let undone = store.editor_get().document; - assert_eq!( - [ - undone.key_positions["4key"][0].counter.clone(), - undone.key_positions["4key"][1].counter.clone(), - undone.stat_positions["4key"][0].position.counter.clone(), - undone.stat_positions["4key"][1].position.counter.clone(), - undone.stat_positions["4key"][2].position.counter.clone(), - undone.stat_positions["4key"][3].position.counter.clone(), - ], - original_counters - ); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Redo, - &redo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let redone = store.editor_get().document; - assert_eq!( - [ - redone.key_positions["4key"][0].counter.clone(), - redone.key_positions["4key"][1].counter.clone(), - redone.stat_positions["4key"][0].position.counter.clone(), - redone.stat_positions["4key"][1].position.counter.clone(), - redone.stat_positions["4key"][2].position.counter.clone(), - redone.stat_positions["4key"][3].position.counter.clone(), - ], - expected_counters - ); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn counter_fill_batch_replays_atomic_pairs_and_round_trips_history() { - let dir = test_directory("editor-counter-fill-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let stat_id = uuid::Uuid::new_v4().to_string(); - let setup = legacy_editor_commit( - &store, - &[EditorField::KeyPositions, EditorField::StatPositions], - |data| { - let key_positions = data.key_positions.get_mut("4key").unwrap(); - for position in key_positions.iter_mut().take(2) { - position.counter.fill.idle = "idle-before".to_string(); - position.counter.fill.active = "active-before".to_string(); - position.counter.fill_idle_gradient = Some( - serde_json::from_value(serde_json::json!({ - "angle": 15, - "stops": [ - { "color": "old-idle", "pos": 0 }, - { "color": "old-idle-end", "pos": 1 } - ] - })) - .unwrap(), - ); - position.counter.fill_active_gradient = Some( - serde_json::from_value(serde_json::json!({ - "angle": 30, - "stops": [ - { "color": "old-active", "pos": 0 }, - { "color": "old-active-end", "pos": 1 } - ] - })) - .unwrap(), - ); - position.counter.gap = 19; - position.counter.font_family = Some("font-sibling".to_string()); - } - let mut stat_position = key_positions[0].clone(); - stat_position.id = stat_id.clone(); - data.stat_positions.insert( - "4key".to_string(), - vec![StatPosition { - stat_type: StatType::Kps, - position: stat_position, - }], - ); - }, - ) - .unwrap(); - let before = store.editor_get().document; - let key_ids = before.key_positions["4key"] - .iter() - .take(2) - .map(|position| position.id.clone()) - .collect::>(); - let original_counters = [ - before.key_positions["4key"][0].counter.clone(), - before.key_positions["4key"][1].counter.clone(), - before.stat_positions["4key"][0].position.counter.clone(), - ]; - let idle_gradient = counter_fill_gradient( - "rgba(170,187,204,1)", - 45.0, - &[("#ABC", 0.0), ("#112233", 1.0)], - ); - let ops = vec![ - patch_property_op( - EditorElementTypeV1::Key, - &key_ids[0], - EditorElementPropertyPatchV1::CounterFillIdle(idle_gradient.clone()), - ), - patch_property_op( - EditorElementTypeV1::Key, - &key_ids[1], - EditorElementPropertyPatchV1::CounterFillActive(counter_fill_solid("")), - ), - patch_property_op( - EditorElementTypeV1::Stat, - &stat_id, - EditorElementPropertyPatchV1::CounterFillIdle(counter_fill_solid(" raw solid ")), - ), - patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - EditorElementPropertyPatchV1::CounterFillIdle(counter_fill_solid("missing")), - ), - ]; - let mutation_id = uuid::Uuid::new_v4().to_string(); - let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); - - let changed = store.commit_editor_document(request.clone()).unwrap(); - assert_eq!( - changed.result.changed_fields, - [EditorField::KeyPositions, EditorField::StatPositions] - ); - assert_eq!( - changed - .result - .op_results - .as_ref() - .unwrap() - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - let changed_counters = [ - changed.document.key_positions["4key"][0].counter.clone(), - changed.document.key_positions["4key"][1].counter.clone(), - changed.document.stat_positions["4key"][0] - .position - .counter - .clone(), - ]; - let mut expected_counters = original_counters.clone(); - expected_counters[0].fill.idle = "rgba(170,187,204,1)".to_string(); - expected_counters[0].fill_idle_gradient = match &idle_gradient { - crate::models::EditorCounterFillIntentV1::Gradient(intent) => { - Some(intent.gradient.to_gradient_spec()) - } - crate::models::EditorCounterFillIntentV1::Solid(_) => unreachable!(), - }; - expected_counters[1].fill.active.clear(); - expected_counters[1].fill_active_gradient = None; - expected_counters[2].fill.idle = " raw solid ".to_string(); - expected_counters[2].fill_idle_gradient = None; - assert_eq!(changed_counters, expected_counters); - let history_revision = store.history_status().history_revision; - - let replay = store.commit_editor_document(request.clone()).unwrap(); - assert!(replay.replayed); - assert_eq!(replay.result, changed.result); - assert_eq!(store.history_status().history_revision, history_revision); - - let mut reused = request; - reused.ops = Some(vec![patch_property_op( - EditorElementTypeV1::Key, - &key_ids[0], - EditorElementPropertyPatchV1::CounterFillIdle(counter_fill_solid("different")), - )]); - assert_eq!( - store.commit_editor_document(reused).unwrap_err().error_code, - EditorCommitErrorCode::MutationIdReused - ); - - let no_change = store - .commit_editor_document(editor_ops_request( - changed.result.revision, - uuid::Uuid::new_v4().to_string(), - ops[..3].to_vec(), - )) - .unwrap(); - assert!(no_change.result.changed_fields.is_empty()); - assert!(no_change.event.is_none()); - assert_eq!(store.history_status().history_revision, history_revision); - - let gate = store.history_gate(); - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Undo, - &undo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let undone = store.editor_get().document; - assert_eq!( - [ - undone.key_positions["4key"][0].counter.clone(), - undone.key_positions["4key"][1].counter.clone(), - undone.stat_positions["4key"][0].position.counter.clone(), - ], - original_counters - ); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Redo, - &redo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let redone = store.editor_get().document; - assert_eq!( - [ - redone.key_positions["4key"][0].counter.clone(), - redone.key_positions["4key"][1].counter.clone(), - redone.stat_positions["4key"][0].position.counter.clone(), - ], - expected_counters - ); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn counter_animation_preset_batch_replays_and_round_trips_one_history_entry() { - let dir = test_directory("editor-counter-animation-preset-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let preset = CounterAnimationPreset { - id: "user-current".to_string(), - name: "Current".to_string(), - source: CounterAnimationSource::User, - label_key: None, - bezier: [0.1, 0.2, 0.8, 0.9], - scale: 1.25, - duration_ms: 420, - }; - let key_id = store.editor_get().document.key_positions["4key"][0] - .id - .clone(); - let stat_id = uuid::Uuid::new_v4().to_string(); - store - .update(|data| data.counter_animation_presets.push(preset.clone())) - .unwrap(); - let setup = legacy_editor_commit( - &store, - &[EditorField::KeyPositions, EditorField::StatPositions], - |data| { - let key = &mut data.key_positions.get_mut("4key").unwrap()[0]; - key.counter.animation.enabled = false; - key.counter.animation.preset_id = Some("builtin-ease-out".to_string()); - key.counter.animation.bezier = [0.3, 0.4, 0.5, 0.6]; - key.counter.animation.scale = 0.75; - key.counter.animation.duration_ms = 777; - let mut stat_position = key.clone(); - stat_position.id = stat_id.clone(); - data.stat_positions.insert( - "4key".to_string(), - vec![StatPosition { - stat_type: StatType::Kps, - position: stat_position, - }], - ); - }, - ) - .unwrap(); - let partial = EditorElementPropertyPatchV1::CounterAnimationPreset( - crate::models::EditorCounterAnimationPresetIntentV1 { - preset_id: preset.id.clone(), - apply_preset_id: None, - bezier: None, - scale: Some(preset.scale), - duration_ms: None, - }, - ); - let full = EditorElementPropertyPatchV1::CounterAnimationPreset( - crate::models::EditorCounterAnimationPresetIntentV1 { - preset_id: preset.id.clone(), - apply_preset_id: Some(true), - bezier: Some(preset.bezier), - scale: Some(preset.scale), - duration_ms: Some(preset.duration_ms), - }, - ); - let ops = vec![ - patch_property_op(EditorElementTypeV1::Key, &key_id, partial.clone()), - patch_property_op(EditorElementTypeV1::Stat, &stat_id, full.clone()), - patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - full.clone(), - ), - ]; - let mutation_id = uuid::Uuid::new_v4().to_string(); - let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); - - let changed = store.commit_editor_document(request.clone()).unwrap(); - assert_eq!( - changed.result.changed_fields, - [EditorField::KeyPositions, EditorField::StatPositions] - ); - assert_eq!( - changed - .result - .op_results - .as_ref() - .unwrap() - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - let key_animation = &changed.document.key_positions["4key"][0].counter.animation; - assert!(!key_animation.enabled); - assert_eq!(key_animation.preset_id.as_deref(), Some("builtin-ease-out")); - assert_eq!(key_animation.bezier, [0.3, 0.4, 0.5, 0.6]); - assert_eq!(key_animation.scale, preset.scale); - assert_eq!(key_animation.duration_ms, 777); - let stat_animation = &changed.document.stat_positions["4key"][0] - .position - .counter - .animation; - assert_eq!( - stat_animation.preset_id.as_deref(), - Some(preset.id.as_str()) - ); - assert_eq!(stat_animation.bezier, preset.bezier); - assert_eq!(stat_animation.scale, preset.scale); - assert_eq!(stat_animation.duration_ms, preset.duration_ms); - let history_revision = store.history_status().history_revision; - - let replay = store.commit_editor_document(request.clone()).unwrap(); - assert!(replay.replayed); - assert_eq!(replay.result, changed.result); - assert_eq!(store.history_status().history_revision, history_revision); - - let mut reused = request; - reused.ops = Some(vec![patch_property_op( - EditorElementTypeV1::Key, - &key_id, - full.clone(), - )]); - assert_eq!( - store.commit_editor_document(reused).unwrap_err().error_code, - EditorCommitErrorCode::MutationIdReused - ); - - let no_change = store - .commit_editor_document(editor_ops_request( - changed.result.revision, - uuid::Uuid::new_v4().to_string(), - ops[..2].to_vec(), - )) - .unwrap(); - assert!(no_change.result.changed_fields.is_empty()); - assert!(no_change.event.is_none()); - assert_eq!(store.history_status().history_revision, history_revision); - - let gate = store.history_gate(); - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Undo, - &undo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let undone = store.editor_get().document; - for animation in [ - &undone.key_positions["4key"][0].counter.animation, - &undone.stat_positions["4key"][0].position.counter.animation, - ] { - assert_eq!(animation.preset_id.as_deref(), Some("builtin-ease-out")); - assert_eq!(animation.bezier, [0.3, 0.4, 0.5, 0.6]); - assert_eq!(animation.scale, 0.75); - assert_eq!(animation.duration_ms, 777); - } - - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Redo, - &redo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let redone = store.editor_get().document; - assert_eq!( - redone.key_positions["4key"][0] - .counter - .animation - .preset_id - .as_deref(), - Some("builtin-ease-out") - ); - assert_eq!( - redone.stat_positions["4key"][0] - .position - .counter - .animation - .preset_id - .as_deref(), - Some(preset.id.as_str()) - ); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn stat_type_patch_replays_and_round_trips_one_history_entry() { - let dir = test_directory("editor-stat-type-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial = store.editor_get(); - let stat_id = uuid::Uuid::new_v4().to_string(); - let stat = StatPosition { - stat_type: StatType::Kps, - position: KeyPosition { - id: stat_id.clone(), - ..initial.document.key_positions["4key"][0].clone() - }, - }; - let setup = store - .commit_editor_document(editor_request( - initial.revision, - uuid::Uuid::new_v4().to_string(), - EditorPatchV1 { - schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, - stat_positions: Some(HashMap::from([("4key".to_string(), vec![stat.clone()])])), - ..EditorPatchV1::default() - }, - )) - .unwrap(); - let patch = |stat_type| EditorElementPropertyPatchV1::StatType(stat_type); - let ops = vec![ - patch_property_op(EditorElementTypeV1::Stat, &stat_id, patch(StatType::Total)), - patch_property_op( - EditorElementTypeV1::Stat, - uuid::Uuid::new_v4().to_string(), - patch(StatType::KpsAvg), - ), - ]; - let mutation_id = uuid::Uuid::new_v4().to_string(); - let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); - - let changed = store.commit_editor_document(request.clone()).unwrap(); - assert_eq!(changed.result.changed_fields, [EditorField::StatPositions]); - assert_eq!( - changed.document.stat_positions["4key"][0].stat_type, - StatType::Total - ); - assert_eq!( - changed.document.stat_positions["4key"][0].position, - stat.position - ); - assert_eq!( - changed - .result - .op_results - .as_ref() - .unwrap() - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - let history_revision = store.history_status().history_revision; - - let replay = store.commit_editor_document(request.clone()).unwrap(); - assert!(replay.replayed); - assert_eq!(replay.result, changed.result); - assert_eq!(store.history_status().history_revision, history_revision); - - let mut reused = request; - reused.ops = Some(vec![patch_property_op( - EditorElementTypeV1::Stat, - &stat_id, - patch(StatType::KpsMax), - )]); - assert_eq!( - store.commit_editor_document(reused).unwrap_err().error_code, - EditorCommitErrorCode::MutationIdReused - ); - - let no_change = store - .commit_editor_document(editor_ops_request( - changed.result.revision, - uuid::Uuid::new_v4().to_string(), - vec![patch_property_op( - EditorElementTypeV1::Stat, - &stat_id, - patch(StatType::Total), - )], - )) - .unwrap(); - assert!(no_change.result.changed_fields.is_empty()); - assert!(no_change.event.is_none()); - assert_eq!(store.history_status().history_revision, history_revision); - - let gate = store.history_gate(); - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Undo, - &undo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - assert_eq!( - store.editor_get().document.stat_positions["4key"][0].stat_type, - StatType::Kps - ); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Redo, - &redo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - assert_eq!( - store.editor_get().document.stat_positions["4key"][0].stat_type, - StatType::Total - ); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn note_paint_batch_replays_and_round_trips_one_history_entry() { - let dir = test_directory("editor-note-paint-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let ids = store.editor_get().document.key_positions["4key"] - .iter() - .take(4) - .map(|position| position.id.clone()) - .collect::>(); - assert_eq!(ids.len(), 4); - let setup = legacy_editor_commit(&store, &[EditorField::KeyPositions], |data| { - let positions = data.key_positions.get_mut("4key").unwrap(); - positions[0].note_color = crate::models::NoteColor::Solid("old".to_string()); - positions[0].note_opacity_top = Some(17); - positions[0].note_opacity_bottom = Some(27); - positions[1].note_glow_color = None; - positions[1].note_glow_opacity = 70; - positions[1].note_glow_opacity_top = None; - positions[1].note_glow_opacity_bottom = None; - positions[2].note_border_color = None; - positions[2].note_border_opacity = 100; - positions[2].note_border_gradient = serde_json::from_value(serde_json::json!({ - "angle": 45, - "stops": [ - { "color": "#010203", "pos": 0 }, - { "color": "#040506", "pos": 1 } - ] - })) - .unwrap(); - positions[3].note_glow_color = None; - positions[3].note_glow_opacity = 61; - }) - .unwrap(); - let legacy_note_wire = - serde_json::to_vec(&setup.document.key_positions["4key"][0]).unwrap(); - let ops = vec![ - patch_property_op( - EditorElementTypeV1::Key, - &ids[0], - EditorElementPropertyPatchV1::NotePaint( - crate::models::EditorNotePaintIntentV1::Descriptor( - crate::models::EditorNotePaintDescriptorIntentV1 { - color: crate::models::EditorNoteColorV1::Gradient( - crate::models::EditorNoteGradientColorV1 { - kind: crate::models::EditorNoteGradientColorKindV1::Gradient, - top: "#112233".to_string(), - bottom: "#445566".to_string(), - }, - ), - opacity: 80, - gradient: Some(crate::models::EditorPaintGradientV1 { - angle: 45.0, - stops: vec![ - crate::models::EditorPaintGradientStopV1 { - color: "rgba(17,34,51,.5)".to_string(), - pos: 0.0, - }, - crate::models::EditorPaintGradientStopV1 { - color: "#44556640".to_string(), - pos: 1.0, - }, - ], - }), - }, - ), - ), - ), - patch_property_op( - EditorElementTypeV1::Key, - &ids[1], - EditorElementPropertyPatchV1::NoteGlowPaint( - crate::models::EditorNotePaintIntentV1::GradientOpacity( - crate::models::EditorNotePaintGradientOpacityIntentV1 { - opacity: 70, - opacity_top: 20, - opacity_bottom: 80, - }, - ), - ), - ), - patch_property_op( - EditorElementTypeV1::Key, - &ids[2], - EditorElementPropertyPatchV1::NoteBorderPaint( - crate::models::EditorNoteBorderPaintV1 { - color: "#112233".to_string(), - opacity: 73, - gradient: Some(crate::models::EditorPaintGradientV1 { - angle: 135.0, - stops: vec![ - crate::models::EditorPaintGradientStopV1 { - color: "rgba(17, 34, 51, .5)".to_string(), - pos: 0.0, - }, - crate::models::EditorPaintGradientStopV1 { - color: "#ABC8".to_string(), - pos: 1.0, - }, - ], - }), - }, - ), - ), - patch_property_op( - EditorElementTypeV1::Key, - &ids[3], - EditorElementPropertyPatchV1::NoteGlowPaint( - crate::models::EditorNotePaintIntentV1::Color( - crate::models::EditorNotePaintColorIntentV1 { - color: crate::models::EditorNoteColorV1::Solid(String::new()), - }, - ), - ), - ), - ]; - let mutation_id = uuid::Uuid::new_v4().to_string(); - let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); - let history_before = store.history_status().history_revision; - - let changed = store.commit_editor_document(request.clone()).unwrap(); - assert_eq!(changed.result.changed_fields, [EditorField::KeyPositions]); - assert!(changed - .result - .op_results - .as_ref() - .unwrap() - .iter() - .all(|result| result.status == EditorOpResultStatusV1::Applied)); - let positions = &changed.document.key_positions["4key"]; - assert_eq!(positions[0].note_opacity, 80); - assert_eq!(positions[0].note_opacity_top, Some(40)); - assert_eq!(positions[0].note_opacity_bottom, Some(20)); - assert_eq!(positions[0].note_gradient.as_ref().unwrap().angle, 45.0); - assert_eq!(positions[1].note_glow_opacity, 70); - assert_eq!(positions[1].note_glow_opacity_top, Some(20)); - assert_eq!(positions[1].note_glow_opacity_bottom, Some(80)); - assert!(positions[1].note_glow_color.is_none()); - assert_eq!(positions[2].note_border_color.as_deref(), Some("#112233")); - assert_eq!(positions[2].note_border_opacity, 73); - assert_eq!( - positions[2].note_border_gradient.as_ref().unwrap().angle, - 135.0 - ); - assert_eq!( - positions[3].note_glow_color, - Some(crate::models::NoteColor::Solid(String::new())) - ); - assert_eq!(positions[3].note_glow_opacity, 61); - assert_eq!(store.history_status().history_revision, history_before + 1); - - let replay = store.commit_editor_document(request.clone()).unwrap(); - assert!(replay.replayed); - assert_eq!(replay.result, changed.result); - assert_eq!(store.history_status().history_revision, history_before + 1); - - let mut reused = request; - reused.ops = Some(vec![patch_property_op( - EditorElementTypeV1::Key, - &ids[0], - EditorElementPropertyPatchV1::NotePaint( - crate::models::EditorNotePaintIntentV1::Opacity( - crate::models::EditorNotePaintOpacityIntentV1 { opacity: 56 }, - ), - ), - )]); - assert_eq!( - store.commit_editor_document(reused).unwrap_err().error_code, - EditorCommitErrorCode::MutationIdReused - ); - - let no_change = store - .commit_editor_document(editor_ops_request( - changed.result.revision, - uuid::Uuid::new_v4().to_string(), - ops, - )) - .unwrap(); - assert!(no_change.result.changed_fields.is_empty()); - assert!(no_change.event.is_none()); - assert_eq!(store.history_status().history_revision, history_before + 1); - - let gate = store.history_gate(); - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Undo, - &undo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let undone = store.editor_get().document; - assert_eq!( - serde_json::to_vec(&undone.key_positions["4key"][0]).unwrap(), - legacy_note_wire - ); - assert!(undone.key_positions["4key"][0].note_gradient.is_none()); - assert_eq!(undone.key_positions["4key"][0].note_opacity_top, Some(17)); - assert!(undone.key_positions["4key"][1] - .note_glow_opacity_top - .is_none()); - assert_eq!( - undone.key_positions["4key"][2].note_border_color.as_deref(), - Some("#010203") - ); - assert_eq!( - undone.key_positions["4key"][2] - .note_border_gradient - .as_ref() - .unwrap() - .angle, - 45.0 - ); - assert!(undone.key_positions["4key"][3].note_glow_color.is_none()); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Redo, - &redo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let redone = store.editor_get().document; - assert_eq!( - serde_json::to_vec(&redone.key_positions["4key"][0]).unwrap(), - serde_json::to_vec(&changed.document.key_positions["4key"][0]).unwrap() - ); - assert_eq!(redone.key_positions["4key"][0].note_opacity, 80); - assert!(redone.key_positions["4key"][0].note_gradient.is_some()); - assert_eq!( - redone.key_positions["4key"][1].note_glow_opacity_top, - Some(20) - ); - assert_eq!( - redone.key_positions["4key"][2].note_border_color.as_deref(), - Some("#112233") - ); - assert_eq!( - redone.key_positions["4key"][2] - .note_border_gradient - .as_ref() - .unwrap() - .angle, - 135.0 - ); - assert_eq!( - redone.key_positions["4key"][3].note_glow_color, - Some(crate::models::NoteColor::Solid(String::new())) - ); - - let event_positions = changed - .event - .as_ref() - .unwrap() - .patch - .key_positions - .as_ref() - .unwrap(); - assert_eq!(event_positions, &changed.document.key_positions); - assert_eq!(EditorDocumentV1::from_store(&store.snapshot()), redone); - - store.flush_and_shutdown().unwrap(); - drop(store); - let reloaded = - crate::state::migration::load_store_from_path(&dir.join("store.json")).unwrap(); - assert!(!reloaded.needs_persist); - assert_eq!(EditorDocumentV1::from_store(&reloaded.data), redone); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn note_glow_size_replays_and_round_trips_one_history_entry() { - let dir = test_directory("editor-note-glow-size-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let target_id = store.editor_get().document.key_positions["4key"][0] - .id - .clone(); - let setup = legacy_editor_commit(&store, &[EditorField::KeyPositions], |data| { - let position = &mut data.key_positions.get_mut("4key").unwrap()[0]; - position.note_glow_size = 20.0; - position.note_glow_enabled = true; - position.note_glow_opacity = 71; - position.note_glow_color = - Some(crate::models::NoteColor::Solid("glow-sibling".to_string())); - position.note_color = crate::models::NoteColor::Solid("note-sibling".to_string()); - position.note_border_width = Some(2.5); - }) - .unwrap(); - let patch = |value| EditorElementPropertyPatchV1::NoteGlowSize(value); - let ops = vec![ - patch_property_op(EditorElementTypeV1::Key, &target_id, patch(0.5)), - patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - patch(0.5), - ), - ]; - let mutation_id = uuid::Uuid::new_v4().to_string(); - let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); - - let changed = store.commit_editor_document(request.clone()).unwrap(); - assert_eq!(changed.result.changed_fields, [EditorField::KeyPositions]); - assert_eq!( - changed - .result - .op_results - .as_ref() - .unwrap() - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - let position = &changed.document.key_positions["4key"][0]; - assert_eq!(position.note_glow_size, 0.5); - assert!(position.note_glow_enabled); - assert_eq!(position.note_glow_opacity, 71); - assert_eq!( - position.note_glow_color, - Some(crate::models::NoteColor::Solid("glow-sibling".to_string())) - ); - assert_eq!( - position.note_color, - crate::models::NoteColor::Solid("note-sibling".to_string()) - ); - assert_eq!(position.note_border_width, Some(2.5)); - let history_revision = store.history_status().history_revision; - - let replay = store.commit_editor_document(request.clone()).unwrap(); - assert!(replay.replayed); - assert_eq!(replay.result, changed.result); - assert_eq!(store.history_status().history_revision, history_revision); - - let mut reused = request; - reused.ops = Some(vec![patch_property_op( - EditorElementTypeV1::Key, - &target_id, - patch(1.5), - )]); - assert_eq!( - store.commit_editor_document(reused).unwrap_err().error_code, - EditorCommitErrorCode::MutationIdReused - ); - - let no_change = store - .commit_editor_document(editor_ops_request( - changed.result.revision, - uuid::Uuid::new_v4().to_string(), - vec![patch_property_op( - EditorElementTypeV1::Key, - &target_id, - patch(0.5), - )], - )) - .unwrap(); - assert!(no_change.result.changed_fields.is_empty()); - assert!(no_change.event.is_none()); - assert_eq!(store.history_status().history_revision, history_revision); - - let gate = store.history_gate(); - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Undo, - &undo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let undone = store.editor_get().document; - assert_eq!(undone.key_positions["4key"][0].note_glow_size, 20.0); - assert!(undone.key_positions["4key"][0].note_glow_enabled); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Redo, - &redo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let redone = store.editor_get().document; - assert_eq!(redone.key_positions["4key"][0].note_glow_size, 0.5); - assert!(redone.key_positions["4key"][0].note_glow_enabled); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn note_numeric_batch_replays_raw_options_and_round_trips_one_history_entry() { - let dir = test_directory("editor-note-numeric-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial = store.editor_get().document; - let ids = [ - initial.key_positions["4key"][0].id.clone(), - initial.key_positions["4key"][1].id.clone(), - initial.key_positions["4key"][2].id.clone(), - initial.key_positions["4key"][3].id.clone(), - initial.key_positions["5key"][0].id.clone(), - ]; - let setup = legacy_editor_commit(&store, &[EditorField::KeyPositions], |data| { - for position in data.key_positions.values_mut().flatten() { - position.note_color = crate::models::NoteColor::Solid("note-sibling".to_string()); - position.note_glow_size = 17.5; - } - data.key_positions.get_mut("4key").unwrap()[0].note_offset_x = None; - data.key_positions.get_mut("4key").unwrap()[1].note_offset_y = Some(8.5); - data.key_positions.get_mut("4key").unwrap()[2].note_width = Some(31.5); - data.key_positions.get_mut("4key").unwrap()[3].note_border_width = None; - data.key_positions.get_mut("5key").unwrap()[0].note_border_radius = None; - }) - .unwrap(); - let ops = vec![ - patch_property_op( - EditorElementTypeV1::Key, - &ids[0], - EditorElementPropertyPatchV1::NoteOffsetX(Some(0.0)), - ), - patch_property_op( - EditorElementTypeV1::Key, - &ids[1], - EditorElementPropertyPatchV1::NoteOffsetY(None), - ), - patch_property_op( - EditorElementTypeV1::Key, - &ids[2], - EditorElementPropertyPatchV1::NoteWidth(None), - ), - patch_property_op( - EditorElementTypeV1::Key, - &ids[3], - EditorElementPropertyPatchV1::NoteBorderWidth(0.0), - ), - patch_property_op( - EditorElementTypeV1::Key, - &ids[4], - EditorElementPropertyPatchV1::NoteBorderRadius(4.0), - ), - patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - EditorElementPropertyPatchV1::NoteWidth(Some(20.0)), - ), - ]; - let mutation_id = uuid::Uuid::new_v4().to_string(); - let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); - - let changed = store.commit_editor_document(request.clone()).unwrap(); - assert_eq!(changed.result.changed_fields, [EditorField::KeyPositions]); - assert_eq!( - changed - .result - .op_results - .as_ref() - .unwrap() - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - assert_eq!( - changed.document.key_positions["4key"][0].note_offset_x, - Some(0.0) - ); - assert_eq!( - changed.document.key_positions["4key"][1].note_offset_y, - None - ); - assert_eq!(changed.document.key_positions["4key"][2].note_width, None); - assert_eq!( - changed.document.key_positions["4key"][3].note_border_width, - Some(0.0) - ); - assert_eq!( - changed.document.key_positions["5key"][0].note_border_radius, - Some(4.0) - ); - for position in changed.document.key_positions.values().flatten() { - assert_eq!( - position.note_color, - crate::models::NoteColor::Solid("note-sibling".to_string()) - ); - assert_eq!(position.note_glow_size, 17.5); - } - let history_revision = store.history_status().history_revision; - - let replay = store.commit_editor_document(request.clone()).unwrap(); - assert!(replay.replayed); - assert_eq!(replay.result, changed.result); - assert_eq!(store.history_status().history_revision, history_revision); - - let mut reused = request; - reused.ops = Some(vec![patch_property_op( - EditorElementTypeV1::Key, - &ids[0], - EditorElementPropertyPatchV1::NoteOffsetX(None), - )]); - assert_eq!( - store.commit_editor_document(reused).unwrap_err().error_code, - EditorCommitErrorCode::MutationIdReused - ); - - let no_change = store - .commit_editor_document(editor_ops_request( - changed.result.revision, - uuid::Uuid::new_v4().to_string(), - ops[..5].to_vec(), - )) - .unwrap(); - assert!(no_change.result.changed_fields.is_empty()); - assert!(no_change.event.is_none()); - assert_eq!(store.history_status().history_revision, history_revision); - - let gate = store.history_gate(); - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Undo, - &undo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let undone = store.editor_get().document; - assert_eq!(undone.key_positions["4key"][0].note_offset_x, None); - assert_eq!(undone.key_positions["4key"][1].note_offset_y, Some(8.5)); - assert_eq!(undone.key_positions["4key"][2].note_width, Some(31.5)); - assert_eq!(undone.key_positions["4key"][3].note_border_width, None); - assert_eq!(undone.key_positions["5key"][0].note_border_radius, None); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Redo, - &redo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let redone = store.editor_get().document; - assert_eq!(redone.key_positions["4key"][0].note_offset_x, Some(0.0)); - assert_eq!(redone.key_positions["4key"][1].note_offset_y, None); - assert_eq!(redone.key_positions["4key"][2].note_width, None); - assert_eq!(redone.key_positions["4key"][3].note_border_width, Some(0.0)); - assert_eq!( - redone.key_positions["5key"][0].note_border_radius, - Some(4.0) - ); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn paint_descriptor_batch_replays_and_round_trips_one_history_entry() { - let dir = test_directory("editor-paint-descriptor-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial = store.editor_get().document; - let first_id = initial.key_positions["4key"][0].id.clone(); - let second_id = initial.key_positions["4key"][1].id.clone(); - let setup = legacy_editor_commit(&store, &[EditorField::KeyPositions], |data| { - let first = &mut data.key_positions.get_mut("4key").unwrap()[0]; - first.background_color = Some("idle-before".to_string()); - first.background_gradient = None; - first.active_background_color = None; - first.active_background_gradient = None; - first.border_color = Some("first-border-sibling".to_string()); - let second = &mut data.key_positions.get_mut("4key").unwrap()[1]; - second.active_border_color = None; - second.active_border_gradient = None; - second.background_color = Some("second-background-sibling".to_string()); - }) - .unwrap(); - let gradient_stops = [("next-bg", 0.0), ("next-tail", 1.0)]; - let ops = vec![ - patch_property_op( - EditorElementTypeV1::Key, - &first_id, - EditorElementPropertyPatchV1::BackgroundPaint(paint_descriptor( - "next-bg", - Some((135.0, &gradient_stops)), - )), - ), - patch_property_op( - EditorElementTypeV1::Key, - &second_id, - EditorElementPropertyPatchV1::ActiveBorderPaint(paint_descriptor( - "active-border", - None, - )), - ), - patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - EditorElementPropertyPatchV1::BorderPaint(paint_descriptor("missing", None)), - ), - ]; - let mutation_id = uuid::Uuid::new_v4().to_string(); - let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); - - let changed = store.commit_editor_document(request.clone()).unwrap(); - assert_eq!(changed.result.changed_fields, [EditorField::KeyPositions]); - assert_eq!( - changed - .result - .op_results - .as_ref() - .unwrap() - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - let first = &changed.document.key_positions["4key"][0]; - assert_eq!(first.background_color.as_deref(), Some("next-bg")); - assert_eq!(first.background_gradient.as_ref().unwrap().angle, 135.0); - assert_eq!( - first.active_background_color.as_deref(), - Some("idle-before") - ); - assert_eq!(first.border_color.as_deref(), Some("first-border-sibling")); - let second = &changed.document.key_positions["4key"][1]; - assert_eq!(second.active_border_color.as_deref(), Some("active-border")); - assert_eq!( - second.background_color.as_deref(), - Some("second-background-sibling") - ); - let history_revision = store.history_status().history_revision; - - let replay = store.commit_editor_document(request.clone()).unwrap(); - assert!(replay.replayed); - assert_eq!(replay.result, changed.result); - assert_eq!(store.history_status().history_revision, history_revision); - - let mut reused = request; - reused.ops = Some(vec![patch_property_op( - EditorElementTypeV1::Key, - &first_id, - EditorElementPropertyPatchV1::BackgroundPaint(paint_descriptor("different", None)), - )]); - assert_eq!( - store.commit_editor_document(reused).unwrap_err().error_code, - EditorCommitErrorCode::MutationIdReused - ); - - let no_change = store - .commit_editor_document(editor_ops_request( - changed.result.revision, - uuid::Uuid::new_v4().to_string(), - ops[..2].to_vec(), - )) - .unwrap(); - assert!(no_change.result.changed_fields.is_empty()); - assert!(no_change.event.is_none()); - assert_eq!(store.history_status().history_revision, history_revision); - - let gate = store.history_gate(); - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Undo, - &undo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let undone = store.editor_get().document; - assert_eq!( - undone.key_positions["4key"][0].background_color.as_deref(), - Some("idle-before") - ); - assert!(undone.key_positions["4key"][0] - .active_background_color - .is_none()); - assert!(undone.key_positions["4key"][1] - .active_border_color - .is_none()); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Redo, - &redo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let redone = store.editor_get().document; - assert_eq!( - redone.key_positions["4key"][0].background_color.as_deref(), - Some("next-bg") - ); - assert_eq!( - redone.key_positions["4key"][0] - .active_background_color - .as_deref(), - Some("idle-before") - ); - assert_eq!( - redone.key_positions["4key"][1] - .active_border_color - .as_deref(), - Some("active-border") - ); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn font_paint_batch_replays_fallbacks_and_round_trips_one_history_entry() { - let dir = test_directory("editor-font-paint-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial = store.editor_get().document; - let key_idle_id = initial.key_positions["4key"][0].id.clone(); - let key_active_id = initial.key_positions["4key"][1].id.clone(); - let stat_id = uuid::Uuid::new_v4().to_string(); - let active_descriptor = paint_descriptor( - "active-first", - Some((25.0, &[("active-first", 0.0), ("active-last", 1.0)])), - ); - let stat_descriptor = paint_descriptor( - "stat-first", - Some((70.0, &[("stat-first", 0.0), ("stat-last", 1.0)])), - ); - let setup = legacy_editor_commit( - &store, - &[EditorField::KeyPositions, EditorField::StatPositions], - |data| { - let template = data.key_positions["4key"][0].clone(); - let key_idle = &mut data.key_positions.get_mut("4key").unwrap()[0]; - key_idle.font_color = Some("same-idle".to_string()); - key_idle.font_gradient = None; - key_idle.active_font_color = None; - key_idle.active_font_gradient = None; - key_idle.background_color = Some("key-background-sibling".to_string()); - let key_active = &mut data.key_positions.get_mut("4key").unwrap()[1]; - key_active.font_color = Some("key-idle-sibling".to_string()); - key_active.font_gradient = None; - key_active.active_font_color = None; - key_active.active_font_gradient = None; - - let mut stat_position = template.clone(); - stat_position.id = stat_id.clone(); - stat_position.font_color = None; - stat_position.font_gradient = None; - stat_position.active_font_color = Some("stat-active-sibling".to_string()); - stat_position.active_font_gradient = None; - data.stat_positions.insert( - "4key".to_string(), - vec![StatPosition { - stat_type: StatType::Kps, - position: stat_position, - }], - ); - }, - ) - .unwrap(); - let ops = vec![ - patch_property_op( - EditorElementTypeV1::Key, - &key_idle_id, - EditorElementPropertyPatchV1::FontPaint(paint_descriptor("same-idle", None)), - ), - patch_property_op( - EditorElementTypeV1::Key, - &key_active_id, - EditorElementPropertyPatchV1::ActiveFontPaint(active_descriptor.clone()), - ), - patch_property_op( - EditorElementTypeV1::Stat, - &stat_id, - EditorElementPropertyPatchV1::FontPaint(stat_descriptor.clone()), - ), - patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - EditorElementPropertyPatchV1::ActiveFontPaint(paint_descriptor("missing", None)), - ), - ]; - let history_before = store.history_status().history_revision; - let mutation_id = uuid::Uuid::new_v4().to_string(); - let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); - - let changed = store.commit_editor_document(request.clone()).unwrap(); - assert_eq!( - changed.result.changed_fields, - [EditorField::KeyPositions, EditorField::StatPositions] - ); - assert_eq!( - changed - .result - .op_results - .as_ref() - .unwrap() - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - assert_eq!( - changed.document.key_positions["4key"][0] - .active_font_color - .as_deref(), - Some("same-idle") - ); - assert_eq!( - changed.document.key_positions["4key"][0] - .background_color - .as_deref(), - Some("key-background-sibling") - ); - assert_eq!( - changed.document.key_positions["4key"][1] - .active_font_color - .as_deref(), - Some("active-first") - ); - assert!(changed.document.key_positions["4key"][1] - .active_font_gradient - .is_some()); - assert_eq!( - changed.document.stat_positions["4key"][0] - .position - .font_color - .as_deref(), - Some("stat-first") - ); - assert!(changed.document.stat_positions["4key"][0] - .position - .font_gradient - .is_some()); - assert_eq!( - changed.document.stat_positions["4key"][0] - .position - .active_font_color - .as_deref(), - Some("stat-active-sibling") - ); - assert_eq!(store.history_status().history_revision, history_before + 1); - - let replay = store.commit_editor_document(request.clone()).unwrap(); - assert!(replay.replayed); - assert_eq!(replay.result, changed.result); - assert_eq!(store.history_status().history_revision, history_before + 1); - - let mut reused = request; - reused.ops = Some(vec![patch_property_op( - EditorElementTypeV1::Key, - &key_idle_id, - EditorElementPropertyPatchV1::FontPaint(paint_descriptor("different", None)), - )]); - assert_eq!( - store.commit_editor_document(reused).unwrap_err().error_code, - EditorCommitErrorCode::MutationIdReused - ); - - let no_change = store - .commit_editor_document(editor_ops_request( - changed.result.revision, - uuid::Uuid::new_v4().to_string(), - ops[..3].to_vec(), - )) - .unwrap(); - assert!(no_change.result.changed_fields.is_empty()); - assert!(no_change.event.is_none()); - assert_eq!(store.history_status().history_revision, history_before + 1); - - let gate = store.history_gate(); - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Undo, - &undo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let undone = store.editor_get().document; - assert!(undone.key_positions["4key"][0].active_font_color.is_none()); - assert!(undone.key_positions["4key"][1].active_font_color.is_none()); - assert!(undone.key_positions["4key"][1] - .active_font_gradient - .is_none()); - assert!(undone.stat_positions["4key"][0] - .position - .font_color - .is_none()); - assert!(undone.stat_positions["4key"][0] - .position - .font_gradient - .is_none()); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Redo, - &redo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let redone = store.editor_get().document; - assert_eq!( - redone.key_positions["4key"][0].active_font_color.as_deref(), - Some("same-idle") - ); - assert_eq!( - redone.key_positions["4key"][1].active_font_color.as_deref(), - Some("active-first") - ); - assert!(redone.key_positions["4key"][1] - .active_font_gradient - .is_some()); - assert!(redone.stat_positions["4key"][0] - .position - .font_gradient - .is_some()); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn paint_fallback_only_is_applied_and_records_one_history_entry() { - let dir = test_directory("editor-paint-fallback-only-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let target_id = store.editor_get().document.key_positions["4key"][0] - .id - .clone(); - let setup = legacy_editor_commit(&store, &[EditorField::KeyPositions], |data| { - let position = &mut data.key_positions.get_mut("4key").unwrap()[0]; - position.background_color = Some("same-paint".to_string()); - position.background_gradient = None; - position.active_background_color = None; - position.active_background_gradient = None; - }) - .unwrap(); - let history_before = store.history_status().history_revision; - let op = patch_property_op( - EditorElementTypeV1::Key, - &target_id, - EditorElementPropertyPatchV1::BackgroundPaint(paint_descriptor("same-paint", None)), - ); - - let changed = store - .commit_editor_document(editor_ops_request( - setup.result.revision, - uuid::Uuid::new_v4().to_string(), - vec![op.clone()], - )) - .unwrap(); - assert_eq!(changed.result.changed_fields, [EditorField::KeyPositions]); - assert_eq!( - changed.result.op_results.as_ref().unwrap()[0].status, - EditorOpResultStatusV1::Applied - ); - assert_eq!( - changed.document.key_positions["4key"][0] - .background_color - .as_deref(), - Some("same-paint") - ); - assert_eq!( - changed.document.key_positions["4key"][0] - .active_background_color - .as_deref(), - Some("same-paint") - ); - assert_eq!(store.history_status().history_revision, history_before + 1); - - let no_change = store - .commit_editor_document(editor_ops_request( - changed.result.revision, - uuid::Uuid::new_v4().to_string(), - vec![op], - )) - .unwrap(); - assert!(no_change.result.changed_fields.is_empty()); - assert_eq!( - no_change.result.op_results.as_ref().unwrap()[0].status, - EditorOpResultStatusV1::NoChange - ); - assert_eq!(store.history_status().history_revision, history_before + 1); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn shadow_enabled_master_replays_and_round_trips_one_history_entry() { - let dir = test_directory("editor-shadow-enabled-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let key_id = store.editor_get().document.key_positions["4key"][0] - .id - .clone(); - let stat_id = uuid::Uuid::new_v4().to_string(); - let knob_id = uuid::Uuid::new_v4().to_string(); - let stat_active_sentinel = crate::models::ElementShadowSpec { - enabled: true, - color: "stat-active-sentinel".to_string(), - offset_x: 1.0, - offset_y: 2.0, - blur: 3.0, - }; - let setup = legacy_editor_commit( - &store, - &[ - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::KnobPositions, - ], - |data| { - let key = &mut data.key_positions.get_mut("4key").unwrap()[0]; - key.inactive_image = Some("idle.png".to_string()); - key.active_image = Some("active.png".to_string()); - key.shadow = None; - key.active_shadow = None; - let mut stat_position = key.clone(); - stat_position.id = stat_id.clone(); - stat_position.shadow = None; - stat_position.active_shadow = Some(stat_active_sentinel.clone()); - let mut knob_position = key.clone(); - knob_position.id = knob_id.clone(); - knob_position.inactive_image = None; - knob_position.active_image = None; - knob_position.idle_transparent = true; - knob_position.active_transparent = true; - knob_position.border_width = Some(2.0); - knob_position.shadow = None; - knob_position.active_shadow = None; - data.stat_positions.insert( - "4key".to_string(), - vec![StatPosition { - stat_type: StatType::Kps, - position: stat_position, - }], - ); - data.knob_positions.insert( - "4key".to_string(), - vec![KnobPosition { - axis_id: "axis".to_string(), - sensitivity: 1.0, - reverse: false, - position: knob_position, - }], - ); - }, - ) - .unwrap(); - let ops = vec![ - patch_property_op( - EditorElementTypeV1::Key, - &key_id, - EditorElementPropertyPatchV1::ShadowEnabled(true), - ), - patch_property_op( - EditorElementTypeV1::Stat, - &stat_id, - EditorElementPropertyPatchV1::ShadowEnabled(false), - ), - patch_property_op( - EditorElementTypeV1::Knob, - &knob_id, - EditorElementPropertyPatchV1::ShadowEnabled(true), - ), - ]; - let mutation_id = uuid::Uuid::new_v4().to_string(); - let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); - let history_before = store.history_status().history_revision; - - let changed = store.commit_editor_document(request.clone()).unwrap(); - assert_eq!( - changed.result.changed_fields, - [ - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::KnobPositions, - ] - ); - assert_eq!( - changed - .result - .op_results - .as_ref() - .unwrap() - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - ] - ); - let key = &changed.document.key_positions["4key"][0]; - assert!(key.shadow.as_ref().unwrap().enabled); - assert!(key.active_shadow.as_ref().unwrap().enabled); - assert_eq!(key.shadow.as_ref().unwrap().color, "rgba(0, 0, 0, 0.28)"); - assert_eq!( - key.active_shadow.as_ref().unwrap().color, - "rgba(0, 0, 0, 0.32)" - ); - let stat = &changed.document.stat_positions["4key"][0].position; - assert!(!stat.shadow.as_ref().unwrap().enabled); - assert_eq!(stat.active_shadow, Some(stat_active_sentinel.clone())); - let knob = &changed.document.knob_positions["4key"][0].position; - assert!(knob.shadow.as_ref().unwrap().enabled); - assert!(knob.active_shadow.as_ref().unwrap().enabled); - assert_eq!(knob.shadow.as_ref().unwrap().color, "rgba(0, 0, 0, 0.28)"); - assert_eq!( - knob.active_shadow.as_ref().unwrap().color, - "rgba(0, 0, 0, 0.32)" - ); - assert_eq!(store.history_status().history_revision, history_before + 1); - - let replay = store.commit_editor_document(request).unwrap(); - assert!(replay.replayed); - assert_eq!(replay.result, changed.result); - assert_eq!(store.history_status().history_revision, history_before + 1); - - let no_change = store - .commit_editor_document(editor_ops_request( - changed.result.revision, - uuid::Uuid::new_v4().to_string(), - ops, - )) - .unwrap(); - assert!(no_change.result.changed_fields.is_empty()); - assert!(no_change.event.is_none()); - assert_eq!(store.history_status().history_revision, history_before + 1); - - let gate = store.history_gate(); - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Undo, - &undo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let undone = store.editor_get().document; - assert!(undone.key_positions["4key"][0].shadow.is_none()); - assert!(undone.key_positions["4key"][0].active_shadow.is_none()); - assert!(undone.stat_positions["4key"][0].position.shadow.is_none()); - assert!(undone.knob_positions["4key"][0].position.shadow.is_none()); - assert!(undone.knob_positions["4key"][0] - .position - .active_shadow - .is_none()); - assert_eq!( - undone.stat_positions["4key"][0].position.active_shadow, - Some(stat_active_sentinel.clone()) - ); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Redo, - &redo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let redone = store.editor_get().document; - assert!( - redone.key_positions["4key"][0] - .shadow - .as_ref() - .unwrap() - .enabled - ); - assert!( - redone.key_positions["4key"][0] - .active_shadow - .as_ref() - .unwrap() - .enabled - ); - assert!( - !redone.stat_positions["4key"][0] - .position - .shadow - .as_ref() - .unwrap() - .enabled - ); - assert_eq!( - redone.stat_positions["4key"][0].position.active_shadow, - Some(stat_active_sentinel) - ); - assert!( - redone.knob_positions["4key"][0] - .position - .shadow - .as_ref() - .unwrap() - .enabled - ); - assert!( - redone.knob_positions["4key"][0] - .position - .active_shadow - .as_ref() - .unwrap() - .enabled - ); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn shadow_mask_replays_and_round_trips_one_history_entry() { - let dir = test_directory("editor-shadow-mask-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let target_id = store.editor_get().document.key_positions["4key"][0] - .id - .clone(); - let setup = legacy_editor_commit(&store, &[EditorField::KeyPositions], |data| { - let position = &mut data.key_positions.get_mut("4key").unwrap()[0]; - position.shadow = None; - position.active_shadow = Some(crate::models::ElementShadowSpec { - enabled: true, - color: "active-sibling".to_string(), - offset_x: 1.0, - offset_y: 2.0, - blur: 3.0, - }); - }) - .unwrap(); - let op = patch_property_op( - EditorElementTypeV1::Key, - &target_id, - EditorElementPropertyPatchV1::Shadow(shadow_leaf_blur(10.0)), - ); - let mutation_id = uuid::Uuid::new_v4().to_string(); - let request = editor_ops_request(setup.result.revision, &mutation_id, vec![op.clone()]); - let history_before = store.history_status().history_revision; - - let changed = store.commit_editor_document(request.clone()).unwrap(); - assert_eq!(changed.result.changed_fields, [EditorField::KeyPositions]); - assert_eq!( - changed.result.op_results.as_ref().unwrap()[0].status, - EditorOpResultStatusV1::Applied - ); - let position = &changed.document.key_positions["4key"][0]; - let shadow = position.shadow.as_ref().unwrap(); - assert_eq!(shadow.blur, 10.0); - assert_eq!(shadow.color, "rgba(0, 0, 0, 0.28)"); - assert_eq!( - position.active_shadow.as_ref().unwrap().color, - "active-sibling" - ); - assert_eq!(store.history_status().history_revision, history_before + 1); - - let replay = store.commit_editor_document(request.clone()).unwrap(); - assert!(replay.replayed); - assert_eq!(replay.result, changed.result); - assert_eq!(store.history_status().history_revision, history_before + 1); - - let mut reused = request; - reused.ops = Some(vec![patch_property_op( - EditorElementTypeV1::Key, - &target_id, - EditorElementPropertyPatchV1::Shadow(shadow_leaf_blur(11.0)), - )]); - assert_eq!( - store.commit_editor_document(reused).unwrap_err().error_code, - EditorCommitErrorCode::MutationIdReused - ); - - let no_change = store - .commit_editor_document(editor_ops_request( - changed.result.revision, - uuid::Uuid::new_v4().to_string(), - vec![op], - )) - .unwrap(); - assert!(no_change.result.changed_fields.is_empty()); - assert!(no_change.event.is_none()); - assert_eq!(store.history_status().history_revision, history_before + 1); - - let gate = store.history_gate(); - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Undo, - &undo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let undone = store.editor_get().document; - assert!(undone.key_positions["4key"][0].shadow.is_none()); - assert_eq!( - undone.key_positions["4key"][0] - .active_shadow - .as_ref() - .unwrap() - .color, - "active-sibling" - ); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Redo, - &redo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let redone = store.editor_get().document; - assert_eq!( - redone.key_positions["4key"][0] - .shadow - .as_ref() - .unwrap() - .blur, - 10.0 - ); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn note_literal_batch_replays_and_round_trips_one_history_entry() { - let dir = test_directory("editor-note-literal-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = initialize_neutral_editor_store(&dir); - let initial = store.editor_get().document; - let ids = initial - .key_positions - .values() - .flat_map(|positions| positions.iter().map(|position| position.id.clone())) - .take(5) - .collect::>(); - assert_eq!(ids.len(), 5); - let ops = vec![ - patch_property_op( - EditorElementTypeV1::Key, - &ids[0], - EditorElementPropertyPatchV1::NoteEffectEnabled(false), - ), - patch_property_op( - EditorElementTypeV1::Key, - &ids[1], - EditorElementPropertyPatchV1::NoteGlowEnabled(true), - ), - patch_property_op( - EditorElementTypeV1::Key, - &ids[2], - EditorElementPropertyPatchV1::NoteAutoYCorrection(false), - ), - patch_property_op( - EditorElementTypeV1::Key, - &ids[3], - EditorElementPropertyPatchV1::NoteAlignment(crate::models::NoteAlignment::Right), - ), - patch_property_op( - EditorElementTypeV1::Key, - &ids[4], - EditorElementPropertyPatchV1::NoteBorderSide( - crate::models::EditorNoteBorderSideV1::All, - ), - ), - patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - EditorElementPropertyPatchV1::NoteGlowEnabled(true), - ), - ]; - let mutation_id = uuid::Uuid::new_v4().to_string(); - let request = editor_ops_request(0, &mutation_id, ops.clone()); - - let changed = store.commit_editor_document(request.clone()).unwrap(); - assert_eq!(changed.result.changed_fields, [EditorField::KeyPositions]); - assert_eq!( - changed - .result - .op_results - .as_ref() - .unwrap() - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - let positions = changed - .document - .key_positions - .values() - .flatten() - .map(|position| (position.id.as_str(), position)) - .collect::>(); - assert!(!positions[ids[0].as_str()].note_effect_enabled); - assert!(positions[ids[1].as_str()].note_glow_enabled); - assert!(!positions[ids[2].as_str()].note_auto_y_correction); - assert_eq!( - positions[ids[3].as_str()].note_alignment, - crate::models::NoteAlignment::Right - ); - assert_eq!( - positions[ids[4].as_str()].note_border_side.as_deref(), - Some("all") - ); - assert_eq!(store.history_status().history_revision, 1); - - let replay = store.commit_editor_document(request.clone()).unwrap(); - assert!(replay.replayed); - assert_eq!(replay.result, changed.result); - - let mut reused = request; - reused.ops = Some(vec![patch_property_op( - EditorElementTypeV1::Key, - &ids[0], - EditorElementPropertyPatchV1::NoteEffectEnabled(true), - )]); - assert_eq!( - store.commit_editor_document(reused).unwrap_err().error_code, - EditorCommitErrorCode::MutationIdReused - ); - - let no_change = store - .commit_editor_document(editor_ops_request( - changed.result.revision, - uuid::Uuid::new_v4().to_string(), - ops[..5].to_vec(), - )) - .unwrap(); - assert!(no_change.result.changed_fields.is_empty()); - assert!(no_change.event.is_none()); - assert_eq!(store.history_status().history_revision, 1); - - let gate = store.history_gate(); - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Undo, - &undo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let undone = store.editor_get().document; - assert!(undone.key_positions.values().flatten().all(|position| { - position.note_effect_enabled - && !position.note_glow_enabled - && position.note_auto_y_correction - && position.note_alignment == crate::models::NoteAlignment::Center - && position.note_border_side.is_none() - })); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation( - HistoryDirection::Redo, - &redo_id, - &store.snapshot().key_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let redone = store.editor_get().document; - let positions = redone - .key_positions - .values() - .flatten() - .map(|position| (position.id.as_str(), position)) - .collect::>(); - assert!(!positions[ids[0].as_str()].note_effect_enabled); - assert!(positions[ids[1].as_str()].note_glow_enabled); - assert!(!positions[ids[2].as_str()].note_auto_y_correction); - assert_eq!( - positions[ids[3].as_str()].note_alignment, - crate::models::NoteAlignment::Right - ); - assert_eq!( - positions[ids[4].as_str()].note_border_side.as_deref(), - Some("all") - ); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn editor_op_reports_target_missing_after_legacy_deletes_its_stable_id() { - let dir = test_directory("editor-op-after-legacy-delete-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial = store.editor_get().document; - let target = initial.key_positions["4key"][0].clone(); - - let legacy = legacy_editor_commit( - &store, - &[EditorField::Keys, EditorField::KeyPositions], - |data| { - data.keys.get_mut("4key").unwrap().remove(0); - data.key_positions.get_mut("4key").unwrap().remove(0); - }, - ) - .unwrap(); - assert_eq!(legacy.result.revision, 1); - assert!(legacy.document.key_positions["4key"] - .iter() - .all(|position| position.id != target.id)); - let history_after_legacy = store.history_status().history_revision; - - let op = store - .commit_editor_document(editor_ops_request( - 1, - uuid::Uuid::new_v4().to_string(), - vec![set_bounds_op( - EditorElementTypeV1::Key, - &target.id, - EditorBoundsV1 { - dx: target.dx + 10.0, - ..bounds(&target) - }, - )], - )) - .unwrap(); - - assert_eq!(op.result.revision, 1); - assert!(op.result.changed_fields.is_empty()); - assert!(op.event.is_none()); - assert_eq!( - op.result.op_results.as_ref().unwrap(), - &[crate::models::EditorOpResultV1 { - status: EditorOpResultStatusV1::TargetMissing, - bounds: None, - }] - ); - assert_eq!( - store.history_status().history_revision, - history_after_legacy - ); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn editor_ops_return_request_order_and_do_not_record_all_missing_or_no_change() { - let dir = test_directory("editor-ops-results-and-no-op-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial = store.editor_get().document; - let first = &initial.key_positions["4key"][0]; - let second = &initial.key_positions["4key"][1]; - let missing_id = uuid::Uuid::new_v4().to_string(); - let mut second_bounds = bounds(second); - second_bounds.dx += 31.0; - - let partial_mutation_id = uuid::Uuid::new_v4().to_string(); - let partial_request = editor_ops_request( - 0, - &partial_mutation_id, - vec![ - set_bounds_op(EditorElementTypeV1::Key, &first.id, bounds(first)), - set_bounds_op( - EditorElementTypeV1::Key, - &missing_id, - EditorBoundsV1 { - dx: 1.0, - dy: 2.0, - width: 3.0, - height: 4.0, - }, - ), - set_bounds_op(EditorElementTypeV1::Key, &second.id, second_bounds), - ], - ); - let change = store - .commit_editor_document(partial_request.clone()) - .unwrap(); - let results = change.result.op_results.as_ref().unwrap(); - assert_eq!( - results - .iter() - .map(|result| result.status) - .collect::>(), - vec![ - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::TargetMissing, - EditorOpResultStatusV1::Applied, - ] - ); - assert_eq!(results[0].bounds, Some(bounds(first))); - assert_eq!(results[1].bounds, None); - assert_eq!(results[2].bounds, Some(second_bounds)); - assert_eq!(store.history_status().history_revision, 1); - - let partial_replay = store.commit_editor_document(partial_request).unwrap(); - assert!(partial_replay.replayed); - assert_eq!(partial_replay.result, change.result); - let changed_reuse = store - .commit_editor_document(editor_ops_request( - 0, - partial_mutation_id, - vec![set_bounds_op( - EditorElementTypeV1::Key, - &first.id, - EditorBoundsV1 { - dx: first.dx + 1.0, - ..bounds(first) - }, - )], - )) - .unwrap_err(); - assert_eq!( - changed_reuse.error_code, - EditorCommitErrorCode::MutationIdReused - ); - - let missing_mutation_id = uuid::Uuid::new_v4().to_string(); - let missing_request = editor_ops_request( - 1, - &missing_mutation_id, - vec![set_bounds_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - EditorBoundsV1 { - dx: 5.0, - dy: 6.0, - width: 7.0, - height: 8.0, - }, - )], - ); - let missing = store - .commit_editor_document(missing_request.clone()) - .unwrap(); - assert_eq!(missing.result.revision, 1); - assert!(missing.result.changed_fields.is_empty()); - assert!(missing.event.is_none()); - assert_eq!( - missing.result.op_results.as_ref().unwrap()[0].status, - EditorOpResultStatusV1::TargetMissing - ); - assert_eq!(store.history_status().history_revision, 1); - - let replay = store.commit_editor_document(missing_request).unwrap(); - assert!(replay.replayed); - assert_eq!(replay.result, missing.result); - assert_eq!(store.history_status().history_revision, 1); - - let reused = store - .commit_editor_document(editor_ops_request( - 1, - missing_mutation_id, - vec![set_bounds_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - EditorBoundsV1 { - dx: 9.0, - dy: 10.0, - width: 11.0, - height: 12.0, - }, - )], - )) - .unwrap_err(); - assert_eq!(reused.error_code, EditorCommitErrorCode::MutationIdReused); - - let current = store.editor_get().document; - let current_second = current.key_positions["4key"] - .iter() - .find(|position| position.id == second.id) - .unwrap(); - let no_change = store - .commit_editor_document(editor_ops_request( - 1, - uuid::Uuid::new_v4().to_string(), - vec![set_bounds_op( - EditorElementTypeV1::Key, - ¤t_second.id, - bounds(current_second), - )], - )) - .unwrap(); - assert!(no_change.result.changed_fields.is_empty()); - assert!(no_change.event.is_none()); - assert_eq!(no_change.result.revision, 1); - assert_eq!(store.history_status().history_revision, 1); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn editor_group_bounds_partially_missing_targets_share_one_gesture_history_entry() { - let dir = test_directory("editor-group-bounds-partial-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial = store.editor_get().document; - let first = initial.key_positions["4key"][0].clone(); - let second = initial.key_positions["4key"][1].clone(); - let first_before = bounds(&first); - let second_before = bounds(&second); - let first_after = EditorBoundsV1 { - dx: first.dx + 11.0, - dy: first.dy + 12.0, - width: first.width + 13.0, - height: first.height + 14.0, - }; - let second_after = EditorBoundsV1 { - dx: second.dx + 21.0, - dy: second.dy + 22.0, - width: second.width + 23.0, - height: second.height + 24.0, - }; - let missing_id = uuid::Uuid::new_v4().to_string(); - let gesture_id = uuid::Uuid::new_v4().to_string(); - let mut request = editor_ops_request( - 0, - uuid::Uuid::new_v4().to_string(), - vec![ - set_bounds_op(EditorElementTypeV1::Key, &first.id, first_after), - set_bounds_op( - EditorElementTypeV1::Key, - missing_id, - EditorBoundsV1 { - dx: 1.0, - dy: 2.0, - width: 3.0, - height: 4.0, - }, - ), - set_bounds_op(EditorElementTypeV1::Key, &second.id, second_after), - ], - ); - request.gesture_id = Some(gesture_id.clone()); - - let change = store.commit_editor_document(request).unwrap(); - - assert_eq!(change.result.revision, 1); - assert_eq!(change.result.changed_fields, [EditorField::KeyPositions]); - assert_eq!( - change - .result - .op_results - .as_ref() - .unwrap() - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - EditorOpResultStatusV1::Applied, - ] - ); - assert_eq!(change.event.as_ref().unwrap().gesture_id, Some(gesture_id)); - assert_eq!(change.history_status.as_ref().unwrap().history_revision, 1); - assert_eq!(store.history_status().history_revision, 1); - assert_eq!( - bounds(&change.document.key_positions["4key"][0]), - first_after - ); - assert_eq!( - bounds(&change.document.key_positions["4key"][1]), - second_after - ); - - let undo_id = uuid::Uuid::new_v4().to_string(); - let gate = store.history_gate(); - let barrier = gate.close(&undo_id).unwrap(); - let current_counters = store.snapshot().key_counters; - let undo = store - .apply_history_operation(HistoryDirection::Undo, &undo_id, ¤t_counters, || {}) - .unwrap(); - drop(barrier); - - assert!(!undo.status.can_undo); - let restored = store.editor_get().document; - assert_eq!(bounds(&restored.key_positions["4key"][0]), first_before); - assert_eq!(bounds(&restored.key_positions["4key"][1]), second_before); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn legacy_delete_patch_keeps_key_pair_and_empty_group_cleanup_atomic() { - let dir = test_directory("legacy-delete-pair-group-contract-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = initialize_neutral_editor_store(&dir); - let mut grouped = store.editor_get().document; - grouped.key_positions.get_mut("4key").unwrap()[0].group_id = - Some("delete-group".to_string()); - grouped.layer_groups.insert( - "4key".to_string(), - vec![crate::models::LayerGroupDef { - id: "delete-group".to_string(), - name: "Delete Group".to_string(), - }], - ); - let setup = store - .commit_editor_document(editor_request( - 0, - uuid::Uuid::new_v4().to_string(), - EditorPatchV1 { - schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, - key_positions: Some(grouped.key_positions.clone()), - layer_groups: Some(grouped.layer_groups.clone()), - ..EditorPatchV1::default() - }, - )) - .unwrap(); - assert_eq!(setup.result.revision, 1); - let grouped = setup.document; - let deleted_id = grouped.key_positions["4key"][0].id.clone(); - let deleted_slot = grouped.keys["4key"][0].clone(); - let deleted_position = grouped.key_positions["4key"][0].clone(); - let mut keys = grouped.keys.clone(); - keys.get_mut("4key").unwrap().remove(0); - let mut positions = grouped.key_positions.clone(); - positions.get_mut("4key").unwrap().remove(0); - let mut groups = grouped.layer_groups.clone(); - groups.insert("4key".to_string(), Vec::new()); - - let deleted = store - .commit_editor_document(editor_request( - 1, - uuid::Uuid::new_v4().to_string(), - EditorPatchV1 { - schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, - keys: Some(keys), - key_positions: Some(positions), - layer_groups: Some(groups), - ..EditorPatchV1::default() - }, - )) - .unwrap(); - - assert_eq!( - deleted.result.changed_fields, - [ - EditorField::Keys, - EditorField::KeyPositions, - EditorField::LayerGroups, - ] - ); - assert_eq!( - deleted.document.keys["4key"].len(), - grouped.keys["4key"].len() - 1 - ); - assert_eq!( - deleted.document.key_positions["4key"].len(), - grouped.key_positions["4key"].len() - 1 - ); - assert!(!deleted.document.key_positions["4key"] - .iter() - .any(|position| position.id == deleted_id)); - assert!(deleted.document.layer_groups["4key"].is_empty()); - assert_eq!(store.history_status().history_revision, 2); - - let operation_id = uuid::Uuid::new_v4().to_string(); - let gate = store.history_gate(); - let barrier = gate.close(&operation_id).unwrap(); - let counters = store.snapshot().key_counters; - store - .apply_history_operation(HistoryDirection::Undo, &operation_id, &counters, || {}) - .unwrap(); - drop(barrier); - let restored = store.editor_get().document; - assert_eq!(restored.keys["4key"][0], deleted_slot); - assert_eq!(restored.key_positions["4key"][0], deleted_position); - assert_eq!(restored.layer_groups["4key"], grouped.layer_groups["4key"]); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn delete_ops_remove_all_types_in_request_order_and_round_trip_one_history_entry() { - let dir = test_directory("delete-ops-all-types-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let mut setup = store.editor_get().document; - let template = setup.key_positions["4key"][0].clone(); - setup.key_positions.get_mut("4key").unwrap()[0].group_id = Some("delete-group".to_string()); - setup.key_positions.get_mut("4key").unwrap()[1].group_id = - Some("survive-group".to_string()); - setup.key_positions.get_mut("4key").unwrap()[2].group_id = - Some("survive-group".to_string()); - - let mut stat_position = template.clone(); - stat_position.id = uuid::Uuid::new_v4().to_string(); - stat_position.group_id = Some("delete-group".to_string()); - let stat_id = stat_position.id.clone(); - setup.stat_positions.insert( - "4key".to_string(), - vec![StatPosition { - stat_type: StatType::Kps, - position: stat_position, - }], - ); - let mut graph_position = template.clone(); - graph_position.id = uuid::Uuid::new_v4().to_string(); - graph_position.group_id = None; - let graph_id = graph_position.id.clone(); - setup.graph_positions.insert( - "4key".to_string(), - vec![GraphPosition { - stat_type: GraphStatType::Kps, - graph_type: GraphType::Line, - graph_speed: 1, - graph_color: "#FFFFFF".to_string(), - show_avg_line: true, - position: graph_position, - }], - ); - let mut knob_position = template; - knob_position.id = uuid::Uuid::new_v4().to_string(); - knob_position.group_id = None; - let knob_id = knob_position.id.clone(); - setup.knob_positions.insert( - "4key".to_string(), - vec![KnobPosition { - axis_id: "delete-axis".to_string(), - sensitivity: 1.0, - reverse: false, - position: knob_position, - }], - ); - setup.layer_groups.insert( - "4key".to_string(), - vec![ - crate::models::LayerGroupDef { - id: "delete-group".to_string(), - name: "Delete Group".to_string(), - }, - crate::models::LayerGroupDef { - id: "survive-group".to_string(), - name: "Survive Group".to_string(), - }, - ], - ); - setup.layer_groups.insert( - "5key".to_string(), - vec![crate::models::LayerGroupDef { - id: "unaffected-mode-group".to_string(), - name: "Unaffected Mode Group".to_string(), - }], - ); - let setup = store - .commit_editor_document(editor_request( - 0, - uuid::Uuid::new_v4().to_string(), - EditorPatchV1 { - schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, - key_positions: Some(setup.key_positions), - stat_positions: Some(setup.stat_positions), - graph_positions: Some(setup.graph_positions), - knob_positions: Some(setup.knob_positions), - layer_groups: Some(setup.layer_groups), - ..EditorPatchV1::default() - }, - )) - .unwrap() - .document; - let first_key_id = setup.key_positions["4key"][0].id.clone(); - let second_key_id = setup.key_positions["4key"][1].id.clone(); - let surviving_key_id = setup.key_positions["4key"][2].id.clone(); - let surviving_slot = setup.keys["4key"][2].clone(); - let missing_id = uuid::Uuid::new_v4().to_string(); - let mutation_id = uuid::Uuid::new_v4().to_string(); - let request = editor_ops_request( - 1, - mutation_id, - vec![ - delete_element_op(EditorElementTypeV1::Key, &first_key_id), - delete_element_op(EditorElementTypeV1::Graph, &missing_id), - delete_element_op(EditorElementTypeV1::Key, &second_key_id), - delete_element_op(EditorElementTypeV1::Stat, &stat_id), - delete_element_op(EditorElementTypeV1::Graph, &graph_id), - delete_element_op(EditorElementTypeV1::Knob, &knob_id), - ], - ); - - let change = store.commit_editor_document(request.clone()).unwrap(); - assert_eq!( - change - .result - .op_results - .as_ref() - .unwrap() - .iter() - .map(|result| (result.status, result.bounds)) - .collect::>(), - vec![ - (EditorOpResultStatusV1::Applied, None), - (EditorOpResultStatusV1::TargetMissing, None), - (EditorOpResultStatusV1::Applied, None), - (EditorOpResultStatusV1::Applied, None), - (EditorOpResultStatusV1::Applied, None), - (EditorOpResultStatusV1::Applied, None), - ] - ); - assert_eq!( - change.result.changed_fields, - [ - EditorField::Keys, - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - EditorField::LayerGroups, - ] - ); - assert_eq!(change.document.keys["4key"][0], surviving_slot); - assert_eq!( - change.document.key_positions["4key"][0].id, - surviving_key_id - ); - assert!(change.document.stat_positions["4key"].is_empty()); - assert!(change.document.graph_positions["4key"].is_empty()); - assert!(change.document.knob_positions["4key"].is_empty()); - assert_eq!( - change.document.layer_groups["4key"] - .iter() - .map(|group| group.id.as_str()) - .collect::>(), - ["survive-group"] - ); - assert_eq!( - change.document.layer_groups["5key"][0].id, - "unaffected-mode-group" - ); - assert_eq!(store.history_status().history_revision, 2); - - let replay = store.commit_editor_document(request.clone()).unwrap(); - assert!(replay.replayed); - assert_eq!(replay.result, change.result); - assert_eq!(store.history_status().history_revision, 2); - let mut reused = request; - reused.ops = Some(vec![delete_element_op( - EditorElementTypeV1::Key, - &surviving_key_id, - )]); - assert_eq!( - store.commit_editor_document(reused).unwrap_err().error_code, - EditorCommitErrorCode::MutationIdReused - ); - - let gate = store.history_gate(); - let counters = store.snapshot().key_counters; - let undo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&undo_id).unwrap(); - store - .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) - .unwrap(); - drop(barrier); - assert_eq!(store.editor_get().document, setup); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - store - .apply_history_operation(HistoryDirection::Redo, &redo_id, &counters, || {}) - .unwrap(); - drop(barrier); - assert_eq!(store.editor_get().document, change.document); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn delete_op_keeps_a_group_definition_while_another_native_member_survives() { - let dir = test_directory("delete-op-surviving-group-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let mut document = store.editor_get().document; - document.key_positions.get_mut("4key").unwrap()[0].group_id = - Some("shared-group".to_string()); - document.key_positions.get_mut("4key").unwrap()[1].group_id = - Some("shared-group".to_string()); - document.layer_groups.insert( - "4key".to_string(), - vec![crate::models::LayerGroupDef { - id: "shared-group".to_string(), - name: "Shared Group".to_string(), - }], - ); - let setup = store - .commit_editor_document(editor_request( - 0, - uuid::Uuid::new_v4().to_string(), - EditorPatchV1 { - schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, - key_positions: Some(document.key_positions), - layer_groups: Some(document.layer_groups), - ..EditorPatchV1::default() - }, - )) - .unwrap(); - let target_id = setup.document.key_positions["4key"][0].id.clone(); - - let deleted = store - .commit_editor_document(editor_ops_request( - 1, - uuid::Uuid::new_v4().to_string(), - vec![delete_element_op(EditorElementTypeV1::Key, target_id)], - )) - .unwrap(); - - assert_eq!( - deleted.result.changed_fields, - [EditorField::Keys, EditorField::KeyPositions] - ); - assert_eq!(deleted.document.layer_groups["4key"][0].id, "shared-group"); - assert_eq!( - deleted.document.key_positions["4key"][0] - .group_id - .as_deref(), - Some("shared-group") - ); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn delete_op_all_missing_is_acknowledged_without_revision_or_history() { - let dir = test_directory("delete-op-all-missing-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let request = editor_ops_request( - 0, - uuid::Uuid::new_v4().to_string(), - vec![delete_element_op( - EditorElementTypeV1::Graph, - uuid::Uuid::new_v4().to_string(), - )], - ); - - let missing = store.commit_editor_document(request.clone()).unwrap(); - assert_eq!(missing.result.revision, 0); - assert!(missing.result.changed_fields.is_empty()); - assert_eq!( - missing.result.op_results, - Some(vec![EditorOpResultV1 { - status: EditorOpResultStatusV1::TargetMissing, - bounds: None, - }]) - ); - assert!(missing.event.is_none()); - assert_eq!(store.history_status().history_revision, 0); - let replay = store.commit_editor_document(request).unwrap(); - assert!(replay.replayed); - assert_eq!(replay.result, missing.result); - assert_eq!(store.history_status().history_revision, 0); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn delete_op_type_mismatch_and_missing_key_pair_fail_atomically() { - let dir = test_directory("delete-op-atomic-validation-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial = store.editor_get(); - let target_id = initial.document.key_positions["4key"][0].id.clone(); - let valid_id = initial.document.key_positions["4key"][1].id.clone(); - let mismatch = store - .commit_editor_document(editor_ops_request( - 0, - uuid::Uuid::new_v4().to_string(), - vec![ - delete_element_op(EditorElementTypeV1::Key, valid_id), - delete_element_op(EditorElementTypeV1::Stat, &target_id), - ], - )) - .unwrap_err(); - assert_eq!( - mismatch - .details - .and_then(|details| details.validation_code) - .as_deref(), - Some("ELEMENT_TYPE_MISMATCH") - ); - assert_eq!(store.editor_get(), initial); - - { - let mut state = store.state.write(); - state.data.keys.remove("4key"); - } - let malformed = store.editor_get(); - let error = store - .commit_editor_document(editor_ops_request( - 0, - uuid::Uuid::new_v4().to_string(), - vec![delete_element_op(EditorElementTypeV1::Key, target_id)], - )) - .unwrap_err(); - assert_eq!( - error - .details - .and_then(|details| details.validation_code) - .as_deref(), - Some("KEY_POSITION_LENGTH_MISMATCH") - ); - assert_eq!(store.editor_get(), malformed); - assert_eq!(store.history_status().history_revision, 0); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn editor_group_bounds_reject_every_invalid_batch_atomically() { - let dir = test_directory("editor-group-bounds-atomic-validation-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial = store.editor_get(); - let first = initial.document.key_positions["4key"][0].clone(); - let second = initial.document.key_positions["4key"][1].clone(); - let valid_first = set_bounds_op( - EditorElementTypeV1::Key, - &first.id, - EditorBoundsV1 { - dx: first.dx + 1.0, - ..bounds(&first) - }, - ); - - let cases = [ - ( - vec![ - valid_first.clone(), - set_bounds_op( - EditorElementTypeV1::Key, - &second.id, - EditorBoundsV1 { - width: 0.0, - ..bounds(&second) - }, - ), - ], - "DIMENSION_OUT_OF_RANGE", - ), - ( - vec![ - valid_first.clone(), - set_bounds_op(EditorElementTypeV1::Stat, &second.id, bounds(&second)), - ], - "ELEMENT_TYPE_MISMATCH", - ), - ( - vec![ - valid_first.clone(), - set_bounds_op(EditorElementTypeV1::Graph, &first.id, bounds(&first)), - ], - "DUPLICATE_EDITOR_OP_TARGET", - ), - ]; - - for (ops, expected_code) in cases { - let error = store - .commit_editor_document(editor_ops_request( - 0, - uuid::Uuid::new_v4().to_string(), - ops, - )) - .unwrap_err(); - assert_eq!( - error.details.unwrap().validation_code.as_deref(), - Some(expected_code) - ); - assert_eq!(store.editor_get(), initial); - assert_eq!(store.history_status().history_revision, 0); - } - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn editor_ops_apply_all_element_types_in_request_order() { - let dir = test_directory("editor-ops-all-element-types-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let base = store.editor_get().document.key_positions["4key"][0].clone(); - let mode = "4key".to_string(); - let mut stat_position = base.clone(); - stat_position.id = uuid::Uuid::new_v4().to_string(); - let mut graph_position = base.clone(); - graph_position.id = uuid::Uuid::new_v4().to_string(); - let mut knob_position = base; - knob_position.id = uuid::Uuid::new_v4().to_string(); - let stat_id = stat_position.id.clone(); - let graph_id = graph_position.id.clone(); - let knob_id = knob_position.id.clone(); - let initial = store.editor_get().document; - let mut stat_positions = initial.stat_positions; - stat_positions.insert( - mode.clone(), - vec![StatPosition { - stat_type: StatType::Kps, - position: stat_position, - }], - ); - let mut graph_positions = initial.graph_positions; - graph_positions.insert( - mode.clone(), - vec![GraphPosition { - stat_type: GraphStatType::Kps, - graph_type: GraphType::Line, - graph_speed: 1, - graph_color: "#FFFFFF".to_string(), - show_avg_line: true, - position: graph_position, - }], - ); - let mut knob_positions = initial.knob_positions; - knob_positions.insert( - mode.clone(), - vec![KnobPosition { - axis_id: "axis-semantic-ops".to_string(), - sensitivity: 1.0, - reverse: false, - position: knob_position, - }], - ); - let mut setup_patch = EditorPatchV1 { - stat_positions: Some(stat_positions), - graph_positions: Some(graph_positions), - knob_positions: Some(knob_positions), - ..EditorPatchV1::default() - }; - setup_patch.schema_version = EDITOR_COMMIT_SCHEMA_VERSION_V2; - store - .commit_editor_document(editor_request( - 0, - uuid::Uuid::new_v4().to_string(), - setup_patch, - )) - .unwrap(); - - let changed_bounds = |offset: f64| EditorBoundsV1 { - dx: 100.0 + offset, - dy: 200.0 + offset, - width: 300.0 + offset, - height: 400.0 + offset, - }; - let key_id = store.editor_get().document.key_positions["4key"][0] - .id - .clone(); - let change = store - .commit_editor_document(editor_ops_request( - 1, - uuid::Uuid::new_v4().to_string(), - vec![ - set_bounds_op(EditorElementTypeV1::Graph, &graph_id, changed_bounds(1.0)), - set_bounds_op(EditorElementTypeV1::Key, &key_id, changed_bounds(2.0)), - set_bounds_op(EditorElementTypeV1::Knob, &knob_id, changed_bounds(3.0)), - set_bounds_op(EditorElementTypeV1::Stat, &stat_id, changed_bounds(4.0)), - ], - )) - .unwrap(); - - let results = change.result.op_results.unwrap(); - assert_eq!( - results - .iter() - .map(|result| result.status) - .collect::>(), - vec![EditorOpResultStatusV1::Applied; 4] - ); - assert_eq!( - results - .iter() - .map(|result| result.bounds.unwrap()) - .collect::>(), - vec![ - changed_bounds(1.0), - changed_bounds(2.0), - changed_bounds(3.0), - changed_bounds(4.0), - ] - ); - assert_eq!( - change.result.changed_fields, - vec![ - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - ] - ); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn editor_ops_reject_type_mismatch_and_invalid_missing_bounds_atomically() { - let dir = test_directory("editor-ops-atomic-validation-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial = store.editor_get(); - let target = &initial.document.key_positions["4key"][0]; - - let type_error = store - .commit_editor_document(editor_ops_request( - 0, - uuid::Uuid::new_v4().to_string(), - vec![set_bounds_op( - EditorElementTypeV1::Stat, - &target.id, - bounds(target), - )], - )) - .unwrap_err(); - assert_eq!( - type_error.error_code, - EditorCommitErrorCode::ValidationFailed - ); - assert_eq!( - type_error.details.unwrap().validation_code.as_deref(), - Some("ELEMENT_TYPE_MISMATCH") - ); - - let bounds_error = store - .commit_editor_document(editor_ops_request( - 0, - uuid::Uuid::new_v4().to_string(), - vec![set_bounds_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - EditorBoundsV1 { - dx: 0.0, - dy: 0.0, - width: 0.0, - height: 10.0, - }, - )], - )) - .unwrap_err(); - assert_eq!( - bounds_error.error_code, - EditorCommitErrorCode::ValidationFailed - ); - assert_eq!( - bounds_error.details.unwrap().validation_code.as_deref(), - Some("DIMENSION_OUT_OF_RANGE") - ); - assert_eq!(store.editor_get(), initial); - assert_eq!(store.history_status().history_revision, 0); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn editor_commits_with_the_same_gesture_merge_into_one_history_entry() { - let dir = test_directory("editor-history-gesture-merge-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial_dx = store.editor_get().document.key_positions["4key"][0].dx; - let gesture_id = uuid::Uuid::new_v4().to_string(); - - let mut first = editor_request( - 0, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, initial_dx + 4.0), - ); - first.gesture_id = Some(gesture_id.clone()); - let first = store.commit_editor_document(first).unwrap(); - assert_eq!(first.history_status.unwrap().history_revision, 1); - - let mut second = editor_request( - 1, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, initial_dx + 8.0), - ); - second.gesture_id = Some(gesture_id); - let second = store.commit_editor_document(second).unwrap(); - assert_eq!(second.history_status.unwrap().history_revision, 1); - - let operation_id = uuid::Uuid::new_v4().to_string(); - let gate = store.history_gate(); - let barrier = gate.close(&operation_id).unwrap(); - let current_counters = store.snapshot().key_counters; - store - .apply_history_operation( - HistoryDirection::Undo, - &operation_id, - ¤t_counters, - || {}, - ) - .unwrap(); - drop(barrier); - - assert_eq!( - store.editor_get().document.key_positions["4key"][0].dx, - initial_dx - ); - assert!(!store.state.read().history.status(false).can_undo); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn net_zero_gesture_removes_its_entry_and_exposes_the_lower_history() { - let dir = test_directory("editor-history-net-zero-gesture-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial_dx = store.editor_get().document.key_positions["4key"][0].dx; - let gesture_base_dx = initial_dx + 10.0; - - store - .commit_editor_document(editor_request( - 0, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, gesture_base_dx), - )) - .unwrap(); - - let gesture_id = uuid::Uuid::new_v4().to_string(); - let mut outward = editor_request( - 1, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, gesture_base_dx + 5.0), - ); - outward.gesture_id = Some(gesture_id.clone()); - store.commit_editor_document(outward).unwrap(); - - let mut returned = editor_request( - 2, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, gesture_base_dx), - ); - returned.gesture_id = Some(gesture_id); - let returned = store.commit_editor_document(returned).unwrap(); - let status = returned.history_status.as_ref().unwrap(); - assert!(status.can_undo); - assert_eq!(status.history_revision, 3); - assert_eq!( - store.editor_get().document.key_positions["4key"][0].dx, - gesture_base_dx - ); - - let operation_id = uuid::Uuid::new_v4().to_string(); - let gate = store.history_gate(); - let barrier = gate.close(&operation_id).unwrap(); - let current_counters = store.snapshot().key_counters; - store - .apply_history_operation( - HistoryDirection::Undo, - &operation_id, - ¤t_counters, - || {}, - ) - .unwrap(); - drop(barrier); - - assert_eq!( - store.editor_get().document.key_positions["4key"][0].dx, - initial_dx - ); - assert!(!store.state.read().history.status(false).can_undo); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn editor_ops_with_the_same_gesture_merge_into_one_history_entry() { - let dir = test_directory("editor-ops-history-gesture-merge-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let target = store.editor_get().document.key_positions["4key"][0].clone(); - let initial_bounds = bounds(&target); - let gesture_id = uuid::Uuid::new_v4().to_string(); - - let mut first = editor_ops_request( - 0, - uuid::Uuid::new_v4().to_string(), - vec![set_bounds_op( - EditorElementTypeV1::Key, - &target.id, - EditorBoundsV1 { - dx: initial_bounds.dx + 4.0, - ..initial_bounds - }, - )], - ); - first.gesture_id = Some(gesture_id.clone()); - let first = store.commit_editor_document(first).unwrap(); - assert_eq!(first.history_status.unwrap().history_revision, 1); - - let mut second = editor_ops_request( - 1, - uuid::Uuid::new_v4().to_string(), - vec![set_bounds_op( - EditorElementTypeV1::Key, - &target.id, - EditorBoundsV1 { - dx: initial_bounds.dx + 8.0, - ..initial_bounds - }, - )], - ); - second.gesture_id = Some(gesture_id); - let second = store.commit_editor_document(second).unwrap(); - assert_eq!(second.history_status.unwrap().history_revision, 1); - - let operation_id = uuid::Uuid::new_v4().to_string(); - let gate = store.history_gate(); - let barrier = gate.close(&operation_id).unwrap(); - let current_counters = store.snapshot().key_counters; - let undo = store - .apply_history_operation( - HistoryDirection::Undo, - &operation_id, - ¤t_counters, - || {}, - ) - .unwrap(); - drop(barrier); - - assert_eq!( - bounds(&store.editor_get().document.key_positions["4key"][0]), - initial_bounds - ); - assert!(!undo.status.can_undo); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn editor_ops_net_zero_gesture_removes_its_history_entry() { - let dir = test_directory("editor-ops-history-net-zero-gesture-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let target = store.editor_get().document.key_positions["4key"][0].clone(); - let initial_bounds = bounds(&target); - let gesture_base = EditorBoundsV1 { - dx: initial_bounds.dx + 10.0, - ..initial_bounds - }; - - store - .commit_editor_document(editor_ops_request( - 0, - uuid::Uuid::new_v4().to_string(), - vec![set_bounds_op( - EditorElementTypeV1::Key, - &target.id, - gesture_base, - )], - )) - .unwrap(); - - let gesture_id = uuid::Uuid::new_v4().to_string(); - let mut outward = editor_ops_request( - 1, - uuid::Uuid::new_v4().to_string(), - vec![set_bounds_op( - EditorElementTypeV1::Key, - &target.id, - EditorBoundsV1 { - dx: gesture_base.dx + 5.0, - ..gesture_base - }, - )], - ); - outward.gesture_id = Some(gesture_id.clone()); - store.commit_editor_document(outward).unwrap(); - - let mut returned = editor_ops_request( - 2, - uuid::Uuid::new_v4().to_string(), - vec![set_bounds_op( - EditorElementTypeV1::Key, - &target.id, - gesture_base, - )], - ); - returned.gesture_id = Some(gesture_id); - let returned = store.commit_editor_document(returned).unwrap(); - let status = returned.history_status.unwrap(); - assert_eq!(status.history_revision, 3); - assert!(status.can_undo); - - let operation_id = uuid::Uuid::new_v4().to_string(); - let gate = store.history_gate(); - let barrier = gate.close(&operation_id).unwrap(); - let current_counters = store.snapshot().key_counters; - let undo = store - .apply_history_operation( - HistoryDirection::Undo, - &operation_id, - ¤t_counters, - || {}, - ) - .unwrap(); - drop(barrier); - - assert_eq!( - bounds(&store.editor_get().document.key_positions["4key"][0]), - initial_bounds - ); - assert!(!undo.status.can_undo); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn oversized_editor_op_clears_history_after_persisting_mutation() { - let dir = test_directory("editor-ops-history-truncation-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let target = store.editor_get().document.key_positions["4key"][0].clone(); - let initial_bounds = bounds(&target); - - store - .commit_editor_document(editor_ops_request( - 0, - uuid::Uuid::new_v4().to_string(), - vec![set_bounds_op( - EditorElementTypeV1::Key, - &target.id, - EditorBoundsV1 { - dx: initial_bounds.dx + 1.0, - ..initial_bounds - }, - )], - )) - .unwrap(); - store - .state - .write() - .history - .set_limits_for_test(1, 32 * 1024 * 1024, 50); - - let expected_bounds = EditorBoundsV1 { - dx: initial_bounds.dx + 2.0, - ..initial_bounds - }; - let change = store - .commit_editor_document(editor_ops_request( - 1, - uuid::Uuid::new_v4().to_string(), - vec![set_bounds_op( - EditorElementTypeV1::Key, - &target.id, - expected_bounds, - )], - )) - .unwrap(); - let status = change.history_status.unwrap(); - - assert_eq!(change.result.revision, 2); - assert_eq!( - change.result.op_results.unwrap()[0].status, - EditorOpResultStatusV1::Applied - ); - assert_eq!( - bounds(&store.editor_get().document.key_positions["4key"][0]), - expected_bounds - ); - assert_eq!(status.history_revision, 2); - assert!(!status.can_undo); - assert!(!status.can_redo); - assert_eq!(status.truncated.unwrap().reason, HISTORY_ENTRY_TOO_LARGE); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn oversized_editor_op_plugin_gesture_rejects_the_whole_transaction() { - let dir = test_directory("gesture-editor-op-history-size-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - store - .state - .write() - .history - .set_limits_for_test(1, 32 * 1024 * 1024, 50); - let before = store.snapshot(); - let target = store.editor_get().document.key_positions["4key"][0].clone(); - let before_plugin_revision = store.plugin_model_revision(); - let before_history_revision = store.history_status().history_revision; - let persist_count = store.writer.persist_count(); - - let error = store - .commit_gesture(gesture_ops_request( - &store, - uuid::Uuid::new_v4().to_string(), - uuid::Uuid::new_v4().to_string(), - vec![set_bounds_op( - EditorElementTypeV1::Key, - &target.id, - EditorBoundsV1 { - dx: target.dx + 25.0, - ..bounds(&target) - }, - )], - "demo-plugin", - vec![saved_plugin_instance(25.0)], - )) - .unwrap_err(); - - assert_eq!(error.error_code, EditorCommitErrorCode::ValidationFailed); - assert_eq!( - error.details.unwrap().validation_code.as_deref(), - Some(HISTORY_ENTRY_TOO_LARGE) - ); - assert_eq!(store.snapshot(), before); - assert_eq!(store.plugin_model_revision(), before_plugin_revision); - assert_eq!( - store.history_status().history_revision, - before_history_revision - ); - assert_eq!(store.writer.persist_count(), persist_count); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn unrecorded_preset_overlap_mutations_clear_redo_only_when_overlap_changes() { - let dir = test_directory("history-overlap-redo-invalidation-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial_dx = store.editor_get().document.key_positions["4key"][0].dx; - - for (index, mutate_css) in [false, true].into_iter().enumerate() { - let base_revision = store.editor_get().revision; - store - .commit_editor_document(editor_request( - base_revision, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, initial_dx + index as f64 + 1.0), - )) - .unwrap(); - let operation_id = uuid::Uuid::new_v4().to_string(); - let gate = store.history_gate(); - let barrier = gate.close(&operation_id).unwrap(); - let current_counters = store.snapshot().key_counters; - store - .apply_history_operation( - HistoryDirection::Undo, - &operation_id, - ¤t_counters, - || {}, - ) - .unwrap(); - drop(barrier); - let before_mutation = store.state.read().history.status(false); - assert!(before_mutation.can_redo); - - let transaction = store - .commit_history_overlap_mutation(|data| { - if mutate_css { - data.custom_css.content = format!("body {{ opacity: {}; }}", index + 1); - } else { - apply_patch_to_store( - data, - &SettingsPatchInput { - background_color: Some(format!("#{:06x}", index + 1)), - ..SettingsPatchInput::default() - }, - ); - } - Ok(()) - }) - .unwrap(); - let status = transaction.history_status.as_ref().unwrap(); - assert!(!status.can_redo); - assert_eq!( - status.history_revision, - before_mutation.history_revision + 1 - ); - drop(transaction); - } - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn unrecorded_legacy_editor_mutations_invalidate_stale_undo_and_redo() { - let dir = test_directory("legacy-editor-history-invalidation-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial_dx = store.editor_get().document.key_positions["4key"][0].dx; - - store - .commit_editor_document(editor_request( - store.editor_get().revision, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, initial_dx + 1.0), - )) - .unwrap(); - assert!(store.history_status().can_undo); - - let transaction = store - .commit_legacy_editor_transaction( - EditorCommitOrigin::LegacyAdapter("resource_delete_test".to_string()), - &[EditorField::KeyPositions], - |data| { - data.key_positions.get_mut("4key").unwrap()[0].dx = initial_dx + 2.0; - Ok(()) - }, - ) - .unwrap(); - let status = transaction.change.history_status.as_ref().unwrap(); - assert!(!status.can_undo); - assert!(!status.can_redo); - drop(transaction); - - store - .commit_editor_document(editor_request( - store.editor_get().revision, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, initial_dx + 3.0), - )) - .unwrap(); - let operation_id = uuid::Uuid::new_v4().to_string(); - let gate = store.history_gate(); - let barrier = gate.close(&operation_id).unwrap(); - let counters = store.snapshot().key_counters; - store - .apply_history_operation(HistoryDirection::Undo, &operation_id, &counters, || {}) - .unwrap(); - drop(barrier); - assert!(store.history_status().can_redo); - - let transaction = store - .commit_legacy_editor_transaction( - EditorCommitOrigin::LegacyAdapter("resource_replace_test".to_string()), - &[EditorField::KeyPositions], - |data| { - data.key_positions.get_mut("4key").unwrap()[0].dx = initial_dx + 4.0; - Ok(()) - }, - ) - .unwrap(); - let status = transaction.change.history_status.as_ref().unwrap(); - assert!(!status.can_undo); - assert!(!status.can_redo); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn counter_preset_library_only_delete_invalidates_stale_history() { - let dir = test_directory("counter-preset-library-history-invalidation-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial_dx = store.editor_get().document.key_positions["4key"][0].dx; - let preset = CounterAnimationPreset { - id: "user-delete-target".to_string(), - name: "Delete target".to_string(), - source: CounterAnimationSource::User, - label_key: None, - bezier: [0.25, 0.1, 0.25, 1.0], - scale: 1.0, - duration_ms: 300, - }; - store - .update(|data| data.counter_animation_presets.push(preset.clone())) - .unwrap(); - store - .commit_editor_document(editor_request( - store.editor_get().revision, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, initial_dx + 1.0), - )) - .unwrap(); - assert!(store.history_status().can_undo); - let editor_revision = store.editor_get().revision; - - let transaction = store - .commit_legacy_resource_deletion( - EditorCommitOrigin::LegacyAdapter("counter_animation_delete_test".to_string()), - &[ - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - ], - |data| { - data.counter_animation_presets - .retain(|candidate| candidate.id != preset.id); - Ok(()) - }, - ) - .unwrap(); - - assert!(transaction.change.result.changed_fields.is_empty()); - assert_eq!(transaction.change.result.revision, editor_revision); - let status = transaction.change.history_status.as_ref().unwrap(); - assert!(!status.can_undo); - assert!(!status.can_redo); - assert!(store - .snapshot() - .counter_animation_presets - .iter() - .all(|candidate| candidate.id != preset.id)); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn unbound_sound_library_delete_invalidates_stale_history() { - let dir = test_directory("sound-library-history-invalidation-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial_dx = store.editor_get().document.key_positions["4key"][0].dx; - let sound_path = "/tmp/dmnote-unbound-history.wav".to_string(); - store - .update(|data| { - data.sound_library - .insert(sound_path.clone(), SoundLibraryEntry::default()); - }) - .unwrap(); - store - .commit_editor_document(editor_request( - store.editor_get().revision, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, initial_dx + 1.0), - )) - .unwrap(); - assert!(store.history_status().can_undo); - - let transaction = store - .commit_legacy_resource_deletion( - EditorCommitOrigin::LegacyAdapter("sound_delete_test".to_string()), - &[ - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - ], - |data| { - data.sound_library.remove(&sound_path); - Ok(()) - }, - ) - .unwrap(); - - assert!(transaction.change.result.changed_fields.is_empty()); - let status = transaction.change.history_status.as_ref().unwrap(); - assert!(!status.can_undo); - assert!(!status.can_redo); - assert!(!store.snapshot().sound_library.contains_key(&sound_path)); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn counter_preset_library_only_update_preserves_editor_history() { - let dir = test_directory("counter-preset-library-history-preservation-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial_dx = store.editor_get().document.key_positions["4key"][0].dx; - let preset = CounterAnimationPreset { - id: "user-update-target".to_string(), - name: "Before".to_string(), - source: CounterAnimationSource::User, - label_key: None, - bezier: [0.25, 0.1, 0.25, 1.0], - scale: 1.0, - duration_ms: 300, - }; - store - .update(|data| data.counter_animation_presets.push(preset.clone())) - .unwrap(); - store - .commit_editor_document(editor_request( - store.editor_get().revision, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, initial_dx + 1.0), - )) - .unwrap(); - let before_status = store.history_status(); - assert!(before_status.can_undo); - - let transaction = store - .commit_legacy_editor_transaction( - EditorCommitOrigin::LegacyAdapter("counter_animation_update_test".to_string()), - &[ - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - ], - |data| { - let target = data - .counter_animation_presets - .iter_mut() - .find(|candidate| candidate.id == preset.id) - .unwrap(); - target.name = "After".to_string(); - target.bezier = [0.42, 0.0, 0.58, 1.0]; - Ok(()) - }, - ) - .unwrap(); - - assert!(transaction.change.result.changed_fields.is_empty()); - assert!(transaction.change.history_status.is_none()); - let after_status = store.history_status(); - assert_eq!( - after_status.history_revision, - before_status.history_revision - ); - assert_eq!(after_status.history_epoch, before_status.history_epoch); - assert!(after_status.can_undo); - assert!(!after_status.can_redo); - assert_eq!( - store - .snapshot() - .counter_animation_presets - .iter() - .find(|candidate| candidate.id == preset.id) - .unwrap() - .name, - "After" - ); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn css_watcher_reload_after_undo_preserves_latest_canonical_and_invalidates_redo() { - let dir = test_directory("css-watcher-history-interleaving-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let watched_path = "/tmp/dmnote-history-watched.css"; - store - .update(|data| { - data.use_custom_css = true; - data.custom_css = CustomCss { - path: Some(watched_path.to_string()), - content: "before".to_string(), - }; - }) - .unwrap(); - - let current_counters = store.snapshot().key_counters; - let preset = store - .commit_preset_editor_transaction( - EditorCommitOrigin::LegacyAdapter("css_watcher_preset_test".to_string()), - &[], - current_counters, - |data| { - data.custom_css.content = "preset".to_string(); - Ok(()) - }, - ) - .unwrap(); - assert_eq!( - preset - .change - .history_status - .as_ref() - .unwrap() - .history_revision, - 1 - ); - drop(preset); - let undo_id = uuid::Uuid::new_v4().to_string(); - let gate = store.history_gate(); - let barrier = gate.close(&undo_id).unwrap(); - let current_counters = store.snapshot().key_counters; - store - .apply_history_operation(HistoryDirection::Undo, &undo_id, ¤t_counters, || {}) - .unwrap(); - drop(barrier); - assert_eq!(store.snapshot().custom_css.content, "before"); - assert!(store.state.read().history.status(false).can_redo); - - let loaded = ValidatedCssFile { - canonical_path: watched_path.to_string(), - content: "after-hot-reload".to_string(), - }; - let transaction = commit_css_reload(&store, watched_path, &loaded).unwrap(); - assert_eq!( - transaction.value.0.as_ref().unwrap().content, - "after-hot-reload" - ); - let status = transaction.history_status.as_ref().unwrap(); - assert!(!status.can_redo); - drop(transaction); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&redo_id).unwrap(); - let current_counters = store.snapshot().key_counters; - let redo = store.apply_history_operation( - HistoryDirection::Redo, - &redo_id, - ¤t_counters, - || {}, - ); - drop(barrier); - assert_eq!(redo.unwrap_err(), "HISTORY_NOTHING_TO_REDO"); - assert_eq!(store.snapshot().custom_css.content, "after-hot-reload"); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn mode_history_records_restores_and_rejects_stale_epoch() { - let dir = test_directory("mode-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial_mode = store.snapshot().selected_key_type; - let next_mode = if initial_mode == "8key" { - "4key".to_string() - } else { - "8key".to_string() - }; - - let no_op = store - .commit_aux_editor_transaction( - HistoryScope::Mode, - Some(0), - EditorCommitOrigin::LegacyAdapter("mode_noop_test".to_string()), - &[], - |data| Ok(data.selected_key_type.clone()), - ) - .unwrap(); - assert!(no_op.change.history_status.is_none()); - drop(no_op); - assert_eq!(store.history_status().history_revision, 0); - - let transaction = store - .commit_aux_editor_transaction( - HistoryScope::Mode, - Some(0), - EditorCommitOrigin::LegacyAdapter("mode_history_test".to_string()), - &[], - |data| { - data.selected_key_type = next_mode.clone(); - Ok(()) - }, - ) - .unwrap(); - let recorded = transaction.change.history_status.as_ref().unwrap(); - assert_eq!(recorded.history_revision, 1); - assert_eq!(recorded.history_epoch, 0); - assert!(recorded.can_undo); - drop(transaction); - - let operation_id = uuid::Uuid::new_v4().to_string(); - let gate = store.history_gate(); - let barrier = gate.close(&operation_id).unwrap(); - let current_counters = store.snapshot().key_counters; - let undo = store - .apply_history_operation( - HistoryDirection::Undo, - &operation_id, - ¤t_counters, - || {}, - ) - .unwrap(); - drop(barrier); - assert!(matches!( - undo.aux_change, - Some(HistoryAuxChange::Mode(ref mode)) if mode == &initial_mode - )); - assert_eq!(store.snapshot().selected_key_type, initial_mode); - assert_eq!(undo.status.history_epoch, 1); - assert!(undo.status.can_redo); - - let before_stale = store.snapshot(); - let before_stale_status = store.history_status(); - let stale = store - .commit_aux_editor_transaction( - HistoryScope::Mode, - Some(0), - EditorCommitOrigin::LegacyAdapter("mode_stale_test".to_string()), - &[], - |data| { - data.selected_key_type = next_mode; - Ok(()) - }, - ) - .unwrap_err(); - assert_eq!( - stale.error_code, - EditorCommitErrorCode::HistoryEpochConflict - ); - assert!(stale.retryable); - assert_eq!( - stale - .details - .as_ref() - .and_then(|details| details.current_history_epoch), - Some(1) - ); - assert_eq!(store.snapshot(), before_stale); - let after_stale_status = store.history_status(); - assert_eq!( - after_stale_status.history_revision, - before_stale_status.history_revision - ); - assert_eq!( - after_stale_status.history_epoch, - before_stale_status.history_epoch - ); - assert_eq!(after_stale_status.can_undo, before_stale_status.can_undo); - assert_eq!(after_stale_status.can_redo, before_stale_status.can_redo); - assert_eq!(after_stale_status.busy, before_stale_status.busy); - assert_eq!(after_stale_status.truncated, before_stale_status.truncated); - assert!(after_stale_status.status_seq > before_stale_status.status_seq); - - let busy_id = uuid::Uuid::new_v4().to_string(); - let busy_barrier = gate.close(&busy_id).unwrap(); - let busy = store - .commit_aux_editor_transaction( - HistoryScope::Mode, - None, - EditorCommitOrigin::LegacyAdapter("mode_busy_test".to_string()), - &[], - |data| Ok(data.selected_key_type.clone()), - ) - .unwrap_err(); - assert_eq!(busy.error_code, EditorCommitErrorCode::HistoryInProgress); - assert!(busy.retryable); - drop(busy_barrier); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn custom_tabs_history_restores_typed_state_and_editor_revision() { - let dir = test_directory("custom-tabs-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial = store.snapshot(); - let tab = CustomTab { - id: "history-custom-tab".to_string(), - name: "History Custom Tab".to_string(), - }; - let tab_id = tab.id.clone(); - - let transaction = store - .commit_aux_editor_transaction( - HistoryScope::CustomTabs, - None, - EditorCommitOrigin::LegacyAdapter("custom_tabs_history_test".to_string()), - &[EditorField::Keys, EditorField::KeyPositions], - |data| { - data.custom_tabs.push(tab); - data.keys.insert(tab_id.clone(), Vec::new()); - data.key_positions.insert(tab_id.clone(), Vec::new()); - data.selected_key_type = tab_id; - Ok(()) - }, - ) - .unwrap(); - assert_eq!(transaction.change.result.revision, 1); - assert_eq!( - transaction.change.result.changed_fields, - vec![EditorField::Keys, EditorField::KeyPositions] - ); - assert_eq!( - transaction - .change - .history_status - .as_ref() - .unwrap() - .history_revision, - 1 - ); - let changed = store.snapshot(); - drop(transaction); - - let undo_id = uuid::Uuid::new_v4().to_string(); - let gate = store.history_gate(); - let barrier = gate.close(&undo_id).unwrap(); - let current_counters = changed.key_counters.clone(); - let undo = store - .apply_history_operation(HistoryDirection::Undo, &undo_id, ¤t_counters, || {}) - .unwrap(); - drop(barrier); - let Some(HistoryAuxChange::CustomTabs { - snapshot: restored, .. - }) = undo.aux_change.as_ref() - else { - panic!("custom tabs history change expected"); - }; - assert_eq!(restored.custom_tabs, initial.custom_tabs); - assert_eq!(restored.selected_key_type, initial.selected_key_type); - assert_eq!(restored.document, EditorDocumentV1::from_store(&initial)); - assert_eq!(restored.key_counters, initial.key_counters); - assert_eq!(store.snapshot().editor_revision, 2); - assert_eq!( - undo.change.as_ref().unwrap().event.as_ref().unwrap().origin, - "historyUndo" - ); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let redo_barrier = gate.close(&redo_id).unwrap(); - let current_counters = store.snapshot().key_counters; - let redo = store - .apply_history_operation(HistoryDirection::Redo, &redo_id, ¤t_counters, || {}) - .unwrap(); - drop(redo_barrier); - assert!(matches!( - redo.aux_change, - Some(HistoryAuxChange::CustomTabs { .. }) - )); - assert_eq!( - EditorDocumentV1::from_store(&store.snapshot()), - EditorDocumentV1::from_store(&changed) - ); - assert_eq!(store.snapshot().custom_tabs, changed.custom_tabs); - assert_eq!( - store.snapshot().selected_key_type, - changed.selected_key_type - ); - assert_eq!(store.snapshot().editor_revision, 3); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn custom_tab_delete_history_restores_note_css_and_change_projection() { - let dir = test_directory("custom-tab-override-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let tab_id = "history-override-tab".to_string(); - let created_tab_id = tab_id.clone(); - let create = store - .commit_aux_editor_transaction( - HistoryScope::CustomTabs, - None, - EditorCommitOrigin::LegacyAdapter("custom_tab_override_create".to_string()), - &[EditorField::Keys, EditorField::KeyPositions], - |data| { - data.custom_tabs.push(CustomTab { - id: created_tab_id.clone(), - name: "Override History".to_string(), - }); - data.keys.insert(created_tab_id.clone(), Vec::new()); - data.key_positions - .insert(created_tab_id.clone(), Vec::new()); - data.selected_key_type = created_tab_id; - Ok(()) - }, - ) - .unwrap(); - drop(create); - - let css = TabCss { - path: Some(dir.join("restored.css").to_string_lossy().to_string()), - content: ".restored {}".to_string(), - enabled: true, - }; - let note = TabNoteSettings::default(); - store - .update(|data| { - data.tab_css_overrides.insert(tab_id.clone(), css.clone()); - data.tab_note_overrides.insert(tab_id.clone(), note.clone()); - }) - .unwrap(); - - let deleted_tab_id = tab_id.clone(); - let delete = store - .commit_aux_editor_transaction( - HistoryScope::CustomTabs, - None, - EditorCommitOrigin::LegacyAdapter("custom_tab_override_delete".to_string()), - &[ - EditorField::Keys, - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - EditorField::LayerGroups, - ], - |data| { - data.custom_tabs.retain(|tab| tab.id != deleted_tab_id); - data.keys.remove(&deleted_tab_id); - data.key_positions.remove(&deleted_tab_id); - data.stat_positions.remove(&deleted_tab_id); - data.graph_positions.remove(&deleted_tab_id); - data.knob_positions.remove(&deleted_tab_id); - data.layer_groups.remove(&deleted_tab_id); - data.key_counters.remove(&deleted_tab_id); - data.tab_css_overrides.remove(&deleted_tab_id); - data.tab_note_overrides.remove(&deleted_tab_id); - data.selected_key_type = "4key".to_string(); - Ok(()) - }, - ) - .unwrap(); - drop(delete); - assert!(!store.snapshot().tab_css_overrides.contains_key(&tab_id)); - assert!(!store.snapshot().tab_note_overrides.contains_key(&tab_id)); - - let other_tab_id = "4key".to_string(); - let other_css = TabCss { - path: Some(dir.join("other.css").to_string_lossy().to_string()), - content: ".other {}".to_string(), - enabled: false, - }; - let other_note = TabNoteSettings { - speed: Some(777), - ..TabNoteSettings::default() - }; - store - .update(|data| { - data.tab_css_overrides - .insert(other_tab_id.clone(), other_css.clone()); - data.tab_note_overrides - .insert(other_tab_id.clone(), other_note.clone()); - }) - .unwrap(); - - let gate = store.history_gate(); - let undo_id = uuid::Uuid::new_v4().to_string(); - let undo_barrier = gate.close(&undo_id).unwrap(); - let current_counters = store.snapshot().key_counters; - let undo = store - .apply_history_operation(HistoryDirection::Undo, &undo_id, ¤t_counters, || {}) - .unwrap(); - drop(undo_barrier); - let Some(HistoryAuxChange::CustomTabs { - snapshot, - changed_tab_css_ids, - .. - }) = undo.aux_change.as_ref() - else { - panic!("custom tabs history change expected"); - }; - assert_eq!(snapshot.tab_css_patch[&tab_id], Some(css.clone())); - assert_eq!(snapshot.tab_note_patch[&tab_id], Some(note.clone())); - assert_eq!(changed_tab_css_ids, std::slice::from_ref(&tab_id)); - assert_eq!(store.snapshot().tab_css_overrides[&tab_id], css); - assert_eq!(store.snapshot().tab_note_overrides[&tab_id], note); - assert_eq!(store.snapshot().tab_css_overrides[&other_tab_id], other_css); - assert_eq!( - store.snapshot().tab_note_overrides[&other_tab_id], - other_note - ); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let redo_barrier = gate.close(&redo_id).unwrap(); - let current_counters = store.snapshot().key_counters; - let redo = store - .apply_history_operation(HistoryDirection::Redo, &redo_id, ¤t_counters, || {}) - .unwrap(); - drop(redo_barrier); - let Some(HistoryAuxChange::CustomTabs { - snapshot, - changed_tab_css_ids, - .. - }) = redo.aux_change.as_ref() - else { - panic!("custom tabs history change expected"); - }; - assert_eq!(snapshot.tab_css_patch[&tab_id], None); - assert_eq!(snapshot.tab_note_patch[&tab_id], None); - assert_eq!(changed_tab_css_ids, std::slice::from_ref(&tab_id)); - assert!(!store.snapshot().tab_css_overrides.contains_key(&tab_id)); - assert!(!store.snapshot().tab_note_overrides.contains_key(&tab_id)); - assert_eq!(store.snapshot().tab_css_overrides[&other_tab_id], other_css); - assert_eq!( - store.snapshot().tab_note_overrides[&other_tab_id], - other_note - ); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn custom_tab_delete_history_restores_plugin_instances_atomically() { - let dir = test_directory("custom-tab-plugin-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let tab_id = "history-plugin-tab".to_string(); - let create_tab_id = tab_id.clone(); - let create = store - .commit_aux_editor_transaction( - HistoryScope::CustomTabs, - None, - EditorCommitOrigin::LegacyAdapter("custom_tab_plugin_create".to_string()), - &[EditorField::Keys, EditorField::KeyPositions], - |data| { - data.custom_tabs.push(CustomTab { - id: create_tab_id.clone(), - name: "Plugin History".to_string(), - }); - data.keys.insert(create_tab_id.clone(), Vec::new()); - data.key_positions.insert(create_tab_id.clone(), Vec::new()); - data.selected_key_type = create_tab_id; - Ok(()) - }, - ) - .unwrap(); - drop(create); - - let mut target = saved_plugin_instance(11.0); - target.tab_id = Some(tab_id.clone()); - target.group_id = Some("group-plugin".to_string()); - let mut retained = saved_plugin_instance(22.0); - retained.tab_id = Some("4key".to_string()); - let plugin_a_key = plugin_instances_storage_key("plugin-a"); - let plugin_b_key = plugin_instances_storage_key("plugin-b"); - let plugin_a_before = serde_json::json!([ - target, - { "broken": true }, - retained, - ]); - let plugin_b_before = serde_json::json!([ - { - "instanceId": "00000000-0000-4000-8000-000000000033", - "position": { "x": 33.0, "y": 20.0 }, - "tabId": tab_id, - "hidden": false, - "groupId": "group-plugin" - } - ]); - store - .update(|data| { - data.plugin_data - .insert(plugin_a_key.clone(), plugin_a_before.clone()); - data.plugin_data - .insert(plugin_b_key.clone(), plugin_b_before.clone()); - }) - .unwrap(); - - let before_plugin_revision = store.plugin_model_revision(); - let before_epoch = store.history_status().history_epoch; - let delete_tab_id = "history-plugin-tab".to_string(); - let admission = store.admit_editor_mutation().unwrap(); - let delete = store - .commit_aux_editor_reset_transaction_with_admission( - AuxEditorResetTransactionOptions { - scope: HistoryScope::CustomTabs, - observed_history_epoch: Some(before_epoch), - origin: EditorCommitOrigin::LegacyAdapter( - "custom_tab_plugin_delete".to_string(), - ), - touched_fields: &[ - EditorField::Keys, - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - EditorField::LayerGroups, - ], - plugin_instances_reset: PluginInstancesResetScope::Mode(delete_tab_id.clone()), - }, - admission, - |data| { - data.custom_tabs.retain(|tab| tab.id != delete_tab_id); - data.keys.remove(&delete_tab_id); - data.key_positions.remove(&delete_tab_id); - data.stat_positions.remove(&delete_tab_id); - data.graph_positions.remove(&delete_tab_id); - data.knob_positions.remove(&delete_tab_id); - data.layer_groups.remove(&delete_tab_id); - data.key_counters.remove(&delete_tab_id); - data.selected_key_type = "4key".to_string(); - Ok(()) - }, - ) - .unwrap(); - - assert_eq!(store.plugin_model_revision(), before_plugin_revision + 1); - assert_eq!(store.history_status().history_epoch, before_epoch + 1); - assert_eq!(delete.change.plugin_instances_changes.len(), 2); - let after_delete = store.snapshot(); - assert_eq!( - after_delete.plugin_data[&plugin_a_key], - serde_json::json!([{ "broken": true }, retained]) - ); - assert!(!after_delete.plugin_data.contains_key(&plugin_b_key)); - let mut stale_instance = saved_plugin_instance(44.0); - stale_instance.tab_id = Some("history-plugin-tab".to_string()); - let mut stale_request = plugin_instances_request( - "plugin-b", - vec![stale_instance], - uuid::Uuid::new_v4().to_string(), - None, - Some(store.plugin_model_revision()), - ); - stale_request.observed_history_epoch = Some(before_epoch); - assert_eq!( - store.commit_plugin_instances(stale_request).unwrap_err(), - "HISTORY_EPOCH_CONFLICT" - ); - assert!(!store.snapshot().plugin_data.contains_key(&plugin_b_key)); - drop(delete); - - let gate = store.history_gate(); - let undo_id = uuid::Uuid::new_v4().to_string(); - let undo_barrier = gate.close(&undo_id).unwrap(); - let current_counters = store.snapshot().key_counters; - let undo = store - .apply_history_operation(HistoryDirection::Undo, &undo_id, ¤t_counters, || {}) - .unwrap(); - drop(undo_barrier); - let Some(HistoryAuxChange::CustomTabs { - plugin_ids, - revision, - .. - }) = undo.aux_change.as_ref() - else { - panic!("custom tabs history change expected"); - }; - assert_eq!( - plugin_ids, - &["plugin-a".to_string(), "plugin-b".to_string()] - ); - assert_eq!(*revision, before_plugin_revision + 2); - let restored = store.snapshot(); - assert_eq!(restored.plugin_data[&plugin_a_key], plugin_a_before); - assert_eq!(restored.plugin_data[&plugin_b_key], plugin_b_before); - assert!(restored - .custom_tabs - .iter() - .any(|tab| tab.id == delete_tab_id)); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let redo_barrier = gate.close(&redo_id).unwrap(); - let current_counters = store.snapshot().key_counters; - store - .apply_history_operation(HistoryDirection::Redo, &redo_id, ¤t_counters, || {}) - .unwrap(); - drop(redo_barrier); - let redone = store.snapshot(); - assert_eq!( - redone.plugin_data[&plugin_a_key], - serde_json::json!([{ "broken": true }, retained]) - ); - assert!(!redone.plugin_data.contains_key(&plugin_b_key)); - assert!(!redone.custom_tabs.iter().any(|tab| tab.id == delete_tab_id)); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn preset_full_history_restores_settings_and_preserves_redo_snapshot() { - let dir = test_directory("preset-full-history-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let mode = store.snapshot().selected_key_type; - let original_key = store.snapshot().keys[&mode][0].canonical(); - let preserved_key = store.snapshot().keys[&mode][1].canonical(); - let initial_plugin = JsPlugin { - id: "initial-plugin".to_string(), - name: "Initial Plugin".to_string(), - path: None, - content: "initial".to_string(), - enabled: true, - }; - let initial_font = CustomFont { - id: "initial-font".to_string(), - font_type: FontType::Web, - name: "InitialFont".to_string(), - display_name: "Initial Font".to_string(), - enabled: true, - local_path: None, - css_content: Some("initial-font-css".to_string()), - weight_ranges: Vec::new(), - }; - store - .update(|data| { - data.key_counters - .entry(mode.clone()) - .or_default() - .insert(original_key.clone(), 3); - data.use_custom_css = false; - data.custom_css = CustomCss { - path: Some(dir.join("initial.css").to_string_lossy().to_string()), - content: ".initial {}".to_string(), - }; - data.use_custom_js = false; - data.custom_js.path = Some(dir.join("initial.js").to_string_lossy().to_string()); - data.custom_js.content = "initial();".to_string(); - data.custom_js.plugins = vec![initial_plugin.clone()]; - data.font_settings = FontSettings { - custom_fonts: vec![initial_font.clone()], - }; - data.background_color = "#111111".to_string(); - data.note_settings.speed = 321; - data.note_effect = false; - data.laboratory_enabled = false; - data.tab_note_overrides.insert( - mode.clone(), - TabNoteSettings { - speed: Some(333), - ..TabNoteSettings::default() - }, - ); - data.tab_css_overrides.insert( - mode.clone(), - TabCss { - path: Some(dir.join("initial-tab.css").to_string_lossy().to_string()), - content: ".initial-tab {}".to_string(), - enabled: true, - }, - ); - }) - .unwrap(); - let mut runtime_counters = store.snapshot().key_counters; - runtime_counters - .entry(mode.clone()) - .or_default() - .insert(original_key.clone(), 11); - runtime_counters - .entry(mode.clone()) - .or_default() - .insert(preserved_key.clone(), 13); - let mut initial = PresetFullHistorySnapshot::from_store(&store.snapshot()); - initial.key_counters = runtime_counters.clone(); - - let preset_key = "PresetHistoryKey".to_string(); - let preset_plugin = JsPlugin { - id: "preset-plugin".to_string(), - name: "Preset Plugin".to_string(), - path: None, - content: "preset".to_string(), - enabled: false, - }; - let preset_font = CustomFont { - id: "preset-font".to_string(), - font_type: FontType::Web, - name: "PresetFont".to_string(), - display_name: "Preset Font".to_string(), - enabled: true, - local_path: None, - css_content: Some("preset-font-css".to_string()), - weight_ranges: Vec::new(), - }; - let transaction = store - .commit_preset_editor_transaction( - EditorCommitOrigin::LegacyAdapter("preset_full_history_test".to_string()), - &[EditorField::Keys], - runtime_counters, - |data| { - data.keys.get_mut(&mode).unwrap()[0] = preset_key.clone().into(); - data.use_custom_css = true; - data.custom_css = CustomCss { - path: Some(dir.join("preset.css").to_string_lossy().to_string()), - content: ".preset {}".to_string(), - }; - data.use_custom_js = true; - data.custom_js.path = Some(dir.join("preset.js").to_string_lossy().to_string()); - data.custom_js.content = "preset();".to_string(); - data.custom_js.plugins = vec![preset_plugin.clone()]; - data.font_settings = FontSettings { - custom_fonts: vec![preset_font.clone()], - }; - data.background_color = "#abcdef".to_string(); - data.note_settings.speed = 654; - data.note_effect = true; - data.laboratory_enabled = true; - data.tab_note_overrides.insert( - mode.clone(), - TabNoteSettings { - speed: Some(777), - ..TabNoteSettings::default() - }, - ); - data.tab_css_overrides.insert( - mode.clone(), - TabCss { - path: Some(dir.join("preset-tab.css").to_string_lossy().to_string()), - content: ".preset-tab {}".to_string(), - enabled: false, - }, - ); - Ok(()) - }, - ) - .unwrap(); - assert_eq!( - transaction - .change - .history_status - .as_ref() - .unwrap() - .history_revision, - 1 - ); - assert_eq!(transaction.change.result.revision, 1); - drop(transaction); - let preset = PresetFullHistorySnapshot::from_store(&store.snapshot()); - - let gate = store.history_gate(); - let undo_id = uuid::Uuid::new_v4().to_string(); - let undo_barrier = gate.close(&undo_id).unwrap(); - let mut current_counters = store.snapshot().key_counters; - current_counters - .get_mut(&mode) - .unwrap() - .insert(preserved_key.clone(), 17); - let undo = store - .apply_history_operation(HistoryDirection::Undo, &undo_id, ¤t_counters, || {}) - .unwrap(); - drop(undo_barrier); - let Some(HistoryAuxChange::PresetFull { - snapshot, - settings_diff, - changed_tab_css_ids, - }) = undo.aux_change.as_ref() - else { - panic!("preset full history change expected"); - }; - assert_eq!(snapshot.as_ref(), &initial); - assert!(settings_diff.changed.use_custom_css.is_some()); - assert!(settings_diff.changed.custom_css.is_some()); - assert!(settings_diff.changed.use_custom_js.is_some()); - assert!(settings_diff.changed.custom_js.is_some()); - assert!(settings_diff.changed.font_settings.is_some()); - assert!(settings_diff.changed.background_color.is_some()); - assert!(settings_diff.changed.note_settings.is_some()); - assert!(settings_diff.changed.note_effect.is_some()); - assert!(settings_diff.changed.laboratory_enabled.is_some()); - assert_eq!(changed_tab_css_ids, std::slice::from_ref(&mode)); - let mut restored = initial.clone(); - restored - .key_counters - .get_mut(&mode) - .unwrap() - .insert(preserved_key.clone(), 17); - assert_eq!( - PresetFullHistorySnapshot::from_store(&store.snapshot()), - restored - ); - assert_eq!(store.snapshot().editor_revision, 2); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let redo_barrier = gate.close(&redo_id).unwrap(); - let mut current_counters = store.snapshot().key_counters; - current_counters - .get_mut(&mode) - .unwrap() - .insert(preserved_key.clone(), 19); - let redo = store - .apply_history_operation(HistoryDirection::Redo, &redo_id, ¤t_counters, || {}) - .unwrap(); - drop(redo_barrier); - let Some(HistoryAuxChange::PresetFull { - snapshot, - settings_diff, - changed_tab_css_ids, - }) = redo.aux_change.as_ref() - else { - panic!("preset full redo change expected"); - }; - let mut redo_snapshot = preset.clone(); - redo_snapshot - .key_counters - .get_mut(&mode) - .unwrap() - .insert(preserved_key.clone(), 17); - assert_eq!(snapshot.as_ref(), &redo_snapshot); - assert_eq!( - settings_diff.changed.background_color.as_deref(), - Some("#abcdef") - ); - assert_eq!(changed_tab_css_ids, std::slice::from_ref(&mode)); - let mut redone = preset; - redone - .key_counters - .get_mut(&mode) - .unwrap() - .insert(preserved_key, 19); - assert_eq!( - PresetFullHistorySnapshot::from_store(&store.snapshot()), - redone - ); - assert_eq!(store.snapshot().editor_revision, 3); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn oversized_aux_record_truncates_history_after_persisting_mutation() { - let dir = test_directory("aux-history-truncation-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial_mode = store.snapshot().selected_key_type; - let next_mode = if initial_mode == "8key" { - "4key".to_string() - } else { - "8key".to_string() - }; - store - .state - .write() - .history - .set_limits_for_test(1, 32 * 1024 * 1024, 50); - - let transaction = store - .commit_aux_editor_transaction( - HistoryScope::Mode, - None, - EditorCommitOrigin::LegacyAdapter("mode_truncation_test".to_string()), - &[], - |data| { - data.selected_key_type = next_mode.clone(); - Ok(()) - }, - ) - .unwrap(); - let status = transaction.change.history_status.unwrap(); - - assert_eq!(store.snapshot().selected_key_type, next_mode); - assert_eq!(status.history_revision, 1); - assert!(!status.can_undo); - assert!(!status.can_redo); - assert_eq!(status.truncated.unwrap().reason, HISTORY_ENTRY_TOO_LARGE); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn history_undo_redo_restore_revision_event_and_reject_stale_commit() { - let dir = test_directory("editor-history-undo-redo-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial_dx = store.editor_get().document.key_positions["4key"][0].dx; - let committed_dx = initial_dx + 18.0; - let commit = editor_request( - 0, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, committed_dx), - ); - store.commit_editor_document(commit).unwrap(); - - let undo_id = uuid::Uuid::new_v4().to_string(); - let gate = store.history_gate(); - let barrier = gate.close(&undo_id).unwrap(); - let cancel_count = std::cell::Cell::new(0); - let current_counters = store.snapshot().key_counters; - let undo = store - .apply_history_operation(HistoryDirection::Undo, &undo_id, ¤t_counters, || { - cancel_count.set(cancel_count.get() + 1); - }) - .unwrap(); - drop(barrier); - - assert_eq!(cancel_count.get(), 1); - assert!(!undo.status.busy); - assert_eq!(undo.change.as_ref().unwrap().result.revision, 2); - assert_eq!( - undo.change.as_ref().unwrap().event.as_ref().unwrap().origin, - "historyUndo" - ); - assert_eq!( - store.editor_get().document.key_positions["4key"][0].dx, - initial_dx - ); - assert!(!undo.status.can_undo); - assert!(undo.status.can_redo); - - let replay_barrier = gate.close(&undo_id).unwrap(); - let current_counters = store.snapshot().key_counters; - let replay = store - .apply_history_operation(HistoryDirection::Undo, &undo_id, ¤t_counters, || { - cancel_count.set(cancel_count.get() + 1); - }) - .unwrap(); - drop(replay_barrier); - assert!(replay.replayed); - assert_eq!(cancel_count.get(), 1); - assert_eq!(store.editor_get().revision, 2); - - let stale = editor_request( - 1, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, committed_dx + 5.0), - ); - let stale_error = store.commit_editor_document(stale).unwrap_err(); - assert_eq!( - stale_error.error_code, - EditorCommitErrorCode::RevisionConflict - ); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let redo_barrier = gate.close(&redo_id).unwrap(); - let current_counters = store.snapshot().key_counters; - let redo = store - .apply_history_operation(HistoryDirection::Redo, &redo_id, ¤t_counters, || {}) - .unwrap(); - drop(redo_barrier); - assert_eq!(redo.change.as_ref().unwrap().result.revision, 3); - assert_eq!( - redo.change.as_ref().unwrap().event.as_ref().unwrap().origin, - "historyRedo" - ); - assert_eq!( - store.editor_get().document.key_positions["4key"][0].dx, - committed_dx - ); - assert!(redo.status.can_undo); - assert!(!redo.status.can_redo); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn oversized_recorded_commit_clears_history_after_persisting_mutation() { - let dir = test_directory("editor-history-truncation-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial_dx = store.editor_get().document.key_positions["4key"][0].dx; - let first = editor_request( - 0, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, initial_dx + 1.0), - ); - store.commit_editor_document(first).unwrap(); - store - .state - .write() - .history - .set_limits_for_test(1, 32 * 1024 * 1024, 50); - - let second = editor_request( - 1, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, initial_dx + 2.0), - ); - let change = store.commit_editor_document(second).unwrap(); - let status = change.history_status.unwrap(); - - assert_eq!(change.result.revision, 2); - assert_eq!( - store.editor_get().document.key_positions["4key"][0].dx, - initial_dx + 2.0 - ); - assert_eq!(status.history_revision, 2); - assert!(!status.can_undo); - assert!(!status.can_redo); - assert_eq!(status.truncated.unwrap().reason, "HISTORY_ENTRY_TOO_LARGE"); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn editor_history_projects_live_and_historical_key_counters() { - let dir = test_directory("editor-history-counter-scope-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = initialize_neutral_editor_store(&dir); - let before = store.snapshot(); - let old_key = before.keys["4key"][0].canonical(); - let preserved_key = before.keys["4key"][1].canonical(); - let mut historical_counters = before.key_counters.clone(); - historical_counters - .get_mut("4key") - .unwrap() - .insert(old_key.clone(), 12); - let new_key = "HistoryCounterScopeKey".to_string(); - let mut keys = before.keys; - keys.get_mut("4key").unwrap()[0] = new_key.clone().into(); - let commit = editor_request( - 0, - uuid::Uuid::new_v4().to_string(), - EditorPatchV1 { - keys: Some(keys), - ..EditorPatchV1::default() - }, - ); - let admission = store.admit_editor_mutation().unwrap(); - store - .commit_editor_document_with_runtime_counters_admitted( - commit, - &admission, - &historical_counters, - ) - .unwrap(); - drop(admission); - let counters_after_commit = store.snapshot().key_counters; - assert!(counters_after_commit["4key"].contains_key(&new_key)); - assert!(!counters_after_commit["4key"].contains_key(&old_key)); - - let operation_id = uuid::Uuid::new_v4().to_string(); - let gate = store.history_gate(); - let barrier = gate.close(&operation_id).unwrap(); - let mut current_counters = store.snapshot().key_counters; - current_counters - .get_mut("4key") - .unwrap() - .insert(new_key.clone(), 9); - current_counters - .get_mut("4key") - .unwrap() - .insert(preserved_key.clone(), 7); - store - .apply_history_operation( - HistoryDirection::Undo, - &operation_id, - ¤t_counters, - || {}, - ) - .unwrap(); - drop(barrier); - - let restored = store.snapshot(); - assert_eq!(restored.keys["4key"][0].canonical(), old_key); - assert_eq!(restored.key_counters["4key"][&old_key], 12); - assert_eq!(restored.key_counters["4key"][&preserved_key], 7); - assert!(!restored.key_counters["4key"].contains_key(&new_key)); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let redo_barrier = gate.close(&redo_id).unwrap(); - let mut redo_counters = restored.key_counters; - redo_counters - .get_mut("4key") - .unwrap() - .insert(old_key.clone(), 14); - redo_counters - .get_mut("4key") - .unwrap() - .insert(preserved_key.clone(), 8); - store - .apply_history_operation(HistoryDirection::Redo, &redo_id, &redo_counters, || {}) - .unwrap(); - drop(redo_barrier); - - let redone = store.snapshot(); - assert_eq!(redone.keys["4key"][0].canonical(), new_key); - assert_eq!(redone.key_counters["4key"][&new_key], 9); - assert_eq!(redone.key_counters["4key"][&preserved_key], 8); - assert!(!redone.key_counters["4key"].contains_key(&old_key)); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn history_counter_projection_keeps_live_overlap_and_restores_missing_domain() { - let target_keys = HashMap::from([ - ( - "shared-mode".to_string(), - vec![KeySlot::from("shared"), KeySlot::from("restored")], - ), - ("restored-mode".to_string(), vec![KeySlot::from("mode-key")]), - ]); - let current = HashMap::from([( - "shared-mode".to_string(), - HashMap::from([("shared".to_string(), 8), ("removed".to_string(), 5)]), - )]); - let historical = HashMap::from([ - ( - "shared-mode".to_string(), - HashMap::from([ - ("shared".to_string(), 3), - ("restored".to_string(), 12), - ("obsolete".to_string(), 4), - ]), - ), - ( - "restored-mode".to_string(), - HashMap::from([("mode-key".to_string(), 21)]), - ), - ]); - - let projected = project_history_key_counters(¤t, &historical, &target_keys); - - assert_eq!( - projected, - HashMap::from([ - ( - "shared-mode".to_string(), - HashMap::from([("shared".to_string(), 8), ("restored".to_string(), 12)]), - ), - ( - "restored-mode".to_string(), - HashMap::from([("mode-key".to_string(), 21)]), - ), - ]) - ); - } - - #[test] - fn history_undo_rejects_oversized_opposite_without_state_change() { - let dir = test_directory("editor-history-undo-budget-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial_dx = store.editor_get().document.key_positions["4key"][0].dx; - let commit = editor_request( - 0, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, initial_dx + 9.0), - ); - store.commit_editor_document(commit).unwrap(); - store - .state - .write() - .history - .set_limits_for_test(1, 32 * 1024 * 1024, 50); - let before_store = store.snapshot(); - let before_status = store.history_status(); - - let operation_id = uuid::Uuid::new_v4().to_string(); - let gate = store.history_gate(); - let barrier = gate.close(&operation_id).unwrap(); - assert!(store.history_status().busy); - let current_counters = store.snapshot().key_counters; - let error = store - .apply_history_operation( - HistoryDirection::Undo, - &operation_id, - ¤t_counters, - || {}, - ) - .unwrap_err(); - drop(barrier); - assert!(!store.history_status().busy); - - assert_eq!(error, "HISTORY_ENTRY_TOO_LARGE"); - assert_eq!(store.snapshot(), before_store); - let after_status = store.history_status(); - assert_eq!( - after_status.history_revision, - before_status.history_revision - ); - assert_eq!(after_status.history_epoch, before_status.history_epoch + 1); - assert_eq!(after_status.can_undo, before_status.can_undo); - assert_eq!(after_status.can_redo, before_status.can_redo); - assert_eq!(after_status.truncated, before_status.truncated); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn editor_mutation_is_rejected_while_history_gate_is_closed() { - let dir = test_directory("editor-history-admission-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let operation_id = uuid::Uuid::new_v4().to_string(); - let gate = store.history_gate(); - let barrier = gate.close(&operation_id).unwrap(); - let request = editor_request( - 0, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, 42.0), - ); - - let error = store.commit_editor_document(request).unwrap_err(); - assert!(error.retryable); - assert_eq!(error.error_code, EditorCommitErrorCode::HistoryInProgress); - assert_eq!( - serde_json::to_value(&error).unwrap()["errorCode"], - "HISTORY_IN_PROGRESS" - ); - drop(barrier); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn strict_commit_revalidates_admission_after_waiting_for_store_lock() { - let dir = test_directory("editor-history-admission-revalidation-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = Arc::new(AppStore::initialize_in_dir(&dir).unwrap()); - let initial_dx = store.editor_get().document.key_positions["4key"][0].dx; - let request = editor_request( - 0, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, initial_dx + 7.0), - ); - let admission = store.admit_editor_mutation().unwrap(); - let store_guard = store.state.write(); - let commit_store = Arc::clone(&store); - let (commit_started_tx, commit_started_rx) = mpsc::channel(); - let commit = thread::spawn(move || { - commit_started_tx.send(()).unwrap(); - commit_store.commit_editor_document_admitted(request, &admission) - }); - commit_started_rx - .recv_timeout(Duration::from_secs(2)) - .unwrap(); - - let gate = store.history_gate(); - let barrier_gate = Arc::clone(&gate); - let operation_id = uuid::Uuid::new_v4().to_string(); - let (barrier_ready_tx, barrier_ready_rx) = mpsc::channel(); - let (release_barrier_tx, release_barrier_rx) = mpsc::channel(); - let barrier = thread::spawn(move || { - let lease = barrier_gate.close(&operation_id).unwrap(); - barrier_ready_tx.send(()).unwrap(); - release_barrier_rx.recv().unwrap(); - drop(lease); - }); - - let deadline = Instant::now() + Duration::from_secs(2); - while !gate.is_closed() { - assert!(Instant::now() < deadline, "history gate did not close"); - thread::yield_now(); - } - assert!(matches!( - barrier_ready_rx.try_recv(), - Err(mpsc::TryRecvError::Empty) - )); - - drop(store_guard); - let error = commit.join().unwrap().unwrap_err(); - assert_eq!(error.error_code, EditorCommitErrorCode::HistoryInProgress); - assert!(error.retryable); - barrier_ready_rx - .recv_timeout(Duration::from_secs(2)) - .unwrap(); - release_barrier_tx.send(()).unwrap(); - barrier.join().unwrap(); - - assert_eq!(store.editor_get().revision, 0); - assert_eq!(store.history_status().history_revision, 0); - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn aux_mutation_revalidates_admission_after_waiting_for_store_lock() { - let dir = test_directory("aux-history-admission-revalidation-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = Arc::new(AppStore::initialize_in_dir(&dir).unwrap()); - let initial_mode = store.snapshot().selected_key_type; - let next_mode = if initial_mode == "8key" { - "4key".to_string() - } else { - "8key".to_string() - }; - let store_guard = store.state.write(); - let mutation_store = Arc::clone(&store); - let (mutation_started_tx, mutation_started_rx) = mpsc::channel(); - let mutation = thread::spawn(move || { - mutation_started_tx.send(()).unwrap(); - mutation_store.commit_aux_editor_transaction( - HistoryScope::Mode, - None, - EditorCommitOrigin::LegacyAdapter("aux_revalidation_test".to_string()), - &[], - |data| { - data.selected_key_type = next_mode; - Ok(()) - }, - ) - }); - mutation_started_rx - .recv_timeout(Duration::from_secs(2)) - .unwrap(); - - let gate = store.history_gate(); - let admission_deadline = Instant::now() + Duration::from_secs(2); - while gate.active_mutations() == 0 { - assert!( - Instant::now() < admission_deadline, - "aux mutation was not admitted" - ); - thread::yield_now(); - } - let barrier_gate = Arc::clone(&gate); - let operation_id = uuid::Uuid::new_v4().to_string(); - let (barrier_ready_tx, barrier_ready_rx) = mpsc::channel(); - let (release_barrier_tx, release_barrier_rx) = mpsc::channel(); - let barrier = thread::spawn(move || { - let lease = barrier_gate.close(&operation_id).unwrap(); - barrier_ready_tx.send(()).unwrap(); - release_barrier_rx.recv().unwrap(); - drop(lease); - }); - - let deadline = Instant::now() + Duration::from_secs(2); - while !gate.is_closed() { - assert!(Instant::now() < deadline, "history gate did not close"); - thread::yield_now(); - } - assert!(matches!( - barrier_ready_rx.try_recv(), - Err(mpsc::TryRecvError::Empty) - )); - - drop(store_guard); - let error = mutation.join().unwrap().unwrap_err(); - assert_eq!(error.error_code, EditorCommitErrorCode::HistoryInProgress); - assert!(error.retryable); - barrier_ready_rx - .recv_timeout(Duration::from_secs(2)) - .unwrap(); - release_barrier_tx.send(()).unwrap(); - barrier.join().unwrap(); - - assert_eq!(store.snapshot().selected_key_type, initial_mode); - assert_eq!(store.history_status().history_revision, 0); - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn css_watcher_reload_revalidates_after_waiting_for_store_lock() { - let dir = test_directory("css-watcher-admission-revalidation-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = Arc::new(AppStore::initialize_in_dir(&dir).unwrap()); - let watched_path = "/tmp/dmnote-watcher-revalidation.css"; - store - .update(|data| { - data.use_custom_css = true; - data.custom_css = CustomCss { - path: Some(watched_path.to_string()), - content: "before".to_string(), - }; - }) - .unwrap(); - let store_guard = store.state.write(); - let mutation_store = Arc::clone(&store); - let loaded = ValidatedCssFile { - canonical_path: watched_path.to_string(), - content: "blocked-reload".to_string(), - }; - let mutation = - thread::spawn(move || commit_css_reload(&mutation_store, watched_path, &loaded)); - - let gate = store.history_gate(); - let admission_deadline = Instant::now() + Duration::from_secs(2); - while gate.active_mutations() == 0 { - assert!( - Instant::now() < admission_deadline, - "CSS watcher reload was not admitted" - ); - thread::yield_now(); - } - let barrier_gate = Arc::clone(&gate); - let operation_id = uuid::Uuid::new_v4().to_string(); - let (barrier_ready_tx, barrier_ready_rx) = mpsc::channel(); - let (release_barrier_tx, release_barrier_rx) = mpsc::channel(); - let barrier = thread::spawn(move || { - let lease = barrier_gate.close(&operation_id).unwrap(); - barrier_ready_tx.send(()).unwrap(); - release_barrier_rx.recv().unwrap(); - drop(lease); - }); - - let deadline = Instant::now() + Duration::from_secs(2); - while !gate.is_closed() { - assert!(Instant::now() < deadline, "history gate did not close"); - thread::yield_now(); - } - assert!(matches!( - barrier_ready_rx.try_recv(), - Err(mpsc::TryRecvError::Empty) - )); - - drop(store_guard); - let error = mutation.join().unwrap().unwrap_err(); - assert_eq!(error.error_code, EditorCommitErrorCode::HistoryInProgress); - assert!(error.retryable); - barrier_ready_rx - .recv_timeout(Duration::from_secs(2)) - .unwrap(); - release_barrier_tx.send(()).unwrap(); - barrier.join().unwrap(); - - assert_eq!(store.snapshot().custom_css.content, "before"); - assert_eq!(store.state.read().history.history_revision(), 0); - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn legacy_keys_publication_drains_before_later_history_restore() { - let dir = test_directory("legacy-editor-publication-drain-test"); - std::fs::create_dir_all(&dir).unwrap(); - let state = Arc::new(AppState::initialize(initialize_neutral_editor_store(&dir)).unwrap()); - let initial = state.store.snapshot(); - let mode = initial.selected_key_type.clone(); - let mut strict_keys = initial.keys.clone(); - strict_keys.get_mut(&mode).unwrap()[0] = "StrictHistoryKey".into(); - state - .store - .commit_editor_document(editor_request( - 0, - uuid::Uuid::new_v4().to_string(), - EditorPatchV1 { - keys: Some(strict_keys), - ..EditorPatchV1::default() - }, - )) - .unwrap(); - - let legacy_key = "LegacyPublishedKey".to_string(); - let mut legacy_keys = state.store.snapshot().keys; - legacy_keys.get_mut(&mode).unwrap()[0] = legacy_key.clone().into(); - let transaction = state - .store - .commit_legacy_editor_transaction( - EditorCommitOrigin::LegacyAdapter("legacy_publish_test".to_string()), - &[EditorField::Keys], - move |data| { - data.keys = legacy_keys; - Ok(()) - }, - ) - .unwrap(); - let expected_counters = transaction.change.key_counters.clone(); - - let events = Arc::new(Mutex::new(Vec::new())); - let (runtime_started_tx, runtime_started_rx) = mpsc::channel(); - let (release_runtime_tx, release_runtime_rx) = mpsc::channel(); - let emitter = TestCounterEmitter::blocking_snapshot( - Arc::clone(&events), - mode.clone(), - legacy_key.clone(), - runtime_started_tx, - release_runtime_rx, - ); - let publisher_state = Arc::clone(&state); - let publisher_events = Arc::clone(&events); - let publisher = thread::spawn(move || { - publisher_state - .apply_committed_editor_key_runtime( - &emitter, - transaction.change.runtime_publication_generation, - &transaction.change.document.keys, - &transaction.change.selected_key_type, - &transaction.change.key_counters, - ) - .unwrap(); - publisher_events - .lock() - .unwrap() - .push("legacy:published".to_string()); - drop(transaction); - }); - runtime_started_rx - .recv_timeout(Duration::from_secs(2)) - .unwrap(); - - let post_legacy_key = "PostLegacyHistoryKey".to_string(); - let mut post_legacy_keys = state.store.snapshot().keys; - post_legacy_keys.get_mut(&mode).unwrap()[0] = post_legacy_key.clone().into(); - state - .store - .commit_editor_document(editor_request( - state.store.editor_get().revision, - uuid::Uuid::new_v4().to_string(), - EditorPatchV1 { - keys: Some(post_legacy_keys), - ..EditorPatchV1::default() - }, - )) - .unwrap(); - - let gate = state.store.history_gate(); - let undo_gate = Arc::clone(&gate); - let undo_state = Arc::clone(&state); - let undo_events = Arc::clone(&events); - let operation_id = uuid::Uuid::new_v4().to_string(); - let (undo_done_tx, undo_done_rx) = mpsc::channel(); - let undo = thread::spawn(move || { - let barrier = undo_gate.close(&operation_id).unwrap(); - let counter_guard = undo_state.lock_key_counters_for_history(); - let current_counters = counter_guard.clone(); - let outcome = undo_state - .store - .apply_history_operation( - HistoryDirection::Undo, - &operation_id, - ¤t_counters, - || {}, - ) - .unwrap(); - let change = outcome.change.as_ref().unwrap(); - undo_state.apply_committed_editor_keys_without_counters( - outcome.runtime_publication_generation, - &change.document.keys, - &change.selected_key_type, - ); - undo_events - .lock() - .unwrap() - .push("undo:restored".to_string()); - drop(counter_guard); - drop(barrier); - undo_done_tx.send(()).unwrap(); - outcome - }); - - let deadline = Instant::now() + Duration::from_secs(2); - while !gate.is_closed() { - assert!(Instant::now() < deadline, "history gate did not close"); - thread::yield_now(); - } - assert!(matches!( - undo_done_rx.try_recv(), - Err(mpsc::TryRecvError::Empty) - )); - - release_runtime_tx.send(()).unwrap(); - publisher.join().unwrap(); - undo_done_rx.recv_timeout(Duration::from_secs(2)).unwrap(); - let outcome = undo.join().unwrap(); - - assert_eq!(outcome.change.unwrap().result.revision, 4); - assert_eq!( - state.store.snapshot().keys[&mode][0].canonical(), - legacy_key - ); - assert_eq!(state.snapshot_key_counters(), expected_counters); - assert!(state.keyboard.register_key_down(&mode, &legacy_key)); - assert!(!state.keyboard.register_key_down(&mode, &post_legacy_key)); - assert_eq!( - *events.lock().unwrap(), - vec![ - "snapshot:0".to_string(), - "legacy:published".to_string(), - "undo:restored".to_string(), - ] - ); - - state.shutdown(); - drop(state); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn stale_key_publisher_cannot_overwrite_newer_counter_runtime() { - let dir = test_directory("stale-key-counter-publication-test"); - std::fs::create_dir_all(&dir).unwrap(); - let state = AppState::initialize(AppStore::initialize_in_dir(&dir).unwrap()).unwrap(); - let initial = state.store.snapshot(); - let mode = initial.selected_key_type.clone(); - let old_key = initial.keys[&mode][0].canonical(); - let new_key = "PublicationOrderedKey".to_string(); - let mut changed_keys = initial.keys; - changed_keys.get_mut(&mode).unwrap()[0] = new_key.clone().into(); - - let stale_transaction = state - .store - .commit_legacy_editor_transaction( - EditorCommitOrigin::LegacyAdapter("stale_key_publisher_test".to_string()), - &[EditorField::Keys], - move |data| { - data.keys = changed_keys; - Ok(()) - }, - ) - .unwrap(); - assert!(state.keyboard.register_key_down(&mode, &old_key)); - assert!(!state.keyboard.register_key_down(&mode, &new_key)); - - let events = Arc::new(Mutex::new(Vec::new())); - let counter_emitter = - TestCounterEmitter::new(Arc::clone(&events), mode.clone(), new_key.clone()); - let mut newer_counters = state.snapshot_key_counters(); - newer_counters.remove(&mode); - newer_counters.insert(mode.clone(), [(new_key.clone(), 9)].into_iter().collect()); - let counter_mutation = state - .replace_key_counters(&counter_emitter, newer_counters, None) - .unwrap(); - assert_eq!(counter_mutation.counters[&mode][&new_key], 9); - drop(counter_mutation); - events.lock().unwrap().clear(); - - let stale_emitter = - TestCounterEmitter::new(Arc::clone(&events), mode.clone(), new_key.clone()); - state - .apply_committed_editor_key_runtime( - &stale_emitter, - stale_transaction.change.runtime_publication_generation, - &stale_transaction.change.document.keys, - &stale_transaction.change.selected_key_type, - &stale_transaction.change.key_counters, - ) - .unwrap(); - drop(stale_transaction); - - assert_eq!(state.snapshot_key_counters()[&mode][&new_key], 9); - assert_eq!(state.store.snapshot().key_counters[&mode][&new_key], 9); - assert_eq!(state.keyboard.pressed_keys(), vec![new_key.clone()]); - assert!(state.keyboard.register_key_up(&mode, &new_key)); - assert!(state.keyboard.register_key_down(&mode, &new_key)); - assert!(!state.keyboard.register_key_down(&mode, &old_key)); - assert!(events.lock().unwrap().is_empty()); - - state.shutdown(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn editor_commit_gradient_repair_keeps_disk_document_and_event_identical() { - let dir = test_directory("editor-gradient-canonicalization-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let mut positions = store.editor_get().document.key_positions; - let position = &mut positions.get_mut("4key").unwrap()[0]; - position.background_color = Some("#BADBAD".to_string()); - position.background_gradient = Some( - serde_json::from_value(json!({ - "type": "linear", - "stops": [ - { "color": "rgba(90, 162, 247, 1)", "pos": 1.2 }, - { "color": "rgba(139, 92, 246, 1)", "pos": -0.1 } - ] - })) - .unwrap(), - ); - position.counter.fill.idle = "#FFFFFF".to_string(); - position.counter.fill_idle_gradient = Some( - serde_json::from_value(json!({ - "angle": 450, - "stops": [ - { "color": "#FFFFFF80", "pos": 0 }, - { "color": "rgba(0, 0, 0, 0)", "pos": 1 } - ] - })) - .unwrap(), - ); - let request = editor_request( - 0, - uuid::Uuid::new_v4().to_string(), - EditorPatchV1 { - key_positions: Some(positions), - ..EditorPatchV1::default() - }, - ); - - let change = store.commit_editor_document(request).unwrap(); - let committed_position = &change.document.key_positions["4key"][0]; - assert_eq!( - change.result.changed_fields, - vec![EditorField::KeyPositions] - ); - assert_eq!( - committed_position - .background_gradient - .as_ref() - .unwrap() - .angle, - 90.0 - ); - assert_eq!( - committed_position - .background_gradient - .as_ref() - .unwrap() - .stops[0] - .pos, - 0.0 - ); - assert_eq!( - committed_position.background_color.as_deref(), - Some("rgba(139, 92, 246, 1)") - ); - assert_eq!( - committed_position.counter.fill.idle, - "rgba(255,255,255,0.502)" - ); - - let event_positions = change - .event - .as_ref() - .unwrap() - .patch - .key_positions - .as_ref() - .unwrap(); - assert_eq!(event_positions, &change.document.key_positions); - assert_eq!( - EditorDocumentV1::from_store(&store.snapshot()), - change.document - ); - - let committed_document = change.document; - store.flush_and_shutdown().unwrap(); - drop(store); - let reloaded = - crate::state::migration::load_store_from_path(&dir.join("store.json")).unwrap(); - assert!(!reloaded.needs_persist); - assert_eq!( - EditorDocumentV1::from_store(&reloaded.data), - committed_document - ); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn editor_commit_folds_replace_image_mode_into_none() { - let dir = test_directory("editor-image-mode-canonicalization-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let revision_before = store.editor_get().revision; - let mut positions = store.editor_get().document.key_positions; - positions.get_mut("4key").unwrap()[0].image_mode = Some(crate::models::ImageMode::Replace); - let request = editor_request( - revision_before, - uuid::Uuid::new_v4().to_string(), - EditorPatchV1 { - key_positions: Some(positions), - ..EditorPatchV1::default() - }, - ); - - // replace만 명시한 패치는 정규화 뒤 무변경 - 빈 undo 항목이 남지 않는다 - let change = store.commit_editor_document(request).unwrap(); - assert!(change.result.changed_fields.is_empty()); - assert_eq!(change.result.revision, revision_before); - assert!(change.event.is_none()); - assert!(change.document.key_positions["4key"][0] - .image_mode - .is_none()); - assert!(store.snapshot().key_positions["4key"][0] - .image_mode - .is_none()); - - store.flush_and_shutdown().unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn invalid_gradient_preset_parse_failure_leaves_store_unchanged() { - let dir = test_directory("invalid-gradient-preset-atomicity-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let before = store.snapshot(); - let persist_count = store.writer.persist_count(); - - let parsed = serde_json::from_value::(json!({ - "keys": { "4key": ["Q"] }, - "keyPositions": { - "4key": [{ - "dx": 0, - "dy": 0, - "width": 60, - "count": 0, - "backgroundGradient": { - "angle": 90, - "stops": [{ "color": "#FFFFFF", "pos": 0 }] - } - }] - } - })); - - assert!(parsed.is_err()); - assert_eq!(store.snapshot(), before); - assert_eq!(store.writer.persist_count(), persist_count); - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn invalid_shadow_preset_read_failure_leaves_store_unchanged() { - let dir = test_directory("invalid-shadow-preset-atomicity-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let before = store.snapshot(); - let persist_count = store.writer.persist_count(); - let source_path = dir.join("invalid-preset.json"); - - for (source, expected_error) in [ - ( - r##"{ - "keyPositions": { - "4key": [{ - "dx": 0, - "dy": 0, - "width": 60, - "count": 0, - "shadow": { - "enabled": true, - "color": "#123456", - "offsetX": 0, - "offsetY": 0, - "blur": 100.1 - } - }] - } - }"##, - "invalid-preset: keyPositions[\"4key\"][0].shadow.blur: must be a finite number between 0 and 100", - ), - ( - r##"{ - "statPositions": { - "4key": [{ - "statType": "kps", - "dx": 0, - "dy": 0, - "width": 60, - "count": 0, - "activeShadow": { - "enabled": true, - "color": "#123456", - "offsetX": -100.1, - "offsetY": 0, - "blur": 12 - } - }] - } - }"##, - "invalid-preset: statPositions[\"4key\"][0].activeShadow.offsetX: must be a finite number between -100 and 100", - ), - ] { - std::fs::write(&source_path, source).unwrap(); - let error = crate::commands::preset::load::read_preset_file_for_simulation( - &source_path, - ) - .err() - .expect("invalid visual effect preset must be rejected") - .to_string(); - - assert_eq!(error, expected_error); - assert_eq!(store.snapshot(), before); - assert_eq!(store.writer.persist_count(), persist_count); - } - - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn strict_editor_noop_is_acknowledged_without_persist_or_event() { - let dir = test_directory("strict-editor-noop-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let persist_count = store.writer.persist_count(); - let request = editor_request( - 0, - uuid::Uuid::new_v4().to_string(), - EditorPatchV1::default(), - ); - - let change = store.commit_editor_document(request).unwrap(); - - assert_eq!(change.result.revision, 0); - assert!(change.result.changed_fields.is_empty()); - assert!(change.event.is_none()); - assert!(!change.replayed); - assert_eq!(store.writer.persist_count(), persist_count); - assert_eq!(store.snapshot().editor_revision, 0); - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn strict_editor_conflict_does_not_persist() { - let dir = test_directory("strict-editor-conflict-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let persist_count = store.writer.persist_count(); - let error = store - .commit_editor_document(editor_request( - 1, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, 123.0), - )) - .unwrap_err(); - - assert_eq!(error.error_code, EditorCommitErrorCode::RevisionConflict); - assert_eq!(error.details.unwrap().current_revision, Some(0)); - assert_eq!(store.writer.persist_count(), persist_count); - assert_eq!(store.snapshot().editor_revision, 0); - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn strict_editor_io_failure_is_not_committed_or_acknowledged() { - let dir = test_directory("strict-editor-io-failure-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let before = store.snapshot(); - let disk_before = std::fs::read(dir.join("store.json")).unwrap(); - let request = editor_request( - 0, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, 456.0), - ); - - store.writer.fail_next_persist(); - let error = store.commit_editor_document(request.clone()).unwrap_err(); - assert_eq!(error.error_code, EditorCommitErrorCode::IoError); - assert_eq!(store.snapshot(), before); - assert_eq!(std::fs::read(dir.join("store.json")).unwrap(), disk_before); - - let retried = store.commit_editor_document(request).unwrap(); - assert_eq!(retried.result.revision, 1); - assert_eq!(store.snapshot().key_positions["4key"][0].dx, 456.0); - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn strict_editor_structural_single_field_update_requires_pair() { - let dir = test_directory("strict-editor-paired-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = initialize_neutral_editor_store(&dir); - let before = store.snapshot(); - let mut keys = before.keys.clone(); - keys.get_mut("4key").unwrap().push("F5".into()); - let error = store - .commit_editor_document(editor_request( - 0, - uuid::Uuid::new_v4().to_string(), - EditorPatchV1 { - keys: Some(keys), - ..EditorPatchV1::default() - }, - )) - .unwrap_err(); - - assert_eq!( - error.error_code, - EditorCommitErrorCode::PairedUpdateRequired - ); - assert_eq!(store.snapshot(), before); - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn strict_editor_retry_returns_original_ack_before_base_check() { - let dir = test_directory("strict-editor-idempotency-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let request = editor_request( - 0, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, 789.0), - ); - let first = store.commit_editor_document(request.clone()).unwrap(); - let persist_count = store.writer.persist_count(); - - let retry = store.commit_editor_document(request).unwrap(); - - assert_eq!(retry.result, first.result); - assert!(retry.event.is_none()); - assert!(retry.replayed); - assert_eq!(store.writer.persist_count(), persist_count); - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn strict_editor_rejects_reused_mutation_id_with_different_request() { - let dir = test_directory("strict-editor-mutation-reuse-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let mutation_id = uuid::Uuid::new_v4().to_string(); - store - .commit_editor_document(editor_request( - 0, - mutation_id.clone(), - EditorPatchV1::default(), - )) - .unwrap(); - - let error = store - .commit_editor_document(editor_request( - 0, - mutation_id, - position_patch(&store, 321.0), - )) - .unwrap_err(); - assert_eq!(error.error_code, EditorCommitErrorCode::MutationIdReused); - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn mutation_ack_ring_retains_only_the_latest_32_results() { - let dir = test_directory("strict-editor-ring-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let ids = (0..33) - .map(|_| uuid::Uuid::new_v4().to_string()) - .collect::>(); - for id in &ids { - store - .commit_editor_document(editor_request(0, id.clone(), EditorPatchV1::default())) - .unwrap(); - } - store - .commit_editor_document(editor_request( - 0, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, 654.0), - )) - .unwrap(); - - let evicted = store - .commit_editor_document(editor_request(0, ids[0].clone(), EditorPatchV1::default())) - .unwrap_err(); - assert_eq!(evicted.error_code, EditorCommitErrorCode::RevisionConflict); - - let retained = store - .commit_editor_document(editor_request(0, ids[32].clone(), EditorPatchV1::default())) - .unwrap(); - assert_eq!(retained.result.revision, 0); - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn canonical_request_hash_makes_map_order_idempotent() { - let dir = test_directory("strict-editor-canonical-hash-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = initialize_neutral_editor_store(&dir); - let mutation_id = uuid::Uuid::new_v4().to_string(); - let current = store.snapshot().keys; - let mut entries = current.into_iter().collect::>(); - entries.sort_by(|left, right| left.0.cmp(&right.0)); - let forward = entries.iter().cloned().collect::>(); - let reverse = entries.into_iter().rev().collect::>(); - - let first = store - .commit_editor_document(editor_request( - 0, - mutation_id.clone(), - EditorPatchV1 { - keys: Some(forward), - ..EditorPatchV1::default() - }, - )) - .unwrap(); - let retry = store - .commit_editor_document(editor_request( - 0, - mutation_id, - EditorPatchV1 { - keys: Some(reverse), - ..EditorPatchV1::default() - }, - )) - .unwrap(); - - assert_eq!(retry.result, first.result); - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn editor_get_never_observes_a_revision_document_mismatch() { - let dir = test_directory("editor-get-atomic-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = Arc::new(AppStore::initialize_in_dir(&dir).unwrap()); - let done = Arc::new(AtomicBool::new(false)); - let reader_store = Arc::clone(&store); - let reader_done = Arc::clone(&done); - let reader = std::thread::spawn(move || { - while !reader_done.load(Ordering::Acquire) { - let snapshot = reader_store.editor_get(); - if snapshot.revision > 0 { - assert_eq!( - snapshot.document.key_positions["4key"][0].dx, - 10_000.0 + snapshot.revision as f64 - ); - } - } - }); - - for expected_revision in 1..=20 { - let base = store.editor_get().revision; - store - .commit_editor_document(editor_request( - base, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, 10_000.0 + expected_revision as f64), - )) - .unwrap(); - } - done.store(true, Ordering::Release); - reader.join().unwrap(); - let final_snapshot = store.editor_get(); - assert_eq!(final_snapshot.revision, 20); - assert_eq!( - final_snapshot.document.key_positions["4key"][0].dx, - 10_020.0 - ); - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn metadata_only_legacy_transaction_persists_without_editor_revision_or_event() { - let dir = test_directory("editor-metadata-only-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let persist_count = store.writer.persist_count(); - - let transaction = store - .commit_legacy_editor_transaction( - EditorCommitOrigin::LegacyAdapter("metadata_test".to_string()), - &[], - |data| { - data.language = "en".to_string(); - Ok(()) - }, - ) - .unwrap(); - - assert_eq!(store.writer.persist_count(), persist_count + 1); - assert_eq!(transaction.change.result.revision, 0); - assert!(transaction.change.result.changed_fields.is_empty()); - assert!(transaction.change.event.is_none()); - assert_eq!(store.snapshot().language, "en"); - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn strict_editor_commit_preserves_existing_ghost_modes_losslessly() { - let dir = test_directory("editor-grandfather-test"); - std::fs::create_dir_all(&dir).unwrap(); - let mut data = super::initialize_default_state(); - data.keys - .insert("ghost".to_string(), vec!["GhostKey".into()]); - let ghost_id = uuid::Uuid::new_v4().to_string(); - data.key_positions.insert( - "ghost".to_string(), - vec![KeyPosition { - id: ghost_id.clone(), - ..KeyPosition::default() - }], - ); - let store = AppStore::new(dir.join("store.json"), data, false).unwrap(); - - store - .commit_editor_document(editor_request( - 0, - uuid::Uuid::new_v4().to_string(), - position_patch(&store, 222.0), - )) - .unwrap(); - - let snapshot = store.snapshot(); - assert_eq!(snapshot.keys["ghost"], vec![KeySlot::from("GhostKey")]); - assert_eq!(snapshot.key_positions["ghost"][0].id, ghost_id); - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn editor_get_exposes_recovered_safe_revision_after_loading_unsafe_wire_value() { - let dir = test_directory("editor-revision-load-recovery-test"); - std::fs::create_dir_all(&dir).unwrap(); - let mut data = super::initialize_default_state(); - data.editor_revision = crate::state::editor::MAX_SAFE_WIRE_REVISION + 1; - data.key_positions.get_mut("4key").unwrap()[0].dx = 12_345.0; - std::fs::write( - dir.join("store.json"), - serde_json::to_vec_pretty(&data).unwrap(), - ) - .unwrap(); - - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let editor = store.editor_get(); - - assert_eq!(editor.revision, 0); - assert_eq!(editor.document.key_positions["4key"][0].dx, 12_345.0); - let on_disk: AppStoreData = - serde_json::from_slice(&std::fs::read(dir.join("store.json")).unwrap()).unwrap(); - assert_eq!(on_disk.editor_revision, 0); - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn generic_update_cannot_mutate_editor_fields_or_revision() { - let dir = test_directory("editor-generic-escape-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let before = store.snapshot(); - - assert!(store - .update(|data| data.key_positions.get_mut("4key").unwrap()[0].dx = 999.0) - .is_err()); - assert!(store.update(|data| data.editor_revision += 1).is_err()); - assert_eq!(store.snapshot(), before); - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn paired_keys_update_returns_the_normalized_mode_for_runtime_sync() { - let dir = test_directory("keys-update-mode-sync-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let custom_mode = "custom-mode".to_string(); - store - .commit_legacy_editor_transaction( - EditorCommitOrigin::LegacyAdapter("test_setup".to_string()), - &[EditorField::Keys, EditorField::KeyPositions], - |data| { - data.custom_tabs.push(CustomTab { - id: custom_mode.clone(), - name: "Custom".to_string(), - }); - data.keys.insert(custom_mode.clone(), vec!["KeyA".into()]); - data.key_positions.insert( - custom_mode.clone(), - vec![KeyPosition { - id: uuid::Uuid::new_v4().to_string(), - ..KeyPosition::default() - }], - ); - data.selected_key_type = custom_mode.clone(); - Ok(()) - }, - ) - .unwrap(); - let initialized = store.snapshot(); - let keyboard = KeyboardManager::new( - initialized.keys.clone(), - initialized.selected_key_type.clone(), - ); - - let mut mappings = initialized.keys; - mappings.remove(&custom_mode); - let mut positions = initialized.key_positions; - positions.remove(&custom_mode); - let change = legacy_editor_commit( - &store, - &[EditorField::Keys, EditorField::KeyPositions], - move |data| { - data.keys = mappings; - data.key_positions = positions; - }, - ) - .unwrap(); - let updated = change.document.keys; - let selected_key_type = change.selected_key_type; - keyboard.update_mappings_and_set_mode(updated, selected_key_type.clone()); - - assert_eq!(selected_key_type, "4key"); - assert_eq!(store.snapshot().selected_key_type, "4key"); - assert_eq!(keyboard.current_mode(), "4key"); - - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn keys_with_positions_commit_is_atomic_without_implicit_padding() { - let dir = test_directory("keys-positions-atomic-commit-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let before = store.snapshot(); - let disk_before = std::fs::read(dir.join("store.json")).unwrap(); - let mut mappings = before.keys.clone(); - mappings.get_mut("4key").unwrap().push("F5".into()); - mappings.get_mut("5key").unwrap().push(KeySlot::default()); - let mut positions = before.key_positions.clone(); - positions.get_mut("4key").unwrap().push(KeyPosition { - id: uuid::Uuid::new_v4().to_string(), - ..KeyPosition::default() - }); - positions.get_mut("5key").unwrap().push(KeyPosition { - id: uuid::Uuid::new_v4().to_string(), - ..KeyPosition::default() - }); - - store.writer.fail_next_persist(); - let failed_mappings = mappings.clone(); - let failed_positions = positions.clone(); - assert!(legacy_editor_commit( - &store, - &[EditorField::Keys, EditorField::KeyPositions], - move |data| { - data.keys = failed_mappings; - data.key_positions = failed_positions; - }, - ) - .is_err()); - assert_eq!(store.snapshot(), before); - assert_eq!(std::fs::read(dir.join("store.json")).unwrap(), disk_before); - - let persist_count = store.writer.persist_count(); - let change = legacy_editor_commit( - &store, - &[EditorField::Keys, EditorField::KeyPositions], - move |data| { - data.keys = mappings; - data.key_positions = positions; - }, - ) - .unwrap(); - let keys = change.document.keys; - let positions = change.document.key_positions; - assert_eq!(store.writer.persist_count(), persist_count + 1); - assert_eq!(keys["4key"].last().unwrap(), &KeySlot::from("F5")); - assert!(crate::state::native_element_id::is_valid_element_id( - &positions["4key"].last().unwrap().id - )); - assert!(keys["5key"].last().unwrap().is_unassigned()); - assert_eq!(keys["4key"].len(), positions["4key"].len()); - assert_eq!(keys["5key"].len(), positions["5key"].len()); - - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn legacy_structural_single_collection_commits_are_rejected() { - let dir = test_directory("legacy-key-position-commit-length-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - - let before = store.snapshot(); - let mut mappings = before.keys.clone(); - mappings.get_mut("4key").unwrap().push("F5".into()); - assert!( - legacy_editor_commit(&store, &[EditorField::Keys], move |data| { - data.keys = mappings; - }) - .is_err() - ); - assert_eq!(store.snapshot(), before); - - let mut positions = before.key_positions.clone(); - positions - .get_mut("5key") - .unwrap() - .push(KeyPosition::default()); - assert!( - legacy_editor_commit(&store, &[EditorField::KeyPositions], move |data| { - data.key_positions = positions; - },) - .is_err() - ); - assert_eq!(store.snapshot(), before); - - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn asset_cleanup_waits_for_an_active_processed_wav_transaction() { - let dir = test_directory("processed-wav-cleanup-lock-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = Arc::new(AppStore::initialize_in_dir(&dir).unwrap()); - let transaction_guard = PROCESSED_WAV_TRANSACTION_LOCK.lock(); - let (started_tx, started_rx) = mpsc::channel(); - let (done_tx, done_rx) = mpsc::channel(); - let cleanup_store = store.clone(); - let cleanup_thread = std::thread::spawn(move || { - started_tx.send(()).unwrap(); - let result = cleanup_store.cleanup_orphan_assets_now(); - done_tx.send(result).unwrap(); - }); - - started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - assert!(done_rx.recv_timeout(Duration::from_millis(50)).is_err()); - drop(transaction_guard); - done_rx - .recv_timeout(Duration::from_secs(2)) - .unwrap() - .unwrap(); - cleanup_thread.join().unwrap(); - - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn shutdown_clears_a_pending_wav_journal_before_stopping_the_writer() { - let dir = test_directory("processed-wav-shutdown-order-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let target_path = dir.join("sounds").join("sound.wav"); - std::fs::write(&target_path, b"old-wav").unwrap(); - store - .update(|data| { - data.sound_library.insert( - target_path.to_string_lossy().to_string(), - Default::default(), - ); - data.pending_processed_wav_replacement = Some(PendingProcessedWavReplacement { - sound_path: target_path.to_string_lossy().to_string(), - had_original: true, - }); - }) - .unwrap(); - - store.flush_cleanup_and_shutdown().unwrap(); - - assert_eq!(store.snapshot().pending_processed_wav_replacement, None); - assert_eq!(std::fs::read(&target_path).unwrap(), b"old-wav"); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn preset_style_layout_and_settings_commit_in_one_writer_revision() { - let dir = test_directory("preset-single-transaction-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - store - .set_plugin_data("plugin_data_fixture", json!({ "kept": true })) - .unwrap(); - let initial_persist_count = store.writer.persist_count(); - - store - .commit_legacy_editor_transaction( - EditorCommitOrigin::LegacyAdapter("preset_test".to_string()), - &[EditorField::KeyPositions], - |data| { - data.key_positions.get_mut("4key").unwrap()[0].dx = 321.0; - apply_patch_to_store( - data, - &SettingsPatchInput { - background_color: Some("#123456".to_string()), - ..SettingsPatchInput::default() - }, - ); - Ok(()) - }, - ) - .unwrap(); - let updated = store.snapshot(); - - assert_eq!(store.writer.persist_count(), initial_persist_count + 1); - assert_eq!(updated.key_positions["4key"][0].dx, 321.0); - assert_eq!(updated.background_color, "#123456"); - assert_eq!(updated.plugin_data["plugin_data_fixture"]["kept"], true); - - let before_failure = store.snapshot(); - let disk_before_failure = std::fs::read(dir.join("store.json")).unwrap(); - store.writer.fail_next_persist(); - let result = store.commit_legacy_editor_transaction( - EditorCommitOrigin::LegacyAdapter("preset_test".to_string()), - &[EditorField::KeyPositions], - |data| { - data.key_positions.get_mut("4key").unwrap()[0].dx = 999.0; - apply_patch_to_store( - data, - &SettingsPatchInput { - background_color: Some("#FFFFFF".to_string()), - ..SettingsPatchInput::default() - }, - ); - Ok(()) - }, - ); - - assert!(result.is_err()); - assert_eq!(store.snapshot(), before_failure); - assert_eq!( - store.snapshot().plugin_data["plugin_data_fixture"]["kept"], - true - ); - assert_eq!( - std::fs::read(dir.join("store.json")).unwrap(), - disk_before_failure - ); - - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn failed_preset_style_commit_keeps_revision_and_key_runtime_unchanged() { - let dir = test_directory("preset-runtime-failure-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let state = AppState::initialize(store).unwrap(); - let before_store = state.store.snapshot(); - let before_disk = std::fs::read(dir.join("store.json")).unwrap(); - let before_mode = state.keyboard.current_mode(); - let before_counters = state.snapshot_key_counters(); - let mut keys = before_store.keys.clone(); - keys.get_mut("4key").unwrap()[0] = "PresetKey".into(); - - state.store.writer.fail_next_persist(); - let result = state.store.commit_legacy_editor_transaction( - EditorCommitOrigin::LegacyAdapter("preset_failure_test".to_string()), - &[EditorField::Keys], - move |data| { - data.keys = keys; - data.selected_key_type = "5key".to_string(); - data.background_color = "#123456".to_string(); - Ok(()) - }, - ); - - assert!(result.is_err()); - assert_eq!(state.store.snapshot(), before_store); - assert_eq!(std::fs::read(dir.join("store.json")).unwrap(), before_disk); - assert_eq!(state.keyboard.current_mode(), before_mode); - assert_eq!(state.snapshot_key_counters(), before_counters); - - state.shutdown(); - drop(state); - let _ = std::fs::remove_dir_all(dir); - } - - // 그룹 정의와 참조는 같은 editor 트랜잭션에서 함께 저장 - #[test] - fn group_ids_and_definitions_commit_atomically() { - let dir = test_directory("group-order-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - - let mut positions = store.snapshot().key_positions; - if let Some(list) = positions.get_mut("4key") { - list[0].group_id = Some("new-group".to_string()); - list[1].group_id = Some("new-group".to_string()); - } - let mut groups = crate::models::LayerGroups::new(); - groups.insert( - "4key".to_string(), - vec![crate::models::LayerGroupDef { - id: "new-group".to_string(), - name: "New Group".to_string(), - }], - ); - let rejected_positions = positions.clone(); - assert!( - legacy_editor_commit(&store, &[EditorField::KeyPositions], move |data| { - data.key_positions = rejected_positions; - },) - .is_err() - ); - store - .commit_legacy_editor_transaction( - EditorCommitOrigin::LegacyAdapter("group_test".to_string()), - &[EditorField::KeyPositions, EditorField::LayerGroups], - move |data| { - data.key_positions = positions; - data.layer_groups = groups; - Ok(()) - }, - ) - .unwrap(); - - let data = store.snapshot(); - assert_eq!( - data.key_positions["4key"][0].group_id.as_deref(), - Some("new-group") - ); - assert_eq!( - data.key_positions["4key"][1].group_id.as_deref(), - Some("new-group") - ); - - // 재기동(로드 경계)에서도 정의가 존재하므로 참조가 유지되어야 함 - store.flush_and_shutdown().unwrap(); - drop(store); - let reopened = AppStore::initialize_in_dir(&dir).unwrap(); - assert_eq!( - reopened.snapshot().key_positions["4key"][0] - .group_id - .as_deref(), - Some("new-group") - ); - reopened.flush_and_shutdown().unwrap(); - drop(reopened); - let _ = std::fs::remove_dir_all(dir); - } - - // 로드 시 dangling groupId 정리는 디스크에도 영속되고, 두 번째 기동은 재저장이 필요 없어야 함 - #[test] - fn dangling_group_ids_are_cleaned_and_persisted_on_boot() { - let dir = test_directory("dangling-persist-test"); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("store.json"); - - let mut data = AppStoreData::default(); - let mut positions = default_positions().clone(); - if let Some(list) = positions.get_mut("4key") { - list[0].group_id = Some("ghost-group".to_string()); - } - data.key_positions = positions; - std::fs::write(&path, serde_json::to_vec_pretty(&data).unwrap()).unwrap(); - - let store = AppStore::initialize_in_dir(&dir).unwrap(); - store.flush_and_shutdown().unwrap(); - drop(store); - - let on_disk: AppStoreData = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); - assert_eq!(on_disk.key_positions["4key"][0].group_id, None); - - let reloaded = super::load_store_from_path(&path).unwrap(); - assert!(!reloaded.needs_persist); - - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn repaired_store_is_backed_up_before_persist() { - let dir = test_directory("store-backup-test"); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("store.json"); - let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); - fixture.as_object_mut().unwrap().insert( - "alwaysOnTop".to_string(), - Value::String("invalid".to_string()), - ); - let original = serde_json::to_vec_pretty(&fixture).unwrap(); - std::fs::write(&path, &original).unwrap(); - - let store = AppStore::initialize_in_dir(&dir).unwrap(); - store.flush_and_shutdown().unwrap(); - - assert_eq!(std::fs::read(dir.join("store.json.bak")).unwrap(), original); - assert!(!dir.join("store.json.pre-migration.bak").exists()); - assert!(serde_json::from_slice::(&std::fs::read(path).unwrap()).is_ok()); - - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn invalid_note_gradient_stop_repair_backs_up_persists_and_is_idempotent() { - let dir = test_directory("note-border-stop-repair-test"); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("store.json"); - let mut data = super::initialize_default_state(); - let position = &mut data.key_positions.get_mut("4key").unwrap()[0]; - position.note_color = NoteColor::Gradient { - top: "body-top".to_string(), - bottom: "body-bottom".to_string(), - }; - position.note_opacity_top = Some(21); - position.note_opacity_bottom = Some(79); - position.note_gradient = serde_json::from_value(serde_json::json!({ - "angle": 45, - "stops": [ - { "color": "#112233", "pos": 0 }, - { "color": "transparent", "pos": 1 } - ] - })) - .unwrap(); - position.note_glow_color = Some(NoteColor::Gradient { - top: "glow-top".to_string(), - bottom: "glow-bottom".to_string(), - }); - position.note_glow_opacity_top = Some(31); - position.note_glow_opacity_bottom = Some(69); - position.note_glow_gradient = serde_json::from_value(serde_json::json!({ - "angle": 135, - "stops": [ - { "color": "invalid", "pos": 0 }, - { "color": "#445566", "pos": 1 } - ] - })) - .unwrap(); - position.note_border_color = Some("#445566".to_string()); - position.note_border_gradient = serde_json::from_value(serde_json::json!({ - "angle": 90, - "stops": [ - { "color": "#112233", "pos": 0 }, - { "color": "transparent", "pos": 1 } - ] - })) - .unwrap(); - position.display_text = Some("preserved sibling".to_string()); - position.counter.font_size = 37; - let original = serde_json::to_vec_pretty(&data).unwrap(); - std::fs::write(&path, &original).unwrap(); - - let store = AppStore::initialize_in_dir(&dir).unwrap(); - assert!(store.skip_asset_sweep); - let repaired = store.snapshot(); - let position = &repaired.key_positions["4key"][0]; - assert!(position.note_gradient.is_none()); - assert_eq!( - position.note_color, - NoteColor::Gradient { - top: "body-top".to_string(), - bottom: "body-bottom".to_string(), - } - ); - assert_eq!(position.note_opacity_top, Some(21)); - assert_eq!(position.note_opacity_bottom, Some(79)); - assert!(position.note_glow_gradient.is_none()); - assert_eq!( - position.note_glow_color, - Some(NoteColor::Gradient { - top: "glow-top".to_string(), - bottom: "glow-bottom".to_string(), - }) - ); - assert_eq!(position.note_glow_opacity_top, Some(31)); - assert_eq!(position.note_glow_opacity_bottom, Some(69)); - assert_eq!(position.note_border_color.as_deref(), Some("#445566")); - assert!(position.note_border_gradient.is_none()); - assert_eq!(position.display_text.as_deref(), Some("preserved sibling")); - assert_eq!(position.counter.font_size, 37); - assert_eq!(std::fs::read(dir.join("store.json.bak")).unwrap(), original); - - let on_disk: serde_json::Value = - serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); - assert!(on_disk["keyPositions"]["4key"][0] - .get("noteGradient") - .is_none()); - assert!(on_disk["keyPositions"]["4key"][0] - .get("noteGlowGradient") - .is_none()); - assert!(on_disk["keyPositions"]["4key"][0] - .get("noteBorderGradient") - .is_none()); - store.flush_and_shutdown().unwrap(); - drop(store); - - let reloaded = super::load_store_from_path(&path).unwrap(); - assert!(!reloaded.needs_persist); - assert!(!reloaded.repaired); - assert_eq!( - reloaded.data.key_positions["4key"][0], - repaired.key_positions["4key"][0] - ); - - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn note_settings_1_2_four_field_store_preserves_speed_on_boot() { - let dir = test_directory("note-settings-1-2-test"); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("store.json"); - let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); - // 1.2.0 시절 noteSettings 실형식 — 4필드뿐 - let legacy_note_settings = r#"{ - "borderRadius": 2, - "speed": 321, - "trackHeight": 150, - "reverse": false - }"#; - fixture.as_object_mut().unwrap().insert( - "noteSettings".to_string(), - serde_json::from_str(legacy_note_settings).unwrap(), - ); - std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); - - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let settings = store.snapshot().note_settings; - assert_eq!(settings.speed, 321); - assert_eq!(settings.track_height, 150); - assert_eq!(settings.fade_top_px, 50); - assert_eq!(settings.short_note_threshold_ms, 50); - - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn backup_failure_keeps_damaged_store_untouched() { - let dir = test_directory("store-backup-failure-test"); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("store.json"); - let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); - fixture.as_object_mut().unwrap().insert( - "alwaysOnTop".to_string(), - Value::String("invalid".to_string()), - ); - let original = serde_json::to_vec_pretty(&fixture).unwrap(); - std::fs::write(&path, &original).unwrap(); - std::fs::create_dir(dir.join("store.json.bak")).unwrap(); - - let result = AppStore::initialize_in_dir(&dir); - if let Ok(store) = result { - let _ = store.flush_and_shutdown(); - panic!("store initialization unexpectedly succeeded without a backup"); - } - assert_eq!(std::fs::read(path).unwrap(), original); - - let _ = std::fs::remove_dir_all(dir); - } - - fn defer_test_bounds(store: &AppStore, x: f64) { - store - .update_deferred(|state| { - state.overlay_bounds = Some(OverlayBounds { - x, - y: 20.0, - width: 800.0, - height: 300.0, - }); - state.overlay_bounds_are_logical = true; - }) - .unwrap(); - } - - #[test] - fn deferred_updates_coalesce_into_one_writer_persist() { - const UPDATE_COUNT: usize = 20; - - let dir = test_directory("store-deferred-coalesce-test"); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial_persist_count = store.writer.persist_count(); - let initial_revision = store.state.read().revision; - let initial_disk = std::fs::read(dir.join("store.json")).unwrap(); - - for index in 0..UPDATE_COUNT { - defer_test_bounds(&store, index as f64); - } - - assert_eq!(store.writer.persist_count(), initial_persist_count); - assert_eq!(store.state.read().revision, initial_revision); - assert!(store.state.read().dirty); - assert_eq!(std::fs::read(dir.join("store.json")).unwrap(), initial_disk); - assert_eq!( - store.snapshot().overlay_bounds.unwrap().x, - (UPDATE_COUNT - 1) as f64 - ); - - store.flush().unwrap(); - assert_eq!(store.writer.persist_count(), initial_persist_count + 1); - assert_eq!(store.state.read().revision, initial_revision + 1); - assert!(!store.state.read().dirty); - let persisted: AppStoreData = - serde_json::from_slice(&std::fs::read(dir.join("store.json")).unwrap()).unwrap(); - assert_eq!( - persisted.overlay_bounds.unwrap().x, - (UPDATE_COUNT - 1) as f64 - ); - - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn panel_bounds_persist_and_restore_across_store_reopen() { - let dir = test_directory("panel-bounds-persist-test"); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let bounds = PanelBounds { - x: 31.0, - y: 47.0, - height: 512.0, - }; - - store - .update_deferred(|state| state.panel_bounds = Some(bounds)) - .unwrap(); - store.flush_and_shutdown().unwrap(); - drop(store); - - let reopened = AppStore::initialize_in_dir(&dir).unwrap(); - assert_eq!(reopened.snapshot().panel_bounds, Some(bounds)); - reopened.flush_and_shutdown().unwrap(); - drop(reopened); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn panel_detached_persists_immediately_and_restores_across_store_reopen() { - let dir = test_directory("panel-detached-persist-test"); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - - // 프로덕션 경로와 같은 조합 - 락 안 deferred 기록 뒤 락 밖 flush. - // flush 직후 디스크에 있어야 강제 종료로 배치 선택이 날아가지 않는다 - store - .update_deferred(|state| state.panel_detached = true) - .unwrap(); - store.flush().unwrap(); - let persisted: AppStoreData = - serde_json::from_slice(&std::fs::read(dir.join("store.json")).unwrap()).unwrap(); - assert!(persisted.panel_detached); - - store.flush_and_shutdown().unwrap(); - drop(store); - - let reopened = AppStore::initialize_in_dir(&dir).unwrap(); - assert!(reopened.snapshot().panel_detached); - reopened.flush_and_shutdown().unwrap(); - drop(reopened); - let _ = std::fs::remove_dir_all(dir); - } - - // 필드가 없는 구버전 store는 도킹 상태로 폴백 - #[test] - fn panel_detached_missing_field_defaults_to_docked() { - let dir = test_directory("panel-detached-default-test"); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("store.json"); - let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); - assert!(fixture - .as_object_mut() - .unwrap() - .remove("panelDetached") - .is_some()); - std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); - - let store = AppStore::initialize_in_dir(&dir).unwrap(); - assert!(!store.snapshot().panel_detached); - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn regular_update_commits_pending_deferred_state() { - let dir = test_directory("store-deferred-regular-update-test"); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial_persist_count = store.writer.persist_count(); - defer_test_bounds(&store, 321.0); - - store - .update(|state| { - state - .plugin_data - .insert("unrelated".to_string(), json!(true)); - }) - .unwrap(); - - assert!(!store.state.read().dirty); - assert_eq!(store.writer.persist_count(), initial_persist_count + 1); - let persisted: AppStoreData = - serde_json::from_slice(&std::fs::read(dir.join("store.json")).unwrap()).unwrap(); - assert_eq!(persisted.overlay_bounds.unwrap().x, 321.0); - assert_eq!(persisted.plugin_data["unrelated"], json!(true)); - - store.flush().unwrap(); - assert_eq!(store.writer.persist_count(), initial_persist_count + 1); - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn failed_flush_keeps_deferred_state_dirty_and_committed_in_memory() { - let dir = test_directory("store-deferred-failure-test"); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial_persist_count = store.writer.persist_count(); - let initial_revision = store.state.read().revision; - let initial_disk = std::fs::read(dir.join("store.json")).unwrap(); - defer_test_bounds(&store, 654.0); - store.writer.fail_next_persist(); - - assert!(store.flush().is_err()); - assert!(store.state.read().dirty); - assert_eq!(store.state.read().revision, initial_revision); - assert_eq!(store.snapshot().overlay_bounds.unwrap().x, 654.0); - assert_eq!(std::fs::read(dir.join("store.json")).unwrap(), initial_disk); - assert_eq!(store.writer.persist_count(), initial_persist_count + 1); - - store.flush().unwrap(); - assert!(!store.state.read().dirty); - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn flush_without_dirty_state_is_idempotent() { - let dir = test_directory("store-deferred-idempotent-test"); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial_persist_count = store.writer.persist_count(); - let initial_revision = store.state.read().revision; - let initial_disk = std::fs::read(dir.join("store.json")).unwrap(); - - store.flush().unwrap(); - store.flush().unwrap(); - - assert_eq!(store.writer.persist_count(), initial_persist_count); - assert_eq!(store.state.read().revision, initial_revision); - assert_eq!(std::fs::read(dir.join("store.json")).unwrap(), initial_disk); - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn concurrent_updates_persist_all_revisions_without_replace_races() { - const UPDATE_COUNT: usize = 60; - - let dir = test_directory("store-concurrency-test"); - let store = Arc::new(AppStore::initialize_in_dir(&dir).unwrap()); - let initial_revision = store.state.read().revision; - let barrier = Arc::new(Barrier::new(UPDATE_COUNT + 1)); - let mut handles = Vec::with_capacity(UPDATE_COUNT); - - for _ in 0..UPDATE_COUNT { - let store = Arc::clone(&store); - let barrier = Arc::clone(&barrier); - handles.push(std::thread::spawn(move || { - barrier.wait(); - store.update(|state| { - let count = state - .plugin_data - .get("concurrent_update_count") - .and_then(Value::as_u64) - .unwrap_or(0); - state - .plugin_data - .insert("concurrent_update_count".to_string(), json!(count + 1)); - }) - })); - } - - barrier.wait(); - let mut failures = Vec::new(); - for handle in handles { - match handle.join() { - Ok(Ok(_)) => {} - Ok(Err(err)) => failures.push(format!("{err:#}")), - Err(_) => failures.push("update thread panicked".to_string()), - } - } - assert!(failures.is_empty(), "persist failures: {failures:?}"); - assert_eq!( - store.state.read().revision, - initial_revision + UPDATE_COUNT as u64 - ); - - let final_snapshot = store.snapshot(); - store.flush_and_shutdown().unwrap(); - assert!(store - .update(|state| state.always_on_top = !state.always_on_top) - .is_err()); - assert_eq!(store.snapshot(), final_snapshot); - let persisted: AppStoreData = - serde_json::from_slice(&std::fs::read(dir.join("store.json")).unwrap()).unwrap(); - assert_eq!( - persisted.plugin_data["concurrent_update_count"].as_u64(), - Some(UPDATE_COUNT as u64) - ); - assert_eq!(persisted, final_snapshot); - assert!(!std::fs::read_dir(&dir).unwrap().any(|entry| { - entry - .ok() - .and_then(|entry| entry.file_name().into_string().ok()) - .is_some_and(|name| name.ends_with(".tmp")) - })); - - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn writer_failure_keeps_snapshot_and_disk_unchanged_and_does_not_leak() { - let dir = test_directory("store-transaction-failure-test"); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let path = dir.join("store.json"); - let before_snapshot = store.snapshot(); - let before_disk = std::fs::read(&path).unwrap(); - let before_revision = store.state.read().revision; - - store.writer.fail_next_persist(); - let result = store.update(|state| { - state.always_on_top = !before_snapshot.always_on_top; - }); - - assert!(result.is_err()); - assert_eq!(store.snapshot(), before_snapshot); - assert_eq!( - store.with_state(|state| state.always_on_top), - before_snapshot.always_on_top - ); - assert_eq!(std::fs::read(&path).unwrap(), before_disk); - assert_eq!(store.state.read().revision, before_revision); - - store - .update(|state| { - state - .plugin_data - .insert("unrelated".to_string(), json!(true)); - }) - .unwrap(); - let after_snapshot = store.snapshot(); - let after_disk: AppStoreData = - serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); - assert_eq!(after_snapshot.always_on_top, before_snapshot.always_on_top); - assert_eq!(after_disk.always_on_top, before_snapshot.always_on_top); - assert_eq!(after_snapshot.plugin_data["unrelated"], json!(true)); - assert_eq!(after_disk, after_snapshot); - assert_eq!(store.state.read().revision, before_revision + 1); - - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn counter_persist_failure_keeps_runtime_mirror_unchanged() { - let dir = test_directory("counter-transaction-failure-test"); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let snapshot = store.snapshot(); - let mode = snapshot.selected_key_type.clone(); - let key = snapshot.keys[&mode][0].canonical(); - let mut counters = snapshot.key_counters; - counters - .entry(mode.clone()) - .or_default() - .insert(key.clone(), 42); - store.set_key_counters(counters).unwrap(); - - let state = AppState::initialize(store).unwrap(); - let before_runtime = state.snapshot_key_counters(); - let before_store = state.store.snapshot(); - let before_disk = std::fs::read(dir.join("store.json")).unwrap(); - state.store.writer.fail_next_persist(); - let emitter = TestCounterEmitter::new(Arc::new(Mutex::new(Vec::new())), mode, key); - - assert!(state.reset_key_counters(&emitter, None).is_err()); - assert_eq!(state.snapshot_key_counters(), before_runtime); - assert_eq!(state.store.snapshot(), before_store); - assert_eq!(std::fs::read(dir.join("store.json")).unwrap(), before_disk); - - state.shutdown(); - drop(state); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn reset_snapshot_emit_precedes_waiting_increment_emit() { - let dir = test_directory("counter-mirror-race-test"); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let snapshot = store.snapshot(); - let mode = snapshot.selected_key_type.clone(); - let key = snapshot.keys[&mode][0].canonical(); - store - .update(|data| { - data.key_counter_enabled = true; - data.key_counters - .entry(mode.clone()) - .or_default() - .insert(key.clone(), 7); - }) - .unwrap(); - let state = Arc::new(AppState::initialize(store).unwrap()); - let events = Arc::new(Mutex::new(Vec::new())); - let (snapshot_emitted_tx, snapshot_emitted_rx) = mpsc::channel(); - let (release_snapshot_tx, release_snapshot_rx) = mpsc::channel(); - let reset_emitter = Arc::new(TestCounterEmitter::blocking_snapshot( - Arc::clone(&events), - mode.clone(), - key.clone(), - snapshot_emitted_tx, - release_snapshot_rx, - )); - let reset_state = Arc::clone(&state); - let reset_handle = std::thread::spawn(move || { - reset_state.reset_key_counters(reset_emitter.as_ref(), None) - }); - snapshot_emitted_rx - .recv_timeout(Duration::from_secs(3)) - .unwrap(); - - let (increment_started_tx, increment_started_rx) = mpsc::channel(); - let (increment_done_tx, increment_done_rx) = mpsc::channel(); - let increment_state = Arc::clone(&state); - let increment_mode = mode.clone(); - let increment_key = key.clone(); - let increment_emitter = TestCounterEmitter::new( - Arc::clone(&events), - increment_mode.clone(), - increment_key.clone(), - ); - let increment_handle = std::thread::spawn(move || { - increment_started_tx.send(()).unwrap(); - let count = increment_state.increment_key_counter_and_emit( - &increment_emitter, - &increment_mode, - &increment_key, - ); - increment_done_tx.send(count).unwrap(); - }); - increment_started_rx.recv().unwrap(); - assert!(increment_done_rx - .recv_timeout(Duration::from_millis(50)) - .is_err()); - assert_eq!(*events.lock().unwrap(), vec!["snapshot:0"]); - - release_snapshot_tx.send(()).unwrap(); - let reset_snapshot = reset_handle.join().unwrap().unwrap(); - assert_eq!(reset_snapshot.counters[&mode][&key], 0); - assert_eq!( - increment_done_rx - .recv_timeout(Duration::from_secs(3)) - .unwrap(), - Some(1) - ); - increment_handle.join().unwrap(); - assert_eq!(*events.lock().unwrap(), vec!["snapshot:0", "counter:1"]); - assert_eq!(state.snapshot_key_counters()[&mode][&key], 1); - let bootstrap = state.bootstrap_payload(); - assert_eq!(bootstrap.key_counters[&mode][&key], 1); - assert!(!bootstrap.key_counters_session_id.is_empty()); - assert_eq!(bootstrap.key_counters_revision, 2); - - state.shutdown(); - drop(state); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn queued_increment_survives_same_generation_noop_key_publication() { - let dir = test_directory("counter-history-queue-test"); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let snapshot = store.snapshot(); - let mode = snapshot.selected_key_type.clone(); - let key = snapshot.keys[&mode][0].canonical(); - store - .update(|data| { - data.key_counter_enabled = true; - }) - .unwrap(); - let state = Arc::new(AppState::initialize(store).unwrap()); - let events = Arc::new(Mutex::new(Vec::new())); - let mutation_emitter = - TestCounterEmitter::new(Arc::clone(&events), mode.clone(), key.clone()); - let mut changed_counters = state.snapshot_key_counters(); - changed_counters - .entry(mode.clone()) - .or_default() - .insert(key.clone(), 7); - let mutation = state - .replace_key_counters(&mutation_emitter, changed_counters, Some(0)) - .unwrap(); - assert_eq!(mutation.counters[&mode][&key], 7); - assert_eq!( - mutation.history_status.as_ref().unwrap().history_revision, - 1 - ); - drop(mutation); - events.lock().unwrap().clear(); - - let operation_id = uuid::Uuid::new_v4().to_string(); - let gate = state.store.history_gate(); - let barrier = gate.close(&operation_id).unwrap(); - state.begin_counter_history_barrier(); - let mut counter_guard = state.lock_key_counters_for_history(); - let current_counters = counter_guard.clone(); - let undo = state - .store - .apply_history_operation( - HistoryDirection::Undo, - &operation_id, - ¤t_counters, - || {}, - ) - .unwrap(); - let Some(HistoryAuxChange::Counters(restored)) = undo.aux_change.as_ref() else { - panic!("counter history change expected"); - }; - state.replace_history_counters_locked( - &mut counter_guard, - undo.runtime_publication_generation, - restored, - ); - assert_eq!(counter_guard[&mode][&key], 0); - - let increment_state = Arc::clone(&state); - let increment_mode = mode.clone(); - let increment_key = key.clone(); - let increment_events = Arc::clone(&events); - let (increment_done_tx, increment_done_rx) = mpsc::channel(); - let increment = thread::spawn(move || { - let emitter = TestCounterEmitter::new( - increment_events, - increment_mode.clone(), - increment_key.clone(), - ); - let count = increment_state.increment_key_counter_and_emit( - &emitter, - &increment_mode, - &increment_key, - ); - increment_done_tx.send(count).unwrap(); - }); - assert_eq!( - increment_done_rx - .recv_timeout(Duration::from_secs(2)) - .unwrap(), - None - ); - increment.join().unwrap(); - assert!(events.lock().unwrap().is_empty()); - - let history_emitter = - TestCounterEmitter::new(Arc::clone(&events), mode.clone(), key.clone()); - state.finish_counter_history_barrier( - &history_emitter, - counter_guard, - true, - undo.runtime_publication_generation, - ); - drop(barrier); - - assert_eq!(state.snapshot_key_counters()[&mode][&key], 1); - assert_eq!(state.store.snapshot().key_counters[&mode][&key], 0); - assert_eq!(*events.lock().unwrap(), vec!["snapshot:1"]); - assert_eq!(undo.status.history_epoch, 1); - assert!(undo.status.can_redo); - - let noop = state - .store - .commit_legacy_editor_transaction( - EditorCommitOrigin::LegacyAdapter( - "same_generation_noop_key_publication".to_string(), - ), - &[EditorField::Keys], - |_| Ok(()), - ) - .unwrap(); - assert!(noop.change.result.changed_fields.is_empty()); - assert_eq!( - noop.change.runtime_publication_generation, - undo.runtime_publication_generation - ); - assert_eq!(noop.change.key_counters[&mode][&key], 0); - state - .apply_committed_editor_key_runtime( - &history_emitter, - noop.change.runtime_publication_generation, - &noop.change.document.keys, - &noop.change.selected_key_type, - &noop.change.key_counters, - ) - .unwrap(); - state.apply_committed_editor_keys_without_counters( - noop.change.runtime_publication_generation, - &noop.change.document.keys, - &noop.change.selected_key_type, - ); - drop(noop); - - assert_eq!(state.snapshot_key_counters()[&mode][&key], 1); - assert_eq!(*events.lock().unwrap(), vec!["snapshot:1"]); - - state.shutdown(); - drop(state); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn existing_obs_token_is_reused_without_persist() { - let dir = test_directory("obs-token-reuse-test"); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let token = "persisted-obs-token".to_string(); - store - .update(|state| { - state.obs_token = Some(token.clone()); - }) - .unwrap(); - let state = AppState::initialize(store).unwrap(); - let persist_count_before = state.store.writer.persist_count(); - let disk_before = std::fs::read(dir.join("store.json")).unwrap(); - - assert_eq!(state.resolve_and_save_obs_token().unwrap(), token); - assert_eq!(state.store.writer.persist_count(), persist_count_before); - assert_eq!(std::fs::read(dir.join("store.json")).unwrap(), disk_before); - - state.shutdown(); - drop(state); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn asset_references_include_every_position_kind() { - let mut data = AppStoreData::default(); - let base_position = default_positions()["4key"][0].clone(); - let root = std::env::temp_dir().join("dmnote-position-assets"); - - let next_position = |kind: &str| { - let mut position = base_position.clone(); - position.active_image = Some( - root.join(format!("{kind}.png")) - .to_string_lossy() - .to_string(), - ); - position.sound_path = Some( - root.join(format!("{kind}.wav")) - .to_string_lossy() - .to_string(), - ); - position - }; - - data.key_positions - .insert("mode".to_string(), vec![next_position("key")]); - data.stat_positions.insert( - "mode".to_string(), - vec![StatPosition { - stat_type: StatType::Kps, - position: next_position("stat"), - }], - ); - data.graph_positions.insert( - "mode".to_string(), - vec![GraphPosition { - stat_type: GraphStatType::Kps, - graph_type: GraphType::Line, - graph_speed: 1, - graph_color: "#FFFFFF".to_string(), - show_avg_line: true, - position: next_position("graph"), - }], - ); - data.knob_positions.insert( - "mode".to_string(), - vec![KnobPosition { - axis_id: String::new(), - sensitivity: 1.0, - reverse: false, - position: next_position("knob"), - }], - ); - - let image_paths = collect_local_image_path_keys(&root, &data); - let sound_paths = collect_local_sound_path_keys(&root, &data); - for kind in ["key", "stat", "graph", "knob"] { - assert!(image_paths.contains(&path_identity_key(&root.join(format!("{kind}.png"))))); - assert!(sound_paths.contains(&path_identity_key(&root.join(format!("{kind}.wav"))))); - } - } - - #[test] - fn plugin_managed_assets_remain_until_plugin_data_releases_them() { - let dir = test_directory("plugin-managed-assets-sweep-test"); - let image_path = dir.join("images").join("plugin-owned.png"); - let font_path = dir.join("fonts").join("plugin-owned.ttf"); - for path in [&image_path, &font_path] { - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(path, b"plugin-asset").unwrap(); - } - - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let plugin_key = "plugin_data_demo-plugin/settings".to_string(); - store - .update(|data| { - data.plugin_data.insert( - plugin_key.clone(), - json!({ - "nested": { - "imagePath": image_path.to_string_lossy(), - "fontPaths": [font_path.to_string_lossy()] - } - }), - ); - }) - .unwrap(); - - store.flush_cleanup_and_shutdown().unwrap(); - drop(store); - - assert!(image_path.exists()); - assert!(font_path.exists()); - assert!(!dir.join("trash").exists()); - - let store = AppStore::initialize_in_dir(&dir).unwrap(); - store - .update(|data| { - data.plugin_data.remove(&plugin_key); - }) - .unwrap(); - store.flush_cleanup_and_shutdown().unwrap(); - drop(store); - - assert!(!image_path.exists()); - assert!(!font_path.exists()); - let trash_sessions = std::fs::read_dir(dir.join("trash")).unwrap(); - let trashed = trash_sessions - .map(|entry| entry.unwrap().path()) - .any(|session| { - session.join("images").join("plugin-owned.png").exists() - && session.join("fonts").join("plugin-owned.ttf").exists() - }); - assert!(trashed); - - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn referenced_v1_gif_cache_remains_a_direct_image_asset() { - let dir = test_directory("gif-v1-direct-reference-test"); - let images = dir.join("images"); - std::fs::create_dir_all(&images).unwrap(); - let hash = "a".repeat(64); - let cache = images.join(format!("gif-cache-{hash}.webp")); - std::fs::write(&cache, b"lossy-v1").unwrap(); - - let store = AppStore::initialize_in_dir(&dir).unwrap(); - store - .commit_legacy_editor_transaction( - EditorCommitOrigin::LegacyAdapter("gif_v1_direct_test".to_string()), - &[EditorField::KeyPositions], - |data| { - data.key_positions.get_mut("4key").unwrap()[0].active_image = - Some(cache.to_string_lossy().to_string()); - Ok(()) - }, - ) - .unwrap(); - store.cleanup_orphan_assets_now().unwrap(); - assert!(cache.exists()); - assert_eq!( - store.snapshot().key_positions["4key"][0] - .active_image - .as_deref(), - Some(cache.to_string_lossy().as_ref()) - ); - - store - .commit_legacy_editor_transaction( - EditorCommitOrigin::LegacyAdapter("gif_v1_direct_test".to_string()), - &[EditorField::KeyPositions], - |data| { - data.key_positions.get_mut("4key").unwrap()[0].active_image = None; - Ok(()) - }, - ) - .unwrap(); - store.cleanup_orphan_assets_now().unwrap(); - assert!(!cache.exists()); - - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[cfg(not(target_os = "windows"))] - #[test] - fn asset_references_preserve_the_leading_slash_in_file_urls() { - let mut data = AppStoreData::default(); - let mut position = default_positions()["4key"][0].clone(); - position.active_image = Some("file:///tmp/dmnote-file-url.png".to_string()); - position.sound_path = Some("file:///tmp/dmnote-file-url.wav".to_string()); - data.key_positions - .insert("4key".to_string(), vec![position]); - - let classify_root = Path::new("/nonexistent-app-data"); - assert!(collect_local_image_path_keys(classify_root, &data) - .contains(&path_identity_key(Path::new("/tmp/dmnote-file-url.png")))); - assert!(collect_local_sound_path_keys(classify_root, &data) - .contains(&path_identity_key(Path::new("/tmp/dmnote-file-url.wav")))); - } - - #[cfg(not(target_os = "windows"))] - #[test] - fn percent_encoded_file_url_keeps_referenced_image_out_of_trash() { - let dir = test_directory("percent-encoded-image-sweep-test"); - let image_path = dir.join("images").join("referenced image.png"); - std::fs::create_dir_all(image_path.parent().unwrap()).unwrap(); - std::fs::write(&image_path, b"image").unwrap(); - let image_url = url::Url::from_file_path(&image_path).unwrap().to_string(); - assert!(image_url.contains("%20")); - - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let mut positions = store.snapshot().key_positions; - positions.get_mut("4key").unwrap()[0].active_image = Some(image_url); - legacy_editor_commit(&store, &[EditorField::KeyPositions], move |data| { - data.key_positions = positions; - }) - .unwrap(); - - store.cleanup_orphan_assets_now().unwrap(); - - assert!(image_path.exists()); - assert!(!dir.join("trash").exists()); - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn invalid_file_url_skips_destructive_image_sweep() { - let dir = test_directory("invalid-file-url-sweep-test"); - let image_path = dir.join("images").join("unreferenced.png"); - std::fs::create_dir_all(image_path.parent().unwrap()).unwrap(); - std::fs::write(&image_path, b"image").unwrap(); - - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let mut positions = store.snapshot().key_positions; - positions.get_mut("4key").unwrap()[0].active_image = - Some("file:///tmp/broken%ZZ.png".to_string()); - legacy_editor_commit(&store, &[EditorField::KeyPositions], move |data| { - data.key_positions = positions; - }) - .unwrap(); - - store.cleanup_orphan_assets_now().unwrap(); - - assert!(image_path.exists()); - assert!(!dir.join("trash").exists()); - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn invalid_file_url_skips_destructive_font_sweep() { - let dir = test_directory("invalid-font-file-url-sweep-test"); - let font_path = dir.join("fonts").join("unreferenced.ttf"); - std::fs::create_dir_all(font_path.parent().unwrap()).unwrap(); - std::fs::write(&font_path, b"font").unwrap(); - - let store = AppStore::initialize_in_dir(&dir).unwrap(); - store - .update(|data| { - data.font_settings.custom_fonts.push(CustomFont { - id: "invalid-url-font".to_string(), - font_type: FontType::Local, - name: "Invalid URL Font".to_string(), - display_name: "Invalid URL Font".to_string(), - enabled: true, - local_path: Some("file:///tmp/broken%ZZ.ttf".to_string()), - css_content: None, - weight_ranges: Vec::new(), - }); - }) - .unwrap(); - - store.cleanup_orphan_assets_now().unwrap(); - - assert!(font_path.exists()); - assert!(!dir.join("trash").exists()); - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn unresolved_path_like_values_skip_destructive_asset_sweeps() { - let dir = test_directory("unresolved-local-path-sweep-test"); - let image_path = dir.join("images").join("unreferenced.png"); - let sound_path = dir.join("sounds").join("unreferenced.wav"); - for path in [&image_path, &sound_path] { - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(path, b"asset").unwrap(); - } - - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let mut positions = store.snapshot().key_positions; - let position = &mut positions.get_mut("4key").unwrap()[0]; - position.active_image = Some(r"\unresolved-image.png".to_string()); - position.sound_path = Some("C:unresolved-sound.wav".to_string()); - legacy_editor_commit(&store, &[EditorField::KeyPositions], move |data| { - data.key_positions = positions; - }) - .unwrap(); - - store.cleanup_orphan_assets_now().unwrap(); - - assert!(image_path.exists()); - assert!(sound_path.exists()); - assert!(!dir.join("trash").exists()); - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn unresolved_asset_reference_counts_are_grouped_by_category() { - let mut data = AppStoreData::default(); - data.font_settings.custom_fonts.push(CustomFont { - id: "unresolved-font".to_string(), - font_type: FontType::Local, - name: "Unresolved Font".to_string(), - display_name: "Unresolved Font".to_string(), - enabled: true, - local_path: Some("C:unresolved-font.ttf".to_string()), - css_content: None, - weight_ranges: Vec::new(), - }); - data.key_positions.insert( - "unresolved-mode".to_string(), - vec![KeyPosition { - active_image: Some(r"\unresolved-image.png".to_string()), - sound_path: Some("C:unresolved-sound.wav".to_string()), - ..KeyPosition::default() - }], - ); - data.sound_library.insert( - r"\unresolved-library-sound.wav".to_string(), - SoundLibraryEntry::default(), - ); - - let classify_root = Path::new("/nonexistent-app-data"); - let fonts = collect_local_font_paths(classify_root, &data); - let images = collect_local_image_paths(classify_root, &data); - let sounds = collect_local_sound_paths(classify_root, &data); - - assert!(!fonts.complete); - assert!(!images.complete); - assert!(!sounds.complete); - assert_eq!(fonts.unresolved_count, 1); - assert_eq!(images.unresolved_count, 1); - assert_eq!(sounds.unresolved_count, 2); - } - - #[test] - fn missing_foreign_file_url_reference_marks_sweep_unresolved() { - let dir = test_directory("foreign-file-url-unresolved-test"); - std::fs::create_dir_all(dir.join("images")).unwrap(); - let mut data = AppStoreData { - key_positions: default_positions().clone(), - ..AppStoreData::default() - }; - data.key_positions.get_mut("4key").unwrap()[0].active_image = Some( - "file:///C:/Users/me/AppData/Roaming/com.dmnote.desktop/images/missing.png".to_string(), - ); - - // 외래 file URL이 이 기기에서 실존하지 않으면 sweep을 보류시켜야 함 - assert_eq!(collect_local_image_paths(&dir, &data).unresolved_count, 1); - - // 현재 기기 appData의 단순 누락 참조는 기존대로 보류 대상이 아님 - data.key_positions.get_mut("4key").unwrap()[0].inactive_image = Some( - dir.join("images") - .join("gone.png") - .to_string_lossy() - .into_owned(), - ); - assert_eq!(collect_local_image_paths(&dir, &data).unresolved_count, 1); - - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn missing_foreign_file_url_holds_sweep_and_keeps_unreferenced_image() { - let dir = test_directory("foreign-file-url-sweep-hold-test"); - let kept_image = dir.join("images").join("kept.png"); - std::fs::create_dir_all(kept_image.parent().unwrap()).unwrap(); - std::fs::write(&kept_image, b"image").unwrap(); - - let mut data = AppStoreData { - keys: crate::defaults::default_keys().clone(), - key_positions: default_positions().clone(), - ..AppStoreData::default() - }; - data.key_positions.get_mut("4key").unwrap()[0].active_image = Some( - "file:///C:/Users/me/AppData/Roaming/com.dmnote.desktop/images/missing.png".to_string(), - ); - std::fs::write( - dir.join("store.json"), - serde_json::to_vec_pretty(&data).unwrap(), - ) - .unwrap(); - - let store = AppStore::initialize_in_dir(&dir).unwrap(); - store.cleanup_orphan_assets_now().unwrap(); - - // 미해석 외래 참조가 있으면 sweep 보류 — 미참조 로컬 이미지도 격리 금지 - assert!(kept_image.exists()); - assert!(!dir.join("trash").exists()); - - store.flush_cleanup_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn foreign_asset_references_rehome_before_persist_and_sweep() { - let dir = test_directory("foreign-asset-rehome-integration-test"); - let font_path = dir.join("fonts").join("portable.ttf"); - let image_path = dir.join("images").join("portable.png"); - let sound_path = dir.join("sounds").join("portable.wav"); - for path in [&font_path, &image_path, &sound_path] { - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(path, b"portable-asset").unwrap(); - } - - let foreign_root = r"C:\Users\Exporter\AppData\Roaming\com.dmnote.desktop"; - let foreign_font = format!(r"{foreign_root}\fonts\portable.ttf"); - let foreign_image = format!(r"{foreign_root}\images\portable.png"); - let foreign_sound = format!(r"{foreign_root}\sounds\portable.wav"); - let mut data = AppStoreData { - keys: crate::defaults::default_keys().clone(), - key_positions: default_positions().clone(), - ..AppStoreData::default() - }; - let position = &mut data.key_positions.get_mut("4key").unwrap()[0]; - position.active_image = Some(foreign_image.clone()); - position.sound_path = Some(foreign_sound.clone()); - data.font_settings.custom_fonts.push(CustomFont { - id: "portable-font".to_string(), - font_type: FontType::Local, - name: "Portable Font".to_string(), - display_name: "Portable Font".to_string(), - enabled: true, - local_path: Some(foreign_font.clone()), - css_content: None, - weight_ranges: Vec::new(), - }); - data.sound_library.insert( - foreign_sound.clone(), - SoundLibraryEntry { - display_name: Some("Portable sound".to_string()), - ..SoundLibraryEntry::default() - }, - ); - - let original = serde_json::to_vec_pretty(&data).unwrap(); - let store_path = dir.join("store.json"); - std::fs::write(&store_path, &original).unwrap(); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let snapshot = store.snapshot(); - let local_font = font_path.to_string_lossy().into_owned(); - let local_image = image_path.to_string_lossy().into_owned(); - let local_sound = sound_path.to_string_lossy().into_owned(); - - assert_eq!( - snapshot.font_settings.custom_fonts[0].local_path.as_deref(), - Some(local_font.as_str()) - ); - // 이전된 폰트는 경로 재귀화와 함께 활성 상태도 유지되어야 함 - assert!(snapshot.font_settings.custom_fonts[0].enabled); - assert_eq!( - snapshot.key_positions["4key"][0].active_image.as_deref(), - Some(local_image.as_str()) - ); - assert_eq!( - snapshot.key_positions["4key"][0].sound_path.as_deref(), - Some(local_sound.as_str()) - ); - assert!(snapshot.sound_library.contains_key(&local_sound)); - assert!(!snapshot.sound_library.contains_key(&foreign_sound)); - assert_eq!( - collect_local_font_paths(&dir, &snapshot).unresolved_count, - 0 - ); - assert_eq!( - collect_local_image_paths(&dir, &snapshot).unresolved_count, - 0 - ); - assert_eq!( - collect_local_sound_paths(&dir, &snapshot).unresolved_count, - 0 - ); - assert_eq!( - std::fs::read(dir.join("store.json.pre-migration.bak")).unwrap(), - original - ); - - store.cleanup_orphan_assets_now().unwrap(); - assert!(font_path.exists()); - assert!(image_path.exists()); - assert!(sound_path.exists()); - store.flush_cleanup_and_shutdown().unwrap(); - drop(store); - - let reloaded = crate::state::migration::load_store_from_path(&store_path).unwrap(); - assert!(!reloaded.needs_persist); - assert_eq!( - collect_local_font_paths(&dir, &reloaded.data).unresolved_count, - 0 - ); - assert_eq!( - collect_local_image_paths(&dir, &reloaded.data).unresolved_count, - 0 - ); - assert_eq!( - collect_local_sound_paths(&dir, &reloaded.data).unresolved_count, - 0 - ); - - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn tauri_1_6_1_literal_fixture_preserves_collections_and_assets_through_shutdown() { - let dir = test_directory("tauri-1-6-1-store-fixture-test"); - let image_path = dir.join("images").join("unbound-knob.png"); - let sound_path = dir.join("sounds").join("legacy-sound.wav"); - for path in [&image_path, &sound_path] { - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(path, b"asset-fixture").unwrap(); - } - - #[cfg(target_os = "windows")] - let stored_sound_path = { - let raw = sound_path.to_string_lossy(); - if let Some(rest) = raw.strip_prefix(r"\\?\UNC\") { - format!(r"\\{rest}") - } else { - raw.strip_prefix(r"\\?\").unwrap_or(&raw).to_string() - } - }; - #[cfg(not(target_os = "windows"))] - let stored_sound_path = - r"C:\Users\DmNote\AppData\Roaming\com.dmnote\sounds\legacy-sound.wav".to_string(); - - let tab_id = "legacy-custom-tab"; - let fixture = r##"{ - "hardwareAcceleration": true, - "alwaysOnTop": true, - "overlayLocked": false, - "overlayVisible": false, - "noteEffect": true, - "noteSettings": { - "frameLimit": 0, - "speed": 180, - "trackHeight": 150, - "reverse": false, - "fadePosition": "auto", - "fadeTopPx": 50, - "fadeBottomPx": 0, - "reverseFadeTopPx": 0, - "reverseFadeBottomPx": 50, - "delayedNoteEnabled": false, - "shortNoteThresholdMs": 50, - "shortNoteMinLengthPx": 30, - "keyDisplayDelayMs": 0 - }, - "selectedKeyType": "legacy-custom-tab", - "customTabs": [{ "id": "legacy-custom-tab", "name": "Legacy custom tab" }], - "angleMode": "d3d11", - "language": "ko", - "laboratoryEnabled": false, - "developerModeEnabled": false, - "trayEnabled": false, - "autoUpdateEnabled": true, - "mainWindowHidden": false, - "keys": { "legacy-custom-tab": ["F5"] }, - "keyPositions": { - "legacy-custom-tab": [{ - "dx": 10.0, - "dy": 20.0, - "width": 60.0, - "height": 60.0, - "activeImage": "", - "inactiveImage": "", - "count": 0, - "noteColor": "#FFFFFF", - "noteOpacity": 80 - }] - }, - "statPositions": { - "legacy-custom-tab": [{ - "statType": "kps", - "dx": 80.0, - "dy": 20.0, - "width": 80.0, - "height": 40.0, - "activeImage": "", - "inactiveImage": "", - "count": 0, - "noteColor": "#FFFFFF", - "noteOpacity": 80 - }] - }, - "graphPositions": { - "legacy-custom-tab": [{ - "statType": "kpsAvg", - "graphType": "line", - "graphSpeed": 1000, - "graphColor": "#24BBB4", - "showAvgLine": true, - "dx": 170.0, - "dy": 20.0, - "width": 160.0, - "height": 80.0, - "activeImage": "", - "inactiveImage": "", - "count": 0, - "noteColor": "#FFFFFF", - "noteOpacity": 80 - }] - }, - "knobPositions": { - "legacy-custom-tab": [{ - "axisId": "", - "sensitivity": 1.40625, - "reverse": false, - "dx": 340.0, - "dy": 20.0, - "width": 80.0, - "height": 80.0, - "activeImage": __IMAGE_PATH_JSON__, - "inactiveImage": "", - "count": 0, - "noteColor": "#FFFFFF", - "noteOpacity": 80 - }] - }, - "layerGroups": { - "legacy-custom-tab": [{ "id": "legacy-group", "name": "Legacy group" }] - }, - "keyCounters": { "legacy-custom-tab": { "F5": 3 } }, - "backgroundColor": "transparent", - "useCustomCss": false, - "customCss": { "path": null, "content": "" }, - "fontSettings": { "customFonts": [] }, - "counterAnimationPresets": [], - "tabCssOverrides": { - "legacy-custom-tab": { "path": null, "content": "", "enabled": true } - }, - "tabNoteOverrides": { - "legacy-custom-tab": { "speed": 200 } - }, - "useCustomJs": false, - "customJs": { "path": null, "content": "", "plugins": [] }, - "overlayResizeAnchor": "top-left", - "overlayBounds": null, - "overlayLastContentTopOffset": null, - "overlayBoundsAreLogical": false, - "keyCounterEnabled": true, - "gridSettings": { - "alignmentGuides": true, - "spacingGuides": true, - "sizeMatchGuides": true, - "minimapEnabled": true, - "gridSnapSize": 5, - "overlayPadding": 30 - }, - "shortcuts": { - "toggleOverlay": { "key": "KeyO", "ctrl": true, "shift": true, "alt": false, "meta": false }, - "toggleOverlayLock": { "key": "", "ctrl": false, "shift": false, "alt": false, "meta": false }, - "toggleAlwaysOnTop": { "key": "", "ctrl": false, "shift": false, "alt": false, "meta": false }, - "switchKeyMode": { "key": "Tab", "ctrl": false, "shift": false, "alt": false, "meta": false }, - "toggleSettingsPanel": { "key": "KeyB", "ctrl": true, "shift": false, "alt": false, "meta": false }, - "zoomIn": { "key": "Equal", "ctrl": true, "shift": false, "alt": false, "meta": false }, - "zoomOut": { "key": "Minus", "ctrl": true, "shift": false, "alt": false, "meta": false }, - "resetZoom": { "key": "Digit0", "ctrl": true, "shift": false, "alt": false, "meta": false } - }, - "soundLibrary": { - __SOUND_PATH_JSON__: { - "enabled": true, - "source": "local", - "originalPath": null, - "trimStartRatio": null, - "trimEndRatio": null, - "displayName": "Legacy sound" - } - }, - "keySoundOutputBackend": null, - "obsModeEnabled": false, - "obsPort": 34891, - "obsToken": null -}"## - .replace( - "__IMAGE_PATH_JSON__", - &serde_json::to_string(&image_path.to_string_lossy()).unwrap(), - ) - .replace( - "__SOUND_PATH_JSON__", - &serde_json::to_string(&stored_sound_path).unwrap(), - ); - let store_path = dir.join("store.json"); - std::fs::write(&store_path, fixture.as_bytes()).unwrap(); - - let store = AppStore::initialize_in_dir(&dir).unwrap(); - assert!(!store.skip_asset_sweep); - let snapshot = store.snapshot(); - assert_eq!(snapshot.custom_tabs.len(), 1); - assert_eq!(snapshot.keys[tab_id], vec![KeySlot::from("F5")]); - assert_eq!(snapshot.key_positions[tab_id].len(), 1); - assert_eq!(snapshot.stat_positions[tab_id].len(), 1); - assert_eq!(snapshot.graph_positions[tab_id].len(), 1); - assert_eq!(snapshot.knob_positions[tab_id].len(), 1); - assert!(snapshot.knob_positions[tab_id][0].axis_id.is_empty()); - assert_eq!(snapshot.knob_positions[tab_id][0].sensitivity, 1.0); - assert_eq!(snapshot.layer_groups[tab_id].len(), 1); - assert_eq!(snapshot.key_counters[tab_id]["F5"], 3); - assert!(snapshot.tab_css_overrides.contains_key(tab_id)); - assert!(snapshot.tab_note_overrides.contains_key(tab_id)); - assert!(snapshot.sound_library.contains_key(&stored_sound_path)); - assert!(!snapshot.sound_library[&stored_sound_path].hidden); - assert!(!dir.join("store.json.bak").exists()); - assert_eq!( - std::fs::read(dir.join("store.json.pre-migration.bak")).unwrap(), - fixture.as_bytes() - ); - - store.flush_cleanup_and_shutdown().unwrap(); - drop(store); - - assert!(image_path.exists()); - assert!(sound_path.exists()); - assert!(!dir.join("store.json.bak").exists()); - let reloaded = crate::state::migration::load_store_from_path(&store_path).unwrap(); - assert!(!reloaded.repaired); - assert_eq!(reloaded.data.custom_tabs.len(), 1); - assert_eq!(reloaded.data.keys[tab_id], vec![KeySlot::from("F5")]); - assert_eq!(reloaded.data.key_positions[tab_id].len(), 1); - assert_eq!(reloaded.data.stat_positions[tab_id].len(), 1); - assert_eq!(reloaded.data.graph_positions[tab_id].len(), 1); - assert_eq!(reloaded.data.knob_positions[tab_id].len(), 1); - assert!(reloaded.data.knob_positions[tab_id][0].axis_id.is_empty()); - assert_eq!(reloaded.data.layer_groups[tab_id].len(), 1); - assert!(reloaded.data.sound_library.contains_key(&stored_sound_path)); - - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn pre_migration_backup_preserves_first_original_across_rewrites() { - let dir = test_directory("pre-migration-backup-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store_path = dir.join("store.json"); - let backup_path = dir.join("store.json.pre-migration.bak"); - let first_original = br##"{ - "hardwareAcceleration": true, - "alwaysOnTop": true, - "overlayLocked": false, - "noteEffect": false, - "selectedKeyType": "4key", - "angleMode": "d3d11", - "language": "ko", - "laboratoryEnabled": false, - "backgroundColor": "transparent", - "useCustomCss": false, - "overlayResizeAnchor": "top-left", - "overlayBounds": null, - "overlayLastContentTopOffset": null, - "soundLibrary": { - "C:\\legacy\\first.wav": { "enabled": true, "source": "local" } - } -}"##; - std::fs::write(&store_path, first_original).unwrap(); - - let first = AppStore::initialize_in_dir(&dir).unwrap(); - assert_eq!(std::fs::read(&backup_path).unwrap(), first_original); - assert!(!dir.join("store.json.bak").exists()); - first.flush_cleanup_and_shutdown().unwrap(); - drop(first); - assert_eq!(std::fs::read(&backup_path).unwrap(), first_original); - - let mut second_input: Value = - serde_json::from_slice(&std::fs::read(&store_path).unwrap()).unwrap(); - second_input["soundLibrary"] - .as_object_mut() - .unwrap() - .insert( - r"C:\legacy\second.wav".to_string(), - json!({ "enabled": true, "source": "local" }), - ); - std::fs::write( - &store_path, - serde_json::to_vec_pretty(&second_input).unwrap(), - ) - .unwrap(); - - let second = AppStore::initialize_in_dir(&dir).unwrap(); - assert_eq!(std::fs::read(&backup_path).unwrap(), first_original); - assert!(!dir.join("store.json.bak").exists()); - second.flush_cleanup_and_shutdown().unwrap(); - drop(second); - assert_eq!(std::fs::read(&backup_path).unwrap(), first_original); - - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn invalid_pre_migration_backup_path_aborts_before_store_rewrite() { - let dir = test_directory("pre-migration-backup-path-failure-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store_path = dir.join("store.json"); - let original = br#"{ - "hardwareAcceleration": true, - "alwaysOnTop": true, - "overlayLocked": false, - "noteEffect": false, - "selectedKeyType": "4key", - "angleMode": "d3d11", - "language": "ko", - "laboratoryEnabled": false, - "backgroundColor": "transparent", - "useCustomCss": false, - "overlayResizeAnchor": "top-left", - "overlayBounds": null, - "overlayLastContentTopOffset": null, - "soundLibrary": { - "C:\\legacy\\first.wav": { "enabled": true, "source": "local" } - } -}"#; - std::fs::write(&store_path, original).unwrap(); - std::fs::create_dir(dir.join("store.json.pre-migration.bak")).unwrap(); - - let result = AppStore::initialize_in_dir(&dir); - - if let Ok(store) = result { - let _ = store.flush_and_shutdown(); - panic!("store initialization unexpectedly rewrote data without a valid backup"); - } - assert_eq!(std::fs::read(&store_path).unwrap(), original); - - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn malformed_existing_pre_migration_backup_aborts_before_store_rewrite() { - let dir = test_directory("pre-migration-backup-content-failure-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store_path = dir.join("store.json"); - let original = br#"{ - "hardwareAcceleration": true, - "alwaysOnTop": true, - "overlayLocked": false, - "noteEffect": false, - "selectedKeyType": "4key", - "angleMode": "d3d11", - "language": "ko", - "laboratoryEnabled": false, - "backgroundColor": "transparent", - "useCustomCss": false, - "overlayResizeAnchor": "top-left", - "overlayBounds": null, - "overlayLastContentTopOffset": null, - "soundLibrary": { - "C:\\legacy\\first.wav": { "enabled": true, "source": "local" } - } -}"#; - std::fs::write(&store_path, original).unwrap(); - std::fs::write(dir.join("store.json.pre-migration.bak"), b"not-json").unwrap(); - - let result = AppStore::initialize_in_dir(&dir); - - if let Ok(store) = result { - let _ = store.flush_and_shutdown(); - panic!("store initialization unexpectedly rewrote data without a valid backup"); - } - assert_eq!(std::fs::read(&store_path).unwrap(), original); - - let _ = std::fs::remove_dir_all(dir); - } - - #[cfg(unix)] - #[test] - fn symlink_pre_migration_backup_aborts_before_store_rewrite() { - use std::os::unix::fs::symlink; - - let dir = test_directory("pre-migration-backup-symlink-failure-test"); - std::fs::create_dir_all(&dir).unwrap(); - let store_path = dir.join("store.json"); - let original = br#"{ - "hardwareAcceleration": true, - "alwaysOnTop": true, - "overlayLocked": false, - "noteEffect": false, - "selectedKeyType": "4key", - "angleMode": "d3d11", - "language": "ko", - "laboratoryEnabled": false, - "backgroundColor": "transparent", - "useCustomCss": false, - "overlayResizeAnchor": "top-left", - "overlayBounds": null, - "overlayLastContentTopOffset": null, - "soundLibrary": { - "C:\\legacy\\first.wav": { "enabled": true, "source": "local" } - } -}"#; - std::fs::write(&store_path, original).unwrap(); - let target = dir.join("unrelated.json"); - std::fs::write(&target, b"{}").unwrap(); - symlink(&target, dir.join("store.json.pre-migration.bak")).unwrap(); - - let result = AppStore::initialize_in_dir(&dir); - - if let Ok(store) = result { - let _ = store.flush_and_shutdown(); - panic!("store initialization unexpectedly followed a backup symlink"); - } - assert_eq!(std::fs::read(&store_path).unwrap(), original); - assert_eq!(std::fs::read(&target).unwrap(), b"{}"); - - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn interrupted_sound_deletion_restores_files_still_referenced_by_store() { - let dir = test_directory("sound-delete-startup-restore-test"); - let sounds_dir = dir.join("sounds"); - let originals_dir = sounds_dir.join("originals"); - std::fs::create_dir_all(&originals_dir).unwrap(); - let sound_path = sounds_dir.join("sound.wav"); - let original_path = originals_dir.join("original.wav"); - std::fs::write(&sound_path, b"sound").unwrap(); - std::fs::write(&original_path, b"original").unwrap(); - - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let sound_key = sound_path.to_string_lossy().to_string(); - store - .update(|data| { - data.sound_library.insert( - sound_key.clone(), - SoundLibraryEntry { - original_path: Some("originals/original.wav".to_string()), - ..Default::default() - }, - ); - }) - .unwrap(); - let staged = - stage_sound_files_for_deletion(&[sound_path.clone(), original_path.clone()]).unwrap(); - assert_eq!(staged.len(), 2); - assert!(!sound_path.exists()); - assert!(!original_path.exists()); - - store - .recover_interrupted_processed_wav_replacements_now() - .unwrap(); - - assert_eq!(std::fs::read(&sound_path).unwrap(), b"sound"); - assert_eq!(std::fs::read(&original_path).unwrap(), b"original"); - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn interrupted_sound_deletion_restore_failure_is_reported_incomplete() { - let dir = test_directory("sound-delete-incomplete-recovery-test"); - let sounds_dir = dir.join("sounds"); - std::fs::create_dir_all(&sounds_dir).unwrap(); - let sound_path = sounds_dir.join("sound.wav"); - std::fs::write(&sound_path, b"sound").unwrap(); - - let mut data = AppStoreData::default(); - data.sound_library.insert( - sound_path.to_string_lossy().to_string(), - SoundLibraryEntry::default(), - ); - let staged = stage_sound_files_for_deletion(std::slice::from_ref(&sound_path)).unwrap(); - let backup_path = staged[0].backup_path.clone(); - - let recovery = recover_interrupted_sound_deletions_with( - &data, - &sounds_dir, - &dir.join("trash"), - |_from, _to| Err(std::io::Error::other("injected recovery failure")), - ) - .unwrap(); - - assert!(!recovery.complete); - assert!(recovery - .protected_keys - .contains(&path_identity_key(&backup_path))); - assert!(!sound_path.exists()); - assert!(backup_path.exists()); - - std::fs::rename(backup_path, sound_path).unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn interrupted_committed_sound_deletion_finishes_in_trash() { - let dir = test_directory("sound-delete-startup-finish-test"); - let sounds_dir = dir.join("sounds"); - std::fs::create_dir_all(&sounds_dir).unwrap(); - let sound_path = sounds_dir.join("sound.wav"); - std::fs::write(&sound_path, b"sound").unwrap(); - - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let sound_key = sound_path.to_string_lossy().to_string(); - store - .update(|data| { - data.sound_library - .insert(sound_key.clone(), SoundLibraryEntry::default()); - }) - .unwrap(); - stage_sound_files_for_deletion(std::slice::from_ref(&sound_path)).unwrap(); - store - .update(|data| { - data.sound_library.remove(&sound_key); - }) - .unwrap(); - - store - .recover_interrupted_processed_wav_replacements_now() - .unwrap(); - - assert!(!sound_path.exists()); - let quarantined = std::fs::read_dir(dir.join("trash")) - .unwrap() - .flat_map(|session| std::fs::read_dir(session.unwrap().path()).unwrap()) - .flat_map(|category| std::fs::read_dir(category.unwrap().path()).unwrap()) - .any(|entry| entry.unwrap().file_name() == "sound.wav"); - assert!(quarantined); - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn sound_deletion_staging_failure_restores_already_staged_files() { - let dir = test_directory("sound-delete-staging-rollback-test"); - std::fs::create_dir_all(&dir).unwrap(); - let sound_path = dir.join("sound.wav"); - let invalid_path = dir.join("not-a-file.wav"); - std::fs::write(&sound_path, b"sound").unwrap(); - std::fs::create_dir(&invalid_path).unwrap(); - - let error = stage_sound_files_for_deletion(&[sound_path.clone(), invalid_path]) - .unwrap_err() - .to_string(); - - assert!(error.contains("not a file")); - assert_eq!(std::fs::read(&sound_path).unwrap(), b"sound"); - assert!(!std::fs::read_dir(&dir).unwrap().any(|entry| { - entry - .unwrap() - .file_name() - .to_string_lossy() - .contains(".delete-backup-") - })); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn repaired_non_utf8_store_skips_every_asset_sweep() { - let dir = test_directory("non-utf8-asset-sweep-health-gate-test"); - let sound_path = dir.join("sounds").join("unreferenced.wav"); - let font_path = dir.join("fonts").join("unreferenced.ttf"); - let image_path = dir.join("images").join("unreferenced.png"); - for path in [&sound_path, &font_path, &image_path] { - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(path, b"asset-fixture").unwrap(); - } - std::fs::write(dir.join("store.json"), [b'{', 0xFF, b'}']).unwrap(); - - let store = AppStore::initialize_in_dir(&dir).unwrap(); - assert!(store.skip_asset_sweep); - - store.cleanup_orphan_assets_now().unwrap(); - - assert!(sound_path.exists()); - assert!(font_path.exists()); - assert!(image_path.exists()); - assert!(!dir.join("trash").exists()); - - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn repaired_sound_library_keeps_valid_local_asset_through_cleanup() { - let dir = test_directory("sound-library-entry-recovery-test"); - let sounds_dir = dir.join("sounds"); - std::fs::create_dir_all(&sounds_dir).unwrap(); - - let builtin_hait = sounds_dir.join("builtin-hait.wav"); - let builtin_click = sounds_dir.join("builtin-click.wav"); - let valid_local = sounds_dir.join("valid-local.wav"); - let invalid_local = sounds_dir.join("invalid-local.wav"); - let wav_fixture = include_bytes!("../../assets/sounds/builtin-click.wav"); - for path in [&builtin_hait, &builtin_click, &valid_local, &invalid_local] { - std::fs::write(path, wav_fixture).unwrap(); - } - - let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); - let sound_library = fixture["soundLibrary"].as_object_mut().unwrap(); - sound_library.insert( - builtin_hait.to_string_lossy().to_string(), - json!({ "source": "builtin" }), - ); - sound_library.insert( - builtin_click.to_string_lossy().to_string(), - json!({ "source": "builtin" }), - ); - sound_library.insert( - valid_local.to_string_lossy().to_string(), - json!({ "source": "local", "displayName": "Valid local" }), - ); - sound_library.insert( - invalid_local.to_string_lossy().to_string(), - json!({ "source": 42, "displayName": "Invalid local" }), - ); - std::fs::write( - dir.join("store.json"), - serde_json::to_vec_pretty(&fixture).unwrap(), - ) - .unwrap(); - - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let snapshot = store.snapshot(); - let valid_local_key = valid_local.to_string_lossy().to_string(); - let invalid_local_key = invalid_local.to_string_lossy().to_string(); - - assert_eq!(snapshot.sound_library.len(), 4); - assert_eq!( - snapshot.sound_library[&valid_local_key].source, - SoundSource::Local - ); - assert_eq!( - snapshot.sound_library[&invalid_local_key].source, - SoundSource::Local - ); - assert_eq!( - snapshot.sound_library[&invalid_local_key] - .display_name - .as_deref(), - Some("Invalid local") - ); - assert!(store.skip_asset_sweep); - - store.cleanup_orphan_assets_now().unwrap(); - - assert!(valid_local.exists()); - assert!(invalid_local.exists()); - assert!(!dir.join("trash").exists()); - - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn repaired_asset_references_survive_the_next_normal_session_sweep() { - fn corrupt_height(mut value: Value) -> Value { - value - .as_object_mut() - .unwrap() - .insert("height".to_string(), json!("invalid")); - value - } - - let dir = test_directory("repaired-asset-reference-next-session-test"); - let font_path = dir.join("fonts").join("recoverable.ttf"); - let library_sound = dir.join("sounds").join("library.wav"); - let stat_sound = dir.join("sounds").join("stat.wav"); - let graph_sound = dir.join("sounds").join("graph.wav"); - let knob_sound = dir.join("sounds").join("knob.wav"); - for path in [ - &font_path, - &library_sound, - &stat_sound, - &graph_sound, - &knob_sound, - ] { - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(path, b"asset-fixture").unwrap(); - } - - let mut font = serde_json::to_value(CustomFont { - id: "recoverable-font".to_string(), - font_type: FontType::Local, - name: "Recoverable Font".to_string(), - display_name: "Recoverable Font".to_string(), - enabled: true, - local_path: Some(font_path.to_string_lossy().to_string()), - css_content: None, - weight_ranges: Vec::new(), - }) - .unwrap(); - font.as_object_mut() - .unwrap() - .insert("enabled".to_string(), json!("invalid")); - - let mut stat_position = default_positions()["4key"][0].clone(); - stat_position.sound_path = Some(stat_sound.to_string_lossy().to_string()); - let stat = corrupt_height( - serde_json::to_value(StatPosition { - stat_type: StatType::Kps, - position: stat_position, - }) - .unwrap(), - ); - let mut graph_position = default_positions()["4key"][1].clone(); - graph_position.sound_path = Some(graph_sound.to_string_lossy().to_string()); - let graph = corrupt_height( - serde_json::to_value(GraphPosition { - stat_type: GraphStatType::Kps, - graph_type: GraphType::Line, - graph_speed: 1, - graph_color: "#FFFFFF".to_string(), - show_avg_line: true, - position: graph_position, - }) - .unwrap(), - ); - let mut knob_position = default_positions()["4key"][2].clone(); - knob_position.sound_path = Some(knob_sound.to_string_lossy().to_string()); - let knob = corrupt_height( - serde_json::to_value(KnobPosition { - axis_id: "axis-recoverable".to_string(), - sensitivity: 1.0, - reverse: false, - position: knob_position, - }) - .unwrap(), - ); - - let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); - let fields = fixture.as_object_mut().unwrap(); - fields.insert("fontSettings".to_string(), json!({ "customFonts": [font] })); - fields.insert( - "soundLibrary".to_string(), - json!({ library_sound.to_string_lossy().to_string(): 42 }), - ); - fields.insert("statPositions".to_string(), json!({ "asset-mode": [stat] })); - fields.insert( - "graphPositions".to_string(), - json!({ "asset-mode": [graph] }), - ); - fields.insert("knobPositions".to_string(), json!({ "asset-mode": [knob] })); - std::fs::write( - dir.join("store.json"), - serde_json::to_vec_pretty(&fixture).unwrap(), - ) - .unwrap(); - - let recovered = AppStore::initialize_in_dir(&dir).unwrap(); - assert!(recovered.skip_asset_sweep); - let snapshot = recovered.snapshot(); - assert!(!snapshot.font_settings.custom_fonts[0].enabled); - assert!(snapshot - .sound_library - .contains_key(&library_sound.to_string_lossy().to_string())); - assert_eq!(snapshot.stat_positions["asset-mode"].len(), 1); - assert_eq!(snapshot.graph_positions["asset-mode"].len(), 1); - assert_eq!(snapshot.knob_positions["asset-mode"].len(), 1); - recovered.cleanup_orphan_assets_now().unwrap(); - recovered.flush_and_shutdown().unwrap(); - drop(recovered); - - let normal = AppStore::initialize_in_dir(&dir).unwrap(); - assert!(!normal.skip_asset_sweep); - normal.cleanup_orphan_assets_now().unwrap(); - for path in [ - &font_path, - &library_sound, - &stat_sound, - &graph_sound, - &knob_sound, - ] { - assert!(path.exists(), "recovered asset was quarantined: {path:?}"); - } - - normal.flush_and_shutdown().unwrap(); - drop(normal); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn normal_session_moves_unreferenced_asset_to_trash() { - let dir = test_directory("asset-trash-quarantine-test"); - let store = AppStore::initialize_in_dir(&dir).unwrap(); - assert!(!store.skip_asset_sweep); - - let orphan_path = dir.join("sounds").join("orphan.wav"); - std::fs::write(&orphan_path, b"orphan-wav").unwrap(); - - store.cleanup_orphan_assets_now().unwrap(); - - assert!(!orphan_path.exists()); - let trash_dir = dir.join("trash"); - let sessions: Vec<_> = std::fs::read_dir(&trash_dir) - .unwrap() - .map(|entry| entry.unwrap().path()) - .collect(); - assert_eq!(sessions.len(), 1); - let quarantined_path = sessions[0].join("sounds").join("orphan.wav"); - assert_eq!(std::fs::read(quarantined_path).unwrap(), b"orphan-wav"); - - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn trash_collision_uses_another_session_and_preserves_file_name() { - let dir = test_directory("asset-trash-collision-test"); - let sounds_dir = dir.join("sounds"); - let trash_dir = dir.join("trash"); - let now = UNIX_EPOCH + Duration::from_secs(2_000_000_000); - let timestamp = system_time_millis(now).unwrap(); - let collision_path = trash_dir - .join(timestamp.to_string()) - .join("sounds") - .join("same.wav"); - let source_path = sounds_dir.join("same.wav"); - std::fs::create_dir_all(collision_path.parent().unwrap()).unwrap(); - std::fs::create_dir_all(&sounds_dir).unwrap(); - std::fs::write(&collision_path, b"existing").unwrap(); - std::fs::write(&source_path, b"incoming").unwrap(); - - let mut trash_session = TrashSession::new(trash_dir.clone(), now).unwrap(); - sweep_unreferenced_asset_files("Sounds", &sounds_dir, &HashSet::new(), &mut trash_session) - .unwrap(); - - let moved_path = trash_dir - .join(format!("{timestamp}-1")) - .join("sounds") - .join("same.wav"); - assert!(!source_path.exists()); - assert_eq!(std::fs::read(collision_path).unwrap(), b"existing"); - assert_eq!(std::fs::read(moved_path).unwrap(), b"incoming"); - - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn trash_purge_removes_only_sessions_older_than_thirty_days() { - let dir = test_directory("asset-trash-retention-test"); - let trash_dir = dir.join("trash"); - let now = UNIX_EPOCH + Duration::from_secs(2_000_000_000); - let expired_time = now - .checked_sub(TRASH_RETENTION + Duration::from_secs(1)) - .unwrap(); - let retained_time = now - .checked_sub(Duration::from_secs(29 * 24 * 60 * 60)) - .unwrap(); - let expired_dir = - trash_dir.join(format!("{}-1", system_time_millis(expired_time).unwrap())); - let retained_dir = trash_dir.join(system_time_millis(retained_time).unwrap().to_string()); - std::fs::create_dir_all(expired_dir.join("sounds")).unwrap(); - std::fs::create_dir_all(retained_dir.join("sounds")).unwrap(); - std::fs::write(expired_dir.join("sounds").join("old.wav"), b"old").unwrap(); - std::fs::write(retained_dir.join("sounds").join("recent.wav"), b"recent").unwrap(); - - purge_expired_trash_sessions_at(&trash_dir, now).unwrap(); - - assert!(!expired_dir.exists()); - assert!(retained_dir.exists()); - - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn orphan_sweep_recovers_processed_wav_backup_before_removing_temp() { - let dir = test_directory("processed-wav-orphan-recovery-test"); - let sounds_dir = dir.join("sounds"); - std::fs::create_dir_all(&sounds_dir).unwrap(); - - let target_path = sounds_dir.join("sound.wav"); - let backup_path = sounds_dir.join("sound.wav.bak"); - let temp_path = sounds_dir.join(format!( - ".sound.wav.processed-wav-{}.tmp", - uuid::Uuid::new_v4() - )); - std::fs::write(&backup_path, b"old-wav").unwrap(); - std::fs::write(&temp_path, b"new-wav").unwrap(); - - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let target_key = target_path.to_string_lossy().to_string(); - store - .update(|data| { - data.sound_library - .insert(target_key.clone(), Default::default()); - }) - .unwrap(); - - store.cleanup_orphan_assets_now().unwrap(); - - assert_eq!(std::fs::read(&target_path).unwrap(), b"old-wav"); - assert!(!backup_path.exists()); - assert!(!temp_path.exists()); - - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn app_state_initialization_recovers_processed_wav_without_sweeping_temp() { - let dir = test_directory("processed-wav-startup-recovery-test"); - let sounds_dir = dir.join("sounds"); - std::fs::create_dir_all(&sounds_dir).unwrap(); - - let target_path = sounds_dir.join("sound.wav"); - let backup_path = sounds_dir.join("sound.wav.bak"); - let temp_path = sounds_dir.join(format!( - ".sound.wav.processed-wav-{}.tmp", - uuid::Uuid::new_v4() - )); - std::fs::write(&backup_path, b"old-wav").unwrap(); - std::fs::write(&temp_path, b"interrupted-new-wav").unwrap(); - - let store = AppStore::initialize_in_dir(&dir).unwrap(); - assert!(!target_path.exists()); - - let state = AppState::initialize(store).unwrap(); - - assert_eq!(std::fs::read(&target_path).unwrap(), b"old-wav"); - assert!(!backup_path.exists()); - assert!(temp_path.exists()); - - state.shutdown(); - drop(state); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn pending_wav_journal_rolls_back_file_before_metadata_is_committed() { - let dir = test_directory("processed-wav-journal-recovery-test"); - let sounds_dir = dir.join("sounds"); - std::fs::create_dir_all(&sounds_dir).unwrap(); - let target_path = sounds_dir.join("sound.wav"); - let backup_path = sounds_dir.join("sound.wav.bak"); - std::fs::write(&target_path, b"old-wav").unwrap(); - - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let target_key = target_path.to_string_lossy().to_string(); - store - .update(|data| { - data.sound_library.insert( - target_key.clone(), - crate::models::SoundLibraryEntry { - display_name: Some("old-metadata".to_string()), - ..Default::default() - }, - ); - data.pending_processed_wav_replacement = Some(PendingProcessedWavReplacement { - sound_path: target_key.clone(), - had_original: true, - }); - }) - .unwrap(); - - std::fs::rename(&target_path, &backup_path).unwrap(); - std::fs::write(&target_path, b"uncommitted-new-wav").unwrap(); - store - .recover_interrupted_processed_wav_replacements_now() - .unwrap(); - - let snapshot = store.snapshot(); - assert_eq!(std::fs::read(&target_path).unwrap(), b"old-wav"); - assert_eq!( - snapshot.sound_library[&target_key].display_name.as_deref(), - Some("old-metadata") - ); - assert_eq!(snapshot.pending_processed_wav_replacement, None); - assert!(!backup_path.exists()); - assert!(std::fs::read_dir(&sounds_dir).unwrap().any(|entry| { - let entry = entry.unwrap(); - entry - .file_name() - .to_string_lossy() - .contains(".interrupted-") - && std::fs::read(entry.path()).unwrap() == b"uncommitted-new-wav" - })); - - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn missing_pending_wav_files_clear_journal_and_allow_repeated_cleanup() { - let dir = test_directory("processed-wav-missing-journal-test"); - let sounds_dir = dir.join("sounds"); - std::fs::create_dir_all(&sounds_dir).unwrap(); - let target_path = sounds_dir.join("missing.wav"); - let orphan_path = sounds_dir.join("orphan.wav"); - std::fs::write(&orphan_path, b"orphan-wav").unwrap(); - - let store = AppStore::initialize_in_dir(&dir).unwrap(); - store - .update(|data| { - data.pending_processed_wav_replacement = Some(PendingProcessedWavReplacement { - sound_path: target_path.to_string_lossy().to_string(), - had_original: true, - }); - }) - .unwrap(); - - store.cleanup_orphan_assets_now().unwrap(); - - assert_eq!(store.snapshot().pending_processed_wav_replacement, None); - assert!(!orphan_path.exists()); - let trash_sessions: Vec<_> = std::fs::read_dir(dir.join("trash")) - .unwrap() - .map(|entry| entry.unwrap().path()) - .collect(); - assert!(trash_sessions - .iter() - .any(|session| session.join("sounds").join("orphan.wav").exists())); - - store.cleanup_orphan_assets_now().unwrap(); - assert_eq!(store.snapshot().pending_processed_wav_replacement, None); - - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn unknown_pending_wav_presence_keeps_journal() { - let dir = test_directory("processed-wav-unknown-presence-test"); - let sounds_dir = dir.join("sounds"); - std::fs::create_dir_all(&sounds_dir).unwrap(); - let target_path = sounds_dir.join("unknown.wav"); - - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let pending = PendingProcessedWavReplacement { - sound_path: target_path.to_string_lossy().to_string(), - had_original: true, - }; - store - .update(|data| { - data.pending_processed_wav_replacement = Some(pending.clone()); - }) - .unwrap(); - let checks = std::cell::Cell::new(0); - - let result = - store.recover_pending_processed_wav_replacement_with(&sounds_dir, |_path| match checks - .get() - { - 0 => { - checks.set(1); - Ok(false) - } - _ => { - checks.set(2); - Err(std::io::Error::new( - std::io::ErrorKind::PermissionDenied, - "injected permission failure", - )) - } - }); - - assert!(result.is_err()); - assert_eq!(checks.get(), 2); - assert_eq!( - store.snapshot().pending_processed_wav_replacement, - Some(pending) - ); - - store - .update(|data| data.pending_processed_wav_replacement = None) - .unwrap(); - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn completed_wav_transaction_keeps_new_file_when_only_backup_cleanup_was_interrupted() { - let dir = test_directory("processed-wav-post-commit-recovery-test"); - let sounds_dir = dir.join("sounds"); - std::fs::create_dir_all(&sounds_dir).unwrap(); - let target_path = sounds_dir.join("sound.wav"); - let backup_path = sounds_dir.join("sound.wav.bak"); - std::fs::write(&target_path, b"new-wav").unwrap(); - std::fs::write(&backup_path, b"old-wav").unwrap(); - - let store = AppStore::initialize_in_dir(&dir).unwrap(); - let target_key = target_path.to_string_lossy().to_string(); - store - .update(|data| { - data.sound_library.insert( - target_key.clone(), - crate::models::SoundLibraryEntry { - display_name: Some("new-metadata".to_string()), - ..Default::default() - }, - ); - data.pending_processed_wav_replacement = None; - }) - .unwrap(); - - store - .recover_interrupted_processed_wav_replacements_now() - .unwrap(); - - assert_eq!(std::fs::read(&target_path).unwrap(), b"new-wav"); - assert_eq!(std::fs::read(&backup_path).unwrap(), b"old-wav"); - assert_eq!( - store.snapshot().sound_library[&target_key] - .display_name - .as_deref(), - Some("new-metadata") - ); - - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn pending_wav_without_an_original_quarantines_the_uncommitted_file() { - let dir = test_directory("processed-wav-new-file-journal-test"); - let sounds_dir = dir.join("sounds"); - std::fs::create_dir_all(&sounds_dir).unwrap(); - let target_path = sounds_dir.join("new.wav"); - std::fs::write(&target_path, b"uncommitted-new-wav").unwrap(); - - let store = AppStore::initialize_in_dir(&dir).unwrap(); - store - .update(|data| { - data.pending_processed_wav_replacement = Some(PendingProcessedWavReplacement { - sound_path: target_path.to_string_lossy().to_string(), - had_original: false, - }); - }) - .unwrap(); - - store - .recover_interrupted_processed_wav_replacements_now() - .unwrap(); - - assert!(!target_path.exists()); - assert_eq!(store.snapshot().pending_processed_wav_replacement, None); - assert!(std::fs::read_dir(&sounds_dir).unwrap().any(|entry| { - let entry = entry.unwrap(); - entry - .file_name() - .to_string_lossy() - .contains(".interrupted-") - && std::fs::read(entry.path()).unwrap() == b"uncommitted-new-wav" - })); - - store.flush_and_shutdown().unwrap(); - drop(store); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn orphan_sweep_continues_recovery_and_protects_failed_backup() { - let dir = test_directory("processed-wav-partial-recovery-test"); - let sounds_dir = dir.join("sounds"); - std::fs::create_dir_all(&sounds_dir).unwrap(); - - let first_target = sounds_dir.join("first.wav"); - let first_backup = sounds_dir.join("first.wav.bak"); - let second_target = sounds_dir.join("second.wav"); - let second_backup = sounds_dir.join("second.wav.bak"); - let temp_path = sounds_dir.join(format!( - ".first.wav.processed-wav-{}.tmp", - uuid::Uuid::new_v4() - )); - std::fs::write(&first_backup, b"first-old-wav").unwrap(); - std::fs::write(&second_backup, b"second-old-wav").unwrap(); - std::fs::write(&temp_path, b"interrupted-new-wav").unwrap(); - - let mut retained_keys = HashSet::from([ - path_identity_key(&first_target), - path_identity_key(&second_target), - ]); - let rename_attempts = std::cell::Cell::new(0); - let failed_backup = std::cell::RefCell::new(None); - let recovered_target = std::cell::RefCell::new(None); - let recovery = - recover_interrupted_processed_wav_replacements_with(&sounds_dir, |from, to| { - let attempt = rename_attempts.get(); - rename_attempts.set(attempt + 1); - if attempt == 0 { - failed_backup.replace(Some(from.to_path_buf())); - Err(std::io::Error::other("injected recovery failure")) - } else { - recovered_target.replace(Some(to.to_path_buf())); - std::fs::rename(from, to) - } - }) - .unwrap(); - assert!(!recovery.complete); - retained_keys.extend(recovery.protected_keys); - let mut trash_session = TrashSession::new(dir.join("trash"), SystemTime::now()).unwrap(); - sweep_unreferenced_asset_files("Sounds", &sounds_dir, &retained_keys, &mut trash_session) - .unwrap(); - - assert_eq!(rename_attempts.get(), 2); - let failed_backup = failed_backup.into_inner().unwrap(); - let failed_target_name = failed_backup - .file_name() - .unwrap() - .to_string_lossy() - .strip_suffix(".bak") - .unwrap() - .to_string(); - let failed_target = failed_backup.with_file_name(failed_target_name); - let recovered_target = recovered_target.into_inner().unwrap(); - assert!(!failed_target.exists()); - assert!(failed_backup.exists()); - assert!(recovered_target.exists()); - assert!(!recovered_target.with_extension("wav.bak").exists()); - assert!(!temp_path.exists()); - - let _ = std::fs::remove_dir_all(dir); - } -} +mod tests; diff --git a/src-tauri/src/state/store/asset_references.rs b/src-tauri/src/state/store/asset_references.rs new file mode 100644 index 000000000..32db5df32 --- /dev/null +++ b/src-tauri/src/state/store/asset_references.rs @@ -0,0 +1,268 @@ +use std::{ + collections::HashSet, + path::{Path, PathBuf}, +}; + +use serde_json::Value; + +use crate::models::{AppStoreData, FontType, KeyPosition}; + +use super::super::{ + local_asset_path::{ + file_url_to_path, path_identity_key, paths_have_same_identity, FileUrlPath, + }, + migration::is_foreign_portable_asset_reference, +}; + +pub(super) struct AssetReferencePaths { + pub(super) keys: HashSet, + pub(super) complete: bool, + pub(super) unresolved_count: usize, +} + +impl AssetReferencePaths { + fn new() -> Self { + Self { + keys: HashSet::new(), + complete: true, + unresolved_count: 0, + } + } + + fn collect(&mut self, app_data_dir: &Path, value: Option<&String>) { + let Some(path) = value else { + return; + }; + match resolve_local_asset_path(app_data_dir, path) { + LocalAssetPathResolution::Path(path) => { + self.keys.insert(path_identity_key(&path)); + } + LocalAssetPathResolution::Unresolved => { + self.complete = false; + self.unresolved_count += 1; + } + LocalAssetPathResolution::Ignored => {} + } + } +} + +fn collect_plugin_managed_asset_paths( + app_data_dir: &Path, + data: &AppStoreData, + directory_name: &str, + paths: &mut AssetReferencePaths, +) { + let managed_dir = app_data_dir.join(directory_name); + for value in data.plugin_data.values() { + collect_plugin_managed_asset_value(app_data_dir, &managed_dir, value, paths); + } +} + +fn collect_plugin_managed_asset_value( + app_data_dir: &Path, + managed_dir: &Path, + value: &Value, + paths: &mut AssetReferencePaths, +) { + match value { + Value::String(raw) => { + let LocalAssetPathResolution::Path(path) = resolve_local_asset_path(app_data_dir, raw) + else { + return; + }; + if path + .parent() + .is_some_and(|parent| paths_have_same_identity(parent, managed_dir)) + { + paths.keys.insert(path_identity_key(&path)); + } + } + Value::Array(values) => { + for value in values { + collect_plugin_managed_asset_value(app_data_dir, managed_dir, value, paths); + } + } + Value::Object(values) => { + for value in values.values() { + collect_plugin_managed_asset_value(app_data_dir, managed_dir, value, paths); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) => {} + } +} + +pub(super) fn warn_unresolved_asset_references(app_data_dir: &Path, data: &AppStoreData) { + for (category, count) in [ + ( + "fonts", + collect_local_font_paths(app_data_dir, data).unresolved_count, + ), + ( + "images", + collect_local_image_paths(app_data_dir, data).unresolved_count, + ), + ( + "sounds", + collect_local_sound_paths(app_data_dir, data).unresolved_count, + ), + ] { + if count > 0 { + log::warn!("[Assets] {category} sweep 보류: 해석 불가 참조 {count}건"); + } + } +} + +pub(super) fn collect_local_font_paths( + app_data_dir: &Path, + data: &AppStoreData, +) -> AssetReferencePaths { + let mut paths = AssetReferencePaths::new(); + + for font in data + .font_settings + .custom_fonts + .iter() + .filter(|font| font.font_type == FontType::Local) + { + paths.collect(app_data_dir, font.local_path.as_ref()); + } + + collect_plugin_managed_asset_paths(app_data_dir, data, "fonts", &mut paths); + + paths +} + +pub(super) fn collect_local_image_paths( + app_data_dir: &Path, + data: &AppStoreData, +) -> AssetReferencePaths { + let mut paths = AssetReferencePaths::new(); + + for position in iter_all_positions(data) { + paths.collect(app_data_dir, position.active_image.as_ref()); + paths.collect(app_data_dir, position.inactive_image.as_ref()); + } + + collect_plugin_managed_asset_paths(app_data_dir, data, "images", &mut paths); + + paths +} + +#[cfg(test)] +pub(super) fn collect_local_image_path_keys( + app_data_dir: &Path, + data: &AppStoreData, +) -> HashSet { + collect_local_image_paths(app_data_dir, data).keys +} + +pub(super) fn collect_local_sound_paths( + app_data_dir: &Path, + data: &AppStoreData, +) -> AssetReferencePaths { + let mut paths = AssetReferencePaths::new(); + + for position in iter_all_positions(data) { + paths.collect(app_data_dir, position.sound_path.as_ref()); + } + + // 사운드 라이브러리에 등록된 파일도 보호 (키에 할당 안 되어도 유지) + for key in data.sound_library.keys() { + paths.collect(app_data_dir, Some(key)); + } + + paths +} + +#[cfg(test)] +pub(super) fn collect_local_sound_path_keys( + app_data_dir: &Path, + data: &AppStoreData, +) -> HashSet { + collect_local_sound_paths(app_data_dir, data).keys +} + +pub(super) fn iter_all_positions(data: &AppStoreData) -> impl Iterator { + data.key_positions + .values() + .flatten() + .chain( + data.stat_positions + .values() + .flatten() + .map(|position| &position.position), + ) + .chain( + data.graph_positions + .values() + .flatten() + .map(|position| &position.position), + ) + .chain( + data.knob_positions + .values() + .flatten() + .map(|position| &position.position), + ) +} + +enum LocalAssetPathResolution { + Path(PathBuf), + Unresolved, + Ignored, +} + +fn resolve_local_asset_path(app_data_dir: &Path, path: &str) -> LocalAssetPathResolution { + let trimmed = path.trim(); + if trimmed.is_empty() { + return LocalAssetPathResolution::Ignored; + } + + let lower = trimmed.to_ascii_lowercase(); + if lower.starts_with("http://") + || lower.starts_with("https://") + || lower.starts_with("data:") + || lower.starts_with("blob:") + || lower.starts_with("asset:") + || lower.starts_with("tauri:") + { + return LocalAssetPathResolution::Ignored; + } + + match file_url_to_path(trimmed) { + FileUrlPath::Path(path) => classify_absolute_asset_path(app_data_dir, trimmed, path), + FileUrlPath::Invalid => LocalAssetPathResolution::Unresolved, + FileUrlPath::NotFileUrl => { + let path = PathBuf::from(trimmed); + if path.is_absolute() { + classify_absolute_asset_path(app_data_dir, trimmed, path) + } else if looks_like_unresolved_local_path(trimmed) { + LocalAssetPathResolution::Unresolved + } else { + LocalAssetPathResolution::Ignored + } + } + } +} + +// 실존하지 않는 외래 참조는 해석 실패로 취급, sweep 보류 fail-safe 유도 +fn classify_absolute_asset_path( + app_data_dir: &Path, + raw: &str, + path: PathBuf, +) -> LocalAssetPathResolution { + if !path.exists() && is_foreign_portable_asset_reference(app_data_dir, raw) { + return LocalAssetPathResolution::Unresolved; + } + LocalAssetPathResolution::Path(path) +} + +fn looks_like_unresolved_local_path(value: &str) -> bool { + let bytes = value.as_bytes(); + let has_windows_drive = bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'; + has_windows_drive + || value.starts_with('\\') + || value.contains('/') + || value.contains('\\') + || Path::new(value).extension().is_some() +} diff --git a/src-tauri/src/state/store/persistence.rs b/src-tauri/src/state/store/persistence.rs new file mode 100644 index 000000000..b3484dd17 --- /dev/null +++ b/src-tauri/src/state/store/persistence.rs @@ -0,0 +1,237 @@ +use std::{ + path::{Path, PathBuf}, + sync::mpsc, + thread::{self, JoinHandle}, +}; + +use anyhow::{anyhow, Context, Result}; +use parking_lot::Mutex; + +use crate::models::AppStoreData; + +use super::super::atomic_file::atomic_replace; + +pub(super) struct PersistTicket { + revision: u64, + completion_rx: mpsc::Receiver, +} + +struct PersistCompletion { + revision: u64, + result: std::result::Result<(), String>, +} + +enum WriterMessage { + Persist { + revision: u64, + snapshot: Box, + completion_tx: mpsc::Sender, + #[cfg(test)] + force_failure: bool, + }, + Shutdown { + completion_tx: mpsc::Sender<()>, + }, +} + +pub(super) struct StoreWriter { + sender: Mutex>>, + handle: Mutex>>, + #[cfg(test)] + fail_next_persist: std::sync::atomic::AtomicBool, + #[cfg(test)] + persist_count: std::sync::atomic::AtomicUsize, +} + +impl PersistTicket { + pub(super) fn wait(self) -> Result<()> { + let completion = self + .completion_rx + .recv() + .with_context(|| format!("store writer stopped before revision {}", self.revision))?; + if completion.revision != self.revision { + return Err(anyhow!( + "store writer returned revision {} for requested revision {}", + completion.revision, + self.revision + )); + } + completion.result.map_err(anyhow::Error::msg) + } +} + +impl StoreWriter { + pub(super) fn start(path: PathBuf) -> Result { + let (sender, receiver) = mpsc::channel(); + let handle = thread::Builder::new() + .name("dmnote-store-writer".to_string()) + .spawn(move || run_store_writer(&path, receiver)) + .context("failed to start store writer")?; + Ok(Self { + sender: Mutex::new(Some(sender)), + handle: Mutex::new(Some(handle)), + #[cfg(test)] + fail_next_persist: std::sync::atomic::AtomicBool::new(false), + #[cfg(test)] + persist_count: std::sync::atomic::AtomicUsize::new(0), + }) + } + + pub(super) fn enqueue(&self, revision: u64, snapshot: AppStoreData) -> Result { + let (completion_tx, completion_rx) = mpsc::channel(); + let guard = self.sender.lock(); + let sender = guard.as_ref().context("store writer is shut down")?; + #[cfg(test)] + let force_failure = self + .fail_next_persist + .swap(false, std::sync::atomic::Ordering::SeqCst); + sender + .send(WriterMessage::Persist { + revision, + snapshot: Box::new(snapshot), + completion_tx, + #[cfg(test)] + force_failure, + }) + .with_context(|| format!("failed to enqueue store revision {revision}"))?; + #[cfg(test)] + self.persist_count + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(PersistTicket { + revision, + completion_rx, + }) + } + + #[cfg(test)] + pub(super) fn fail_next_persist(&self) { + self.fail_next_persist + .store(true, std::sync::atomic::Ordering::SeqCst); + } + + #[cfg(test)] + pub(super) fn persist_count(&self) -> usize { + self.persist_count.load(std::sync::atomic::Ordering::SeqCst) + } + + pub(super) fn shutdown(&self) -> Result<()> { + let sender = self.sender.lock().take(); + let Some(sender) = sender else { + return Ok(()); + }; + + let (completion_tx, completion_rx) = mpsc::channel(); + if let Err(err) = sender.send(WriterMessage::Shutdown { completion_tx }) { + drop(sender); + let join_result = self + .handle + .lock() + .take() + .map(|handle| { + handle + .join() + .map_err(|_| anyhow!("store writer thread panicked")) + }) + .transpose(); + join_result?; + return Err(anyhow!("failed to enqueue store writer shutdown: {err}")); + } + drop(sender); + let completion_result = completion_rx + .recv() + .context("store writer stopped before shutdown flush completed"); + let join_result = self + .handle + .lock() + .take() + .map(|handle| { + handle + .join() + .map_err(|_| anyhow!("store writer thread panicked")) + }) + .transpose(); + + completion_result?; + join_result?; + Ok(()) + } +} + +fn run_store_writer(path: &Path, receiver: mpsc::Receiver) { + while let Ok(message) = receiver.recv() { + match message { + WriterMessage::Persist { + revision, + snapshot, + completion_tx, + #[cfg(test)] + force_failure, + } => { + #[cfg(test)] + let result = if force_failure { + Err("injected store writer failure".to_string()) + } else { + write_store_snapshot(path, revision, snapshot.as_ref()) + .map_err(|err| format!("{err:#}")) + }; + #[cfg(not(test))] + let result = write_store_snapshot(path, revision, snapshot.as_ref()) + .map_err(|err| format!("{err:#}")); + let _ = completion_tx.send(PersistCompletion { revision, result }); + } + WriterMessage::Shutdown { completion_tx } => { + let _ = completion_tx.send(()); + break; + } + } + } +} + +fn write_store_snapshot(path: &Path, revision: u64, state: &AppStoreData) -> Result<()> { + let json = serialize_store(state)?; + atomic_replace(path, json.as_bytes(), &format!("revision-{revision}")) +} + +fn serialize_store(state: &AppStoreData) -> Result { + use serde_json::{to_value, Map, Value}; + + let mut root = to_value(state)?; + if let Value::Object(ref mut obj) = root { + let reorder = |value: &mut Value| { + if let Value::Object(current) = value { + let desired = ["4key", "5key", "6key", "8key"]; + let mut next = Map::new(); + for key in desired.iter() { + if let Some(value) = current.get(*key) { + next.insert((*key).to_string(), value.clone()); + } + } + let mut rest: Vec<(String, Value)> = current + .iter() + .filter(|(key, _)| !desired.contains(&key.as_str())) + .map(|(key, value)| (key.clone(), value.clone())) + .collect(); + rest.sort_by(|left, right| left.0.cmp(&right.0)); + for (key, value) in rest { + next.insert(key, value); + } + *value = Value::Object(next); + } + }; + + for field in [ + "keys", + "keyPositions", + "statPositions", + "graphPositions", + "knobPositions", + "keyCounters", + ] { + if let Some(value) = obj.get_mut(field) { + reorder(value); + } + } + } + + serde_json::to_string_pretty(&root).context("failed to serialize store") +} diff --git a/src-tauri/src/state/store/tests.rs b/src-tauri/src/state/store/tests.rs new file mode 100644 index 000000000..6aa2a588f --- /dev/null +++ b/src-tauri/src/state/store/tests.rs @@ -0,0 +1,18103 @@ +use super::{ + collect_local_font_paths, collect_local_image_path_keys, collect_local_image_paths, + collect_local_sound_path_keys, collect_local_sound_paths, initialize_default_state, + plugin_instances_storage_key, project_history_key_counters, purge_expired_trash_sessions_at, + recover_interrupted_processed_wav_replacements_with, recover_interrupted_sound_deletions_with, + stage_sound_files_for_deletion, sweep_unreferenced_asset_files, system_time_millis, AppStore, + AuxEditorResetTransactionOptions, AuxEditorTransactionOptions, HistoryAuxChange, + PluginInstancesResetScope, TrashSession, PROCESSED_WAV_TRANSACTION_LOCK, TRASH_RETENTION, +}; +use crate::{ + commands::keys::keys::reset_mode_data_for_test, + custom_css::ValidatedCssFile, + defaults::{default_keys, default_positions}, + errors::{EditorCommitError, EditorCommitErrorCode}, + keyboard::KeyboardManager, + models::{ + AppStoreData, CommittedEditorChange, CounterAnimationPreset, CounterAnimationSource, + CustomCss, CustomFont, CustomTab, EditorBoundsV1, EditorCommitOrigin, EditorCommitRequest, + EditorDocumentV1, EditorElementGroupTargetV1, EditorElementPropertyPatchV1, + EditorElementTypeV1, EditorField, EditorFrozenElementV1, EditorFrozenKeySlotV1, + EditorOpResultStatusV1, EditorOpResultV1, EditorOpV1, EditorPatchV1, EditorTargetGroupV1, + EditorZUpdateV1, FontSettings, FontType, GestureCommitRequest, + GesturePluginInstancesChange, GraphPosition, GraphStatType, GraphType, JsPlugin, + KeyCounters, KeyPosition, KeySlot, KnobPosition, LayerGroupDef, NoteColor, OverlayBounds, + PanelBounds, PendingProcessedWavReplacement, PluginInstancesCommitRequest, + PluginInstancesReconcileRequest, PluginPoint, SavedPluginInstance, SettingsPatchInput, + SlotMatch, SoundLibraryEntry, SoundSource, StatPosition, StatType, TabCss, TabNoteSettings, + EDITOR_COMMIT_SCHEMA_VERSION_V2, EDITOR_OPS_VERSION, EDITOR_SCHEMA_VERSION, + }, + services::{css_watcher::commit_css_reload, settings::apply_patch_to_store}, + state::{ + app_state::KeyCounterEventEmitter, + history::{ + HistoryDirection, HistoryScope, PresetFullHistorySnapshot, HISTORY_ENTRY_TOO_LARGE, + HISTORY_IN_PROGRESS, + }, + local_asset_path::path_identity_key, + AppState, + }, +}; +use serde_json::{json, Value}; +use std::{ + collections::{HashMap, HashSet}, + path::Path, + sync::{ + atomic::{AtomicBool, Ordering}, + mpsc, Arc, Barrier, Mutex, + }, + thread, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +fn test_directory(label: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("dmnote-{label}-{}", uuid::Uuid::new_v4())) +} + +fn regular_file_count(directory: &Path) -> usize { + std::fs::read_dir(directory) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_file())) + .count() +} + +fn initialize_neutral_editor_store(dir: &Path) -> AppStore { + std::fs::create_dir_all(dir).unwrap(); + let keys = default_keys() + .iter() + .map(|(mode, slots)| { + ( + mode.clone(), + slots + .iter() + .map(|slot| KeySlot::from(slot.canonical())) + .collect(), + ) + }) + .collect::(); + let key_positions = keys + .iter() + .map(|(mode, slots)| (mode.clone(), vec![KeyPosition::default(); slots.len()])) + .collect(); + let mut data = crate::state::migration::normalize_state(AppStoreData { + keys, + key_positions, + ..AppStoreData::default() + }); + crate::state::native_element_id::backfill_store_element_ids(&mut data); + let store = AppStore::new(dir.join("store.json"), data, false).unwrap(); + store.persist_current().unwrap(); + store +} + +#[test] +fn first_initialize_migrates_default_images_and_reopen_is_idempotent() { + let dir = test_directory("default-image-initialize"); + let images_dir = dir.join("images"); + + let first = AppStore::initialize_in_dir(&dir).unwrap(); + let first_snapshot = first.snapshot(); + let image_references = super::iter_all_positions(&first_snapshot) + .flat_map(|position| [&position.active_image, &position.inactive_image]) + .flatten() + .filter(|image| !image.is_empty()) + .collect::>(); + + assert!(!image_references.is_empty()); + assert!(image_references + .iter() + .all(|image| !image.starts_with("data:"))); + assert!(image_references + .iter() + .all(|image| Path::new(image.as_str()).is_file())); + for mode in first_snapshot.keys.keys() { + let stats = first_snapshot + .stat_positions + .get(mode) + .unwrap_or_else(|| panic!("missing initialized stats for {mode}")); + assert!(!stats.is_empty()); + assert!(stats.iter().all(|stat| { + crate::state::native_element_id::is_valid_element_id(&stat.position.id) + })); + } + let first_image_count = regular_file_count(&images_dir); + assert!(first_image_count > 0); + first.flush_and_shutdown().unwrap(); + drop(first); + + let second = AppStore::initialize_in_dir(&dir).unwrap(); + assert_eq!(regular_file_count(&images_dir), first_image_count); + assert!(super::iter_all_positions(&second.snapshot()) + .flat_map(|position| [&position.active_image, &position.inactive_image]) + .flatten() + .all(|image| !image.starts_with("data:"))); + second.flush_and_shutdown().unwrap(); + drop(second); + + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn runtime_publication_generation_mirror_tracks_committed_revision() { + let directory = test_directory("runtime-publication-generation-mirror"); + std::fs::create_dir_all(&directory).unwrap(); + let store = AppStore::initialize_for_test(&directory).unwrap(); + + assert_eq!( + store.runtime_publication_generation(), + store.state.read().revision + ); + let previous_revision = store.runtime_publication_generation(); + store + .update(|data| data.language = "generation-mirror-test".to_string()) + .unwrap(); + let committed_revision = store.state.read().revision; + assert!(committed_revision > previous_revision); + assert_eq!(store.runtime_publication_generation(), committed_revision); + + store.flush_and_shutdown().unwrap(); + drop(store); + std::fs::remove_dir_all(directory).unwrap(); +} + +struct TestCounterEmitter { + events: Arc>>, + mode: String, + key: String, + snapshot_emitted: Option>, + release_snapshot: Option>>, +} + +impl TestCounterEmitter { + fn new(events: Arc>>, mode: String, key: String) -> Self { + Self { + events, + mode, + key, + snapshot_emitted: None, + release_snapshot: None, + } + } + + fn blocking_snapshot( + events: Arc>>, + mode: String, + key: String, + snapshot_emitted: mpsc::Sender<()>, + release_snapshot: mpsc::Receiver<()>, + ) -> Self { + Self { + events, + mode, + key, + snapshot_emitted: Some(snapshot_emitted), + release_snapshot: Some(Mutex::new(release_snapshot)), + } + } +} + +impl KeyCounterEventEmitter for TestCounterEmitter { + fn emit_key_counters( + &self, + counters: &KeyCounters, + _session_id: &str, + _revision: u64, + ) -> anyhow::Result<()> { + let count = counters[&self.mode][&self.key]; + self.events + .lock() + .unwrap() + .push(format!("snapshot:{count}")); + if let Some(snapshot_emitted) = &self.snapshot_emitted { + snapshot_emitted.send(()).unwrap(); + } + if let Some(release_snapshot) = &self.release_snapshot { + release_snapshot.lock().unwrap().recv().unwrap(); + } + Ok(()) + } + + fn emit_key_counter( + &self, + _mode: &str, + _key: &str, + count: u32, + _session_id: &str, + _revision: u64, + ) -> anyhow::Result<()> { + self.events.lock().unwrap().push(format!("counter:{count}")); + Ok(()) + } +} + +fn editor_request( + base_revision: u64, + mutation_id: impl Into, + changes: EditorPatchV1, +) -> EditorCommitRequest { + EditorCommitRequest { + base_revision, + mutation_id: mutation_id.into(), + multi_key: false, + gesture_id: None, + gesture_ids: Vec::new(), + changes: Some(changes), + ops_version: None, + ops: None, + } +} + +fn editor_ops_request( + base_revision: u64, + mutation_id: impl Into, + ops: Vec, +) -> EditorCommitRequest { + EditorCommitRequest { + base_revision, + mutation_id: mutation_id.into(), + multi_key: false, + gesture_id: None, + gesture_ids: Vec::new(), + changes: None, + ops_version: Some(EDITOR_OPS_VERSION), + ops: Some(ops), + } +} + +fn bounds(position: &KeyPosition) -> EditorBoundsV1 { + EditorBoundsV1 { + dx: position.dx, + dy: position.dy, + width: position.width, + height: position.height, + } +} + +fn set_bounds_op( + element_type: EditorElementTypeV1, + id: impl Into, + bounds: EditorBoundsV1, +) -> EditorOpV1 { + EditorOpV1::SetBounds { + element_type, + id: id.into(), + bounds, + } +} + +fn delete_element_op(element_type: EditorElementTypeV1, id: impl Into) -> EditorOpV1 { + EditorOpV1::DeleteElement { + element_type, + id: id.into(), + } +} + +fn patch_hidden_op( + element_type: EditorElementTypeV1, + id: impl Into, + hidden: bool, +) -> EditorOpV1 { + EditorOpV1::PatchElement { + element_type, + id: id.into(), + patch: EditorElementPropertyPatchV1::Hidden(hidden), + } +} + +fn patch_layer_name_op( + element_type: EditorElementTypeV1, + id: impl Into, + layer_name: Option<&str>, +) -> EditorOpV1 { + EditorOpV1::PatchElement { + element_type, + id: id.into(), + patch: EditorElementPropertyPatchV1::LayerName(layer_name.map(str::to_string)), + } +} + +fn patch_graph_type_op(id: impl Into, graph_type: GraphType) -> EditorOpV1 { + EditorOpV1::PatchElement { + element_type: EditorElementTypeV1::Graph, + id: id.into(), + patch: EditorElementPropertyPatchV1::GraphType(graph_type), + } +} + +fn patch_graph_color_op(id: impl Into, graph_color: impl Into) -> EditorOpV1 { + EditorOpV1::PatchElement { + element_type: EditorElementTypeV1::Graph, + id: id.into(), + patch: EditorElementPropertyPatchV1::GraphColor(graph_color.into()), + } +} + +fn patch_property_op( + element_type: EditorElementTypeV1, + id: impl Into, + patch: EditorElementPropertyPatchV1, +) -> EditorOpV1 { + EditorOpV1::PatchElement { + element_type, + id: id.into(), + patch, + } +} + +fn paint_descriptor( + color: &str, + gradient: Option<(f64, &[(&str, f64)])>, +) -> crate::models::EditorPaintDescriptorV1 { + crate::models::EditorPaintDescriptorV1 { + color: color.to_string(), + gradient: gradient.map(|(angle, stops)| crate::models::EditorPaintGradientV1 { + angle, + stops: stops + .iter() + .map(|(color, pos)| crate::models::EditorPaintGradientStopV1 { + color: (*color).to_string(), + pos: *pos, + }) + .collect(), + }), + } +} + +fn counter_fill_solid(color: &str) -> crate::models::EditorCounterFillIntentV1 { + crate::models::EditorCounterFillIntentV1::Solid(crate::models::EditorCounterFillSolidIntentV1 { + color: color.to_string(), + }) +} + +fn counter_fill_gradient( + color: &str, + angle: f64, + stops: &[(&str, f64)], +) -> crate::models::EditorCounterFillIntentV1 { + crate::models::EditorCounterFillIntentV1::Gradient( + crate::models::EditorCounterFillGradientIntentV1 { + color: color.to_string(), + gradient: crate::models::EditorPaintGradientV1 { + angle, + stops: stops + .iter() + .map(|(color, pos)| crate::models::EditorPaintGradientStopV1 { + color: (*color).to_string(), + pos: *pos, + }) + .collect(), + }, + }, + ) +} + +fn shadow_leaf_blur(blur: f64) -> crate::models::EditorShadowLeafPatchV1 { + crate::models::EditorShadowLeafPatchV1::Blur(blur) +} + +fn frozen_key_insert_op(id: impl Into) -> EditorOpV1 { + EditorOpV1::InsertFrozenElements { + mode: "4key".to_string(), + elements: vec![EditorFrozenElementV1::Key { + slot: EditorFrozenKeySlotV1::Single("FROZEN".to_string()), + position: KeyPosition { + id: id.into(), + dx: 111.0, + dy: 222.0, + z_index: Some(100), + ..KeyPosition::default() + }, + }], + groups: Vec::new(), + z_updates: Vec::new(), + } +} + +fn complete_key_reorder_op(document: &EditorDocumentV1) -> EditorOpV1 { + EditorOpV1::ReorderElements { + mode: "4key".to_string(), + complete_mode_order: true, + z_updates: document.key_positions["4key"] + .iter() + .enumerate() + .map(|(index, position)| EditorZUpdateV1 { + element_type: EditorElementTypeV1::Key, + id: position.id.clone(), + z_index: 100 - index as i32, + }) + .collect(), + group_updates: Vec::new(), + } +} + +fn position_patch(store: &AppStore, dx: f64) -> EditorPatchV1 { + let mut positions = store.editor_get().document.key_positions; + positions.get_mut("4key").unwrap()[0].dx = dx; + EditorPatchV1 { + key_positions: Some(positions), + ..EditorPatchV1::default() + } +} + +fn saved_plugin_instance(x: f64) -> SavedPluginInstance { + SavedPluginInstance { + // x 파생 결정적 ID - 같은 x는 같은 인스턴스라는 동등성 단언 유지 + instance_id: Some(format!( + "00000000-0000-4000-8000-{:012x}", + (x * 1000.0) as u64 + )), + position: PluginPoint { x, y: 20.0 }, + settings: None, + measured_size: None, + tab_id: Some("4key".to_string()), + hidden: false, + z_index: None, + group_id: None, + } +} + +fn plugin_instances_request( + plugin_id: &str, + instances: Vec, + mutation_id: String, + gesture_id: Option, + expected_model_revision: Option, +) -> PluginInstancesCommitRequest { + PluginInstancesCommitRequest { + plugin_id: plugin_id.to_string(), + instances, + mutation_id, + gesture_id, + observed_history_epoch: None, + expected_model_revision, + authority_generation: 1, + } +} + +fn plugin_instances_reconcile_request( + plugin_id: &str, + valid_tab_ids: &[&str], + mutation_id: String, +) -> PluginInstancesReconcileRequest { + PluginInstancesReconcileRequest { + plugin_id: plugin_id.to_string(), + valid_tab_ids: valid_tab_ids + .iter() + .map(|tab_id| (*tab_id).to_string()) + .collect(), + mutation_id, + observed_history_epoch: None, + authority_generation: 1, + } +} + +#[test] +fn editor_reset_all_atomically_clears_only_plugin_instances() { + let dir = test_directory("editor-reset-all-plugin-instances-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + store + .commit_plugin_instances(plugin_instances_request( + "plugin-a", + vec![saved_plugin_instance(1.0)], + uuid::Uuid::new_v4().to_string(), + None, + Some(store.plugin_model_revision()), + )) + .unwrap(); + store + .set_plugin_data("plugin_data_plugin-a/settings", json!({ "theme": "dark" })) + .unwrap(); + store + .set_plugin_data("plugin_data_plugin-a/total", json!(37)) + .unwrap(); + let before_revision = store.plugin_model_revision(); + let before_epoch = store.history_status().history_epoch; + + let admission = store.admit_editor_mutation().unwrap(); + let transaction = store + .commit_legacy_editor_reset_transaction_with_admission( + EditorCommitOrigin::LegacyAdapter("reset-all-test".to_string()), + &[], + PluginInstancesResetScope::All, + admission, + |_| Ok(()), + ) + .unwrap(); + + assert!(store.plugin_instances_get("plugin-a").unwrap().0.is_empty()); + assert_eq!( + store + .get_plugin_data("plugin_data_plugin-a/settings") + .unwrap(), + Some(json!({ "theme": "dark" })) + ); + assert_eq!(store.plugin_model_revision(), before_revision + 1); + assert_eq!(store.history_status().history_epoch, before_epoch + 1); + assert_eq!( + transaction.change.plugin_instances_changes, + vec![crate::models::PluginInstancesChangedPayload { + plugin_id: "plugin-a".to_string(), + revision: before_revision + 1, + origin_mutation_id: None, + }] + ); + assert!(!transaction.change.history_status.as_ref().unwrap().can_undo); + assert_eq!( + store.get_plugin_data("plugin_data_plugin-a/total").unwrap(), + Some(json!(37)) + ); + assert_eq!( + store + .get_plugin_data("plugin_data_plugin-a/instances") + .unwrap(), + None + ); + + drop(transaction); + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn editor_reset_mode_filters_loaded_and_unloaded_plugin_instances_once() { + let dir = test_directory("editor-reset-mode-plugin-instances-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let four_key = saved_plugin_instance(1.0); + let mut five_key = saved_plugin_instance(2.0); + five_key.tab_id = Some("5key".to_string()); + for (plugin_id, instances) in [ + ("plugin-a", vec![four_key, five_key.clone()]), + ("plugin-b", vec![saved_plugin_instance(3.0)]), + ] { + store + .commit_plugin_instances(plugin_instances_request( + plugin_id, + instances, + uuid::Uuid::new_v4().to_string(), + None, + Some(store.plugin_model_revision()), + )) + .unwrap(); + } + let before_revision = store.plugin_model_revision(); + let before_epoch = store.history_status().history_epoch; + + let admission = store.admit_editor_mutation().unwrap(); + let transaction = store + .commit_legacy_editor_reset_transaction_with_admission( + EditorCommitOrigin::LegacyAdapter("reset-mode-test".to_string()), + &[ + EditorField::Keys, + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + EditorField::LayerGroups, + ], + PluginInstancesResetScope::Mode("4key".to_string()), + admission, + |data| { + reset_mode_data_for_test(data, "4key"); + Ok(()) + }, + ) + .unwrap(); + + assert_eq!( + store.plugin_instances_get("plugin-a").unwrap().0, + vec![five_key] + ); + assert!(store.plugin_instances_get("plugin-b").unwrap().0.is_empty()); + assert_eq!(store.plugin_model_revision(), before_revision + 1); + assert_eq!(store.history_status().history_epoch, before_epoch + 1); + assert!(!store.history_status().can_undo); + assert_eq!(transaction.change.plugin_instances_changes.len(), 2); + assert!(transaction + .change + .plugin_instances_changes + .iter() + .all(|change| change.revision == before_revision + 1)); + + drop(transaction); + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn editor_reset_mode_preserves_malformed_entries_and_skips_non_array_buckets() { + let dir = test_directory("editor-reset-mode-mixed-plugin-instances-test"); + std::fs::create_dir_all(&dir).unwrap(); + let mut data = initialize_default_state(); + let target_with_id = saved_plugin_instance(1.0); + let mut target_without_id = saved_plugin_instance(2.0); + target_without_id.instance_id = None; + let mut retained = saved_plugin_instance(3.0); + retained.tab_id = Some("5key".to_string()); + let retained_value = serde_json::to_value(&retained).unwrap(); + let malformed = json!({ "position": "broken", "keep": true }); + let non_array = json!({ "keep": "unchanged" }); + let key = super::plugin_instances_storage_key("plugin-a"); + let non_array_key = super::plugin_instances_storage_key("broken"); + data.plugin_data.insert( + key.clone(), + json!([ + serde_json::to_value(target_with_id).unwrap(), + serde_json::to_value(target_without_id).unwrap(), + retained_value.clone(), + malformed.clone() + ]), + ); + data.plugin_data + .insert(non_array_key.clone(), non_array.clone()); + let store = AppStore::new(dir.join("store.json"), data, false).unwrap(); + + let admission = store.admit_editor_mutation().unwrap(); + let transaction = store + .commit_legacy_editor_reset_transaction_with_admission( + EditorCommitOrigin::LegacyAdapter("reset-mode-mixed-test".to_string()), + &[], + PluginInstancesResetScope::Mode("4key".to_string()), + admission, + |_| Ok(()), + ) + .unwrap(); + + assert_eq!( + store.get_plugin_data(&key).unwrap(), + Some(json!([retained_value.clone(), malformed.clone()])) + ); + assert_eq!( + store.get_plugin_data(&non_array_key).unwrap(), + Some(non_array.clone()) + ); + assert_eq!( + store.plugin_instances_get("plugin-a").unwrap().0, + vec![retained] + ); + assert_eq!( + transaction + .change + .plugin_instances_changes + .iter() + .map(|change| change.plugin_id.as_str()) + .collect::>(), + vec!["plugin-a"] + ); + + drop(transaction); + store.flush_and_shutdown().unwrap(); + let reloaded = AppStore::initialize_in_dir(&dir).unwrap(); + assert_eq!( + reloaded.get_plugin_data(&key).unwrap(), + Some(json!([retained_value, malformed])) + ); + assert_eq!( + reloaded.get_plugin_data(&non_array_key).unwrap(), + Some(non_array) + ); + reloaded.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn editor_reset_missing_mode_does_not_touch_plugin_instances() { + let dir = test_directory("editor-reset-missing-mode-plugin-instances-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let mut ghost = saved_plugin_instance(1.0); + ghost.tab_id = Some("ghost".to_string()); + store + .commit_plugin_instances(plugin_instances_request( + "plugin-a", + vec![ghost.clone()], + uuid::Uuid::new_v4().to_string(), + None, + Some(store.plugin_model_revision()), + )) + .unwrap(); + let before_revision = store.plugin_model_revision(); + let before_epoch = store.history_status().history_epoch; + + let admission = store.admit_editor_mutation().unwrap(); + let transaction = store + .commit_legacy_editor_reset_transaction_with_admission( + EditorCommitOrigin::LegacyAdapter("reset-missing-mode-test".to_string()), + &[], + PluginInstancesResetScope::Mode("ghost".to_string()), + admission, + |_| Ok(None::<()>), + ) + .unwrap(); + + assert_eq!(transaction.value, None); + assert_eq!( + store.plugin_instances_get("plugin-a").unwrap().0, + vec![ghost] + ); + assert_eq!(store.plugin_model_revision(), before_revision); + assert_eq!(store.history_status().history_epoch, before_epoch); + assert!(transaction.change.plugin_instances_changes.is_empty()); + + drop(transaction); + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn editor_reset_noop_advances_epoch_without_erasing_unrelated_history() { + let dir = test_directory("editor-reset-noop-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let mut five_key = saved_plugin_instance(1.0); + five_key.tab_id = Some("5key".to_string()); + store + .commit_plugin_instances(plugin_instances_request( + "plugin-a", + vec![five_key.clone()], + uuid::Uuid::new_v4().to_string(), + None, + Some(store.plugin_model_revision()), + )) + .unwrap(); + let before = store.history_status(); + assert!(before.can_undo); + + let admission = store.admit_editor_mutation().unwrap(); + let transaction = store + .commit_legacy_editor_reset_transaction_with_admission( + EditorCommitOrigin::LegacyAdapter("reset-noop-test".to_string()), + &[ + EditorField::Keys, + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + EditorField::LayerGroups, + ], + PluginInstancesResetScope::Mode("4key".to_string()), + admission, + |data| { + assert!(!reset_mode_data_for_test(data, "4key")); + Ok(()) + }, + ) + .unwrap(); + + let after = transaction.change.history_status.unwrap(); + assert!(after.can_undo); + assert_eq!(after.history_epoch, before.history_epoch + 1); + assert_eq!( + store.plugin_instances_get("plugin-a").unwrap().0, + vec![five_key] + ); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn editor_reset_rejects_an_instance_save_captured_before_the_reset() { + let dir = test_directory("editor-reset-stale-plugin-save-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let instance = saved_plugin_instance(1.0); + store + .commit_plugin_instances(plugin_instances_request( + "plugin-a", + vec![instance.clone()], + uuid::Uuid::new_v4().to_string(), + None, + Some(store.plugin_model_revision()), + )) + .unwrap(); + let stale_epoch = store.history_status().history_epoch; + let mut stale_request = plugin_instances_request( + "plugin-a", + vec![instance], + uuid::Uuid::new_v4().to_string(), + None, + None, + ); + stale_request.observed_history_epoch = Some(stale_epoch); + + let admission = store.admit_editor_mutation().unwrap(); + store + .commit_legacy_editor_reset_transaction_with_admission( + EditorCommitOrigin::LegacyAdapter("reset-race-test".to_string()), + &[], + PluginInstancesResetScope::All, + admission, + |_| Ok(()), + ) + .unwrap(); + + assert_eq!( + store.commit_plugin_instances(stale_request).unwrap_err(), + "HISTORY_EPOCH_CONFLICT" + ); + assert!(store.plugin_instances_get("plugin-a").unwrap().0.is_empty()); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn canonical_plugin_bucket_preserves_legacy_outlier_while_editing_sibling() { + let dir = test_directory("legacy-plugin-outlier-canonical-test"); + std::fs::create_dir_all(&dir).unwrap(); + let mut data = initialize_default_state(); + let mut legacy = saved_plugin_instance(40_000.0); + let sibling = saved_plugin_instance(10.0); + data.plugin_data.insert( + super::plugin_instances_storage_key("plugin-a"), + serde_json::to_value(vec![legacy.clone(), sibling.clone()]).unwrap(), + ); + let store = AppStore::new(dir.join("store.json"), data, false).unwrap(); + + assert_eq!( + store.plugin_instances_get("plugin-a").unwrap().0, + vec![legacy.clone(), sibling.clone()] + ); + let mut edited_sibling = sibling; + edited_sibling.hidden = true; + store + .commit_plugin_instances(plugin_instances_request( + "plugin-a", + vec![legacy.clone(), edited_sibling.clone()], + uuid::Uuid::new_v4().to_string(), + None, + Some(store.plugin_model_revision()), + )) + .unwrap(); + assert_eq!( + store.plugin_instances_get("plugin-a").unwrap().0, + vec![legacy.clone(), edited_sibling] + ); + + legacy.position.x = 41_000.0; + assert_eq!( + store + .commit_plugin_instances(plugin_instances_request( + "plugin-a", + vec![legacy], + uuid::Uuid::new_v4().to_string(), + None, + Some(store.plugin_model_revision()), + )) + .unwrap_err(), + "INVALID_PLUGIN_INSTANCE_POSITION:0" + ); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn canonical_get_keeps_valid_instances_next_to_malformed_storage_entries() { + let dir = test_directory("lenient-canonical-plugin-read-test"); + std::fs::create_dir_all(&dir).unwrap(); + let mut data = initialize_default_state(); + let valid = saved_plugin_instance(10.0); + data.plugin_data.insert( + super::plugin_instances_storage_key("plugin-a"), + json!([ + serde_json::to_value(&valid).unwrap(), + { "position": "broken" } + ]), + ); + let store = AppStore::new(dir.join("store.json"), data, false).unwrap(); + + assert_eq!( + store.plugin_instances_get("plugin-a").unwrap().0, + vec![valid] + ); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +fn gesture_request( + store: &AppStore, + gesture_id: String, + editor_changes: EditorPatchV1, + plugin_id: &str, + instances: Vec, +) -> GestureCommitRequest { + GestureCommitRequest { + gesture_id, + mutation_id: uuid::Uuid::new_v4().to_string(), + editor_base_revision: store.editor_get().revision, + plugin_base_revision: store.plugin_model_revision(), + observed_history_epoch: Some(store.state.read().history.history_epoch()), + authority_generation: 1, + editor_changes: Some(editor_changes), + editor_ops_version: None, + editor_ops: None, + plugin_changes: vec![GesturePluginInstancesChange { + plugin_id: plugin_id.to_string(), + instances, + }], + } +} + +fn gesture_ops_request( + store: &AppStore, + gesture_id: String, + mutation_id: String, + ops: Vec, + plugin_id: &str, + instances: Vec, +) -> GestureCommitRequest { + GestureCommitRequest { + gesture_id, + mutation_id, + editor_base_revision: store.editor_get().revision, + plugin_base_revision: store.plugin_model_revision(), + observed_history_epoch: Some(store.state.read().history.history_epoch()), + authority_generation: 1, + editor_changes: None, + editor_ops_version: Some(EDITOR_OPS_VERSION), + editor_ops: Some(ops), + plugin_changes: vec![GesturePluginInstancesChange { + plugin_id: plugin_id.to_string(), + instances, + }], + } +} + +fn legacy_editor_commit( + store: &AppStore, + fields: &[EditorField], + updater: impl FnOnce(&mut AppStoreData), +) -> std::result::Result { + store + .commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("test_adapter".to_string()), + fields, + |data| { + updater(data); + Ok(()) + }, + ) + .map(|transaction| transaction.change) +} + +fn assert_counter_baseline_required(error: &EditorCommitError) { + assert_eq!(error.error_code, EditorCommitErrorCode::ValidationFailed); + assert_eq!( + error + .details + .as_ref() + .and_then(|details| details.validation_code.as_deref()), + Some("KEY_COUNTER_BASELINE_REQUIRED") + ); +} + +#[test] +fn editor_admission_without_runtime_counters_rejects_key_changes() { + let dir = test_directory("editor-counter-baseline-required-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let mut keys = store.snapshot().keys; + keys.get_mut("4key").unwrap()[0] = KeySlot::from("CounterBaselineEditorKey"); + let mut request = editor_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + keys: Some(keys), + ..EditorPatchV1::default() + }, + ); + request.multi_key = true; + let admission = store.admit_editor_mutation().unwrap(); + let error = store + .commit_editor_document_admitted(request, &admission) + .expect_err("key-changing editor commit must require live counters"); + assert_counter_baseline_required(&error); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn gesture_admission_without_runtime_counters_rejects_key_changes() { + let dir = test_directory("gesture-counter-baseline-required-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let mut keys = store.snapshot().keys; + keys.get_mut("4key").unwrap()[0] = KeySlot::from("CounterBaselineGestureKey"); + let request = gesture_request( + &store, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + keys: Some(keys), + ..EditorPatchV1::default() + }, + "demo-plugin", + Vec::new(), + ); + let admission = store.admit_editor_mutation().unwrap(); + let error = store + .commit_gesture_with_admission(request, admission) + .expect_err("key-changing gesture must require live counters"); + assert_counter_baseline_required(&error); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn aux_admission_without_runtime_counters_rejects_declared_keys() { + let dir = test_directory("aux-counter-baseline-required-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let admission = store.admit_editor_mutation().unwrap(); + let error = store + .commit_aux_editor_transaction_with_admission( + AuxEditorTransactionOptions { + scope: HistoryScope::CustomTabs, + observed_history_epoch: None, + origin: EditorCommitOrigin::LegacyAdapter( + "counter_baseline_required_test".to_string(), + ), + touched_fields: &[EditorField::Keys], + }, + admission, + |data| { + data.keys.get_mut("4key").unwrap()[0] = KeySlot::from("CounterBaselineAuxKey"); + Ok(()) + }, + ) + .expect_err("key-changing aux transaction must require live counters"); + assert_counter_baseline_required(&error); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn plugin_instances_commit_records_merges_and_skips_noop() { + let dir = test_directory("plugin-instances-commit-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_revision = store.plugin_model_revision(); + let gesture_id = uuid::Uuid::new_v4().to_string(); + + let first_request = plugin_instances_request( + "demo-plugin", + vec![saved_plugin_instance(10.0)], + uuid::Uuid::new_v4().to_string(), + Some(gesture_id.clone()), + Some(initial_revision), + ); + let first = store.commit_plugin_instances(first_request).unwrap(); + let first_revision = first.outcome.model_revision; + assert!(first.outcome.changed); + assert_eq!( + first + .outcome + .history_status + .as_ref() + .unwrap() + .history_revision, + 1 + ); + drop(first); + + let second_request = plugin_instances_request( + "demo-plugin", + vec![saved_plugin_instance(30.0)], + uuid::Uuid::new_v4().to_string(), + Some(gesture_id), + Some(first_revision), + ); + let second = store.commit_plugin_instances(second_request).unwrap(); + let second_revision = second.outcome.model_revision; + assert_eq!( + second + .outcome + .history_status + .as_ref() + .unwrap() + .history_revision, + 1 + ); + drop(second); + + let noop = store + .commit_plugin_instances(plugin_instances_request( + "demo-plugin", + vec![saved_plugin_instance(30.0)], + uuid::Uuid::new_v4().to_string(), + None, + Some(second_revision), + )) + .unwrap(); + assert!(!noop.outcome.changed); + assert!(noop.outcome.history_status.is_none()); + drop(noop); + + let (instances, revision) = store.plugin_instances_get("demo-plugin").unwrap(); + assert_eq!(instances, vec![saved_plugin_instance(30.0)]); + assert_eq!(revision, second_revision); + assert_eq!(store.history_status().history_revision, 1); + + let gate = store.history_gate(); + let counters = store.snapshot().key_counters; + let undo_id = uuid::Uuid::new_v4().to_string(); + let undo_barrier = gate.close(&undo_id).unwrap(); + let undo = store + .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) + .unwrap(); + assert!(store + .plugin_instances_get("demo-plugin") + .unwrap() + .0 + .is_empty()); + assert!(undo.status.can_redo); + drop(undo_barrier); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let redo_barrier = gate.close(&redo_id).unwrap(); + let redo = store + .apply_history_operation(HistoryDirection::Redo, &redo_id, &counters, || {}) + .unwrap(); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + vec![saved_plugin_instance(30.0)] + ); + assert!(redo.status.can_undo); + drop(redo_barrier); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn plugin_instance_visibility_and_z_index_survive_restart() { + let dir = test_directory("plugin-instance-layout-restart-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let mut expected = saved_plugin_instance(15.0); + expected.hidden = true; + expected.z_index = Some(-12.0); + let committed = store + .commit_plugin_instances(plugin_instances_request( + "demo-plugin", + vec![expected.clone()], + uuid::Uuid::new_v4().to_string(), + None, + Some(store.plugin_model_revision()), + )) + .unwrap(); + drop(committed); + store.flush_and_shutdown().unwrap(); + drop(store); + + let restored = AppStore::initialize_in_dir(&dir).unwrap(); + assert_eq!( + restored.plugin_instances_get("demo-plugin").unwrap().0, + vec![expected] + ); + + restored.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn plugin_instance_identity_fields_survive_commit_and_restart() { + let dir = test_directory("plugin-instance-identity-restart-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let mut expected = saved_plugin_instance(15.0); + expected.instance_id = Some(uuid::Uuid::new_v4().to_string()); + expected.group_id = Some("layer-group".to_string()); + let committed = store + .commit_plugin_instances(plugin_instances_request( + "demo-plugin", + vec![expected.clone()], + uuid::Uuid::new_v4().to_string(), + None, + Some(store.plugin_model_revision()), + )) + .unwrap(); + drop(committed); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + vec![expected.clone()] + ); + store.flush_and_shutdown().unwrap(); + drop(store); + + let restored = AppStore::initialize_in_dir(&dir).unwrap(); + assert_eq!( + restored.plugin_instances_get("demo-plugin").unwrap().0, + vec![expected] + ); + + restored.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn plugin_instances_allow_same_instance_id_across_plugins() { + // instance_id 유일성은 플러그인 단위 네임스페이스 - fullId가 pluginId로 + // 접두되므로 서로 다른 플러그인 간 같은 ID는 충돌하지 않는다 + let dir = test_directory("plugin-instance-cross-plugin-id-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let mut instance = saved_plugin_instance(10.0); + instance.instance_id = Some(uuid::Uuid::new_v4().to_string()); + + for plugin_id in ["demo-plugin", "other-plugin"] { + let committed = store + .commit_plugin_instances(plugin_instances_request( + plugin_id, + vec![instance.clone()], + uuid::Uuid::new_v4().to_string(), + None, + Some(store.plugin_model_revision()), + )) + .unwrap(); + assert!(committed.outcome.changed); + drop(committed); + assert_eq!( + store.plugin_instances_get(plugin_id).unwrap().0, + vec![instance.clone()] + ); + } + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn plugin_instances_history_budget_truncates_after_successful_commit() { + let dir = test_directory("plugin-instances-budget-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + store + .state + .write() + .history + .set_limits_for_test(1, 32 * 1024 * 1024, 50); + let expected_revision = store.plugin_model_revision(); + + let committed = store + .commit_plugin_instances(plugin_instances_request( + "demo-plugin", + vec![saved_plugin_instance(10.0)], + uuid::Uuid::new_v4().to_string(), + None, + Some(expected_revision), + )) + .unwrap(); + let status = committed.outcome.history_status.as_ref().unwrap(); + assert!(committed.outcome.changed); + assert!(!status.can_undo); + assert!(!status.can_redo); + assert_eq!( + status.truncated.as_ref().unwrap().reason, + HISTORY_ENTRY_TOO_LARGE + ); + drop(committed); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + vec![saved_plugin_instance(10.0)] + ); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn plugin_instances_undo_redo_restore_backend_without_runtime_projection() { + let dir = test_directory("plugin-instances-history-restore-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let expected_revision = store.plugin_model_revision(); + let committed = store + .commit_plugin_instances(plugin_instances_request( + "demo-plugin", + vec![saved_plugin_instance(42.0)], + uuid::Uuid::new_v4().to_string(), + None, + Some(expected_revision), + )) + .unwrap(); + drop(committed); + + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let undo_barrier = gate.close(&undo_id).unwrap(); + let counters = store.snapshot().key_counters; + let undo = store + .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) + .unwrap(); + let Some(HistoryAuxChange::PluginElements { + plugin_id, + revision: undo_revision, + }) = undo.aux_change + else { + panic!("plugin undo must expose its projection payload"); + }; + assert_eq!(plugin_id, "demo-plugin"); + assert_eq!(undo_revision, store.plugin_model_revision()); + assert!(store + .plugin_instances_get("demo-plugin") + .unwrap() + .0 + .is_empty()); + assert!(undo.status.can_redo); + drop(undo_barrier); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let redo_barrier = gate.close(&redo_id).unwrap(); + let redo = store + .apply_history_operation(HistoryDirection::Redo, &redo_id, &counters, || {}) + .unwrap(); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + vec![saved_plugin_instance(42.0)] + ); + assert!(redo.status.can_undo); + drop(redo_barrier); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn mixed_editor_plugin_gesture_undoes_and_redoes_atomically() { + let dir = test_directory("compound-editor-plugin-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_x = store.editor_get().document.key_positions["4key"][0].dx; + let gesture_id = uuid::Uuid::new_v4().to_string(); + + let mut editor = editor_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_x + 40.0), + ); + editor.gesture_id = Some(gesture_id.clone()); + store.commit_editor_document(editor).unwrap(); + let plugin = store + .commit_plugin_instances(plugin_instances_request( + "demo-plugin", + vec![saved_plugin_instance(42.0)], + uuid::Uuid::new_v4().to_string(), + Some(gesture_id.clone()), + Some(store.plugin_model_revision()), + )) + .unwrap(); + let first_plugin_revision = plugin.outcome.model_revision; + assert_eq!( + plugin + .outcome + .history_status + .as_ref() + .unwrap() + .history_revision, + 1 + ); + drop(plugin); + let second_plugin = store + .commit_plugin_instances(plugin_instances_request( + "second-plugin", + vec![saved_plugin_instance(84.0)], + uuid::Uuid::new_v4().to_string(), + Some(gesture_id), + Some(first_plugin_revision), + )) + .unwrap(); + assert_eq!( + second_plugin + .outcome + .history_status + .as_ref() + .unwrap() + .history_revision, + 1 + ); + drop(second_plugin); + + let gate = store.history_gate(); + let counters = store.snapshot().key_counters; + let undo_id = uuid::Uuid::new_v4().to_string(); + let undo_barrier = gate.close(&undo_id).unwrap(); + let undo = store + .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) + .unwrap(); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + initial_x + ); + assert!(store + .plugin_instances_get("demo-plugin") + .unwrap() + .0 + .is_empty()); + assert!(store + .plugin_instances_get("second-plugin") + .unwrap() + .0 + .is_empty()); + assert!(!undo.status.can_undo); + assert!(undo.status.can_redo); + assert!(undo.change.is_some()); + assert!(matches!( + undo.aux_change, + Some(HistoryAuxChange::PluginElementsBatch { ref plugin_ids, .. }) + if plugin_ids == &["demo-plugin".to_string(), "second-plugin".to_string()] + )); + drop(undo_barrier); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let redo_barrier = gate.close(&redo_id).unwrap(); + let redo = store + .apply_history_operation(HistoryDirection::Redo, &redo_id, &counters, || {}) + .unwrap(); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + initial_x + 40.0 + ); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + vec![saved_plugin_instance(42.0)] + ); + assert_eq!( + store.plugin_instances_get("second-plugin").unwrap().0, + vec![saved_plugin_instance(84.0)] + ); + assert!(redo.status.can_undo); + drop(redo_barrier); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn compound_history_projects_live_and_historical_key_counters() { + let dir = test_directory("compound-history-counter-scope-test"); + let store = initialize_neutral_editor_store(&dir); + let before = store.snapshot(); + let old_key = before.keys["4key"][0].canonical(); + let preserved_key = before.keys["4key"][1].canonical(); + let mut historical_counters = before.key_counters.clone(); + historical_counters + .get_mut("4key") + .unwrap() + .insert(old_key.clone(), 12); + let new_key = "CompoundHistoryCounterScopeKey".to_string(); + let mut keys = before.keys; + keys.get_mut("4key").unwrap()[0] = new_key.clone().into(); + let gesture_id = uuid::Uuid::new_v4().to_string(); + let mut editor = editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + keys: Some(keys), + ..EditorPatchV1::default() + }, + ); + editor.gesture_id = Some(gesture_id.clone()); + let admission = store.admit_editor_mutation().unwrap(); + store + .commit_editor_document_with_runtime_counters_admitted( + editor, + &admission, + &historical_counters, + ) + .unwrap(); + drop(admission); + let plugin = store + .commit_plugin_instances(plugin_instances_request( + "demo-plugin", + vec![saved_plugin_instance(42.0)], + uuid::Uuid::new_v4().to_string(), + Some(gesture_id), + Some(store.plugin_model_revision()), + )) + .unwrap(); + drop(plugin); + + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let undo_barrier = gate.close(&undo_id).unwrap(); + let mut undo_counters = store.snapshot().key_counters; + undo_counters + .get_mut("4key") + .unwrap() + .insert(new_key.clone(), 9); + undo_counters + .get_mut("4key") + .unwrap() + .insert(preserved_key.clone(), 7); + let undo = store + .apply_history_operation(HistoryDirection::Undo, &undo_id, &undo_counters, || {}) + .unwrap(); + let restored = store.snapshot(); + assert_eq!(restored.keys["4key"][0].canonical(), old_key); + assert_eq!(restored.key_counters["4key"][&old_key], 12); + assert_eq!(restored.key_counters["4key"][&preserved_key], 7); + assert!(!restored.key_counters["4key"].contains_key(&new_key)); + assert!(store + .plugin_instances_get("demo-plugin") + .unwrap() + .0 + .is_empty()); + assert!(undo + .change + .as_ref() + .is_some_and(|change| change.result.changed_fields.contains(&EditorField::Keys))); + assert!(matches!( + undo.aux_change, + Some(HistoryAuxChange::PluginElementsBatch { .. }) + )); + drop(undo_barrier); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let redo_barrier = gate.close(&redo_id).unwrap(); + let mut redo_counters = restored.key_counters; + redo_counters + .get_mut("4key") + .unwrap() + .insert(old_key.clone(), 5); + redo_counters + .get_mut("4key") + .unwrap() + .insert(preserved_key.clone(), 8); + store + .apply_history_operation(HistoryDirection::Redo, &redo_id, &redo_counters, || {}) + .unwrap(); + let redone = store.snapshot(); + assert_eq!(redone.keys["4key"][0].canonical(), new_key); + assert_eq!(redone.key_counters["4key"][&new_key], 9); + assert_eq!(redone.key_counters["4key"][&preserved_key], 8); + assert!(!redone.key_counters["4key"].contains_key(&old_key)); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + vec![saved_plugin_instance(42.0)] + ); + drop(redo_barrier); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn plugin_only_cross_plugin_gesture_merges_into_one_undo_step() { + let dir = test_directory("cross-plugin-gesture-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let gesture_id = uuid::Uuid::new_v4().to_string(); + + // editor 선행 커밋 없이 plugin 커밋 2건만 같은 gestureId로 순차 도착 + let first = store + .commit_plugin_instances(plugin_instances_request( + "demo-plugin", + vec![saved_plugin_instance(42.0)], + uuid::Uuid::new_v4().to_string(), + Some(gesture_id.clone()), + Some(store.plugin_model_revision()), + )) + .unwrap(); + let first_revision = first.outcome.model_revision; + assert_eq!( + first + .outcome + .history_status + .as_ref() + .unwrap() + .history_revision, + 1 + ); + drop(first); + let second = store + .commit_plugin_instances(plugin_instances_request( + "second-plugin", + vec![saved_plugin_instance(84.0)], + uuid::Uuid::new_v4().to_string(), + Some(gesture_id), + Some(first_revision), + )) + .unwrap(); + // 크로스 플러그인 병합 - 히스토리 엔트리는 한 줄 유지 + assert_eq!( + second + .outcome + .history_status + .as_ref() + .unwrap() + .history_revision, + 1 + ); + drop(second); + + let gate = store.history_gate(); + let counters = store.snapshot().key_counters; + let undo_id = uuid::Uuid::new_v4().to_string(); + let undo_barrier = gate.close(&undo_id).unwrap(); + let undo = store + .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) + .unwrap(); + assert!(store + .plugin_instances_get("demo-plugin") + .unwrap() + .0 + .is_empty()); + assert!(store + .plugin_instances_get("second-plugin") + .unwrap() + .0 + .is_empty()); + assert!(!undo.status.can_undo); + assert!(undo.status.can_redo); + assert!(matches!( + undo.aux_change, + Some(HistoryAuxChange::PluginElementsBatch { ref plugin_ids, .. }) + if plugin_ids == &["demo-plugin".to_string(), "second-plugin".to_string()] + )); + drop(undo_barrier); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let redo_barrier = gate.close(&redo_id).unwrap(); + let redo = store + .apply_history_operation(HistoryDirection::Redo, &redo_id, &counters, || {}) + .unwrap(); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + vec![saved_plugin_instance(42.0)] + ); + assert_eq!( + store.plugin_instances_get("second-plugin").unwrap().0, + vec![saved_plugin_instance(84.0)] + ); + assert!(redo.status.can_undo); + drop(redo_barrier); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +fn empty_targets_group_create_op(group_id: &str, name: &str) -> EditorOpV1 { + EditorOpV1::SetElementGroups { + mode: "4key".to_string(), + targets: Vec::new(), + target_group: Some(crate::models::EditorTargetGroupV1::Create { + id: group_id.to_string(), + name: name.to_string(), + }), + } +} + +#[test] +fn plugin_only_group_survives_gesture_and_editor_normalize() { + let dir = test_directory("plugin-group-survival-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + + // gesture 동봉: 빈 native targets op가 def를 만들고, 생존 판정은 + // 요청 동봉 plugin_changes의 group_id를 쓴다 (store 값은 아직 무소속) + let mut grouped = saved_plugin_instance(10.0); + grouped.group_id = Some("plugin-group".to_string()); + let committed = store + .commit_gesture(gesture_ops_request( + &store, + uuid::Uuid::new_v4().to_string(), + uuid::Uuid::new_v4().to_string(), + vec![empty_targets_group_create_op( + "plugin-group", + "Plugin Group", + )], + "demo-plugin", + vec![grouped.clone()], + )) + .unwrap(); + assert_eq!( + committed.outcome.result.changed_fields, + vec![EditorField::LayerGroups] + ); + drop(committed); + assert_eq!( + store.editor_get().document.layer_groups["4key"], + vec![crate::models::LayerGroupDef { + id: "plugin-group".to_string(), + name: "Plugin Group".to_string(), + }] + ); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + vec![grouped.clone()] + ); + + // editor 단독 commit: normalize가 store 인스턴스 decode의 group_id + // 참조로 def를 보존한다 (플러그인만 남은 그룹 생존) + let victim_id = store.editor_get().document.key_positions["4key"][0] + .id + .clone(); + store + .commit_editor_document(editor_ops_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + vec![EditorOpV1::DeleteElement { + element_type: EditorElementTypeV1::Key, + id: victim_id, + }], + )) + .unwrap(); + assert_eq!( + store.editor_get().document.layer_groups["4key"], + vec![crate::models::LayerGroupDef { + id: "plugin-group".to_string(), + name: "Plugin Group".to_string(), + }] + ); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn gesture_group_membership_undo_restores_editor_and_plugin_atomically() { + let dir = test_directory("plugin-group-compound-undo-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + + // 선행: 무소속 인스턴스 저장 (히스토리 1) + let seeded = store + .commit_plugin_instances(plugin_instances_request( + "demo-plugin", + vec![saved_plugin_instance(10.0)], + uuid::Uuid::new_v4().to_string(), + None, + Some(store.plugin_model_revision()), + )) + .unwrap(); + drop(seeded); + + // 그룹화: def 생성 + 플러그인 소속 부여를 같은 gestureId 커밋에 결합 + let mut grouped = saved_plugin_instance(10.0); + grouped.group_id = Some("plugin-group".to_string()); + let committed = store + .commit_gesture(gesture_ops_request( + &store, + uuid::Uuid::new_v4().to_string(), + uuid::Uuid::new_v4().to_string(), + vec![empty_targets_group_create_op( + "plugin-group", + "Plugin Group", + )], + "demo-plugin", + vec![grouped.clone()], + )) + .unwrap(); + drop(committed); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + vec![grouped.clone()] + ); + + // undo 1회로 layerGroups와 plugin group_id가 원자 복원 + let gate = store.history_gate(); + let counters = store.snapshot().key_counters; + let undo_id = uuid::Uuid::new_v4().to_string(); + let undo_barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) + .unwrap(); + drop(undo_barrier); + assert!(store + .editor_get() + .document + .layer_groups + .get("4key") + .is_none_or(|groups| groups.is_empty())); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + vec![saved_plugin_instance(10.0)] + ); + + // redo 1회로 동반 복귀 + let redo_id = uuid::Uuid::new_v4().to_string(); + let redo_barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Redo, &redo_id, &counters, || {}) + .unwrap(); + drop(redo_barrier); + assert_eq!( + store.editor_get().document.layer_groups["4key"], + vec![crate::models::LayerGroupDef { + id: "plugin-group".to_string(), + name: "Plugin Group".to_string(), + }] + ); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + vec![grouped] + ); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn dangling_plugin_group_id_neither_blocks_commits_nor_resurrects_groups() { + let dir = test_directory("plugin-group-dangling-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + + // def 없는 group_id 저장 수용 (그룹 존재 검증은 도메인 결합 회피로 미수행) + let mut dangling = saved_plugin_instance(10.0); + dangling.group_id = Some("missing-group".to_string()); + let committed = store + .commit_plugin_instances(plugin_instances_request( + "demo-plugin", + vec![dangling.clone()], + uuid::Uuid::new_v4().to_string(), + None, + Some(store.plugin_model_revision()), + )) + .unwrap(); + drop(committed); + + // normalize 경유 editor 커밋이 막히지 않고, 없는 def를 만들지도 않는다 + let victim_id = store.editor_get().document.key_positions["4key"][0] + .id + .clone(); + store + .commit_editor_document(editor_ops_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + vec![EditorOpV1::DeleteElement { + element_type: EditorElementTypeV1::Key, + id: victim_id, + }], + )) + .unwrap(); + assert!(store + .editor_get() + .document + .layer_groups + .get("4key") + .is_none_or(|groups| groups.is_empty())); + // dangling group_id는 읽기 가드가 무해화 - 저장 값은 보존 + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + vec![dangling] + ); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn plugin_group_refs_by_plugin_includes_unloaded_data_and_skips_broken_keys() { + let dir = test_directory("plugin-group-refs-by-plugin-test"); + std::fs::create_dir_all(&dir).unwrap(); + let mut data = initialize_default_state(); + + // 미로드 플러그인 시나리오 - 런타임 등록 없이 저장 데이터만 존재. + // tab 미지정 인스턴스는 4key로 normalize + let mut first = saved_plugin_instance(10.0); + first.group_id = Some("group-a".to_string()); + let mut second = saved_plugin_instance(20.0); + second.tab_id = None; + second.group_id = Some("group-b".to_string()); + let mut third = saved_plugin_instance(30.0); + third.tab_id = Some("6key".to_string()); + third.group_id = Some("group-c".to_string()); + let ungrouped = saved_plugin_instance(40.0); + data.plugin_data.insert( + super::plugin_instances_storage_key("grouped-plugin"), + serde_json::to_value(vec![first, second, third, ungrouped]).unwrap(), + ); + // 그룹 참조 없는 플러그인은 결과에서 제외 + data.plugin_data.insert( + super::plugin_instances_storage_key("plain-plugin"), + serde_json::to_value(vec![saved_plugin_instance(50.0)]).unwrap(), + ); + // 형태가 깨진 인스턴스 키는 집계만 skip + data.plugin_data.insert( + super::plugin_instances_storage_key("broken-plugin"), + json!({ "not": "an array" }), + ); + // 인스턴스 예약 키가 아닌 plugin_data는 무시 + data.plugin_data.insert( + "plugin_data_grouped-plugin/settings".to_string(), + json!({ "groupId": "not-a-ref" }), + ); + let store = AppStore::new(dir.join("store.json"), data, false).unwrap(); + + let (refs, model_revision) = store.plugin_group_refs_by_plugin(); + assert_eq!(model_revision, store.plugin_model_revision()); + assert_eq!(refs.len(), 1); + let grouped = &refs["grouped-plugin"]; + assert_eq!( + grouped["4key"], + HashSet::from(["group-a".to_string(), "group-b".to_string()]) + ); + assert_eq!(grouped["6key"], HashSet::from(["group-c".to_string()])); + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn gesture_transactions_keep_rejoined_actions_atomic_and_ordered() { + let dir = test_directory("gesture-transaction-rejoined-actions-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_x = store.editor_get().document.key_positions["4key"][0].dx; + let persist_count = store.writer.persist_count(); + + let first = store + .commit_gesture(gesture_request( + &store, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_x + 10.0), + "demo-plugin", + vec![saved_plugin_instance(10.0)], + )) + .unwrap(); + assert_eq!(first.outcome.result.editor_revision, 1); + assert_eq!(first.outcome.result.plugin_model_revision, 1); + assert!(first.outcome.result.editor_op_results.is_none()); + drop(first); + + let second = store + .commit_gesture(gesture_request( + &store, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_x + 20.0), + "demo-plugin", + vec![saved_plugin_instance(20.0)], + )) + .unwrap(); + assert_eq!(second.outcome.result.editor_revision, 2); + assert_eq!(second.outcome.result.plugin_model_revision, 2); + assert_eq!(store.writer.persist_count(), persist_count + 2); + assert_eq!(store.history_status().history_revision, 2); + drop(second); + + let gate = store.history_gate(); + let counters = store.snapshot().key_counters; + for (expected_editor_x, expected_plugin_x) in + [(initial_x + 10.0, Some(10.0)), (initial_x, None)] + { + let operation_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&operation_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Undo, &operation_id, &counters, || {}) + .unwrap(); + drop(barrier); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + expected_editor_x + ); + let plugin = store.plugin_instances_get("demo-plugin").unwrap().0; + assert_eq!( + plugin.first().map(|instance| instance.position.x), + expected_plugin_x + ); + } + + for (expected_editor_x, expected_plugin_x) in + [(initial_x + 10.0, 10.0), (initial_x + 20.0, 20.0)] + { + let operation_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&operation_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Redo, &operation_id, &counters, || {}) + .unwrap(); + drop(barrier); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + expected_editor_x + ); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0[0] + .position + .x, + expected_plugin_x + ); + } + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn plugin_only_gesture_keeps_the_existing_editor_neutral_contract() { + let dir = test_directory("gesture-plugin-only-contract-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let editor_before = store.editor_get(); + let mut request = gesture_request( + &store, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1::default(), + "demo-plugin", + vec![saved_plugin_instance(15.0)], + ); + request.editor_changes = None; + + let committed = store.commit_gesture(request).unwrap(); + assert_eq!( + committed.outcome.result.editor_revision, + editor_before.revision + ); + assert!(committed.outcome.result.changed_fields.is_empty()); + assert!(committed.outcome.result.editor_op_results.is_none()); + assert!(committed.outcome.change.is_none()); + assert_eq!(committed.outcome.result.plugin_model_revision, 1); + assert_eq!(committed.outcome.result.changed_plugin_ids, ["demo-plugin"]); + drop(committed); + assert_eq!(store.editor_get(), editor_before); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + vec![saved_plugin_instance(15.0)] + ); + assert_eq!(store.history_status().history_revision, 1); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn mixed_editor_op_and_plugin_gesture_replays_and_round_trips_atomically() { + let dir = test_directory("gesture-editor-op-plugin-atomic-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let target = store.editor_get().document.key_positions["4key"][0].clone(); + let initial_bounds = bounds(&target); + let changed_bounds = EditorBoundsV1 { + dx: initial_bounds.dx + 40.0, + ..initial_bounds + }; + let mutation_id = uuid::Uuid::new_v4().to_string(); + let request = gesture_ops_request( + &store, + uuid::Uuid::new_v4().to_string(), + mutation_id.clone(), + vec![set_bounds_op( + EditorElementTypeV1::Key, + &target.id, + changed_bounds, + )], + "demo-plugin", + vec![saved_plugin_instance(42.0)], + ); + let persist_count = store.writer.persist_count(); + + let committed = store.commit_gesture(request.clone()).unwrap(); + assert_eq!(committed.outcome.result.editor_revision, 1); + assert_eq!(committed.outcome.result.plugin_model_revision, 1); + assert_eq!( + committed.outcome.result.editor_op_results, + Some(vec![EditorOpResultV1 { + status: EditorOpResultStatusV1::Applied, + bounds: Some(changed_bounds), + }]) + ); + let change = committed.outcome.change.as_ref().unwrap(); + assert_eq!( + change.result.op_results, + committed.outcome.result.editor_op_results + ); + assert_eq!( + change.event.as_ref().unwrap().patch.schema_version, + EDITOR_SCHEMA_VERSION + ); + assert!(change.event.as_ref().unwrap().patch.key_positions.is_some()); + assert_eq!(store.writer.persist_count(), persist_count + 1); + assert_eq!(store.history_status().history_revision, 1); + drop(committed); + assert_eq!( + bounds( + store.editor_get().document.key_positions["4key"] + .iter() + .find(|position| position.id == target.id) + .unwrap() + ), + changed_bounds + ); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + vec![saved_plugin_instance(42.0)] + ); + + let replay = store.commit_gesture(request.clone()).unwrap(); + assert!(replay.outcome.replayed); + assert_eq!( + replay.outcome.result.editor_op_results, + Some(vec![EditorOpResultV1 { + status: EditorOpResultStatusV1::Applied, + bounds: Some(changed_bounds), + }]) + ); + assert!(replay.outcome.change.is_none()); + assert_eq!(store.writer.persist_count(), persist_count + 1); + drop(replay); + + let mut reused = request; + reused.editor_ops = Some(vec![set_bounds_op( + EditorElementTypeV1::Key, + &target.id, + EditorBoundsV1 { + dx: changed_bounds.dx + 1.0, + ..changed_bounds + }, + )]); + assert_eq!( + store.commit_gesture(reused).unwrap_err().error_code, + EditorCommitErrorCode::MutationIdReused + ); + + let gate = store.history_gate(); + let counters = store.snapshot().key_counters; + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) + .unwrap(); + drop(barrier); + assert_eq!( + bounds( + store.editor_get().document.key_positions["4key"] + .iter() + .find(|position| position.id == target.id) + .unwrap() + ), + initial_bounds + ); + assert!(store + .plugin_instances_get("demo-plugin") + .unwrap() + .0 + .is_empty()); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Redo, &redo_id, &counters, || {}) + .unwrap(); + drop(barrier); + assert_eq!( + bounds( + store.editor_get().document.key_positions["4key"] + .iter() + .find(|position| position.id == target.id) + .unwrap() + ), + changed_bounds + ); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + vec![saved_plugin_instance(42.0)] + ); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn mixed_editor_op_validation_and_persist_failures_leave_both_scopes_unchanged() { + let dir = test_directory("gesture-editor-op-plugin-failure-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let target = store.editor_get().document.key_positions["4key"][0].clone(); + let changed_bounds = EditorBoundsV1 { + dx: target.dx + 25.0, + ..bounds(&target) + }; + let before = store.snapshot(); + let before_plugin_revision = store.plugin_model_revision(); + let before_history_revision = store.history_status().history_revision; + let mut invalid_plugin = saved_plugin_instance(25.0); + invalid_plugin.position.x = f64::NAN; + + let error = store + .commit_gesture(gesture_ops_request( + &store, + uuid::Uuid::new_v4().to_string(), + uuid::Uuid::new_v4().to_string(), + vec![set_bounds_op( + EditorElementTypeV1::Stat, + &target.id, + changed_bounds, + )], + "demo-plugin", + vec![saved_plugin_instance(25.0)], + )) + .unwrap_err(); + assert_eq!(error.error_code, EditorCommitErrorCode::ValidationFailed); + assert_eq!( + error + .details + .and_then(|details| details.validation_code) + .as_deref(), + Some("ELEMENT_TYPE_MISMATCH") + ); + assert_eq!(store.snapshot(), before); + + let error = store + .commit_gesture(gesture_ops_request( + &store, + uuid::Uuid::new_v4().to_string(), + uuid::Uuid::new_v4().to_string(), + vec![set_bounds_op( + EditorElementTypeV1::Key, + &target.id, + changed_bounds, + )], + "demo-plugin", + vec![invalid_plugin], + )) + .unwrap_err(); + assert_eq!(error.error_code, EditorCommitErrorCode::ValidationFailed); + assert_eq!(store.snapshot(), before); + + store.writer.fail_next_persist(); + let error = store + .commit_gesture(gesture_ops_request( + &store, + uuid::Uuid::new_v4().to_string(), + uuid::Uuid::new_v4().to_string(), + vec![set_bounds_op( + EditorElementTypeV1::Key, + &target.id, + changed_bounds, + )], + "demo-plugin", + vec![saved_plugin_instance(25.0)], + )) + .unwrap_err(); + assert_eq!(error.error_code, EditorCommitErrorCode::IoError); + assert_eq!(store.snapshot(), before); + assert_eq!(store.plugin_model_revision(), before_plugin_revision); + assert_eq!( + store.history_status().history_revision, + before_history_revision + ); + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn frozen_insert_commits_replays_and_round_trips_with_snapshot_history() { + let dir = test_directory("frozen-insert-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let before = store.editor_get().document; + let inserted_id = uuid::Uuid::new_v4().to_string(); + let mutation_id = uuid::Uuid::new_v4().to_string(); + let request = editor_ops_request( + 0, + mutation_id.clone(), + vec![frozen_key_insert_op(&inserted_id)], + ); + let persist_count = store.writer.persist_count(); + + let committed = store.commit_editor_document(request.clone()).unwrap(); + assert_eq!( + committed.result.op_results, + Some(vec![EditorOpResultV1 { + status: EditorOpResultStatusV1::Applied, + bounds: None, + }]) + ); + assert_eq!( + committed.result.changed_fields, + [EditorField::Keys, EditorField::KeyPositions] + ); + assert_eq!(store.writer.persist_count(), persist_count + 1); + assert_eq!(store.history_status().history_revision, 1); + drop(committed); + + let replay = store.commit_editor_document(request).unwrap(); + assert!(replay.replayed); + assert_eq!(store.writer.persist_count(), persist_count + 1); + assert_eq!( + replay.result.op_results.unwrap()[0].status, + EditorOpResultStatusV1::Applied + ); + + let counters = store.snapshot().key_counters; + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) + .unwrap(); + drop(barrier); + assert_eq!(store.editor_get().document, before); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Redo, &redo_id, &counters, || {}) + .unwrap(); + drop(barrier); + assert!(store.editor_get().document.key_positions["4key"] + .iter() + .any(|position| position.id == inserted_id)); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn mixed_frozen_insert_and_plugin_change_are_atomic_on_persist_failure() { + let dir = test_directory("mixed-frozen-insert-plugin-atomic-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let before = store.snapshot(); + let plugin_revision = store.plugin_model_revision(); + let history_revision = store.history_status().history_revision; + let inserted_id = uuid::Uuid::new_v4().to_string(); + let request = gesture_ops_request( + &store, + uuid::Uuid::new_v4().to_string(), + uuid::Uuid::new_v4().to_string(), + vec![frozen_key_insert_op(&inserted_id)], + "demo-plugin", + vec![saved_plugin_instance(55.0)], + ); + + store.writer.fail_next_persist(); + assert_eq!( + store + .commit_gesture(request.clone()) + .unwrap_err() + .error_code, + EditorCommitErrorCode::IoError + ); + assert_eq!(store.snapshot(), before); + assert_eq!(store.plugin_model_revision(), plugin_revision); + assert_eq!(store.history_status().history_revision, history_revision); + + let committed = store.commit_gesture(request).unwrap(); + assert_eq!(committed.outcome.result.editor_revision, 1); + assert_eq!(committed.outcome.result.plugin_model_revision, 1); + assert_eq!(committed.outcome.result.changed_plugin_ids, ["demo-plugin"]); + assert_eq!(store.history_status().history_revision, 1); + drop(committed); + assert!(store.editor_get().document.key_positions["4key"] + .iter() + .any(|position| position.id == inserted_id)); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + vec![saved_plugin_instance(55.0)] + ); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn frozen_insert_z_only_no_change_keeps_revision_history_and_persist_count() { + let dir = test_directory("frozen-insert-z-no-change-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let target = store.editor_get().document.key_positions["4key"][0].clone(); + let request = editor_ops_request( + 0, + uuid::Uuid::new_v4().to_string(), + vec![EditorOpV1::InsertFrozenElements { + mode: "4key".to_string(), + elements: Vec::new(), + groups: Vec::new(), + z_updates: vec![crate::models::EditorZUpdateV1 { + element_type: EditorElementTypeV1::Key, + id: target.id, + z_index: target.z_index.unwrap_or_default(), + }], + }], + ); + let persist_count = store.writer.persist_count(); + + let result = store.commit_editor_document(request).unwrap(); + assert_eq!(result.result.revision, 0); + assert!(result.result.changed_fields.is_empty()); + assert_eq!( + result.result.op_results.unwrap()[0].status, + EditorOpResultStatusV1::NoChange + ); + assert_eq!(store.writer.persist_count(), persist_count); + assert_eq!(store.history_status().history_revision, 0); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn reorder_commits_replays_and_round_trips_with_snapshot_history() { + let dir = test_directory("reorder-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = initialize_neutral_editor_store(&dir); + let initial = store.editor_get(); + let before_revision = initial.revision; + let before = initial.document; + let op = complete_key_reorder_op(&before); + let request = editor_ops_request( + before_revision, + uuid::Uuid::new_v4().to_string(), + vec![op.clone()], + ); + let persist_count = store.writer.persist_count(); + + let committed = store.commit_editor_document(request.clone()).unwrap(); + assert_eq!( + committed.result.op_results, + Some(vec![EditorOpResultV1 { + status: EditorOpResultStatusV1::Applied, + bounds: None, + }]) + ); + assert_eq!(committed.result.changed_fields, [EditorField::KeyPositions]); + assert_eq!( + committed + .event + .as_ref() + .unwrap() + .patch + .key_positions + .as_ref() + .unwrap()["4key"][0] + .z_index, + Some(100) + ); + assert_eq!(store.writer.persist_count(), persist_count + 1); + assert_eq!(store.history_status().history_revision, 1); + let after = committed.document.clone(); + drop(committed); + + let replay = store.commit_editor_document(request.clone()).unwrap(); + assert!(replay.replayed); + assert_eq!( + replay.result.op_results, + Some(vec![EditorOpResultV1 { + status: EditorOpResultStatusV1::Applied, + bounds: None, + }]) + ); + assert_eq!(store.writer.persist_count(), persist_count + 1); + + let mut reused = request; + let Some(EditorOpV1::ReorderElements { z_updates, .. }) = + reused.ops.as_mut().and_then(|ops| ops.first_mut()) + else { + unreachable!(); + }; + z_updates[0].z_index += 1; + assert_eq!( + store.commit_editor_document(reused).unwrap_err().error_code, + EditorCommitErrorCode::MutationIdReused + ); + + let no_change = store + .commit_editor_document(editor_ops_request( + replay.result.revision, + uuid::Uuid::new_v4().to_string(), + vec![op], + )) + .unwrap(); + assert_eq!( + no_change.result.op_results, + Some(vec![EditorOpResultV1 { + status: EditorOpResultStatusV1::NoChange, + bounds: None, + }]) + ); + assert_eq!(no_change.result.revision, replay.result.revision); + assert_eq!(store.writer.persist_count(), persist_count + 1); + assert_eq!(store.history_status().history_revision, 1); + + let counters = store.snapshot().key_counters; + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) + .unwrap(); + drop(barrier); + assert_eq!(store.editor_get().document, before); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Redo, &redo_id, &counters, || {}) + .unwrap(); + drop(barrier); + assert_eq!(store.editor_get().document, after); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn group_structural_ops_replay_reuse_and_round_trip_snapshot_history() { + let dir = test_directory("group-structural-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let setup = legacy_editor_commit( + &store, + &[ + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + EditorField::LayerGroups, + ], + |data| { + data.key_positions.get_mut("4key").unwrap()[0].group_id = + Some("source-group".to_string()); + data.key_positions.get_mut("4key").unwrap()[1].group_id = + Some("source-group".to_string()); + data.stat_positions.insert( + "4key".to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: KeyPosition { + id: uuid::Uuid::new_v4().to_string(), + group_id: Some("source-group".to_string()), + ..KeyPosition::default() + }, + }], + ); + data.graph_positions.insert( + "4key".to_string(), + vec![GraphPosition { + stat_type: GraphStatType::Kps, + graph_type: GraphType::Line, + graph_speed: 100, + graph_color: "#123456".to_string(), + show_avg_line: false, + position: KeyPosition { + id: uuid::Uuid::new_v4().to_string(), + group_id: Some("source-group".to_string()), + ..KeyPosition::default() + }, + }], + ); + data.knob_positions.insert( + "4key".to_string(), + vec![KnobPosition { + axis_id: "axis".to_string(), + sensitivity: 1.0, + reverse: false, + position: KeyPosition { + id: uuid::Uuid::new_v4().to_string(), + group_id: Some("source-group".to_string()), + ..KeyPosition::default() + }, + }], + ); + data.layer_groups.insert( + "4key".to_string(), + vec![LayerGroupDef { + id: "source-group".to_string(), + name: "Source".to_string(), + }], + ); + }, + ) + .unwrap(); + let before = setup.document; + let targets = vec![ + EditorElementGroupTargetV1 { + element_type: EditorElementTypeV1::Key, + id: before.key_positions["4key"][0].id.clone(), + }, + EditorElementGroupTargetV1 { + element_type: EditorElementTypeV1::Stat, + id: before.stat_positions["4key"][0].position.id.clone(), + }, + EditorElementGroupTargetV1 { + element_type: EditorElementTypeV1::Graph, + id: before.graph_positions["4key"][0].position.id.clone(), + }, + EditorElementGroupTargetV1 { + element_type: EditorElementTypeV1::Knob, + id: before.knob_positions["4key"][0].position.id.clone(), + }, + ]; + let create = EditorOpV1::SetElementGroups { + mode: "4key".to_string(), + targets: targets.clone(), + target_group: Some(EditorTargetGroupV1::Create { + id: "target-group".to_string(), + name: " Target ".to_string(), + }), + }; + let mutation_id = uuid::Uuid::new_v4().to_string(); + let request = editor_ops_request( + store.editor_get().revision, + &mutation_id, + vec![create.clone()], + ); + let history_before = store.history_status().history_revision; + let committed = store.commit_editor_document(request.clone()).unwrap(); + assert_eq!( + committed.result.op_results, + Some(vec![EditorOpResultV1 { + status: EditorOpResultStatusV1::Applied, + bounds: None, + }]) + ); + assert_eq!( + committed.result.changed_fields, + [ + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + EditorField::LayerGroups, + ] + ); + assert_eq!(store.history_status().history_revision, history_before + 1); + let after_set = committed.document.clone(); + assert_eq!( + after_set.key_positions["4key"][1].group_id.as_deref(), + Some("source-group") + ); + assert!(after_set.layer_groups["4key"] + .iter() + .any(|group| group.id == "source-group")); + assert!(after_set.layer_groups["4key"] + .iter() + .any(|group| group.id == "target-group" && group.name == " Target ")); + + let replay = store.commit_editor_document(request.clone()).unwrap(); + assert!(replay.replayed); + assert_eq!(replay.result, committed.result); + + let mut reused = request; + reused.ops = Some(vec![EditorOpV1::SetElementGroups { + mode: "4key".to_string(), + targets: targets.clone(), + target_group: None, + }]); + assert_eq!( + store.commit_editor_document(reused).unwrap_err().error_code, + EditorCommitErrorCode::MutationIdReused + ); + + let missing_revision = store.editor_get().revision; + let missing_history = store.history_status().history_revision; + let missing = store + .commit_editor_document(editor_ops_request( + missing_revision, + uuid::Uuid::new_v4().to_string(), + vec![EditorOpV1::SetElementGroups { + mode: "4key".to_string(), + targets: vec![ + targets[0].clone(), + EditorElementGroupTargetV1 { + element_type: EditorElementTypeV1::Key, + id: uuid::Uuid::new_v4().to_string(), + }, + ], + target_group: None, + }], + )) + .unwrap(); + assert_eq!(missing.result.revision, missing_revision); + assert_eq!( + missing.result.op_results.unwrap()[0].status, + EditorOpResultStatusV1::TargetMissing + ); + assert!(missing.event.is_none()); + assert_eq!(store.history_status().history_revision, missing_history); + + let existing = EditorOpV1::SetElementGroups { + mode: "4key".to_string(), + targets: targets.clone(), + target_group: Some(EditorTargetGroupV1::Existing { + id: "target-group".to_string(), + }), + }; + let no_change = store + .commit_editor_document(editor_ops_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + vec![existing], + )) + .unwrap(); + assert_eq!( + no_change.result.op_results.unwrap()[0].status, + EditorOpResultStatusV1::NoChange + ); + assert!(no_change.result.changed_fields.is_empty()); + assert!(no_change.event.is_none()); + assert_eq!(store.history_status().history_revision, history_before + 1); + + let counters = store.snapshot().key_counters; + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) + .unwrap(); + drop(barrier); + assert_eq!(store.editor_get().document, before); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Redo, &redo_id, &counters, || {}) + .unwrap(); + drop(barrier); + assert_eq!(store.editor_get().document, after_set); + + let rename = EditorOpV1::RenameLayerGroup { + mode: "4key".to_string(), + group_id: "target-group".to_string(), + name: " Renamed ".to_string(), + }; + let rename_request = editor_ops_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + vec![rename.clone()], + ); + let renamed = store + .commit_editor_document(rename_request.clone()) + .unwrap(); + assert_eq!(renamed.result.changed_fields, [EditorField::LayerGroups]); + assert_eq!( + renamed.result.op_results.as_ref().unwrap()[0].status, + EditorOpResultStatusV1::Applied + ); + let after_rename = renamed.document.clone(); + assert!(after_rename.layer_groups["4key"] + .iter() + .any(|group| group.id == "target-group" && group.name == " Renamed ")); + let rename_replay = store.commit_editor_document(rename_request).unwrap(); + assert!(rename_replay.replayed); + assert_eq!(rename_replay.result, renamed.result); + + let rename_no_change = store + .commit_editor_document(editor_ops_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + vec![rename], + )) + .unwrap(); + assert_eq!( + rename_no_change.result.op_results.unwrap()[0].status, + EditorOpResultStatusV1::NoChange + ); + assert!(rename_no_change.event.is_none()); + + let undo_rename_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_rename_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Undo, + &undo_rename_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + assert_eq!(store.editor_get().document, after_set); + + let redo_rename_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_rename_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Redo, + &redo_rename_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + assert_eq!(store.editor_get().document, after_rename); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn mixed_reorder_and_plugin_change_are_atomic_on_validation_and_persist_failure() { + let dir = test_directory("mixed-reorder-plugin-atomic-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = initialize_neutral_editor_store(&dir); + let before = store.snapshot(); + let before_plugin_revision = store.plugin_model_revision(); + let before_history_revision = store.history_status().history_revision; + let valid_op = complete_key_reorder_op(&store.editor_get().document); + + let mut missing = valid_op.clone(); + let EditorOpV1::ReorderElements { z_updates, .. } = &mut missing else { + unreachable!(); + }; + z_updates[0].id = uuid::Uuid::new_v4().to_string(); + let error = store + .commit_gesture(gesture_ops_request( + &store, + uuid::Uuid::new_v4().to_string(), + uuid::Uuid::new_v4().to_string(), + vec![missing], + "demo-plugin", + vec![saved_plugin_instance(50.0)], + )) + .unwrap_err(); + assert_eq!(error.error_code, EditorCommitErrorCode::ValidationFailed); + assert_eq!( + error + .details + .and_then(|details| details.validation_code) + .as_deref(), + Some("REORDER_TARGET_MISSING") + ); + assert_eq!(store.snapshot(), before); + assert!(store + .plugin_instances_get("demo-plugin") + .unwrap() + .0 + .is_empty()); + + store.writer.fail_next_persist(); + let request = gesture_ops_request( + &store, + uuid::Uuid::new_v4().to_string(), + uuid::Uuid::new_v4().to_string(), + vec![valid_op], + "demo-plugin", + vec![saved_plugin_instance(50.0)], + ); + assert_eq!( + store + .commit_gesture(request.clone()) + .unwrap_err() + .error_code, + EditorCommitErrorCode::IoError + ); + assert_eq!(store.snapshot(), before); + assert_eq!(store.plugin_model_revision(), before_plugin_revision); + assert_eq!( + store.history_status().history_revision, + before_history_revision + ); + + let committed = store.commit_gesture(request).unwrap(); + assert_eq!( + committed.outcome.result.editor_op_results, + Some(vec![EditorOpResultV1 { + status: EditorOpResultStatusV1::Applied, + bounds: None, + }]) + ); + assert_eq!(committed.outcome.result.changed_plugin_ids, ["demo-plugin"]); + assert_eq!(store.history_status().history_revision, 1); + drop(committed); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + vec![saved_plugin_instance(50.0)] + ); + + let gate = store.history_gate(); + let counters = store.snapshot().key_counters; + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) + .unwrap(); + drop(barrier); + assert_eq!( + store.editor_get().document.key_positions, + before.key_positions + ); + assert!(store + .plugin_instances_get("demo-plugin") + .unwrap() + .0 + .is_empty()); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Redo, &redo_id, &counters, || {}) + .unwrap(); + drop(barrier); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].z_index, + Some(100) + ); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + vec![saved_plugin_instance(50.0)] + ); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn missing_editor_op_does_not_block_the_plugin_half_of_a_gesture() { + let dir = test_directory("gesture-missing-editor-op-plugin-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let before_editor_revision = store.editor_get().revision; + + let committed = store + .commit_gesture(gesture_ops_request( + &store, + uuid::Uuid::new_v4().to_string(), + uuid::Uuid::new_v4().to_string(), + vec![set_bounds_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + EditorBoundsV1 { + dx: 1.0, + dy: 2.0, + width: 3.0, + height: 4.0, + }, + )], + "demo-plugin", + vec![saved_plugin_instance(10.0)], + )) + .unwrap(); + + assert_eq!( + committed.outcome.result.editor_revision, + before_editor_revision + ); + assert_eq!( + committed.outcome.result.editor_op_results, + Some(vec![EditorOpResultV1 { + status: EditorOpResultStatusV1::TargetMissing, + bounds: None, + }]) + ); + assert!(committed.outcome.result.changed_fields.is_empty()); + assert!(committed.outcome.change.is_none()); + assert_eq!(committed.outcome.result.changed_plugin_ids, ["demo-plugin"]); + drop(committed); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + vec![saved_plugin_instance(10.0)] + ); + assert_eq!(store.history_status().history_revision, 1); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn delete_op_and_plugin_change_commit_and_restore_as_one_gesture() { + let dir = test_directory("gesture-delete-op-plugin-atomic-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let before = store.editor_get().document; + let deleted_id = before.key_positions["4key"][0].id.clone(); + let request = gesture_ops_request( + &store, + uuid::Uuid::new_v4().to_string(), + uuid::Uuid::new_v4().to_string(), + vec![delete_element_op(EditorElementTypeV1::Key, &deleted_id)], + "demo-plugin", + vec![saved_plugin_instance(10.0)], + ); + + let committed = store.commit_gesture(request.clone()).unwrap(); + assert_eq!( + committed.outcome.result.editor_op_results, + Some(vec![EditorOpResultV1 { + status: EditorOpResultStatusV1::Applied, + bounds: None, + }]) + ); + assert_eq!( + committed.outcome.result.changed_fields, + [EditorField::Keys, EditorField::KeyPositions] + ); + assert_eq!(committed.outcome.result.changed_plugin_ids, ["demo-plugin"]); + assert_eq!(store.history_status().history_revision, 1); + drop(committed); + assert!(!store.editor_get().document.key_positions["4key"] + .iter() + .any(|position| position.id == deleted_id)); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + vec![saved_plugin_instance(10.0)] + ); + let replay = store.commit_gesture(request).unwrap(); + assert!(replay.outcome.replayed); + assert_eq!( + replay.outcome.result.editor_op_results, + Some(vec![EditorOpResultV1 { + status: EditorOpResultStatusV1::Applied, + bounds: None, + }]) + ); + drop(replay); + + let gate = store.history_gate(); + let counters = store.snapshot().key_counters; + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) + .unwrap(); + drop(barrier); + assert_eq!(store.editor_get().document, before); + assert!(store + .plugin_instances_get("demo-plugin") + .unwrap() + .0 + .is_empty()); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Redo, &redo_id, &counters, || {}) + .unwrap(); + drop(barrier); + assert!(!store.editor_get().document.key_positions["4key"] + .iter() + .any(|position| position.id == deleted_id)); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + vec![saved_plugin_instance(10.0)] + ); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn gesture_plugin_validation_failure_leaves_editor_and_store_unchanged() { + let dir = test_directory("gesture-transaction-validation-failure-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let before = store.snapshot(); + let before_plugin_revision = store.plugin_model_revision(); + let before_history_revision = store.history_status().history_revision; + let persist_count = store.writer.persist_count(); + let mut invalid = saved_plugin_instance(10.0); + invalid.position.x = f64::NAN; + + let error = store + .commit_gesture(gesture_request( + &store, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, before.key_positions["4key"][0].dx + 25.0), + "demo-plugin", + vec![invalid], + )) + .unwrap_err(); + + assert_eq!(error.error_code, EditorCommitErrorCode::ValidationFailed); + assert_eq!(store.snapshot(), before); + assert_eq!(store.plugin_model_revision(), before_plugin_revision); + assert_eq!( + store.history_status().history_revision, + before_history_revision + ); + assert_eq!(store.writer.persist_count(), persist_count); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn gesture_plugin_transition_rejects_new_and_worsened_legacy_outliers_atomically() { + let dir = test_directory("gesture-plugin-legacy-outlier-transition-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + + let before = store.snapshot(); + let new_outlier = saved_plugin_instance(40_000.0); + let error = store + .commit_gesture(gesture_request( + &store, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, before.key_positions["4key"][0].dx + 25.0), + "demo-plugin", + vec![new_outlier.clone()], + )) + .unwrap_err(); + assert_eq!( + error.details.and_then(|details| details.validation_code), + Some("INVALID_PLUGIN_INSTANCE_POSITION:0".to_string()) + ); + assert_eq!(store.snapshot(), before); + + let key = super::plugin_instances_storage_key("demo-plugin"); + store.state.write().data.plugin_data.insert( + key, + serde_json::to_value(vec![new_outlier.clone()]).unwrap(), + ); + let legacy_before = store.snapshot(); + let mut worsened = new_outlier; + worsened.position.x = 41_000.0; + let error = store + .commit_gesture(gesture_request( + &store, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, legacy_before.key_positions["4key"][0].dx + 30.0), + "demo-plugin", + vec![worsened], + )) + .unwrap_err(); + assert_eq!( + error.details.and_then(|details| details.validation_code), + Some("INVALID_PLUGIN_INSTANCE_POSITION:0".to_string()) + ); + assert_eq!(store.snapshot(), legacy_before); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn gesture_plugin_transition_allows_legacy_outlier_sibling_edit_and_improvement() { + let dir = test_directory("gesture-plugin-legacy-outlier-compatible-edit-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let legacy = saved_plugin_instance(40_000.0); + store.state.write().data.plugin_data.insert( + super::plugin_instances_storage_key("demo-plugin"), + serde_json::to_value(vec![legacy.clone()]).unwrap(), + ); + + let mut sibling_edit = legacy.clone(); + sibling_edit.hidden = true; + drop( + store + .commit_gesture(gesture_request( + &store, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1::default(), + "demo-plugin", + vec![sibling_edit.clone()], + )) + .unwrap(), + ); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + vec![sibling_edit.clone()] + ); + + let mut improved = sibling_edit; + improved.position.x = 39_000.0; + drop( + store + .commit_gesture(gesture_request( + &store, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1::default(), + "demo-plugin", + vec![improved.clone()], + )) + .unwrap(), + ); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + vec![improved] + ); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn gesture_persist_failure_does_not_publish_partial_state() { + let dir = test_directory("gesture-transaction-persist-failure-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let before = store.snapshot(); + let before_plugin_revision = store.plugin_model_revision(); + let before_history_revision = store.history_status().history_revision; + store.writer.fail_next_persist(); + + let error = store + .commit_gesture(gesture_request( + &store, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, before.key_positions["4key"][0].dx + 25.0), + "demo-plugin", + vec![saved_plugin_instance(25.0)], + )) + .unwrap_err(); + + assert_eq!(error.error_code, EditorCommitErrorCode::IoError); + assert_eq!(store.snapshot(), before); + assert_eq!(store.plugin_model_revision(), before_plugin_revision); + assert_eq!( + store.history_status().history_revision, + before_history_revision + ); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn gesture_oversized_history_rejects_the_whole_transaction() { + let dir = test_directory("gesture-transaction-history-size-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + store + .state + .write() + .history + .set_limits_for_test(1, 32 * 1024 * 1024, 50); + let before = store.snapshot(); + let before_plugin_revision = store.plugin_model_revision(); + let before_history_revision = store.history_status().history_revision; + let persist_count = store.writer.persist_count(); + + let error = store + .commit_gesture(gesture_request( + &store, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, before.key_positions["4key"][0].dx + 25.0), + "demo-plugin", + vec![saved_plugin_instance(25.0)], + )) + .unwrap_err(); + + assert_eq!(error.error_code, EditorCommitErrorCode::ValidationFailed); + assert_eq!( + error.details.unwrap().validation_code.as_deref(), + Some(HISTORY_ENTRY_TOO_LARGE) + ); + assert_eq!(store.snapshot(), before); + assert_eq!(store.plugin_model_revision(), before_plugin_revision); + assert_eq!( + store.history_status().history_revision, + before_history_revision + ); + assert_eq!(store.writer.persist_count(), persist_count); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn merged_editor_gesture_aliases_only_join_the_top_plugin_entry() { + let dir = test_directory("merged-editor-gesture-alias-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_x = store.editor_get().document.key_positions["4key"][0].dx; + let first_gesture = uuid::Uuid::new_v4().to_string(); + let second_gesture = uuid::Uuid::new_v4().to_string(); + + for (plugin_id, x, gesture_id) in [ + ("plugin-a", 41.0, first_gesture.clone()), + ("plugin-b", 82.0, second_gesture.clone()), + ] { + let plugin = store + .commit_plugin_instances(plugin_instances_request( + plugin_id, + vec![saved_plugin_instance(x)], + uuid::Uuid::new_v4().to_string(), + Some(gesture_id), + Some(store.plugin_model_revision()), + )) + .unwrap(); + drop(plugin); + } + + let mut editor = editor_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_x + 60.0), + ); + editor.gesture_id = Some(second_gesture.clone()); + editor.gesture_ids = vec![first_gesture, second_gesture]; + store.commit_editor_document(editor).unwrap(); + + let gate = store.history_gate(); + let counters = store.snapshot().key_counters; + let first_undo_id = uuid::Uuid::new_v4().to_string(); + let first_barrier = gate.close(&first_undo_id).unwrap(); + let first_undo = store + .apply_history_operation(HistoryDirection::Undo, &first_undo_id, &counters, || {}) + .unwrap(); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + initial_x + ); + assert_eq!( + store.plugin_instances_get("plugin-a").unwrap().0, + vec![saved_plugin_instance(41.0)] + ); + assert!(store.plugin_instances_get("plugin-b").unwrap().0.is_empty()); + assert!(first_undo.status.can_undo); + assert!(first_undo.status.can_redo); + drop(first_barrier); + + let second_undo_id = uuid::Uuid::new_v4().to_string(); + let second_barrier = gate.close(&second_undo_id).unwrap(); + let second_undo = store + .apply_history_operation(HistoryDirection::Undo, &second_undo_id, &counters, || {}) + .unwrap(); + assert!(store.plugin_instances_get("plugin-a").unwrap().0.is_empty()); + assert!(store.plugin_instances_get("plugin-b").unwrap().0.is_empty()); + assert!(!second_undo.status.can_undo); + assert!(second_undo.status.can_redo); + drop(second_barrier); + + let first_redo_id = uuid::Uuid::new_v4().to_string(); + let first_redo_barrier = gate.close(&first_redo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Redo, &first_redo_id, &counters, || {}) + .unwrap(); + assert_eq!( + store.plugin_instances_get("plugin-a").unwrap().0, + vec![saved_plugin_instance(41.0)] + ); + assert!(store.plugin_instances_get("plugin-b").unwrap().0.is_empty()); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + initial_x + ); + drop(first_redo_barrier); + + let second_redo_id = uuid::Uuid::new_v4().to_string(); + let second_redo_barrier = gate.close(&second_redo_id).unwrap(); + let second_redo = store + .apply_history_operation(HistoryDirection::Redo, &second_redo_id, &counters, || {}) + .unwrap(); + assert_eq!( + store.plugin_instances_get("plugin-a").unwrap().0, + vec![saved_plugin_instance(41.0)] + ); + assert_eq!( + store.plugin_instances_get("plugin-b").unwrap().0, + vec![saved_plugin_instance(82.0)] + ); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + initial_x + 60.0 + ); + assert!(second_redo.status.can_undo); + assert!(!second_redo.status.can_redo); + drop(second_redo_barrier); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn multi_alias_editor_commit_stays_above_a_later_editor_commit() { + let dir = test_directory("multi-alias-editor-order-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_x = store.editor_get().document.key_positions["4key"][0].dx; + let first_gesture = uuid::Uuid::new_v4().to_string(); + let second_gesture = uuid::Uuid::new_v4().to_string(); + + for (plugin_id, x, gesture_id) in [ + ("plugin-a", 41.0, first_gesture.clone()), + ("plugin-b", 82.0, second_gesture.clone()), + ] { + let plugin = store + .commit_plugin_instances(plugin_instances_request( + plugin_id, + vec![saved_plugin_instance(x)], + uuid::Uuid::new_v4().to_string(), + Some(gesture_id), + Some(store.plugin_model_revision()), + )) + .unwrap(); + drop(plugin); + } + + let mut intervening_editor = editor_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_x + 20.0), + ); + intervening_editor.gesture_id = Some(uuid::Uuid::new_v4().to_string()); + store.commit_editor_document(intervening_editor).unwrap(); + + let mut latest_editor = editor_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_x + 40.0), + ); + latest_editor.gesture_id = Some(second_gesture.clone()); + latest_editor.gesture_ids = vec![first_gesture, second_gesture]; + store.commit_editor_document(latest_editor).unwrap(); + + let gate = store.history_gate(); + let counters = store.snapshot().key_counters; + for (expected_x, expected_a, expected_b) in [ + (initial_x + 20.0, Some(41.0), Some(82.0)), + (initial_x, Some(41.0), Some(82.0)), + (initial_x, Some(41.0), None), + (initial_x, None, None), + ] { + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) + .unwrap(); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + expected_x + ); + assert_eq!( + store.plugin_instances_get("plugin-a").unwrap().0, + expected_a + .map(|x| vec![saved_plugin_instance(x)]) + .unwrap_or_default() + ); + assert_eq!( + store.plugin_instances_get("plugin-b").unwrap().0, + expected_b + .map(|x| vec![saved_plugin_instance(x)]) + .unwrap_or_default() + ); + drop(barrier); + } + assert!(!store.history_status().can_undo); + + for (expected_x, expected_a, expected_b) in [ + (initial_x, Some(41.0), None), + (initial_x, Some(41.0), Some(82.0)), + (initial_x + 20.0, Some(41.0), Some(82.0)), + (initial_x + 40.0, Some(41.0), Some(82.0)), + ] { + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Redo, &redo_id, &counters, || {}) + .unwrap(); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + expected_x + ); + assert_eq!( + store.plugin_instances_get("plugin-a").unwrap().0, + expected_a + .map(|x| vec![saved_plugin_instance(x)]) + .unwrap_or_default() + ); + assert_eq!( + store.plugin_instances_get("plugin-b").unwrap().0, + expected_b + .map(|x| vec![saved_plugin_instance(x)]) + .unwrap_or_default() + ); + drop(barrier); + } + assert!(!store.history_status().can_redo); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn merged_aliases_do_not_cross_an_intervening_same_plugin_edit() { + let dir = test_directory("intervening-same-plugin-alias-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_x = store.editor_get().document.key_positions["4key"][0].dx; + let first_gesture = uuid::Uuid::new_v4().to_string(); + let intervening_gesture = uuid::Uuid::new_v4().to_string(); + let last_gesture = uuid::Uuid::new_v4().to_string(); + + for (x, gesture_id) in [ + (10.0, first_gesture.clone()), + (20.0, intervening_gesture), + (30.0, last_gesture.clone()), + ] { + let plugin = store + .commit_plugin_instances(plugin_instances_request( + "demo-plugin", + vec![saved_plugin_instance(x)], + uuid::Uuid::new_v4().to_string(), + Some(gesture_id), + Some(store.plugin_model_revision()), + )) + .unwrap(); + drop(plugin); + } + + let mut editor = editor_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_x + 60.0), + ); + editor.gesture_id = Some(last_gesture.clone()); + editor.gesture_ids = vec![first_gesture, last_gesture]; + store.commit_editor_document(editor).unwrap(); + + let gate = store.history_gate(); + let counters = store.snapshot().key_counters; + for expected_plugin_x in [Some(20.0), Some(10.0), None] { + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) + .unwrap(); + let expected = expected_plugin_x + .map(|x| vec![saved_plugin_instance(x)]) + .unwrap_or_default(); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + expected + ); + drop(barrier); + } + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + initial_x + ); + assert!(!store.history_status().can_undo); + + for expected_plugin_x in [10.0, 20.0, 30.0] { + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Redo, &redo_id, &counters, || {}) + .unwrap(); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + vec![saved_plugin_instance(expected_plugin_x)] + ); + drop(barrier); + } + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + initial_x + 60.0 + ); + assert!(!store.history_status().can_redo); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn delayed_plugin_commit_preserves_later_editor_undo_order() { + let dir = test_directory("delayed-compound-history-order-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_x = store.editor_get().document.key_positions["4key"][0].dx; + let gesture_id = uuid::Uuid::new_v4().to_string(); + + let mut mixed_editor = editor_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_x + 40.0), + ); + mixed_editor.gesture_id = Some(gesture_id.clone()); + store.commit_editor_document(mixed_editor).unwrap(); + + let later_editor = editor_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_x + 80.0), + ); + store.commit_editor_document(later_editor).unwrap(); + + let plugin = store + .commit_plugin_instances(plugin_instances_request( + "demo-plugin", + vec![saved_plugin_instance(42.0)], + uuid::Uuid::new_v4().to_string(), + Some(gesture_id), + Some(store.plugin_model_revision()), + )) + .unwrap(); + drop(plugin); + + let gate = store.history_gate(); + let counters = store.snapshot().key_counters; + let first_undo_id = uuid::Uuid::new_v4().to_string(); + let first_barrier = gate.close(&first_undo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Undo, &first_undo_id, &counters, || {}) + .unwrap(); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + initial_x + 40.0 + ); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + vec![saved_plugin_instance(42.0)] + ); + drop(first_barrier); + + let second_undo_id = uuid::Uuid::new_v4().to_string(); + let second_barrier = gate.close(&second_undo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Undo, &second_undo_id, &counters, || {}) + .unwrap(); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + initial_x + ); + assert!(store + .plugin_instances_get("demo-plugin") + .unwrap() + .0 + .is_empty()); + drop(second_barrier); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn repeated_editor_gesture_keeps_an_intervening_plugin_undo_in_order() { + let dir = test_directory("repeated-editor-gesture-order-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_x = store.editor_get().document.key_positions["4key"][0].dx; + let gesture_id = uuid::Uuid::new_v4().to_string(); + + let mut first_editor = editor_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_x + 40.0), + ); + first_editor.gesture_id = Some(gesture_id.clone()); + store.commit_editor_document(first_editor).unwrap(); + let plugin = store + .commit_plugin_instances(plugin_instances_request( + "demo-plugin", + vec![saved_plugin_instance(42.0)], + uuid::Uuid::new_v4().to_string(), + None, + Some(store.plugin_model_revision()), + )) + .unwrap(); + drop(plugin); + let mut repeated_editor = editor_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_x + 80.0), + ); + repeated_editor.gesture_id = Some(gesture_id); + store.commit_editor_document(repeated_editor).unwrap(); + + let gate = store.history_gate(); + let counters = store.snapshot().key_counters; + let first_undo_id = uuid::Uuid::new_v4().to_string(); + let first_barrier = gate.close(&first_undo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Undo, &first_undo_id, &counters, || {}) + .unwrap(); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + initial_x + 40.0 + ); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + vec![saved_plugin_instance(42.0)] + ); + drop(first_barrier); + + let second_undo_id = uuid::Uuid::new_v4().to_string(); + let second_barrier = gate.close(&second_undo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Undo, &second_undo_id, &counters, || {}) + .unwrap(); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + initial_x + 40.0 + ); + assert!(store + .plugin_instances_get("demo-plugin") + .unwrap() + .0 + .is_empty()); + drop(second_barrier); + + let third_undo_id = uuid::Uuid::new_v4().to_string(); + let third_barrier = gate.close(&third_undo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Undo, &third_undo_id, &counters, || {}) + .unwrap(); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + initial_x + ); + drop(third_barrier); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn plugin_first_compound_undo_restores_both_scopes() { + let dir = test_directory("plugin-first-compound-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_x = store.editor_get().document.key_positions["4key"][0].dx; + let gesture_id = uuid::Uuid::new_v4().to_string(); + + let plugin = store + .commit_plugin_instances(plugin_instances_request( + "demo-plugin", + vec![saved_plugin_instance(91.0)], + uuid::Uuid::new_v4().to_string(), + Some(gesture_id.clone()), + Some(store.plugin_model_revision()), + )) + .unwrap(); + drop(plugin); + let mut editor = editor_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_x + 35.0), + ); + editor.gesture_id = Some(gesture_id); + store.commit_editor_document(editor).unwrap(); + + let gate = store.history_gate(); + let counters = store.snapshot().key_counters; + let undo_id = uuid::Uuid::new_v4().to_string(); + let undo_barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) + .unwrap(); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + initial_x + ); + assert!(store + .plugin_instances_get("demo-plugin") + .unwrap() + .0 + .is_empty()); + drop(undo_barrier); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn early_plugin_commit_and_gesture_commit_share_one_undo_step() { + let dir = test_directory("early-plugin-gesture-merge-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_x = store.editor_get().document.key_positions["4key"][0].dx; + let gesture_id = uuid::Uuid::new_v4().to_string(); + + // 디바운스 plugin 커밋이 gesture 커밋보다 먼저 착지한 순서 + let plugin = store + .commit_plugin_instances(plugin_instances_request( + "demo-plugin", + vec![saved_plugin_instance(10.0)], + uuid::Uuid::new_v4().to_string(), + Some(gesture_id.clone()), + Some(store.plugin_model_revision()), + )) + .unwrap(); + drop(plugin); + let committed = store + .commit_gesture(gesture_request( + &store, + gesture_id, + position_patch(&store, initial_x + 40.0), + "demo-plugin", + vec![saved_plugin_instance(10.0)], + )) + .unwrap(); + drop(committed); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + initial_x + 40.0 + ); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + vec![saved_plugin_instance(10.0)] + ); + + let gate = store.history_gate(); + let counters = store.snapshot().key_counters; + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) + .unwrap(); + drop(barrier); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + initial_x + ); + assert!(store + .plugin_instances_get("demo-plugin") + .unwrap() + .0 + .is_empty()); + assert!(!store.history_status().can_undo); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Redo, &redo_id, &counters, || {}) + .unwrap(); + drop(barrier); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + initial_x + 40.0 + ); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + vec![saved_plugin_instance(10.0)] + ); + assert!(!store.history_status().can_redo); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn early_plugin_commit_with_a_different_gesture_stays_a_separate_undo_step() { + let dir = test_directory("early-plugin-other-gesture-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_x = store.editor_get().document.key_positions["4key"][0].dx; + + let plugin = store + .commit_plugin_instances(plugin_instances_request( + "demo-plugin", + vec![saved_plugin_instance(10.0)], + uuid::Uuid::new_v4().to_string(), + Some(uuid::Uuid::new_v4().to_string()), + Some(store.plugin_model_revision()), + )) + .unwrap(); + drop(plugin); + let committed = store + .commit_gesture(gesture_request( + &store, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_x + 40.0), + "demo-plugin", + vec![saved_plugin_instance(10.0)], + )) + .unwrap(); + drop(committed); + + let gate = store.history_gate(); + let counters = store.snapshot().key_counters; + let first_undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&first_undo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Undo, &first_undo_id, &counters, || {}) + .unwrap(); + drop(barrier); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + initial_x + ); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + vec![saved_plugin_instance(10.0)] + ); + + let second_undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&second_undo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Undo, &second_undo_id, &counters, || {}) + .unwrap(); + drop(barrier); + assert!(store + .plugin_instances_get("demo-plugin") + .unwrap() + .0 + .is_empty()); + assert!(!store.history_status().can_undo); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn gesture_merge_with_a_reverted_plugin_state_keeps_one_undo_step() { + let dir = test_directory("gesture-merge-reverted-plugin-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_x = store.editor_get().document.key_positions["4key"][0].dx; + let gesture_id = uuid::Uuid::new_v4().to_string(); + + let plugin = store + .commit_plugin_instances(plugin_instances_request( + "demo-plugin", + vec![saved_plugin_instance(10.0)], + uuid::Uuid::new_v4().to_string(), + Some(gesture_id.clone()), + Some(store.plugin_model_revision()), + )) + .unwrap(); + drop(plugin); + // gesture 커밋이 plugin을 선행 커밋 이전 상태로 되돌리는 net-zero 조합 + let committed = store + .commit_gesture(gesture_request( + &store, + gesture_id, + position_patch(&store, initial_x + 40.0), + "demo-plugin", + Vec::new(), + )) + .unwrap(); + drop(committed); + assert!(store + .plugin_instances_get("demo-plugin") + .unwrap() + .0 + .is_empty()); + + let gate = store.history_gate(); + let counters = store.snapshot().key_counters; + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) + .unwrap(); + drop(barrier); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + initial_x + ); + assert!(store + .plugin_instances_get("demo-plugin") + .unwrap() + .0 + .is_empty()); + assert!(!store.history_status().can_undo); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn compound_history_persist_failure_changes_neither_scope() { + let dir = test_directory("compound-history-persist-failure-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_x = store.editor_get().document.key_positions["4key"][0].dx; + let changed_x = initial_x + 55.0; + let gesture_id = uuid::Uuid::new_v4().to_string(); + + let mut editor = editor_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, changed_x), + ); + editor.gesture_id = Some(gesture_id.clone()); + store.commit_editor_document(editor).unwrap(); + let plugin = store + .commit_plugin_instances(plugin_instances_request( + "demo-plugin", + vec![saved_plugin_instance(66.0)], + uuid::Uuid::new_v4().to_string(), + Some(gesture_id), + Some(store.plugin_model_revision()), + )) + .unwrap(); + drop(plugin); + + let gate = store.history_gate(); + let counters = store.snapshot().key_counters; + let undo_id = uuid::Uuid::new_v4().to_string(); + let undo_barrier = gate.close(&undo_id).unwrap(); + store.writer.fail_next_persist(); + assert!(store + .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) + .is_err()); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + changed_x + ); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + vec![saved_plugin_instance(66.0)] + ); + assert!(store.history_status().can_undo); + drop(undo_barrier); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn plugin_instances_commit_revalidates_admission_after_store_lock_wait() { + let dir = test_directory("plugin-instances-admission-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = Arc::new(AppStore::initialize_in_dir(&dir).unwrap()); + let expected_revision = store.plugin_model_revision(); + let request = plugin_instances_request( + "demo-plugin", + vec![saved_plugin_instance(10.0)], + uuid::Uuid::new_v4().to_string(), + None, + Some(expected_revision), + ); + let store_guard = store.state.write(); + let commit_store = Arc::clone(&store); + let commit = thread::spawn(move || commit_store.commit_plugin_instances(request)); + let gate = store.history_gate(); + let deadline = Instant::now() + Duration::from_secs(2); + while gate.active_mutations() == 0 { + assert!( + Instant::now() < deadline, + "plugin mutation was not admitted" + ); + thread::yield_now(); + } + + let barrier_gate = Arc::clone(&gate); + let operation_id = uuid::Uuid::new_v4().to_string(); + let (barrier_ready_tx, barrier_ready_rx) = mpsc::channel(); + let (release_barrier_tx, release_barrier_rx) = mpsc::channel(); + let barrier = thread::spawn(move || { + let lease = barrier_gate.close(&operation_id).unwrap(); + barrier_ready_tx.send(()).unwrap(); + release_barrier_rx.recv().unwrap(); + drop(lease); + }); + let deadline = Instant::now() + Duration::from_secs(2); + while !gate.is_closed() { + assert!(Instant::now() < deadline, "history gate did not close"); + thread::yield_now(); + } + + drop(store_guard); + assert_eq!(commit.join().unwrap().unwrap_err(), HISTORY_IN_PROGRESS); + barrier_ready_rx + .recv_timeout(Duration::from_secs(2)) + .unwrap(); + release_barrier_tx.send(()).unwrap(); + barrier.join().unwrap(); + assert!(store + .plugin_instances_get("demo-plugin") + .unwrap() + .0 + .is_empty()); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn plugin_instances_net_zero_gesture_removes_merged_entry() { + let dir = test_directory("plugin-instances-net-zero-gesture-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let gesture_id = uuid::Uuid::new_v4().to_string(); + let expected_revision = store.plugin_model_revision(); + let first = store + .commit_plugin_instances(plugin_instances_request( + "demo-plugin", + vec![saved_plugin_instance(10.0)], + uuid::Uuid::new_v4().to_string(), + Some(gesture_id.clone()), + Some(expected_revision), + )) + .unwrap(); + let next_revision = first.outcome.model_revision; + drop(first); + + let second = store + .commit_plugin_instances(plugin_instances_request( + "demo-plugin", + Vec::new(), + uuid::Uuid::new_v4().to_string(), + Some(gesture_id), + Some(next_revision), + )) + .unwrap(); + assert!(!second.outcome.history_status.as_ref().unwrap().can_undo); + drop(second); + assert!(store + .plugin_instances_get("demo-plugin") + .unwrap() + .0 + .is_empty()); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn mixed_net_zero_gesture_removes_compound_entry() { + let dir = test_directory("compound-net-zero-gesture-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_x = store.editor_get().document.key_positions["4key"][0].dx; + let gesture_id = uuid::Uuid::new_v4().to_string(); + + let mut editor_out = editor_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_x + 25.0), + ); + editor_out.gesture_id = Some(gesture_id.clone()); + store.commit_editor_document(editor_out).unwrap(); + let plugin_out = store + .commit_plugin_instances(plugin_instances_request( + "demo-plugin", + vec![saved_plugin_instance(25.0)], + uuid::Uuid::new_v4().to_string(), + Some(gesture_id.clone()), + Some(store.plugin_model_revision()), + )) + .unwrap(); + drop(plugin_out); + + let mut editor_back = editor_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_x), + ); + editor_back.gesture_id = Some(gesture_id.clone()); + store.commit_editor_document(editor_back).unwrap(); + let plugin_back = store + .commit_plugin_instances(plugin_instances_request( + "demo-plugin", + Vec::new(), + uuid::Uuid::new_v4().to_string(), + Some(gesture_id), + Some(store.plugin_model_revision()), + )) + .unwrap(); + assert!( + !plugin_back + .outcome + .history_status + .as_ref() + .unwrap() + .can_undo + ); + drop(plugin_back); + + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + initial_x + ); + assert!(store + .plugin_instances_get("demo-plugin") + .unwrap() + .0 + .is_empty()); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn plugin_instances_reconcile_is_noop_when_canonical_tabs_are_valid() { + let dir = test_directory("plugin-instances-reconcile-noop-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let committed = store + .commit_plugin_instances(plugin_instances_request( + "demo-plugin", + vec![saved_plugin_instance(10.0)], + uuid::Uuid::new_v4().to_string(), + None, + Some(store.plugin_model_revision()), + )) + .unwrap(); + drop(committed); + let revision = store.plugin_model_revision(); + let history_revision = store.history_status().history_revision; + + let reconciled = store + .reconcile_plugin_instances(plugin_instances_reconcile_request( + "demo-plugin", + &["4key", "5key"], + uuid::Uuid::new_v4().to_string(), + )) + .unwrap(); + + assert!(!reconciled.outcome.changed); + assert!(reconciled.outcome.history_status.is_none()); + assert_eq!(reconciled.outcome.model_revision, revision); + drop(reconciled); + assert_eq!(store.history_status().history_revision, history_revision); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn plugin_instances_reconcile_records_and_round_trips_history() { + let dir = test_directory("plugin-instances-reconcile-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let mut default_tab = saved_plugin_instance(10.0); + default_tab.tab_id = None; + default_tab.hidden = true; + default_tab.z_index = Some(17.0); + let mut stale_tab = saved_plugin_instance(30.0); + stale_tab.tab_id = Some("deleted-tab".to_string()); + let before = vec![default_tab, stale_tab]; + let committed = store + .commit_plugin_instances(plugin_instances_request( + "demo-plugin", + before.clone(), + uuid::Uuid::new_v4().to_string(), + None, + Some(store.plugin_model_revision()), + )) + .unwrap(); + drop(committed); + + let request = plugin_instances_reconcile_request( + "demo-plugin", + &["4key"], + uuid::Uuid::new_v4().to_string(), + ); + let reconciled = store.reconcile_plugin_instances(request).unwrap(); + assert!(reconciled.outcome.changed); + assert!(reconciled.outcome.history_status.is_some()); + drop(reconciled); + let mut normalized = saved_plugin_instance(10.0); + normalized.tab_id = Some("4key".to_string()); + normalized.hidden = true; + normalized.z_index = Some(17.0); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + vec![normalized.clone()] + ); + + let gate = store.history_gate(); + let counters = store.snapshot().key_counters; + let undo_id = uuid::Uuid::new_v4().to_string(); + let undo_barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) + .unwrap(); + drop(undo_barrier); + assert_eq!(store.plugin_instances_get("demo-plugin").unwrap().0, before); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let redo_barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Redo, &redo_id, &counters, || {}) + .unwrap(); + drop(redo_barrier); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + vec![normalized] + ); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn plugin_instances_reconcile_rejects_stale_history_epoch_after_undo() { + let dir = test_directory("plugin-instances-reconcile-history-epoch-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let restored_instances = vec![saved_plugin_instance(10.0)]; + let first = store + .commit_plugin_instances(plugin_instances_request( + "demo-plugin", + restored_instances.clone(), + uuid::Uuid::new_v4().to_string(), + None, + Some(store.plugin_model_revision()), + )) + .unwrap(); + drop(first); + + let mut replacement = saved_plugin_instance(30.0); + replacement.tab_id = Some("5key".to_string()); + let second = store + .commit_plugin_instances(plugin_instances_request( + "demo-plugin", + vec![replacement], + uuid::Uuid::new_v4().to_string(), + None, + Some(store.plugin_model_revision()), + )) + .unwrap(); + drop(second); + let stale_epoch = store.history_status().history_epoch; + + let operation_id = uuid::Uuid::new_v4().to_string(); + let gate = store.history_gate(); + let barrier = gate.close(&operation_id).unwrap(); + let counters = store.snapshot().key_counters; + let undo = store + .apply_history_operation(HistoryDirection::Undo, &operation_id, &counters, || {}) + .unwrap(); + drop(barrier); + assert_ne!(undo.status.history_epoch, stale_epoch); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + restored_instances + ); + + let before_revision = store.plugin_model_revision(); + let before_status = store.history_status(); + let mutation_id = uuid::Uuid::new_v4().to_string(); + let mut request = plugin_instances_reconcile_request("demo-plugin", &["5key"], mutation_id); + request.observed_history_epoch = Some(stale_epoch); + assert_eq!( + store.reconcile_plugin_instances(request).unwrap_err(), + "HISTORY_EPOCH_CONFLICT" + ); + assert_eq!(store.plugin_model_revision(), before_revision); + let after_status = store.history_status(); + assert_eq!( + after_status.history_revision, + before_status.history_revision + ); + assert_eq!(after_status.history_epoch, before_status.history_epoch); + assert_eq!(after_status.can_undo, before_status.can_undo); + assert_eq!(after_status.can_redo, before_status.can_redo); + assert_eq!(after_status.busy, before_status.busy); + assert_eq!(after_status.truncated, before_status.truncated); + assert!(after_status.status_seq > before_status.status_seq); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + restored_instances + ); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn plugin_instances_reconcile_cannot_resurrect_after_bulk_clear() { + let dir = test_directory("plugin-instances-reconcile-clear-race-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = Arc::new(AppStore::initialize_in_dir(&dir).unwrap()); + let committed = store + .commit_plugin_instances(plugin_instances_request( + "demo-plugin", + vec![saved_plugin_instance(10.0)], + uuid::Uuid::new_v4().to_string(), + None, + Some(store.plugin_model_revision()), + )) + .unwrap(); + drop(committed); + let stale_snapshot = store.plugin_instances_get("demo-plugin").unwrap().0; + assert!(!stale_snapshot.is_empty()); + + let store_guard = store.state.write(); + let reconcile_store = Arc::clone(&store); + let reconcile = thread::spawn(move || { + let result = + reconcile_store.reconcile_plugin_instances(plugin_instances_reconcile_request( + "demo-plugin", + &["4key"], + uuid::Uuid::new_v4().to_string(), + )); + result.map(|outcome| outcome.outcome.changed) + }); + let clear_store = Arc::clone(&store); + let clear = thread::spawn(move || { + let result = clear_store.remove_plugin_data_by_prefix("plugin_data_demo-plugin/"); + result.map(|outcome| outcome.value) + }); + + let gate = store.history_gate(); + let deadline = Instant::now() + Duration::from_secs(2); + while gate.active_mutations() < 2 { + assert!( + Instant::now() < deadline, + "plugin mutations were not admitted" + ); + thread::yield_now(); + } + drop(store_guard); + + reconcile.join().unwrap().unwrap(); + assert_eq!(clear.join().unwrap().unwrap(), 1); + assert!(store + .plugin_instances_get("demo-plugin") + .unwrap() + .0 + .is_empty()); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn generic_instance_storage_set_and_remove_are_reserved() { + let dir = test_directory("plugin-instances-reserved-key-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let committed = store + .commit_plugin_instances(plugin_instances_request( + "demo-plugin", + vec![saved_plugin_instance(10.0)], + uuid::Uuid::new_v4().to_string(), + None, + Some(store.plugin_model_revision()), + )) + .unwrap(); + drop(committed); + + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + let counters = store.snapshot().key_counters; + store + .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) + .unwrap(); + drop(barrier); + assert!(store.history_status().can_redo); + let revision = store.plugin_model_revision(); + + let set_error = store + .set_plugin_data( + "plugin_data_demo-plugin/instances", + serde_json::to_value(vec![saved_plugin_instance(90.0)]).unwrap(), + ) + .unwrap_err(); + let remove_error = store + .remove_plugin_data("plugin_data_demo-plugin/instances") + .unwrap_err(); + + assert_eq!(set_error.to_string(), "PLUGIN_INSTANCES_KEY_RESERVED"); + assert_eq!(remove_error.to_string(), "PLUGIN_INSTANCES_KEY_RESERVED"); + assert_eq!(store.plugin_model_revision(), revision); + assert!(store.history_status().can_redo); + assert!(store + .plugin_instances_get("demo-plugin") + .unwrap() + .0 + .is_empty()); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn plugin_namespace_clear_invalidates_history_after_instances_are_already_empty() { + let dir = test_directory("plugin-empty-instances-history-clear-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + + for plugin_id in ["alpha", "beta"] { + drop( + store + .commit_plugin_instances(plugin_instances_request( + plugin_id, + vec![saved_plugin_instance(10.0)], + uuid::Uuid::new_v4().to_string(), + None, + Some(store.plugin_model_revision()), + )) + .unwrap(), + ); + drop( + store + .commit_plugin_instances(plugin_instances_request( + plugin_id, + Vec::new(), + uuid::Uuid::new_v4().to_string(), + None, + Some(store.plugin_model_revision()), + )) + .unwrap(), + ); + assert!(store.history_status().can_undo); + assert!(store.plugin_instances_get(plugin_id).unwrap().0.is_empty()); + + if plugin_id == "alpha" { + drop( + store + .set_plugin_data("plugin_data_alpha/cache/item", json!(true)) + .unwrap(), + ); + let nested = store + .remove_plugin_data_by_prefix("plugin_data_alpha/cache/") + .unwrap(); + assert_eq!(nested.value, 1); + assert!(nested.history_status.is_none()); + assert!(store.history_status().can_undo); + + drop( + store + .set_plugin_data("plugin_data_alpha/preferences", json!(true)) + .unwrap(), + ); + let namespace = store + .remove_plugin_data_by_prefix("plugin_data_alpha/") + .unwrap(); + assert_eq!(namespace.value, 1); + let status = namespace.history_status.unwrap(); + assert!(!status.can_undo); + assert!(!status.can_redo); + } else { + assert!(store.snapshot().plugin_data.is_empty()); + let clear = store.clear_all_plugin_data().unwrap(); + let status = clear.history_status.unwrap(); + assert!(!status.can_undo); + assert!(!status.can_redo); + } + } + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn bulk_plugin_storage_deletion_reports_each_instance_revision() { + let dir = test_directory("plugin-instances-bulk-delete-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + + for plugin_id in ["alpha", "beta"] { + let committed = store + .commit_plugin_instances(plugin_instances_request( + plugin_id, + vec![saved_plugin_instance(10.0)], + uuid::Uuid::new_v4().to_string(), + None, + Some(store.plugin_model_revision()), + )) + .unwrap(); + drop(committed); + } + drop( + store + .set_plugin_data("plugin_data_alpha/preferences", json!({ "enabled": true })) + .unwrap(), + ); + + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + let counters = store.snapshot().key_counters; + store + .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) + .unwrap(); + drop(barrier); + assert!(store.history_status().can_redo); + + let prefix_revision = store.plugin_model_revision() + 1; + let prefix_delete = store + .remove_plugin_data_by_prefix("plugin_data_alpha/") + .unwrap(); + assert_eq!(prefix_delete.value, 2); + assert!(!prefix_delete.history_status.as_ref().unwrap().can_undo); + assert!(!prefix_delete.history_status.as_ref().unwrap().can_redo); + assert_eq!( + prefix_delete.plugin_instances_changes, + vec![super::PluginInstancesStorageChange { + plugin_id: "alpha".to_string(), + revision: prefix_revision, + }] + ); + drop(prefix_delete); + assert!(store.plugin_instances_get("alpha").unwrap().0.is_empty()); + assert!(store.plugin_instances_get("beta").unwrap().0.is_empty()); + + for plugin_id in ["beta", "gamma"] { + let committed = store + .commit_plugin_instances(plugin_instances_request( + plugin_id, + vec![saved_plugin_instance(20.0)], + uuid::Uuid::new_v4().to_string(), + None, + Some(store.plugin_model_revision()), + )) + .unwrap(); + drop(committed); + } + + let clear_revision = store.plugin_model_revision() + 1; + let clear = store.clear_all_plugin_data().unwrap(); + assert!(!clear.history_status.as_ref().unwrap().can_undo); + assert!(!clear.history_status.as_ref().unwrap().can_redo); + assert_eq!( + clear.plugin_instances_changes, + vec![ + super::PluginInstancesStorageChange { + plugin_id: "beta".to_string(), + revision: clear_revision, + }, + super::PluginInstancesStorageChange { + plugin_id: "gamma".to_string(), + revision: clear_revision, + }, + ] + ); + drop(clear); + assert_eq!(store.plugin_model_revision(), clear_revision); + assert!(store.plugin_instances_get("beta").unwrap().0.is_empty()); + assert!(store.plugin_instances_get("gamma").unwrap().0.is_empty()); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn strict_editor_commit_persists_once_and_synchronizes_key_counters() { + let dir = test_directory("strict-editor-success-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = initialize_neutral_editor_store(&dir); + let persist_count = store.writer.persist_count(); + let before = store.snapshot(); + let old_key = before.keys["4key"][0].canonical(); + let mut keys = before.keys.clone(); + keys.get_mut("4key").unwrap()[0] = "StrictKey".into(); + let first_gesture_id = uuid::Uuid::new_v4().to_string(); + let gesture_id = uuid::Uuid::new_v4().to_string(); + let mut request = editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + keys: Some(keys), + ..EditorPatchV1::default() + }, + ); + request.gesture_id = Some(gesture_id.clone()); + request.gesture_ids = vec![ + first_gesture_id.clone(), + first_gesture_id.clone(), + gesture_id.clone(), + ]; + + let change = store.commit_editor_document(request).unwrap(); + + assert_eq!(change.result.revision, 1); + assert_eq!(change.result.changed_fields, vec![EditorField::Keys]); + assert_eq!(change.event.as_ref().unwrap().origin, "editorCommit"); + assert_eq!( + change.event.as_ref().unwrap().gesture_id.as_deref(), + Some(gesture_id.as_str()) + ); + assert_eq!( + change.event.as_ref().unwrap().gesture_ids, + vec![first_gesture_id, gesture_id] + ); + assert_eq!(store.writer.persist_count(), persist_count + 1); + let snapshot = store.snapshot(); + assert_eq!(snapshot.editor_revision, 1); + assert_eq!(snapshot.keys["4key"][0], KeySlot::from("StrictKey")); + assert_eq!(snapshot.key_counters["4key"]["StrictKey"], 0); + assert!(!snapshot.key_counters["4key"].contains_key(&old_key)); + assert_eq!(change.history_status.unwrap().history_revision, 1); + assert!(store.history_status().can_undo); + + store.flush_and_shutdown().unwrap(); + drop(store); + let reloaded = crate::state::migration::load_store_from_path(&dir.join("store.json")) + .unwrap() + .data; + assert_eq!(reloaded.editor_revision, 1); + assert_eq!(reloaded.keys["4key"][0], KeySlot::from("StrictKey")); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn fresh_install_has_globally_unique_native_element_ids() { + let dir = test_directory("fresh-native-element-ids-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let document = store.editor_get().document; + + crate::state::native_element_id::validate_document_element_ids(&document).unwrap(); + assert!(document + .key_positions + .values() + .flatten() + .all(|position| uuid::Uuid::parse_str(&position.id) + .is_ok_and(|id| id.get_version_num() == 4))); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn legacy_position_replacements_reject_noncanonical_ids_atomically() { + enum PositionReplacement { + Stat(crate::models::StatPositions), + Graph(crate::models::GraphPositions), + Knob(crate::models::KnobPositions), + } + + fn with_raw_position_id(mut payload: serde_json::Value, id: Option<&str>) -> serde_json::Value { + let position = payload["4key"][0] + .as_object_mut() + .expect("serialized position object"); + match id { + Some(id) => { + position.insert("id".to_string(), serde_json::json!(id)); + } + None => { + position.remove("id"); + } + } + payload + } + + let dir = test_directory("legacy-position-id-validation-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let baseline = store.editor_get(); + let baseline_snapshot = store.snapshot(); + let baseline_persist_count = store.writer.persist_count(); + let duplicate_id = baseline.document.key_positions["4key"][0].id.clone(); + + let stat_payload = serde_json::to_value(HashMap::from([( + "4key".to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: KeyPosition { + id: uuid::Uuid::new_v4().to_string(), + ..KeyPosition::default() + }, + }], + )])) + .unwrap(); + let graph_payload = serde_json::to_value(HashMap::from([( + "4key".to_string(), + vec![GraphPosition { + stat_type: GraphStatType::Kps, + graph_type: GraphType::Line, + graph_speed: 1000, + graph_color: "#ffffff".to_string(), + show_avg_line: true, + position: KeyPosition { + id: uuid::Uuid::new_v4().to_string(), + ..KeyPosition::default() + }, + }], + )])) + .unwrap(); + let knob_payload = serde_json::to_value(HashMap::from([( + "4key".to_string(), + vec![KnobPosition { + axis_id: String::new(), + sensitivity: 1.0, + reverse: false, + position: KeyPosition { + id: uuid::Uuid::new_v4().to_string(), + ..KeyPosition::default() + }, + }], + )])) + .unwrap(); + + let invalid_cases = vec![ + ( + EditorField::StatPositions, + "MISSING_ELEMENT_ID", + PositionReplacement::Stat( + serde_json::from_value(with_raw_position_id(stat_payload.clone(), None)).unwrap(), + ), + ), + ( + EditorField::GraphPositions, + "INVALID_ELEMENT_ID", + PositionReplacement::Graph( + serde_json::from_value(with_raw_position_id( + graph_payload.clone(), + Some("not-a-uuid"), + )) + .unwrap(), + ), + ), + ( + EditorField::KnobPositions, + "INVALID_ELEMENT_ID", + PositionReplacement::Knob( + serde_json::from_value(with_raw_position_id( + knob_payload, + Some(&uuid::Uuid::nil().to_string()), + )) + .unwrap(), + ), + ), + ( + EditorField::StatPositions, + "DUPLICATE_ELEMENT_ID", + PositionReplacement::Stat( + serde_json::from_value(with_raw_position_id(stat_payload, Some(&duplicate_id))) + .unwrap(), + ), + ), + ]; + + for (field, expected_code, replacement) in invalid_cases { + let error = legacy_editor_commit(&store, &[field], move |data| match replacement { + PositionReplacement::Stat(positions) => data.stat_positions = positions, + PositionReplacement::Graph(positions) => data.graph_positions = positions, + PositionReplacement::Knob(positions) => data.knob_positions = positions, + }) + .unwrap_err(); + assert_eq!(error.error_code, EditorCommitErrorCode::ValidationFailed); + assert!(!error.retryable); + assert_eq!( + error.details.unwrap().validation_code.as_deref(), + Some(expected_code) + ); + assert_eq!(store.editor_get(), baseline); + assert_eq!(store.snapshot(), baseline_snapshot); + assert_eq!(store.writer.persist_count(), baseline_persist_count); + } + + let valid_stat_id = uuid::Uuid::new_v4().to_string(); + let valid_stat = legacy_editor_commit(&store, &[EditorField::StatPositions], |data| { + data.stat_positions.insert( + "4key".to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: KeyPosition { + id: valid_stat_id.clone(), + ..KeyPosition::default() + }, + }], + ); + }) + .unwrap(); + assert_eq!( + valid_stat.result.changed_fields, + vec![EditorField::StatPositions] + ); + assert_eq!( + valid_stat.document.stat_positions["4key"][0].position.id, + valid_stat_id + ); + + let valid_graph_id = uuid::Uuid::new_v4().to_string(); + let valid_graph = legacy_editor_commit(&store, &[EditorField::GraphPositions], |data| { + data.graph_positions.insert( + "4key".to_string(), + vec![GraphPosition { + stat_type: GraphStatType::Kps, + graph_type: GraphType::Line, + graph_speed: 1000, + graph_color: "#ffffff".to_string(), + show_avg_line: true, + position: KeyPosition { + id: valid_graph_id.clone(), + ..KeyPosition::default() + }, + }], + ); + }) + .unwrap(); + assert_eq!( + valid_graph.result.changed_fields, + vec![EditorField::GraphPositions] + ); + assert_eq!( + valid_graph.document.graph_positions["4key"][0].position.id, + valid_graph_id + ); + + let valid_knob_id = uuid::Uuid::new_v4().to_string(); + let valid_knob = legacy_editor_commit(&store, &[EditorField::KnobPositions], |data| { + data.knob_positions.insert( + "4key".to_string(), + vec![KnobPosition { + axis_id: String::new(), + sensitivity: 1.0, + reverse: false, + position: KeyPosition { + id: valid_knob_id.clone(), + ..KeyPosition::default() + }, + }], + ); + }) + .unwrap(); + assert_eq!( + valid_knob.result.changed_fields, + vec![EditorField::KnobPositions] + ); + assert_eq!( + valid_knob.document.knob_positions["4key"][0].position.id, + valid_knob_id + ); + + crate::state::native_element_id::validate_document_element_ids(&store.editor_get().document) + .unwrap(); + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn alternate_uuid_spellings_remain_raw_distinct_through_load_bootstrap_and_event() { + let dir = test_directory("alternate-native-element-id-spellings-test"); + std::fs::create_dir_all(&dir).unwrap(); + let uuid = uuid::Uuid::new_v4(); + let ids = vec![ + uuid.simple().to_string(), + uuid.braced().to_string(), + uuid.urn().to_string(), + uuid.hyphenated().to_string().to_uppercase(), + ]; + let mut data = initialize_default_state(); + for (position, id) in data + .key_positions + .get_mut("4key") + .unwrap() + .iter_mut() + .zip(&ids) + { + position.id.clone_from(id); + position.hidden = false; + } + std::fs::write( + dir.join("store.json"), + serde_json::to_vec_pretty(&data).unwrap(), + ) + .unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let loaded = store.editor_get(); + let loaded_ids = loaded.document.key_positions["4key"] + .iter() + .take(ids.len()) + .map(|position| position.id.clone()) + .collect::>(); + assert_eq!(loaded_ids, ids); + crate::state::native_element_id::validate_document_element_ids(&loaded.document).unwrap(); + + let state = AppState::initialize(store).unwrap(); + let bootstrap = state.bootstrap_payload(); + assert_eq!( + bootstrap.positions["4key"] + .iter() + .take(ids.len()) + .map(|position| position.id.clone()) + .collect::>(), + ids + ); + + let change = state + .store + .commit_editor_document(editor_ops_request( + bootstrap.editor_revision, + uuid::Uuid::new_v4().to_string(), + vec![patch_hidden_op(EditorElementTypeV1::Key, &ids[0], true)], + )) + .unwrap(); + assert_eq!( + change.document.key_positions["4key"] + .iter() + .take(ids.len()) + .map(|position| position.id.clone()) + .collect::>(), + ids + ); + assert!(change.document.key_positions["4key"][0].hidden); + assert!(change.document.key_positions["4key"][1..ids.len()] + .iter() + .all(|position| !position.hidden)); + let event_positions = change + .event + .as_ref() + .unwrap() + .patch + .key_positions + .as_ref() + .unwrap(); + assert_eq!( + event_positions["4key"] + .iter() + .take(ids.len()) + .map(|position| position.id.clone()) + .collect::>(), + ids + ); + + state.shutdown(); + drop(state); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn v2_editor_commit_rejects_invalid_ids_atomically_and_keeps_read_event_v1() { + let dir = test_directory("v2-native-element-id-commit-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + legacy_editor_commit(&store, &[EditorField::StatPositions], |data| { + data.stat_positions.insert( + "4key".to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: KeyPosition { + id: uuid::Uuid::new_v4().to_string(), + ..KeyPosition::default() + }, + }], + ); + }) + .unwrap(); + let baseline = store.editor_get(); + let persist_count = store.writer.persist_count(); + + let mut invalid_patches = Vec::new(); + let mut missing = baseline.document.key_positions.clone(); + missing.get_mut("4key").unwrap()[0].id.clear(); + invalid_patches.push((missing, "MISSING_ELEMENT_ID")); + + let mut malformed = baseline.document.key_positions.clone(); + malformed.get_mut("4key").unwrap()[0].id = "not-a-uuid".to_string(); + invalid_patches.push((malformed, "INVALID_ELEMENT_ID")); + + let mut nil = baseline.document.key_positions.clone(); + nil.get_mut("4key").unwrap()[0].id = uuid::Uuid::nil().to_string(); + invalid_patches.push((nil, "INVALID_ELEMENT_ID")); + + let mut merged_duplicate = baseline.document.key_positions.clone(); + merged_duplicate.get_mut("4key").unwrap()[0].id = baseline.document.stat_positions["4key"][0] + .position + .id + .clone(); + invalid_patches.push((merged_duplicate, "DUPLICATE_ELEMENT_ID")); + + for (positions, expected_code) in invalid_patches { + let error = store + .commit_editor_document(editor_request( + baseline.revision, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, + key_positions: Some(positions), + ..EditorPatchV1::default() + }, + )) + .unwrap_err(); + assert_eq!(error.error_code, EditorCommitErrorCode::ValidationFailed); + assert!(!error.retryable); + assert_eq!( + error.details.unwrap().validation_code.as_deref(), + Some(expected_code) + ); + assert_eq!(store.editor_get(), baseline); + assert_eq!(store.writer.persist_count(), persist_count); + } + + let mut valid = baseline.document.key_positions.clone(); + valid.get_mut("4key").unwrap()[0].dx += 1.0; + let change = store + .commit_editor_document(editor_request( + baseline.revision, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, + key_positions: Some(valid), + ..EditorPatchV1::default() + }, + )) + .unwrap(); + + assert_eq!(change.document.schema_version, EDITOR_SCHEMA_VERSION); + assert_eq!( + change.event.as_ref().unwrap().schema_version, + EDITOR_SCHEMA_VERSION + ); + assert_eq!( + change.event.as_ref().unwrap().patch.schema_version, + EDITOR_SCHEMA_VERSION + ); + assert!(change + .document + .key_positions + .values() + .flatten() + .all(|position| !position.id.is_empty())); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn v1_stale_snapshot_commit_rekeys_deleted_element_instead_of_reviving_it() { + let dir = test_directory("v1-stale-native-element-id-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = initialize_neutral_editor_store(&dir); + let initial = store.editor_get(); + let stale_keys = initial.document.keys.clone(); + let stale_positions = initial.document.key_positions.clone(); + let deleted_id = stale_positions["4key"][0].id.clone(); + + let mut deleted_keys = stale_keys.clone(); + let mut deleted_positions = stale_positions.clone(); + deleted_keys.get_mut("4key").unwrap().remove(0); + deleted_positions.get_mut("4key").unwrap().remove(0); + store + .commit_editor_document(editor_request( + initial.revision, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + keys: Some(deleted_keys), + key_positions: Some(deleted_positions), + ..EditorPatchV1::default() + }, + )) + .unwrap(); + + let after_delete = store.editor_get(); + let restored = store + .commit_editor_document(editor_request( + after_delete.revision, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + keys: Some(stale_keys), + key_positions: Some(stale_positions), + ..EditorPatchV1::default() + }, + )) + .unwrap(); + + assert_ne!(restored.document.key_positions["4key"][0].id, deleted_id); + assert!(crate::state::native_element_id::is_valid_element_id( + &restored.document.key_positions["4key"][0].id + )); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn strict_editor_commit_rechecks_multi_key_capability_under_store_lock() { + let dir = test_directory("multi-key-capability-gate-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial = store.editor_get().document; + + let mut legacy_keys = initial.keys.clone(); + legacy_keys.get_mut("4key").unwrap()[0] = KeySlot::Single("Legacy".to_string()); + let legacy_request = editor_request( + 1, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + keys: Some(legacy_keys), + ..EditorPatchV1::default() + }, + ); + + let mut multi_keys = initial.keys; + multi_keys.get_mut("4key").unwrap()[0] = KeySlot::Multi { + keys: vec!["A".to_string(), "B".to_string()], + match_mode: SlotMatch::Any, + }; + let mut capable_request = editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + keys: Some(multi_keys), + ..EditorPatchV1::default() + }, + ); + capable_request.multi_key = true; + store.commit_editor_document(capable_request).unwrap(); + let committed_keys = serde_json::to_vec(&store.snapshot().keys).unwrap(); + let committed_disk = std::fs::read(dir.join("store.json")).unwrap(); + let persist_count = store.writer.persist_count(); + + let error = store.commit_editor_document(legacy_request).unwrap_err(); + + assert_eq!(error.error_code, EditorCommitErrorCode::MultiKeyUnsupported); + assert_eq!( + serde_json::to_value(&error).unwrap()["errorCode"], + "MULTI_KEY_UNSUPPORTED" + ); + assert!(!error.retryable); + assert_eq!(store.snapshot().editor_revision, 1); + assert_eq!( + serde_json::to_vec(&store.snapshot().keys).unwrap(), + committed_keys + ); + assert_eq!(store.writer.persist_count(), persist_count); + assert_eq!( + std::fs::read(dir.join("store.json")).unwrap(), + committed_disk + ); + + let position_change = store + .commit_editor_document(editor_request( + 1, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, 321.0), + )) + .unwrap(); + assert_eq!(position_change.result.revision, 2); + assert_eq!( + serde_json::to_vec(&store.snapshot().keys).unwrap(), + committed_keys + ); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn editor_commit_history_skips_no_op_and_deduplicates_mutation() { + let dir = test_directory("editor-history-recording-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_dx = store.editor_get().document.key_positions["4key"][0].dx; + + let no_op = editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_dx), + ); + let no_op_change = store.commit_editor_document(no_op).unwrap(); + assert!(no_op_change.result.changed_fields.is_empty()); + assert_eq!(store.history_status().history_revision, 0); + + let mutation_id = uuid::Uuid::new_v4().to_string(); + let request = editor_request(0, mutation_id, position_patch(&store, initial_dx + 12.0)); + let committed = store.commit_editor_document(request.clone()).unwrap(); + assert_eq!(committed.history_status.unwrap().history_revision, 1); + + let replayed = store.commit_editor_document(request).unwrap(); + assert!(replayed.replayed); + assert!(replayed.history_status.is_none()); + assert_eq!(store.history_status().history_revision, 1); + + let repeated_value = editor_request( + 1, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_dx + 12.0), + ); + let repeated_value = store.commit_editor_document(repeated_value).unwrap(); + assert!(repeated_value.result.changed_fields.is_empty()); + assert_eq!(store.history_status().history_revision, 1); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn editor_ops_follow_stable_id_and_restore_the_latest_snapshot_on_undo() { + let dir = test_directory("editor-ops-stable-id-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = initialize_neutral_editor_store(&dir); + let initial = store.editor_get().document; + let target = initial.key_positions["4key"][0].clone(); + let initial_bounds = bounds(&target); + + let mut reordered_keys = initial.keys.clone(); + reordered_keys.get_mut("4key").unwrap().swap(0, 1); + let mut reordered_positions = initial.key_positions.clone(); + reordered_positions.get_mut("4key").unwrap().swap(0, 1); + store + .commit_editor_document(editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + keys: Some(reordered_keys), + key_positions: Some(reordered_positions), + ..EditorPatchV1::default() + }, + )) + .unwrap(); + + let changed_bounds = EditorBoundsV1 { + dx: initial_bounds.dx + 17.0, + dy: initial_bounds.dy + 19.0, + width: initial_bounds.width + 23.0, + height: initial_bounds.height + 29.0, + }; + let change = store + .commit_editor_document(editor_ops_request( + 1, + uuid::Uuid::new_v4().to_string(), + vec![set_bounds_op( + EditorElementTypeV1::Key, + &target.id, + changed_bounds, + )], + )) + .unwrap(); + + assert_eq!(change.result.revision, 2); + assert_eq!( + change.result.changed_fields, + vec![EditorField::KeyPositions] + ); + assert_eq!( + change.result.op_results.as_ref().unwrap()[0].status, + EditorOpResultStatusV1::Applied + ); + assert_eq!( + change.result.op_results.as_ref().unwrap()[0].bounds, + Some(changed_bounds) + ); + assert_eq!( + change.event.as_ref().unwrap().patch.schema_version, + EDITOR_SCHEMA_VERSION + ); + assert!(change.event.as_ref().unwrap().patch.key_positions.is_some()); + assert!(change.event.as_ref().unwrap().patch.keys.is_none()); + assert_eq!(change.document.key_positions["4key"][1].id, target.id); + assert_eq!( + bounds(&change.document.key_positions["4key"][1]), + changed_bounds + ); + + let undo_id = uuid::Uuid::new_v4().to_string(); + let gate = store.history_gate(); + let barrier = gate.close(&undo_id).unwrap(); + let current_counters = store.snapshot().key_counters; + store + .apply_history_operation(HistoryDirection::Undo, &undo_id, ¤t_counters, || {}) + .unwrap(); + drop(barrier); + + let after_undo = store.editor_get().document; + assert_eq!(after_undo.key_positions["4key"][1].id, target.id); + assert_eq!(bounds(&after_undo.key_positions["4key"][1]), initial_bounds); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + let current_counters = store.snapshot().key_counters; + store + .apply_history_operation(HistoryDirection::Redo, &redo_id, ¤t_counters, || {}) + .unwrap(); + drop(barrier); + let after_redo = store.editor_get().document; + assert_eq!(after_redo.key_positions["4key"][1].id, target.id); + assert_eq!(bounds(&after_redo.key_positions["4key"][1]), changed_bounds); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn property_and_key_slot_ops_replay_exact_results_and_round_trip_history() { + let dir = test_directory("editor-property-slot-ops-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial = store.editor_get(); + let property_id = initial.document.key_positions["4key"][0].id.clone(); + let slot_id = initial.document.key_positions["4key"][1].id.clone(); + let layer_name_id = initial.document.key_positions["4key"][2].id.clone(); + let initial_hidden = initial.document.key_positions["4key"][0].hidden; + let initial_slot = initial.document.keys["4key"][1].clone(); + let initial_layer_name = initial.document.key_positions["4key"][2].layer_name.clone(); + let initial_slot_canonical = initial_slot.canonical(); + let missing_id = uuid::Uuid::new_v4().to_string(); + let mutation_id = uuid::Uuid::new_v4().to_string(); + let request = editor_ops_request( + initial.revision, + &mutation_id, + vec![ + patch_hidden_op(EditorElementTypeV1::Key, &property_id, !initial_hidden), + EditorOpV1::SetKeySlot { + id: slot_id.clone(), + slot: EditorFrozenKeySlotV1::Multi(crate::models::EditorFrozenMultiKeySlotV1 { + keys: vec!["A".to_string(), "B".to_string()], + match_mode: SlotMatch::All, + }), + }, + patch_layer_name_op( + EditorElementTypeV1::Key, + &layer_name_id, + Some("Named layer"), + ), + patch_hidden_op(EditorElementTypeV1::Graph, missing_id, true), + ], + ); + + let committed = store.commit_editor_document(request.clone()).unwrap(); + assert_eq!( + committed.result.changed_fields, + [EditorField::Keys, EditorField::KeyPositions] + ); + assert_eq!( + committed + .result + .op_results + .as_ref() + .unwrap() + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + assert_eq!(store.history_status().history_revision, 1); + let committed_store = store.snapshot(); + assert!(committed_store.key_counters["4key"].contains_key("A+B")); + assert!(!committed_store.key_counters["4key"].contains_key(&initial_slot_canonical)); + + let replay = store.commit_editor_document(request).unwrap(); + assert!(replay.replayed); + assert_eq!(replay.result, committed.result); + assert_eq!(store.history_status().history_revision, 1); + + let reused = store + .commit_editor_document(editor_ops_request( + initial.revision, + mutation_id, + vec![patch_hidden_op( + EditorElementTypeV1::Key, + &property_id, + initial_hidden, + )], + )) + .unwrap_err(); + assert_eq!(reused.error_code, EditorCommitErrorCode::MutationIdReused); + + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Undo, + &undo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let after_undo = store.editor_get().document; + assert_eq!(after_undo.key_positions["4key"][0].hidden, initial_hidden); + assert_eq!(after_undo.keys["4key"][1], initial_slot); + assert_eq!( + after_undo.key_positions["4key"][2].layer_name, + initial_layer_name + ); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Redo, + &redo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let after_redo = store.editor_get().document; + assert_eq!(after_redo.key_positions["4key"][0].hidden, !initial_hidden); + assert_eq!( + after_redo.key_positions["4key"][2].layer_name.as_deref(), + Some("Named layer") + ); + assert_eq!( + after_redo.keys["4key"][1], + KeySlot::Multi { + keys: vec!["A".to_string(), "B".to_string()], + match_mode: SlotMatch::All, + } + ); + + let no_change = store + .commit_editor_document(editor_ops_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + vec![ + patch_hidden_op(EditorElementTypeV1::Key, property_id, !initial_hidden), + EditorOpV1::SetKeySlot { + id: slot_id, + slot: EditorFrozenKeySlotV1::Multi(crate::models::EditorFrozenMultiKeySlotV1 { + keys: vec!["A".to_string(), "B".to_string()], + match_mode: SlotMatch::All, + }), + }, + patch_layer_name_op(EditorElementTypeV1::Key, layer_name_id, Some("Named layer")), + ], + )) + .unwrap(); + assert!(no_change.result.changed_fields.is_empty()); + assert!(no_change.event.is_none()); + assert_eq!( + no_change + .result + .op_results + .unwrap() + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange + ] + ); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn layer_name_null_clear_replays_and_round_trips_history() { + let dir = test_directory("editor-layer-name-clear-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial = store.editor_get(); + let id = initial.document.key_positions["4key"][0].id.clone(); + + store + .commit_editor_document(editor_ops_request( + initial.revision, + uuid::Uuid::new_v4().to_string(), + vec![patch_layer_name_op( + EditorElementTypeV1::Key, + &id, + Some("Named layer"), + )], + )) + .unwrap(); + + let clear_mutation_id = uuid::Uuid::new_v4().to_string(); + let clear_request = editor_ops_request( + store.editor_get().revision, + &clear_mutation_id, + vec![patch_layer_name_op(EditorElementTypeV1::Key, &id, None)], + ); + let cleared = store.commit_editor_document(clear_request.clone()).unwrap(); + assert_eq!( + cleared.result.op_results.as_ref().unwrap()[0].status, + EditorOpResultStatusV1::Applied + ); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].layer_name, + None + ); + assert_eq!(store.history_status().history_revision, 2); + + let replay = store.commit_editor_document(clear_request).unwrap(); + assert!(replay.replayed); + assert_eq!(replay.result, cleared.result); + assert_eq!(store.history_status().history_revision, 2); + + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Undo, + &undo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + assert_eq!( + store.editor_get().document.key_positions["4key"][0] + .layer_name + .as_deref(), + Some("Named layer") + ); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Redo, + &redo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].layer_name, + None + ); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn graph_type_batch_replays_and_round_trips_one_history_entry() { + let dir = test_directory("editor-graph-type-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let mut document = store.editor_get().document; + let template = document.key_positions["4key"][0].clone(); + let first_id = uuid::Uuid::new_v4().to_string(); + let second_id = uuid::Uuid::new_v4().to_string(); + document.graph_positions.insert( + "4key".to_string(), + vec![ + GraphPosition { + stat_type: GraphStatType::Kps, + graph_type: GraphType::Line, + graph_speed: 1000, + graph_color: "#111111".to_string(), + show_avg_line: true, + position: KeyPosition { + id: first_id.clone(), + ..template.clone() + }, + }, + GraphPosition { + stat_type: GraphStatType::Total, + graph_type: GraphType::Line, + graph_speed: 2000, + graph_color: "#222222".to_string(), + show_avg_line: false, + position: KeyPosition { + id: second_id.clone(), + ..template + }, + }, + ], + ); + let setup = store + .commit_editor_document(editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, + graph_positions: Some(document.graph_positions), + ..EditorPatchV1::default() + }, + )) + .unwrap(); + let missing_id = uuid::Uuid::new_v4().to_string(); + let mutation_id = uuid::Uuid::new_v4().to_string(); + let request = editor_ops_request( + setup.result.revision, + &mutation_id, + vec![ + patch_graph_type_op(&first_id, GraphType::Bar), + patch_graph_type_op(&second_id, GraphType::Bar), + patch_graph_type_op(missing_id, GraphType::Bar), + ], + ); + + let changed = store.commit_editor_document(request.clone()).unwrap(); + assert_eq!(changed.result.changed_fields, [EditorField::GraphPositions]); + assert_eq!( + changed + .result + .op_results + .as_ref() + .unwrap() + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + assert!(changed.document.graph_positions["4key"] + .iter() + .all(|graph| graph.graph_type == GraphType::Bar)); + assert_eq!(store.history_status().history_revision, 2); + + let replay = store.commit_editor_document(request.clone()).unwrap(); + assert!(replay.replayed); + assert_eq!(replay.result, changed.result); + assert_eq!(store.history_status().history_revision, 2); + + let mut reused = request; + reused.ops = Some(vec![patch_graph_type_op(&first_id, GraphType::Line)]); + assert_eq!( + store.commit_editor_document(reused).unwrap_err().error_code, + EditorCommitErrorCode::MutationIdReused + ); + + let no_change = store + .commit_editor_document(editor_ops_request( + changed.result.revision, + uuid::Uuid::new_v4().to_string(), + vec![ + patch_graph_type_op(&first_id, GraphType::Bar), + patch_graph_type_op(&second_id, GraphType::Bar), + ], + )) + .unwrap(); + assert!(no_change.result.changed_fields.is_empty()); + assert!(no_change.event.is_none()); + assert_eq!(store.history_status().history_revision, 2); + + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Undo, + &undo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + assert!(store.editor_get().document.graph_positions["4key"] + .iter() + .all(|graph| graph.graph_type == GraphType::Line)); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Redo, + &redo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + assert!(store.editor_get().document.graph_positions["4key"] + .iter() + .all(|graph| graph.graph_type == GraphType::Bar)); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn graph_color_batch_replays_raw_strings_and_round_trips_history() { + let dir = test_directory("editor-graph-color-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let mut document = store.editor_get().document; + let template = document.key_positions["4key"][0].clone(); + let first_id = uuid::Uuid::new_v4().to_string(); + let second_id = uuid::Uuid::new_v4().to_string(); + document.graph_positions.insert( + "4key".to_string(), + vec![ + GraphPosition { + stat_type: GraphStatType::Kps, + graph_type: GraphType::Line, + graph_speed: 1000, + graph_color: "first".to_string(), + show_avg_line: true, + position: KeyPosition { + id: first_id.clone(), + ..template.clone() + }, + }, + GraphPosition { + stat_type: GraphStatType::Total, + graph_type: GraphType::Bar, + graph_speed: 2000, + graph_color: "second".to_string(), + show_avg_line: false, + position: KeyPosition { + id: second_id.clone(), + ..template + }, + }, + ], + ); + let setup = store + .commit_editor_document(editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, + graph_positions: Some(document.graph_positions), + ..EditorPatchV1::default() + }, + )) + .unwrap(); + let raw_color = "color(display-p3 1 0 0 / .5)"; + let mutation_id = uuid::Uuid::new_v4().to_string(); + let request = editor_ops_request( + setup.result.revision, + &mutation_id, + vec![ + patch_graph_color_op(&first_id, raw_color), + patch_graph_color_op(&second_id, raw_color), + patch_graph_color_op(uuid::Uuid::new_v4().to_string(), ""), + ], + ); + + let changed = store.commit_editor_document(request.clone()).unwrap(); + assert_eq!(changed.result.changed_fields, [EditorField::GraphPositions]); + assert_eq!( + changed + .result + .op_results + .as_ref() + .unwrap() + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + assert!(changed.document.graph_positions["4key"] + .iter() + .all(|graph| graph.graph_color == raw_color)); + assert_eq!(store.history_status().history_revision, 2); + + let replay = store.commit_editor_document(request.clone()).unwrap(); + assert!(replay.replayed); + assert_eq!(replay.result, changed.result); + + let mut reused = request; + reused.ops = Some(vec![patch_graph_color_op(&first_id, "different")]); + assert_eq!( + store.commit_editor_document(reused).unwrap_err().error_code, + EditorCommitErrorCode::MutationIdReused + ); + + let no_change = store + .commit_editor_document(editor_ops_request( + changed.result.revision, + uuid::Uuid::new_v4().to_string(), + vec![ + patch_graph_color_op(&first_id, raw_color), + patch_graph_color_op(&second_id, raw_color), + ], + )) + .unwrap(); + assert!(no_change.result.changed_fields.is_empty()); + assert!(no_change.event.is_none()); + assert_eq!(store.history_status().history_revision, 2); + + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Undo, + &undo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + assert_eq!( + store.editor_get().document.graph_positions["4key"] + .iter() + .map(|graph| graph.graph_color.as_str()) + .collect::>(), + ["first", "second"] + ); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Redo, + &redo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + assert!(store.editor_get().document.graph_positions["4key"] + .iter() + .all(|graph| graph.graph_color == raw_color)); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn graph_and_knob_literal_batch_replays_and_round_trips_one_history_entry() { + let dir = test_directory("editor-graph-knob-literal-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let mut document = store.editor_get().document; + let template = document.key_positions["4key"][0].clone(); + let graph_ids = (0..3) + .map(|_| uuid::Uuid::new_v4().to_string()) + .collect::>(); + let knob_ids = (0..3) + .map(|_| uuid::Uuid::new_v4().to_string()) + .collect::>(); + document.graph_positions.insert( + "4key".to_string(), + graph_ids + .iter() + .map(|id| GraphPosition { + stat_type: GraphStatType::Kps, + graph_type: GraphType::Line, + graph_speed: 1000, + graph_color: "raw".to_string(), + show_avg_line: true, + position: KeyPosition { + id: id.clone(), + graph_animation_enabled: None, + ..template.clone() + }, + }) + .collect(), + ); + document.knob_positions.insert( + "4key".to_string(), + knob_ids + .iter() + .map(|id| KnobPosition { + axis_id: "axis".to_string(), + sensitivity: 1.0, + reverse: false, + position: KeyPosition { + id: id.clone(), + ..template.clone() + }, + }) + .collect(), + ); + let setup = store + .commit_editor_document(editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, + graph_positions: Some(document.graph_positions), + knob_positions: Some(document.knob_positions), + ..EditorPatchV1::default() + }, + )) + .unwrap(); + let missing_id = uuid::Uuid::new_v4().to_string(); + let ops = vec![ + patch_property_op( + EditorElementTypeV1::Graph, + &graph_ids[0], + EditorElementPropertyPatchV1::ShowAvgLine(false), + ), + patch_property_op( + EditorElementTypeV1::Graph, + &graph_ids[1], + EditorElementPropertyPatchV1::GraphAnimationEnabled(true), + ), + patch_property_op( + EditorElementTypeV1::Graph, + &graph_ids[2], + EditorElementPropertyPatchV1::GraphSpeed(u32::MAX), + ), + patch_property_op( + EditorElementTypeV1::Knob, + &knob_ids[0], + EditorElementPropertyPatchV1::Reverse(true), + ), + patch_property_op( + EditorElementTypeV1::Knob, + &knob_ids[1], + EditorElementPropertyPatchV1::Sensitivity(-7.25), + ), + patch_property_op( + EditorElementTypeV1::Knob, + &knob_ids[2], + EditorElementPropertyPatchV1::AxisId(" HIDA:raw ".to_string()), + ), + patch_property_op( + EditorElementTypeV1::Knob, + missing_id, + EditorElementPropertyPatchV1::AxisId("missing-axis".to_string()), + ), + ]; + let mutation_id = uuid::Uuid::new_v4().to_string(); + let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); + + let changed = store.commit_editor_document(request.clone()).unwrap(); + assert_eq!( + changed.result.changed_fields, + [EditorField::GraphPositions, EditorField::KnobPositions] + ); + assert_eq!( + changed + .result + .op_results + .as_ref() + .unwrap() + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + let graphs = &changed.document.graph_positions["4key"]; + let knobs = &changed.document.knob_positions["4key"]; + assert!(!graphs[0].show_avg_line); + assert_eq!(graphs[1].position.graph_animation_enabled, Some(true)); + assert_eq!(graphs[2].graph_speed, u32::MAX); + assert!(knobs[0].reverse); + assert_eq!(knobs[1].sensitivity, -7.25); + assert_eq!(knobs[2].axis_id, " HIDA:raw "); + assert!(!knobs[2].reverse); + assert_eq!(knobs[2].sensitivity, 1.0); + assert_eq!(store.history_status().history_revision, 2); + + let replay = store.commit_editor_document(request.clone()).unwrap(); + assert!(replay.replayed); + assert_eq!(replay.result, changed.result); + assert_eq!(store.history_status().history_revision, 2); + + let mut reused = request; + reused.ops = Some(vec![patch_property_op( + EditorElementTypeV1::Knob, + &knob_ids[2], + EditorElementPropertyPatchV1::AxisId("different-axis".to_string()), + )]); + assert_eq!( + store.commit_editor_document(reused).unwrap_err().error_code, + EditorCommitErrorCode::MutationIdReused + ); + + let no_change = store + .commit_editor_document(editor_ops_request( + changed.result.revision, + uuid::Uuid::new_v4().to_string(), + ops[..6].to_vec(), + )) + .unwrap(); + assert!(no_change.result.changed_fields.is_empty()); + assert!(no_change.event.is_none()); + assert_eq!(store.history_status().history_revision, 2); + + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Undo, + &undo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let undone = store.editor_get().document; + assert!(undone.graph_positions["4key"] + .iter() + .all(|graph| graph.show_avg_line + && graph.graph_speed == 1000 + && graph.position.graph_animation_enabled.is_none())); + assert!(undone.knob_positions["4key"] + .iter() + .all(|knob| !knob.reverse && knob.sensitivity == 1.0 && knob.axis_id == "axis")); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Redo, + &redo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let redone = store.editor_get().document; + assert!(!redone.graph_positions["4key"][0].show_avg_line); + assert_eq!( + redone.graph_positions["4key"][1] + .position + .graph_animation_enabled, + Some(true) + ); + assert_eq!(redone.graph_positions["4key"][2].graph_speed, u32::MAX); + assert!(redone.knob_positions["4key"][0].reverse); + assert_eq!(redone.knob_positions["4key"][1].sensitivity, -7.25); + assert_eq!(redone.knob_positions["4key"][2].axis_id, " HIDA:raw "); + assert!(!redone.knob_positions["4key"][2].reverse); + assert_eq!(redone.knob_positions["4key"][2].sensitivity, 1.0); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn common_option_batches_replay_and_round_trip_all_native_types() { + let dir = test_directory("editor-inline-style-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = initialize_neutral_editor_store(&dir); + let mut document = store.editor_get().document; + let mut template = document.key_positions["4key"][0].clone(); + template.counter.font_italic = true; + template.counter.font_underline = true; + template.counter.font_strikethrough = true; + template.counter.font_family = Some("counter-font".to_string()); + document.key_positions.get_mut("4key").unwrap()[0] + .counter + .font_family = Some("counter-font".to_string()); + let key_id = template.id.clone(); + let stat_id = uuid::Uuid::new_v4().to_string(); + let graph_id = uuid::Uuid::new_v4().to_string(); + let knob_id = uuid::Uuid::new_v4().to_string(); + document.stat_positions.insert( + "4key".to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: KeyPosition { + id: stat_id.clone(), + ..template.clone() + }, + }], + ); + document.graph_positions.insert( + "4key".to_string(), + vec![GraphPosition { + stat_type: GraphStatType::Kps, + graph_type: GraphType::Line, + graph_speed: 1000, + graph_color: "raw".to_string(), + show_avg_line: true, + position: KeyPosition { + id: graph_id.clone(), + ..template.clone() + }, + }], + ); + document.knob_positions.insert( + "4key".to_string(), + vec![KnobPosition { + axis_id: "axis".to_string(), + sensitivity: 1.0, + reverse: false, + position: KeyPosition { + id: knob_id.clone(), + ..template + }, + }], + ); + let setup = store + .commit_editor_document(editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, + keys: Some(document.keys), + key_positions: Some(document.key_positions), + stat_positions: Some(document.stat_positions), + graph_positions: Some(document.graph_positions), + knob_positions: Some(document.knob_positions), + ..EditorPatchV1::default() + }, + )) + .unwrap(); + let patch = EditorElementPropertyPatchV1::UseInlineStyles(false); + let ops = [ + (EditorElementTypeV1::Key, key_id), + (EditorElementTypeV1::Stat, stat_id), + (EditorElementTypeV1::Graph, graph_id), + (EditorElementTypeV1::Knob, knob_id), + ] + .map(|(element_type, id)| patch_property_op(element_type, id, patch.clone())) + .into_iter() + .chain(std::iter::once(patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + patch, + ))) + .collect::>(); + let mutation_id = uuid::Uuid::new_v4().to_string(); + let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); + + let changed = store.commit_editor_document(request.clone()).unwrap(); + assert_eq!( + changed.result.changed_fields, + [ + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + ] + ); + assert_eq!( + changed + .result + .op_results + .as_ref() + .unwrap() + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + assert_eq!( + changed.document.key_positions["4key"][0].use_inline_styles, + Some(false) + ); + assert_eq!( + changed.document.stat_positions["4key"][0] + .position + .use_inline_styles, + Some(false) + ); + assert_eq!( + changed.document.graph_positions["4key"][0] + .position + .use_inline_styles, + Some(false) + ); + assert_eq!( + changed.document.knob_positions["4key"][0] + .position + .use_inline_styles, + Some(false) + ); + assert_eq!(store.history_status().history_revision, 2); + + let replay = store.commit_editor_document(request.clone()).unwrap(); + assert!(replay.replayed); + assert_eq!(replay.result, changed.result); + + let mut reused = request; + reused.ops = Some(vec![patch_property_op( + EditorElementTypeV1::Key, + &changed.document.key_positions["4key"][0].id, + EditorElementPropertyPatchV1::UseInlineStyles(true), + )]); + assert_eq!( + store.commit_editor_document(reused).unwrap_err().error_code, + EditorCommitErrorCode::MutationIdReused + ); + + let no_change = store + .commit_editor_document(editor_ops_request( + changed.result.revision, + uuid::Uuid::new_v4().to_string(), + ops[..4].to_vec(), + )) + .unwrap(); + assert!(no_change.result.changed_fields.is_empty()); + assert!(no_change.event.is_none()); + assert_eq!(store.history_status().history_revision, 2); + + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Undo, + &undo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let undone = store.editor_get().document; + assert_eq!(undone.key_positions["4key"][0].use_inline_styles, None); + assert_eq!( + undone.stat_positions["4key"][0].position.use_inline_styles, + None + ); + assert_eq!( + undone.graph_positions["4key"][0].position.use_inline_styles, + None + ); + assert_eq!( + undone.knob_positions["4key"][0].position.use_inline_styles, + None + ); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Redo, + &redo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let redone = store.editor_get().document; + assert_eq!( + redone.key_positions["4key"][0].use_inline_styles, + Some(false) + ); + assert_eq!( + redone.stat_positions["4key"][0].position.use_inline_styles, + Some(false) + ); + assert_eq!( + redone.graph_positions["4key"][0].position.use_inline_styles, + Some(false) + ); + assert_eq!( + redone.knob_positions["4key"][0].position.use_inline_styles, + Some(false) + ); + + let counter_font_weight = redone.key_positions["4key"][0].counter.font_weight; + let original_font_weight = redone.key_positions["4key"][0].font_weight; + let original_font_italic = redone.stat_positions["4key"][0].position.font_italic; + let original_font_underline = redone.graph_positions["4key"][0].position.font_underline; + let original_font_strikethrough = redone.knob_positions["4key"][0].position.font_strikethrough; + let font_ops = vec![ + patch_property_op( + EditorElementTypeV1::Key, + &redone.key_positions["4key"][0].id, + EditorElementPropertyPatchV1::FontWeight(u32::MAX), + ), + patch_property_op( + EditorElementTypeV1::Stat, + &redone.stat_positions["4key"][0].position.id, + EditorElementPropertyPatchV1::FontItalic(false), + ), + patch_property_op( + EditorElementTypeV1::Graph, + &redone.graph_positions["4key"][0].position.id, + EditorElementPropertyPatchV1::FontUnderline(false), + ), + patch_property_op( + EditorElementTypeV1::Knob, + &redone.knob_positions["4key"][0].position.id, + EditorElementPropertyPatchV1::FontStrikethrough(false), + ), + patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + EditorElementPropertyPatchV1::FontWeight(u32::MAX), + ), + ]; + let font_mutation_id = uuid::Uuid::new_v4().to_string(); + let font_request = editor_ops_request( + store.editor_get().revision, + &font_mutation_id, + font_ops.clone(), + ); + let font_changed = store.commit_editor_document(font_request.clone()).unwrap(); + assert_eq!( + font_changed.result.changed_fields, + [ + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + ] + ); + assert_eq!( + font_changed + .result + .op_results + .as_ref() + .unwrap() + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + assert_eq!( + font_changed.document.key_positions["4key"][0].font_weight, + Some(u32::MAX) + ); + assert_eq!( + font_changed.document.stat_positions["4key"][0] + .position + .font_italic, + Some(false) + ); + assert_eq!( + font_changed.document.graph_positions["4key"][0] + .position + .font_underline, + Some(false) + ); + assert_eq!( + font_changed.document.knob_positions["4key"][0] + .position + .font_strikethrough, + Some(false) + ); + assert_eq!( + font_changed.document.key_positions["4key"][0] + .counter + .font_weight, + counter_font_weight + ); + assert!( + font_changed.document.stat_positions["4key"][0] + .position + .counter + .font_italic + ); + assert!( + font_changed.document.graph_positions["4key"][0] + .position + .counter + .font_underline + ); + assert!( + font_changed.document.knob_positions["4key"][0] + .position + .counter + .font_strikethrough + ); + + let font_replay = store.commit_editor_document(font_request.clone()).unwrap(); + assert!(font_replay.replayed); + assert_eq!(font_replay.result, font_changed.result); + + let mut font_reused = font_request; + font_reused.ops = Some(vec![patch_property_op( + EditorElementTypeV1::Key, + &font_changed.document.key_positions["4key"][0].id, + EditorElementPropertyPatchV1::FontWeight(700), + )]); + assert_eq!( + store + .commit_editor_document(font_reused) + .unwrap_err() + .error_code, + EditorCommitErrorCode::MutationIdReused + ); + + let font_no_change = store + .commit_editor_document(editor_ops_request( + font_changed.result.revision, + uuid::Uuid::new_v4().to_string(), + font_ops[..4].to_vec(), + )) + .unwrap(); + assert!(font_no_change.result.changed_fields.is_empty()); + assert!(font_no_change.event.is_none()); + + let font_undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&font_undo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Undo, + &font_undo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let font_undone = store.editor_get().document; + assert_eq!( + font_undone.key_positions["4key"][0].font_weight, + original_font_weight + ); + assert_eq!( + font_undone.stat_positions["4key"][0].position.font_italic, + original_font_italic + ); + assert_eq!( + font_undone.graph_positions["4key"][0] + .position + .font_underline, + original_font_underline + ); + assert_eq!( + font_undone.knob_positions["4key"][0] + .position + .font_strikethrough, + original_font_strikethrough + ); + assert_eq!( + font_undone.key_positions["4key"][0].counter.font_weight, + counter_font_weight + ); + assert!( + font_undone.stat_positions["4key"][0] + .position + .counter + .font_italic + ); + assert!( + font_undone.graph_positions["4key"][0] + .position + .counter + .font_underline + ); + assert!( + font_undone.knob_positions["4key"][0] + .position + .counter + .font_strikethrough + ); + + let font_redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&font_redo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Redo, + &font_redo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let font_redone = store.editor_get().document; + assert_eq!( + font_redone.key_positions["4key"][0].font_weight, + Some(u32::MAX) + ); + assert_eq!( + font_redone.stat_positions["4key"][0].position.font_italic, + Some(false) + ); + assert_eq!( + font_redone.graph_positions["4key"][0] + .position + .font_underline, + Some(false) + ); + assert_eq!( + font_redone.knob_positions["4key"][0] + .position + .font_strikethrough, + Some(false) + ); + assert_eq!( + font_redone.key_positions["4key"][0].counter.font_weight, + counter_font_weight + ); + assert!( + font_redone.stat_positions["4key"][0] + .position + .counter + .font_italic + ); + assert!( + font_redone.graph_positions["4key"][0] + .position + .counter + .font_underline + ); + assert!( + font_redone.knob_positions["4key"][0] + .position + .counter + .font_strikethrough + ); + + let family_patch = EditorElementPropertyPatchV1::FontFamily(" raw-family ".to_string()); + let family_ops = [ + ( + EditorElementTypeV1::Key, + font_redone.key_positions["4key"][0].id.clone(), + ), + ( + EditorElementTypeV1::Stat, + font_redone.stat_positions["4key"][0].position.id.clone(), + ), + ( + EditorElementTypeV1::Graph, + font_redone.graph_positions["4key"][0].position.id.clone(), + ), + ( + EditorElementTypeV1::Knob, + font_redone.knob_positions["4key"][0].position.id.clone(), + ), + ] + .map(|(element_type, id)| patch_property_op(element_type, id, family_patch.clone())) + .into_iter() + .chain(std::iter::once(patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + family_patch.clone(), + ))) + .collect::>(); + let family_mutation_id = uuid::Uuid::new_v4().to_string(); + let family_request = editor_ops_request( + store.editor_get().revision, + &family_mutation_id, + family_ops.clone(), + ); + let family_changed = store + .commit_editor_document(family_request.clone()) + .unwrap(); + assert_eq!( + family_changed.result.changed_fields, + [ + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + ] + ); + assert_eq!( + family_changed + .result + .op_results + .as_ref() + .unwrap() + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + assert_eq!( + family_changed.document.key_positions["4key"][0] + .font_family + .as_deref(), + Some(" raw-family ") + ); + assert_eq!( + family_changed.document.stat_positions["4key"][0] + .position + .font_family + .as_deref(), + Some(" raw-family ") + ); + assert_eq!( + family_changed.document.graph_positions["4key"][0] + .position + .font_family + .as_deref(), + Some(" raw-family ") + ); + assert_eq!( + family_changed.document.knob_positions["4key"][0] + .position + .font_family + .as_deref(), + Some(" raw-family ") + ); + for position in [ + &family_changed.document.key_positions["4key"][0], + &family_changed.document.stat_positions["4key"][0].position, + &family_changed.document.graph_positions["4key"][0].position, + &family_changed.document.knob_positions["4key"][0].position, + ] { + assert_eq!( + position.counter.font_family.as_deref(), + Some("counter-font") + ); + } + + let family_replay = store + .commit_editor_document(family_request.clone()) + .unwrap(); + assert!(family_replay.replayed); + assert_eq!(family_replay.result, family_changed.result); + + let mut family_reused = family_request; + family_reused.ops = Some(vec![patch_property_op( + EditorElementTypeV1::Key, + &family_changed.document.key_positions["4key"][0].id, + EditorElementPropertyPatchV1::FontFamily("different".to_string()), + )]); + assert_eq!( + store + .commit_editor_document(family_reused) + .unwrap_err() + .error_code, + EditorCommitErrorCode::MutationIdReused + ); + + let family_no_change = store + .commit_editor_document(editor_ops_request( + family_changed.result.revision, + uuid::Uuid::new_v4().to_string(), + family_ops[..4].to_vec(), + )) + .unwrap(); + assert!(family_no_change.result.changed_fields.is_empty()); + assert!(family_no_change.event.is_none()); + + let family_undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&family_undo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Undo, + &family_undo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let family_undone = store.editor_get().document; + for position in [ + &family_undone.key_positions["4key"][0], + &family_undone.stat_positions["4key"][0].position, + &family_undone.graph_positions["4key"][0].position, + &family_undone.knob_positions["4key"][0].position, + ] { + assert_eq!(position.font_family, None); + assert_eq!( + position.counter.font_family.as_deref(), + Some("counter-font") + ); + } + + let family_redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&family_redo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Redo, + &family_redo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let family_redone = store.editor_get().document; + for position in [ + &family_redone.key_positions["4key"][0], + &family_redone.stat_positions["4key"][0].position, + &family_redone.graph_positions["4key"][0].position, + &family_redone.knob_positions["4key"][0].position, + ] { + assert_eq!(position.font_family.as_deref(), Some(" raw-family ")); + assert_eq!( + position.counter.font_family.as_deref(), + Some("counter-font") + ); + } + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn display_text_batch_replays_raw_empty_and_round_trips_one_history_entry() { + let dir = test_directory("editor-display-text-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = initialize_neutral_editor_store(&dir); + let mut document = store.editor_get().document; + let mut template = document.key_positions["4key"][0].clone(); + template.display_text = None; + template.layer_name = Some("layer-sibling".to_string()); + template.font_family = Some("font-sibling".to_string()); + template.counter.font_family = Some("counter-font-sibling".to_string()); + template.inactive_image = Some("image-sibling".to_string()); + document.key_positions.get_mut("4key").unwrap()[0] = template.clone(); + let targets = [ + (EditorElementTypeV1::Key, template.id.clone(), ""), + ( + EditorElementTypeV1::Stat, + uuid::Uuid::new_v4().to_string(), + " raw stat ", + ), + ( + EditorElementTypeV1::Graph, + uuid::Uuid::new_v4().to_string(), + "raw graph", + ), + ( + EditorElementTypeV1::Knob, + uuid::Uuid::new_v4().to_string(), + "raw knob", + ), + ]; + document.stat_positions.insert( + "4key".to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: KeyPosition { + id: targets[1].1.clone(), + ..template.clone() + }, + }], + ); + document.graph_positions.insert( + "4key".to_string(), + vec![GraphPosition { + stat_type: GraphStatType::Kps, + graph_type: GraphType::Line, + graph_speed: 1000, + graph_color: "graph-sibling".to_string(), + show_avg_line: true, + position: KeyPosition { + id: targets[2].1.clone(), + ..template.clone() + }, + }], + ); + document.knob_positions.insert( + "4key".to_string(), + vec![KnobPosition { + axis_id: "axis-sibling".to_string(), + sensitivity: 1.0, + reverse: false, + position: KeyPosition { + id: targets[3].1.clone(), + ..template + }, + }], + ); + let setup = store + .commit_editor_document(editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, + keys: Some(document.keys), + key_positions: Some(document.key_positions), + stat_positions: Some(document.stat_positions), + graph_positions: Some(document.graph_positions), + knob_positions: Some(document.knob_positions), + ..EditorPatchV1::default() + }, + )) + .unwrap(); + let ops = targets + .iter() + .map(|(element_type, id, display_text)| { + patch_property_op( + *element_type, + id, + EditorElementPropertyPatchV1::DisplayText((*display_text).to_string()), + ) + }) + .chain(std::iter::once(patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + EditorElementPropertyPatchV1::DisplayText("missing".to_string()), + ))) + .collect::>(); + let mutation_id = uuid::Uuid::new_v4().to_string(); + let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); + + let changed = store.commit_editor_document(request.clone()).unwrap(); + assert_eq!( + changed.result.changed_fields, + [ + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + ] + ); + assert_eq!( + changed + .result + .op_results + .as_ref() + .unwrap() + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + let positions = [ + &changed.document.key_positions["4key"][0], + &changed.document.stat_positions["4key"][0].position, + &changed.document.graph_positions["4key"][0].position, + &changed.document.knob_positions["4key"][0].position, + ]; + for (position, expected) in positions + .into_iter() + .zip(targets.iter().map(|target| target.2)) + { + assert_eq!(position.display_text.as_deref(), Some(expected)); + assert_eq!(position.layer_name.as_deref(), Some("layer-sibling")); + assert_eq!(position.font_family.as_deref(), Some("font-sibling")); + assert_eq!( + position.counter.font_family.as_deref(), + Some("counter-font-sibling") + ); + assert_eq!(position.inactive_image.as_deref(), Some("image-sibling")); + } + let history_revision = store.history_status().history_revision; + + let replay = store.commit_editor_document(request.clone()).unwrap(); + assert!(replay.replayed); + assert_eq!(replay.result, changed.result); + assert_eq!(store.history_status().history_revision, history_revision); + + let mut reused = request; + reused.ops = Some(vec![patch_property_op( + EditorElementTypeV1::Key, + &targets[0].1, + EditorElementPropertyPatchV1::DisplayText("different".to_string()), + )]); + assert_eq!( + store.commit_editor_document(reused).unwrap_err().error_code, + EditorCommitErrorCode::MutationIdReused + ); + + let no_change = store + .commit_editor_document(editor_ops_request( + changed.result.revision, + uuid::Uuid::new_v4().to_string(), + ops[..4].to_vec(), + )) + .unwrap(); + assert!(no_change.result.changed_fields.is_empty()); + assert!(no_change.event.is_none()); + assert_eq!(store.history_status().history_revision, history_revision); + + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Undo, + &undo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let undone = store.editor_get().document; + for position in [ + &undone.key_positions["4key"][0], + &undone.stat_positions["4key"][0].position, + &undone.graph_positions["4key"][0].position, + &undone.knob_positions["4key"][0].position, + ] { + assert_eq!(position.display_text, None); + assert_eq!(position.layer_name.as_deref(), Some("layer-sibling")); + } + + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Redo, + &redo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let redone = store.editor_get().document; + let positions = [ + &redone.key_positions["4key"][0], + &redone.stat_positions["4key"][0].position, + &redone.graph_positions["4key"][0].position, + &redone.knob_positions["4key"][0].position, + ]; + for (position, expected) in positions + .into_iter() + .zip(targets.iter().map(|target| target.2)) + { + assert_eq!(position.display_text.as_deref(), Some(expected)); + assert_eq!(position.layer_name.as_deref(), Some("layer-sibling")); + } + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn class_name_replays_raw_empty_and_round_trips_one_history_entry() { + let dir = test_directory("editor-class-name-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = initialize_neutral_editor_store(&dir); + let mut document = store.editor_get().document; + let target_id = document.key_positions["4key"][0].id.clone(); + { + let position = &mut document.key_positions.get_mut("4key").unwrap()[0]; + position.class_name = None; + position.display_text = Some("display-sibling".to_string()); + position.font_family = Some("font-sibling".to_string()); + position.counter.font_family = Some("counter-font-sibling".to_string()); + } + let setup = store + .commit_editor_document(editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, + keys: Some(document.keys), + key_positions: Some(document.key_positions), + ..EditorPatchV1::default() + }, + )) + .unwrap(); + let ops = vec![ + patch_property_op( + EditorElementTypeV1::Key, + &target_id, + EditorElementPropertyPatchV1::ClassName(String::new()), + ), + patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + EditorElementPropertyPatchV1::ClassName("missing".to_string()), + ), + ]; + let mutation_id = uuid::Uuid::new_v4().to_string(); + let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); + + let changed = store.commit_editor_document(request.clone()).unwrap(); + assert_eq!(changed.result.changed_fields, [EditorField::KeyPositions]); + assert_eq!( + changed + .result + .op_results + .as_ref() + .unwrap() + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + let position = &changed.document.key_positions["4key"][0]; + assert_eq!(position.class_name.as_deref(), Some("")); + assert_eq!(position.display_text.as_deref(), Some("display-sibling")); + assert_eq!(position.font_family.as_deref(), Some("font-sibling")); + assert_eq!( + position.counter.font_family.as_deref(), + Some("counter-font-sibling") + ); + let history_revision = store.history_status().history_revision; + + let replay = store.commit_editor_document(request.clone()).unwrap(); + assert!(replay.replayed); + assert_eq!(replay.result, changed.result); + assert_eq!(store.history_status().history_revision, history_revision); + + let mut reused = request; + reused.ops = Some(vec![patch_property_op( + EditorElementTypeV1::Key, + &target_id, + EditorElementPropertyPatchV1::ClassName("different".to_string()), + )]); + assert_eq!( + store.commit_editor_document(reused).unwrap_err().error_code, + EditorCommitErrorCode::MutationIdReused + ); + + let no_change = store + .commit_editor_document(editor_ops_request( + changed.result.revision, + uuid::Uuid::new_v4().to_string(), + vec![ops[0].clone()], + )) + .unwrap(); + assert!(no_change.result.changed_fields.is_empty()); + assert!(no_change.event.is_none()); + assert_eq!(store.history_status().history_revision, history_revision); + + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Undo, + &undo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let undone = store.editor_get().document; + let position = &undone.key_positions["4key"][0]; + assert_eq!(position.class_name, None); + assert_eq!(position.display_text.as_deref(), Some("display-sibling")); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Redo, + &redo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let redone = store.editor_get().document; + let position = &redone.key_positions["4key"][0]; + assert_eq!(position.class_name.as_deref(), Some("")); + assert_eq!(position.display_text.as_deref(), Some("display-sibling")); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn numeric_style_batch_replays_and_round_trips_one_history_entry() { + let dir = test_directory("editor-numeric-style-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = initialize_neutral_editor_store(&dir); + let mut document = store.editor_get().document; + let mut template = document.key_positions["4key"][0].clone(); + template.border_width = None; + template.border_radius = None; + template.font_size = None; + template.class_name = Some("class-sibling".to_string()); + template.display_text = Some("display-sibling".to_string()); + template.counter.font_size = 33; + document.key_positions.get_mut("4key").unwrap()[0] = template.clone(); + let targets = [ + (EditorElementTypeV1::Key, template.id.clone()), + (EditorElementTypeV1::Stat, uuid::Uuid::new_v4().to_string()), + (EditorElementTypeV1::Knob, uuid::Uuid::new_v4().to_string()), + ]; + document.stat_positions.insert( + "4key".to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: KeyPosition { + id: targets[1].1.clone(), + ..template.clone() + }, + }], + ); + document.knob_positions.insert( + "4key".to_string(), + vec![KnobPosition { + axis_id: "axis-sibling".to_string(), + sensitivity: 1.0, + reverse: false, + position: KeyPosition { + id: targets[2].1.clone(), + ..template + }, + }], + ); + let setup = store + .commit_editor_document(editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, + keys: Some(document.keys), + key_positions: Some(document.key_positions), + stat_positions: Some(document.stat_positions), + knob_positions: Some(document.knob_positions), + ..EditorPatchV1::default() + }, + )) + .unwrap(); + let ops = vec![ + patch_property_op( + EditorElementTypeV1::Key, + &targets[0].1, + EditorElementPropertyPatchV1::BorderWidth(0.5), + ), + patch_property_op( + EditorElementTypeV1::Stat, + &targets[1].1, + EditorElementPropertyPatchV1::BorderRadius(99.5), + ), + patch_property_op( + EditorElementTypeV1::Knob, + &targets[2].1, + EditorElementPropertyPatchV1::FontSize(8.5), + ), + patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + EditorElementPropertyPatchV1::BorderWidth(1.0), + ), + ]; + let mutation_id = uuid::Uuid::new_v4().to_string(); + let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); + + let changed = store.commit_editor_document(request.clone()).unwrap(); + assert_eq!( + changed.result.changed_fields, + [ + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::KnobPositions, + ] + ); + assert_eq!( + changed + .result + .op_results + .as_ref() + .unwrap() + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + let positions = [ + &changed.document.key_positions["4key"][0], + &changed.document.stat_positions["4key"][0].position, + &changed.document.knob_positions["4key"][0].position, + ]; + assert_eq!(positions[0].border_width, Some(0.5)); + assert_eq!(positions[1].border_radius, Some(99.5)); + assert_eq!(positions[2].font_size, Some(8.5)); + for position in positions { + assert_eq!(position.class_name.as_deref(), Some("class-sibling")); + assert_eq!(position.display_text.as_deref(), Some("display-sibling")); + assert_eq!(position.counter.font_size, 33); + } + let history_revision = store.history_status().history_revision; + + let replay = store.commit_editor_document(request.clone()).unwrap(); + assert!(replay.replayed); + assert_eq!(replay.result, changed.result); + assert_eq!(store.history_status().history_revision, history_revision); + + let mut reused = request; + reused.ops = Some(vec![patch_property_op( + EditorElementTypeV1::Key, + &targets[0].1, + EditorElementPropertyPatchV1::FontSize(9.0), + )]); + assert_eq!( + store.commit_editor_document(reused).unwrap_err().error_code, + EditorCommitErrorCode::MutationIdReused + ); + + let no_change = store + .commit_editor_document(editor_ops_request( + changed.result.revision, + uuid::Uuid::new_v4().to_string(), + ops[..3].to_vec(), + )) + .unwrap(); + assert!(no_change.result.changed_fields.is_empty()); + assert!(no_change.event.is_none()); + assert_eq!(store.history_status().history_revision, history_revision); + + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Undo, + &undo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let undone = store.editor_get().document; + let positions = [ + &undone.key_positions["4key"][0], + &undone.stat_positions["4key"][0].position, + &undone.knob_positions["4key"][0].position, + ]; + for position in positions { + assert_eq!(position.border_width, None); + assert_eq!(position.border_radius, None); + assert_eq!(position.font_size, None); + assert_eq!(position.class_name.as_deref(), Some("class-sibling")); + } + + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Redo, + &redo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let redone = store.editor_get().document; + let positions = [ + &redone.key_positions["4key"][0], + &redone.stat_positions["4key"][0].position, + &redone.knob_positions["4key"][0].position, + ]; + assert_eq!(positions[0].border_width, Some(0.5)); + assert_eq!(positions[1].border_radius, Some(99.5)); + assert_eq!(positions[2].font_size, Some(8.5)); + for position in positions { + assert_eq!(position.class_name.as_deref(), Some("class-sibling")); + } + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn inactive_image_batch_replays_raw_empty_and_round_trips_one_history_entry() { + let dir = test_directory("editor-inactive-image-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = initialize_neutral_editor_store(&dir); + let mut document = store.editor_get().document; + let mut template = document.key_positions["4key"][0].clone(); + template.inactive_image = None; + template.active_image = Some("active-sibling.png".to_string()); + template.idle_image_fit = Some(crate::models::ImageFit::Contain); + template.idle_transparent = true; + template.counter.enabled = false; + document.key_positions.get_mut("4key").unwrap()[0] = template.clone(); + let targets = [ + (EditorElementTypeV1::Key, template.id.clone()), + (EditorElementTypeV1::Stat, uuid::Uuid::new_v4().to_string()), + (EditorElementTypeV1::Graph, uuid::Uuid::new_v4().to_string()), + (EditorElementTypeV1::Knob, uuid::Uuid::new_v4().to_string()), + ]; + document.stat_positions.insert( + "4key".to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: KeyPosition { + id: targets[1].1.clone(), + ..template.clone() + }, + }], + ); + document.graph_positions.insert( + "4key".to_string(), + vec![GraphPosition { + stat_type: GraphStatType::Kps, + graph_type: GraphType::Line, + graph_speed: 1000, + graph_color: "graph-sibling".to_string(), + show_avg_line: true, + position: KeyPosition { + id: targets[2].1.clone(), + ..template.clone() + }, + }], + ); + document.knob_positions.insert( + "4key".to_string(), + vec![KnobPosition { + axis_id: "axis-sibling".to_string(), + sensitivity: 2.0, + reverse: true, + position: KeyPosition { + id: targets[3].1.clone(), + ..template + }, + }], + ); + let setup = store + .commit_editor_document(editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, + keys: Some(document.keys), + key_positions: Some(document.key_positions), + stat_positions: Some(document.stat_positions), + graph_positions: Some(document.graph_positions), + knob_positions: Some(document.knob_positions), + ..EditorPatchV1::default() + }, + )) + .unwrap(); + let patch = EditorElementPropertyPatchV1::InactiveImage(String::new()); + let ops = targets + .iter() + .map(|(element_type, id)| patch_property_op(*element_type, id, patch.clone())) + .chain(std::iter::once(patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + patch.clone(), + ))) + .collect::>(); + let mutation_id = uuid::Uuid::new_v4().to_string(); + let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); + + let changed = store.commit_editor_document(request.clone()).unwrap(); + assert_eq!( + changed.result.changed_fields, + [ + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + ] + ); + assert_eq!( + changed + .result + .op_results + .as_ref() + .unwrap() + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + for position in [ + &changed.document.key_positions["4key"][0], + &changed.document.stat_positions["4key"][0].position, + &changed.document.graph_positions["4key"][0].position, + &changed.document.knob_positions["4key"][0].position, + ] { + assert_eq!(position.inactive_image.as_deref(), Some("")); + assert_eq!(position.active_image.as_deref(), Some("active-sibling.png")); + assert_eq!( + position.idle_image_fit, + Some(crate::models::ImageFit::Contain) + ); + assert!(position.idle_transparent); + assert!(!position.counter.enabled); + } + assert_eq!( + changed.document.graph_positions["4key"][0].graph_color, + "graph-sibling" + ); + assert_eq!( + changed.document.knob_positions["4key"][0].axis_id, + "axis-sibling" + ); + let history_revision = store.history_status().history_revision; + + let replay = store.commit_editor_document(request.clone()).unwrap(); + assert!(replay.replayed); + assert_eq!(replay.result, changed.result); + assert_eq!(store.history_status().history_revision, history_revision); + + let mut reused = request; + reused.ops = Some(vec![patch_property_op( + EditorElementTypeV1::Key, + &targets[0].1, + EditorElementPropertyPatchV1::InactiveImage("different.png".to_string()), + )]); + assert_eq!( + store.commit_editor_document(reused).unwrap_err().error_code, + EditorCommitErrorCode::MutationIdReused + ); + + let no_change = store + .commit_editor_document(editor_ops_request( + changed.result.revision, + uuid::Uuid::new_v4().to_string(), + ops[..4].to_vec(), + )) + .unwrap(); + assert!(no_change.result.changed_fields.is_empty()); + assert!(no_change.event.is_none()); + assert_eq!(store.history_status().history_revision, history_revision); + + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Undo, + &undo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let undone = store.editor_get().document; + for position in [ + &undone.key_positions["4key"][0], + &undone.stat_positions["4key"][0].position, + &undone.graph_positions["4key"][0].position, + &undone.knob_positions["4key"][0].position, + ] { + assert_eq!(position.inactive_image, None); + assert_eq!(position.active_image.as_deref(), Some("active-sibling.png")); + } + + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Redo, + &redo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let redone = store.editor_get().document; + for position in [ + &redone.key_positions["4key"][0], + &redone.stat_positions["4key"][0].position, + &redone.graph_positions["4key"][0].position, + &redone.knob_positions["4key"][0].position, + ] { + assert_eq!(position.inactive_image.as_deref(), Some("")); + assert_eq!(position.active_image.as_deref(), Some("active-sibling.png")); + } + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn active_image_batch_replays_raw_empty_and_round_trips_one_history_entry() { + let dir = test_directory("editor-active-image-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = initialize_neutral_editor_store(&dir); + let mut document = store.editor_get().document; + let mut template = document.key_positions["4key"][0].clone(); + template.active_image = None; + template.inactive_image = Some("idle-sibling.png".to_string()); + template.active_image_fit = Some(crate::models::ImageFit::Contain); + template.active_transparent = true; + template.counter.enabled = false; + document.key_positions.get_mut("4key").unwrap()[0] = template.clone(); + let knob_id = uuid::Uuid::new_v4().to_string(); + document.knob_positions.insert( + "4key".to_string(), + vec![KnobPosition { + axis_id: "axis-sibling".to_string(), + sensitivity: 2.0, + reverse: true, + position: KeyPosition { + id: knob_id.clone(), + ..template.clone() + }, + }], + ); + let setup = store + .commit_editor_document(editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, + keys: Some(document.keys), + key_positions: Some(document.key_positions), + knob_positions: Some(document.knob_positions), + ..EditorPatchV1::default() + }, + )) + .unwrap(); + let patch = EditorElementPropertyPatchV1::ActiveImage(String::new()); + let targets = [ + (EditorElementTypeV1::Key, template.id.clone()), + (EditorElementTypeV1::Knob, knob_id), + ]; + let ops = targets + .iter() + .map(|(element_type, id)| patch_property_op(*element_type, id, patch.clone())) + .chain(std::iter::once(patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + patch.clone(), + ))) + .collect::>(); + let mutation_id = uuid::Uuid::new_v4().to_string(); + let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); + + let changed = store.commit_editor_document(request.clone()).unwrap(); + assert_eq!( + changed.result.changed_fields, + [EditorField::KeyPositions, EditorField::KnobPositions] + ); + assert_eq!( + changed + .result + .op_results + .as_ref() + .unwrap() + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + for position in [ + &changed.document.key_positions["4key"][0], + &changed.document.knob_positions["4key"][0].position, + ] { + assert_eq!(position.active_image.as_deref(), Some("")); + assert_eq!(position.inactive_image.as_deref(), Some("idle-sibling.png")); + assert_eq!( + position.active_image_fit, + Some(crate::models::ImageFit::Contain) + ); + assert!(position.active_transparent); + assert!(!position.counter.enabled); + } + assert_eq!( + changed.document.knob_positions["4key"][0].axis_id, + "axis-sibling" + ); + let history_revision = store.history_status().history_revision; + + let replay = store.commit_editor_document(request.clone()).unwrap(); + assert!(replay.replayed); + assert_eq!(replay.result, changed.result); + assert_eq!(store.history_status().history_revision, history_revision); + + let mut reused = request; + reused.ops = Some(vec![patch_property_op( + EditorElementTypeV1::Key, + &targets[0].1, + EditorElementPropertyPatchV1::ActiveImage("different.png".to_string()), + )]); + assert_eq!( + store.commit_editor_document(reused).unwrap_err().error_code, + EditorCommitErrorCode::MutationIdReused + ); + + let no_change = store + .commit_editor_document(editor_ops_request( + changed.result.revision, + uuid::Uuid::new_v4().to_string(), + ops[..2].to_vec(), + )) + .unwrap(); + assert!(no_change.result.changed_fields.is_empty()); + assert!(no_change.event.is_none()); + assert_eq!(store.history_status().history_revision, history_revision); + + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Undo, + &undo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let undone = store.editor_get().document; + for position in [ + &undone.key_positions["4key"][0], + &undone.knob_positions["4key"][0].position, + ] { + assert_eq!(position.active_image, None); + assert_eq!(position.inactive_image.as_deref(), Some("idle-sibling.png")); + } + + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Redo, + &redo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let redone = store.editor_get().document; + for position in [ + &redone.key_positions["4key"][0], + &redone.knob_positions["4key"][0].position, + ] { + assert_eq!(position.active_image.as_deref(), Some("")); + assert_eq!(position.inactive_image.as_deref(), Some("idle-sibling.png")); + } + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn image_transparency_batch_replays_and_round_trips_one_history_entry() { + let dir = test_directory("editor-image-transparency-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = initialize_neutral_editor_store(&dir); + let mut document = store.editor_get().document; + let mut template = document.key_positions["4key"][0].clone(); + template.idle_transparent = false; + template.active_transparent = false; + template.inactive_image = Some("idle-sibling.png".to_string()); + template.active_image = Some("active-sibling.png".to_string()); + template.image_fit = Some(crate::models::ImageFit::Fill); + template.idle_image_fit = Some(crate::models::ImageFit::Contain); + template.active_image_fit = Some(crate::models::ImageFit::None); + template.counter.enabled = true; + let active_key_id = document.key_positions["4key"][1].id.clone(); + document.key_positions.get_mut("4key").unwrap()[0] = template.clone(); + document.key_positions.get_mut("4key").unwrap()[1] = KeyPosition { + id: active_key_id.clone(), + ..template.clone() + }; + let targets = [ + (EditorElementTypeV1::Key, template.id.clone()), + (EditorElementTypeV1::Stat, uuid::Uuid::new_v4().to_string()), + (EditorElementTypeV1::Graph, uuid::Uuid::new_v4().to_string()), + (EditorElementTypeV1::Knob, uuid::Uuid::new_v4().to_string()), + ]; + document.stat_positions.insert( + "4key".to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: KeyPosition { + id: targets[1].1.clone(), + ..template.clone() + }, + }], + ); + document.graph_positions.insert( + "4key".to_string(), + vec![GraphPosition { + stat_type: GraphStatType::Kps, + graph_type: GraphType::Line, + graph_speed: 1000, + graph_color: "graph-sibling".to_string(), + show_avg_line: true, + position: KeyPosition { + id: targets[2].1.clone(), + ..template.clone() + }, + }], + ); + let active_knob_id = uuid::Uuid::new_v4().to_string(); + document.knob_positions.insert( + "4key".to_string(), + vec![ + KnobPosition { + axis_id: "axis-sibling".to_string(), + sensitivity: 2.0, + reverse: true, + position: KeyPosition { + id: targets[3].1.clone(), + ..template.clone() + }, + }, + KnobPosition { + axis_id: "active-axis-sibling".to_string(), + sensitivity: 3.0, + reverse: false, + position: KeyPosition { + id: active_knob_id.clone(), + ..template + }, + }, + ], + ); + let setup = store + .commit_editor_document(editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, + keys: Some(document.keys), + key_positions: Some(document.key_positions), + stat_positions: Some(document.stat_positions), + graph_positions: Some(document.graph_positions), + knob_positions: Some(document.knob_positions), + ..EditorPatchV1::default() + }, + )) + .unwrap(); + let idle_patch = EditorElementPropertyPatchV1::IdleTransparent(true); + let active_patch = EditorElementPropertyPatchV1::ActiveTransparent(true); + let mut ops = targets + .iter() + .map(|(element_type, id)| patch_property_op(*element_type, id, idle_patch.clone())) + .collect::>(); + let active_targets = [ + (EditorElementTypeV1::Key, active_key_id), + (EditorElementTypeV1::Knob, active_knob_id), + ]; + ops.extend([ + patch_property_op( + active_targets[0].0, + &active_targets[0].1, + active_patch.clone(), + ), + patch_property_op( + active_targets[1].0, + &active_targets[1].1, + active_patch.clone(), + ), + patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + active_patch.clone(), + ), + ]); + let mutation_id = uuid::Uuid::new_v4().to_string(); + let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); + + let changed = store.commit_editor_document(request.clone()).unwrap(); + assert_eq!( + changed.result.changed_fields, + [ + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + ] + ); + assert_eq!( + changed + .result + .op_results + .as_ref() + .unwrap() + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + for position in [ + &changed.document.key_positions["4key"][0], + &changed.document.stat_positions["4key"][0].position, + &changed.document.graph_positions["4key"][0].position, + &changed.document.knob_positions["4key"][0].position, + ] { + assert!(position.idle_transparent); + assert_eq!(position.inactive_image.as_deref(), Some("idle-sibling.png")); + assert_eq!(position.active_image.as_deref(), Some("active-sibling.png")); + assert_eq!(position.image_fit, Some(crate::models::ImageFit::Fill)); + assert_eq!( + position.idle_image_fit, + Some(crate::models::ImageFit::Contain) + ); + assert_eq!( + position.active_image_fit, + Some(crate::models::ImageFit::None) + ); + assert!(position.counter.enabled); + } + assert!(!changed.document.key_positions["4key"][0].active_transparent); + assert!( + !changed.document.knob_positions["4key"][0] + .position + .active_transparent + ); + assert!(changed.document.key_positions["4key"][1].active_transparent); + assert!( + changed.document.knob_positions["4key"][1] + .position + .active_transparent + ); + assert!( + !changed.document.stat_positions["4key"][0] + .position + .active_transparent + ); + assert!( + !changed.document.graph_positions["4key"][0] + .position + .active_transparent + ); + assert_eq!( + changed.document.graph_positions["4key"][0].graph_color, + "graph-sibling" + ); + assert_eq!( + changed.document.knob_positions["4key"][0].axis_id, + "axis-sibling" + ); + let history_revision = store.history_status().history_revision; + + let replay = store.commit_editor_document(request.clone()).unwrap(); + assert!(replay.replayed); + assert_eq!(replay.result, changed.result); + assert_eq!(store.history_status().history_revision, history_revision); + + let mut reused = request; + reused.ops = Some(vec![patch_property_op( + EditorElementTypeV1::Key, + &targets[0].1, + EditorElementPropertyPatchV1::IdleTransparent(false), + )]); + assert_eq!( + store.commit_editor_document(reused).unwrap_err().error_code, + EditorCommitErrorCode::MutationIdReused + ); + + let no_change = store + .commit_editor_document(editor_ops_request( + changed.result.revision, + uuid::Uuid::new_v4().to_string(), + ops[..6].to_vec(), + )) + .unwrap(); + assert!(no_change.result.changed_fields.is_empty()); + assert!(no_change.event.is_none()); + assert_eq!(store.history_status().history_revision, history_revision); + + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Undo, + &undo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let undone = store.editor_get().document; + for position in [ + &undone.key_positions["4key"][0], + &undone.stat_positions["4key"][0].position, + &undone.graph_positions["4key"][0].position, + &undone.knob_positions["4key"][0].position, + ] { + assert!(!position.idle_transparent); + assert!(!position.active_transparent); + assert_eq!(position.image_fit, Some(crate::models::ImageFit::Fill)); + } + assert!(!undone.key_positions["4key"][1].active_transparent); + assert!(!undone.knob_positions["4key"][1].position.active_transparent); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Redo, + &redo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let redone = store.editor_get().document; + for position in [ + &redone.key_positions["4key"][0], + &redone.stat_positions["4key"][0].position, + &redone.graph_positions["4key"][0].position, + &redone.knob_positions["4key"][0].position, + ] { + assert!(position.idle_transparent); + assert_eq!(position.image_fit, Some(crate::models::ImageFit::Fill)); + } + assert!(!redone.key_positions["4key"][0].active_transparent); + assert!(!redone.knob_positions["4key"][0].position.active_transparent); + assert!(redone.key_positions["4key"][1].active_transparent); + assert!(redone.knob_positions["4key"][1].position.active_transparent); + assert!(!redone.stat_positions["4key"][0].position.active_transparent); + assert!( + !redone.graph_positions["4key"][0] + .position + .active_transparent + ); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn image_fit_patches_replay_and_round_trip_raw_option_history() { + let dir = test_directory("editor-image-fit-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = initialize_neutral_editor_store(&dir); + let mut document = store.editor_get().document; + let mut template = document.key_positions["4key"][0].clone(); + template.image_fit = Some(crate::models::ImageFit::Cover); + template.idle_image_fit = None; + template.active_image_fit = None; + template.idle_transparent = true; + template.active_transparent = true; + template.inactive_image = Some("idle-sibling.png".to_string()); + template.active_image = Some("active-sibling.png".to_string()); + template.counter.enabled = true; + document.key_positions.get_mut("4key").unwrap()[0] = template.clone(); + let knob_id = uuid::Uuid::new_v4().to_string(); + document.knob_positions.insert( + "4key".to_string(), + vec![KnobPosition { + axis_id: "axis-sibling".to_string(), + sensitivity: 2.0, + reverse: true, + position: KeyPosition { + id: knob_id.clone(), + ..template.clone() + }, + }], + ); + let setup = store + .commit_editor_document(editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, + keys: Some(document.keys), + key_positions: Some(document.key_positions), + knob_positions: Some(document.knob_positions), + ..EditorPatchV1::default() + }, + )) + .unwrap(); + let idle_patch = EditorElementPropertyPatchV1::IdleImageFit(crate::models::ImageFit::Cover); + let active_patch = EditorElementPropertyPatchV1::ActiveImageFit(crate::models::ImageFit::Cover); + let ops = vec![ + patch_property_op(EditorElementTypeV1::Key, &template.id, idle_patch.clone()), + patch_property_op(EditorElementTypeV1::Knob, &knob_id, active_patch.clone()), + patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + idle_patch.clone(), + ), + ]; + let mutation_id = uuid::Uuid::new_v4().to_string(); + let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); + + let changed = store.commit_editor_document(request.clone()).unwrap(); + assert_eq!( + changed.result.changed_fields, + [EditorField::KeyPositions, EditorField::KnobPositions] + ); + assert_eq!( + changed + .result + .op_results + .as_ref() + .unwrap() + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + let key = &changed.document.key_positions["4key"][0]; + let knob = &changed.document.knob_positions["4key"][0]; + assert_eq!(key.idle_image_fit, Some(crate::models::ImageFit::Cover)); + assert_eq!(key.active_image_fit, None); + assert_eq!( + knob.position.active_image_fit, + Some(crate::models::ImageFit::Cover) + ); + assert_eq!(knob.position.idle_image_fit, None); + for position in [key, &knob.position] { + assert_eq!(position.image_fit, Some(crate::models::ImageFit::Cover)); + assert!(position.idle_transparent); + assert!(position.active_transparent); + assert_eq!(position.inactive_image.as_deref(), Some("idle-sibling.png")); + assert_eq!(position.active_image.as_deref(), Some("active-sibling.png")); + assert!(position.counter.enabled); + } + assert_eq!(knob.axis_id, "axis-sibling"); + let history_revision = store.history_status().history_revision; + + let replay = store.commit_editor_document(request.clone()).unwrap(); + assert!(replay.replayed); + assert_eq!(replay.result, changed.result); + assert_eq!(store.history_status().history_revision, history_revision); + + let mut reused = request; + reused.ops = Some(vec![patch_property_op( + EditorElementTypeV1::Key, + &template.id, + EditorElementPropertyPatchV1::IdleImageFit(crate::models::ImageFit::Contain), + )]); + assert_eq!( + store.commit_editor_document(reused).unwrap_err().error_code, + EditorCommitErrorCode::MutationIdReused + ); + + let no_change = store + .commit_editor_document(editor_ops_request( + changed.result.revision, + uuid::Uuid::new_v4().to_string(), + ops[..2].to_vec(), + )) + .unwrap(); + assert!(no_change.result.changed_fields.is_empty()); + assert!(no_change.event.is_none()); + assert_eq!(store.history_status().history_revision, history_revision); + + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Undo, + &undo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let undone = store.editor_get().document; + assert_eq!(undone.key_positions["4key"][0].idle_image_fit, None); + assert_eq!( + undone.knob_positions["4key"][0].position.active_image_fit, + None + ); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Redo, + &redo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let redone = store.editor_get().document; + assert_eq!( + redone.key_positions["4key"][0].idle_image_fit, + Some(crate::models::ImageFit::Cover) + ); + assert_eq!( + redone.knob_positions["4key"][0].position.active_image_fit, + Some(crate::models::ImageFit::Cover) + ); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn sound_path_batch_replays_raw_empty_and_round_trips_one_history_entry() { + let dir = test_directory("editor-sound-path-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = initialize_neutral_editor_store(&dir); + let mut document = store.editor_get().document; + let mut first = document.key_positions["4key"][0].clone(); + first.sound_path = None; + first.sound_enabled = Some(true); + first.sound_volume = Some(137.5); + first.inactive_image = Some("idle-sibling.png".to_string()); + first.active_image = Some("active-sibling.png".to_string()); + first.counter.enabled = false; + let second_id = document.key_positions["4key"][1].id.clone(); + document.key_positions.get_mut("4key").unwrap()[0] = first.clone(); + document.key_positions.get_mut("4key").unwrap()[1] = KeyPosition { + id: second_id.clone(), + ..first.clone() + }; + let setup = store + .commit_editor_document(editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, + keys: Some(document.keys), + key_positions: Some(document.key_positions), + ..EditorPatchV1::default() + }, + )) + .unwrap(); + let patch = EditorElementPropertyPatchV1::SoundPath(String::new()); + let targets = [first.id, second_id]; + let ops = targets + .iter() + .map(|id| patch_property_op(EditorElementTypeV1::Key, id, patch.clone())) + .chain(std::iter::once(patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + patch.clone(), + ))) + .collect::>(); + let mutation_id = uuid::Uuid::new_v4().to_string(); + let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); + + let changed = store.commit_editor_document(request.clone()).unwrap(); + assert_eq!(changed.result.changed_fields, [EditorField::KeyPositions]); + assert_eq!( + changed + .result + .op_results + .as_ref() + .unwrap() + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + for position in changed.document.key_positions["4key"].iter().take(2) { + assert_eq!(position.sound_path.as_deref(), Some("")); + assert_eq!(position.sound_enabled, Some(true)); + assert_eq!(position.sound_volume, Some(137.5)); + assert_eq!(position.inactive_image.as_deref(), Some("idle-sibling.png")); + assert_eq!(position.active_image.as_deref(), Some("active-sibling.png")); + assert!(!position.counter.enabled); + } + let history_revision = store.history_status().history_revision; + + let replay = store.commit_editor_document(request.clone()).unwrap(); + assert!(replay.replayed); + assert_eq!(replay.result, changed.result); + assert_eq!(store.history_status().history_revision, history_revision); + + let mut reused = request; + reused.ops = Some(vec![patch_property_op( + EditorElementTypeV1::Key, + &targets[0], + EditorElementPropertyPatchV1::SoundPath("different.wav".to_string()), + )]); + assert_eq!( + store.commit_editor_document(reused).unwrap_err().error_code, + EditorCommitErrorCode::MutationIdReused + ); + + let no_change = store + .commit_editor_document(editor_ops_request( + changed.result.revision, + uuid::Uuid::new_v4().to_string(), + ops[..2].to_vec(), + )) + .unwrap(); + assert!(no_change.result.changed_fields.is_empty()); + assert!(no_change.event.is_none()); + assert_eq!(store.history_status().history_revision, history_revision); + + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Undo, + &undo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let undone = store.editor_get().document; + for position in undone.key_positions["4key"].iter().take(2) { + assert_eq!(position.sound_path, None); + assert_eq!(position.sound_enabled, Some(true)); + assert_eq!(position.sound_volume, Some(137.5)); + } + + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Redo, + &redo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let redone = store.editor_get().document; + for position in redone.key_positions["4key"].iter().take(2) { + assert_eq!(position.sound_path.as_deref(), Some("")); + assert_eq!(position.sound_enabled, Some(true)); + assert_eq!(position.sound_volume, Some(137.5)); + } + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn sound_enabled_batch_replays_and_round_trips_one_history_entry() { + let dir = test_directory("editor-sound-enabled-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = initialize_neutral_editor_store(&dir); + let mut document = store.editor_get().document; + let mut first = document.key_positions["4key"][0].clone(); + first.sound_enabled = None; + first.sound_path = Some("sounds/sibling.wav".to_string()); + first.sound_volume = Some(137.5); + first.inactive_image = Some("idle-sibling.png".to_string()); + first.active_image = Some("active-sibling.png".to_string()); + first.counter.enabled = false; + let second_id = document.key_positions["4key"][1].id.clone(); + document.key_positions.get_mut("4key").unwrap()[0] = first.clone(); + document.key_positions.get_mut("4key").unwrap()[1] = KeyPosition { + id: second_id.clone(), + sound_enabled: Some(false), + ..first.clone() + }; + let setup = store + .commit_editor_document(editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, + keys: Some(document.keys), + key_positions: Some(document.key_positions), + ..EditorPatchV1::default() + }, + )) + .unwrap(); + let patch = EditorElementPropertyPatchV1::SoundEnabled(false); + let targets = [first.id, second_id]; + let ops = targets + .iter() + .map(|id| patch_property_op(EditorElementTypeV1::Key, id, patch.clone())) + .chain(std::iter::once(patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + patch.clone(), + ))) + .collect::>(); + let mutation_id = uuid::Uuid::new_v4().to_string(); + let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); + + let changed = store.commit_editor_document(request.clone()).unwrap(); + assert_eq!(changed.result.changed_fields, [EditorField::KeyPositions]); + assert_eq!( + changed + .result + .op_results + .as_ref() + .unwrap() + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::TargetMissing, + ] + ); + for position in changed.document.key_positions["4key"].iter().take(2) { + assert_eq!(position.sound_enabled, Some(false)); + assert_eq!(position.sound_path.as_deref(), Some("sounds/sibling.wav")); + assert_eq!(position.sound_volume, Some(137.5)); + assert_eq!(position.inactive_image.as_deref(), Some("idle-sibling.png")); + assert_eq!(position.active_image.as_deref(), Some("active-sibling.png")); + assert!(!position.counter.enabled); + } + let history_revision = store.history_status().history_revision; + + let replay = store.commit_editor_document(request.clone()).unwrap(); + assert!(replay.replayed); + assert_eq!(replay.result, changed.result); + assert_eq!(store.history_status().history_revision, history_revision); + + let mut reused = request; + reused.ops = Some(vec![patch_property_op( + EditorElementTypeV1::Key, + &targets[0], + EditorElementPropertyPatchV1::SoundEnabled(true), + )]); + assert_eq!( + store.commit_editor_document(reused).unwrap_err().error_code, + EditorCommitErrorCode::MutationIdReused + ); + + let no_change = store + .commit_editor_document(editor_ops_request( + changed.result.revision, + uuid::Uuid::new_v4().to_string(), + ops[..2].to_vec(), + )) + .unwrap(); + assert!(no_change.result.changed_fields.is_empty()); + assert!(no_change.event.is_none()); + assert_eq!(store.history_status().history_revision, history_revision); + + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Undo, + &undo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let undone = store.editor_get().document; + assert_eq!(undone.key_positions["4key"][0].sound_enabled, None); + assert_eq!(undone.key_positions["4key"][1].sound_enabled, Some(false)); + for position in undone.key_positions["4key"].iter().take(2) { + assert_eq!(position.sound_path.as_deref(), Some("sounds/sibling.wav")); + assert_eq!(position.sound_volume, Some(137.5)); + } + + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Redo, + &redo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let redone = store.editor_get().document; + for position in redone.key_positions["4key"].iter().take(2) { + assert_eq!(position.sound_enabled, Some(false)); + assert_eq!(position.sound_path.as_deref(), Some("sounds/sibling.wav")); + assert_eq!(position.sound_volume, Some(137.5)); + } + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn sound_volume_batch_replays_and_round_trips_one_history_entry() { + let dir = test_directory("editor-sound-volume-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = initialize_neutral_editor_store(&dir); + let mut document = store.editor_get().document; + let mut first = document.key_positions["4key"][0].clone(); + first.sound_volume = None; + first.sound_enabled = Some(true); + first.sound_path = Some("sounds/sibling.wav".to_string()); + first.inactive_image = Some("idle-sibling.png".to_string()); + first.active_image = Some("active-sibling.png".to_string()); + first.counter.enabled = false; + let second_id = document.key_positions["4key"][1].id.clone(); + document.key_positions.get_mut("4key").unwrap()[0] = first.clone(); + document.key_positions.get_mut("4key").unwrap()[1] = KeyPosition { + id: second_id.clone(), + sound_volume: Some(100.0), + ..first.clone() + }; + let setup = store + .commit_editor_document(editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, + keys: Some(document.keys), + key_positions: Some(document.key_positions), + ..EditorPatchV1::default() + }, + )) + .unwrap(); + let patch = EditorElementPropertyPatchV1::SoundVolume(100.0); + let targets = [first.id, second_id]; + let ops = targets + .iter() + .map(|id| patch_property_op(EditorElementTypeV1::Key, id, patch.clone())) + .chain(std::iter::once(patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + patch.clone(), + ))) + .collect::>(); + let mutation_id = uuid::Uuid::new_v4().to_string(); + let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); + + let changed = store.commit_editor_document(request.clone()).unwrap(); + assert_eq!(changed.result.changed_fields, [EditorField::KeyPositions]); + assert_eq!( + changed + .result + .op_results + .as_ref() + .unwrap() + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::TargetMissing, + ] + ); + for position in changed.document.key_positions["4key"].iter().take(2) { + assert_eq!(position.sound_volume, Some(100.0)); + assert_eq!(position.sound_enabled, Some(true)); + assert_eq!(position.sound_path.as_deref(), Some("sounds/sibling.wav")); + assert_eq!(position.inactive_image.as_deref(), Some("idle-sibling.png")); + assert_eq!(position.active_image.as_deref(), Some("active-sibling.png")); + assert!(!position.counter.enabled); + } + let history_revision = store.history_status().history_revision; + + let replay = store.commit_editor_document(request.clone()).unwrap(); + assert!(replay.replayed); + assert_eq!(replay.result, changed.result); + assert_eq!(store.history_status().history_revision, history_revision); + + let mut reused = request; + reused.ops = Some(vec![patch_property_op( + EditorElementTypeV1::Key, + &targets[0], + EditorElementPropertyPatchV1::SoundVolume(50.0), + )]); + assert_eq!( + store.commit_editor_document(reused).unwrap_err().error_code, + EditorCommitErrorCode::MutationIdReused + ); + + let no_change = store + .commit_editor_document(editor_ops_request( + changed.result.revision, + uuid::Uuid::new_v4().to_string(), + ops[..2].to_vec(), + )) + .unwrap(); + assert!(no_change.result.changed_fields.is_empty()); + assert!(no_change.event.is_none()); + assert_eq!(store.history_status().history_revision, history_revision); + + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Undo, + &undo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let undone = store.editor_get().document; + assert_eq!(undone.key_positions["4key"][0].sound_volume, None); + assert_eq!(undone.key_positions["4key"][1].sound_volume, Some(100.0)); + for position in undone.key_positions["4key"].iter().take(2) { + assert_eq!(position.sound_enabled, Some(true)); + assert_eq!(position.sound_path.as_deref(), Some("sounds/sibling.wav")); + } + + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Redo, + &redo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let redone = store.editor_get().document; + for position in redone.key_positions["4key"].iter().take(2) { + assert_eq!(position.sound_volume, Some(100.0)); + assert_eq!(position.sound_enabled, Some(true)); + assert_eq!(position.sound_path.as_deref(), Some("sounds/sibling.wav")); + } + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn counter_boolean_batch_replays_and_round_trips_one_history_entry() { + let dir = test_directory("editor-counter-boolean-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let key_id = store.editor_get().document.key_positions["4key"][0] + .id + .clone(); + let stat_id = uuid::Uuid::new_v4().to_string(); + let setup = legacy_editor_commit( + &store, + &[EditorField::KeyPositions, EditorField::StatPositions], + |data| { + let key = &mut data.key_positions.get_mut("4key").unwrap()[0]; + key.counter.enabled = true; + key.counter.placement = crate::models::KeyCounterPlacement::Outside; + key.counter.animation.enabled = true; + key.counter.animation.preset_id = Some("builtin-linear".to_string()); + key.counter.animation.scale = 1.75; + let mut stat_position = key.clone(); + stat_position.id = stat_id.clone(); + data.stat_positions.insert( + "4key".to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: stat_position, + }], + ); + }, + ) + .unwrap(); + let counter_enabled = EditorElementPropertyPatchV1::CounterEnabled(false); + let animation_enabled = EditorElementPropertyPatchV1::CounterAnimationEnabled(false); + let ops = vec![ + patch_property_op(EditorElementTypeV1::Key, &key_id, counter_enabled.clone()), + patch_property_op( + EditorElementTypeV1::Stat, + &stat_id, + animation_enabled.clone(), + ), + patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + counter_enabled.clone(), + ), + ]; + let mutation_id = uuid::Uuid::new_v4().to_string(); + let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); + + let changed = store.commit_editor_document(request.clone()).unwrap(); + assert_eq!( + changed.result.changed_fields, + [EditorField::KeyPositions, EditorField::StatPositions] + ); + assert_eq!( + changed + .result + .op_results + .as_ref() + .unwrap() + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + let key_counter = &changed.document.key_positions["4key"][0].counter; + assert!(!key_counter.enabled); + assert!(key_counter.animation.enabled); + assert_eq!( + key_counter.animation.preset_id.as_deref(), + Some("builtin-linear") + ); + assert_eq!(key_counter.animation.scale, 1.75); + let stat_counter = &changed.document.stat_positions["4key"][0].position.counter; + assert!(stat_counter.enabled); + assert!(!stat_counter.animation.enabled); + assert_eq!( + stat_counter.animation.preset_id.as_deref(), + Some("builtin-linear") + ); + assert_eq!(stat_counter.animation.scale, 1.75); + let history_revision = store.history_status().history_revision; + + let replay = store.commit_editor_document(request.clone()).unwrap(); + assert!(replay.replayed); + assert_eq!(replay.result, changed.result); + assert_eq!(store.history_status().history_revision, history_revision); + + let mut reused = request; + reused.ops = Some(vec![patch_property_op( + EditorElementTypeV1::Key, + &key_id, + animation_enabled, + )]); + assert_eq!( + store.commit_editor_document(reused).unwrap_err().error_code, + EditorCommitErrorCode::MutationIdReused + ); + + let no_change = store + .commit_editor_document(editor_ops_request( + changed.result.revision, + uuid::Uuid::new_v4().to_string(), + ops[..2].to_vec(), + )) + .unwrap(); + assert!(no_change.result.changed_fields.is_empty()); + assert!(no_change.event.is_none()); + assert_eq!(store.history_status().history_revision, history_revision); + + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Undo, + &undo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let undone = store.editor_get().document; + assert!(undone.key_positions["4key"][0].counter.enabled); + assert!( + undone.stat_positions["4key"][0] + .position + .counter + .animation + .enabled + ); + for counter in [ + &undone.key_positions["4key"][0].counter, + &undone.stat_positions["4key"][0].position.counter, + ] { + assert_eq!( + counter.animation.preset_id.as_deref(), + Some("builtin-linear") + ); + assert_eq!(counter.animation.scale, 1.75); + } + + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Redo, + &redo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let redone = store.editor_get().document; + assert!(!redone.key_positions["4key"][0].counter.enabled); + assert!( + !redone.stat_positions["4key"][0] + .position + .counter + .animation + .enabled + ); + assert_eq!( + redone.stat_positions["4key"][0] + .position + .counter + .animation + .preset_id + .as_deref(), + Some("builtin-linear") + ); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn counter_layout_batch_replays_and_round_trips_one_history_entry() { + let dir = test_directory("editor-counter-layout-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let stat_ids = [ + uuid::Uuid::new_v4().to_string(), + uuid::Uuid::new_v4().to_string(), + ]; + let setup = legacy_editor_commit( + &store, + &[EditorField::KeyPositions, EditorField::StatPositions], + |data| { + let key_positions = data.key_positions.get_mut("4key").unwrap(); + for position in key_positions.iter_mut().take(2) { + let counter = &mut position.counter; + counter.enabled = false; + counter.placement = crate::models::KeyCounterPlacement::Inside; + counter.align = crate::models::KeyCounterAlign::Bottom; + counter.align_mode = crate::models::KeyCounterAlignMode::Between; + counter.gap = 7; + counter.fill.idle = "fill-sibling".to_string(); + counter.fill.active = "active-fill-sibling".to_string(); + counter.font_family = Some("font-sibling".to_string()); + counter.animation.enabled = false; + counter.animation.preset_id = Some("builtin-linear".to_string()); + counter.animation.scale = 1.75; + } + let stat_positions = key_positions + .iter() + .take(2) + .zip(stat_ids.iter()) + .map(|(position, id)| { + let mut position = position.clone(); + position.id = id.clone(); + StatPosition { + stat_type: StatType::Kps, + position, + } + }) + .collect(); + data.stat_positions + .insert("4key".to_string(), stat_positions); + }, + ) + .unwrap(); + let before = store.editor_get().document; + let key_ids = before.key_positions["4key"] + .iter() + .take(2) + .map(|position| position.id.clone()) + .collect::>(); + let original_counters = [ + before.key_positions["4key"][0].counter.clone(), + before.key_positions["4key"][1].counter.clone(), + before.stat_positions["4key"][0].position.counter.clone(), + before.stat_positions["4key"][1].position.counter.clone(), + ]; + let ops = vec![ + patch_property_op( + EditorElementTypeV1::Key, + &key_ids[0], + EditorElementPropertyPatchV1::CounterPlacement( + crate::models::KeyCounterPlacement::Outside, + ), + ), + patch_property_op( + EditorElementTypeV1::Key, + &key_ids[1], + EditorElementPropertyPatchV1::CounterAlign(crate::models::KeyCounterAlign::Right), + ), + patch_property_op( + EditorElementTypeV1::Stat, + &stat_ids[0], + EditorElementPropertyPatchV1::CounterAlignMode( + crate::models::KeyCounterAlignMode::Center, + ), + ), + patch_property_op( + EditorElementTypeV1::Stat, + &stat_ids[1], + EditorElementPropertyPatchV1::CounterGap(u32::MAX), + ), + patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + EditorElementPropertyPatchV1::CounterGap(9), + ), + ]; + let mutation_id = uuid::Uuid::new_v4().to_string(); + let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); + + let changed = store.commit_editor_document(request.clone()).unwrap(); + assert_eq!( + changed.result.changed_fields, + [EditorField::KeyPositions, EditorField::StatPositions] + ); + assert_eq!( + changed + .result + .op_results + .as_ref() + .unwrap() + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + let changed_counters = [ + changed.document.key_positions["4key"][0].counter.clone(), + changed.document.key_positions["4key"][1].counter.clone(), + changed.document.stat_positions["4key"][0] + .position + .counter + .clone(), + changed.document.stat_positions["4key"][1] + .position + .counter + .clone(), + ]; + let mut expected_counters = original_counters.clone(); + expected_counters[0].placement = crate::models::KeyCounterPlacement::Outside; + expected_counters[1].align = crate::models::KeyCounterAlign::Right; + expected_counters[2].align_mode = crate::models::KeyCounterAlignMode::Center; + expected_counters[3].gap = u32::MAX; + assert_eq!(changed_counters, expected_counters); + assert!(matches!( + changed_counters[0].align_mode, + crate::models::KeyCounterAlignMode::Between + )); + let history_revision = store.history_status().history_revision; + + let replay = store.commit_editor_document(request.clone()).unwrap(); + assert!(replay.replayed); + assert_eq!(replay.result, changed.result); + assert_eq!(store.history_status().history_revision, history_revision); + + let mut reused = request; + reused.ops = Some(vec![patch_property_op( + EditorElementTypeV1::Key, + &key_ids[0], + EditorElementPropertyPatchV1::CounterGap(13), + )]); + assert_eq!( + store.commit_editor_document(reused).unwrap_err().error_code, + EditorCommitErrorCode::MutationIdReused + ); + + let no_change = store + .commit_editor_document(editor_ops_request( + changed.result.revision, + uuid::Uuid::new_v4().to_string(), + ops[..4].to_vec(), + )) + .unwrap(); + assert!(no_change.result.changed_fields.is_empty()); + assert!(no_change.event.is_none()); + assert_eq!(store.history_status().history_revision, history_revision); + + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Undo, + &undo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let undone = store.editor_get().document; + assert_eq!( + [ + undone.key_positions["4key"][0].counter.clone(), + undone.key_positions["4key"][1].counter.clone(), + undone.stat_positions["4key"][0].position.counter.clone(), + undone.stat_positions["4key"][1].position.counter.clone(), + ], + original_counters + ); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Redo, + &redo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let redone = store.editor_get().document; + assert_eq!( + [ + redone.key_positions["4key"][0].counter.clone(), + redone.key_positions["4key"][1].counter.clone(), + redone.stat_positions["4key"][0].position.counter.clone(), + redone.stat_positions["4key"][1].position.counter.clone(), + ], + expected_counters + ); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn counter_typography_batch_replays_and_round_trips_one_history_entry() { + let dir = test_directory("editor-counter-typography-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let stat_ids = (0..4) + .map(|_| uuid::Uuid::new_v4().to_string()) + .collect::>(); + let setup = legacy_editor_commit( + &store, + &[EditorField::KeyPositions, EditorField::StatPositions], + |data| { + let key_positions = data.key_positions.get_mut("4key").unwrap(); + for position in key_positions.iter_mut().take(2) { + let counter = &mut position.counter; + counter.font_size = 16; + counter.font_weight = 400; + counter.font_italic = false; + counter.font_underline = false; + counter.font_strikethrough = false; + counter.font_family = Some("font-sibling".to_string()); + counter.fill.idle = "fill-sibling".to_string(); + counter.fill.active = "active-fill-sibling".to_string(); + counter.placement = crate::models::KeyCounterPlacement::Outside; + counter.animation.preset_id = Some("builtin-linear".to_string()); + } + let stat_positions = key_positions + .iter() + .cycle() + .zip(stat_ids.iter()) + .map(|(position, id)| { + let mut position = position.clone(); + position.id = id.clone(); + StatPosition { + stat_type: StatType::Kps, + position, + } + }) + .collect(); + data.stat_positions + .insert("4key".to_string(), stat_positions); + data.stat_positions.get_mut("4key").unwrap()[3] + .position + .counter + .font_family = None; + data.stat_positions.get_mut("4key").unwrap()[3] + .position + .font_family = Some("top-level-font-sibling".to_string()); + }, + ) + .unwrap(); + let before = store.editor_get().document; + let key_ids = before.key_positions["4key"] + .iter() + .take(2) + .map(|position| position.id.clone()) + .collect::>(); + let original_counters = [ + before.key_positions["4key"][0].counter.clone(), + before.key_positions["4key"][1].counter.clone(), + before.stat_positions["4key"][0].position.counter.clone(), + before.stat_positions["4key"][1].position.counter.clone(), + before.stat_positions["4key"][2].position.counter.clone(), + before.stat_positions["4key"][3].position.counter.clone(), + ]; + let ops = vec![ + patch_property_op( + EditorElementTypeV1::Key, + &key_ids[0], + EditorElementPropertyPatchV1::CounterFontSize(72), + ), + patch_property_op( + EditorElementTypeV1::Key, + &key_ids[1], + EditorElementPropertyPatchV1::CounterFontWeight(900), + ), + patch_property_op( + EditorElementTypeV1::Stat, + &stat_ids[0], + EditorElementPropertyPatchV1::CounterFontItalic(true), + ), + patch_property_op( + EditorElementTypeV1::Stat, + &stat_ids[1], + EditorElementPropertyPatchV1::CounterFontUnderline(true), + ), + patch_property_op( + EditorElementTypeV1::Stat, + &stat_ids[2], + EditorElementPropertyPatchV1::CounterFontStrikethrough(true), + ), + patch_property_op( + EditorElementTypeV1::Stat, + &stat_ids[3], + EditorElementPropertyPatchV1::CounterFontFamily(" raw-counter-font ".to_string()), + ), + patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + EditorElementPropertyPatchV1::CounterFontSize(72), + ), + ]; + let mutation_id = uuid::Uuid::new_v4().to_string(); + let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); + + let changed = store.commit_editor_document(request.clone()).unwrap(); + assert_eq!( + changed.result.changed_fields, + [EditorField::KeyPositions, EditorField::StatPositions] + ); + assert_eq!( + changed + .result + .op_results + .as_ref() + .unwrap() + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + let changed_counters = [ + changed.document.key_positions["4key"][0].counter.clone(), + changed.document.key_positions["4key"][1].counter.clone(), + changed.document.stat_positions["4key"][0] + .position + .counter + .clone(), + changed.document.stat_positions["4key"][1] + .position + .counter + .clone(), + changed.document.stat_positions["4key"][2] + .position + .counter + .clone(), + changed.document.stat_positions["4key"][3] + .position + .counter + .clone(), + ]; + let mut expected_counters = original_counters.clone(); + expected_counters[0].font_size = 72; + expected_counters[1].font_weight = 900; + expected_counters[2].font_italic = true; + expected_counters[3].font_underline = true; + expected_counters[4].font_strikethrough = true; + expected_counters[5].font_family = Some(" raw-counter-font ".to_string()); + assert_eq!(changed_counters, expected_counters); + assert_eq!( + changed.document.stat_positions["4key"][3] + .position + .font_family + .as_deref(), + Some("top-level-font-sibling") + ); + let history_revision = store.history_status().history_revision; + + let replay = store.commit_editor_document(request.clone()).unwrap(); + assert!(replay.replayed); + assert_eq!(replay.result, changed.result); + assert_eq!(store.history_status().history_revision, history_revision); + + let mut reused = request; + reused.ops = Some(vec![patch_property_op( + EditorElementTypeV1::Key, + &key_ids[0], + EditorElementPropertyPatchV1::CounterFontSize(24), + )]); + assert_eq!( + store.commit_editor_document(reused).unwrap_err().error_code, + EditorCommitErrorCode::MutationIdReused + ); + + let no_change = store + .commit_editor_document(editor_ops_request( + changed.result.revision, + uuid::Uuid::new_v4().to_string(), + ops[..6].to_vec(), + )) + .unwrap(); + assert!(no_change.result.changed_fields.is_empty()); + assert!(no_change.event.is_none()); + assert_eq!(store.history_status().history_revision, history_revision); + + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Undo, + &undo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let undone = store.editor_get().document; + assert_eq!( + [ + undone.key_positions["4key"][0].counter.clone(), + undone.key_positions["4key"][1].counter.clone(), + undone.stat_positions["4key"][0].position.counter.clone(), + undone.stat_positions["4key"][1].position.counter.clone(), + undone.stat_positions["4key"][2].position.counter.clone(), + undone.stat_positions["4key"][3].position.counter.clone(), + ], + original_counters + ); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Redo, + &redo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let redone = store.editor_get().document; + assert_eq!( + [ + redone.key_positions["4key"][0].counter.clone(), + redone.key_positions["4key"][1].counter.clone(), + redone.stat_positions["4key"][0].position.counter.clone(), + redone.stat_positions["4key"][1].position.counter.clone(), + redone.stat_positions["4key"][2].position.counter.clone(), + redone.stat_positions["4key"][3].position.counter.clone(), + ], + expected_counters + ); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn counter_fill_batch_replays_atomic_pairs_and_round_trips_history() { + let dir = test_directory("editor-counter-fill-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let stat_id = uuid::Uuid::new_v4().to_string(); + let setup = legacy_editor_commit( + &store, + &[EditorField::KeyPositions, EditorField::StatPositions], + |data| { + let key_positions = data.key_positions.get_mut("4key").unwrap(); + for position in key_positions.iter_mut().take(2) { + position.counter.fill.idle = "idle-before".to_string(); + position.counter.fill.active = "active-before".to_string(); + position.counter.fill_idle_gradient = Some( + serde_json::from_value(serde_json::json!({ + "angle": 15, + "stops": [ + { "color": "old-idle", "pos": 0 }, + { "color": "old-idle-end", "pos": 1 } + ] + })) + .unwrap(), + ); + position.counter.fill_active_gradient = Some( + serde_json::from_value(serde_json::json!({ + "angle": 30, + "stops": [ + { "color": "old-active", "pos": 0 }, + { "color": "old-active-end", "pos": 1 } + ] + })) + .unwrap(), + ); + position.counter.gap = 19; + position.counter.font_family = Some("font-sibling".to_string()); + } + let mut stat_position = key_positions[0].clone(); + stat_position.id = stat_id.clone(); + data.stat_positions.insert( + "4key".to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: stat_position, + }], + ); + }, + ) + .unwrap(); + let before = store.editor_get().document; + let key_ids = before.key_positions["4key"] + .iter() + .take(2) + .map(|position| position.id.clone()) + .collect::>(); + let original_counters = [ + before.key_positions["4key"][0].counter.clone(), + before.key_positions["4key"][1].counter.clone(), + before.stat_positions["4key"][0].position.counter.clone(), + ]; + let idle_gradient = counter_fill_gradient( + "rgba(170,187,204,1)", + 45.0, + &[("#ABC", 0.0), ("#112233", 1.0)], + ); + let ops = vec![ + patch_property_op( + EditorElementTypeV1::Key, + &key_ids[0], + EditorElementPropertyPatchV1::CounterFillIdle(idle_gradient.clone()), + ), + patch_property_op( + EditorElementTypeV1::Key, + &key_ids[1], + EditorElementPropertyPatchV1::CounterFillActive(counter_fill_solid("")), + ), + patch_property_op( + EditorElementTypeV1::Stat, + &stat_id, + EditorElementPropertyPatchV1::CounterFillIdle(counter_fill_solid(" raw solid ")), + ), + patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + EditorElementPropertyPatchV1::CounterFillIdle(counter_fill_solid("missing")), + ), + ]; + let mutation_id = uuid::Uuid::new_v4().to_string(); + let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); + + let changed = store.commit_editor_document(request.clone()).unwrap(); + assert_eq!( + changed.result.changed_fields, + [EditorField::KeyPositions, EditorField::StatPositions] + ); + assert_eq!( + changed + .result + .op_results + .as_ref() + .unwrap() + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + let changed_counters = [ + changed.document.key_positions["4key"][0].counter.clone(), + changed.document.key_positions["4key"][1].counter.clone(), + changed.document.stat_positions["4key"][0] + .position + .counter + .clone(), + ]; + let mut expected_counters = original_counters.clone(); + expected_counters[0].fill.idle = "rgba(170,187,204,1)".to_string(); + expected_counters[0].fill_idle_gradient = match &idle_gradient { + crate::models::EditorCounterFillIntentV1::Gradient(intent) => { + Some(intent.gradient.to_gradient_spec()) + } + crate::models::EditorCounterFillIntentV1::Solid(_) => unreachable!(), + }; + expected_counters[1].fill.active.clear(); + expected_counters[1].fill_active_gradient = None; + expected_counters[2].fill.idle = " raw solid ".to_string(); + expected_counters[2].fill_idle_gradient = None; + assert_eq!(changed_counters, expected_counters); + let history_revision = store.history_status().history_revision; + + let replay = store.commit_editor_document(request.clone()).unwrap(); + assert!(replay.replayed); + assert_eq!(replay.result, changed.result); + assert_eq!(store.history_status().history_revision, history_revision); + + let mut reused = request; + reused.ops = Some(vec![patch_property_op( + EditorElementTypeV1::Key, + &key_ids[0], + EditorElementPropertyPatchV1::CounterFillIdle(counter_fill_solid("different")), + )]); + assert_eq!( + store.commit_editor_document(reused).unwrap_err().error_code, + EditorCommitErrorCode::MutationIdReused + ); + + let no_change = store + .commit_editor_document(editor_ops_request( + changed.result.revision, + uuid::Uuid::new_v4().to_string(), + ops[..3].to_vec(), + )) + .unwrap(); + assert!(no_change.result.changed_fields.is_empty()); + assert!(no_change.event.is_none()); + assert_eq!(store.history_status().history_revision, history_revision); + + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Undo, + &undo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let undone = store.editor_get().document; + assert_eq!( + [ + undone.key_positions["4key"][0].counter.clone(), + undone.key_positions["4key"][1].counter.clone(), + undone.stat_positions["4key"][0].position.counter.clone(), + ], + original_counters + ); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Redo, + &redo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let redone = store.editor_get().document; + assert_eq!( + [ + redone.key_positions["4key"][0].counter.clone(), + redone.key_positions["4key"][1].counter.clone(), + redone.stat_positions["4key"][0].position.counter.clone(), + ], + expected_counters + ); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn counter_animation_preset_batch_replays_and_round_trips_one_history_entry() { + let dir = test_directory("editor-counter-animation-preset-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let preset = CounterAnimationPreset { + id: "user-current".to_string(), + name: "Current".to_string(), + source: CounterAnimationSource::User, + label_key: None, + bezier: [0.1, 0.2, 0.8, 0.9], + scale: 1.25, + duration_ms: 420, + }; + let key_id = store.editor_get().document.key_positions["4key"][0] + .id + .clone(); + let stat_id = uuid::Uuid::new_v4().to_string(); + store + .update(|data| data.counter_animation_presets.push(preset.clone())) + .unwrap(); + let setup = legacy_editor_commit( + &store, + &[EditorField::KeyPositions, EditorField::StatPositions], + |data| { + let key = &mut data.key_positions.get_mut("4key").unwrap()[0]; + key.counter.animation.enabled = false; + key.counter.animation.preset_id = Some("builtin-ease-out".to_string()); + key.counter.animation.bezier = [0.3, 0.4, 0.5, 0.6]; + key.counter.animation.scale = 0.75; + key.counter.animation.duration_ms = 777; + let mut stat_position = key.clone(); + stat_position.id = stat_id.clone(); + data.stat_positions.insert( + "4key".to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: stat_position, + }], + ); + }, + ) + .unwrap(); + let partial = EditorElementPropertyPatchV1::CounterAnimationPreset( + crate::models::EditorCounterAnimationPresetIntentV1 { + preset_id: preset.id.clone(), + apply_preset_id: None, + bezier: None, + scale: Some(preset.scale), + duration_ms: None, + }, + ); + let full = EditorElementPropertyPatchV1::CounterAnimationPreset( + crate::models::EditorCounterAnimationPresetIntentV1 { + preset_id: preset.id.clone(), + apply_preset_id: Some(true), + bezier: Some(preset.bezier), + scale: Some(preset.scale), + duration_ms: Some(preset.duration_ms), + }, + ); + let ops = vec![ + patch_property_op(EditorElementTypeV1::Key, &key_id, partial.clone()), + patch_property_op(EditorElementTypeV1::Stat, &stat_id, full.clone()), + patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + full.clone(), + ), + ]; + let mutation_id = uuid::Uuid::new_v4().to_string(); + let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); + + let changed = store.commit_editor_document(request.clone()).unwrap(); + assert_eq!( + changed.result.changed_fields, + [EditorField::KeyPositions, EditorField::StatPositions] + ); + assert_eq!( + changed + .result + .op_results + .as_ref() + .unwrap() + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + let key_animation = &changed.document.key_positions["4key"][0].counter.animation; + assert!(!key_animation.enabled); + assert_eq!(key_animation.preset_id.as_deref(), Some("builtin-ease-out")); + assert_eq!(key_animation.bezier, [0.3, 0.4, 0.5, 0.6]); + assert_eq!(key_animation.scale, preset.scale); + assert_eq!(key_animation.duration_ms, 777); + let stat_animation = &changed.document.stat_positions["4key"][0] + .position + .counter + .animation; + assert_eq!( + stat_animation.preset_id.as_deref(), + Some(preset.id.as_str()) + ); + assert_eq!(stat_animation.bezier, preset.bezier); + assert_eq!(stat_animation.scale, preset.scale); + assert_eq!(stat_animation.duration_ms, preset.duration_ms); + let history_revision = store.history_status().history_revision; + + let replay = store.commit_editor_document(request.clone()).unwrap(); + assert!(replay.replayed); + assert_eq!(replay.result, changed.result); + assert_eq!(store.history_status().history_revision, history_revision); + + let mut reused = request; + reused.ops = Some(vec![patch_property_op( + EditorElementTypeV1::Key, + &key_id, + full.clone(), + )]); + assert_eq!( + store.commit_editor_document(reused).unwrap_err().error_code, + EditorCommitErrorCode::MutationIdReused + ); + + let no_change = store + .commit_editor_document(editor_ops_request( + changed.result.revision, + uuid::Uuid::new_v4().to_string(), + ops[..2].to_vec(), + )) + .unwrap(); + assert!(no_change.result.changed_fields.is_empty()); + assert!(no_change.event.is_none()); + assert_eq!(store.history_status().history_revision, history_revision); + + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Undo, + &undo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let undone = store.editor_get().document; + for animation in [ + &undone.key_positions["4key"][0].counter.animation, + &undone.stat_positions["4key"][0].position.counter.animation, + ] { + assert_eq!(animation.preset_id.as_deref(), Some("builtin-ease-out")); + assert_eq!(animation.bezier, [0.3, 0.4, 0.5, 0.6]); + assert_eq!(animation.scale, 0.75); + assert_eq!(animation.duration_ms, 777); + } + + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Redo, + &redo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let redone = store.editor_get().document; + assert_eq!( + redone.key_positions["4key"][0] + .counter + .animation + .preset_id + .as_deref(), + Some("builtin-ease-out") + ); + assert_eq!( + redone.stat_positions["4key"][0] + .position + .counter + .animation + .preset_id + .as_deref(), + Some(preset.id.as_str()) + ); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn stat_type_patch_replays_and_round_trips_one_history_entry() { + let dir = test_directory("editor-stat-type-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial = store.editor_get(); + let stat_id = uuid::Uuid::new_v4().to_string(); + let stat = StatPosition { + stat_type: StatType::Kps, + position: KeyPosition { + id: stat_id.clone(), + ..initial.document.key_positions["4key"][0].clone() + }, + }; + let setup = store + .commit_editor_document(editor_request( + initial.revision, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, + stat_positions: Some(HashMap::from([("4key".to_string(), vec![stat.clone()])])), + ..EditorPatchV1::default() + }, + )) + .unwrap(); + let patch = |stat_type| EditorElementPropertyPatchV1::StatType(stat_type); + let ops = vec![ + patch_property_op(EditorElementTypeV1::Stat, &stat_id, patch(StatType::Total)), + patch_property_op( + EditorElementTypeV1::Stat, + uuid::Uuid::new_v4().to_string(), + patch(StatType::KpsAvg), + ), + ]; + let mutation_id = uuid::Uuid::new_v4().to_string(); + let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); + + let changed = store.commit_editor_document(request.clone()).unwrap(); + assert_eq!(changed.result.changed_fields, [EditorField::StatPositions]); + assert_eq!( + changed.document.stat_positions["4key"][0].stat_type, + StatType::Total + ); + assert_eq!( + changed.document.stat_positions["4key"][0].position, + stat.position + ); + assert_eq!( + changed + .result + .op_results + .as_ref() + .unwrap() + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + let history_revision = store.history_status().history_revision; + + let replay = store.commit_editor_document(request.clone()).unwrap(); + assert!(replay.replayed); + assert_eq!(replay.result, changed.result); + assert_eq!(store.history_status().history_revision, history_revision); + + let mut reused = request; + reused.ops = Some(vec![patch_property_op( + EditorElementTypeV1::Stat, + &stat_id, + patch(StatType::KpsMax), + )]); + assert_eq!( + store.commit_editor_document(reused).unwrap_err().error_code, + EditorCommitErrorCode::MutationIdReused + ); + + let no_change = store + .commit_editor_document(editor_ops_request( + changed.result.revision, + uuid::Uuid::new_v4().to_string(), + vec![patch_property_op( + EditorElementTypeV1::Stat, + &stat_id, + patch(StatType::Total), + )], + )) + .unwrap(); + assert!(no_change.result.changed_fields.is_empty()); + assert!(no_change.event.is_none()); + assert_eq!(store.history_status().history_revision, history_revision); + + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Undo, + &undo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + assert_eq!( + store.editor_get().document.stat_positions["4key"][0].stat_type, + StatType::Kps + ); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Redo, + &redo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + assert_eq!( + store.editor_get().document.stat_positions["4key"][0].stat_type, + StatType::Total + ); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn note_paint_batch_replays_and_round_trips_one_history_entry() { + let dir = test_directory("editor-note-paint-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let ids = store.editor_get().document.key_positions["4key"] + .iter() + .take(4) + .map(|position| position.id.clone()) + .collect::>(); + assert_eq!(ids.len(), 4); + let setup = legacy_editor_commit(&store, &[EditorField::KeyPositions], |data| { + let positions = data.key_positions.get_mut("4key").unwrap(); + positions[0].note_color = crate::models::NoteColor::Solid("old".to_string()); + positions[0].note_opacity_top = Some(17); + positions[0].note_opacity_bottom = Some(27); + positions[1].note_glow_color = None; + positions[1].note_glow_opacity = 70; + positions[1].note_glow_opacity_top = None; + positions[1].note_glow_opacity_bottom = None; + positions[2].note_border_color = None; + positions[2].note_border_opacity = 100; + positions[2].note_border_gradient = serde_json::from_value(serde_json::json!({ + "angle": 45, + "stops": [ + { "color": "#010203", "pos": 0 }, + { "color": "#040506", "pos": 1 } + ] + })) + .unwrap(); + positions[3].note_glow_color = None; + positions[3].note_glow_opacity = 61; + }) + .unwrap(); + let legacy_note_wire = serde_json::to_vec(&setup.document.key_positions["4key"][0]).unwrap(); + let ops = vec![ + patch_property_op( + EditorElementTypeV1::Key, + &ids[0], + EditorElementPropertyPatchV1::NotePaint( + crate::models::EditorNotePaintIntentV1::Descriptor( + crate::models::EditorNotePaintDescriptorIntentV1 { + color: crate::models::EditorNoteColorV1::Gradient( + crate::models::EditorNoteGradientColorV1 { + kind: crate::models::EditorNoteGradientColorKindV1::Gradient, + top: "#112233".to_string(), + bottom: "#445566".to_string(), + }, + ), + opacity: 80, + gradient: Some(crate::models::EditorPaintGradientV1 { + angle: 45.0, + stops: vec![ + crate::models::EditorPaintGradientStopV1 { + color: "rgba(17,34,51,.5)".to_string(), + pos: 0.0, + }, + crate::models::EditorPaintGradientStopV1 { + color: "#44556640".to_string(), + pos: 1.0, + }, + ], + }), + }, + ), + ), + ), + patch_property_op( + EditorElementTypeV1::Key, + &ids[1], + EditorElementPropertyPatchV1::NoteGlowPaint( + crate::models::EditorNotePaintIntentV1::GradientOpacity( + crate::models::EditorNotePaintGradientOpacityIntentV1 { + opacity: 70, + opacity_top: 20, + opacity_bottom: 80, + }, + ), + ), + ), + patch_property_op( + EditorElementTypeV1::Key, + &ids[2], + EditorElementPropertyPatchV1::NoteBorderPaint(crate::models::EditorNoteBorderPaintV1 { + color: "#112233".to_string(), + opacity: 73, + gradient: Some(crate::models::EditorPaintGradientV1 { + angle: 135.0, + stops: vec![ + crate::models::EditorPaintGradientStopV1 { + color: "rgba(17, 34, 51, .5)".to_string(), + pos: 0.0, + }, + crate::models::EditorPaintGradientStopV1 { + color: "#ABC8".to_string(), + pos: 1.0, + }, + ], + }), + }), + ), + patch_property_op( + EditorElementTypeV1::Key, + &ids[3], + EditorElementPropertyPatchV1::NoteGlowPaint( + crate::models::EditorNotePaintIntentV1::Color( + crate::models::EditorNotePaintColorIntentV1 { + color: crate::models::EditorNoteColorV1::Solid(String::new()), + }, + ), + ), + ), + ]; + let mutation_id = uuid::Uuid::new_v4().to_string(); + let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); + let history_before = store.history_status().history_revision; + + let changed = store.commit_editor_document(request.clone()).unwrap(); + assert_eq!(changed.result.changed_fields, [EditorField::KeyPositions]); + assert!(changed + .result + .op_results + .as_ref() + .unwrap() + .iter() + .all(|result| result.status == EditorOpResultStatusV1::Applied)); + let positions = &changed.document.key_positions["4key"]; + assert_eq!(positions[0].note_opacity, 80); + assert_eq!(positions[0].note_opacity_top, Some(40)); + assert_eq!(positions[0].note_opacity_bottom, Some(20)); + assert_eq!(positions[0].note_gradient.as_ref().unwrap().angle, 45.0); + assert_eq!(positions[1].note_glow_opacity, 70); + assert_eq!(positions[1].note_glow_opacity_top, Some(20)); + assert_eq!(positions[1].note_glow_opacity_bottom, Some(80)); + assert!(positions[1].note_glow_color.is_none()); + assert_eq!(positions[2].note_border_color.as_deref(), Some("#112233")); + assert_eq!(positions[2].note_border_opacity, 73); + assert_eq!( + positions[2].note_border_gradient.as_ref().unwrap().angle, + 135.0 + ); + assert_eq!( + positions[3].note_glow_color, + Some(crate::models::NoteColor::Solid(String::new())) + ); + assert_eq!(positions[3].note_glow_opacity, 61); + assert_eq!(store.history_status().history_revision, history_before + 1); + + let replay = store.commit_editor_document(request.clone()).unwrap(); + assert!(replay.replayed); + assert_eq!(replay.result, changed.result); + assert_eq!(store.history_status().history_revision, history_before + 1); + + let mut reused = request; + reused.ops = Some(vec![patch_property_op( + EditorElementTypeV1::Key, + &ids[0], + EditorElementPropertyPatchV1::NotePaint(crate::models::EditorNotePaintIntentV1::Opacity( + crate::models::EditorNotePaintOpacityIntentV1 { opacity: 56 }, + )), + )]); + assert_eq!( + store.commit_editor_document(reused).unwrap_err().error_code, + EditorCommitErrorCode::MutationIdReused + ); + + let no_change = store + .commit_editor_document(editor_ops_request( + changed.result.revision, + uuid::Uuid::new_v4().to_string(), + ops, + )) + .unwrap(); + assert!(no_change.result.changed_fields.is_empty()); + assert!(no_change.event.is_none()); + assert_eq!(store.history_status().history_revision, history_before + 1); + + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Undo, + &undo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let undone = store.editor_get().document; + assert_eq!( + serde_json::to_vec(&undone.key_positions["4key"][0]).unwrap(), + legacy_note_wire + ); + assert!(undone.key_positions["4key"][0].note_gradient.is_none()); + assert_eq!(undone.key_positions["4key"][0].note_opacity_top, Some(17)); + assert!(undone.key_positions["4key"][1] + .note_glow_opacity_top + .is_none()); + assert_eq!( + undone.key_positions["4key"][2].note_border_color.as_deref(), + Some("#010203") + ); + assert_eq!( + undone.key_positions["4key"][2] + .note_border_gradient + .as_ref() + .unwrap() + .angle, + 45.0 + ); + assert!(undone.key_positions["4key"][3].note_glow_color.is_none()); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Redo, + &redo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let redone = store.editor_get().document; + assert_eq!( + serde_json::to_vec(&redone.key_positions["4key"][0]).unwrap(), + serde_json::to_vec(&changed.document.key_positions["4key"][0]).unwrap() + ); + assert_eq!(redone.key_positions["4key"][0].note_opacity, 80); + assert!(redone.key_positions["4key"][0].note_gradient.is_some()); + assert_eq!( + redone.key_positions["4key"][1].note_glow_opacity_top, + Some(20) + ); + assert_eq!( + redone.key_positions["4key"][2].note_border_color.as_deref(), + Some("#112233") + ); + assert_eq!( + redone.key_positions["4key"][2] + .note_border_gradient + .as_ref() + .unwrap() + .angle, + 135.0 + ); + assert_eq!( + redone.key_positions["4key"][3].note_glow_color, + Some(crate::models::NoteColor::Solid(String::new())) + ); + + let event_positions = changed + .event + .as_ref() + .unwrap() + .patch + .key_positions + .as_ref() + .unwrap(); + assert_eq!(event_positions, &changed.document.key_positions); + assert_eq!(EditorDocumentV1::from_store(&store.snapshot()), redone); + + store.flush_and_shutdown().unwrap(); + drop(store); + let reloaded = crate::state::migration::load_store_from_path(&dir.join("store.json")).unwrap(); + assert!(!reloaded.needs_persist); + assert_eq!(EditorDocumentV1::from_store(&reloaded.data), redone); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn note_glow_size_replays_and_round_trips_one_history_entry() { + let dir = test_directory("editor-note-glow-size-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let target_id = store.editor_get().document.key_positions["4key"][0] + .id + .clone(); + let setup = legacy_editor_commit(&store, &[EditorField::KeyPositions], |data| { + let position = &mut data.key_positions.get_mut("4key").unwrap()[0]; + position.note_glow_size = 20.0; + position.note_glow_enabled = true; + position.note_glow_opacity = 71; + position.note_glow_color = + Some(crate::models::NoteColor::Solid("glow-sibling".to_string())); + position.note_color = crate::models::NoteColor::Solid("note-sibling".to_string()); + position.note_border_width = Some(2.5); + }) + .unwrap(); + let patch = |value| EditorElementPropertyPatchV1::NoteGlowSize(value); + let ops = vec![ + patch_property_op(EditorElementTypeV1::Key, &target_id, patch(0.5)), + patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + patch(0.5), + ), + ]; + let mutation_id = uuid::Uuid::new_v4().to_string(); + let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); + + let changed = store.commit_editor_document(request.clone()).unwrap(); + assert_eq!(changed.result.changed_fields, [EditorField::KeyPositions]); + assert_eq!( + changed + .result + .op_results + .as_ref() + .unwrap() + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + let position = &changed.document.key_positions["4key"][0]; + assert_eq!(position.note_glow_size, 0.5); + assert!(position.note_glow_enabled); + assert_eq!(position.note_glow_opacity, 71); + assert_eq!( + position.note_glow_color, + Some(crate::models::NoteColor::Solid("glow-sibling".to_string())) + ); + assert_eq!( + position.note_color, + crate::models::NoteColor::Solid("note-sibling".to_string()) + ); + assert_eq!(position.note_border_width, Some(2.5)); + let history_revision = store.history_status().history_revision; + + let replay = store.commit_editor_document(request.clone()).unwrap(); + assert!(replay.replayed); + assert_eq!(replay.result, changed.result); + assert_eq!(store.history_status().history_revision, history_revision); + + let mut reused = request; + reused.ops = Some(vec![patch_property_op( + EditorElementTypeV1::Key, + &target_id, + patch(1.5), + )]); + assert_eq!( + store.commit_editor_document(reused).unwrap_err().error_code, + EditorCommitErrorCode::MutationIdReused + ); + + let no_change = store + .commit_editor_document(editor_ops_request( + changed.result.revision, + uuid::Uuid::new_v4().to_string(), + vec![patch_property_op( + EditorElementTypeV1::Key, + &target_id, + patch(0.5), + )], + )) + .unwrap(); + assert!(no_change.result.changed_fields.is_empty()); + assert!(no_change.event.is_none()); + assert_eq!(store.history_status().history_revision, history_revision); + + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Undo, + &undo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let undone = store.editor_get().document; + assert_eq!(undone.key_positions["4key"][0].note_glow_size, 20.0); + assert!(undone.key_positions["4key"][0].note_glow_enabled); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Redo, + &redo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let redone = store.editor_get().document; + assert_eq!(redone.key_positions["4key"][0].note_glow_size, 0.5); + assert!(redone.key_positions["4key"][0].note_glow_enabled); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn note_numeric_batch_replays_raw_options_and_round_trips_one_history_entry() { + let dir = test_directory("editor-note-numeric-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial = store.editor_get().document; + let ids = [ + initial.key_positions["4key"][0].id.clone(), + initial.key_positions["4key"][1].id.clone(), + initial.key_positions["4key"][2].id.clone(), + initial.key_positions["4key"][3].id.clone(), + initial.key_positions["5key"][0].id.clone(), + ]; + let setup = legacy_editor_commit(&store, &[EditorField::KeyPositions], |data| { + for position in data.key_positions.values_mut().flatten() { + position.note_color = crate::models::NoteColor::Solid("note-sibling".to_string()); + position.note_glow_size = 17.5; + } + data.key_positions.get_mut("4key").unwrap()[0].note_offset_x = None; + data.key_positions.get_mut("4key").unwrap()[1].note_offset_y = Some(8.5); + data.key_positions.get_mut("4key").unwrap()[2].note_width = Some(31.5); + data.key_positions.get_mut("4key").unwrap()[3].note_border_width = None; + data.key_positions.get_mut("5key").unwrap()[0].note_border_radius = None; + }) + .unwrap(); + let ops = vec![ + patch_property_op( + EditorElementTypeV1::Key, + &ids[0], + EditorElementPropertyPatchV1::NoteOffsetX(Some(0.0)), + ), + patch_property_op( + EditorElementTypeV1::Key, + &ids[1], + EditorElementPropertyPatchV1::NoteOffsetY(None), + ), + patch_property_op( + EditorElementTypeV1::Key, + &ids[2], + EditorElementPropertyPatchV1::NoteWidth(None), + ), + patch_property_op( + EditorElementTypeV1::Key, + &ids[3], + EditorElementPropertyPatchV1::NoteBorderWidth(0.0), + ), + patch_property_op( + EditorElementTypeV1::Key, + &ids[4], + EditorElementPropertyPatchV1::NoteBorderRadius(4.0), + ), + patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + EditorElementPropertyPatchV1::NoteWidth(Some(20.0)), + ), + ]; + let mutation_id = uuid::Uuid::new_v4().to_string(); + let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); + + let changed = store.commit_editor_document(request.clone()).unwrap(); + assert_eq!(changed.result.changed_fields, [EditorField::KeyPositions]); + assert_eq!( + changed + .result + .op_results + .as_ref() + .unwrap() + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + assert_eq!( + changed.document.key_positions["4key"][0].note_offset_x, + Some(0.0) + ); + assert_eq!( + changed.document.key_positions["4key"][1].note_offset_y, + None + ); + assert_eq!(changed.document.key_positions["4key"][2].note_width, None); + assert_eq!( + changed.document.key_positions["4key"][3].note_border_width, + Some(0.0) + ); + assert_eq!( + changed.document.key_positions["5key"][0].note_border_radius, + Some(4.0) + ); + for position in changed.document.key_positions.values().flatten() { + assert_eq!( + position.note_color, + crate::models::NoteColor::Solid("note-sibling".to_string()) + ); + assert_eq!(position.note_glow_size, 17.5); + } + let history_revision = store.history_status().history_revision; + + let replay = store.commit_editor_document(request.clone()).unwrap(); + assert!(replay.replayed); + assert_eq!(replay.result, changed.result); + assert_eq!(store.history_status().history_revision, history_revision); + + let mut reused = request; + reused.ops = Some(vec![patch_property_op( + EditorElementTypeV1::Key, + &ids[0], + EditorElementPropertyPatchV1::NoteOffsetX(None), + )]); + assert_eq!( + store.commit_editor_document(reused).unwrap_err().error_code, + EditorCommitErrorCode::MutationIdReused + ); + + let no_change = store + .commit_editor_document(editor_ops_request( + changed.result.revision, + uuid::Uuid::new_v4().to_string(), + ops[..5].to_vec(), + )) + .unwrap(); + assert!(no_change.result.changed_fields.is_empty()); + assert!(no_change.event.is_none()); + assert_eq!(store.history_status().history_revision, history_revision); + + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Undo, + &undo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let undone = store.editor_get().document; + assert_eq!(undone.key_positions["4key"][0].note_offset_x, None); + assert_eq!(undone.key_positions["4key"][1].note_offset_y, Some(8.5)); + assert_eq!(undone.key_positions["4key"][2].note_width, Some(31.5)); + assert_eq!(undone.key_positions["4key"][3].note_border_width, None); + assert_eq!(undone.key_positions["5key"][0].note_border_radius, None); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Redo, + &redo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let redone = store.editor_get().document; + assert_eq!(redone.key_positions["4key"][0].note_offset_x, Some(0.0)); + assert_eq!(redone.key_positions["4key"][1].note_offset_y, None); + assert_eq!(redone.key_positions["4key"][2].note_width, None); + assert_eq!(redone.key_positions["4key"][3].note_border_width, Some(0.0)); + assert_eq!( + redone.key_positions["5key"][0].note_border_radius, + Some(4.0) + ); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn paint_descriptor_batch_replays_and_round_trips_one_history_entry() { + let dir = test_directory("editor-paint-descriptor-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial = store.editor_get().document; + let first_id = initial.key_positions["4key"][0].id.clone(); + let second_id = initial.key_positions["4key"][1].id.clone(); + let setup = legacy_editor_commit(&store, &[EditorField::KeyPositions], |data| { + let first = &mut data.key_positions.get_mut("4key").unwrap()[0]; + first.background_color = Some("idle-before".to_string()); + first.background_gradient = None; + first.active_background_color = None; + first.active_background_gradient = None; + first.border_color = Some("first-border-sibling".to_string()); + let second = &mut data.key_positions.get_mut("4key").unwrap()[1]; + second.active_border_color = None; + second.active_border_gradient = None; + second.background_color = Some("second-background-sibling".to_string()); + }) + .unwrap(); + let gradient_stops = [("next-bg", 0.0), ("next-tail", 1.0)]; + let ops = vec![ + patch_property_op( + EditorElementTypeV1::Key, + &first_id, + EditorElementPropertyPatchV1::BackgroundPaint(paint_descriptor( + "next-bg", + Some((135.0, &gradient_stops)), + )), + ), + patch_property_op( + EditorElementTypeV1::Key, + &second_id, + EditorElementPropertyPatchV1::ActiveBorderPaint(paint_descriptor( + "active-border", + None, + )), + ), + patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + EditorElementPropertyPatchV1::BorderPaint(paint_descriptor("missing", None)), + ), + ]; + let mutation_id = uuid::Uuid::new_v4().to_string(); + let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); + + let changed = store.commit_editor_document(request.clone()).unwrap(); + assert_eq!(changed.result.changed_fields, [EditorField::KeyPositions]); + assert_eq!( + changed + .result + .op_results + .as_ref() + .unwrap() + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + let first = &changed.document.key_positions["4key"][0]; + assert_eq!(first.background_color.as_deref(), Some("next-bg")); + assert_eq!(first.background_gradient.as_ref().unwrap().angle, 135.0); + assert_eq!( + first.active_background_color.as_deref(), + Some("idle-before") + ); + assert_eq!(first.border_color.as_deref(), Some("first-border-sibling")); + let second = &changed.document.key_positions["4key"][1]; + assert_eq!(second.active_border_color.as_deref(), Some("active-border")); + assert_eq!( + second.background_color.as_deref(), + Some("second-background-sibling") + ); + let history_revision = store.history_status().history_revision; + + let replay = store.commit_editor_document(request.clone()).unwrap(); + assert!(replay.replayed); + assert_eq!(replay.result, changed.result); + assert_eq!(store.history_status().history_revision, history_revision); + + let mut reused = request; + reused.ops = Some(vec![patch_property_op( + EditorElementTypeV1::Key, + &first_id, + EditorElementPropertyPatchV1::BackgroundPaint(paint_descriptor("different", None)), + )]); + assert_eq!( + store.commit_editor_document(reused).unwrap_err().error_code, + EditorCommitErrorCode::MutationIdReused + ); + + let no_change = store + .commit_editor_document(editor_ops_request( + changed.result.revision, + uuid::Uuid::new_v4().to_string(), + ops[..2].to_vec(), + )) + .unwrap(); + assert!(no_change.result.changed_fields.is_empty()); + assert!(no_change.event.is_none()); + assert_eq!(store.history_status().history_revision, history_revision); + + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Undo, + &undo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let undone = store.editor_get().document; + assert_eq!( + undone.key_positions["4key"][0].background_color.as_deref(), + Some("idle-before") + ); + assert!(undone.key_positions["4key"][0] + .active_background_color + .is_none()); + assert!(undone.key_positions["4key"][1] + .active_border_color + .is_none()); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Redo, + &redo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let redone = store.editor_get().document; + assert_eq!( + redone.key_positions["4key"][0].background_color.as_deref(), + Some("next-bg") + ); + assert_eq!( + redone.key_positions["4key"][0] + .active_background_color + .as_deref(), + Some("idle-before") + ); + assert_eq!( + redone.key_positions["4key"][1] + .active_border_color + .as_deref(), + Some("active-border") + ); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn font_paint_batch_replays_fallbacks_and_round_trips_one_history_entry() { + let dir = test_directory("editor-font-paint-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial = store.editor_get().document; + let key_idle_id = initial.key_positions["4key"][0].id.clone(); + let key_active_id = initial.key_positions["4key"][1].id.clone(); + let stat_id = uuid::Uuid::new_v4().to_string(); + let active_descriptor = paint_descriptor( + "active-first", + Some((25.0, &[("active-first", 0.0), ("active-last", 1.0)])), + ); + let stat_descriptor = paint_descriptor( + "stat-first", + Some((70.0, &[("stat-first", 0.0), ("stat-last", 1.0)])), + ); + let setup = legacy_editor_commit( + &store, + &[EditorField::KeyPositions, EditorField::StatPositions], + |data| { + let template = data.key_positions["4key"][0].clone(); + let key_idle = &mut data.key_positions.get_mut("4key").unwrap()[0]; + key_idle.font_color = Some("same-idle".to_string()); + key_idle.font_gradient = None; + key_idle.active_font_color = None; + key_idle.active_font_gradient = None; + key_idle.background_color = Some("key-background-sibling".to_string()); + let key_active = &mut data.key_positions.get_mut("4key").unwrap()[1]; + key_active.font_color = Some("key-idle-sibling".to_string()); + key_active.font_gradient = None; + key_active.active_font_color = None; + key_active.active_font_gradient = None; + + let mut stat_position = template.clone(); + stat_position.id = stat_id.clone(); + stat_position.font_color = None; + stat_position.font_gradient = None; + stat_position.active_font_color = Some("stat-active-sibling".to_string()); + stat_position.active_font_gradient = None; + data.stat_positions.insert( + "4key".to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: stat_position, + }], + ); + }, + ) + .unwrap(); + let ops = vec![ + patch_property_op( + EditorElementTypeV1::Key, + &key_idle_id, + EditorElementPropertyPatchV1::FontPaint(paint_descriptor("same-idle", None)), + ), + patch_property_op( + EditorElementTypeV1::Key, + &key_active_id, + EditorElementPropertyPatchV1::ActiveFontPaint(active_descriptor.clone()), + ), + patch_property_op( + EditorElementTypeV1::Stat, + &stat_id, + EditorElementPropertyPatchV1::FontPaint(stat_descriptor.clone()), + ), + patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + EditorElementPropertyPatchV1::ActiveFontPaint(paint_descriptor("missing", None)), + ), + ]; + let history_before = store.history_status().history_revision; + let mutation_id = uuid::Uuid::new_v4().to_string(); + let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); + + let changed = store.commit_editor_document(request.clone()).unwrap(); + assert_eq!( + changed.result.changed_fields, + [EditorField::KeyPositions, EditorField::StatPositions] + ); + assert_eq!( + changed + .result + .op_results + .as_ref() + .unwrap() + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + assert_eq!( + changed.document.key_positions["4key"][0] + .active_font_color + .as_deref(), + Some("same-idle") + ); + assert_eq!( + changed.document.key_positions["4key"][0] + .background_color + .as_deref(), + Some("key-background-sibling") + ); + assert_eq!( + changed.document.key_positions["4key"][1] + .active_font_color + .as_deref(), + Some("active-first") + ); + assert!(changed.document.key_positions["4key"][1] + .active_font_gradient + .is_some()); + assert_eq!( + changed.document.stat_positions["4key"][0] + .position + .font_color + .as_deref(), + Some("stat-first") + ); + assert!(changed.document.stat_positions["4key"][0] + .position + .font_gradient + .is_some()); + assert_eq!( + changed.document.stat_positions["4key"][0] + .position + .active_font_color + .as_deref(), + Some("stat-active-sibling") + ); + assert_eq!(store.history_status().history_revision, history_before + 1); + + let replay = store.commit_editor_document(request.clone()).unwrap(); + assert!(replay.replayed); + assert_eq!(replay.result, changed.result); + assert_eq!(store.history_status().history_revision, history_before + 1); + + let mut reused = request; + reused.ops = Some(vec![patch_property_op( + EditorElementTypeV1::Key, + &key_idle_id, + EditorElementPropertyPatchV1::FontPaint(paint_descriptor("different", None)), + )]); + assert_eq!( + store.commit_editor_document(reused).unwrap_err().error_code, + EditorCommitErrorCode::MutationIdReused + ); + + let no_change = store + .commit_editor_document(editor_ops_request( + changed.result.revision, + uuid::Uuid::new_v4().to_string(), + ops[..3].to_vec(), + )) + .unwrap(); + assert!(no_change.result.changed_fields.is_empty()); + assert!(no_change.event.is_none()); + assert_eq!(store.history_status().history_revision, history_before + 1); + + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Undo, + &undo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let undone = store.editor_get().document; + assert!(undone.key_positions["4key"][0].active_font_color.is_none()); + assert!(undone.key_positions["4key"][1].active_font_color.is_none()); + assert!(undone.key_positions["4key"][1] + .active_font_gradient + .is_none()); + assert!(undone.stat_positions["4key"][0] + .position + .font_color + .is_none()); + assert!(undone.stat_positions["4key"][0] + .position + .font_gradient + .is_none()); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Redo, + &redo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let redone = store.editor_get().document; + assert_eq!( + redone.key_positions["4key"][0].active_font_color.as_deref(), + Some("same-idle") + ); + assert_eq!( + redone.key_positions["4key"][1].active_font_color.as_deref(), + Some("active-first") + ); + assert!(redone.key_positions["4key"][1] + .active_font_gradient + .is_some()); + assert!(redone.stat_positions["4key"][0] + .position + .font_gradient + .is_some()); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn paint_fallback_only_is_applied_and_records_one_history_entry() { + let dir = test_directory("editor-paint-fallback-only-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let target_id = store.editor_get().document.key_positions["4key"][0] + .id + .clone(); + let setup = legacy_editor_commit(&store, &[EditorField::KeyPositions], |data| { + let position = &mut data.key_positions.get_mut("4key").unwrap()[0]; + position.background_color = Some("same-paint".to_string()); + position.background_gradient = None; + position.active_background_color = None; + position.active_background_gradient = None; + }) + .unwrap(); + let history_before = store.history_status().history_revision; + let op = patch_property_op( + EditorElementTypeV1::Key, + &target_id, + EditorElementPropertyPatchV1::BackgroundPaint(paint_descriptor("same-paint", None)), + ); + + let changed = store + .commit_editor_document(editor_ops_request( + setup.result.revision, + uuid::Uuid::new_v4().to_string(), + vec![op.clone()], + )) + .unwrap(); + assert_eq!(changed.result.changed_fields, [EditorField::KeyPositions]); + assert_eq!( + changed.result.op_results.as_ref().unwrap()[0].status, + EditorOpResultStatusV1::Applied + ); + assert_eq!( + changed.document.key_positions["4key"][0] + .background_color + .as_deref(), + Some("same-paint") + ); + assert_eq!( + changed.document.key_positions["4key"][0] + .active_background_color + .as_deref(), + Some("same-paint") + ); + assert_eq!(store.history_status().history_revision, history_before + 1); + + let no_change = store + .commit_editor_document(editor_ops_request( + changed.result.revision, + uuid::Uuid::new_v4().to_string(), + vec![op], + )) + .unwrap(); + assert!(no_change.result.changed_fields.is_empty()); + assert_eq!( + no_change.result.op_results.as_ref().unwrap()[0].status, + EditorOpResultStatusV1::NoChange + ); + assert_eq!(store.history_status().history_revision, history_before + 1); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn shadow_enabled_master_replays_and_round_trips_one_history_entry() { + let dir = test_directory("editor-shadow-enabled-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let key_id = store.editor_get().document.key_positions["4key"][0] + .id + .clone(); + let stat_id = uuid::Uuid::new_v4().to_string(); + let knob_id = uuid::Uuid::new_v4().to_string(); + let stat_active_sentinel = crate::models::ElementShadowSpec { + enabled: true, + color: "stat-active-sentinel".to_string(), + offset_x: 1.0, + offset_y: 2.0, + blur: 3.0, + }; + let setup = legacy_editor_commit( + &store, + &[ + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::KnobPositions, + ], + |data| { + let key = &mut data.key_positions.get_mut("4key").unwrap()[0]; + key.inactive_image = Some("idle.png".to_string()); + key.active_image = Some("active.png".to_string()); + key.shadow = None; + key.active_shadow = None; + let mut stat_position = key.clone(); + stat_position.id = stat_id.clone(); + stat_position.shadow = None; + stat_position.active_shadow = Some(stat_active_sentinel.clone()); + let mut knob_position = key.clone(); + knob_position.id = knob_id.clone(); + knob_position.inactive_image = None; + knob_position.active_image = None; + knob_position.idle_transparent = true; + knob_position.active_transparent = true; + knob_position.border_width = Some(2.0); + knob_position.shadow = None; + knob_position.active_shadow = None; + data.stat_positions.insert( + "4key".to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: stat_position, + }], + ); + data.knob_positions.insert( + "4key".to_string(), + vec![KnobPosition { + axis_id: "axis".to_string(), + sensitivity: 1.0, + reverse: false, + position: knob_position, + }], + ); + }, + ) + .unwrap(); + let ops = vec![ + patch_property_op( + EditorElementTypeV1::Key, + &key_id, + EditorElementPropertyPatchV1::ShadowEnabled(true), + ), + patch_property_op( + EditorElementTypeV1::Stat, + &stat_id, + EditorElementPropertyPatchV1::ShadowEnabled(false), + ), + patch_property_op( + EditorElementTypeV1::Knob, + &knob_id, + EditorElementPropertyPatchV1::ShadowEnabled(true), + ), + ]; + let mutation_id = uuid::Uuid::new_v4().to_string(); + let request = editor_ops_request(setup.result.revision, &mutation_id, ops.clone()); + let history_before = store.history_status().history_revision; + + let changed = store.commit_editor_document(request.clone()).unwrap(); + assert_eq!( + changed.result.changed_fields, + [ + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::KnobPositions, + ] + ); + assert_eq!( + changed + .result + .op_results + .as_ref() + .unwrap() + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + ] + ); + let key = &changed.document.key_positions["4key"][0]; + assert!(key.shadow.as_ref().unwrap().enabled); + assert!(key.active_shadow.as_ref().unwrap().enabled); + assert_eq!(key.shadow.as_ref().unwrap().color, "rgba(0, 0, 0, 0.28)"); + assert_eq!( + key.active_shadow.as_ref().unwrap().color, + "rgba(0, 0, 0, 0.32)" + ); + let stat = &changed.document.stat_positions["4key"][0].position; + assert!(!stat.shadow.as_ref().unwrap().enabled); + assert_eq!(stat.active_shadow, Some(stat_active_sentinel.clone())); + let knob = &changed.document.knob_positions["4key"][0].position; + assert!(knob.shadow.as_ref().unwrap().enabled); + assert!(knob.active_shadow.as_ref().unwrap().enabled); + assert_eq!(knob.shadow.as_ref().unwrap().color, "rgba(0, 0, 0, 0.28)"); + assert_eq!( + knob.active_shadow.as_ref().unwrap().color, + "rgba(0, 0, 0, 0.32)" + ); + assert_eq!(store.history_status().history_revision, history_before + 1); + + let replay = store.commit_editor_document(request).unwrap(); + assert!(replay.replayed); + assert_eq!(replay.result, changed.result); + assert_eq!(store.history_status().history_revision, history_before + 1); + + let no_change = store + .commit_editor_document(editor_ops_request( + changed.result.revision, + uuid::Uuid::new_v4().to_string(), + ops, + )) + .unwrap(); + assert!(no_change.result.changed_fields.is_empty()); + assert!(no_change.event.is_none()); + assert_eq!(store.history_status().history_revision, history_before + 1); + + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Undo, + &undo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let undone = store.editor_get().document; + assert!(undone.key_positions["4key"][0].shadow.is_none()); + assert!(undone.key_positions["4key"][0].active_shadow.is_none()); + assert!(undone.stat_positions["4key"][0].position.shadow.is_none()); + assert!(undone.knob_positions["4key"][0].position.shadow.is_none()); + assert!(undone.knob_positions["4key"][0] + .position + .active_shadow + .is_none()); + assert_eq!( + undone.stat_positions["4key"][0].position.active_shadow, + Some(stat_active_sentinel.clone()) + ); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Redo, + &redo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let redone = store.editor_get().document; + assert!( + redone.key_positions["4key"][0] + .shadow + .as_ref() + .unwrap() + .enabled + ); + assert!( + redone.key_positions["4key"][0] + .active_shadow + .as_ref() + .unwrap() + .enabled + ); + assert!( + !redone.stat_positions["4key"][0] + .position + .shadow + .as_ref() + .unwrap() + .enabled + ); + assert_eq!( + redone.stat_positions["4key"][0].position.active_shadow, + Some(stat_active_sentinel) + ); + assert!( + redone.knob_positions["4key"][0] + .position + .shadow + .as_ref() + .unwrap() + .enabled + ); + assert!( + redone.knob_positions["4key"][0] + .position + .active_shadow + .as_ref() + .unwrap() + .enabled + ); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn shadow_mask_replays_and_round_trips_one_history_entry() { + let dir = test_directory("editor-shadow-mask-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let target_id = store.editor_get().document.key_positions["4key"][0] + .id + .clone(); + let setup = legacy_editor_commit(&store, &[EditorField::KeyPositions], |data| { + let position = &mut data.key_positions.get_mut("4key").unwrap()[0]; + position.shadow = None; + position.active_shadow = Some(crate::models::ElementShadowSpec { + enabled: true, + color: "active-sibling".to_string(), + offset_x: 1.0, + offset_y: 2.0, + blur: 3.0, + }); + }) + .unwrap(); + let op = patch_property_op( + EditorElementTypeV1::Key, + &target_id, + EditorElementPropertyPatchV1::Shadow(shadow_leaf_blur(10.0)), + ); + let mutation_id = uuid::Uuid::new_v4().to_string(); + let request = editor_ops_request(setup.result.revision, &mutation_id, vec![op.clone()]); + let history_before = store.history_status().history_revision; + + let changed = store.commit_editor_document(request.clone()).unwrap(); + assert_eq!(changed.result.changed_fields, [EditorField::KeyPositions]); + assert_eq!( + changed.result.op_results.as_ref().unwrap()[0].status, + EditorOpResultStatusV1::Applied + ); + let position = &changed.document.key_positions["4key"][0]; + let shadow = position.shadow.as_ref().unwrap(); + assert_eq!(shadow.blur, 10.0); + assert_eq!(shadow.color, "rgba(0, 0, 0, 0.28)"); + assert_eq!( + position.active_shadow.as_ref().unwrap().color, + "active-sibling" + ); + assert_eq!(store.history_status().history_revision, history_before + 1); + + let replay = store.commit_editor_document(request.clone()).unwrap(); + assert!(replay.replayed); + assert_eq!(replay.result, changed.result); + assert_eq!(store.history_status().history_revision, history_before + 1); + + let mut reused = request; + reused.ops = Some(vec![patch_property_op( + EditorElementTypeV1::Key, + &target_id, + EditorElementPropertyPatchV1::Shadow(shadow_leaf_blur(11.0)), + )]); + assert_eq!( + store.commit_editor_document(reused).unwrap_err().error_code, + EditorCommitErrorCode::MutationIdReused + ); + + let no_change = store + .commit_editor_document(editor_ops_request( + changed.result.revision, + uuid::Uuid::new_v4().to_string(), + vec![op], + )) + .unwrap(); + assert!(no_change.result.changed_fields.is_empty()); + assert!(no_change.event.is_none()); + assert_eq!(store.history_status().history_revision, history_before + 1); + + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Undo, + &undo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let undone = store.editor_get().document; + assert!(undone.key_positions["4key"][0].shadow.is_none()); + assert_eq!( + undone.key_positions["4key"][0] + .active_shadow + .as_ref() + .unwrap() + .color, + "active-sibling" + ); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Redo, + &redo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let redone = store.editor_get().document; + assert_eq!( + redone.key_positions["4key"][0] + .shadow + .as_ref() + .unwrap() + .blur, + 10.0 + ); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn note_literal_batch_replays_and_round_trips_one_history_entry() { + let dir = test_directory("editor-note-literal-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = initialize_neutral_editor_store(&dir); + let initial = store.editor_get().document; + let ids = initial + .key_positions + .values() + .flat_map(|positions| positions.iter().map(|position| position.id.clone())) + .take(5) + .collect::>(); + assert_eq!(ids.len(), 5); + let ops = vec![ + patch_property_op( + EditorElementTypeV1::Key, + &ids[0], + EditorElementPropertyPatchV1::NoteEffectEnabled(false), + ), + patch_property_op( + EditorElementTypeV1::Key, + &ids[1], + EditorElementPropertyPatchV1::NoteGlowEnabled(true), + ), + patch_property_op( + EditorElementTypeV1::Key, + &ids[2], + EditorElementPropertyPatchV1::NoteAutoYCorrection(false), + ), + patch_property_op( + EditorElementTypeV1::Key, + &ids[3], + EditorElementPropertyPatchV1::NoteAlignment(crate::models::NoteAlignment::Right), + ), + patch_property_op( + EditorElementTypeV1::Key, + &ids[4], + EditorElementPropertyPatchV1::NoteBorderSide( + crate::models::EditorNoteBorderSideV1::All, + ), + ), + patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + EditorElementPropertyPatchV1::NoteGlowEnabled(true), + ), + ]; + let mutation_id = uuid::Uuid::new_v4().to_string(); + let request = editor_ops_request(0, &mutation_id, ops.clone()); + + let changed = store.commit_editor_document(request.clone()).unwrap(); + assert_eq!(changed.result.changed_fields, [EditorField::KeyPositions]); + assert_eq!( + changed + .result + .op_results + .as_ref() + .unwrap() + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + let positions = changed + .document + .key_positions + .values() + .flatten() + .map(|position| (position.id.as_str(), position)) + .collect::>(); + assert!(!positions[ids[0].as_str()].note_effect_enabled); + assert!(positions[ids[1].as_str()].note_glow_enabled); + assert!(!positions[ids[2].as_str()].note_auto_y_correction); + assert_eq!( + positions[ids[3].as_str()].note_alignment, + crate::models::NoteAlignment::Right + ); + assert_eq!( + positions[ids[4].as_str()].note_border_side.as_deref(), + Some("all") + ); + assert_eq!(store.history_status().history_revision, 1); + + let replay = store.commit_editor_document(request.clone()).unwrap(); + assert!(replay.replayed); + assert_eq!(replay.result, changed.result); + + let mut reused = request; + reused.ops = Some(vec![patch_property_op( + EditorElementTypeV1::Key, + &ids[0], + EditorElementPropertyPatchV1::NoteEffectEnabled(true), + )]); + assert_eq!( + store.commit_editor_document(reused).unwrap_err().error_code, + EditorCommitErrorCode::MutationIdReused + ); + + let no_change = store + .commit_editor_document(editor_ops_request( + changed.result.revision, + uuid::Uuid::new_v4().to_string(), + ops[..5].to_vec(), + )) + .unwrap(); + assert!(no_change.result.changed_fields.is_empty()); + assert!(no_change.event.is_none()); + assert_eq!(store.history_status().history_revision, 1); + + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Undo, + &undo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let undone = store.editor_get().document; + assert!(undone.key_positions.values().flatten().all(|position| { + position.note_effect_enabled + && !position.note_glow_enabled + && position.note_auto_y_correction + && position.note_alignment == crate::models::NoteAlignment::Center + && position.note_border_side.is_none() + })); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Redo, + &redo_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let redone = store.editor_get().document; + let positions = redone + .key_positions + .values() + .flatten() + .map(|position| (position.id.as_str(), position)) + .collect::>(); + assert!(!positions[ids[0].as_str()].note_effect_enabled); + assert!(positions[ids[1].as_str()].note_glow_enabled); + assert!(!positions[ids[2].as_str()].note_auto_y_correction); + assert_eq!( + positions[ids[3].as_str()].note_alignment, + crate::models::NoteAlignment::Right + ); + assert_eq!( + positions[ids[4].as_str()].note_border_side.as_deref(), + Some("all") + ); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn editor_op_reports_target_missing_after_legacy_deletes_its_stable_id() { + let dir = test_directory("editor-op-after-legacy-delete-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial = store.editor_get().document; + let target = initial.key_positions["4key"][0].clone(); + + let legacy = legacy_editor_commit( + &store, + &[EditorField::Keys, EditorField::KeyPositions], + |data| { + data.keys.get_mut("4key").unwrap().remove(0); + data.key_positions.get_mut("4key").unwrap().remove(0); + }, + ) + .unwrap(); + assert_eq!(legacy.result.revision, 1); + assert!(legacy.document.key_positions["4key"] + .iter() + .all(|position| position.id != target.id)); + let history_after_legacy = store.history_status().history_revision; + + let op = store + .commit_editor_document(editor_ops_request( + 1, + uuid::Uuid::new_v4().to_string(), + vec![set_bounds_op( + EditorElementTypeV1::Key, + &target.id, + EditorBoundsV1 { + dx: target.dx + 10.0, + ..bounds(&target) + }, + )], + )) + .unwrap(); + + assert_eq!(op.result.revision, 1); + assert!(op.result.changed_fields.is_empty()); + assert!(op.event.is_none()); + assert_eq!( + op.result.op_results.as_ref().unwrap(), + &[crate::models::EditorOpResultV1 { + status: EditorOpResultStatusV1::TargetMissing, + bounds: None, + }] + ); + assert_eq!( + store.history_status().history_revision, + history_after_legacy + ); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn editor_ops_return_request_order_and_do_not_record_all_missing_or_no_change() { + let dir = test_directory("editor-ops-results-and-no-op-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial = store.editor_get().document; + let first = &initial.key_positions["4key"][0]; + let second = &initial.key_positions["4key"][1]; + let missing_id = uuid::Uuid::new_v4().to_string(); + let mut second_bounds = bounds(second); + second_bounds.dx += 31.0; + + let partial_mutation_id = uuid::Uuid::new_v4().to_string(); + let partial_request = editor_ops_request( + 0, + &partial_mutation_id, + vec![ + set_bounds_op(EditorElementTypeV1::Key, &first.id, bounds(first)), + set_bounds_op( + EditorElementTypeV1::Key, + &missing_id, + EditorBoundsV1 { + dx: 1.0, + dy: 2.0, + width: 3.0, + height: 4.0, + }, + ), + set_bounds_op(EditorElementTypeV1::Key, &second.id, second_bounds), + ], + ); + let change = store + .commit_editor_document(partial_request.clone()) + .unwrap(); + let results = change.result.op_results.as_ref().unwrap(); + assert_eq!( + results + .iter() + .map(|result| result.status) + .collect::>(), + vec![ + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::TargetMissing, + EditorOpResultStatusV1::Applied, + ] + ); + assert_eq!(results[0].bounds, Some(bounds(first))); + assert_eq!(results[1].bounds, None); + assert_eq!(results[2].bounds, Some(second_bounds)); + assert_eq!(store.history_status().history_revision, 1); + + let partial_replay = store.commit_editor_document(partial_request).unwrap(); + assert!(partial_replay.replayed); + assert_eq!(partial_replay.result, change.result); + let changed_reuse = store + .commit_editor_document(editor_ops_request( + 0, + partial_mutation_id, + vec![set_bounds_op( + EditorElementTypeV1::Key, + &first.id, + EditorBoundsV1 { + dx: first.dx + 1.0, + ..bounds(first) + }, + )], + )) + .unwrap_err(); + assert_eq!( + changed_reuse.error_code, + EditorCommitErrorCode::MutationIdReused + ); + + let missing_mutation_id = uuid::Uuid::new_v4().to_string(); + let missing_request = editor_ops_request( + 1, + &missing_mutation_id, + vec![set_bounds_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + EditorBoundsV1 { + dx: 5.0, + dy: 6.0, + width: 7.0, + height: 8.0, + }, + )], + ); + let missing = store + .commit_editor_document(missing_request.clone()) + .unwrap(); + assert_eq!(missing.result.revision, 1); + assert!(missing.result.changed_fields.is_empty()); + assert!(missing.event.is_none()); + assert_eq!( + missing.result.op_results.as_ref().unwrap()[0].status, + EditorOpResultStatusV1::TargetMissing + ); + assert_eq!(store.history_status().history_revision, 1); + + let replay = store.commit_editor_document(missing_request).unwrap(); + assert!(replay.replayed); + assert_eq!(replay.result, missing.result); + assert_eq!(store.history_status().history_revision, 1); + + let reused = store + .commit_editor_document(editor_ops_request( + 1, + missing_mutation_id, + vec![set_bounds_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + EditorBoundsV1 { + dx: 9.0, + dy: 10.0, + width: 11.0, + height: 12.0, + }, + )], + )) + .unwrap_err(); + assert_eq!(reused.error_code, EditorCommitErrorCode::MutationIdReused); + + let current = store.editor_get().document; + let current_second = current.key_positions["4key"] + .iter() + .find(|position| position.id == second.id) + .unwrap(); + let no_change = store + .commit_editor_document(editor_ops_request( + 1, + uuid::Uuid::new_v4().to_string(), + vec![set_bounds_op( + EditorElementTypeV1::Key, + ¤t_second.id, + bounds(current_second), + )], + )) + .unwrap(); + assert!(no_change.result.changed_fields.is_empty()); + assert!(no_change.event.is_none()); + assert_eq!(no_change.result.revision, 1); + assert_eq!(store.history_status().history_revision, 1); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn editor_group_bounds_partially_missing_targets_share_one_gesture_history_entry() { + let dir = test_directory("editor-group-bounds-partial-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial = store.editor_get().document; + let first = initial.key_positions["4key"][0].clone(); + let second = initial.key_positions["4key"][1].clone(); + let first_before = bounds(&first); + let second_before = bounds(&second); + let first_after = EditorBoundsV1 { + dx: first.dx + 11.0, + dy: first.dy + 12.0, + width: first.width + 13.0, + height: first.height + 14.0, + }; + let second_after = EditorBoundsV1 { + dx: second.dx + 21.0, + dy: second.dy + 22.0, + width: second.width + 23.0, + height: second.height + 24.0, + }; + let missing_id = uuid::Uuid::new_v4().to_string(); + let gesture_id = uuid::Uuid::new_v4().to_string(); + let mut request = editor_ops_request( + 0, + uuid::Uuid::new_v4().to_string(), + vec![ + set_bounds_op(EditorElementTypeV1::Key, &first.id, first_after), + set_bounds_op( + EditorElementTypeV1::Key, + missing_id, + EditorBoundsV1 { + dx: 1.0, + dy: 2.0, + width: 3.0, + height: 4.0, + }, + ), + set_bounds_op(EditorElementTypeV1::Key, &second.id, second_after), + ], + ); + request.gesture_id = Some(gesture_id.clone()); + + let change = store.commit_editor_document(request).unwrap(); + + assert_eq!(change.result.revision, 1); + assert_eq!(change.result.changed_fields, [EditorField::KeyPositions]); + assert_eq!( + change + .result + .op_results + .as_ref() + .unwrap() + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + EditorOpResultStatusV1::Applied, + ] + ); + assert_eq!(change.event.as_ref().unwrap().gesture_id, Some(gesture_id)); + assert_eq!(change.history_status.as_ref().unwrap().history_revision, 1); + assert_eq!(store.history_status().history_revision, 1); + assert_eq!( + bounds(&change.document.key_positions["4key"][0]), + first_after + ); + assert_eq!( + bounds(&change.document.key_positions["4key"][1]), + second_after + ); + + let undo_id = uuid::Uuid::new_v4().to_string(); + let gate = store.history_gate(); + let barrier = gate.close(&undo_id).unwrap(); + let current_counters = store.snapshot().key_counters; + let undo = store + .apply_history_operation(HistoryDirection::Undo, &undo_id, ¤t_counters, || {}) + .unwrap(); + drop(barrier); + + assert!(!undo.status.can_undo); + let restored = store.editor_get().document; + assert_eq!(bounds(&restored.key_positions["4key"][0]), first_before); + assert_eq!(bounds(&restored.key_positions["4key"][1]), second_before); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn legacy_delete_patch_keeps_key_pair_and_empty_group_cleanup_atomic() { + let dir = test_directory("legacy-delete-pair-group-contract-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = initialize_neutral_editor_store(&dir); + let mut grouped = store.editor_get().document; + grouped.key_positions.get_mut("4key").unwrap()[0].group_id = Some("delete-group".to_string()); + grouped.layer_groups.insert( + "4key".to_string(), + vec![crate::models::LayerGroupDef { + id: "delete-group".to_string(), + name: "Delete Group".to_string(), + }], + ); + let setup = store + .commit_editor_document(editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, + key_positions: Some(grouped.key_positions.clone()), + layer_groups: Some(grouped.layer_groups.clone()), + ..EditorPatchV1::default() + }, + )) + .unwrap(); + assert_eq!(setup.result.revision, 1); + let grouped = setup.document; + let deleted_id = grouped.key_positions["4key"][0].id.clone(); + let deleted_slot = grouped.keys["4key"][0].clone(); + let deleted_position = grouped.key_positions["4key"][0].clone(); + let mut keys = grouped.keys.clone(); + keys.get_mut("4key").unwrap().remove(0); + let mut positions = grouped.key_positions.clone(); + positions.get_mut("4key").unwrap().remove(0); + let mut groups = grouped.layer_groups.clone(); + groups.insert("4key".to_string(), Vec::new()); + + let deleted = store + .commit_editor_document(editor_request( + 1, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, + keys: Some(keys), + key_positions: Some(positions), + layer_groups: Some(groups), + ..EditorPatchV1::default() + }, + )) + .unwrap(); + + assert_eq!( + deleted.result.changed_fields, + [ + EditorField::Keys, + EditorField::KeyPositions, + EditorField::LayerGroups, + ] + ); + assert_eq!( + deleted.document.keys["4key"].len(), + grouped.keys["4key"].len() - 1 + ); + assert_eq!( + deleted.document.key_positions["4key"].len(), + grouped.key_positions["4key"].len() - 1 + ); + assert!(!deleted.document.key_positions["4key"] + .iter() + .any(|position| position.id == deleted_id)); + assert!(deleted.document.layer_groups["4key"].is_empty()); + assert_eq!(store.history_status().history_revision, 2); + + let operation_id = uuid::Uuid::new_v4().to_string(); + let gate = store.history_gate(); + let barrier = gate.close(&operation_id).unwrap(); + let counters = store.snapshot().key_counters; + store + .apply_history_operation(HistoryDirection::Undo, &operation_id, &counters, || {}) + .unwrap(); + drop(barrier); + let restored = store.editor_get().document; + assert_eq!(restored.keys["4key"][0], deleted_slot); + assert_eq!(restored.key_positions["4key"][0], deleted_position); + assert_eq!(restored.layer_groups["4key"], grouped.layer_groups["4key"]); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn delete_ops_remove_all_types_in_request_order_and_round_trip_one_history_entry() { + let dir = test_directory("delete-ops-all-types-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let mut setup = store.editor_get().document; + let template = setup.key_positions["4key"][0].clone(); + setup.key_positions.get_mut("4key").unwrap()[0].group_id = Some("delete-group".to_string()); + setup.key_positions.get_mut("4key").unwrap()[1].group_id = Some("survive-group".to_string()); + setup.key_positions.get_mut("4key").unwrap()[2].group_id = Some("survive-group".to_string()); + + let mut stat_position = template.clone(); + stat_position.id = uuid::Uuid::new_v4().to_string(); + stat_position.group_id = Some("delete-group".to_string()); + let stat_id = stat_position.id.clone(); + setup.stat_positions.insert( + "4key".to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: stat_position, + }], + ); + let mut graph_position = template.clone(); + graph_position.id = uuid::Uuid::new_v4().to_string(); + graph_position.group_id = None; + let graph_id = graph_position.id.clone(); + setup.graph_positions.insert( + "4key".to_string(), + vec![GraphPosition { + stat_type: GraphStatType::Kps, + graph_type: GraphType::Line, + graph_speed: 1, + graph_color: "#FFFFFF".to_string(), + show_avg_line: true, + position: graph_position, + }], + ); + let mut knob_position = template; + knob_position.id = uuid::Uuid::new_v4().to_string(); + knob_position.group_id = None; + let knob_id = knob_position.id.clone(); + setup.knob_positions.insert( + "4key".to_string(), + vec![KnobPosition { + axis_id: "delete-axis".to_string(), + sensitivity: 1.0, + reverse: false, + position: knob_position, + }], + ); + setup.layer_groups.insert( + "4key".to_string(), + vec![ + crate::models::LayerGroupDef { + id: "delete-group".to_string(), + name: "Delete Group".to_string(), + }, + crate::models::LayerGroupDef { + id: "survive-group".to_string(), + name: "Survive Group".to_string(), + }, + ], + ); + setup.layer_groups.insert( + "5key".to_string(), + vec![crate::models::LayerGroupDef { + id: "unaffected-mode-group".to_string(), + name: "Unaffected Mode Group".to_string(), + }], + ); + let setup = store + .commit_editor_document(editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, + key_positions: Some(setup.key_positions), + stat_positions: Some(setup.stat_positions), + graph_positions: Some(setup.graph_positions), + knob_positions: Some(setup.knob_positions), + layer_groups: Some(setup.layer_groups), + ..EditorPatchV1::default() + }, + )) + .unwrap() + .document; + let first_key_id = setup.key_positions["4key"][0].id.clone(); + let second_key_id = setup.key_positions["4key"][1].id.clone(); + let surviving_key_id = setup.key_positions["4key"][2].id.clone(); + let surviving_slot = setup.keys["4key"][2].clone(); + let missing_id = uuid::Uuid::new_v4().to_string(); + let mutation_id = uuid::Uuid::new_v4().to_string(); + let request = editor_ops_request( + 1, + mutation_id, + vec![ + delete_element_op(EditorElementTypeV1::Key, &first_key_id), + delete_element_op(EditorElementTypeV1::Graph, &missing_id), + delete_element_op(EditorElementTypeV1::Key, &second_key_id), + delete_element_op(EditorElementTypeV1::Stat, &stat_id), + delete_element_op(EditorElementTypeV1::Graph, &graph_id), + delete_element_op(EditorElementTypeV1::Knob, &knob_id), + ], + ); + + let change = store.commit_editor_document(request.clone()).unwrap(); + assert_eq!( + change + .result + .op_results + .as_ref() + .unwrap() + .iter() + .map(|result| (result.status, result.bounds)) + .collect::>(), + vec![ + (EditorOpResultStatusV1::Applied, None), + (EditorOpResultStatusV1::TargetMissing, None), + (EditorOpResultStatusV1::Applied, None), + (EditorOpResultStatusV1::Applied, None), + (EditorOpResultStatusV1::Applied, None), + (EditorOpResultStatusV1::Applied, None), + ] + ); + assert_eq!( + change.result.changed_fields, + [ + EditorField::Keys, + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + EditorField::LayerGroups, + ] + ); + assert_eq!(change.document.keys["4key"][0], surviving_slot); + assert_eq!( + change.document.key_positions["4key"][0].id, + surviving_key_id + ); + assert!(change.document.stat_positions["4key"].is_empty()); + assert!(change.document.graph_positions["4key"].is_empty()); + assert!(change.document.knob_positions["4key"].is_empty()); + assert_eq!( + change.document.layer_groups["4key"] + .iter() + .map(|group| group.id.as_str()) + .collect::>(), + ["survive-group"] + ); + assert_eq!( + change.document.layer_groups["5key"][0].id, + "unaffected-mode-group" + ); + assert_eq!(store.history_status().history_revision, 2); + + let replay = store.commit_editor_document(request.clone()).unwrap(); + assert!(replay.replayed); + assert_eq!(replay.result, change.result); + assert_eq!(store.history_status().history_revision, 2); + let mut reused = request; + reused.ops = Some(vec![delete_element_op( + EditorElementTypeV1::Key, + &surviving_key_id, + )]); + assert_eq!( + store.commit_editor_document(reused).unwrap_err().error_code, + EditorCommitErrorCode::MutationIdReused + ); + + let gate = store.history_gate(); + let counters = store.snapshot().key_counters; + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) + .unwrap(); + drop(barrier); + assert_eq!(store.editor_get().document, setup); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Redo, &redo_id, &counters, || {}) + .unwrap(); + drop(barrier); + assert_eq!(store.editor_get().document, change.document); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn delete_op_keeps_a_group_definition_while_another_native_member_survives() { + let dir = test_directory("delete-op-surviving-group-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let mut document = store.editor_get().document; + document.key_positions.get_mut("4key").unwrap()[0].group_id = Some("shared-group".to_string()); + document.key_positions.get_mut("4key").unwrap()[1].group_id = Some("shared-group".to_string()); + document.layer_groups.insert( + "4key".to_string(), + vec![crate::models::LayerGroupDef { + id: "shared-group".to_string(), + name: "Shared Group".to_string(), + }], + ); + let setup = store + .commit_editor_document(editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, + key_positions: Some(document.key_positions), + layer_groups: Some(document.layer_groups), + ..EditorPatchV1::default() + }, + )) + .unwrap(); + let target_id = setup.document.key_positions["4key"][0].id.clone(); + + let deleted = store + .commit_editor_document(editor_ops_request( + 1, + uuid::Uuid::new_v4().to_string(), + vec![delete_element_op(EditorElementTypeV1::Key, target_id)], + )) + .unwrap(); + + assert_eq!( + deleted.result.changed_fields, + [EditorField::Keys, EditorField::KeyPositions] + ); + assert_eq!(deleted.document.layer_groups["4key"][0].id, "shared-group"); + assert_eq!( + deleted.document.key_positions["4key"][0] + .group_id + .as_deref(), + Some("shared-group") + ); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn delete_op_all_missing_is_acknowledged_without_revision_or_history() { + let dir = test_directory("delete-op-all-missing-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let request = editor_ops_request( + 0, + uuid::Uuid::new_v4().to_string(), + vec![delete_element_op( + EditorElementTypeV1::Graph, + uuid::Uuid::new_v4().to_string(), + )], + ); + + let missing = store.commit_editor_document(request.clone()).unwrap(); + assert_eq!(missing.result.revision, 0); + assert!(missing.result.changed_fields.is_empty()); + assert_eq!( + missing.result.op_results, + Some(vec![EditorOpResultV1 { + status: EditorOpResultStatusV1::TargetMissing, + bounds: None, + }]) + ); + assert!(missing.event.is_none()); + assert_eq!(store.history_status().history_revision, 0); + let replay = store.commit_editor_document(request).unwrap(); + assert!(replay.replayed); + assert_eq!(replay.result, missing.result); + assert_eq!(store.history_status().history_revision, 0); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn delete_op_type_mismatch_and_missing_key_pair_fail_atomically() { + let dir = test_directory("delete-op-atomic-validation-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial = store.editor_get(); + let target_id = initial.document.key_positions["4key"][0].id.clone(); + let valid_id = initial.document.key_positions["4key"][1].id.clone(); + let mismatch = store + .commit_editor_document(editor_ops_request( + 0, + uuid::Uuid::new_v4().to_string(), + vec![ + delete_element_op(EditorElementTypeV1::Key, valid_id), + delete_element_op(EditorElementTypeV1::Stat, &target_id), + ], + )) + .unwrap_err(); + assert_eq!( + mismatch + .details + .and_then(|details| details.validation_code) + .as_deref(), + Some("ELEMENT_TYPE_MISMATCH") + ); + assert_eq!(store.editor_get(), initial); + + { + let mut state = store.state.write(); + state.data.keys.remove("4key"); + } + let malformed = store.editor_get(); + let error = store + .commit_editor_document(editor_ops_request( + 0, + uuid::Uuid::new_v4().to_string(), + vec![delete_element_op(EditorElementTypeV1::Key, target_id)], + )) + .unwrap_err(); + assert_eq!( + error + .details + .and_then(|details| details.validation_code) + .as_deref(), + Some("KEY_POSITION_LENGTH_MISMATCH") + ); + assert_eq!(store.editor_get(), malformed); + assert_eq!(store.history_status().history_revision, 0); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn editor_group_bounds_reject_every_invalid_batch_atomically() { + let dir = test_directory("editor-group-bounds-atomic-validation-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial = store.editor_get(); + let first = initial.document.key_positions["4key"][0].clone(); + let second = initial.document.key_positions["4key"][1].clone(); + let valid_first = set_bounds_op( + EditorElementTypeV1::Key, + &first.id, + EditorBoundsV1 { + dx: first.dx + 1.0, + ..bounds(&first) + }, + ); + + let cases = [ + ( + vec![ + valid_first.clone(), + set_bounds_op( + EditorElementTypeV1::Key, + &second.id, + EditorBoundsV1 { + width: 0.0, + ..bounds(&second) + }, + ), + ], + "DIMENSION_OUT_OF_RANGE", + ), + ( + vec![ + valid_first.clone(), + set_bounds_op(EditorElementTypeV1::Stat, &second.id, bounds(&second)), + ], + "ELEMENT_TYPE_MISMATCH", + ), + ( + vec![ + valid_first.clone(), + set_bounds_op(EditorElementTypeV1::Graph, &first.id, bounds(&first)), + ], + "DUPLICATE_EDITOR_OP_TARGET", + ), + ]; + + for (ops, expected_code) in cases { + let error = store + .commit_editor_document(editor_ops_request(0, uuid::Uuid::new_v4().to_string(), ops)) + .unwrap_err(); + assert_eq!( + error.details.unwrap().validation_code.as_deref(), + Some(expected_code) + ); + assert_eq!(store.editor_get(), initial); + assert_eq!(store.history_status().history_revision, 0); + } + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn editor_ops_apply_all_element_types_in_request_order() { + let dir = test_directory("editor-ops-all-element-types-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let base = store.editor_get().document.key_positions["4key"][0].clone(); + let mode = "4key".to_string(); + let mut stat_position = base.clone(); + stat_position.id = uuid::Uuid::new_v4().to_string(); + let mut graph_position = base.clone(); + graph_position.id = uuid::Uuid::new_v4().to_string(); + let mut knob_position = base; + knob_position.id = uuid::Uuid::new_v4().to_string(); + let stat_id = stat_position.id.clone(); + let graph_id = graph_position.id.clone(); + let knob_id = knob_position.id.clone(); + let initial = store.editor_get().document; + let mut stat_positions = initial.stat_positions; + stat_positions.insert( + mode.clone(), + vec![StatPosition { + stat_type: StatType::Kps, + position: stat_position, + }], + ); + let mut graph_positions = initial.graph_positions; + graph_positions.insert( + mode.clone(), + vec![GraphPosition { + stat_type: GraphStatType::Kps, + graph_type: GraphType::Line, + graph_speed: 1, + graph_color: "#FFFFFF".to_string(), + show_avg_line: true, + position: graph_position, + }], + ); + let mut knob_positions = initial.knob_positions; + knob_positions.insert( + mode.clone(), + vec![KnobPosition { + axis_id: "axis-semantic-ops".to_string(), + sensitivity: 1.0, + reverse: false, + position: knob_position, + }], + ); + let mut setup_patch = EditorPatchV1 { + stat_positions: Some(stat_positions), + graph_positions: Some(graph_positions), + knob_positions: Some(knob_positions), + ..EditorPatchV1::default() + }; + setup_patch.schema_version = EDITOR_COMMIT_SCHEMA_VERSION_V2; + store + .commit_editor_document(editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + setup_patch, + )) + .unwrap(); + + let changed_bounds = |offset: f64| EditorBoundsV1 { + dx: 100.0 + offset, + dy: 200.0 + offset, + width: 300.0 + offset, + height: 400.0 + offset, + }; + let key_id = store.editor_get().document.key_positions["4key"][0] + .id + .clone(); + let change = store + .commit_editor_document(editor_ops_request( + 1, + uuid::Uuid::new_v4().to_string(), + vec![ + set_bounds_op(EditorElementTypeV1::Graph, &graph_id, changed_bounds(1.0)), + set_bounds_op(EditorElementTypeV1::Key, &key_id, changed_bounds(2.0)), + set_bounds_op(EditorElementTypeV1::Knob, &knob_id, changed_bounds(3.0)), + set_bounds_op(EditorElementTypeV1::Stat, &stat_id, changed_bounds(4.0)), + ], + )) + .unwrap(); + + let results = change.result.op_results.unwrap(); + assert_eq!( + results + .iter() + .map(|result| result.status) + .collect::>(), + vec![EditorOpResultStatusV1::Applied; 4] + ); + assert_eq!( + results + .iter() + .map(|result| result.bounds.unwrap()) + .collect::>(), + vec![ + changed_bounds(1.0), + changed_bounds(2.0), + changed_bounds(3.0), + changed_bounds(4.0), + ] + ); + assert_eq!( + change.result.changed_fields, + vec![ + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + ] + ); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn editor_ops_reject_type_mismatch_and_invalid_missing_bounds_atomically() { + let dir = test_directory("editor-ops-atomic-validation-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial = store.editor_get(); + let target = &initial.document.key_positions["4key"][0]; + + let type_error = store + .commit_editor_document(editor_ops_request( + 0, + uuid::Uuid::new_v4().to_string(), + vec![set_bounds_op( + EditorElementTypeV1::Stat, + &target.id, + bounds(target), + )], + )) + .unwrap_err(); + assert_eq!( + type_error.error_code, + EditorCommitErrorCode::ValidationFailed + ); + assert_eq!( + type_error.details.unwrap().validation_code.as_deref(), + Some("ELEMENT_TYPE_MISMATCH") + ); + + let bounds_error = store + .commit_editor_document(editor_ops_request( + 0, + uuid::Uuid::new_v4().to_string(), + vec![set_bounds_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + EditorBoundsV1 { + dx: 0.0, + dy: 0.0, + width: 0.0, + height: 10.0, + }, + )], + )) + .unwrap_err(); + assert_eq!( + bounds_error.error_code, + EditorCommitErrorCode::ValidationFailed + ); + assert_eq!( + bounds_error.details.unwrap().validation_code.as_deref(), + Some("DIMENSION_OUT_OF_RANGE") + ); + assert_eq!(store.editor_get(), initial); + assert_eq!(store.history_status().history_revision, 0); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn editor_commits_with_the_same_gesture_merge_into_one_history_entry() { + let dir = test_directory("editor-history-gesture-merge-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_dx = store.editor_get().document.key_positions["4key"][0].dx; + let gesture_id = uuid::Uuid::new_v4().to_string(); + + let mut first = editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_dx + 4.0), + ); + first.gesture_id = Some(gesture_id.clone()); + let first = store.commit_editor_document(first).unwrap(); + assert_eq!(first.history_status.unwrap().history_revision, 1); + + let mut second = editor_request( + 1, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_dx + 8.0), + ); + second.gesture_id = Some(gesture_id); + let second = store.commit_editor_document(second).unwrap(); + assert_eq!(second.history_status.unwrap().history_revision, 1); + + let operation_id = uuid::Uuid::new_v4().to_string(); + let gate = store.history_gate(); + let barrier = gate.close(&operation_id).unwrap(); + let current_counters = store.snapshot().key_counters; + store + .apply_history_operation( + HistoryDirection::Undo, + &operation_id, + ¤t_counters, + || {}, + ) + .unwrap(); + drop(barrier); + + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + initial_dx + ); + assert!(!store.state.read().history.status(false).can_undo); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn net_zero_gesture_removes_its_entry_and_exposes_the_lower_history() { + let dir = test_directory("editor-history-net-zero-gesture-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_dx = store.editor_get().document.key_positions["4key"][0].dx; + let gesture_base_dx = initial_dx + 10.0; + + store + .commit_editor_document(editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, gesture_base_dx), + )) + .unwrap(); + + let gesture_id = uuid::Uuid::new_v4().to_string(); + let mut outward = editor_request( + 1, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, gesture_base_dx + 5.0), + ); + outward.gesture_id = Some(gesture_id.clone()); + store.commit_editor_document(outward).unwrap(); + + let mut returned = editor_request( + 2, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, gesture_base_dx), + ); + returned.gesture_id = Some(gesture_id); + let returned = store.commit_editor_document(returned).unwrap(); + let status = returned.history_status.as_ref().unwrap(); + assert!(status.can_undo); + assert_eq!(status.history_revision, 3); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + gesture_base_dx + ); + + let operation_id = uuid::Uuid::new_v4().to_string(); + let gate = store.history_gate(); + let barrier = gate.close(&operation_id).unwrap(); + let current_counters = store.snapshot().key_counters; + store + .apply_history_operation( + HistoryDirection::Undo, + &operation_id, + ¤t_counters, + || {}, + ) + .unwrap(); + drop(barrier); + + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + initial_dx + ); + assert!(!store.state.read().history.status(false).can_undo); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn editor_ops_with_the_same_gesture_merge_into_one_history_entry() { + let dir = test_directory("editor-ops-history-gesture-merge-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let target = store.editor_get().document.key_positions["4key"][0].clone(); + let initial_bounds = bounds(&target); + let gesture_id = uuid::Uuid::new_v4().to_string(); + + let mut first = editor_ops_request( + 0, + uuid::Uuid::new_v4().to_string(), + vec![set_bounds_op( + EditorElementTypeV1::Key, + &target.id, + EditorBoundsV1 { + dx: initial_bounds.dx + 4.0, + ..initial_bounds + }, + )], + ); + first.gesture_id = Some(gesture_id.clone()); + let first = store.commit_editor_document(first).unwrap(); + assert_eq!(first.history_status.unwrap().history_revision, 1); + + let mut second = editor_ops_request( + 1, + uuid::Uuid::new_v4().to_string(), + vec![set_bounds_op( + EditorElementTypeV1::Key, + &target.id, + EditorBoundsV1 { + dx: initial_bounds.dx + 8.0, + ..initial_bounds + }, + )], + ); + second.gesture_id = Some(gesture_id); + let second = store.commit_editor_document(second).unwrap(); + assert_eq!(second.history_status.unwrap().history_revision, 1); + + let operation_id = uuid::Uuid::new_v4().to_string(); + let gate = store.history_gate(); + let barrier = gate.close(&operation_id).unwrap(); + let current_counters = store.snapshot().key_counters; + let undo = store + .apply_history_operation( + HistoryDirection::Undo, + &operation_id, + ¤t_counters, + || {}, + ) + .unwrap(); + drop(barrier); + + assert_eq!( + bounds(&store.editor_get().document.key_positions["4key"][0]), + initial_bounds + ); + assert!(!undo.status.can_undo); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn editor_ops_net_zero_gesture_removes_its_history_entry() { + let dir = test_directory("editor-ops-history-net-zero-gesture-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let target = store.editor_get().document.key_positions["4key"][0].clone(); + let initial_bounds = bounds(&target); + let gesture_base = EditorBoundsV1 { + dx: initial_bounds.dx + 10.0, + ..initial_bounds + }; + + store + .commit_editor_document(editor_ops_request( + 0, + uuid::Uuid::new_v4().to_string(), + vec![set_bounds_op( + EditorElementTypeV1::Key, + &target.id, + gesture_base, + )], + )) + .unwrap(); + + let gesture_id = uuid::Uuid::new_v4().to_string(); + let mut outward = editor_ops_request( + 1, + uuid::Uuid::new_v4().to_string(), + vec![set_bounds_op( + EditorElementTypeV1::Key, + &target.id, + EditorBoundsV1 { + dx: gesture_base.dx + 5.0, + ..gesture_base + }, + )], + ); + outward.gesture_id = Some(gesture_id.clone()); + store.commit_editor_document(outward).unwrap(); + + let mut returned = editor_ops_request( + 2, + uuid::Uuid::new_v4().to_string(), + vec![set_bounds_op( + EditorElementTypeV1::Key, + &target.id, + gesture_base, + )], + ); + returned.gesture_id = Some(gesture_id); + let returned = store.commit_editor_document(returned).unwrap(); + let status = returned.history_status.unwrap(); + assert_eq!(status.history_revision, 3); + assert!(status.can_undo); + + let operation_id = uuid::Uuid::new_v4().to_string(); + let gate = store.history_gate(); + let barrier = gate.close(&operation_id).unwrap(); + let current_counters = store.snapshot().key_counters; + let undo = store + .apply_history_operation( + HistoryDirection::Undo, + &operation_id, + ¤t_counters, + || {}, + ) + .unwrap(); + drop(barrier); + + assert_eq!( + bounds(&store.editor_get().document.key_positions["4key"][0]), + initial_bounds + ); + assert!(!undo.status.can_undo); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn oversized_editor_op_clears_history_after_persisting_mutation() { + let dir = test_directory("editor-ops-history-truncation-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let target = store.editor_get().document.key_positions["4key"][0].clone(); + let initial_bounds = bounds(&target); + + store + .commit_editor_document(editor_ops_request( + 0, + uuid::Uuid::new_v4().to_string(), + vec![set_bounds_op( + EditorElementTypeV1::Key, + &target.id, + EditorBoundsV1 { + dx: initial_bounds.dx + 1.0, + ..initial_bounds + }, + )], + )) + .unwrap(); + store + .state + .write() + .history + .set_limits_for_test(1, 32 * 1024 * 1024, 50); + + let expected_bounds = EditorBoundsV1 { + dx: initial_bounds.dx + 2.0, + ..initial_bounds + }; + let change = store + .commit_editor_document(editor_ops_request( + 1, + uuid::Uuid::new_v4().to_string(), + vec![set_bounds_op( + EditorElementTypeV1::Key, + &target.id, + expected_bounds, + )], + )) + .unwrap(); + let status = change.history_status.unwrap(); + + assert_eq!(change.result.revision, 2); + assert_eq!( + change.result.op_results.unwrap()[0].status, + EditorOpResultStatusV1::Applied + ); + assert_eq!( + bounds(&store.editor_get().document.key_positions["4key"][0]), + expected_bounds + ); + assert_eq!(status.history_revision, 2); + assert!(!status.can_undo); + assert!(!status.can_redo); + assert_eq!(status.truncated.unwrap().reason, HISTORY_ENTRY_TOO_LARGE); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn oversized_editor_op_plugin_gesture_rejects_the_whole_transaction() { + let dir = test_directory("gesture-editor-op-history-size-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + store + .state + .write() + .history + .set_limits_for_test(1, 32 * 1024 * 1024, 50); + let before = store.snapshot(); + let target = store.editor_get().document.key_positions["4key"][0].clone(); + let before_plugin_revision = store.plugin_model_revision(); + let before_history_revision = store.history_status().history_revision; + let persist_count = store.writer.persist_count(); + + let error = store + .commit_gesture(gesture_ops_request( + &store, + uuid::Uuid::new_v4().to_string(), + uuid::Uuid::new_v4().to_string(), + vec![set_bounds_op( + EditorElementTypeV1::Key, + &target.id, + EditorBoundsV1 { + dx: target.dx + 25.0, + ..bounds(&target) + }, + )], + "demo-plugin", + vec![saved_plugin_instance(25.0)], + )) + .unwrap_err(); + + assert_eq!(error.error_code, EditorCommitErrorCode::ValidationFailed); + assert_eq!( + error.details.unwrap().validation_code.as_deref(), + Some(HISTORY_ENTRY_TOO_LARGE) + ); + assert_eq!(store.snapshot(), before); + assert_eq!(store.plugin_model_revision(), before_plugin_revision); + assert_eq!( + store.history_status().history_revision, + before_history_revision + ); + assert_eq!(store.writer.persist_count(), persist_count); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn unrecorded_preset_overlap_mutations_clear_redo_only_when_overlap_changes() { + let dir = test_directory("history-overlap-redo-invalidation-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_dx = store.editor_get().document.key_positions["4key"][0].dx; + + for (index, mutate_css) in [false, true].into_iter().enumerate() { + let base_revision = store.editor_get().revision; + store + .commit_editor_document(editor_request( + base_revision, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_dx + index as f64 + 1.0), + )) + .unwrap(); + let operation_id = uuid::Uuid::new_v4().to_string(); + let gate = store.history_gate(); + let barrier = gate.close(&operation_id).unwrap(); + let current_counters = store.snapshot().key_counters; + store + .apply_history_operation( + HistoryDirection::Undo, + &operation_id, + ¤t_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let before_mutation = store.state.read().history.status(false); + assert!(before_mutation.can_redo); + + let transaction = store + .commit_history_overlap_mutation(|data| { + if mutate_css { + data.custom_css.content = format!("body {{ opacity: {}; }}", index + 1); + } else { + apply_patch_to_store( + data, + &SettingsPatchInput { + background_color: Some(format!("#{:06x}", index + 1)), + ..SettingsPatchInput::default() + }, + ); + } + Ok(()) + }) + .unwrap(); + let status = transaction.history_status.as_ref().unwrap(); + assert!(!status.can_redo); + assert_eq!( + status.history_revision, + before_mutation.history_revision + 1 + ); + drop(transaction); + } + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn unrecorded_legacy_editor_mutations_invalidate_stale_undo_and_redo() { + let dir = test_directory("legacy-editor-history-invalidation-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_dx = store.editor_get().document.key_positions["4key"][0].dx; + + store + .commit_editor_document(editor_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_dx + 1.0), + )) + .unwrap(); + assert!(store.history_status().can_undo); + + let transaction = store + .commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("resource_delete_test".to_string()), + &[EditorField::KeyPositions], + |data| { + data.key_positions.get_mut("4key").unwrap()[0].dx = initial_dx + 2.0; + Ok(()) + }, + ) + .unwrap(); + let status = transaction.change.history_status.as_ref().unwrap(); + assert!(!status.can_undo); + assert!(!status.can_redo); + drop(transaction); + + store + .commit_editor_document(editor_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_dx + 3.0), + )) + .unwrap(); + let operation_id = uuid::Uuid::new_v4().to_string(); + let gate = store.history_gate(); + let barrier = gate.close(&operation_id).unwrap(); + let counters = store.snapshot().key_counters; + store + .apply_history_operation(HistoryDirection::Undo, &operation_id, &counters, || {}) + .unwrap(); + drop(barrier); + assert!(store.history_status().can_redo); + + let transaction = store + .commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("resource_replace_test".to_string()), + &[EditorField::KeyPositions], + |data| { + data.key_positions.get_mut("4key").unwrap()[0].dx = initial_dx + 4.0; + Ok(()) + }, + ) + .unwrap(); + let status = transaction.change.history_status.as_ref().unwrap(); + assert!(!status.can_undo); + assert!(!status.can_redo); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn counter_preset_library_only_delete_invalidates_stale_history() { + let dir = test_directory("counter-preset-library-history-invalidation-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_dx = store.editor_get().document.key_positions["4key"][0].dx; + let preset = CounterAnimationPreset { + id: "user-delete-target".to_string(), + name: "Delete target".to_string(), + source: CounterAnimationSource::User, + label_key: None, + bezier: [0.25, 0.1, 0.25, 1.0], + scale: 1.0, + duration_ms: 300, + }; + store + .update(|data| data.counter_animation_presets.push(preset.clone())) + .unwrap(); + store + .commit_editor_document(editor_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_dx + 1.0), + )) + .unwrap(); + assert!(store.history_status().can_undo); + let editor_revision = store.editor_get().revision; + + let transaction = store + .commit_legacy_resource_deletion( + EditorCommitOrigin::LegacyAdapter("counter_animation_delete_test".to_string()), + &[ + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + ], + |data| { + data.counter_animation_presets + .retain(|candidate| candidate.id != preset.id); + Ok(()) + }, + ) + .unwrap(); + + assert!(transaction.change.result.changed_fields.is_empty()); + assert_eq!(transaction.change.result.revision, editor_revision); + let status = transaction.change.history_status.as_ref().unwrap(); + assert!(!status.can_undo); + assert!(!status.can_redo); + assert!(store + .snapshot() + .counter_animation_presets + .iter() + .all(|candidate| candidate.id != preset.id)); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn unbound_sound_library_delete_invalidates_stale_history() { + let dir = test_directory("sound-library-history-invalidation-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_dx = store.editor_get().document.key_positions["4key"][0].dx; + let sound_path = "/tmp/dmnote-unbound-history.wav".to_string(); + store + .update(|data| { + data.sound_library + .insert(sound_path.clone(), SoundLibraryEntry::default()); + }) + .unwrap(); + store + .commit_editor_document(editor_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_dx + 1.0), + )) + .unwrap(); + assert!(store.history_status().can_undo); + + let transaction = store + .commit_legacy_resource_deletion( + EditorCommitOrigin::LegacyAdapter("sound_delete_test".to_string()), + &[ + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + ], + |data| { + data.sound_library.remove(&sound_path); + Ok(()) + }, + ) + .unwrap(); + + assert!(transaction.change.result.changed_fields.is_empty()); + let status = transaction.change.history_status.as_ref().unwrap(); + assert!(!status.can_undo); + assert!(!status.can_redo); + assert!(!store.snapshot().sound_library.contains_key(&sound_path)); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn counter_preset_library_only_update_preserves_editor_history() { + let dir = test_directory("counter-preset-library-history-preservation-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_dx = store.editor_get().document.key_positions["4key"][0].dx; + let preset = CounterAnimationPreset { + id: "user-update-target".to_string(), + name: "Before".to_string(), + source: CounterAnimationSource::User, + label_key: None, + bezier: [0.25, 0.1, 0.25, 1.0], + scale: 1.0, + duration_ms: 300, + }; + store + .update(|data| data.counter_animation_presets.push(preset.clone())) + .unwrap(); + store + .commit_editor_document(editor_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_dx + 1.0), + )) + .unwrap(); + let before_status = store.history_status(); + assert!(before_status.can_undo); + + let transaction = store + .commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("counter_animation_update_test".to_string()), + &[ + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + ], + |data| { + let target = data + .counter_animation_presets + .iter_mut() + .find(|candidate| candidate.id == preset.id) + .unwrap(); + target.name = "After".to_string(); + target.bezier = [0.42, 0.0, 0.58, 1.0]; + Ok(()) + }, + ) + .unwrap(); + + assert!(transaction.change.result.changed_fields.is_empty()); + assert!(transaction.change.history_status.is_none()); + let after_status = store.history_status(); + assert_eq!( + after_status.history_revision, + before_status.history_revision + ); + assert_eq!(after_status.history_epoch, before_status.history_epoch); + assert!(after_status.can_undo); + assert!(!after_status.can_redo); + assert_eq!( + store + .snapshot() + .counter_animation_presets + .iter() + .find(|candidate| candidate.id == preset.id) + .unwrap() + .name, + "After" + ); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn css_watcher_reload_after_undo_preserves_latest_canonical_and_invalidates_redo() { + let dir = test_directory("css-watcher-history-interleaving-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let watched_path = "/tmp/dmnote-history-watched.css"; + store + .update(|data| { + data.use_custom_css = true; + data.custom_css = CustomCss { + path: Some(watched_path.to_string()), + content: "before".to_string(), + }; + }) + .unwrap(); + + let current_counters = store.snapshot().key_counters; + let preset = store + .commit_preset_editor_transaction( + EditorCommitOrigin::LegacyAdapter("css_watcher_preset_test".to_string()), + &[], + current_counters, + |data| { + data.custom_css.content = "preset".to_string(); + Ok(()) + }, + ) + .unwrap(); + assert_eq!( + preset + .change + .history_status + .as_ref() + .unwrap() + .history_revision, + 1 + ); + drop(preset); + let undo_id = uuid::Uuid::new_v4().to_string(); + let gate = store.history_gate(); + let barrier = gate.close(&undo_id).unwrap(); + let current_counters = store.snapshot().key_counters; + store + .apply_history_operation(HistoryDirection::Undo, &undo_id, ¤t_counters, || {}) + .unwrap(); + drop(barrier); + assert_eq!(store.snapshot().custom_css.content, "before"); + assert!(store.state.read().history.status(false).can_redo); + + let loaded = ValidatedCssFile { + canonical_path: watched_path.to_string(), + content: "after-hot-reload".to_string(), + }; + let transaction = commit_css_reload(&store, watched_path, &loaded).unwrap(); + assert_eq!( + transaction.value.0.as_ref().unwrap().content, + "after-hot-reload" + ); + let status = transaction.history_status.as_ref().unwrap(); + assert!(!status.can_redo); + drop(transaction); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + let current_counters = store.snapshot().key_counters; + let redo = + store.apply_history_operation(HistoryDirection::Redo, &redo_id, ¤t_counters, || {}); + drop(barrier); + assert_eq!(redo.unwrap_err(), "HISTORY_NOTHING_TO_REDO"); + assert_eq!(store.snapshot().custom_css.content, "after-hot-reload"); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn mode_history_records_restores_and_rejects_stale_epoch() { + let dir = test_directory("mode-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_mode = store.snapshot().selected_key_type; + let next_mode = if initial_mode == "8key" { + "4key".to_string() + } else { + "8key".to_string() + }; + + let no_op = store + .commit_aux_editor_transaction( + HistoryScope::Mode, + Some(0), + EditorCommitOrigin::LegacyAdapter("mode_noop_test".to_string()), + &[], + |data| Ok(data.selected_key_type.clone()), + ) + .unwrap(); + assert!(no_op.change.history_status.is_none()); + drop(no_op); + assert_eq!(store.history_status().history_revision, 0); + + let transaction = store + .commit_aux_editor_transaction( + HistoryScope::Mode, + Some(0), + EditorCommitOrigin::LegacyAdapter("mode_history_test".to_string()), + &[], + |data| { + data.selected_key_type = next_mode.clone(); + Ok(()) + }, + ) + .unwrap(); + let recorded = transaction.change.history_status.as_ref().unwrap(); + assert_eq!(recorded.history_revision, 1); + assert_eq!(recorded.history_epoch, 0); + assert!(recorded.can_undo); + drop(transaction); + + let operation_id = uuid::Uuid::new_v4().to_string(); + let gate = store.history_gate(); + let barrier = gate.close(&operation_id).unwrap(); + let current_counters = store.snapshot().key_counters; + let undo = store + .apply_history_operation( + HistoryDirection::Undo, + &operation_id, + ¤t_counters, + || {}, + ) + .unwrap(); + drop(barrier); + assert!(matches!( + undo.aux_change, + Some(HistoryAuxChange::Mode(ref mode)) if mode == &initial_mode + )); + assert_eq!(store.snapshot().selected_key_type, initial_mode); + assert_eq!(undo.status.history_epoch, 1); + assert!(undo.status.can_redo); + + let before_stale = store.snapshot(); + let before_stale_status = store.history_status(); + let stale = store + .commit_aux_editor_transaction( + HistoryScope::Mode, + Some(0), + EditorCommitOrigin::LegacyAdapter("mode_stale_test".to_string()), + &[], + |data| { + data.selected_key_type = next_mode; + Ok(()) + }, + ) + .unwrap_err(); + assert_eq!( + stale.error_code, + EditorCommitErrorCode::HistoryEpochConflict + ); + assert!(stale.retryable); + assert_eq!( + stale + .details + .as_ref() + .and_then(|details| details.current_history_epoch), + Some(1) + ); + assert_eq!(store.snapshot(), before_stale); + let after_stale_status = store.history_status(); + assert_eq!( + after_stale_status.history_revision, + before_stale_status.history_revision + ); + assert_eq!( + after_stale_status.history_epoch, + before_stale_status.history_epoch + ); + assert_eq!(after_stale_status.can_undo, before_stale_status.can_undo); + assert_eq!(after_stale_status.can_redo, before_stale_status.can_redo); + assert_eq!(after_stale_status.busy, before_stale_status.busy); + assert_eq!(after_stale_status.truncated, before_stale_status.truncated); + assert!(after_stale_status.status_seq > before_stale_status.status_seq); + + let busy_id = uuid::Uuid::new_v4().to_string(); + let busy_barrier = gate.close(&busy_id).unwrap(); + let busy = store + .commit_aux_editor_transaction( + HistoryScope::Mode, + None, + EditorCommitOrigin::LegacyAdapter("mode_busy_test".to_string()), + &[], + |data| Ok(data.selected_key_type.clone()), + ) + .unwrap_err(); + assert_eq!(busy.error_code, EditorCommitErrorCode::HistoryInProgress); + assert!(busy.retryable); + drop(busy_barrier); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn custom_tabs_history_restores_typed_state_and_editor_revision() { + let dir = test_directory("custom-tabs-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial = store.snapshot(); + let tab = CustomTab { + id: "history-custom-tab".to_string(), + name: "History Custom Tab".to_string(), + }; + let tab_id = tab.id.clone(); + + let transaction = store + .commit_aux_editor_transaction( + HistoryScope::CustomTabs, + None, + EditorCommitOrigin::LegacyAdapter("custom_tabs_history_test".to_string()), + &[EditorField::Keys, EditorField::KeyPositions], + |data| { + data.custom_tabs.push(tab); + data.keys.insert(tab_id.clone(), Vec::new()); + data.key_positions.insert(tab_id.clone(), Vec::new()); + data.selected_key_type = tab_id; + Ok(()) + }, + ) + .unwrap(); + assert_eq!(transaction.change.result.revision, 1); + assert_eq!( + transaction.change.result.changed_fields, + vec![EditorField::Keys, EditorField::KeyPositions] + ); + assert_eq!( + transaction + .change + .history_status + .as_ref() + .unwrap() + .history_revision, + 1 + ); + let changed = store.snapshot(); + drop(transaction); + + let undo_id = uuid::Uuid::new_v4().to_string(); + let gate = store.history_gate(); + let barrier = gate.close(&undo_id).unwrap(); + let current_counters = changed.key_counters.clone(); + let undo = store + .apply_history_operation(HistoryDirection::Undo, &undo_id, ¤t_counters, || {}) + .unwrap(); + drop(barrier); + let Some(HistoryAuxChange::CustomTabs { + snapshot: restored, .. + }) = undo.aux_change.as_ref() + else { + panic!("custom tabs history change expected"); + }; + assert_eq!(restored.custom_tabs, initial.custom_tabs); + assert_eq!(restored.selected_key_type, initial.selected_key_type); + assert_eq!(restored.document, EditorDocumentV1::from_store(&initial)); + assert_eq!(restored.key_counters, initial.key_counters); + assert_eq!(store.snapshot().editor_revision, 2); + assert_eq!( + undo.change.as_ref().unwrap().event.as_ref().unwrap().origin, + "historyUndo" + ); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let redo_barrier = gate.close(&redo_id).unwrap(); + let current_counters = store.snapshot().key_counters; + let redo = store + .apply_history_operation(HistoryDirection::Redo, &redo_id, ¤t_counters, || {}) + .unwrap(); + drop(redo_barrier); + assert!(matches!( + redo.aux_change, + Some(HistoryAuxChange::CustomTabs { .. }) + )); + assert_eq!( + EditorDocumentV1::from_store(&store.snapshot()), + EditorDocumentV1::from_store(&changed) + ); + assert_eq!(store.snapshot().custom_tabs, changed.custom_tabs); + assert_eq!( + store.snapshot().selected_key_type, + changed.selected_key_type + ); + assert_eq!(store.snapshot().editor_revision, 3); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn custom_tab_delete_history_restores_note_css_and_change_projection() { + let dir = test_directory("custom-tab-override-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let tab_id = "history-override-tab".to_string(); + let created_tab_id = tab_id.clone(); + let create = store + .commit_aux_editor_transaction( + HistoryScope::CustomTabs, + None, + EditorCommitOrigin::LegacyAdapter("custom_tab_override_create".to_string()), + &[EditorField::Keys, EditorField::KeyPositions], + |data| { + data.custom_tabs.push(CustomTab { + id: created_tab_id.clone(), + name: "Override History".to_string(), + }); + data.keys.insert(created_tab_id.clone(), Vec::new()); + data.key_positions + .insert(created_tab_id.clone(), Vec::new()); + data.selected_key_type = created_tab_id; + Ok(()) + }, + ) + .unwrap(); + drop(create); + + let css = TabCss { + path: Some(dir.join("restored.css").to_string_lossy().to_string()), + content: ".restored {}".to_string(), + enabled: true, + }; + let note = TabNoteSettings::default(); + store + .update(|data| { + data.tab_css_overrides.insert(tab_id.clone(), css.clone()); + data.tab_note_overrides.insert(tab_id.clone(), note.clone()); + }) + .unwrap(); + + let deleted_tab_id = tab_id.clone(); + let delete = store + .commit_aux_editor_transaction( + HistoryScope::CustomTabs, + None, + EditorCommitOrigin::LegacyAdapter("custom_tab_override_delete".to_string()), + &[ + EditorField::Keys, + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + EditorField::LayerGroups, + ], + |data| { + data.custom_tabs.retain(|tab| tab.id != deleted_tab_id); + data.keys.remove(&deleted_tab_id); + data.key_positions.remove(&deleted_tab_id); + data.stat_positions.remove(&deleted_tab_id); + data.graph_positions.remove(&deleted_tab_id); + data.knob_positions.remove(&deleted_tab_id); + data.layer_groups.remove(&deleted_tab_id); + data.key_counters.remove(&deleted_tab_id); + data.tab_css_overrides.remove(&deleted_tab_id); + data.tab_note_overrides.remove(&deleted_tab_id); + data.selected_key_type = "4key".to_string(); + Ok(()) + }, + ) + .unwrap(); + drop(delete); + assert!(!store.snapshot().tab_css_overrides.contains_key(&tab_id)); + assert!(!store.snapshot().tab_note_overrides.contains_key(&tab_id)); + + let other_tab_id = "4key".to_string(); + let other_css = TabCss { + path: Some(dir.join("other.css").to_string_lossy().to_string()), + content: ".other {}".to_string(), + enabled: false, + }; + let other_note = TabNoteSettings { + speed: Some(777), + ..TabNoteSettings::default() + }; + store + .update(|data| { + data.tab_css_overrides + .insert(other_tab_id.clone(), other_css.clone()); + data.tab_note_overrides + .insert(other_tab_id.clone(), other_note.clone()); + }) + .unwrap(); + + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let undo_barrier = gate.close(&undo_id).unwrap(); + let current_counters = store.snapshot().key_counters; + let undo = store + .apply_history_operation(HistoryDirection::Undo, &undo_id, ¤t_counters, || {}) + .unwrap(); + drop(undo_barrier); + let Some(HistoryAuxChange::CustomTabs { + snapshot, + changed_tab_css_ids, + .. + }) = undo.aux_change.as_ref() + else { + panic!("custom tabs history change expected"); + }; + assert_eq!(snapshot.tab_css_patch[&tab_id], Some(css.clone())); + assert_eq!(snapshot.tab_note_patch[&tab_id], Some(note.clone())); + assert_eq!(changed_tab_css_ids, std::slice::from_ref(&tab_id)); + assert_eq!(store.snapshot().tab_css_overrides[&tab_id], css); + assert_eq!(store.snapshot().tab_note_overrides[&tab_id], note); + assert_eq!(store.snapshot().tab_css_overrides[&other_tab_id], other_css); + assert_eq!( + store.snapshot().tab_note_overrides[&other_tab_id], + other_note + ); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let redo_barrier = gate.close(&redo_id).unwrap(); + let current_counters = store.snapshot().key_counters; + let redo = store + .apply_history_operation(HistoryDirection::Redo, &redo_id, ¤t_counters, || {}) + .unwrap(); + drop(redo_barrier); + let Some(HistoryAuxChange::CustomTabs { + snapshot, + changed_tab_css_ids, + .. + }) = redo.aux_change.as_ref() + else { + panic!("custom tabs history change expected"); + }; + assert_eq!(snapshot.tab_css_patch[&tab_id], None); + assert_eq!(snapshot.tab_note_patch[&tab_id], None); + assert_eq!(changed_tab_css_ids, std::slice::from_ref(&tab_id)); + assert!(!store.snapshot().tab_css_overrides.contains_key(&tab_id)); + assert!(!store.snapshot().tab_note_overrides.contains_key(&tab_id)); + assert_eq!(store.snapshot().tab_css_overrides[&other_tab_id], other_css); + assert_eq!( + store.snapshot().tab_note_overrides[&other_tab_id], + other_note + ); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn custom_tab_delete_history_restores_plugin_instances_atomically() { + let dir = test_directory("custom-tab-plugin-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let tab_id = "history-plugin-tab".to_string(); + let create_tab_id = tab_id.clone(); + let create = store + .commit_aux_editor_transaction( + HistoryScope::CustomTabs, + None, + EditorCommitOrigin::LegacyAdapter("custom_tab_plugin_create".to_string()), + &[EditorField::Keys, EditorField::KeyPositions], + |data| { + data.custom_tabs.push(CustomTab { + id: create_tab_id.clone(), + name: "Plugin History".to_string(), + }); + data.keys.insert(create_tab_id.clone(), Vec::new()); + data.key_positions.insert(create_tab_id.clone(), Vec::new()); + data.selected_key_type = create_tab_id; + Ok(()) + }, + ) + .unwrap(); + drop(create); + + let mut target = saved_plugin_instance(11.0); + target.tab_id = Some(tab_id.clone()); + target.group_id = Some("group-plugin".to_string()); + let mut retained = saved_plugin_instance(22.0); + retained.tab_id = Some("4key".to_string()); + let plugin_a_key = plugin_instances_storage_key("plugin-a"); + let plugin_b_key = plugin_instances_storage_key("plugin-b"); + let plugin_a_before = serde_json::json!([ + target, + { "broken": true }, + retained, + ]); + let plugin_b_before = serde_json::json!([ + { + "instanceId": "00000000-0000-4000-8000-000000000033", + "position": { "x": 33.0, "y": 20.0 }, + "tabId": tab_id, + "hidden": false, + "groupId": "group-plugin" + } + ]); + store + .update(|data| { + data.plugin_data + .insert(plugin_a_key.clone(), plugin_a_before.clone()); + data.plugin_data + .insert(plugin_b_key.clone(), plugin_b_before.clone()); + }) + .unwrap(); + + let before_plugin_revision = store.plugin_model_revision(); + let before_epoch = store.history_status().history_epoch; + let delete_tab_id = "history-plugin-tab".to_string(); + let admission = store.admit_editor_mutation().unwrap(); + let delete = store + .commit_aux_editor_reset_transaction_with_admission( + AuxEditorResetTransactionOptions { + scope: HistoryScope::CustomTabs, + observed_history_epoch: Some(before_epoch), + origin: EditorCommitOrigin::LegacyAdapter("custom_tab_plugin_delete".to_string()), + touched_fields: &[ + EditorField::Keys, + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + EditorField::LayerGroups, + ], + plugin_instances_reset: PluginInstancesResetScope::Mode(delete_tab_id.clone()), + }, + admission, + |data| { + data.custom_tabs.retain(|tab| tab.id != delete_tab_id); + data.keys.remove(&delete_tab_id); + data.key_positions.remove(&delete_tab_id); + data.stat_positions.remove(&delete_tab_id); + data.graph_positions.remove(&delete_tab_id); + data.knob_positions.remove(&delete_tab_id); + data.layer_groups.remove(&delete_tab_id); + data.key_counters.remove(&delete_tab_id); + data.selected_key_type = "4key".to_string(); + Ok(()) + }, + ) + .unwrap(); + + assert_eq!(store.plugin_model_revision(), before_plugin_revision + 1); + assert_eq!(store.history_status().history_epoch, before_epoch + 1); + assert_eq!(delete.change.plugin_instances_changes.len(), 2); + let after_delete = store.snapshot(); + assert_eq!( + after_delete.plugin_data[&plugin_a_key], + serde_json::json!([{ "broken": true }, retained]) + ); + assert!(!after_delete.plugin_data.contains_key(&plugin_b_key)); + let mut stale_instance = saved_plugin_instance(44.0); + stale_instance.tab_id = Some("history-plugin-tab".to_string()); + let mut stale_request = plugin_instances_request( + "plugin-b", + vec![stale_instance], + uuid::Uuid::new_v4().to_string(), + None, + Some(store.plugin_model_revision()), + ); + stale_request.observed_history_epoch = Some(before_epoch); + assert_eq!( + store.commit_plugin_instances(stale_request).unwrap_err(), + "HISTORY_EPOCH_CONFLICT" + ); + assert!(!store.snapshot().plugin_data.contains_key(&plugin_b_key)); + drop(delete); + + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let undo_barrier = gate.close(&undo_id).unwrap(); + let current_counters = store.snapshot().key_counters; + let undo = store + .apply_history_operation(HistoryDirection::Undo, &undo_id, ¤t_counters, || {}) + .unwrap(); + drop(undo_barrier); + let Some(HistoryAuxChange::CustomTabs { + plugin_ids, + revision, + .. + }) = undo.aux_change.as_ref() + else { + panic!("custom tabs history change expected"); + }; + assert_eq!( + plugin_ids, + &["plugin-a".to_string(), "plugin-b".to_string()] + ); + assert_eq!(*revision, before_plugin_revision + 2); + let restored = store.snapshot(); + assert_eq!(restored.plugin_data[&plugin_a_key], plugin_a_before); + assert_eq!(restored.plugin_data[&plugin_b_key], plugin_b_before); + assert!(restored + .custom_tabs + .iter() + .any(|tab| tab.id == delete_tab_id)); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let redo_barrier = gate.close(&redo_id).unwrap(); + let current_counters = store.snapshot().key_counters; + store + .apply_history_operation(HistoryDirection::Redo, &redo_id, ¤t_counters, || {}) + .unwrap(); + drop(redo_barrier); + let redone = store.snapshot(); + assert_eq!( + redone.plugin_data[&plugin_a_key], + serde_json::json!([{ "broken": true }, retained]) + ); + assert!(!redone.plugin_data.contains_key(&plugin_b_key)); + assert!(!redone.custom_tabs.iter().any(|tab| tab.id == delete_tab_id)); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn preset_full_history_restores_settings_and_preserves_redo_snapshot() { + let dir = test_directory("preset-full-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let mode = store.snapshot().selected_key_type; + let original_key = store.snapshot().keys[&mode][0].canonical(); + let preserved_key = store.snapshot().keys[&mode][1].canonical(); + let initial_plugin = JsPlugin { + id: "initial-plugin".to_string(), + name: "Initial Plugin".to_string(), + path: None, + content: "initial".to_string(), + enabled: true, + }; + let initial_font = CustomFont { + id: "initial-font".to_string(), + font_type: FontType::Web, + name: "InitialFont".to_string(), + display_name: "Initial Font".to_string(), + enabled: true, + local_path: None, + css_content: Some("initial-font-css".to_string()), + weight_ranges: Vec::new(), + }; + store + .update(|data| { + data.key_counters + .entry(mode.clone()) + .or_default() + .insert(original_key.clone(), 3); + data.use_custom_css = false; + data.custom_css = CustomCss { + path: Some(dir.join("initial.css").to_string_lossy().to_string()), + content: ".initial {}".to_string(), + }; + data.use_custom_js = false; + data.custom_js.path = Some(dir.join("initial.js").to_string_lossy().to_string()); + data.custom_js.content = "initial();".to_string(); + data.custom_js.plugins = vec![initial_plugin.clone()]; + data.font_settings = FontSettings { + custom_fonts: vec![initial_font.clone()], + }; + data.background_color = "#111111".to_string(); + data.note_settings.speed = 321; + data.note_effect = false; + data.laboratory_enabled = false; + data.tab_note_overrides.insert( + mode.clone(), + TabNoteSettings { + speed: Some(333), + ..TabNoteSettings::default() + }, + ); + data.tab_css_overrides.insert( + mode.clone(), + TabCss { + path: Some(dir.join("initial-tab.css").to_string_lossy().to_string()), + content: ".initial-tab {}".to_string(), + enabled: true, + }, + ); + }) + .unwrap(); + let mut runtime_counters = store.snapshot().key_counters; + runtime_counters + .entry(mode.clone()) + .or_default() + .insert(original_key.clone(), 11); + runtime_counters + .entry(mode.clone()) + .or_default() + .insert(preserved_key.clone(), 13); + let mut initial = PresetFullHistorySnapshot::from_store(&store.snapshot()); + initial.key_counters = runtime_counters.clone(); + + let preset_key = "PresetHistoryKey".to_string(); + let preset_plugin = JsPlugin { + id: "preset-plugin".to_string(), + name: "Preset Plugin".to_string(), + path: None, + content: "preset".to_string(), + enabled: false, + }; + let preset_font = CustomFont { + id: "preset-font".to_string(), + font_type: FontType::Web, + name: "PresetFont".to_string(), + display_name: "Preset Font".to_string(), + enabled: true, + local_path: None, + css_content: Some("preset-font-css".to_string()), + weight_ranges: Vec::new(), + }; + let transaction = store + .commit_preset_editor_transaction( + EditorCommitOrigin::LegacyAdapter("preset_full_history_test".to_string()), + &[EditorField::Keys], + runtime_counters, + |data| { + data.keys.get_mut(&mode).unwrap()[0] = preset_key.clone().into(); + data.use_custom_css = true; + data.custom_css = CustomCss { + path: Some(dir.join("preset.css").to_string_lossy().to_string()), + content: ".preset {}".to_string(), + }; + data.use_custom_js = true; + data.custom_js.path = Some(dir.join("preset.js").to_string_lossy().to_string()); + data.custom_js.content = "preset();".to_string(); + data.custom_js.plugins = vec![preset_plugin.clone()]; + data.font_settings = FontSettings { + custom_fonts: vec![preset_font.clone()], + }; + data.background_color = "#abcdef".to_string(); + data.note_settings.speed = 654; + data.note_effect = true; + data.laboratory_enabled = true; + data.tab_note_overrides.insert( + mode.clone(), + TabNoteSettings { + speed: Some(777), + ..TabNoteSettings::default() + }, + ); + data.tab_css_overrides.insert( + mode.clone(), + TabCss { + path: Some(dir.join("preset-tab.css").to_string_lossy().to_string()), + content: ".preset-tab {}".to_string(), + enabled: false, + }, + ); + Ok(()) + }, + ) + .unwrap(); + assert_eq!( + transaction + .change + .history_status + .as_ref() + .unwrap() + .history_revision, + 1 + ); + assert_eq!(transaction.change.result.revision, 1); + drop(transaction); + let preset = PresetFullHistorySnapshot::from_store(&store.snapshot()); + + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let undo_barrier = gate.close(&undo_id).unwrap(); + let mut current_counters = store.snapshot().key_counters; + current_counters + .get_mut(&mode) + .unwrap() + .insert(preserved_key.clone(), 17); + let undo = store + .apply_history_operation(HistoryDirection::Undo, &undo_id, ¤t_counters, || {}) + .unwrap(); + drop(undo_barrier); + let Some(HistoryAuxChange::PresetFull { + snapshot, + settings_diff, + changed_tab_css_ids, + }) = undo.aux_change.as_ref() + else { + panic!("preset full history change expected"); + }; + assert_eq!(snapshot.as_ref(), &initial); + assert!(settings_diff.changed.use_custom_css.is_some()); + assert!(settings_diff.changed.custom_css.is_some()); + assert!(settings_diff.changed.use_custom_js.is_some()); + assert!(settings_diff.changed.custom_js.is_some()); + assert!(settings_diff.changed.font_settings.is_some()); + assert!(settings_diff.changed.background_color.is_some()); + assert!(settings_diff.changed.note_settings.is_some()); + assert!(settings_diff.changed.note_effect.is_some()); + assert!(settings_diff.changed.laboratory_enabled.is_some()); + assert_eq!(changed_tab_css_ids, std::slice::from_ref(&mode)); + let mut restored = initial.clone(); + restored + .key_counters + .get_mut(&mode) + .unwrap() + .insert(preserved_key.clone(), 17); + assert_eq!( + PresetFullHistorySnapshot::from_store(&store.snapshot()), + restored + ); + assert_eq!(store.snapshot().editor_revision, 2); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let redo_barrier = gate.close(&redo_id).unwrap(); + let mut current_counters = store.snapshot().key_counters; + current_counters + .get_mut(&mode) + .unwrap() + .insert(preserved_key.clone(), 19); + let redo = store + .apply_history_operation(HistoryDirection::Redo, &redo_id, ¤t_counters, || {}) + .unwrap(); + drop(redo_barrier); + let Some(HistoryAuxChange::PresetFull { + snapshot, + settings_diff, + changed_tab_css_ids, + }) = redo.aux_change.as_ref() + else { + panic!("preset full redo change expected"); + }; + let mut redo_snapshot = preset.clone(); + redo_snapshot + .key_counters + .get_mut(&mode) + .unwrap() + .insert(preserved_key.clone(), 17); + assert_eq!(snapshot.as_ref(), &redo_snapshot); + assert_eq!( + settings_diff.changed.background_color.as_deref(), + Some("#abcdef") + ); + assert_eq!(changed_tab_css_ids, std::slice::from_ref(&mode)); + let mut redone = preset; + redone + .key_counters + .get_mut(&mode) + .unwrap() + .insert(preserved_key, 19); + assert_eq!( + PresetFullHistorySnapshot::from_store(&store.snapshot()), + redone + ); + assert_eq!(store.snapshot().editor_revision, 3); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn oversized_aux_record_truncates_history_after_persisting_mutation() { + let dir = test_directory("aux-history-truncation-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_mode = store.snapshot().selected_key_type; + let next_mode = if initial_mode == "8key" { + "4key".to_string() + } else { + "8key".to_string() + }; + store + .state + .write() + .history + .set_limits_for_test(1, 32 * 1024 * 1024, 50); + + let transaction = store + .commit_aux_editor_transaction( + HistoryScope::Mode, + None, + EditorCommitOrigin::LegacyAdapter("mode_truncation_test".to_string()), + &[], + |data| { + data.selected_key_type = next_mode.clone(); + Ok(()) + }, + ) + .unwrap(); + let status = transaction.change.history_status.unwrap(); + + assert_eq!(store.snapshot().selected_key_type, next_mode); + assert_eq!(status.history_revision, 1); + assert!(!status.can_undo); + assert!(!status.can_redo); + assert_eq!(status.truncated.unwrap().reason, HISTORY_ENTRY_TOO_LARGE); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn history_undo_redo_restore_revision_event_and_reject_stale_commit() { + let dir = test_directory("editor-history-undo-redo-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_dx = store.editor_get().document.key_positions["4key"][0].dx; + let committed_dx = initial_dx + 18.0; + let commit = editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, committed_dx), + ); + store.commit_editor_document(commit).unwrap(); + + let undo_id = uuid::Uuid::new_v4().to_string(); + let gate = store.history_gate(); + let barrier = gate.close(&undo_id).unwrap(); + let cancel_count = std::cell::Cell::new(0); + let current_counters = store.snapshot().key_counters; + let undo = store + .apply_history_operation(HistoryDirection::Undo, &undo_id, ¤t_counters, || { + cancel_count.set(cancel_count.get() + 1); + }) + .unwrap(); + drop(barrier); + + assert_eq!(cancel_count.get(), 1); + assert!(!undo.status.busy); + assert_eq!(undo.change.as_ref().unwrap().result.revision, 2); + assert_eq!( + undo.change.as_ref().unwrap().event.as_ref().unwrap().origin, + "historyUndo" + ); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + initial_dx + ); + assert!(!undo.status.can_undo); + assert!(undo.status.can_redo); + + let replay_barrier = gate.close(&undo_id).unwrap(); + let current_counters = store.snapshot().key_counters; + let replay = store + .apply_history_operation(HistoryDirection::Undo, &undo_id, ¤t_counters, || { + cancel_count.set(cancel_count.get() + 1); + }) + .unwrap(); + drop(replay_barrier); + assert!(replay.replayed); + assert_eq!(cancel_count.get(), 1); + assert_eq!(store.editor_get().revision, 2); + + let stale = editor_request( + 1, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, committed_dx + 5.0), + ); + let stale_error = store.commit_editor_document(stale).unwrap_err(); + assert_eq!( + stale_error.error_code, + EditorCommitErrorCode::RevisionConflict + ); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let redo_barrier = gate.close(&redo_id).unwrap(); + let current_counters = store.snapshot().key_counters; + let redo = store + .apply_history_operation(HistoryDirection::Redo, &redo_id, ¤t_counters, || {}) + .unwrap(); + drop(redo_barrier); + assert_eq!(redo.change.as_ref().unwrap().result.revision, 3); + assert_eq!( + redo.change.as_ref().unwrap().event.as_ref().unwrap().origin, + "historyRedo" + ); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + committed_dx + ); + assert!(redo.status.can_undo); + assert!(!redo.status.can_redo); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn oversized_recorded_commit_clears_history_after_persisting_mutation() { + let dir = test_directory("editor-history-truncation-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_dx = store.editor_get().document.key_positions["4key"][0].dx; + let first = editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_dx + 1.0), + ); + store.commit_editor_document(first).unwrap(); + store + .state + .write() + .history + .set_limits_for_test(1, 32 * 1024 * 1024, 50); + + let second = editor_request( + 1, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_dx + 2.0), + ); + let change = store.commit_editor_document(second).unwrap(); + let status = change.history_status.unwrap(); + + assert_eq!(change.result.revision, 2); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + initial_dx + 2.0 + ); + assert_eq!(status.history_revision, 2); + assert!(!status.can_undo); + assert!(!status.can_redo); + assert_eq!(status.truncated.unwrap().reason, "HISTORY_ENTRY_TOO_LARGE"); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn editor_history_projects_live_and_historical_key_counters() { + let dir = test_directory("editor-history-counter-scope-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = initialize_neutral_editor_store(&dir); + let before = store.snapshot(); + let old_key = before.keys["4key"][0].canonical(); + let preserved_key = before.keys["4key"][1].canonical(); + let mut historical_counters = before.key_counters.clone(); + historical_counters + .get_mut("4key") + .unwrap() + .insert(old_key.clone(), 12); + let new_key = "HistoryCounterScopeKey".to_string(); + let mut keys = before.keys; + keys.get_mut("4key").unwrap()[0] = new_key.clone().into(); + let commit = editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + keys: Some(keys), + ..EditorPatchV1::default() + }, + ); + let admission = store.admit_editor_mutation().unwrap(); + store + .commit_editor_document_with_runtime_counters_admitted( + commit, + &admission, + &historical_counters, + ) + .unwrap(); + drop(admission); + let counters_after_commit = store.snapshot().key_counters; + assert!(counters_after_commit["4key"].contains_key(&new_key)); + assert!(!counters_after_commit["4key"].contains_key(&old_key)); + + let operation_id = uuid::Uuid::new_v4().to_string(); + let gate = store.history_gate(); + let barrier = gate.close(&operation_id).unwrap(); + let mut current_counters = store.snapshot().key_counters; + current_counters + .get_mut("4key") + .unwrap() + .insert(new_key.clone(), 9); + current_counters + .get_mut("4key") + .unwrap() + .insert(preserved_key.clone(), 7); + store + .apply_history_operation( + HistoryDirection::Undo, + &operation_id, + ¤t_counters, + || {}, + ) + .unwrap(); + drop(barrier); + + let restored = store.snapshot(); + assert_eq!(restored.keys["4key"][0].canonical(), old_key); + assert_eq!(restored.key_counters["4key"][&old_key], 12); + assert_eq!(restored.key_counters["4key"][&preserved_key], 7); + assert!(!restored.key_counters["4key"].contains_key(&new_key)); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let redo_barrier = gate.close(&redo_id).unwrap(); + let mut redo_counters = restored.key_counters; + redo_counters + .get_mut("4key") + .unwrap() + .insert(old_key.clone(), 14); + redo_counters + .get_mut("4key") + .unwrap() + .insert(preserved_key.clone(), 8); + store + .apply_history_operation(HistoryDirection::Redo, &redo_id, &redo_counters, || {}) + .unwrap(); + drop(redo_barrier); + + let redone = store.snapshot(); + assert_eq!(redone.keys["4key"][0].canonical(), new_key); + assert_eq!(redone.key_counters["4key"][&new_key], 9); + assert_eq!(redone.key_counters["4key"][&preserved_key], 8); + assert!(!redone.key_counters["4key"].contains_key(&old_key)); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn history_counter_projection_keeps_live_overlap_and_restores_missing_domain() { + let target_keys = HashMap::from([ + ( + "shared-mode".to_string(), + vec![KeySlot::from("shared"), KeySlot::from("restored")], + ), + ("restored-mode".to_string(), vec![KeySlot::from("mode-key")]), + ]); + let current = HashMap::from([( + "shared-mode".to_string(), + HashMap::from([("shared".to_string(), 8), ("removed".to_string(), 5)]), + )]); + let historical = HashMap::from([ + ( + "shared-mode".to_string(), + HashMap::from([ + ("shared".to_string(), 3), + ("restored".to_string(), 12), + ("obsolete".to_string(), 4), + ]), + ), + ( + "restored-mode".to_string(), + HashMap::from([("mode-key".to_string(), 21)]), + ), + ]); + + let projected = project_history_key_counters(¤t, &historical, &target_keys); + + assert_eq!( + projected, + HashMap::from([ + ( + "shared-mode".to_string(), + HashMap::from([("shared".to_string(), 8), ("restored".to_string(), 12)]), + ), + ( + "restored-mode".to_string(), + HashMap::from([("mode-key".to_string(), 21)]), + ), + ]) + ); +} + +#[test] +fn history_undo_rejects_oversized_opposite_without_state_change() { + let dir = test_directory("editor-history-undo-budget-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_dx = store.editor_get().document.key_positions["4key"][0].dx; + let commit = editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_dx + 9.0), + ); + store.commit_editor_document(commit).unwrap(); + store + .state + .write() + .history + .set_limits_for_test(1, 32 * 1024 * 1024, 50); + let before_store = store.snapshot(); + let before_status = store.history_status(); + + let operation_id = uuid::Uuid::new_v4().to_string(); + let gate = store.history_gate(); + let barrier = gate.close(&operation_id).unwrap(); + assert!(store.history_status().busy); + let current_counters = store.snapshot().key_counters; + let error = store + .apply_history_operation( + HistoryDirection::Undo, + &operation_id, + ¤t_counters, + || {}, + ) + .unwrap_err(); + drop(barrier); + assert!(!store.history_status().busy); + + assert_eq!(error, "HISTORY_ENTRY_TOO_LARGE"); + assert_eq!(store.snapshot(), before_store); + let after_status = store.history_status(); + assert_eq!( + after_status.history_revision, + before_status.history_revision + ); + assert_eq!(after_status.history_epoch, before_status.history_epoch + 1); + assert_eq!(after_status.can_undo, before_status.can_undo); + assert_eq!(after_status.can_redo, before_status.can_redo); + assert_eq!(after_status.truncated, before_status.truncated); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn editor_mutation_is_rejected_while_history_gate_is_closed() { + let dir = test_directory("editor-history-admission-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let operation_id = uuid::Uuid::new_v4().to_string(); + let gate = store.history_gate(); + let barrier = gate.close(&operation_id).unwrap(); + let request = editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, 42.0), + ); + + let error = store.commit_editor_document(request).unwrap_err(); + assert!(error.retryable); + assert_eq!(error.error_code, EditorCommitErrorCode::HistoryInProgress); + assert_eq!( + serde_json::to_value(&error).unwrap()["errorCode"], + "HISTORY_IN_PROGRESS" + ); + drop(barrier); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn strict_commit_revalidates_admission_after_waiting_for_store_lock() { + let dir = test_directory("editor-history-admission-revalidation-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = Arc::new(AppStore::initialize_in_dir(&dir).unwrap()); + let initial_dx = store.editor_get().document.key_positions["4key"][0].dx; + let request = editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_dx + 7.0), + ); + let admission = store.admit_editor_mutation().unwrap(); + let store_guard = store.state.write(); + let commit_store = Arc::clone(&store); + let (commit_started_tx, commit_started_rx) = mpsc::channel(); + let commit = thread::spawn(move || { + commit_started_tx.send(()).unwrap(); + commit_store.commit_editor_document_admitted(request, &admission) + }); + commit_started_rx + .recv_timeout(Duration::from_secs(2)) + .unwrap(); + + let gate = store.history_gate(); + let barrier_gate = Arc::clone(&gate); + let operation_id = uuid::Uuid::new_v4().to_string(); + let (barrier_ready_tx, barrier_ready_rx) = mpsc::channel(); + let (release_barrier_tx, release_barrier_rx) = mpsc::channel(); + let barrier = thread::spawn(move || { + let lease = barrier_gate.close(&operation_id).unwrap(); + barrier_ready_tx.send(()).unwrap(); + release_barrier_rx.recv().unwrap(); + drop(lease); + }); + + let deadline = Instant::now() + Duration::from_secs(2); + while !gate.is_closed() { + assert!(Instant::now() < deadline, "history gate did not close"); + thread::yield_now(); + } + assert!(matches!( + barrier_ready_rx.try_recv(), + Err(mpsc::TryRecvError::Empty) + )); + + drop(store_guard); + let error = commit.join().unwrap().unwrap_err(); + assert_eq!(error.error_code, EditorCommitErrorCode::HistoryInProgress); + assert!(error.retryable); + barrier_ready_rx + .recv_timeout(Duration::from_secs(2)) + .unwrap(); + release_barrier_tx.send(()).unwrap(); + barrier.join().unwrap(); + + assert_eq!(store.editor_get().revision, 0); + assert_eq!(store.history_status().history_revision, 0); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn aux_mutation_revalidates_admission_after_waiting_for_store_lock() { + let dir = test_directory("aux-history-admission-revalidation-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = Arc::new(AppStore::initialize_in_dir(&dir).unwrap()); + let initial_mode = store.snapshot().selected_key_type; + let next_mode = if initial_mode == "8key" { + "4key".to_string() + } else { + "8key".to_string() + }; + let store_guard = store.state.write(); + let mutation_store = Arc::clone(&store); + let (mutation_started_tx, mutation_started_rx) = mpsc::channel(); + let mutation = thread::spawn(move || { + mutation_started_tx.send(()).unwrap(); + mutation_store.commit_aux_editor_transaction( + HistoryScope::Mode, + None, + EditorCommitOrigin::LegacyAdapter("aux_revalidation_test".to_string()), + &[], + |data| { + data.selected_key_type = next_mode; + Ok(()) + }, + ) + }); + mutation_started_rx + .recv_timeout(Duration::from_secs(2)) + .unwrap(); + + let gate = store.history_gate(); + let admission_deadline = Instant::now() + Duration::from_secs(2); + while gate.active_mutations() == 0 { + assert!( + Instant::now() < admission_deadline, + "aux mutation was not admitted" + ); + thread::yield_now(); + } + let barrier_gate = Arc::clone(&gate); + let operation_id = uuid::Uuid::new_v4().to_string(); + let (barrier_ready_tx, barrier_ready_rx) = mpsc::channel(); + let (release_barrier_tx, release_barrier_rx) = mpsc::channel(); + let barrier = thread::spawn(move || { + let lease = barrier_gate.close(&operation_id).unwrap(); + barrier_ready_tx.send(()).unwrap(); + release_barrier_rx.recv().unwrap(); + drop(lease); + }); + + let deadline = Instant::now() + Duration::from_secs(2); + while !gate.is_closed() { + assert!(Instant::now() < deadline, "history gate did not close"); + thread::yield_now(); + } + assert!(matches!( + barrier_ready_rx.try_recv(), + Err(mpsc::TryRecvError::Empty) + )); + + drop(store_guard); + let error = mutation.join().unwrap().unwrap_err(); + assert_eq!(error.error_code, EditorCommitErrorCode::HistoryInProgress); + assert!(error.retryable); + barrier_ready_rx + .recv_timeout(Duration::from_secs(2)) + .unwrap(); + release_barrier_tx.send(()).unwrap(); + barrier.join().unwrap(); + + assert_eq!(store.snapshot().selected_key_type, initial_mode); + assert_eq!(store.history_status().history_revision, 0); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn css_watcher_reload_revalidates_after_waiting_for_store_lock() { + let dir = test_directory("css-watcher-admission-revalidation-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = Arc::new(AppStore::initialize_in_dir(&dir).unwrap()); + let watched_path = "/tmp/dmnote-watcher-revalidation.css"; + store + .update(|data| { + data.use_custom_css = true; + data.custom_css = CustomCss { + path: Some(watched_path.to_string()), + content: "before".to_string(), + }; + }) + .unwrap(); + let store_guard = store.state.write(); + let mutation_store = Arc::clone(&store); + let loaded = ValidatedCssFile { + canonical_path: watched_path.to_string(), + content: "blocked-reload".to_string(), + }; + let mutation = thread::spawn(move || commit_css_reload(&mutation_store, watched_path, &loaded)); + + let gate = store.history_gate(); + let admission_deadline = Instant::now() + Duration::from_secs(2); + while gate.active_mutations() == 0 { + assert!( + Instant::now() < admission_deadline, + "CSS watcher reload was not admitted" + ); + thread::yield_now(); + } + let barrier_gate = Arc::clone(&gate); + let operation_id = uuid::Uuid::new_v4().to_string(); + let (barrier_ready_tx, barrier_ready_rx) = mpsc::channel(); + let (release_barrier_tx, release_barrier_rx) = mpsc::channel(); + let barrier = thread::spawn(move || { + let lease = barrier_gate.close(&operation_id).unwrap(); + barrier_ready_tx.send(()).unwrap(); + release_barrier_rx.recv().unwrap(); + drop(lease); + }); + + let deadline = Instant::now() + Duration::from_secs(2); + while !gate.is_closed() { + assert!(Instant::now() < deadline, "history gate did not close"); + thread::yield_now(); + } + assert!(matches!( + barrier_ready_rx.try_recv(), + Err(mpsc::TryRecvError::Empty) + )); + + drop(store_guard); + let error = mutation.join().unwrap().unwrap_err(); + assert_eq!(error.error_code, EditorCommitErrorCode::HistoryInProgress); + assert!(error.retryable); + barrier_ready_rx + .recv_timeout(Duration::from_secs(2)) + .unwrap(); + release_barrier_tx.send(()).unwrap(); + barrier.join().unwrap(); + + assert_eq!(store.snapshot().custom_css.content, "before"); + assert_eq!(store.state.read().history.history_revision(), 0); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn legacy_keys_publication_drains_before_later_history_restore() { + let dir = test_directory("legacy-editor-publication-drain-test"); + std::fs::create_dir_all(&dir).unwrap(); + let state = Arc::new(AppState::initialize(initialize_neutral_editor_store(&dir)).unwrap()); + let initial = state.store.snapshot(); + let mode = initial.selected_key_type.clone(); + let mut strict_keys = initial.keys.clone(); + strict_keys.get_mut(&mode).unwrap()[0] = "StrictHistoryKey".into(); + state + .store + .commit_editor_document(editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + keys: Some(strict_keys), + ..EditorPatchV1::default() + }, + )) + .unwrap(); + + let legacy_key = "LegacyPublishedKey".to_string(); + let mut legacy_keys = state.store.snapshot().keys; + legacy_keys.get_mut(&mode).unwrap()[0] = legacy_key.clone().into(); + let transaction = state + .store + .commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("legacy_publish_test".to_string()), + &[EditorField::Keys], + move |data| { + data.keys = legacy_keys; + Ok(()) + }, + ) + .unwrap(); + let expected_counters = transaction.change.key_counters.clone(); + + let events = Arc::new(Mutex::new(Vec::new())); + let (runtime_started_tx, runtime_started_rx) = mpsc::channel(); + let (release_runtime_tx, release_runtime_rx) = mpsc::channel(); + let emitter = TestCounterEmitter::blocking_snapshot( + Arc::clone(&events), + mode.clone(), + legacy_key.clone(), + runtime_started_tx, + release_runtime_rx, + ); + let publisher_state = Arc::clone(&state); + let publisher_events = Arc::clone(&events); + let publisher = thread::spawn(move || { + publisher_state + .apply_committed_editor_key_runtime( + &emitter, + transaction.change.runtime_publication_generation, + &transaction.change.document.keys, + &transaction.change.selected_key_type, + &transaction.change.key_counters, + ) + .unwrap(); + publisher_events + .lock() + .unwrap() + .push("legacy:published".to_string()); + drop(transaction); + }); + runtime_started_rx + .recv_timeout(Duration::from_secs(2)) + .unwrap(); + + let post_legacy_key = "PostLegacyHistoryKey".to_string(); + let mut post_legacy_keys = state.store.snapshot().keys; + post_legacy_keys.get_mut(&mode).unwrap()[0] = post_legacy_key.clone().into(); + state + .store + .commit_editor_document(editor_request( + state.store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + keys: Some(post_legacy_keys), + ..EditorPatchV1::default() + }, + )) + .unwrap(); + + let gate = state.store.history_gate(); + let undo_gate = Arc::clone(&gate); + let undo_state = Arc::clone(&state); + let undo_events = Arc::clone(&events); + let operation_id = uuid::Uuid::new_v4().to_string(); + let (undo_done_tx, undo_done_rx) = mpsc::channel(); + let undo = thread::spawn(move || { + let barrier = undo_gate.close(&operation_id).unwrap(); + let counter_guard = undo_state.lock_key_counters_for_history(); + let current_counters = counter_guard.clone(); + let outcome = undo_state + .store + .apply_history_operation( + HistoryDirection::Undo, + &operation_id, + ¤t_counters, + || {}, + ) + .unwrap(); + let change = outcome.change.as_ref().unwrap(); + undo_state.apply_committed_editor_keys_without_counters( + outcome.runtime_publication_generation, + &change.document.keys, + &change.selected_key_type, + ); + undo_events + .lock() + .unwrap() + .push("undo:restored".to_string()); + drop(counter_guard); + drop(barrier); + undo_done_tx.send(()).unwrap(); + outcome + }); + + let deadline = Instant::now() + Duration::from_secs(2); + while !gate.is_closed() { + assert!(Instant::now() < deadline, "history gate did not close"); + thread::yield_now(); + } + assert!(matches!( + undo_done_rx.try_recv(), + Err(mpsc::TryRecvError::Empty) + )); + + release_runtime_tx.send(()).unwrap(); + publisher.join().unwrap(); + undo_done_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + let outcome = undo.join().unwrap(); + + assert_eq!(outcome.change.unwrap().result.revision, 4); + assert_eq!( + state.store.snapshot().keys[&mode][0].canonical(), + legacy_key + ); + assert_eq!(state.snapshot_key_counters(), expected_counters); + assert!(state.keyboard.register_key_down(&mode, &legacy_key)); + assert!(!state.keyboard.register_key_down(&mode, &post_legacy_key)); + assert_eq!( + *events.lock().unwrap(), + vec![ + "snapshot:0".to_string(), + "legacy:published".to_string(), + "undo:restored".to_string(), + ] + ); + + state.shutdown(); + drop(state); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn stale_key_publisher_cannot_overwrite_newer_counter_runtime() { + let dir = test_directory("stale-key-counter-publication-test"); + std::fs::create_dir_all(&dir).unwrap(); + let state = AppState::initialize(AppStore::initialize_in_dir(&dir).unwrap()).unwrap(); + let initial = state.store.snapshot(); + let mode = initial.selected_key_type.clone(); + let old_key = initial.keys[&mode][0].canonical(); + let new_key = "PublicationOrderedKey".to_string(); + let mut changed_keys = initial.keys; + changed_keys.get_mut(&mode).unwrap()[0] = new_key.clone().into(); + + let stale_transaction = state + .store + .commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("stale_key_publisher_test".to_string()), + &[EditorField::Keys], + move |data| { + data.keys = changed_keys; + Ok(()) + }, + ) + .unwrap(); + assert!(state.keyboard.register_key_down(&mode, &old_key)); + assert!(!state.keyboard.register_key_down(&mode, &new_key)); + + let events = Arc::new(Mutex::new(Vec::new())); + let counter_emitter = + TestCounterEmitter::new(Arc::clone(&events), mode.clone(), new_key.clone()); + let mut newer_counters = state.snapshot_key_counters(); + newer_counters.remove(&mode); + newer_counters.insert(mode.clone(), [(new_key.clone(), 9)].into_iter().collect()); + let counter_mutation = state + .replace_key_counters(&counter_emitter, newer_counters, None) + .unwrap(); + assert_eq!(counter_mutation.counters[&mode][&new_key], 9); + drop(counter_mutation); + events.lock().unwrap().clear(); + + let stale_emitter = TestCounterEmitter::new(Arc::clone(&events), mode.clone(), new_key.clone()); + state + .apply_committed_editor_key_runtime( + &stale_emitter, + stale_transaction.change.runtime_publication_generation, + &stale_transaction.change.document.keys, + &stale_transaction.change.selected_key_type, + &stale_transaction.change.key_counters, + ) + .unwrap(); + drop(stale_transaction); + + assert_eq!(state.snapshot_key_counters()[&mode][&new_key], 9); + assert_eq!(state.store.snapshot().key_counters[&mode][&new_key], 9); + assert_eq!(state.keyboard.pressed_keys(), vec![new_key.clone()]); + assert!(state.keyboard.register_key_up(&mode, &new_key)); + assert!(state.keyboard.register_key_down(&mode, &new_key)); + assert!(!state.keyboard.register_key_down(&mode, &old_key)); + assert!(events.lock().unwrap().is_empty()); + + state.shutdown(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn editor_commit_gradient_repair_keeps_disk_document_and_event_identical() { + let dir = test_directory("editor-gradient-canonicalization-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let mut positions = store.editor_get().document.key_positions; + let position = &mut positions.get_mut("4key").unwrap()[0]; + position.background_color = Some("#BADBAD".to_string()); + position.background_gradient = Some( + serde_json::from_value(json!({ + "type": "linear", + "stops": [ + { "color": "rgba(90, 162, 247, 1)", "pos": 1.2 }, + { "color": "rgba(139, 92, 246, 1)", "pos": -0.1 } + ] + })) + .unwrap(), + ); + position.counter.fill.idle = "#FFFFFF".to_string(); + position.counter.fill_idle_gradient = Some( + serde_json::from_value(json!({ + "angle": 450, + "stops": [ + { "color": "#FFFFFF80", "pos": 0 }, + { "color": "rgba(0, 0, 0, 0)", "pos": 1 } + ] + })) + .unwrap(), + ); + let request = editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + key_positions: Some(positions), + ..EditorPatchV1::default() + }, + ); + + let change = store.commit_editor_document(request).unwrap(); + let committed_position = &change.document.key_positions["4key"][0]; + assert_eq!( + change.result.changed_fields, + vec![EditorField::KeyPositions] + ); + assert_eq!( + committed_position + .background_gradient + .as_ref() + .unwrap() + .angle, + 90.0 + ); + assert_eq!( + committed_position + .background_gradient + .as_ref() + .unwrap() + .stops[0] + .pos, + 0.0 + ); + assert_eq!( + committed_position.background_color.as_deref(), + Some("rgba(139, 92, 246, 1)") + ); + assert_eq!( + committed_position.counter.fill.idle, + "rgba(255,255,255,0.502)" + ); + + let event_positions = change + .event + .as_ref() + .unwrap() + .patch + .key_positions + .as_ref() + .unwrap(); + assert_eq!(event_positions, &change.document.key_positions); + assert_eq!( + EditorDocumentV1::from_store(&store.snapshot()), + change.document + ); + + let committed_document = change.document; + store.flush_and_shutdown().unwrap(); + drop(store); + let reloaded = crate::state::migration::load_store_from_path(&dir.join("store.json")).unwrap(); + assert!(!reloaded.needs_persist); + assert_eq!( + EditorDocumentV1::from_store(&reloaded.data), + committed_document + ); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn editor_commit_folds_replace_image_mode_into_none() { + let dir = test_directory("editor-image-mode-canonicalization-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let revision_before = store.editor_get().revision; + let mut positions = store.editor_get().document.key_positions; + positions.get_mut("4key").unwrap()[0].image_mode = Some(crate::models::ImageMode::Replace); + let request = editor_request( + revision_before, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + key_positions: Some(positions), + ..EditorPatchV1::default() + }, + ); + + // replace만 명시한 패치는 정규화 뒤 무변경 - 빈 undo 항목이 남지 않는다 + let change = store.commit_editor_document(request).unwrap(); + assert!(change.result.changed_fields.is_empty()); + assert_eq!(change.result.revision, revision_before); + assert!(change.event.is_none()); + assert!(change.document.key_positions["4key"][0] + .image_mode + .is_none()); + assert!(store.snapshot().key_positions["4key"][0] + .image_mode + .is_none()); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn invalid_gradient_preset_parse_failure_leaves_store_unchanged() { + let dir = test_directory("invalid-gradient-preset-atomicity-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let before = store.snapshot(); + let persist_count = store.writer.persist_count(); + + let parsed = serde_json::from_value::(json!({ + "keys": { "4key": ["Q"] }, + "keyPositions": { + "4key": [{ + "dx": 0, + "dy": 0, + "width": 60, + "count": 0, + "backgroundGradient": { + "angle": 90, + "stops": [{ "color": "#FFFFFF", "pos": 0 }] + } + }] + } + })); + + assert!(parsed.is_err()); + assert_eq!(store.snapshot(), before); + assert_eq!(store.writer.persist_count(), persist_count); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn invalid_shadow_preset_read_failure_leaves_store_unchanged() { + let dir = test_directory("invalid-shadow-preset-atomicity-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let before = store.snapshot(); + let persist_count = store.writer.persist_count(); + let source_path = dir.join("invalid-preset.json"); + + for (source, expected_error) in [ + ( + r##"{ + "keyPositions": { + "4key": [{ + "dx": 0, + "dy": 0, + "width": 60, + "count": 0, + "shadow": { + "enabled": true, + "color": "#123456", + "offsetX": 0, + "offsetY": 0, + "blur": 100.1 + } + }] + } + }"##, + "invalid-preset: keyPositions[\"4key\"][0].shadow.blur: must be a finite number between 0 and 100", + ), + ( + r##"{ + "statPositions": { + "4key": [{ + "statType": "kps", + "dx": 0, + "dy": 0, + "width": 60, + "count": 0, + "activeShadow": { + "enabled": true, + "color": "#123456", + "offsetX": -100.1, + "offsetY": 0, + "blur": 12 + } + }] + } + }"##, + "invalid-preset: statPositions[\"4key\"][0].activeShadow.offsetX: must be a finite number between -100 and 100", + ), + ] { + std::fs::write(&source_path, source).unwrap(); + let error = crate::commands::preset::load::read_preset_file_for_simulation( + &source_path, + ) + .err() + .expect("invalid visual effect preset must be rejected") + .to_string(); + + assert_eq!(error, expected_error); + assert_eq!(store.snapshot(), before); + assert_eq!(store.writer.persist_count(), persist_count); + } + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn strict_editor_noop_is_acknowledged_without_persist_or_event() { + let dir = test_directory("strict-editor-noop-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let persist_count = store.writer.persist_count(); + let request = editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1::default(), + ); + + let change = store.commit_editor_document(request).unwrap(); + + assert_eq!(change.result.revision, 0); + assert!(change.result.changed_fields.is_empty()); + assert!(change.event.is_none()); + assert!(!change.replayed); + assert_eq!(store.writer.persist_count(), persist_count); + assert_eq!(store.snapshot().editor_revision, 0); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn strict_editor_conflict_does_not_persist() { + let dir = test_directory("strict-editor-conflict-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let persist_count = store.writer.persist_count(); + let error = store + .commit_editor_document(editor_request( + 1, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, 123.0), + )) + .unwrap_err(); + + assert_eq!(error.error_code, EditorCommitErrorCode::RevisionConflict); + assert_eq!(error.details.unwrap().current_revision, Some(0)); + assert_eq!(store.writer.persist_count(), persist_count); + assert_eq!(store.snapshot().editor_revision, 0); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn strict_editor_io_failure_is_not_committed_or_acknowledged() { + let dir = test_directory("strict-editor-io-failure-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let before = store.snapshot(); + let disk_before = std::fs::read(dir.join("store.json")).unwrap(); + let request = editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, 456.0), + ); + + store.writer.fail_next_persist(); + let error = store.commit_editor_document(request.clone()).unwrap_err(); + assert_eq!(error.error_code, EditorCommitErrorCode::IoError); + assert_eq!(store.snapshot(), before); + assert_eq!(std::fs::read(dir.join("store.json")).unwrap(), disk_before); + + let retried = store.commit_editor_document(request).unwrap(); + assert_eq!(retried.result.revision, 1); + assert_eq!(store.snapshot().key_positions["4key"][0].dx, 456.0); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn strict_editor_structural_single_field_update_requires_pair() { + let dir = test_directory("strict-editor-paired-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = initialize_neutral_editor_store(&dir); + let before = store.snapshot(); + let mut keys = before.keys.clone(); + keys.get_mut("4key").unwrap().push("F5".into()); + let error = store + .commit_editor_document(editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + keys: Some(keys), + ..EditorPatchV1::default() + }, + )) + .unwrap_err(); + + assert_eq!( + error.error_code, + EditorCommitErrorCode::PairedUpdateRequired + ); + assert_eq!(store.snapshot(), before); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn strict_editor_retry_returns_original_ack_before_base_check() { + let dir = test_directory("strict-editor-idempotency-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let request = editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, 789.0), + ); + let first = store.commit_editor_document(request.clone()).unwrap(); + let persist_count = store.writer.persist_count(); + + let retry = store.commit_editor_document(request).unwrap(); + + assert_eq!(retry.result, first.result); + assert!(retry.event.is_none()); + assert!(retry.replayed); + assert_eq!(store.writer.persist_count(), persist_count); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn strict_editor_rejects_reused_mutation_id_with_different_request() { + let dir = test_directory("strict-editor-mutation-reuse-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let mutation_id = uuid::Uuid::new_v4().to_string(); + store + .commit_editor_document(editor_request( + 0, + mutation_id.clone(), + EditorPatchV1::default(), + )) + .unwrap(); + + let error = store + .commit_editor_document(editor_request( + 0, + mutation_id, + position_patch(&store, 321.0), + )) + .unwrap_err(); + assert_eq!(error.error_code, EditorCommitErrorCode::MutationIdReused); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn mutation_ack_ring_retains_only_the_latest_32_results() { + let dir = test_directory("strict-editor-ring-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let ids = (0..33) + .map(|_| uuid::Uuid::new_v4().to_string()) + .collect::>(); + for id in &ids { + store + .commit_editor_document(editor_request(0, id.clone(), EditorPatchV1::default())) + .unwrap(); + } + store + .commit_editor_document(editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, 654.0), + )) + .unwrap(); + + let evicted = store + .commit_editor_document(editor_request(0, ids[0].clone(), EditorPatchV1::default())) + .unwrap_err(); + assert_eq!(evicted.error_code, EditorCommitErrorCode::RevisionConflict); + + let retained = store + .commit_editor_document(editor_request(0, ids[32].clone(), EditorPatchV1::default())) + .unwrap(); + assert_eq!(retained.result.revision, 0); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn canonical_request_hash_makes_map_order_idempotent() { + let dir = test_directory("strict-editor-canonical-hash-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = initialize_neutral_editor_store(&dir); + let mutation_id = uuid::Uuid::new_v4().to_string(); + let current = store.snapshot().keys; + let mut entries = current.into_iter().collect::>(); + entries.sort_by(|left, right| left.0.cmp(&right.0)); + let forward = entries.iter().cloned().collect::>(); + let reverse = entries.into_iter().rev().collect::>(); + + let first = store + .commit_editor_document(editor_request( + 0, + mutation_id.clone(), + EditorPatchV1 { + keys: Some(forward), + ..EditorPatchV1::default() + }, + )) + .unwrap(); + let retry = store + .commit_editor_document(editor_request( + 0, + mutation_id, + EditorPatchV1 { + keys: Some(reverse), + ..EditorPatchV1::default() + }, + )) + .unwrap(); + + assert_eq!(retry.result, first.result); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn editor_get_never_observes_a_revision_document_mismatch() { + let dir = test_directory("editor-get-atomic-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = Arc::new(AppStore::initialize_in_dir(&dir).unwrap()); + let done = Arc::new(AtomicBool::new(false)); + let reader_store = Arc::clone(&store); + let reader_done = Arc::clone(&done); + let reader = std::thread::spawn(move || { + while !reader_done.load(Ordering::Acquire) { + let snapshot = reader_store.editor_get(); + if snapshot.revision > 0 { + assert_eq!( + snapshot.document.key_positions["4key"][0].dx, + 10_000.0 + snapshot.revision as f64 + ); + } + } + }); + + for expected_revision in 1..=20 { + let base = store.editor_get().revision; + store + .commit_editor_document(editor_request( + base, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, 10_000.0 + expected_revision as f64), + )) + .unwrap(); + } + done.store(true, Ordering::Release); + reader.join().unwrap(); + let final_snapshot = store.editor_get(); + assert_eq!(final_snapshot.revision, 20); + assert_eq!( + final_snapshot.document.key_positions["4key"][0].dx, + 10_020.0 + ); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn metadata_only_legacy_transaction_persists_without_editor_revision_or_event() { + let dir = test_directory("editor-metadata-only-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let persist_count = store.writer.persist_count(); + + let transaction = store + .commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("metadata_test".to_string()), + &[], + |data| { + data.language = "en".to_string(); + Ok(()) + }, + ) + .unwrap(); + + assert_eq!(store.writer.persist_count(), persist_count + 1); + assert_eq!(transaction.change.result.revision, 0); + assert!(transaction.change.result.changed_fields.is_empty()); + assert!(transaction.change.event.is_none()); + assert_eq!(store.snapshot().language, "en"); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn strict_editor_commit_preserves_existing_ghost_modes_losslessly() { + let dir = test_directory("editor-grandfather-test"); + std::fs::create_dir_all(&dir).unwrap(); + let mut data = super::initialize_default_state(); + data.keys + .insert("ghost".to_string(), vec!["GhostKey".into()]); + let ghost_id = uuid::Uuid::new_v4().to_string(); + data.key_positions.insert( + "ghost".to_string(), + vec![KeyPosition { + id: ghost_id.clone(), + ..KeyPosition::default() + }], + ); + let store = AppStore::new(dir.join("store.json"), data, false).unwrap(); + + store + .commit_editor_document(editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, 222.0), + )) + .unwrap(); + + let snapshot = store.snapshot(); + assert_eq!(snapshot.keys["ghost"], vec![KeySlot::from("GhostKey")]); + assert_eq!(snapshot.key_positions["ghost"][0].id, ghost_id); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn editor_get_exposes_recovered_safe_revision_after_loading_unsafe_wire_value() { + let dir = test_directory("editor-revision-load-recovery-test"); + std::fs::create_dir_all(&dir).unwrap(); + let mut data = super::initialize_default_state(); + data.editor_revision = crate::state::editor::MAX_SAFE_WIRE_REVISION + 1; + data.key_positions.get_mut("4key").unwrap()[0].dx = 12_345.0; + std::fs::write( + dir.join("store.json"), + serde_json::to_vec_pretty(&data).unwrap(), + ) + .unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let editor = store.editor_get(); + + assert_eq!(editor.revision, 0); + assert_eq!(editor.document.key_positions["4key"][0].dx, 12_345.0); + let on_disk: AppStoreData = + serde_json::from_slice(&std::fs::read(dir.join("store.json")).unwrap()).unwrap(); + assert_eq!(on_disk.editor_revision, 0); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn generic_update_cannot_mutate_editor_fields_or_revision() { + let dir = test_directory("editor-generic-escape-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let before = store.snapshot(); + + assert!(store + .update(|data| data.key_positions.get_mut("4key").unwrap()[0].dx = 999.0) + .is_err()); + assert!(store.update(|data| data.editor_revision += 1).is_err()); + assert_eq!(store.snapshot(), before); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn paired_keys_update_returns_the_normalized_mode_for_runtime_sync() { + let dir = test_directory("keys-update-mode-sync-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let custom_mode = "custom-mode".to_string(); + store + .commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("test_setup".to_string()), + &[EditorField::Keys, EditorField::KeyPositions], + |data| { + data.custom_tabs.push(CustomTab { + id: custom_mode.clone(), + name: "Custom".to_string(), + }); + data.keys.insert(custom_mode.clone(), vec!["KeyA".into()]); + data.key_positions.insert( + custom_mode.clone(), + vec![KeyPosition { + id: uuid::Uuid::new_v4().to_string(), + ..KeyPosition::default() + }], + ); + data.selected_key_type = custom_mode.clone(); + Ok(()) + }, + ) + .unwrap(); + let initialized = store.snapshot(); + let keyboard = KeyboardManager::new( + initialized.keys.clone(), + initialized.selected_key_type.clone(), + ); + + let mut mappings = initialized.keys; + mappings.remove(&custom_mode); + let mut positions = initialized.key_positions; + positions.remove(&custom_mode); + let change = legacy_editor_commit( + &store, + &[EditorField::Keys, EditorField::KeyPositions], + move |data| { + data.keys = mappings; + data.key_positions = positions; + }, + ) + .unwrap(); + let updated = change.document.keys; + let selected_key_type = change.selected_key_type; + keyboard.update_mappings_and_set_mode(updated, selected_key_type.clone()); + + assert_eq!(selected_key_type, "4key"); + assert_eq!(store.snapshot().selected_key_type, "4key"); + assert_eq!(keyboard.current_mode(), "4key"); + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn keys_with_positions_commit_is_atomic_without_implicit_padding() { + let dir = test_directory("keys-positions-atomic-commit-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let before = store.snapshot(); + let disk_before = std::fs::read(dir.join("store.json")).unwrap(); + let mut mappings = before.keys.clone(); + mappings.get_mut("4key").unwrap().push("F5".into()); + mappings.get_mut("5key").unwrap().push(KeySlot::default()); + let mut positions = before.key_positions.clone(); + positions.get_mut("4key").unwrap().push(KeyPosition { + id: uuid::Uuid::new_v4().to_string(), + ..KeyPosition::default() + }); + positions.get_mut("5key").unwrap().push(KeyPosition { + id: uuid::Uuid::new_v4().to_string(), + ..KeyPosition::default() + }); + + store.writer.fail_next_persist(); + let failed_mappings = mappings.clone(); + let failed_positions = positions.clone(); + assert!(legacy_editor_commit( + &store, + &[EditorField::Keys, EditorField::KeyPositions], + move |data| { + data.keys = failed_mappings; + data.key_positions = failed_positions; + }, + ) + .is_err()); + assert_eq!(store.snapshot(), before); + assert_eq!(std::fs::read(dir.join("store.json")).unwrap(), disk_before); + + let persist_count = store.writer.persist_count(); + let change = legacy_editor_commit( + &store, + &[EditorField::Keys, EditorField::KeyPositions], + move |data| { + data.keys = mappings; + data.key_positions = positions; + }, + ) + .unwrap(); + let keys = change.document.keys; + let positions = change.document.key_positions; + assert_eq!(store.writer.persist_count(), persist_count + 1); + assert_eq!(keys["4key"].last().unwrap(), &KeySlot::from("F5")); + assert!(crate::state::native_element_id::is_valid_element_id( + &positions["4key"].last().unwrap().id + )); + assert!(keys["5key"].last().unwrap().is_unassigned()); + assert_eq!(keys["4key"].len(), positions["4key"].len()); + assert_eq!(keys["5key"].len(), positions["5key"].len()); + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn legacy_structural_single_collection_commits_are_rejected() { + let dir = test_directory("legacy-key-position-commit-length-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + + let before = store.snapshot(); + let mut mappings = before.keys.clone(); + mappings.get_mut("4key").unwrap().push("F5".into()); + assert!( + legacy_editor_commit(&store, &[EditorField::Keys], move |data| { + data.keys = mappings; + }) + .is_err() + ); + assert_eq!(store.snapshot(), before); + + let mut positions = before.key_positions.clone(); + positions + .get_mut("5key") + .unwrap() + .push(KeyPosition::default()); + assert!( + legacy_editor_commit(&store, &[EditorField::KeyPositions], move |data| { + data.key_positions = positions; + },) + .is_err() + ); + assert_eq!(store.snapshot(), before); + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn asset_cleanup_waits_for_an_active_processed_wav_transaction() { + let dir = test_directory("processed-wav-cleanup-lock-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = Arc::new(AppStore::initialize_in_dir(&dir).unwrap()); + let transaction_guard = PROCESSED_WAV_TRANSACTION_LOCK.lock(); + let (started_tx, started_rx) = mpsc::channel(); + let (done_tx, done_rx) = mpsc::channel(); + let cleanup_store = store.clone(); + let cleanup_thread = std::thread::spawn(move || { + started_tx.send(()).unwrap(); + let result = cleanup_store.cleanup_orphan_assets_now(); + done_tx.send(result).unwrap(); + }); + + started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + assert!(done_rx.recv_timeout(Duration::from_millis(50)).is_err()); + drop(transaction_guard); + done_rx + .recv_timeout(Duration::from_secs(2)) + .unwrap() + .unwrap(); + cleanup_thread.join().unwrap(); + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn shutdown_clears_a_pending_wav_journal_before_stopping_the_writer() { + let dir = test_directory("processed-wav-shutdown-order-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let target_path = dir.join("sounds").join("sound.wav"); + std::fs::write(&target_path, b"old-wav").unwrap(); + store + .update(|data| { + data.sound_library.insert( + target_path.to_string_lossy().to_string(), + Default::default(), + ); + data.pending_processed_wav_replacement = Some(PendingProcessedWavReplacement { + sound_path: target_path.to_string_lossy().to_string(), + had_original: true, + }); + }) + .unwrap(); + + store.flush_cleanup_and_shutdown().unwrap(); + + assert_eq!(store.snapshot().pending_processed_wav_replacement, None); + assert_eq!(std::fs::read(&target_path).unwrap(), b"old-wav"); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn preset_style_layout_and_settings_commit_in_one_writer_revision() { + let dir = test_directory("preset-single-transaction-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + store + .set_plugin_data("plugin_data_fixture", json!({ "kept": true })) + .unwrap(); + let initial_persist_count = store.writer.persist_count(); + + store + .commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("preset_test".to_string()), + &[EditorField::KeyPositions], + |data| { + data.key_positions.get_mut("4key").unwrap()[0].dx = 321.0; + apply_patch_to_store( + data, + &SettingsPatchInput { + background_color: Some("#123456".to_string()), + ..SettingsPatchInput::default() + }, + ); + Ok(()) + }, + ) + .unwrap(); + let updated = store.snapshot(); + + assert_eq!(store.writer.persist_count(), initial_persist_count + 1); + assert_eq!(updated.key_positions["4key"][0].dx, 321.0); + assert_eq!(updated.background_color, "#123456"); + assert_eq!(updated.plugin_data["plugin_data_fixture"]["kept"], true); + + let before_failure = store.snapshot(); + let disk_before_failure = std::fs::read(dir.join("store.json")).unwrap(); + store.writer.fail_next_persist(); + let result = store.commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("preset_test".to_string()), + &[EditorField::KeyPositions], + |data| { + data.key_positions.get_mut("4key").unwrap()[0].dx = 999.0; + apply_patch_to_store( + data, + &SettingsPatchInput { + background_color: Some("#FFFFFF".to_string()), + ..SettingsPatchInput::default() + }, + ); + Ok(()) + }, + ); + + assert!(result.is_err()); + assert_eq!(store.snapshot(), before_failure); + assert_eq!( + store.snapshot().plugin_data["plugin_data_fixture"]["kept"], + true + ); + assert_eq!( + std::fs::read(dir.join("store.json")).unwrap(), + disk_before_failure + ); + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn failed_preset_style_commit_keeps_revision_and_key_runtime_unchanged() { + let dir = test_directory("preset-runtime-failure-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let state = AppState::initialize(store).unwrap(); + let before_store = state.store.snapshot(); + let before_disk = std::fs::read(dir.join("store.json")).unwrap(); + let before_mode = state.keyboard.current_mode(); + let before_counters = state.snapshot_key_counters(); + let mut keys = before_store.keys.clone(); + keys.get_mut("4key").unwrap()[0] = "PresetKey".into(); + + state.store.writer.fail_next_persist(); + let result = state.store.commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("preset_failure_test".to_string()), + &[EditorField::Keys], + move |data| { + data.keys = keys; + data.selected_key_type = "5key".to_string(); + data.background_color = "#123456".to_string(); + Ok(()) + }, + ); + + assert!(result.is_err()); + assert_eq!(state.store.snapshot(), before_store); + assert_eq!(std::fs::read(dir.join("store.json")).unwrap(), before_disk); + assert_eq!(state.keyboard.current_mode(), before_mode); + assert_eq!(state.snapshot_key_counters(), before_counters); + + state.shutdown(); + drop(state); + let _ = std::fs::remove_dir_all(dir); +} + +// 그룹 정의와 참조는 같은 editor 트랜잭션에서 함께 저장 +#[test] +fn group_ids_and_definitions_commit_atomically() { + let dir = test_directory("group-order-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + + let mut positions = store.snapshot().key_positions; + if let Some(list) = positions.get_mut("4key") { + list[0].group_id = Some("new-group".to_string()); + list[1].group_id = Some("new-group".to_string()); + } + let mut groups = crate::models::LayerGroups::new(); + groups.insert( + "4key".to_string(), + vec![crate::models::LayerGroupDef { + id: "new-group".to_string(), + name: "New Group".to_string(), + }], + ); + let rejected_positions = positions.clone(); + assert!( + legacy_editor_commit(&store, &[EditorField::KeyPositions], move |data| { + data.key_positions = rejected_positions; + },) + .is_err() + ); + store + .commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("group_test".to_string()), + &[EditorField::KeyPositions, EditorField::LayerGroups], + move |data| { + data.key_positions = positions; + data.layer_groups = groups; + Ok(()) + }, + ) + .unwrap(); + + let data = store.snapshot(); + assert_eq!( + data.key_positions["4key"][0].group_id.as_deref(), + Some("new-group") + ); + assert_eq!( + data.key_positions["4key"][1].group_id.as_deref(), + Some("new-group") + ); + + // 재기동(로드 경계)에서도 정의가 존재하므로 참조가 유지되어야 함 + store.flush_and_shutdown().unwrap(); + drop(store); + let reopened = AppStore::initialize_in_dir(&dir).unwrap(); + assert_eq!( + reopened.snapshot().key_positions["4key"][0] + .group_id + .as_deref(), + Some("new-group") + ); + reopened.flush_and_shutdown().unwrap(); + drop(reopened); + let _ = std::fs::remove_dir_all(dir); +} + +// 로드 시 dangling groupId 정리는 디스크에도 영속되고, 두 번째 기동은 재저장이 필요 없어야 함 +#[test] +fn dangling_group_ids_are_cleaned_and_persisted_on_boot() { + let dir = test_directory("dangling-persist-test"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("store.json"); + + let mut data = AppStoreData::default(); + let mut positions = default_positions().clone(); + if let Some(list) = positions.get_mut("4key") { + list[0].group_id = Some("ghost-group".to_string()); + } + data.key_positions = positions; + std::fs::write(&path, serde_json::to_vec_pretty(&data).unwrap()).unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + store.flush_and_shutdown().unwrap(); + drop(store); + + let on_disk: AppStoreData = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(on_disk.key_positions["4key"][0].group_id, None); + + let reloaded = super::load_store_from_path(&path).unwrap(); + assert!(!reloaded.needs_persist); + + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn repaired_store_is_backed_up_before_persist() { + let dir = test_directory("store-backup-test"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("store.json"); + let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); + fixture.as_object_mut().unwrap().insert( + "alwaysOnTop".to_string(), + Value::String("invalid".to_string()), + ); + let original = serde_json::to_vec_pretty(&fixture).unwrap(); + std::fs::write(&path, &original).unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + store.flush_and_shutdown().unwrap(); + + assert_eq!(std::fs::read(dir.join("store.json.bak")).unwrap(), original); + assert!(!dir.join("store.json.pre-migration.bak").exists()); + assert!(serde_json::from_slice::(&std::fs::read(path).unwrap()).is_ok()); + + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn invalid_note_gradient_stop_repair_backs_up_persists_and_is_idempotent() { + let dir = test_directory("note-border-stop-repair-test"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("store.json"); + let mut data = super::initialize_default_state(); + let position = &mut data.key_positions.get_mut("4key").unwrap()[0]; + position.note_color = NoteColor::Gradient { + top: "body-top".to_string(), + bottom: "body-bottom".to_string(), + }; + position.note_opacity_top = Some(21); + position.note_opacity_bottom = Some(79); + position.note_gradient = serde_json::from_value(serde_json::json!({ + "angle": 45, + "stops": [ + { "color": "#112233", "pos": 0 }, + { "color": "transparent", "pos": 1 } + ] + })) + .unwrap(); + position.note_glow_color = Some(NoteColor::Gradient { + top: "glow-top".to_string(), + bottom: "glow-bottom".to_string(), + }); + position.note_glow_opacity_top = Some(31); + position.note_glow_opacity_bottom = Some(69); + position.note_glow_gradient = serde_json::from_value(serde_json::json!({ + "angle": 135, + "stops": [ + { "color": "invalid", "pos": 0 }, + { "color": "#445566", "pos": 1 } + ] + })) + .unwrap(); + position.note_border_color = Some("#445566".to_string()); + position.note_border_gradient = serde_json::from_value(serde_json::json!({ + "angle": 90, + "stops": [ + { "color": "#112233", "pos": 0 }, + { "color": "transparent", "pos": 1 } + ] + })) + .unwrap(); + position.display_text = Some("preserved sibling".to_string()); + position.counter.font_size = 37; + let original = serde_json::to_vec_pretty(&data).unwrap(); + std::fs::write(&path, &original).unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + assert!(store.skip_asset_sweep); + let repaired = store.snapshot(); + let position = &repaired.key_positions["4key"][0]; + assert!(position.note_gradient.is_none()); + assert_eq!( + position.note_color, + NoteColor::Gradient { + top: "body-top".to_string(), + bottom: "body-bottom".to_string(), + } + ); + assert_eq!(position.note_opacity_top, Some(21)); + assert_eq!(position.note_opacity_bottom, Some(79)); + assert!(position.note_glow_gradient.is_none()); + assert_eq!( + position.note_glow_color, + Some(NoteColor::Gradient { + top: "glow-top".to_string(), + bottom: "glow-bottom".to_string(), + }) + ); + assert_eq!(position.note_glow_opacity_top, Some(31)); + assert_eq!(position.note_glow_opacity_bottom, Some(69)); + assert_eq!(position.note_border_color.as_deref(), Some("#445566")); + assert!(position.note_border_gradient.is_none()); + assert_eq!(position.display_text.as_deref(), Some("preserved sibling")); + assert_eq!(position.counter.font_size, 37); + assert_eq!(std::fs::read(dir.join("store.json.bak")).unwrap(), original); + + let on_disk: serde_json::Value = + serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert!(on_disk["keyPositions"]["4key"][0] + .get("noteGradient") + .is_none()); + assert!(on_disk["keyPositions"]["4key"][0] + .get("noteGlowGradient") + .is_none()); + assert!(on_disk["keyPositions"]["4key"][0] + .get("noteBorderGradient") + .is_none()); + store.flush_and_shutdown().unwrap(); + drop(store); + + let reloaded = super::load_store_from_path(&path).unwrap(); + assert!(!reloaded.needs_persist); + assert!(!reloaded.repaired); + assert_eq!( + reloaded.data.key_positions["4key"][0], + repaired.key_positions["4key"][0] + ); + + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn note_settings_1_2_four_field_store_preserves_speed_on_boot() { + let dir = test_directory("note-settings-1-2-test"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("store.json"); + let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); + // 1.2.0 시절 noteSettings 실형식 — 4필드뿐 + let legacy_note_settings = r#"{ + "borderRadius": 2, + "speed": 321, + "trackHeight": 150, + "reverse": false + }"#; + fixture.as_object_mut().unwrap().insert( + "noteSettings".to_string(), + serde_json::from_str(legacy_note_settings).unwrap(), + ); + std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let settings = store.snapshot().note_settings; + assert_eq!(settings.speed, 321); + assert_eq!(settings.track_height, 150); + assert_eq!(settings.fade_top_px, 50); + assert_eq!(settings.short_note_threshold_ms, 50); + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn backup_failure_keeps_damaged_store_untouched() { + let dir = test_directory("store-backup-failure-test"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("store.json"); + let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); + fixture.as_object_mut().unwrap().insert( + "alwaysOnTop".to_string(), + Value::String("invalid".to_string()), + ); + let original = serde_json::to_vec_pretty(&fixture).unwrap(); + std::fs::write(&path, &original).unwrap(); + std::fs::create_dir(dir.join("store.json.bak")).unwrap(); + + let result = AppStore::initialize_in_dir(&dir); + if let Ok(store) = result { + let _ = store.flush_and_shutdown(); + panic!("store initialization unexpectedly succeeded without a backup"); + } + assert_eq!(std::fs::read(path).unwrap(), original); + + let _ = std::fs::remove_dir_all(dir); +} + +fn defer_test_bounds(store: &AppStore, x: f64) { + store + .update_deferred(|state| { + state.overlay_bounds = Some(OverlayBounds { + x, + y: 20.0, + width: 800.0, + height: 300.0, + }); + state.overlay_bounds_are_logical = true; + }) + .unwrap(); +} + +#[test] +fn deferred_updates_coalesce_into_one_writer_persist() { + const UPDATE_COUNT: usize = 20; + + let dir = test_directory("store-deferred-coalesce-test"); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_persist_count = store.writer.persist_count(); + let initial_revision = store.state.read().revision; + let initial_disk = std::fs::read(dir.join("store.json")).unwrap(); + + for index in 0..UPDATE_COUNT { + defer_test_bounds(&store, index as f64); + } + + assert_eq!(store.writer.persist_count(), initial_persist_count); + assert_eq!(store.state.read().revision, initial_revision); + assert!(store.state.read().dirty); + assert_eq!(std::fs::read(dir.join("store.json")).unwrap(), initial_disk); + assert_eq!( + store.snapshot().overlay_bounds.unwrap().x, + (UPDATE_COUNT - 1) as f64 + ); + + store.flush().unwrap(); + assert_eq!(store.writer.persist_count(), initial_persist_count + 1); + assert_eq!(store.state.read().revision, initial_revision + 1); + assert!(!store.state.read().dirty); + let persisted: AppStoreData = + serde_json::from_slice(&std::fs::read(dir.join("store.json")).unwrap()).unwrap(); + assert_eq!( + persisted.overlay_bounds.unwrap().x, + (UPDATE_COUNT - 1) as f64 + ); + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn panel_bounds_persist_and_restore_across_store_reopen() { + let dir = test_directory("panel-bounds-persist-test"); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let bounds = PanelBounds { + x: 31.0, + y: 47.0, + height: 512.0, + }; + + store + .update_deferred(|state| state.panel_bounds = Some(bounds)) + .unwrap(); + store.flush_and_shutdown().unwrap(); + drop(store); + + let reopened = AppStore::initialize_in_dir(&dir).unwrap(); + assert_eq!(reopened.snapshot().panel_bounds, Some(bounds)); + reopened.flush_and_shutdown().unwrap(); + drop(reopened); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn panel_detached_persists_immediately_and_restores_across_store_reopen() { + let dir = test_directory("panel-detached-persist-test"); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + + // 프로덕션 경로와 같은 조합 - 락 안 deferred 기록 뒤 락 밖 flush. + // flush 직후 디스크에 있어야 강제 종료로 배치 선택이 날아가지 않는다 + store + .update_deferred(|state| state.panel_detached = true) + .unwrap(); + store.flush().unwrap(); + let persisted: AppStoreData = + serde_json::from_slice(&std::fs::read(dir.join("store.json")).unwrap()).unwrap(); + assert!(persisted.panel_detached); + + store.flush_and_shutdown().unwrap(); + drop(store); + + let reopened = AppStore::initialize_in_dir(&dir).unwrap(); + assert!(reopened.snapshot().panel_detached); + reopened.flush_and_shutdown().unwrap(); + drop(reopened); + let _ = std::fs::remove_dir_all(dir); +} + +// 필드가 없는 구버전 store는 도킹 상태로 폴백 +#[test] +fn panel_detached_missing_field_defaults_to_docked() { + let dir = test_directory("panel-detached-default-test"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("store.json"); + let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); + assert!(fixture + .as_object_mut() + .unwrap() + .remove("panelDetached") + .is_some()); + std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + assert!(!store.snapshot().panel_detached); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn regular_update_commits_pending_deferred_state() { + let dir = test_directory("store-deferred-regular-update-test"); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_persist_count = store.writer.persist_count(); + defer_test_bounds(&store, 321.0); + + store + .update(|state| { + state + .plugin_data + .insert("unrelated".to_string(), json!(true)); + }) + .unwrap(); + + assert!(!store.state.read().dirty); + assert_eq!(store.writer.persist_count(), initial_persist_count + 1); + let persisted: AppStoreData = + serde_json::from_slice(&std::fs::read(dir.join("store.json")).unwrap()).unwrap(); + assert_eq!(persisted.overlay_bounds.unwrap().x, 321.0); + assert_eq!(persisted.plugin_data["unrelated"], json!(true)); + + store.flush().unwrap(); + assert_eq!(store.writer.persist_count(), initial_persist_count + 1); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn failed_flush_keeps_deferred_state_dirty_and_committed_in_memory() { + let dir = test_directory("store-deferred-failure-test"); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_persist_count = store.writer.persist_count(); + let initial_revision = store.state.read().revision; + let initial_disk = std::fs::read(dir.join("store.json")).unwrap(); + defer_test_bounds(&store, 654.0); + store.writer.fail_next_persist(); + + assert!(store.flush().is_err()); + assert!(store.state.read().dirty); + assert_eq!(store.state.read().revision, initial_revision); + assert_eq!(store.snapshot().overlay_bounds.unwrap().x, 654.0); + assert_eq!(std::fs::read(dir.join("store.json")).unwrap(), initial_disk); + assert_eq!(store.writer.persist_count(), initial_persist_count + 1); + + store.flush().unwrap(); + assert!(!store.state.read().dirty); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn flush_without_dirty_state_is_idempotent() { + let dir = test_directory("store-deferred-idempotent-test"); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_persist_count = store.writer.persist_count(); + let initial_revision = store.state.read().revision; + let initial_disk = std::fs::read(dir.join("store.json")).unwrap(); + + store.flush().unwrap(); + store.flush().unwrap(); + + assert_eq!(store.writer.persist_count(), initial_persist_count); + assert_eq!(store.state.read().revision, initial_revision); + assert_eq!(std::fs::read(dir.join("store.json")).unwrap(), initial_disk); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn concurrent_updates_persist_all_revisions_without_replace_races() { + const UPDATE_COUNT: usize = 60; + + let dir = test_directory("store-concurrency-test"); + let store = Arc::new(AppStore::initialize_in_dir(&dir).unwrap()); + let initial_revision = store.state.read().revision; + let barrier = Arc::new(Barrier::new(UPDATE_COUNT + 1)); + let mut handles = Vec::with_capacity(UPDATE_COUNT); + + for _ in 0..UPDATE_COUNT { + let store = Arc::clone(&store); + let barrier = Arc::clone(&barrier); + handles.push(std::thread::spawn(move || { + barrier.wait(); + store.update(|state| { + let count = state + .plugin_data + .get("concurrent_update_count") + .and_then(Value::as_u64) + .unwrap_or(0); + state + .plugin_data + .insert("concurrent_update_count".to_string(), json!(count + 1)); + }) + })); + } + + barrier.wait(); + let mut failures = Vec::new(); + for handle in handles { + match handle.join() { + Ok(Ok(_)) => {} + Ok(Err(err)) => failures.push(format!("{err:#}")), + Err(_) => failures.push("update thread panicked".to_string()), + } + } + assert!(failures.is_empty(), "persist failures: {failures:?}"); + assert_eq!( + store.state.read().revision, + initial_revision + UPDATE_COUNT as u64 + ); + + let final_snapshot = store.snapshot(); + store.flush_and_shutdown().unwrap(); + assert!(store + .update(|state| state.always_on_top = !state.always_on_top) + .is_err()); + assert_eq!(store.snapshot(), final_snapshot); + let persisted: AppStoreData = + serde_json::from_slice(&std::fs::read(dir.join("store.json")).unwrap()).unwrap(); + assert_eq!( + persisted.plugin_data["concurrent_update_count"].as_u64(), + Some(UPDATE_COUNT as u64) + ); + assert_eq!(persisted, final_snapshot); + assert!(!std::fs::read_dir(&dir).unwrap().any(|entry| { + entry + .ok() + .and_then(|entry| entry.file_name().into_string().ok()) + .is_some_and(|name| name.ends_with(".tmp")) + })); + + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn writer_failure_keeps_snapshot_and_disk_unchanged_and_does_not_leak() { + let dir = test_directory("store-transaction-failure-test"); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let path = dir.join("store.json"); + let before_snapshot = store.snapshot(); + let before_disk = std::fs::read(&path).unwrap(); + let before_revision = store.state.read().revision; + + store.writer.fail_next_persist(); + let result = store.update(|state| { + state.always_on_top = !before_snapshot.always_on_top; + }); + + assert!(result.is_err()); + assert_eq!(store.snapshot(), before_snapshot); + assert_eq!( + store.with_state(|state| state.always_on_top), + before_snapshot.always_on_top + ); + assert_eq!(std::fs::read(&path).unwrap(), before_disk); + assert_eq!(store.state.read().revision, before_revision); + + store + .update(|state| { + state + .plugin_data + .insert("unrelated".to_string(), json!(true)); + }) + .unwrap(); + let after_snapshot = store.snapshot(); + let after_disk: AppStoreData = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(after_snapshot.always_on_top, before_snapshot.always_on_top); + assert_eq!(after_disk.always_on_top, before_snapshot.always_on_top); + assert_eq!(after_snapshot.plugin_data["unrelated"], json!(true)); + assert_eq!(after_disk, after_snapshot); + assert_eq!(store.state.read().revision, before_revision + 1); + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn counter_persist_failure_keeps_runtime_mirror_unchanged() { + let dir = test_directory("counter-transaction-failure-test"); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let snapshot = store.snapshot(); + let mode = snapshot.selected_key_type.clone(); + let key = snapshot.keys[&mode][0].canonical(); + let mut counters = snapshot.key_counters; + counters + .entry(mode.clone()) + .or_default() + .insert(key.clone(), 42); + store.set_key_counters(counters).unwrap(); + + let state = AppState::initialize(store).unwrap(); + let before_runtime = state.snapshot_key_counters(); + let before_store = state.store.snapshot(); + let before_disk = std::fs::read(dir.join("store.json")).unwrap(); + state.store.writer.fail_next_persist(); + let emitter = TestCounterEmitter::new(Arc::new(Mutex::new(Vec::new())), mode, key); + + assert!(state.reset_key_counters(&emitter, None).is_err()); + assert_eq!(state.snapshot_key_counters(), before_runtime); + assert_eq!(state.store.snapshot(), before_store); + assert_eq!(std::fs::read(dir.join("store.json")).unwrap(), before_disk); + + state.shutdown(); + drop(state); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn reset_snapshot_emit_precedes_waiting_increment_emit() { + let dir = test_directory("counter-mirror-race-test"); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let snapshot = store.snapshot(); + let mode = snapshot.selected_key_type.clone(); + let key = snapshot.keys[&mode][0].canonical(); + store + .update(|data| { + data.key_counter_enabled = true; + data.key_counters + .entry(mode.clone()) + .or_default() + .insert(key.clone(), 7); + }) + .unwrap(); + let state = Arc::new(AppState::initialize(store).unwrap()); + let events = Arc::new(Mutex::new(Vec::new())); + let (snapshot_emitted_tx, snapshot_emitted_rx) = mpsc::channel(); + let (release_snapshot_tx, release_snapshot_rx) = mpsc::channel(); + let reset_emitter = Arc::new(TestCounterEmitter::blocking_snapshot( + Arc::clone(&events), + mode.clone(), + key.clone(), + snapshot_emitted_tx, + release_snapshot_rx, + )); + let reset_state = Arc::clone(&state); + let reset_handle = + std::thread::spawn(move || reset_state.reset_key_counters(reset_emitter.as_ref(), None)); + snapshot_emitted_rx + .recv_timeout(Duration::from_secs(3)) + .unwrap(); + + let (increment_started_tx, increment_started_rx) = mpsc::channel(); + let (increment_done_tx, increment_done_rx) = mpsc::channel(); + let increment_state = Arc::clone(&state); + let increment_mode = mode.clone(); + let increment_key = key.clone(); + let increment_emitter = TestCounterEmitter::new( + Arc::clone(&events), + increment_mode.clone(), + increment_key.clone(), + ); + let increment_handle = std::thread::spawn(move || { + increment_started_tx.send(()).unwrap(); + let count = increment_state.increment_key_counter_and_emit( + &increment_emitter, + &increment_mode, + &increment_key, + ); + increment_done_tx.send(count).unwrap(); + }); + increment_started_rx.recv().unwrap(); + assert!(increment_done_rx + .recv_timeout(Duration::from_millis(50)) + .is_err()); + assert_eq!(*events.lock().unwrap(), vec!["snapshot:0"]); + + release_snapshot_tx.send(()).unwrap(); + let reset_snapshot = reset_handle.join().unwrap().unwrap(); + assert_eq!(reset_snapshot.counters[&mode][&key], 0); + assert_eq!( + increment_done_rx + .recv_timeout(Duration::from_secs(3)) + .unwrap(), + Some(1) + ); + increment_handle.join().unwrap(); + assert_eq!(*events.lock().unwrap(), vec!["snapshot:0", "counter:1"]); + assert_eq!(state.snapshot_key_counters()[&mode][&key], 1); + let bootstrap = state.bootstrap_payload(); + assert_eq!(bootstrap.key_counters[&mode][&key], 1); + assert!(!bootstrap.key_counters_session_id.is_empty()); + assert_eq!(bootstrap.key_counters_revision, 2); + + state.shutdown(); + drop(state); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn queued_increment_survives_same_generation_noop_key_publication() { + let dir = test_directory("counter-history-queue-test"); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let snapshot = store.snapshot(); + let mode = snapshot.selected_key_type.clone(); + let key = snapshot.keys[&mode][0].canonical(); + store + .update(|data| { + data.key_counter_enabled = true; + }) + .unwrap(); + let state = Arc::new(AppState::initialize(store).unwrap()); + let events = Arc::new(Mutex::new(Vec::new())); + let mutation_emitter = TestCounterEmitter::new(Arc::clone(&events), mode.clone(), key.clone()); + let mut changed_counters = state.snapshot_key_counters(); + changed_counters + .entry(mode.clone()) + .or_default() + .insert(key.clone(), 7); + let mutation = state + .replace_key_counters(&mutation_emitter, changed_counters, Some(0)) + .unwrap(); + assert_eq!(mutation.counters[&mode][&key], 7); + assert_eq!( + mutation.history_status.as_ref().unwrap().history_revision, + 1 + ); + drop(mutation); + events.lock().unwrap().clear(); + + let operation_id = uuid::Uuid::new_v4().to_string(); + let gate = state.store.history_gate(); + let barrier = gate.close(&operation_id).unwrap(); + state.begin_counter_history_barrier(); + let mut counter_guard = state.lock_key_counters_for_history(); + let current_counters = counter_guard.clone(); + let undo = state + .store + .apply_history_operation( + HistoryDirection::Undo, + &operation_id, + ¤t_counters, + || {}, + ) + .unwrap(); + let Some(HistoryAuxChange::Counters(restored)) = undo.aux_change.as_ref() else { + panic!("counter history change expected"); + }; + state.replace_history_counters_locked( + &mut counter_guard, + undo.runtime_publication_generation, + restored, + ); + assert_eq!(counter_guard[&mode][&key], 0); + + let increment_state = Arc::clone(&state); + let increment_mode = mode.clone(); + let increment_key = key.clone(); + let increment_events = Arc::clone(&events); + let (increment_done_tx, increment_done_rx) = mpsc::channel(); + let increment = thread::spawn(move || { + let emitter = TestCounterEmitter::new( + increment_events, + increment_mode.clone(), + increment_key.clone(), + ); + let count = increment_state.increment_key_counter_and_emit( + &emitter, + &increment_mode, + &increment_key, + ); + increment_done_tx.send(count).unwrap(); + }); + assert_eq!( + increment_done_rx + .recv_timeout(Duration::from_secs(2)) + .unwrap(), + None + ); + increment.join().unwrap(); + assert!(events.lock().unwrap().is_empty()); + + let history_emitter = TestCounterEmitter::new(Arc::clone(&events), mode.clone(), key.clone()); + state.finish_counter_history_barrier( + &history_emitter, + counter_guard, + true, + undo.runtime_publication_generation, + ); + drop(barrier); + + assert_eq!(state.snapshot_key_counters()[&mode][&key], 1); + assert_eq!(state.store.snapshot().key_counters[&mode][&key], 0); + assert_eq!(*events.lock().unwrap(), vec!["snapshot:1"]); + assert_eq!(undo.status.history_epoch, 1); + assert!(undo.status.can_redo); + + let noop = state + .store + .commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("same_generation_noop_key_publication".to_string()), + &[EditorField::Keys], + |_| Ok(()), + ) + .unwrap(); + assert!(noop.change.result.changed_fields.is_empty()); + assert_eq!( + noop.change.runtime_publication_generation, + undo.runtime_publication_generation + ); + assert_eq!(noop.change.key_counters[&mode][&key], 0); + state + .apply_committed_editor_key_runtime( + &history_emitter, + noop.change.runtime_publication_generation, + &noop.change.document.keys, + &noop.change.selected_key_type, + &noop.change.key_counters, + ) + .unwrap(); + state.apply_committed_editor_keys_without_counters( + noop.change.runtime_publication_generation, + &noop.change.document.keys, + &noop.change.selected_key_type, + ); + drop(noop); + + assert_eq!(state.snapshot_key_counters()[&mode][&key], 1); + assert_eq!(*events.lock().unwrap(), vec!["snapshot:1"]); + + state.shutdown(); + drop(state); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn existing_obs_token_is_reused_without_persist() { + let dir = test_directory("obs-token-reuse-test"); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let token = "persisted-obs-token".to_string(); + store + .update(|state| { + state.obs_token = Some(token.clone()); + }) + .unwrap(); + let state = AppState::initialize(store).unwrap(); + let persist_count_before = state.store.writer.persist_count(); + let disk_before = std::fs::read(dir.join("store.json")).unwrap(); + + assert_eq!(state.resolve_and_save_obs_token().unwrap(), token); + assert_eq!(state.store.writer.persist_count(), persist_count_before); + assert_eq!(std::fs::read(dir.join("store.json")).unwrap(), disk_before); + + state.shutdown(); + drop(state); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn asset_references_include_every_position_kind() { + let mut data = AppStoreData::default(); + let base_position = default_positions()["4key"][0].clone(); + let root = std::env::temp_dir().join("dmnote-position-assets"); + + let next_position = |kind: &str| { + let mut position = base_position.clone(); + position.active_image = Some( + root.join(format!("{kind}.png")) + .to_string_lossy() + .to_string(), + ); + position.sound_path = Some( + root.join(format!("{kind}.wav")) + .to_string_lossy() + .to_string(), + ); + position + }; + + data.key_positions + .insert("mode".to_string(), vec![next_position("key")]); + data.stat_positions.insert( + "mode".to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: next_position("stat"), + }], + ); + data.graph_positions.insert( + "mode".to_string(), + vec![GraphPosition { + stat_type: GraphStatType::Kps, + graph_type: GraphType::Line, + graph_speed: 1, + graph_color: "#FFFFFF".to_string(), + show_avg_line: true, + position: next_position("graph"), + }], + ); + data.knob_positions.insert( + "mode".to_string(), + vec![KnobPosition { + axis_id: String::new(), + sensitivity: 1.0, + reverse: false, + position: next_position("knob"), + }], + ); + + let image_paths = collect_local_image_path_keys(&root, &data); + let sound_paths = collect_local_sound_path_keys(&root, &data); + for kind in ["key", "stat", "graph", "knob"] { + assert!(image_paths.contains(&path_identity_key(&root.join(format!("{kind}.png"))))); + assert!(sound_paths.contains(&path_identity_key(&root.join(format!("{kind}.wav"))))); + } +} + +#[test] +fn plugin_managed_assets_remain_until_plugin_data_releases_them() { + let dir = test_directory("plugin-managed-assets-sweep-test"); + let image_path = dir.join("images").join("plugin-owned.png"); + let font_path = dir.join("fonts").join("plugin-owned.ttf"); + for path in [&image_path, &font_path] { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, b"plugin-asset").unwrap(); + } + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let plugin_key = "plugin_data_demo-plugin/settings".to_string(); + store + .update(|data| { + data.plugin_data.insert( + plugin_key.clone(), + json!({ + "nested": { + "imagePath": image_path.to_string_lossy(), + "fontPaths": [font_path.to_string_lossy()] + } + }), + ); + }) + .unwrap(); + + store.flush_cleanup_and_shutdown().unwrap(); + drop(store); + + assert!(image_path.exists()); + assert!(font_path.exists()); + assert!(!dir.join("trash").exists()); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + store + .update(|data| { + data.plugin_data.remove(&plugin_key); + }) + .unwrap(); + store.flush_cleanup_and_shutdown().unwrap(); + drop(store); + + assert!(!image_path.exists()); + assert!(!font_path.exists()); + let trash_sessions = std::fs::read_dir(dir.join("trash")).unwrap(); + let trashed = trash_sessions + .map(|entry| entry.unwrap().path()) + .any(|session| { + session.join("images").join("plugin-owned.png").exists() + && session.join("fonts").join("plugin-owned.ttf").exists() + }); + assert!(trashed); + + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn referenced_v1_gif_cache_remains_a_direct_image_asset() { + let dir = test_directory("gif-v1-direct-reference-test"); + let images = dir.join("images"); + std::fs::create_dir_all(&images).unwrap(); + let hash = "a".repeat(64); + let cache = images.join(format!("gif-cache-{hash}.webp")); + std::fs::write(&cache, b"lossy-v1").unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + store + .commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("gif_v1_direct_test".to_string()), + &[EditorField::KeyPositions], + |data| { + data.key_positions.get_mut("4key").unwrap()[0].active_image = + Some(cache.to_string_lossy().to_string()); + Ok(()) + }, + ) + .unwrap(); + store.cleanup_orphan_assets_now().unwrap(); + assert!(cache.exists()); + assert_eq!( + store.snapshot().key_positions["4key"][0] + .active_image + .as_deref(), + Some(cache.to_string_lossy().as_ref()) + ); + + store + .commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("gif_v1_direct_test".to_string()), + &[EditorField::KeyPositions], + |data| { + data.key_positions.get_mut("4key").unwrap()[0].active_image = None; + Ok(()) + }, + ) + .unwrap(); + store.cleanup_orphan_assets_now().unwrap(); + assert!(!cache.exists()); + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn asset_references_preserve_the_leading_slash_in_file_urls() { + let mut data = AppStoreData::default(); + let mut position = default_positions()["4key"][0].clone(); + position.active_image = Some("file:///tmp/dmnote-file-url.png".to_string()); + position.sound_path = Some("file:///tmp/dmnote-file-url.wav".to_string()); + data.key_positions + .insert("4key".to_string(), vec![position]); + + let classify_root = Path::new("/nonexistent-app-data"); + assert!(collect_local_image_path_keys(classify_root, &data) + .contains(&path_identity_key(Path::new("/tmp/dmnote-file-url.png")))); + assert!(collect_local_sound_path_keys(classify_root, &data) + .contains(&path_identity_key(Path::new("/tmp/dmnote-file-url.wav")))); +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn percent_encoded_file_url_keeps_referenced_image_out_of_trash() { + let dir = test_directory("percent-encoded-image-sweep-test"); + let image_path = dir.join("images").join("referenced image.png"); + std::fs::create_dir_all(image_path.parent().unwrap()).unwrap(); + std::fs::write(&image_path, b"image").unwrap(); + let image_url = url::Url::from_file_path(&image_path).unwrap().to_string(); + assert!(image_url.contains("%20")); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let mut positions = store.snapshot().key_positions; + positions.get_mut("4key").unwrap()[0].active_image = Some(image_url); + legacy_editor_commit(&store, &[EditorField::KeyPositions], move |data| { + data.key_positions = positions; + }) + .unwrap(); + + store.cleanup_orphan_assets_now().unwrap(); + + assert!(image_path.exists()); + assert!(!dir.join("trash").exists()); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn invalid_file_url_skips_destructive_image_sweep() { + let dir = test_directory("invalid-file-url-sweep-test"); + let image_path = dir.join("images").join("unreferenced.png"); + std::fs::create_dir_all(image_path.parent().unwrap()).unwrap(); + std::fs::write(&image_path, b"image").unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let mut positions = store.snapshot().key_positions; + positions.get_mut("4key").unwrap()[0].active_image = + Some("file:///tmp/broken%ZZ.png".to_string()); + legacy_editor_commit(&store, &[EditorField::KeyPositions], move |data| { + data.key_positions = positions; + }) + .unwrap(); + + store.cleanup_orphan_assets_now().unwrap(); + + assert!(image_path.exists()); + assert!(!dir.join("trash").exists()); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn invalid_file_url_skips_destructive_font_sweep() { + let dir = test_directory("invalid-font-file-url-sweep-test"); + let font_path = dir.join("fonts").join("unreferenced.ttf"); + std::fs::create_dir_all(font_path.parent().unwrap()).unwrap(); + std::fs::write(&font_path, b"font").unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + store + .update(|data| { + data.font_settings.custom_fonts.push(CustomFont { + id: "invalid-url-font".to_string(), + font_type: FontType::Local, + name: "Invalid URL Font".to_string(), + display_name: "Invalid URL Font".to_string(), + enabled: true, + local_path: Some("file:///tmp/broken%ZZ.ttf".to_string()), + css_content: None, + weight_ranges: Vec::new(), + }); + }) + .unwrap(); + + store.cleanup_orphan_assets_now().unwrap(); + + assert!(font_path.exists()); + assert!(!dir.join("trash").exists()); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn unresolved_path_like_values_skip_destructive_asset_sweeps() { + let dir = test_directory("unresolved-local-path-sweep-test"); + let image_path = dir.join("images").join("unreferenced.png"); + let sound_path = dir.join("sounds").join("unreferenced.wav"); + for path in [&image_path, &sound_path] { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, b"asset").unwrap(); + } + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let mut positions = store.snapshot().key_positions; + let position = &mut positions.get_mut("4key").unwrap()[0]; + position.active_image = Some(r"\unresolved-image.png".to_string()); + position.sound_path = Some("C:unresolved-sound.wav".to_string()); + legacy_editor_commit(&store, &[EditorField::KeyPositions], move |data| { + data.key_positions = positions; + }) + .unwrap(); + + store.cleanup_orphan_assets_now().unwrap(); + + assert!(image_path.exists()); + assert!(sound_path.exists()); + assert!(!dir.join("trash").exists()); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn unresolved_asset_reference_counts_are_grouped_by_category() { + let mut data = AppStoreData::default(); + data.font_settings.custom_fonts.push(CustomFont { + id: "unresolved-font".to_string(), + font_type: FontType::Local, + name: "Unresolved Font".to_string(), + display_name: "Unresolved Font".to_string(), + enabled: true, + local_path: Some("C:unresolved-font.ttf".to_string()), + css_content: None, + weight_ranges: Vec::new(), + }); + data.key_positions.insert( + "unresolved-mode".to_string(), + vec![KeyPosition { + active_image: Some(r"\unresolved-image.png".to_string()), + sound_path: Some("C:unresolved-sound.wav".to_string()), + ..KeyPosition::default() + }], + ); + data.sound_library.insert( + r"\unresolved-library-sound.wav".to_string(), + SoundLibraryEntry::default(), + ); + + let classify_root = Path::new("/nonexistent-app-data"); + let fonts = collect_local_font_paths(classify_root, &data); + let images = collect_local_image_paths(classify_root, &data); + let sounds = collect_local_sound_paths(classify_root, &data); + + assert!(!fonts.complete); + assert!(!images.complete); + assert!(!sounds.complete); + assert_eq!(fonts.unresolved_count, 1); + assert_eq!(images.unresolved_count, 1); + assert_eq!(sounds.unresolved_count, 2); +} + +#[test] +fn missing_foreign_file_url_reference_marks_sweep_unresolved() { + let dir = test_directory("foreign-file-url-unresolved-test"); + std::fs::create_dir_all(dir.join("images")).unwrap(); + let mut data = AppStoreData { + key_positions: default_positions().clone(), + ..AppStoreData::default() + }; + data.key_positions.get_mut("4key").unwrap()[0].active_image = Some( + "file:///C:/Users/me/AppData/Roaming/com.dmnote.desktop/images/missing.png".to_string(), + ); + + // 외래 file URL이 이 기기에서 실존하지 않으면 sweep을 보류시켜야 함 + assert_eq!(collect_local_image_paths(&dir, &data).unresolved_count, 1); + + // 현재 기기 appData의 단순 누락 참조는 기존대로 보류 대상이 아님 + data.key_positions.get_mut("4key").unwrap()[0].inactive_image = Some( + dir.join("images") + .join("gone.png") + .to_string_lossy() + .into_owned(), + ); + assert_eq!(collect_local_image_paths(&dir, &data).unresolved_count, 1); + + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn missing_foreign_file_url_holds_sweep_and_keeps_unreferenced_image() { + let dir = test_directory("foreign-file-url-sweep-hold-test"); + let kept_image = dir.join("images").join("kept.png"); + std::fs::create_dir_all(kept_image.parent().unwrap()).unwrap(); + std::fs::write(&kept_image, b"image").unwrap(); + + let mut data = AppStoreData { + keys: crate::defaults::default_keys().clone(), + key_positions: default_positions().clone(), + ..AppStoreData::default() + }; + data.key_positions.get_mut("4key").unwrap()[0].active_image = Some( + "file:///C:/Users/me/AppData/Roaming/com.dmnote.desktop/images/missing.png".to_string(), + ); + std::fs::write( + dir.join("store.json"), + serde_json::to_vec_pretty(&data).unwrap(), + ) + .unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + store.cleanup_orphan_assets_now().unwrap(); + + // 미해석 외래 참조가 있으면 sweep 보류 — 미참조 로컬 이미지도 격리 금지 + assert!(kept_image.exists()); + assert!(!dir.join("trash").exists()); + + store.flush_cleanup_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn foreign_asset_references_rehome_before_persist_and_sweep() { + let dir = test_directory("foreign-asset-rehome-integration-test"); + let font_path = dir.join("fonts").join("portable.ttf"); + let image_path = dir.join("images").join("portable.png"); + let sound_path = dir.join("sounds").join("portable.wav"); + for path in [&font_path, &image_path, &sound_path] { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, b"portable-asset").unwrap(); + } + + let foreign_root = r"C:\Users\Exporter\AppData\Roaming\com.dmnote.desktop"; + let foreign_font = format!(r"{foreign_root}\fonts\portable.ttf"); + let foreign_image = format!(r"{foreign_root}\images\portable.png"); + let foreign_sound = format!(r"{foreign_root}\sounds\portable.wav"); + let mut data = AppStoreData { + keys: crate::defaults::default_keys().clone(), + key_positions: default_positions().clone(), + ..AppStoreData::default() + }; + let position = &mut data.key_positions.get_mut("4key").unwrap()[0]; + position.active_image = Some(foreign_image.clone()); + position.sound_path = Some(foreign_sound.clone()); + data.font_settings.custom_fonts.push(CustomFont { + id: "portable-font".to_string(), + font_type: FontType::Local, + name: "Portable Font".to_string(), + display_name: "Portable Font".to_string(), + enabled: true, + local_path: Some(foreign_font.clone()), + css_content: None, + weight_ranges: Vec::new(), + }); + data.sound_library.insert( + foreign_sound.clone(), + SoundLibraryEntry { + display_name: Some("Portable sound".to_string()), + ..SoundLibraryEntry::default() + }, + ); + + let original = serde_json::to_vec_pretty(&data).unwrap(); + let store_path = dir.join("store.json"); + std::fs::write(&store_path, &original).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let snapshot = store.snapshot(); + let local_font = font_path.to_string_lossy().into_owned(); + let local_image = image_path.to_string_lossy().into_owned(); + let local_sound = sound_path.to_string_lossy().into_owned(); + + assert_eq!( + snapshot.font_settings.custom_fonts[0].local_path.as_deref(), + Some(local_font.as_str()) + ); + // 이전된 폰트는 경로 재귀화와 함께 활성 상태도 유지되어야 함 + assert!(snapshot.font_settings.custom_fonts[0].enabled); + assert_eq!( + snapshot.key_positions["4key"][0].active_image.as_deref(), + Some(local_image.as_str()) + ); + assert_eq!( + snapshot.key_positions["4key"][0].sound_path.as_deref(), + Some(local_sound.as_str()) + ); + assert!(snapshot.sound_library.contains_key(&local_sound)); + assert!(!snapshot.sound_library.contains_key(&foreign_sound)); + assert_eq!( + collect_local_font_paths(&dir, &snapshot).unresolved_count, + 0 + ); + assert_eq!( + collect_local_image_paths(&dir, &snapshot).unresolved_count, + 0 + ); + assert_eq!( + collect_local_sound_paths(&dir, &snapshot).unresolved_count, + 0 + ); + assert_eq!( + std::fs::read(dir.join("store.json.pre-migration.bak")).unwrap(), + original + ); + + store.cleanup_orphan_assets_now().unwrap(); + assert!(font_path.exists()); + assert!(image_path.exists()); + assert!(sound_path.exists()); + store.flush_cleanup_and_shutdown().unwrap(); + drop(store); + + let reloaded = crate::state::migration::load_store_from_path(&store_path).unwrap(); + assert!(!reloaded.needs_persist); + assert_eq!( + collect_local_font_paths(&dir, &reloaded.data).unresolved_count, + 0 + ); + assert_eq!( + collect_local_image_paths(&dir, &reloaded.data).unresolved_count, + 0 + ); + assert_eq!( + collect_local_sound_paths(&dir, &reloaded.data).unresolved_count, + 0 + ); + + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn tauri_1_6_1_literal_fixture_preserves_collections_and_assets_through_shutdown() { + let dir = test_directory("tauri-1-6-1-store-fixture-test"); + let image_path = dir.join("images").join("unbound-knob.png"); + let sound_path = dir.join("sounds").join("legacy-sound.wav"); + for path in [&image_path, &sound_path] { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, b"asset-fixture").unwrap(); + } + + #[cfg(target_os = "windows")] + let stored_sound_path = { + let raw = sound_path.to_string_lossy(); + if let Some(rest) = raw.strip_prefix(r"\\?\UNC\") { + format!(r"\\{rest}") + } else { + raw.strip_prefix(r"\\?\").unwrap_or(&raw).to_string() + } + }; + #[cfg(not(target_os = "windows"))] + let stored_sound_path = + r"C:\Users\DmNote\AppData\Roaming\com.dmnote\sounds\legacy-sound.wav".to_string(); + + let tab_id = "legacy-custom-tab"; + let fixture = r##"{ + "hardwareAcceleration": true, + "alwaysOnTop": true, + "overlayLocked": false, + "overlayVisible": false, + "noteEffect": true, + "noteSettings": { + "frameLimit": 0, + "speed": 180, + "trackHeight": 150, + "reverse": false, + "fadePosition": "auto", + "fadeTopPx": 50, + "fadeBottomPx": 0, + "reverseFadeTopPx": 0, + "reverseFadeBottomPx": 50, + "delayedNoteEnabled": false, + "shortNoteThresholdMs": 50, + "shortNoteMinLengthPx": 30, + "keyDisplayDelayMs": 0 + }, + "selectedKeyType": "legacy-custom-tab", + "customTabs": [{ "id": "legacy-custom-tab", "name": "Legacy custom tab" }], + "angleMode": "d3d11", + "language": "ko", + "laboratoryEnabled": false, + "developerModeEnabled": false, + "trayEnabled": false, + "autoUpdateEnabled": true, + "mainWindowHidden": false, + "keys": { "legacy-custom-tab": ["F5"] }, + "keyPositions": { + "legacy-custom-tab": [{ + "dx": 10.0, + "dy": 20.0, + "width": 60.0, + "height": 60.0, + "activeImage": "", + "inactiveImage": "", + "count": 0, + "noteColor": "#FFFFFF", + "noteOpacity": 80 + }] + }, + "statPositions": { + "legacy-custom-tab": [{ + "statType": "kps", + "dx": 80.0, + "dy": 20.0, + "width": 80.0, + "height": 40.0, + "activeImage": "", + "inactiveImage": "", + "count": 0, + "noteColor": "#FFFFFF", + "noteOpacity": 80 + }] + }, + "graphPositions": { + "legacy-custom-tab": [{ + "statType": "kpsAvg", + "graphType": "line", + "graphSpeed": 1000, + "graphColor": "#24BBB4", + "showAvgLine": true, + "dx": 170.0, + "dy": 20.0, + "width": 160.0, + "height": 80.0, + "activeImage": "", + "inactiveImage": "", + "count": 0, + "noteColor": "#FFFFFF", + "noteOpacity": 80 + }] + }, + "knobPositions": { + "legacy-custom-tab": [{ + "axisId": "", + "sensitivity": 1.40625, + "reverse": false, + "dx": 340.0, + "dy": 20.0, + "width": 80.0, + "height": 80.0, + "activeImage": __IMAGE_PATH_JSON__, + "inactiveImage": "", + "count": 0, + "noteColor": "#FFFFFF", + "noteOpacity": 80 + }] + }, + "layerGroups": { + "legacy-custom-tab": [{ "id": "legacy-group", "name": "Legacy group" }] + }, + "keyCounters": { "legacy-custom-tab": { "F5": 3 } }, + "backgroundColor": "transparent", + "useCustomCss": false, + "customCss": { "path": null, "content": "" }, + "fontSettings": { "customFonts": [] }, + "counterAnimationPresets": [], + "tabCssOverrides": { + "legacy-custom-tab": { "path": null, "content": "", "enabled": true } + }, + "tabNoteOverrides": { + "legacy-custom-tab": { "speed": 200 } + }, + "useCustomJs": false, + "customJs": { "path": null, "content": "", "plugins": [] }, + "overlayResizeAnchor": "top-left", + "overlayBounds": null, + "overlayLastContentTopOffset": null, + "overlayBoundsAreLogical": false, + "keyCounterEnabled": true, + "gridSettings": { + "alignmentGuides": true, + "spacingGuides": true, + "sizeMatchGuides": true, + "minimapEnabled": true, + "gridSnapSize": 5, + "overlayPadding": 30 + }, + "shortcuts": { + "toggleOverlay": { "key": "KeyO", "ctrl": true, "shift": true, "alt": false, "meta": false }, + "toggleOverlayLock": { "key": "", "ctrl": false, "shift": false, "alt": false, "meta": false }, + "toggleAlwaysOnTop": { "key": "", "ctrl": false, "shift": false, "alt": false, "meta": false }, + "switchKeyMode": { "key": "Tab", "ctrl": false, "shift": false, "alt": false, "meta": false }, + "toggleSettingsPanel": { "key": "KeyB", "ctrl": true, "shift": false, "alt": false, "meta": false }, + "zoomIn": { "key": "Equal", "ctrl": true, "shift": false, "alt": false, "meta": false }, + "zoomOut": { "key": "Minus", "ctrl": true, "shift": false, "alt": false, "meta": false }, + "resetZoom": { "key": "Digit0", "ctrl": true, "shift": false, "alt": false, "meta": false } + }, + "soundLibrary": { + __SOUND_PATH_JSON__: { + "enabled": true, + "source": "local", + "originalPath": null, + "trimStartRatio": null, + "trimEndRatio": null, + "displayName": "Legacy sound" + } + }, + "keySoundOutputBackend": null, + "obsModeEnabled": false, + "obsPort": 34891, + "obsToken": null +}"## + .replace( + "__IMAGE_PATH_JSON__", + &serde_json::to_string(&image_path.to_string_lossy()).unwrap(), + ) + .replace( + "__SOUND_PATH_JSON__", + &serde_json::to_string(&stored_sound_path).unwrap(), + ); + let store_path = dir.join("store.json"); + std::fs::write(&store_path, fixture.as_bytes()).unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + assert!(!store.skip_asset_sweep); + let snapshot = store.snapshot(); + assert_eq!(snapshot.custom_tabs.len(), 1); + assert_eq!(snapshot.keys[tab_id], vec![KeySlot::from("F5")]); + assert_eq!(snapshot.key_positions[tab_id].len(), 1); + assert_eq!(snapshot.stat_positions[tab_id].len(), 1); + assert_eq!(snapshot.graph_positions[tab_id].len(), 1); + assert_eq!(snapshot.knob_positions[tab_id].len(), 1); + assert!(snapshot.knob_positions[tab_id][0].axis_id.is_empty()); + assert_eq!(snapshot.knob_positions[tab_id][0].sensitivity, 1.0); + assert_eq!(snapshot.layer_groups[tab_id].len(), 1); + assert_eq!(snapshot.key_counters[tab_id]["F5"], 3); + assert!(snapshot.tab_css_overrides.contains_key(tab_id)); + assert!(snapshot.tab_note_overrides.contains_key(tab_id)); + assert!(snapshot.sound_library.contains_key(&stored_sound_path)); + assert!(!snapshot.sound_library[&stored_sound_path].hidden); + assert!(!dir.join("store.json.bak").exists()); + assert_eq!( + std::fs::read(dir.join("store.json.pre-migration.bak")).unwrap(), + fixture.as_bytes() + ); + + store.flush_cleanup_and_shutdown().unwrap(); + drop(store); + + assert!(image_path.exists()); + assert!(sound_path.exists()); + assert!(!dir.join("store.json.bak").exists()); + let reloaded = crate::state::migration::load_store_from_path(&store_path).unwrap(); + assert!(!reloaded.repaired); + assert_eq!(reloaded.data.custom_tabs.len(), 1); + assert_eq!(reloaded.data.keys[tab_id], vec![KeySlot::from("F5")]); + assert_eq!(reloaded.data.key_positions[tab_id].len(), 1); + assert_eq!(reloaded.data.stat_positions[tab_id].len(), 1); + assert_eq!(reloaded.data.graph_positions[tab_id].len(), 1); + assert_eq!(reloaded.data.knob_positions[tab_id].len(), 1); + assert!(reloaded.data.knob_positions[tab_id][0].axis_id.is_empty()); + assert_eq!(reloaded.data.layer_groups[tab_id].len(), 1); + assert!(reloaded.data.sound_library.contains_key(&stored_sound_path)); + + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn pre_migration_backup_preserves_first_original_across_rewrites() { + let dir = test_directory("pre-migration-backup-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store_path = dir.join("store.json"); + let backup_path = dir.join("store.json.pre-migration.bak"); + let first_original = br##"{ + "hardwareAcceleration": true, + "alwaysOnTop": true, + "overlayLocked": false, + "noteEffect": false, + "selectedKeyType": "4key", + "angleMode": "d3d11", + "language": "ko", + "laboratoryEnabled": false, + "backgroundColor": "transparent", + "useCustomCss": false, + "overlayResizeAnchor": "top-left", + "overlayBounds": null, + "overlayLastContentTopOffset": null, + "soundLibrary": { + "C:\\legacy\\first.wav": { "enabled": true, "source": "local" } + } +}"##; + std::fs::write(&store_path, first_original).unwrap(); + + let first = AppStore::initialize_in_dir(&dir).unwrap(); + assert_eq!(std::fs::read(&backup_path).unwrap(), first_original); + assert!(!dir.join("store.json.bak").exists()); + first.flush_cleanup_and_shutdown().unwrap(); + drop(first); + assert_eq!(std::fs::read(&backup_path).unwrap(), first_original); + + let mut second_input: Value = + serde_json::from_slice(&std::fs::read(&store_path).unwrap()).unwrap(); + second_input["soundLibrary"] + .as_object_mut() + .unwrap() + .insert( + r"C:\legacy\second.wav".to_string(), + json!({ "enabled": true, "source": "local" }), + ); + std::fs::write( + &store_path, + serde_json::to_vec_pretty(&second_input).unwrap(), + ) + .unwrap(); + + let second = AppStore::initialize_in_dir(&dir).unwrap(); + assert_eq!(std::fs::read(&backup_path).unwrap(), first_original); + assert!(!dir.join("store.json.bak").exists()); + second.flush_cleanup_and_shutdown().unwrap(); + drop(second); + assert_eq!(std::fs::read(&backup_path).unwrap(), first_original); + + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn invalid_pre_migration_backup_path_aborts_before_store_rewrite() { + let dir = test_directory("pre-migration-backup-path-failure-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store_path = dir.join("store.json"); + let original = br#"{ + "hardwareAcceleration": true, + "alwaysOnTop": true, + "overlayLocked": false, + "noteEffect": false, + "selectedKeyType": "4key", + "angleMode": "d3d11", + "language": "ko", + "laboratoryEnabled": false, + "backgroundColor": "transparent", + "useCustomCss": false, + "overlayResizeAnchor": "top-left", + "overlayBounds": null, + "overlayLastContentTopOffset": null, + "soundLibrary": { + "C:\\legacy\\first.wav": { "enabled": true, "source": "local" } + } +}"#; + std::fs::write(&store_path, original).unwrap(); + std::fs::create_dir(dir.join("store.json.pre-migration.bak")).unwrap(); + + let result = AppStore::initialize_in_dir(&dir); + + if let Ok(store) = result { + let _ = store.flush_and_shutdown(); + panic!("store initialization unexpectedly rewrote data without a valid backup"); + } + assert_eq!(std::fs::read(&store_path).unwrap(), original); + + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn malformed_existing_pre_migration_backup_aborts_before_store_rewrite() { + let dir = test_directory("pre-migration-backup-content-failure-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store_path = dir.join("store.json"); + let original = br#"{ + "hardwareAcceleration": true, + "alwaysOnTop": true, + "overlayLocked": false, + "noteEffect": false, + "selectedKeyType": "4key", + "angleMode": "d3d11", + "language": "ko", + "laboratoryEnabled": false, + "backgroundColor": "transparent", + "useCustomCss": false, + "overlayResizeAnchor": "top-left", + "overlayBounds": null, + "overlayLastContentTopOffset": null, + "soundLibrary": { + "C:\\legacy\\first.wav": { "enabled": true, "source": "local" } + } +}"#; + std::fs::write(&store_path, original).unwrap(); + std::fs::write(dir.join("store.json.pre-migration.bak"), b"not-json").unwrap(); + + let result = AppStore::initialize_in_dir(&dir); + + if let Ok(store) = result { + let _ = store.flush_and_shutdown(); + panic!("store initialization unexpectedly rewrote data without a valid backup"); + } + assert_eq!(std::fs::read(&store_path).unwrap(), original); + + let _ = std::fs::remove_dir_all(dir); +} + +#[cfg(unix)] +#[test] +fn symlink_pre_migration_backup_aborts_before_store_rewrite() { + use std::os::unix::fs::symlink; + + let dir = test_directory("pre-migration-backup-symlink-failure-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store_path = dir.join("store.json"); + let original = br#"{ + "hardwareAcceleration": true, + "alwaysOnTop": true, + "overlayLocked": false, + "noteEffect": false, + "selectedKeyType": "4key", + "angleMode": "d3d11", + "language": "ko", + "laboratoryEnabled": false, + "backgroundColor": "transparent", + "useCustomCss": false, + "overlayResizeAnchor": "top-left", + "overlayBounds": null, + "overlayLastContentTopOffset": null, + "soundLibrary": { + "C:\\legacy\\first.wav": { "enabled": true, "source": "local" } + } +}"#; + std::fs::write(&store_path, original).unwrap(); + let target = dir.join("unrelated.json"); + std::fs::write(&target, b"{}").unwrap(); + symlink(&target, dir.join("store.json.pre-migration.bak")).unwrap(); + + let result = AppStore::initialize_in_dir(&dir); + + if let Ok(store) = result { + let _ = store.flush_and_shutdown(); + panic!("store initialization unexpectedly followed a backup symlink"); + } + assert_eq!(std::fs::read(&store_path).unwrap(), original); + assert_eq!(std::fs::read(&target).unwrap(), b"{}"); + + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn interrupted_sound_deletion_restores_files_still_referenced_by_store() { + let dir = test_directory("sound-delete-startup-restore-test"); + let sounds_dir = dir.join("sounds"); + let originals_dir = sounds_dir.join("originals"); + std::fs::create_dir_all(&originals_dir).unwrap(); + let sound_path = sounds_dir.join("sound.wav"); + let original_path = originals_dir.join("original.wav"); + std::fs::write(&sound_path, b"sound").unwrap(); + std::fs::write(&original_path, b"original").unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let sound_key = sound_path.to_string_lossy().to_string(); + store + .update(|data| { + data.sound_library.insert( + sound_key.clone(), + SoundLibraryEntry { + original_path: Some("originals/original.wav".to_string()), + ..Default::default() + }, + ); + }) + .unwrap(); + let staged = + stage_sound_files_for_deletion(&[sound_path.clone(), original_path.clone()]).unwrap(); + assert_eq!(staged.len(), 2); + assert!(!sound_path.exists()); + assert!(!original_path.exists()); + + store + .recover_interrupted_processed_wav_replacements_now() + .unwrap(); + + assert_eq!(std::fs::read(&sound_path).unwrap(), b"sound"); + assert_eq!(std::fs::read(&original_path).unwrap(), b"original"); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn interrupted_sound_deletion_restore_failure_is_reported_incomplete() { + let dir = test_directory("sound-delete-incomplete-recovery-test"); + let sounds_dir = dir.join("sounds"); + std::fs::create_dir_all(&sounds_dir).unwrap(); + let sound_path = sounds_dir.join("sound.wav"); + std::fs::write(&sound_path, b"sound").unwrap(); + + let mut data = AppStoreData::default(); + data.sound_library.insert( + sound_path.to_string_lossy().to_string(), + SoundLibraryEntry::default(), + ); + let staged = stage_sound_files_for_deletion(std::slice::from_ref(&sound_path)).unwrap(); + let backup_path = staged[0].backup_path.clone(); + + let recovery = recover_interrupted_sound_deletions_with( + &data, + &sounds_dir, + &dir.join("trash"), + |_from, _to| Err(std::io::Error::other("injected recovery failure")), + ) + .unwrap(); + + assert!(!recovery.complete); + assert!(recovery + .protected_keys + .contains(&path_identity_key(&backup_path))); + assert!(!sound_path.exists()); + assert!(backup_path.exists()); + + std::fs::rename(backup_path, sound_path).unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn interrupted_committed_sound_deletion_finishes_in_trash() { + let dir = test_directory("sound-delete-startup-finish-test"); + let sounds_dir = dir.join("sounds"); + std::fs::create_dir_all(&sounds_dir).unwrap(); + let sound_path = sounds_dir.join("sound.wav"); + std::fs::write(&sound_path, b"sound").unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let sound_key = sound_path.to_string_lossy().to_string(); + store + .update(|data| { + data.sound_library + .insert(sound_key.clone(), SoundLibraryEntry::default()); + }) + .unwrap(); + stage_sound_files_for_deletion(std::slice::from_ref(&sound_path)).unwrap(); + store + .update(|data| { + data.sound_library.remove(&sound_key); + }) + .unwrap(); + + store + .recover_interrupted_processed_wav_replacements_now() + .unwrap(); + + assert!(!sound_path.exists()); + let quarantined = std::fs::read_dir(dir.join("trash")) + .unwrap() + .flat_map(|session| std::fs::read_dir(session.unwrap().path()).unwrap()) + .flat_map(|category| std::fs::read_dir(category.unwrap().path()).unwrap()) + .any(|entry| entry.unwrap().file_name() == "sound.wav"); + assert!(quarantined); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn sound_deletion_staging_failure_restores_already_staged_files() { + let dir = test_directory("sound-delete-staging-rollback-test"); + std::fs::create_dir_all(&dir).unwrap(); + let sound_path = dir.join("sound.wav"); + let invalid_path = dir.join("not-a-file.wav"); + std::fs::write(&sound_path, b"sound").unwrap(); + std::fs::create_dir(&invalid_path).unwrap(); + + let error = stage_sound_files_for_deletion(&[sound_path.clone(), invalid_path]) + .unwrap_err() + .to_string(); + + assert!(error.contains("not a file")); + assert_eq!(std::fs::read(&sound_path).unwrap(), b"sound"); + assert!(!std::fs::read_dir(&dir).unwrap().any(|entry| { + entry + .unwrap() + .file_name() + .to_string_lossy() + .contains(".delete-backup-") + })); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn repaired_non_utf8_store_skips_every_asset_sweep() { + let dir = test_directory("non-utf8-asset-sweep-health-gate-test"); + let sound_path = dir.join("sounds").join("unreferenced.wav"); + let font_path = dir.join("fonts").join("unreferenced.ttf"); + let image_path = dir.join("images").join("unreferenced.png"); + for path in [&sound_path, &font_path, &image_path] { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, b"asset-fixture").unwrap(); + } + std::fs::write(dir.join("store.json"), [b'{', 0xFF, b'}']).unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + assert!(store.skip_asset_sweep); + + store.cleanup_orphan_assets_now().unwrap(); + + assert!(sound_path.exists()); + assert!(font_path.exists()); + assert!(image_path.exists()); + assert!(!dir.join("trash").exists()); + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn repaired_sound_library_keeps_valid_local_asset_through_cleanup() { + let dir = test_directory("sound-library-entry-recovery-test"); + let sounds_dir = dir.join("sounds"); + std::fs::create_dir_all(&sounds_dir).unwrap(); + + let builtin_hait = sounds_dir.join("builtin-hait.wav"); + let builtin_click = sounds_dir.join("builtin-click.wav"); + let valid_local = sounds_dir.join("valid-local.wav"); + let invalid_local = sounds_dir.join("invalid-local.wav"); + let wav_fixture = include_bytes!("../../../assets/sounds/builtin-click.wav"); + for path in [&builtin_hait, &builtin_click, &valid_local, &invalid_local] { + std::fs::write(path, wav_fixture).unwrap(); + } + + let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); + let sound_library = fixture["soundLibrary"].as_object_mut().unwrap(); + sound_library.insert( + builtin_hait.to_string_lossy().to_string(), + json!({ "source": "builtin" }), + ); + sound_library.insert( + builtin_click.to_string_lossy().to_string(), + json!({ "source": "builtin" }), + ); + sound_library.insert( + valid_local.to_string_lossy().to_string(), + json!({ "source": "local", "displayName": "Valid local" }), + ); + sound_library.insert( + invalid_local.to_string_lossy().to_string(), + json!({ "source": 42, "displayName": "Invalid local" }), + ); + std::fs::write( + dir.join("store.json"), + serde_json::to_vec_pretty(&fixture).unwrap(), + ) + .unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let snapshot = store.snapshot(); + let valid_local_key = valid_local.to_string_lossy().to_string(); + let invalid_local_key = invalid_local.to_string_lossy().to_string(); + + assert_eq!(snapshot.sound_library.len(), 4); + assert_eq!( + snapshot.sound_library[&valid_local_key].source, + SoundSource::Local + ); + assert_eq!( + snapshot.sound_library[&invalid_local_key].source, + SoundSource::Local + ); + assert_eq!( + snapshot.sound_library[&invalid_local_key] + .display_name + .as_deref(), + Some("Invalid local") + ); + assert!(store.skip_asset_sweep); + + store.cleanup_orphan_assets_now().unwrap(); + + assert!(valid_local.exists()); + assert!(invalid_local.exists()); + assert!(!dir.join("trash").exists()); + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn repaired_asset_references_survive_the_next_normal_session_sweep() { + fn corrupt_height(mut value: Value) -> Value { + value + .as_object_mut() + .unwrap() + .insert("height".to_string(), json!("invalid")); + value + } + + let dir = test_directory("repaired-asset-reference-next-session-test"); + let font_path = dir.join("fonts").join("recoverable.ttf"); + let library_sound = dir.join("sounds").join("library.wav"); + let stat_sound = dir.join("sounds").join("stat.wav"); + let graph_sound = dir.join("sounds").join("graph.wav"); + let knob_sound = dir.join("sounds").join("knob.wav"); + for path in [ + &font_path, + &library_sound, + &stat_sound, + &graph_sound, + &knob_sound, + ] { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, b"asset-fixture").unwrap(); + } + + let mut font = serde_json::to_value(CustomFont { + id: "recoverable-font".to_string(), + font_type: FontType::Local, + name: "Recoverable Font".to_string(), + display_name: "Recoverable Font".to_string(), + enabled: true, + local_path: Some(font_path.to_string_lossy().to_string()), + css_content: None, + weight_ranges: Vec::new(), + }) + .unwrap(); + font.as_object_mut() + .unwrap() + .insert("enabled".to_string(), json!("invalid")); + + let mut stat_position = default_positions()["4key"][0].clone(); + stat_position.sound_path = Some(stat_sound.to_string_lossy().to_string()); + let stat = corrupt_height( + serde_json::to_value(StatPosition { + stat_type: StatType::Kps, + position: stat_position, + }) + .unwrap(), + ); + let mut graph_position = default_positions()["4key"][1].clone(); + graph_position.sound_path = Some(graph_sound.to_string_lossy().to_string()); + let graph = corrupt_height( + serde_json::to_value(GraphPosition { + stat_type: GraphStatType::Kps, + graph_type: GraphType::Line, + graph_speed: 1, + graph_color: "#FFFFFF".to_string(), + show_avg_line: true, + position: graph_position, + }) + .unwrap(), + ); + let mut knob_position = default_positions()["4key"][2].clone(); + knob_position.sound_path = Some(knob_sound.to_string_lossy().to_string()); + let knob = corrupt_height( + serde_json::to_value(KnobPosition { + axis_id: "axis-recoverable".to_string(), + sensitivity: 1.0, + reverse: false, + position: knob_position, + }) + .unwrap(), + ); + + let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); + let fields = fixture.as_object_mut().unwrap(); + fields.insert("fontSettings".to_string(), json!({ "customFonts": [font] })); + fields.insert( + "soundLibrary".to_string(), + json!({ library_sound.to_string_lossy().to_string(): 42 }), + ); + fields.insert("statPositions".to_string(), json!({ "asset-mode": [stat] })); + fields.insert( + "graphPositions".to_string(), + json!({ "asset-mode": [graph] }), + ); + fields.insert("knobPositions".to_string(), json!({ "asset-mode": [knob] })); + std::fs::write( + dir.join("store.json"), + serde_json::to_vec_pretty(&fixture).unwrap(), + ) + .unwrap(); + + let recovered = AppStore::initialize_in_dir(&dir).unwrap(); + assert!(recovered.skip_asset_sweep); + let snapshot = recovered.snapshot(); + assert!(!snapshot.font_settings.custom_fonts[0].enabled); + assert!(snapshot + .sound_library + .contains_key(&library_sound.to_string_lossy().to_string())); + assert_eq!(snapshot.stat_positions["asset-mode"].len(), 1); + assert_eq!(snapshot.graph_positions["asset-mode"].len(), 1); + assert_eq!(snapshot.knob_positions["asset-mode"].len(), 1); + recovered.cleanup_orphan_assets_now().unwrap(); + recovered.flush_and_shutdown().unwrap(); + drop(recovered); + + let normal = AppStore::initialize_in_dir(&dir).unwrap(); + assert!(!normal.skip_asset_sweep); + normal.cleanup_orphan_assets_now().unwrap(); + for path in [ + &font_path, + &library_sound, + &stat_sound, + &graph_sound, + &knob_sound, + ] { + assert!(path.exists(), "recovered asset was quarantined: {path:?}"); + } + + normal.flush_and_shutdown().unwrap(); + drop(normal); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn normal_session_moves_unreferenced_asset_to_trash() { + let dir = test_directory("asset-trash-quarantine-test"); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + assert!(!store.skip_asset_sweep); + + let orphan_path = dir.join("sounds").join("orphan.wav"); + std::fs::write(&orphan_path, b"orphan-wav").unwrap(); + + store.cleanup_orphan_assets_now().unwrap(); + + assert!(!orphan_path.exists()); + let trash_dir = dir.join("trash"); + let sessions: Vec<_> = std::fs::read_dir(&trash_dir) + .unwrap() + .map(|entry| entry.unwrap().path()) + .collect(); + assert_eq!(sessions.len(), 1); + let quarantined_path = sessions[0].join("sounds").join("orphan.wav"); + assert_eq!(std::fs::read(quarantined_path).unwrap(), b"orphan-wav"); + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn trash_collision_uses_another_session_and_preserves_file_name() { + let dir = test_directory("asset-trash-collision-test"); + let sounds_dir = dir.join("sounds"); + let trash_dir = dir.join("trash"); + let now = UNIX_EPOCH + Duration::from_secs(2_000_000_000); + let timestamp = system_time_millis(now).unwrap(); + let collision_path = trash_dir + .join(timestamp.to_string()) + .join("sounds") + .join("same.wav"); + let source_path = sounds_dir.join("same.wav"); + std::fs::create_dir_all(collision_path.parent().unwrap()).unwrap(); + std::fs::create_dir_all(&sounds_dir).unwrap(); + std::fs::write(&collision_path, b"existing").unwrap(); + std::fs::write(&source_path, b"incoming").unwrap(); + + let mut trash_session = TrashSession::new(trash_dir.clone(), now).unwrap(); + sweep_unreferenced_asset_files("Sounds", &sounds_dir, &HashSet::new(), &mut trash_session) + .unwrap(); + + let moved_path = trash_dir + .join(format!("{timestamp}-1")) + .join("sounds") + .join("same.wav"); + assert!(!source_path.exists()); + assert_eq!(std::fs::read(collision_path).unwrap(), b"existing"); + assert_eq!(std::fs::read(moved_path).unwrap(), b"incoming"); + + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn trash_purge_removes_only_sessions_older_than_thirty_days() { + let dir = test_directory("asset-trash-retention-test"); + let trash_dir = dir.join("trash"); + let now = UNIX_EPOCH + Duration::from_secs(2_000_000_000); + let expired_time = now + .checked_sub(TRASH_RETENTION + Duration::from_secs(1)) + .unwrap(); + let retained_time = now + .checked_sub(Duration::from_secs(29 * 24 * 60 * 60)) + .unwrap(); + let expired_dir = trash_dir.join(format!("{}-1", system_time_millis(expired_time).unwrap())); + let retained_dir = trash_dir.join(system_time_millis(retained_time).unwrap().to_string()); + std::fs::create_dir_all(expired_dir.join("sounds")).unwrap(); + std::fs::create_dir_all(retained_dir.join("sounds")).unwrap(); + std::fs::write(expired_dir.join("sounds").join("old.wav"), b"old").unwrap(); + std::fs::write(retained_dir.join("sounds").join("recent.wav"), b"recent").unwrap(); + + purge_expired_trash_sessions_at(&trash_dir, now).unwrap(); + + assert!(!expired_dir.exists()); + assert!(retained_dir.exists()); + + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn orphan_sweep_recovers_processed_wav_backup_before_removing_temp() { + let dir = test_directory("processed-wav-orphan-recovery-test"); + let sounds_dir = dir.join("sounds"); + std::fs::create_dir_all(&sounds_dir).unwrap(); + + let target_path = sounds_dir.join("sound.wav"); + let backup_path = sounds_dir.join("sound.wav.bak"); + let temp_path = sounds_dir.join(format!( + ".sound.wav.processed-wav-{}.tmp", + uuid::Uuid::new_v4() + )); + std::fs::write(&backup_path, b"old-wav").unwrap(); + std::fs::write(&temp_path, b"new-wav").unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let target_key = target_path.to_string_lossy().to_string(); + store + .update(|data| { + data.sound_library + .insert(target_key.clone(), Default::default()); + }) + .unwrap(); + + store.cleanup_orphan_assets_now().unwrap(); + + assert_eq!(std::fs::read(&target_path).unwrap(), b"old-wav"); + assert!(!backup_path.exists()); + assert!(!temp_path.exists()); + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn app_state_initialization_recovers_processed_wav_without_sweeping_temp() { + let dir = test_directory("processed-wav-startup-recovery-test"); + let sounds_dir = dir.join("sounds"); + std::fs::create_dir_all(&sounds_dir).unwrap(); + + let target_path = sounds_dir.join("sound.wav"); + let backup_path = sounds_dir.join("sound.wav.bak"); + let temp_path = sounds_dir.join(format!( + ".sound.wav.processed-wav-{}.tmp", + uuid::Uuid::new_v4() + )); + std::fs::write(&backup_path, b"old-wav").unwrap(); + std::fs::write(&temp_path, b"interrupted-new-wav").unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + assert!(!target_path.exists()); + + let state = AppState::initialize(store).unwrap(); + + assert_eq!(std::fs::read(&target_path).unwrap(), b"old-wav"); + assert!(!backup_path.exists()); + assert!(temp_path.exists()); + + state.shutdown(); + drop(state); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn pending_wav_journal_rolls_back_file_before_metadata_is_committed() { + let dir = test_directory("processed-wav-journal-recovery-test"); + let sounds_dir = dir.join("sounds"); + std::fs::create_dir_all(&sounds_dir).unwrap(); + let target_path = sounds_dir.join("sound.wav"); + let backup_path = sounds_dir.join("sound.wav.bak"); + std::fs::write(&target_path, b"old-wav").unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let target_key = target_path.to_string_lossy().to_string(); + store + .update(|data| { + data.sound_library.insert( + target_key.clone(), + crate::models::SoundLibraryEntry { + display_name: Some("old-metadata".to_string()), + ..Default::default() + }, + ); + data.pending_processed_wav_replacement = Some(PendingProcessedWavReplacement { + sound_path: target_key.clone(), + had_original: true, + }); + }) + .unwrap(); + + std::fs::rename(&target_path, &backup_path).unwrap(); + std::fs::write(&target_path, b"uncommitted-new-wav").unwrap(); + store + .recover_interrupted_processed_wav_replacements_now() + .unwrap(); + + let snapshot = store.snapshot(); + assert_eq!(std::fs::read(&target_path).unwrap(), b"old-wav"); + assert_eq!( + snapshot.sound_library[&target_key].display_name.as_deref(), + Some("old-metadata") + ); + assert_eq!(snapshot.pending_processed_wav_replacement, None); + assert!(!backup_path.exists()); + assert!(std::fs::read_dir(&sounds_dir).unwrap().any(|entry| { + let entry = entry.unwrap(); + entry + .file_name() + .to_string_lossy() + .contains(".interrupted-") + && std::fs::read(entry.path()).unwrap() == b"uncommitted-new-wav" + })); + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn missing_pending_wav_files_clear_journal_and_allow_repeated_cleanup() { + let dir = test_directory("processed-wav-missing-journal-test"); + let sounds_dir = dir.join("sounds"); + std::fs::create_dir_all(&sounds_dir).unwrap(); + let target_path = sounds_dir.join("missing.wav"); + let orphan_path = sounds_dir.join("orphan.wav"); + std::fs::write(&orphan_path, b"orphan-wav").unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + store + .update(|data| { + data.pending_processed_wav_replacement = Some(PendingProcessedWavReplacement { + sound_path: target_path.to_string_lossy().to_string(), + had_original: true, + }); + }) + .unwrap(); + + store.cleanup_orphan_assets_now().unwrap(); + + assert_eq!(store.snapshot().pending_processed_wav_replacement, None); + assert!(!orphan_path.exists()); + let trash_sessions: Vec<_> = std::fs::read_dir(dir.join("trash")) + .unwrap() + .map(|entry| entry.unwrap().path()) + .collect(); + assert!(trash_sessions + .iter() + .any(|session| session.join("sounds").join("orphan.wav").exists())); + + store.cleanup_orphan_assets_now().unwrap(); + assert_eq!(store.snapshot().pending_processed_wav_replacement, None); + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn unknown_pending_wav_presence_keeps_journal() { + let dir = test_directory("processed-wav-unknown-presence-test"); + let sounds_dir = dir.join("sounds"); + std::fs::create_dir_all(&sounds_dir).unwrap(); + let target_path = sounds_dir.join("unknown.wav"); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let pending = PendingProcessedWavReplacement { + sound_path: target_path.to_string_lossy().to_string(), + had_original: true, + }; + store + .update(|data| { + data.pending_processed_wav_replacement = Some(pending.clone()); + }) + .unwrap(); + let checks = std::cell::Cell::new(0); + + let result = store.recover_pending_processed_wav_replacement_with(&sounds_dir, |_path| { + match checks.get() { + 0 => { + checks.set(1); + Ok(false) + } + _ => { + checks.set(2); + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "injected permission failure", + )) + } + } + }); + + assert!(result.is_err()); + assert_eq!(checks.get(), 2); + assert_eq!( + store.snapshot().pending_processed_wav_replacement, + Some(pending) + ); + + store + .update(|data| data.pending_processed_wav_replacement = None) + .unwrap(); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn completed_wav_transaction_keeps_new_file_when_only_backup_cleanup_was_interrupted() { + let dir = test_directory("processed-wav-post-commit-recovery-test"); + let sounds_dir = dir.join("sounds"); + std::fs::create_dir_all(&sounds_dir).unwrap(); + let target_path = sounds_dir.join("sound.wav"); + let backup_path = sounds_dir.join("sound.wav.bak"); + std::fs::write(&target_path, b"new-wav").unwrap(); + std::fs::write(&backup_path, b"old-wav").unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let target_key = target_path.to_string_lossy().to_string(); + store + .update(|data| { + data.sound_library.insert( + target_key.clone(), + crate::models::SoundLibraryEntry { + display_name: Some("new-metadata".to_string()), + ..Default::default() + }, + ); + data.pending_processed_wav_replacement = None; + }) + .unwrap(); + + store + .recover_interrupted_processed_wav_replacements_now() + .unwrap(); + + assert_eq!(std::fs::read(&target_path).unwrap(), b"new-wav"); + assert_eq!(std::fs::read(&backup_path).unwrap(), b"old-wav"); + assert_eq!( + store.snapshot().sound_library[&target_key] + .display_name + .as_deref(), + Some("new-metadata") + ); + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn pending_wav_without_an_original_quarantines_the_uncommitted_file() { + let dir = test_directory("processed-wav-new-file-journal-test"); + let sounds_dir = dir.join("sounds"); + std::fs::create_dir_all(&sounds_dir).unwrap(); + let target_path = sounds_dir.join("new.wav"); + std::fs::write(&target_path, b"uncommitted-new-wav").unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + store + .update(|data| { + data.pending_processed_wav_replacement = Some(PendingProcessedWavReplacement { + sound_path: target_path.to_string_lossy().to_string(), + had_original: false, + }); + }) + .unwrap(); + + store + .recover_interrupted_processed_wav_replacements_now() + .unwrap(); + + assert!(!target_path.exists()); + assert_eq!(store.snapshot().pending_processed_wav_replacement, None); + assert!(std::fs::read_dir(&sounds_dir).unwrap().any(|entry| { + let entry = entry.unwrap(); + entry + .file_name() + .to_string_lossy() + .contains(".interrupted-") + && std::fs::read(entry.path()).unwrap() == b"uncommitted-new-wav" + })); + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn orphan_sweep_continues_recovery_and_protects_failed_backup() { + let dir = test_directory("processed-wav-partial-recovery-test"); + let sounds_dir = dir.join("sounds"); + std::fs::create_dir_all(&sounds_dir).unwrap(); + + let first_target = sounds_dir.join("first.wav"); + let first_backup = sounds_dir.join("first.wav.bak"); + let second_target = sounds_dir.join("second.wav"); + let second_backup = sounds_dir.join("second.wav.bak"); + let temp_path = sounds_dir.join(format!( + ".first.wav.processed-wav-{}.tmp", + uuid::Uuid::new_v4() + )); + std::fs::write(&first_backup, b"first-old-wav").unwrap(); + std::fs::write(&second_backup, b"second-old-wav").unwrap(); + std::fs::write(&temp_path, b"interrupted-new-wav").unwrap(); + + let mut retained_keys = HashSet::from([ + path_identity_key(&first_target), + path_identity_key(&second_target), + ]); + let rename_attempts = std::cell::Cell::new(0); + let failed_backup = std::cell::RefCell::new(None); + let recovered_target = std::cell::RefCell::new(None); + let recovery = recover_interrupted_processed_wav_replacements_with(&sounds_dir, |from, to| { + let attempt = rename_attempts.get(); + rename_attempts.set(attempt + 1); + if attempt == 0 { + failed_backup.replace(Some(from.to_path_buf())); + Err(std::io::Error::other("injected recovery failure")) + } else { + recovered_target.replace(Some(to.to_path_buf())); + std::fs::rename(from, to) + } + }) + .unwrap(); + assert!(!recovery.complete); + retained_keys.extend(recovery.protected_keys); + let mut trash_session = TrashSession::new(dir.join("trash"), SystemTime::now()).unwrap(); + sweep_unreferenced_asset_files("Sounds", &sounds_dir, &retained_keys, &mut trash_session) + .unwrap(); + + assert_eq!(rename_attempts.get(), 2); + let failed_backup = failed_backup.into_inner().unwrap(); + let failed_target_name = failed_backup + .file_name() + .unwrap() + .to_string_lossy() + .strip_suffix(".bak") + .unwrap() + .to_string(); + let failed_target = failed_backup.with_file_name(failed_target_name); + let recovered_target = recovered_target.into_inner().unwrap(); + assert!(!failed_target.exists()); + assert!(failed_backup.exists()); + assert!(recovered_target.exists()); + assert!(!recovered_target.with_extension("wav.bak").exists()); + assert!(!temp_path.exists()); + + let _ = std::fs::remove_dir_all(dir); +} From a8fe0389af95ed95bf00e38a381e9cc8c3f92c2d Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 06:11:01 +0900 Subject: [PATCH 003/245] =?UTF-8?q?refactor:=20Rust=20=EB=8C=80=ED=98=95?= =?UTF-8?q?=20=EB=AA=A8=EB=93=88=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/models/mod.rs | 1138 +--- src-tauri/src/models/tests.rs | 1132 ++++ src-tauri/src/state/app_state.rs | 2716 +-------- src-tauri/src/state/app_state/tests.rs | 2688 +++++++++ src-tauri/src/state/editor.rs | 3383 +---------- src-tauri/src/state/editor/tests.rs | 3342 +++++++++++ src-tauri/src/state/editor_ops.rs | 7216 +---------------------- src-tauri/src/state/editor_ops/tests.rs | 7195 ++++++++++++++++++++++ src-tauri/src/state/migration.rs | 3817 +----------- src-tauri/src/state/migration/tests.rs | 3811 ++++++++++++ 10 files changed, 18173 insertions(+), 18265 deletions(-) create mode 100644 src-tauri/src/models/tests.rs create mode 100644 src-tauri/src/state/app_state/tests.rs create mode 100644 src-tauri/src/state/editor/tests.rs create mode 100644 src-tauri/src/state/editor_ops/tests.rs create mode 100644 src-tauri/src/state/migration/tests.rs diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index dc59c88f6..b33867161 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -3030,1140 +3030,4 @@ pub struct SettingsPatch { } #[cfg(test)] -mod tests { - use super::{ - compact_canonical_rgba, note_border_representative_hex, scrub_removed_text_outline_fields, - FadePosition, GradientSpec, GraphPosition, GraphStatType, GraphType, KeyCounterAlign, - KeyCounterAlignMode, KeyCounterColor, KeyCounterPlacement, KeyCounterSettings, KeyMappings, - KeyPosition, KeySlot, KnobPosition, NoteColor, NoteSettings, SlotMatch, StatPosition, - StatType, MAX_SLOT_KEYS, POSITION_COLLECTION_FIELDS, - }; - use serde::Deserialize; - - const NOTE_BORDER_STOP_COLOR_FIXTURE: &str = - include_str!("../../../tests/fixtures/note-border-stop-colors.json"); - - #[derive(Deserialize)] - struct NoteBorderStopColorFixture { - valid: Vec, - invalid: Vec, - } - - #[derive(Deserialize)] - struct ValidNoteBorderStopColor { - input: String, - representative: String, - } - - #[test] - fn legacy_string_key_mappings_round_trip_without_loss() { - let raw = serde_json::json!({ - "4key": ["A", "A+B", "+", ""] - }); - - let mappings: KeyMappings = serde_json::from_value(raw.clone()).unwrap(); - - assert_eq!(serde_json::to_value(mappings).unwrap(), raw); - } - - #[test] - fn legacy_700_weights_migrate_to_400_with_bold_modifier() { - let mut raw = serde_json::to_value(KeyPosition::default()).unwrap(); - let object = raw.as_object_mut().unwrap(); - object.insert("fontWeight".to_string(), serde_json::json!(700)); - object.remove("fontBold"); - let counter = object["counter"].as_object_mut().unwrap(); - counter.insert("fontWeight".to_string(), serde_json::json!(700)); - counter.remove("fontBold"); - - let mut position: KeyPosition = serde_json::from_value(raw).unwrap(); - assert!(position.migrate_legacy_font_weight()); - assert_eq!(position.font_weight, Some(400)); - assert_eq!(position.font_bold, Some(true)); - assert_eq!(position.counter.font_weight, 400); - assert_eq!(position.counter.font_bold, Some(true)); - assert!(!position.migrate_legacy_font_weight()); - } - - #[test] - fn legacy_non_bold_weights_remain_sparse() { - let mut raw = serde_json::to_value(KeyPosition::default()).unwrap(); - let object = raw.as_object_mut().unwrap(); - object.insert("fontWeight".to_string(), serde_json::json!(600)); - object.remove("fontBold"); - let counter = object["counter"].as_object_mut().unwrap(); - counter.insert("fontWeight".to_string(), serde_json::json!(500)); - counter.remove("fontBold"); - - let mut position: KeyPosition = serde_json::from_value(raw).unwrap(); - assert!(!position.migrate_legacy_font_weight()); - assert_eq!(position.font_weight, Some(600)); - assert_eq!(position.font_bold, None); - assert_eq!(position.counter.font_weight, 500); - assert_eq!(position.counter.font_bold, None); - } - - #[test] - fn element_id_defaults_to_empty_and_flattens_into_every_position_type() { - let id = uuid::Uuid::new_v4().to_string(); - let position = KeyPosition { - id: id.clone(), - ..KeyPosition::default() - }; - let mut values = [ - serde_json::to_value(&position).unwrap(), - serde_json::to_value(StatPosition { - stat_type: StatType::Kps, - position: position.clone(), - }) - .unwrap(), - serde_json::to_value(GraphPosition { - stat_type: GraphStatType::Kps, - graph_type: GraphType::Line, - graph_speed: 100, - graph_color: "#123456".to_string(), - show_avg_line: true, - position: position.clone(), - }) - .unwrap(), - serde_json::to_value(KnobPosition { - axis_id: "axis".to_string(), - sensitivity: 1.0, - reverse: false, - position, - }) - .unwrap(), - ]; - - assert!(values.iter().all(|value| value["id"] == id)); - for value in &mut values { - value.as_object_mut().unwrap().remove("id"); - } - let stat: StatPosition = serde_json::from_value(values[1].clone()).unwrap(); - let graph: GraphPosition = serde_json::from_value(values[2].clone()).unwrap(); - let knob: KnobPosition = serde_json::from_value(values[3].clone()).unwrap(); - assert!(stat.position.id.is_empty()); - assert!(graph.position.id.is_empty()); - assert!(knob.position.id.is_empty()); - - let missing: KeyPosition = serde_json::from_value(serde_json::json!({ - "dx": 0, - "dy": 0, - "width": 60, - "count": 0 - })) - .unwrap(); - assert!(missing.id.is_empty()); - } - - #[test] - fn new_surface_gradients_flatten_into_every_position_collection() { - let position = KeyPosition { - note_border_gradient: serde_json::from_value(serde_json::json!({ - "angle": 90, - "stops": [ - { "color": "#112233", "pos": 0 }, - { "color": "#445566", "pos": 1 } - ] - })) - .unwrap(), - font_gradient: serde_json::from_value(serde_json::json!({ - "angle": 45, - "stops": [ - { "color": "#556677", "pos": 0 }, - { "color": "#8899AA", "pos": 1 } - ] - })) - .unwrap(), - active_font_gradient: serde_json::from_value(serde_json::json!({ - "angle": 135, - "stops": [ - { "color": "#AABBCC", "pos": 0 }, - { "color": "#DDEEFF", "pos": 1 } - ] - })) - .unwrap(), - ..KeyPosition::default() - }; - let values = [ - serde_json::to_value(&position).unwrap(), - serde_json::to_value(StatPosition { - stat_type: StatType::Kps, - position: position.clone(), - }) - .unwrap(), - serde_json::to_value(GraphPosition { - stat_type: GraphStatType::Kps, - graph_type: GraphType::Line, - graph_speed: 100, - graph_color: "#123456".to_string(), - show_avg_line: true, - position: position.clone(), - }) - .unwrap(), - serde_json::to_value(KnobPosition { - axis_id: "axis".to_string(), - sensitivity: 1.0, - reverse: false, - position, - }) - .unwrap(), - ]; - - for value in values { - assert_eq!(value["noteBorderGradient"]["angle"], 90.0); - assert_eq!(value["fontGradient"]["angle"], 45.0); - assert_eq!(value["activeFontGradient"]["angle"], 135.0); - } - } - - #[test] - fn multi_key_slot_wire_shape_and_canonical_are_stable() { - let raw = serde_json::json!({ "keys": ["LEFT CTRL", "Z"], "match": "all" }); - - let slot: KeySlot = serde_json::from_value(raw.clone()).unwrap(); - - assert_eq!( - slot, - KeySlot::Multi { - keys: vec!["LEFT CTRL".to_string(), "Z".to_string()], - match_mode: SlotMatch::All, - } - ); - assert_eq!(slot.canonical(), "LEFT CTRL+Z"); - assert_eq!(serde_json::to_value(slot).unwrap(), raw); - } - - #[test] - fn malformed_key_slots_normalize_without_deserialization_failure() { - let too_many = (0..=MAX_SLOT_KEYS) - .map(|index| serde_json::Value::String(format!("K{index}"))) - .collect::>(); - let cases = [ - (serde_json::json!({ "keys": ["Z"] }), KeySlot::default()), - ( - serde_json::json!({ - "keys": ["A", 7, "", "A", "B+C", "D|E", "B"], - "match": "any", - "ignored": true - }), - KeySlot::Multi { - keys: vec!["A".to_string(), "B".to_string()], - match_mode: SlotMatch::Any, - }, - ), - ( - serde_json::json!({ "keys": ["Z"], "match": "all" }), - KeySlot::Single("Z".to_string()), - ), - ( - serde_json::json!({ "keys": [], "match": "any" }), - KeySlot::default(), - ), - ( - serde_json::json!({ "keys": too_many, "match": "any" }), - KeySlot::Multi { - keys: (0..MAX_SLOT_KEYS) - .map(|index| format!("K{index}")) - .collect(), - match_mode: SlotMatch::Any, - }, - ), - (serde_json::json!(42), KeySlot::default()), - (serde_json::json!(["A", "B"]), KeySlot::default()), - (serde_json::Value::Null, KeySlot::default()), - ]; - - for (raw, expected) in cases { - let slot: KeySlot = serde_json::from_value(raw).unwrap(); - assert_eq!(slot, expected); - } - } - - #[test] - fn stat_type_wire_values_round_trip() { - for (stat_type, wire_value) in [ - (StatType::Kps, "kps"), - (StatType::KpsAvg, "kpsAvg"), - (StatType::KpsMax, "kpsMax"), - (StatType::Total, "total"), - ] { - let serialized = serde_json::to_value(&stat_type).unwrap(); - assert_eq!(serialized, wire_value); - - let restored: StatType = serde_json::from_value(serialized).unwrap(); - assert_eq!(restored, stat_type); - } - } - - // 필수 필드만 채운 최소 KeyPosition JSON. 시각 px 필드는 호출부에서 주입 - fn key_position_json(visual_px: &str) -> String { - format!( - r##"{{ - "dx": 0.0, "dy": 0.0, "width": 60.0, "height": 60.0, - "count": 0, "noteColor": "#FFFFFF", "noteOpacity": 80, - {visual_px} - }}"## - ) - } - - // 기존 정수 저장값이 f64 필드로 그대로 역직렬화되는지 (하위 호환) - #[test] - fn visual_px_fields_accept_integer_json() { - let json = - key_position_json(r#""noteWidth": 100, "noteBorderRadius": 8, "noteGlowSize": 20"#); - let pos: KeyPosition = serde_json::from_str(&json).unwrap(); - assert_eq!(pos.note_width, Some(100.0)); - assert_eq!(pos.note_border_radius, Some(8.0)); - assert_eq!(pos.note_glow_size, 20.0); - } - - // 소수 저장값이 정상 역직렬화되는지 - #[test] - fn visual_px_fields_accept_decimal_json() { - let json = key_position_json( - r#""noteWidth": 100.5, "noteBorderRadius": 8.5, "noteGlowSize": 20.5"#, - ); - let pos: KeyPosition = serde_json::from_str(&json).unwrap(); - assert_eq!(pos.note_width, Some(100.5)); - assert_eq!(pos.note_border_radius, Some(8.5)); - assert_eq!(pos.note_glow_size, 20.5); - } - - // note_glow_size 미지정 시 기본값(10.0) 적용 - #[test] - fn note_glow_size_defaults_to_10() { - let json = key_position_json(r#""noteWidth": null"#); - let pos: KeyPosition = serde_json::from_str(&json).unwrap(); - assert_eq!(pos.note_glow_size, 10.0); - assert_eq!(pos.note_width, None); - } - - #[test] - fn gradient_opacity_fields_survive_serde_round_trip() { - let json = key_position_json( - r#""noteOpacityTop": 91, "noteOpacityBottom": 37, - "noteGlowOpacityTop": 64, "noteGlowOpacityBottom": 18"#, - ); - let position: KeyPosition = serde_json::from_str(&json).unwrap(); - - assert_eq!(position.note_opacity_top, Some(91)); - assert_eq!(position.note_opacity_bottom, Some(37)); - assert_eq!(position.note_glow_opacity_top, Some(64)); - assert_eq!(position.note_glow_opacity_bottom, Some(18)); - - let serialized = serde_json::to_value(&position).unwrap(); - assert_eq!(serialized["noteOpacityTop"], 91); - assert_eq!(serialized["noteOpacityBottom"], 37); - assert_eq!(serialized["noteGlowOpacityTop"], 64); - assert_eq!(serialized["noteGlowOpacityBottom"], 18); - - let restored: KeyPosition = serde_json::from_value(serialized).unwrap(); - assert_eq!(restored, position); - } - - #[test] - fn note_settings_1_3_format_still_preserves_every_field() { - // 1.3 시절 noteSettings 전체 필드 실형식 - let fixture = r#"{ - "borderRadius": 9, - "speed": 456, - "trackHeight": 222, - "reverse": true, - "fadePosition": "bottom", - "delayedNoteEnabled": true, - "shortNoteThresholdMs": 73, - "shortNoteMinLengthPx": 41 - }"#; - let settings: NoteSettings = serde_json::from_str(fixture).unwrap(); - - assert_eq!(settings.border_radius, Some(9)); - assert_eq!(settings.speed, 456); - assert_eq!(settings.track_height, 222); - assert!(settings.reverse); - assert_eq!(settings.fade_position, FadePosition::Bottom); - assert!(settings.delayed_note_enabled); - assert_eq!(settings.short_note_threshold_ms, 73); - assert_eq!(settings.short_note_min_length_px, 41); - } - - #[test] - fn key_position_1_0_missing_visual_fields_uses_legacy_defaults() { - let position: KeyPosition = - serde_json::from_str(r#"{"dx":777,"dy":12,"width":60,"count":42}"#).unwrap(); - - assert_eq!(position.dx, 777.0); - assert_eq!(position.count, 42); - assert_eq!(position.height, 60.0); - assert_eq!(position.note_color, NoteColor::Solid("#FFFFFF".to_string())); - assert_eq!(position.note_opacity, 90); - assert_eq!(position.shadow, None); - assert_eq!(position.active_shadow, None); - } - - #[test] - fn key_position_visual_effects_round_trip_without_rewriting_missing_defaults() { - let fixture = serde_json::json!({ - "dx": 0, - "dy": 0, - "width": 60, - "count": 0, - "shadow": { - "enabled": true, - "color": "rgba(10, 20, 30, 0.45)", - "offsetX": -2.0, - "offsetY": 7.0, - "blur": 18.0 - }, - "activeShadow": { - "enabled": false, - "color": "rgba(0, 0, 0, 0.32)", - "offsetX": 0.0, - "offsetY": 3.0, - "blur": 8.0 - } - }); - - let position: KeyPosition = serde_json::from_value(fixture.clone()).unwrap(); - let serialized = serde_json::to_value(position).unwrap(); - - assert_eq!(serialized.get("shadow"), fixture.get("shadow")); - assert_eq!(serialized.get("activeShadow"), fixture.get("activeShadow")); - } - - #[test] - fn gradient_spec_tolerates_legacy_shape_and_serializes_canonically() { - let gradient: GradientSpec = serde_json::from_value(serde_json::json!({ - "type": "linear", - "stops": [ - { "color": "c9", "pos": 1.4 }, - { "color": "c8", "pos": 0.8 }, - { "color": "c7", "pos": 0.7 }, - { "color": "c6", "pos": 0.6 }, - { "color": "c5", "pos": 0.5 }, - { "color": "c4", "pos": 0.4 }, - { "color": "c3", "pos": 0.3 }, - { "color": "c2", "pos": 0.2 }, - { "color": "c1", "pos": 0.1 }, - { "color": "c0", "pos": -0.2 } - ] - })) - .unwrap(); - - assert_eq!(gradient.angle, 90.0); - assert_eq!(gradient.stops.len(), 8); - assert_eq!(gradient.stops.first().unwrap().color, "c0"); - assert_eq!(gradient.stops.first().unwrap().pos, 0.0); - assert_eq!(gradient.stops.last().unwrap().color, "c7"); - - let canonical = serde_json::to_value(&gradient).unwrap(); - assert_eq!(canonical["angle"], 90.0); - assert_eq!(canonical["stops"].as_array().unwrap().len(), 8); - assert!(canonical.get("type").is_none()); - - let restored: GradientSpec = serde_json::from_value(canonical.clone()).unwrap(); - assert_eq!(serde_json::to_value(restored).unwrap(), canonical); - } - - #[test] - fn gradient_spec_rejects_fewer_than_two_stops() { - let error = serde_json::from_value::(serde_json::json!({ - "angle": 90, - "stops": [{ "color": "#FFFFFF", "pos": 0 }] - })) - .unwrap_err(); - - assert!(error.to_string().contains("at least two stops")); - } - - #[test] - fn gradient_spec_rejects_null_angle_but_preserves_stop_alpha_strings() { - let error = serde_json::from_value::(serde_json::json!({ - "angle": null, - "stops": [ - { "color": "rgba(1,2,3,0)", "pos": 0 }, - { "color": "rgba(1,2,3,1)", "pos": 1 } - ] - })) - .unwrap_err(); - assert!(error.to_string().contains("invalid gradient angle")); - - let gradient: GradientSpec = serde_json::from_value(serde_json::json!({ - "stops": [ - { "color": "rgba(1,2,3,0)", "pos": 0 }, - { "color": "rgba(1,2,3,0.5)", "pos": 0.5 }, - { "color": "rgba(1,2,3,1)", "pos": 1 } - ] - })) - .unwrap(); - assert_eq!( - gradient - .stops - .iter() - .map(|stop| stop.color.as_str()) - .collect::>(), - ["rgba(1,2,3,0)", "rgba(1,2,3,0.5)", "rgba(1,2,3,1)"] - ); - } - - #[test] - fn note_border_stop_color_parser_matches_shared_fixture() { - let fixture: NoteBorderStopColorFixture = - serde_json::from_str(NOTE_BORDER_STOP_COLOR_FIXTURE).unwrap(); - - for case in fixture.valid { - assert_eq!( - note_border_representative_hex(&case.input), - Some(case.representative), - "valid fixture mismatch for {:?}", - case.input - ); - } - for input in fixture.invalid { - assert_eq!( - note_border_representative_hex(&input), - None, - "invalid fixture mismatch for {input:?}" - ); - } - } - - #[test] - fn new_gradient_fields_round_trip_and_missing_fields_remain_none() { - let legacy: KeyPosition = serde_json::from_value(serde_json::json!({ - "dx": 0, - "dy": 0, - "width": 60, - "count": 0 - })) - .unwrap(); - assert!(legacy.note_gradient.is_none()); - assert!(legacy.note_glow_gradient.is_none()); - assert!(legacy.note_border_gradient.is_none()); - assert!(legacy.font_gradient.is_none()); - assert!(legacy.active_font_gradient.is_none()); - - let value = serde_json::json!({ - "dx": 0, - "dy": 0, - "width": 60, - "count": 0, - "noteGradient": { - "angle": 15, - "stops": [ - { "color": "#1238", "pos": 0 }, - { "color": "rgba(4,5,6,.5)", "pos": 1 } - ] - }, - "noteGlowGradient": { - "angle": 25, - "stops": [ - { "color": "rgb(7,8,9)", "pos": 0 }, - { "color": "#ABC0", "pos": 1 } - ] - }, - "noteBorderColor": "#112233", - "noteBorderGradient": { - "angle": 45, - "stops": [ - { "color": "rgba(17,34,51,.5)", "pos": 0 }, - { "color": "#ABC", "pos": 1 } - ] - }, - "fontColor": "#112233", - "fontGradient": { - "angle": 90, - "stops": [ - { "color": "#112233", "pos": 0 }, - { "color": "#445566", "pos": 1 } - ] - }, - "activeFontColor": "#778899", - "activeFontGradient": { - "angle": 180, - "stops": [ - { "color": "#778899", "pos": 0 }, - { "color": "#AABBCC", "pos": 1 } - ] - } - }); - let position: KeyPosition = serde_json::from_value(value).unwrap(); - let serialized = serde_json::to_value(&position).unwrap(); - assert!(serialized.get("noteGradient").is_some()); - assert!(serialized.get("noteGlowGradient").is_some()); - assert!(serialized.get("noteBorderGradient").is_some()); - assert!(serialized.get("fontGradient").is_some()); - assert!(serialized.get("activeFontGradient").is_some()); - assert_eq!( - serde_json::from_value::(serialized).unwrap(), - position - ); - } - - #[test] - fn removed_text_outline_fields_scrub_every_collection_in_place() { - let position = serde_json::json!({ - "id": "keep-entry", - "fontStrokeColor": "#111111", - "activeFontStrokeColor": "#222222", - "counter": { - "stroke": { "idle": "#333333", "active": "#444444" }, - "strokeIdleGradient": { "stops": [] }, - "strokeActiveGradient": { "stops": [] }, - "fill": { "idle": "keep-idle", "active": "keep-active" } - } - }); - let mut value = serde_json::json!({ - "keyPositions": { "4key": [position.clone()] }, - "statPositions": { "4key": [position.clone()] }, - "graphPositions": { "4key": [position.clone()] }, - "knobPositions": { "4key": [position] } - }); - - assert!(scrub_removed_text_outline_fields(&mut value)); - for collection in POSITION_COLLECTION_FIELDS { - let entries = value[collection]["4key"].as_array().unwrap(); - assert_eq!(entries.len(), 1); - let position = entries[0].as_object().unwrap(); - assert_eq!(position["id"], "keep-entry"); - assert!(!position.contains_key("fontStrokeColor")); - assert!(!position.contains_key("activeFontStrokeColor")); - let counter = position["counter"].as_object().unwrap(); - assert!(!counter.contains_key("stroke")); - assert!(!counter.contains_key("strokeIdleGradient")); - assert!(!counter.contains_key("strokeActiveGradient")); - assert_eq!(counter["fill"]["idle"], "keep-idle"); - } - assert!(!scrub_removed_text_outline_fields(&mut value)); - } - - #[test] - fn note_gradients_atomically_canonicalize_color_and_alpha_shadows() { - let mut position: KeyPosition = serde_json::from_value(serde_json::json!({ - "dx": 0, - "dy": 0, - "width": 60, - "count": 0, - "noteColor": "stale-body", - "noteOpacity": 75, - "noteOpacityTop": 1, - "noteOpacityBottom": 2, - "noteGradient": { - "angle": 15, - "stops": [ - { "color": "#1238", "pos": 0 }, - { "color": "rgba(4,5,6,.5)", "pos": 1 } - ] - }, - "noteGlowColor": "stale-glow", - "noteGlowOpacity": 60, - "noteGlowOpacityTop": 3, - "noteGlowOpacityBottom": 4, - "noteGlowGradient": { - "angle": 25, - "stops": [ - { "color": "rgb(7,8,9)", "pos": 0 }, - { "color": "#ABC0", "pos": 1 } - ] - } - })) - .unwrap(); - - assert_eq!(position.canonicalize_gradient_pairs(), (true, true)); - assert_eq!( - position.note_color, - NoteColor::Gradient { - top: "#112233".to_string(), - bottom: "#040506".to_string(), - } - ); - assert_eq!(position.note_opacity_top, Some(40)); - assert_eq!(position.note_opacity_bottom, Some(38)); - assert_eq!( - position.note_glow_color, - Some(NoteColor::Gradient { - top: "#070809".to_string(), - bottom: "#AABBCC".to_string(), - }) - ); - assert_eq!(position.note_glow_opacity_top, Some(60)); - assert_eq!(position.note_glow_opacity_bottom, Some(0)); - assert_eq!(position.canonicalize_gradient_pairs(), (false, false)); - } - - #[test] - fn synced_note_glow_canonicalization_refreshes_stale_mirror_without_pair_repair() { - let mut position = KeyPosition { - note_glow_sync_paint: true, - note_color: NoteColor::Gradient { - top: "#112233".to_string(), - bottom: "#445566".to_string(), - }, - note_gradient: serde_json::from_value(serde_json::json!({ - "angle": 180, - "stops": [ - { "color": "#112233", "pos": 0 }, - { "color": "rgba(68, 85, 102, 0.5)", "pos": 1 } - ] - })) - .unwrap(), - note_opacity: 80, - note_opacity_top: Some(80), - note_opacity_bottom: Some(40), - note_glow_color: Some(NoteColor::Solid("stale".to_string())), - note_glow_opacity: 70, - note_glow_opacity_top: Some(70), - note_glow_opacity_bottom: Some(70), - ..KeyPosition::default() - }; - - assert_eq!(position.canonicalize_gradient_pairs(), (true, false)); - assert_eq!(position.note_glow_gradient, position.note_gradient); - assert_eq!(position.note_glow_opacity, position.note_opacity); - assert_eq!( - position.note_glow_color.as_ref(), - Some(&position.note_color) - ); - assert_eq!(position.note_glow_opacity_top, position.note_opacity_top); - assert_eq!( - position.note_glow_opacity_bottom, - position.note_opacity_bottom - ); - } - - #[test] - fn synced_note_glow_canonicalization_is_idempotent_when_mirror_matches() { - let mut position = KeyPosition { - note_glow_sync_paint: true, - note_color: NoteColor::Solid("#112233".to_string()), - note_opacity: 80, - note_opacity_top: Some(70), - note_opacity_bottom: Some(60), - ..KeyPosition::default() - }; - assert!(position.mirror_note_body_to_glow()); - - assert_eq!(position.canonicalize_gradient_pairs(), (false, false)); - } - - #[test] - fn font_gradient_pairs_are_optional_and_canonicalize_representative_colors() { - let mut position = KeyPosition { - font_color: Some("stale-idle".to_string()), - font_gradient: serde_json::from_value(serde_json::json!({ - "angle": 405, - "stops": [ - { "color": "rgba(17, 34, 51, .5)", "pos": 0 }, - { "color": "#445566", "pos": 1 } - ] - })) - .unwrap(), - active_font_color: None, - active_font_gradient: serde_json::from_value(serde_json::json!({ - "angle": -45, - "stops": [ - { "color": "#778899", "pos": 0 }, - { "color": "rgb(170, 187, 204)", "pos": 1 } - ] - })) - .unwrap(), - ..KeyPosition::default() - }; - - assert_eq!(position.canonicalize_gradient_pairs(), (true, true)); - assert_eq!(position.font_color.as_deref(), Some("rgba(17, 34, 51, .5)")); - assert_eq!(position.active_font_color.as_deref(), Some("#778899")); - assert_eq!(position.font_gradient.as_ref().unwrap().angle, 45.0); - assert_eq!(position.active_font_gradient.as_ref().unwrap().angle, 315.0); - assert_eq!(position.canonicalize_gradient_pairs(), (false, false)); - - let serialized_default = serde_json::to_value(KeyPosition::default()).unwrap(); - assert!(serialized_default.get("fontGradient").is_none()); - assert!(serialized_default.get("activeFontGradient").is_none()); - } - - #[test] - fn note_gradient_rejects_invalid_original_stop_before_truncation() { - let mut position = KeyPosition { - note_color: NoteColor::Solid("preserved".to_string()), - note_gradient: serde_json::from_value(serde_json::json!({ - "angle": 90, - "stops": [ - { "color": "#000000", "pos": 0.0 }, - { "color": "#111111", "pos": 0.1 }, - { "color": "#222222", "pos": 0.2 }, - { "color": "#333333", "pos": 0.3 }, - { "color": "#444444", "pos": 0.4 }, - { "color": "#555555", "pos": 0.5 }, - { "color": "#666666", "pos": 0.6 }, - { "color": "#777777", "pos": 0.7 }, - { "color": "invalid-discarded-stop", "pos": 1.0 } - ] - })) - .unwrap(), - ..KeyPosition::default() - }; - - assert_eq!(position.note_gradient.as_ref().unwrap().stops.len(), 8); - assert_eq!(position.canonicalize_gradient_pairs(), (true, true)); - assert!(position.note_gradient.is_none()); - assert_eq!( - position.note_color, - NoteColor::Solid("preserved".to_string()) - ); - } - - #[test] - fn note_border_gradient_canonicalization_uses_hex_and_drops_invalid_stop() { - let mut position: KeyPosition = serde_json::from_value(serde_json::json!({ - "dx": 0, - "dy": 0, - "width": 60, - "count": 0, - "noteBorderColor": "#000000", - "noteBorderGradient": { - "angle": 90, - "stops": [ - { "color": "rgba(17, 34, 51, .5)", "pos": 0 }, - { "color": "#ABC", "pos": 1 } - ] - } - })) - .unwrap(); - - assert_eq!(position.canonicalize_gradient_pairs(), (true, true)); - assert_eq!(position.note_border_color.as_deref(), Some("#112233")); - assert!(position.note_border_gradient.is_some()); - assert_eq!(position.canonicalize_gradient_pairs(), (false, false)); - - position.note_border_color = Some("#445566".to_string()); - position.note_border_gradient = serde_json::from_value(serde_json::json!({ - "angle": 90, - "stops": [ - { "color": "#112233", "pos": 0 }, - { "color": "transparent", "pos": 1 } - ] - })) - .unwrap(); - assert_eq!(position.canonicalize_gradient_pairs(), (true, true)); - assert_eq!(position.note_border_color.as_deref(), Some("#445566")); - assert!(position.note_border_gradient.is_none()); - - position.note_border_gradient = serde_json::from_value(serde_json::json!({ - "angle": 90, - "stops": [ - { "color": "#000000", "pos": 0.0 }, - { "color": "#111111", "pos": 0.1 }, - { "color": "#222222", "pos": 0.2 }, - { "color": "#333333", "pos": 0.3 }, - { "color": "#444444", "pos": 0.4 }, - { "color": "#555555", "pos": 0.5 }, - { "color": "#666666", "pos": 0.6 }, - { "color": "#777777", "pos": 0.7 }, - { "color": "invalid-discarded-stop", "pos": 1.0 } - ] - })) - .unwrap(); - assert_eq!( - position.note_border_gradient.as_ref().unwrap().stops.len(), - 8 - ); - assert_eq!(position.canonicalize_gradient_pairs(), (true, true)); - assert!(position.note_border_gradient.is_none()); - } - - #[test] - fn counter_gradient_escape_differs_from_every_legacy_snapshot_literal() { - let legacy_literals = [ - "#FFFFFF", - "#000000", - "rgba(121, 121, 121, 0.9)", - "transparent", - ]; - let visual_pairs = [ - ("#FFFFFF", "rgba(255,255,255,1)"), - ("#000000", "rgba(0,0,0,1)"), - ("rgba(121, 121, 121, 0.9)", "rgba(121,121,121,0.9)"), - ]; - - for (input, expected) in visual_pairs { - let escaped = compact_canonical_rgba(input); - assert_eq!(escaped, expected); - assert!(legacy_literals.iter().all(|literal| escaped != *literal)); - } - } - - #[test] - fn removed_custom_stroke_evidence_blocks_legacy_default_migration_permanently() { - let legacy_counter = |stroke: serde_json::Value, gradient: bool| { - let mut counter = serde_json::json!({ - "placement": "inside", - "align": "top", - "alignMode": "center", - "fill": { "idle": "#FFFFFF", "active": "#000000" }, - "stroke": stroke, - "gap": 6, - "fontSize": 16, - "fontWeight": 400, - "fontFamily": null, - "fontItalic": false, - "fontUnderline": false, - "fontStrikethrough": false - }); - if gradient { - counter["strokeIdleGradient"] = serde_json::json!({ "stops": [] }); - } - counter - }; - - for counter in [ - legacy_counter( - serde_json::json!({ "idle": "#123456", "active": "#FFFFFF" }), - false, - ), - legacy_counter( - serde_json::json!({ "idle": "#000000", "active": "#FFFFFF" }), - true, - ), - ] { - let mut raw = - serde_json::json!({ "keyPositions": { "4key": [{ "counter": counter }] } }); - assert!(scrub_removed_text_outline_fields(&mut raw)); - let mut parsed: KeyCounterSettings = - serde_json::from_value(raw["keyPositions"]["4key"][0]["counter"].clone()).unwrap(); - assert_eq!(parsed.fill.idle, "rgba(255,255,255,1)"); - assert!(!parsed.migrate_legacy_defaults()); - assert_eq!(parsed.align, KeyCounterAlign::Top); - } - - let mut raw = serde_json::json!({ - "keyPositions": { "4key": [{ - "counter": legacy_counter( - serde_json::json!({ "idle": "#000000", "active": "#FFFFFF" }), - false, - ) - }] } - }); - assert!(scrub_removed_text_outline_fields(&mut raw)); - let mut parsed: KeyCounterSettings = - serde_json::from_value(raw["keyPositions"]["4key"][0]["counter"].clone()).unwrap(); - assert!(parsed.migrate_legacy_defaults()); - assert_eq!(parsed.align, KeyCounterAlign::Bottom); - } - - #[derive(Deserialize)] - #[serde(rename_all = "camelCase")] - struct PreFeatureKeyPosition { - background_color: Option, - counter: PreFeatureCounterSettings, - } - - #[derive(serde::Serialize, Deserialize)] - #[serde(rename_all = "camelCase")] - struct PreUnifiedNotePosition { - dx: f64, - dy: f64, - width: f64, - count: u32, - note_color: NoteColor, - note_opacity: u32, - note_opacity_top: Option, - note_opacity_bottom: Option, - note_glow_color: Option, - note_glow_opacity: u32, - note_glow_opacity_top: Option, - note_glow_opacity_bottom: Option, - } - - #[derive(Deserialize)] - #[serde(rename_all = "camelCase")] - struct PreFeatureCounterSettings { - fill: KeyCounterColor, - placement: KeyCounterPlacement, - align: KeyCounterAlign, - align_mode: KeyCounterAlignMode, - gap: u32, - font_size: u32, - font_weight: u32, - font_family: Option, - font_italic: bool, - font_underline: bool, - font_strikethrough: bool, - } - - impl PreFeatureCounterSettings { - fn matches_legacy_migration_snapshot(&self) -> bool { - let shared = matches!(self.placement, KeyCounterPlacement::Inside) - && matches!(self.align, KeyCounterAlign::Top) - && matches!(self.align_mode, KeyCounterAlignMode::Center) - && self.gap == 6 - && self.font_size == 16 - && self.font_family.is_none() - && !self.font_italic - && !self.font_underline - && !self.font_strikethrough; - let oldest = self.fill.idle == "#FFFFFF" - && self.fill.active == "#000000" - && self.font_weight == 400; - let previous = self.fill.idle == "rgba(121, 121, 121, 0.9)" - && self.fill.active == "#FFFFFF" - && self.font_weight == 700; - shared && (oldest || previous) - } - } - - #[test] - fn pre_feature_shadow_downgrade_ignores_gradients_without_triggering_migration() { - for (legacy_fill, first_stop, expected_escape) in [ - ("#FFFFFF", "#FFFFFF", "rgba(255,255,255,1)"), - ( - "rgba(121, 121, 121, 0.9)", - "rgba(121, 121, 121, 0.9)", - "rgba(121,121,121,0.9)", - ), - ] { - let mut position: KeyPosition = serde_json::from_value(serde_json::json!({ - "dx": 0, - "dy": 0, - "width": 60, - "count": 0, - "backgroundColor": "#102030", - "backgroundGradient": { - "angle": 90, - "stops": [ - { "color": "#102030", "pos": 0 }, - { "color": "#405060", "pos": 1 } - ] - }, - "counter": { - "enabled": true, - "placement": "inside", - "align": "top", - "alignMode": "center", - "fill": { - "idle": legacy_fill, - "active": if legacy_fill == "#FFFFFF" { "#000000" } else { "#FFFFFF" } - }, - "fillIdleGradient": { - "angle": 90, - "stops": [ - { "color": first_stop, "pos": 0 }, - { "color": "#654321", "pos": 1 } - ] - }, - "gap": 6, - "fontSize": 16, - "fontWeight": if legacy_fill == "#FFFFFF" { 400 } else { 700 }, - "fontFamily": null, - "fontItalic": false, - "fontUnderline": false, - "fontStrikethrough": false - } - })) - .unwrap(); - - let (_, pair_repaired) = position.canonicalize_gradient_pairs(); - assert!(pair_repaired); - assert_eq!(position.counter.fill.idle, expected_escape); - assert!(!position.counter.migrate_legacy_defaults()); - - let serialized = serde_json::to_value(&position).unwrap(); - let shadow: PreFeatureKeyPosition = serde_json::from_value(serialized).unwrap(); - assert_eq!(shadow.background_color.as_deref(), Some("#102030")); - assert_eq!(shadow.counter.fill.idle, expected_escape); - assert!(!shadow.counter.matches_legacy_migration_snapshot()); - } - } - - #[test] - fn pre_unification_round_trip_drops_siblings_and_preserves_note_shadows() { - let mut position: KeyPosition = serde_json::from_value(serde_json::json!({ - "dx": 0, - "dy": 0, - "width": 60, - "count": 0, - "noteOpacity": 80, - "noteGradient": { - "angle": 45, - "stops": [ - { "color": "rgba(17,34,51,.5)", "pos": 0 }, - { "color": "#44556640", "pos": 1 } - ] - }, - "noteGlowOpacity": 60, - "noteGlowGradient": { - "angle": 135, - "stops": [ - { "color": "#77889980", "pos": 0 }, - { "color": "rgb(170,187,204)", "pos": 1 } - ] - } - })) - .unwrap(); - assert_eq!(position.canonicalize_gradient_pairs(), (true, true)); - let expected_note_color = position.note_color.clone(); - let expected_note_top = position.note_opacity_top; - let expected_note_bottom = position.note_opacity_bottom; - let expected_glow_color = position.note_glow_color.clone(); - let expected_glow_top = position.note_glow_opacity_top; - let expected_glow_bottom = position.note_glow_opacity_bottom; - - let new_wire = serde_json::to_value(position).unwrap(); - let old: PreUnifiedNotePosition = serde_json::from_value(new_wire).unwrap(); - let old_wire = serde_json::to_value(old).unwrap(); - assert!(old_wire.get("noteGradient").is_none()); - assert!(old_wire.get("noteGlowGradient").is_none()); - - let restored: KeyPosition = serde_json::from_value(old_wire).unwrap(); - assert!(restored.note_gradient.is_none()); - assert!(restored.note_glow_gradient.is_none()); - assert_eq!(restored.note_color, expected_note_color); - assert_eq!(restored.note_opacity_top, expected_note_top); - assert_eq!(restored.note_opacity_bottom, expected_note_bottom); - assert_eq!(restored.note_glow_color, expected_glow_color); - assert_eq!(restored.note_glow_opacity_top, expected_glow_top); - assert_eq!(restored.note_glow_opacity_bottom, expected_glow_bottom); - } - - #[test] - fn counter_migration_without_gradients_preserves_both_legacy_upgrade_branches() { - for snapshot in [ - serde_json::json!({ - "placement": "inside", - "align": "top", - "alignMode": "center", - "fill": { "idle": "#FFFFFF", "active": "#000000" }, - "stroke": { "idle": "#000000", "active": "#FFFFFF" }, - "gap": 6, - "fontSize": 16, - "fontWeight": 400, - "fontFamily": null, - "fontItalic": false, - "fontUnderline": false, - "fontStrikethrough": false - }), - serde_json::json!({ - "placement": "inside", - "align": "top", - "alignMode": "center", - "fill": { - "idle": "rgba(121, 121, 121, 0.9)", - "active": "#FFFFFF" - }, - "stroke": { "idle": "transparent", "active": "transparent" }, - "gap": 6, - "fontSize": 16, - "fontWeight": 700, - "fontFamily": null, - "fontItalic": false, - "fontUnderline": false, - "fontStrikethrough": false - }), - ] { - let mut counter: KeyCounterSettings = serde_json::from_value(snapshot).unwrap(); - - assert!(counter.migrate_legacy_defaults()); - assert_eq!(counter, KeyCounterSettings::default()); - } - } -} +mod tests; diff --git a/src-tauri/src/models/tests.rs b/src-tauri/src/models/tests.rs new file mode 100644 index 000000000..8f76a6738 --- /dev/null +++ b/src-tauri/src/models/tests.rs @@ -0,0 +1,1132 @@ + +use super::{ + compact_canonical_rgba, note_border_representative_hex, scrub_removed_text_outline_fields, + FadePosition, GradientSpec, GraphPosition, GraphStatType, GraphType, KeyCounterAlign, + KeyCounterAlignMode, KeyCounterColor, KeyCounterPlacement, KeyCounterSettings, KeyMappings, + KeyPosition, KeySlot, KnobPosition, NoteColor, NoteSettings, SlotMatch, StatPosition, StatType, + MAX_SLOT_KEYS, POSITION_COLLECTION_FIELDS, +}; +use serde::Deserialize; + +const NOTE_BORDER_STOP_COLOR_FIXTURE: &str = + include_str!("../../../tests/fixtures/note-border-stop-colors.json"); + +#[derive(Deserialize)] +struct NoteBorderStopColorFixture { + valid: Vec, + invalid: Vec, +} + +#[derive(Deserialize)] +struct ValidNoteBorderStopColor { + input: String, + representative: String, +} + +#[test] +fn legacy_string_key_mappings_round_trip_without_loss() { + let raw = serde_json::json!({ + "4key": ["A", "A+B", "+", ""] + }); + + let mappings: KeyMappings = serde_json::from_value(raw.clone()).unwrap(); + + assert_eq!(serde_json::to_value(mappings).unwrap(), raw); +} + +#[test] +fn legacy_700_weights_migrate_to_400_with_bold_modifier() { + let mut raw = serde_json::to_value(KeyPosition::default()).unwrap(); + let object = raw.as_object_mut().unwrap(); + object.insert("fontWeight".to_string(), serde_json::json!(700)); + object.remove("fontBold"); + let counter = object["counter"].as_object_mut().unwrap(); + counter.insert("fontWeight".to_string(), serde_json::json!(700)); + counter.remove("fontBold"); + + let mut position: KeyPosition = serde_json::from_value(raw).unwrap(); + assert!(position.migrate_legacy_font_weight()); + assert_eq!(position.font_weight, Some(400)); + assert_eq!(position.font_bold, Some(true)); + assert_eq!(position.counter.font_weight, 400); + assert_eq!(position.counter.font_bold, Some(true)); + assert!(!position.migrate_legacy_font_weight()); +} + +#[test] +fn legacy_non_bold_weights_remain_sparse() { + let mut raw = serde_json::to_value(KeyPosition::default()).unwrap(); + let object = raw.as_object_mut().unwrap(); + object.insert("fontWeight".to_string(), serde_json::json!(600)); + object.remove("fontBold"); + let counter = object["counter"].as_object_mut().unwrap(); + counter.insert("fontWeight".to_string(), serde_json::json!(500)); + counter.remove("fontBold"); + + let mut position: KeyPosition = serde_json::from_value(raw).unwrap(); + assert!(!position.migrate_legacy_font_weight()); + assert_eq!(position.font_weight, Some(600)); + assert_eq!(position.font_bold, None); + assert_eq!(position.counter.font_weight, 500); + assert_eq!(position.counter.font_bold, None); +} + +#[test] +fn element_id_defaults_to_empty_and_flattens_into_every_position_type() { + let id = uuid::Uuid::new_v4().to_string(); + let position = KeyPosition { + id: id.clone(), + ..KeyPosition::default() + }; + let mut values = [ + serde_json::to_value(&position).unwrap(), + serde_json::to_value(StatPosition { + stat_type: StatType::Kps, + position: position.clone(), + }) + .unwrap(), + serde_json::to_value(GraphPosition { + stat_type: GraphStatType::Kps, + graph_type: GraphType::Line, + graph_speed: 100, + graph_color: "#123456".to_string(), + show_avg_line: true, + position: position.clone(), + }) + .unwrap(), + serde_json::to_value(KnobPosition { + axis_id: "axis".to_string(), + sensitivity: 1.0, + reverse: false, + position, + }) + .unwrap(), + ]; + + assert!(values.iter().all(|value| value["id"] == id)); + for value in &mut values { + value.as_object_mut().unwrap().remove("id"); + } + let stat: StatPosition = serde_json::from_value(values[1].clone()).unwrap(); + let graph: GraphPosition = serde_json::from_value(values[2].clone()).unwrap(); + let knob: KnobPosition = serde_json::from_value(values[3].clone()).unwrap(); + assert!(stat.position.id.is_empty()); + assert!(graph.position.id.is_empty()); + assert!(knob.position.id.is_empty()); + + let missing: KeyPosition = serde_json::from_value(serde_json::json!({ + "dx": 0, + "dy": 0, + "width": 60, + "count": 0 + })) + .unwrap(); + assert!(missing.id.is_empty()); +} + +#[test] +fn new_surface_gradients_flatten_into_every_position_collection() { + let position = KeyPosition { + note_border_gradient: serde_json::from_value(serde_json::json!({ + "angle": 90, + "stops": [ + { "color": "#112233", "pos": 0 }, + { "color": "#445566", "pos": 1 } + ] + })) + .unwrap(), + font_gradient: serde_json::from_value(serde_json::json!({ + "angle": 45, + "stops": [ + { "color": "#556677", "pos": 0 }, + { "color": "#8899AA", "pos": 1 } + ] + })) + .unwrap(), + active_font_gradient: serde_json::from_value(serde_json::json!({ + "angle": 135, + "stops": [ + { "color": "#AABBCC", "pos": 0 }, + { "color": "#DDEEFF", "pos": 1 } + ] + })) + .unwrap(), + ..KeyPosition::default() + }; + let values = [ + serde_json::to_value(&position).unwrap(), + serde_json::to_value(StatPosition { + stat_type: StatType::Kps, + position: position.clone(), + }) + .unwrap(), + serde_json::to_value(GraphPosition { + stat_type: GraphStatType::Kps, + graph_type: GraphType::Line, + graph_speed: 100, + graph_color: "#123456".to_string(), + show_avg_line: true, + position: position.clone(), + }) + .unwrap(), + serde_json::to_value(KnobPosition { + axis_id: "axis".to_string(), + sensitivity: 1.0, + reverse: false, + position, + }) + .unwrap(), + ]; + + for value in values { + assert_eq!(value["noteBorderGradient"]["angle"], 90.0); + assert_eq!(value["fontGradient"]["angle"], 45.0); + assert_eq!(value["activeFontGradient"]["angle"], 135.0); + } +} + +#[test] +fn multi_key_slot_wire_shape_and_canonical_are_stable() { + let raw = serde_json::json!({ "keys": ["LEFT CTRL", "Z"], "match": "all" }); + + let slot: KeySlot = serde_json::from_value(raw.clone()).unwrap(); + + assert_eq!( + slot, + KeySlot::Multi { + keys: vec!["LEFT CTRL".to_string(), "Z".to_string()], + match_mode: SlotMatch::All, + } + ); + assert_eq!(slot.canonical(), "LEFT CTRL+Z"); + assert_eq!(serde_json::to_value(slot).unwrap(), raw); +} + +#[test] +fn malformed_key_slots_normalize_without_deserialization_failure() { + let too_many = (0..=MAX_SLOT_KEYS) + .map(|index| serde_json::Value::String(format!("K{index}"))) + .collect::>(); + let cases = [ + (serde_json::json!({ "keys": ["Z"] }), KeySlot::default()), + ( + serde_json::json!({ + "keys": ["A", 7, "", "A", "B+C", "D|E", "B"], + "match": "any", + "ignored": true + }), + KeySlot::Multi { + keys: vec!["A".to_string(), "B".to_string()], + match_mode: SlotMatch::Any, + }, + ), + ( + serde_json::json!({ "keys": ["Z"], "match": "all" }), + KeySlot::Single("Z".to_string()), + ), + ( + serde_json::json!({ "keys": [], "match": "any" }), + KeySlot::default(), + ), + ( + serde_json::json!({ "keys": too_many, "match": "any" }), + KeySlot::Multi { + keys: (0..MAX_SLOT_KEYS) + .map(|index| format!("K{index}")) + .collect(), + match_mode: SlotMatch::Any, + }, + ), + (serde_json::json!(42), KeySlot::default()), + (serde_json::json!(["A", "B"]), KeySlot::default()), + (serde_json::Value::Null, KeySlot::default()), + ]; + + for (raw, expected) in cases { + let slot: KeySlot = serde_json::from_value(raw).unwrap(); + assert_eq!(slot, expected); + } +} + +#[test] +fn stat_type_wire_values_round_trip() { + for (stat_type, wire_value) in [ + (StatType::Kps, "kps"), + (StatType::KpsAvg, "kpsAvg"), + (StatType::KpsMax, "kpsMax"), + (StatType::Total, "total"), + ] { + let serialized = serde_json::to_value(&stat_type).unwrap(); + assert_eq!(serialized, wire_value); + + let restored: StatType = serde_json::from_value(serialized).unwrap(); + assert_eq!(restored, stat_type); + } +} + +// 필수 필드만 채운 최소 KeyPosition JSON. 시각 px 필드는 호출부에서 주입 +fn key_position_json(visual_px: &str) -> String { + format!( + r##"{{ + "dx": 0.0, "dy": 0.0, "width": 60.0, "height": 60.0, + "count": 0, "noteColor": "#FFFFFF", "noteOpacity": 80, + {visual_px} + }}"## + ) +} + +// 기존 정수 저장값이 f64 필드로 그대로 역직렬화되는지 (하위 호환) +#[test] +fn visual_px_fields_accept_integer_json() { + let json = key_position_json(r#""noteWidth": 100, "noteBorderRadius": 8, "noteGlowSize": 20"#); + let pos: KeyPosition = serde_json::from_str(&json).unwrap(); + assert_eq!(pos.note_width, Some(100.0)); + assert_eq!(pos.note_border_radius, Some(8.0)); + assert_eq!(pos.note_glow_size, 20.0); +} + +// 소수 저장값이 정상 역직렬화되는지 +#[test] +fn visual_px_fields_accept_decimal_json() { + let json = + key_position_json(r#""noteWidth": 100.5, "noteBorderRadius": 8.5, "noteGlowSize": 20.5"#); + let pos: KeyPosition = serde_json::from_str(&json).unwrap(); + assert_eq!(pos.note_width, Some(100.5)); + assert_eq!(pos.note_border_radius, Some(8.5)); + assert_eq!(pos.note_glow_size, 20.5); +} + +// note_glow_size 미지정 시 기본값(10.0) 적용 +#[test] +fn note_glow_size_defaults_to_10() { + let json = key_position_json(r#""noteWidth": null"#); + let pos: KeyPosition = serde_json::from_str(&json).unwrap(); + assert_eq!(pos.note_glow_size, 10.0); + assert_eq!(pos.note_width, None); +} + +#[test] +fn gradient_opacity_fields_survive_serde_round_trip() { + let json = key_position_json( + r#""noteOpacityTop": 91, "noteOpacityBottom": 37, + "noteGlowOpacityTop": 64, "noteGlowOpacityBottom": 18"#, + ); + let position: KeyPosition = serde_json::from_str(&json).unwrap(); + + assert_eq!(position.note_opacity_top, Some(91)); + assert_eq!(position.note_opacity_bottom, Some(37)); + assert_eq!(position.note_glow_opacity_top, Some(64)); + assert_eq!(position.note_glow_opacity_bottom, Some(18)); + + let serialized = serde_json::to_value(&position).unwrap(); + assert_eq!(serialized["noteOpacityTop"], 91); + assert_eq!(serialized["noteOpacityBottom"], 37); + assert_eq!(serialized["noteGlowOpacityTop"], 64); + assert_eq!(serialized["noteGlowOpacityBottom"], 18); + + let restored: KeyPosition = serde_json::from_value(serialized).unwrap(); + assert_eq!(restored, position); +} + +#[test] +fn note_settings_1_3_format_still_preserves_every_field() { + // 1.3 시절 noteSettings 전체 필드 실형식 + let fixture = r#"{ + "borderRadius": 9, + "speed": 456, + "trackHeight": 222, + "reverse": true, + "fadePosition": "bottom", + "delayedNoteEnabled": true, + "shortNoteThresholdMs": 73, + "shortNoteMinLengthPx": 41 + }"#; + let settings: NoteSettings = serde_json::from_str(fixture).unwrap(); + + assert_eq!(settings.border_radius, Some(9)); + assert_eq!(settings.speed, 456); + assert_eq!(settings.track_height, 222); + assert!(settings.reverse); + assert_eq!(settings.fade_position, FadePosition::Bottom); + assert!(settings.delayed_note_enabled); + assert_eq!(settings.short_note_threshold_ms, 73); + assert_eq!(settings.short_note_min_length_px, 41); +} + +#[test] +fn key_position_1_0_missing_visual_fields_uses_legacy_defaults() { + let position: KeyPosition = + serde_json::from_str(r#"{"dx":777,"dy":12,"width":60,"count":42}"#).unwrap(); + + assert_eq!(position.dx, 777.0); + assert_eq!(position.count, 42); + assert_eq!(position.height, 60.0); + assert_eq!(position.note_color, NoteColor::Solid("#FFFFFF".to_string())); + assert_eq!(position.note_opacity, 90); + assert_eq!(position.shadow, None); + assert_eq!(position.active_shadow, None); +} + +#[test] +fn key_position_visual_effects_round_trip_without_rewriting_missing_defaults() { + let fixture = serde_json::json!({ + "dx": 0, + "dy": 0, + "width": 60, + "count": 0, + "shadow": { + "enabled": true, + "color": "rgba(10, 20, 30, 0.45)", + "offsetX": -2.0, + "offsetY": 7.0, + "blur": 18.0 + }, + "activeShadow": { + "enabled": false, + "color": "rgba(0, 0, 0, 0.32)", + "offsetX": 0.0, + "offsetY": 3.0, + "blur": 8.0 + } + }); + + let position: KeyPosition = serde_json::from_value(fixture.clone()).unwrap(); + let serialized = serde_json::to_value(position).unwrap(); + + assert_eq!(serialized.get("shadow"), fixture.get("shadow")); + assert_eq!(serialized.get("activeShadow"), fixture.get("activeShadow")); +} + +#[test] +fn gradient_spec_tolerates_legacy_shape_and_serializes_canonically() { + let gradient: GradientSpec = serde_json::from_value(serde_json::json!({ + "type": "linear", + "stops": [ + { "color": "c9", "pos": 1.4 }, + { "color": "c8", "pos": 0.8 }, + { "color": "c7", "pos": 0.7 }, + { "color": "c6", "pos": 0.6 }, + { "color": "c5", "pos": 0.5 }, + { "color": "c4", "pos": 0.4 }, + { "color": "c3", "pos": 0.3 }, + { "color": "c2", "pos": 0.2 }, + { "color": "c1", "pos": 0.1 }, + { "color": "c0", "pos": -0.2 } + ] + })) + .unwrap(); + + assert_eq!(gradient.angle, 90.0); + assert_eq!(gradient.stops.len(), 8); + assert_eq!(gradient.stops.first().unwrap().color, "c0"); + assert_eq!(gradient.stops.first().unwrap().pos, 0.0); + assert_eq!(gradient.stops.last().unwrap().color, "c7"); + + let canonical = serde_json::to_value(&gradient).unwrap(); + assert_eq!(canonical["angle"], 90.0); + assert_eq!(canonical["stops"].as_array().unwrap().len(), 8); + assert!(canonical.get("type").is_none()); + + let restored: GradientSpec = serde_json::from_value(canonical.clone()).unwrap(); + assert_eq!(serde_json::to_value(restored).unwrap(), canonical); +} + +#[test] +fn gradient_spec_rejects_fewer_than_two_stops() { + let error = serde_json::from_value::(serde_json::json!({ + "angle": 90, + "stops": [{ "color": "#FFFFFF", "pos": 0 }] + })) + .unwrap_err(); + + assert!(error.to_string().contains("at least two stops")); +} + +#[test] +fn gradient_spec_rejects_null_angle_but_preserves_stop_alpha_strings() { + let error = serde_json::from_value::(serde_json::json!({ + "angle": null, + "stops": [ + { "color": "rgba(1,2,3,0)", "pos": 0 }, + { "color": "rgba(1,2,3,1)", "pos": 1 } + ] + })) + .unwrap_err(); + assert!(error.to_string().contains("invalid gradient angle")); + + let gradient: GradientSpec = serde_json::from_value(serde_json::json!({ + "stops": [ + { "color": "rgba(1,2,3,0)", "pos": 0 }, + { "color": "rgba(1,2,3,0.5)", "pos": 0.5 }, + { "color": "rgba(1,2,3,1)", "pos": 1 } + ] + })) + .unwrap(); + assert_eq!( + gradient + .stops + .iter() + .map(|stop| stop.color.as_str()) + .collect::>(), + ["rgba(1,2,3,0)", "rgba(1,2,3,0.5)", "rgba(1,2,3,1)"] + ); +} + +#[test] +fn note_border_stop_color_parser_matches_shared_fixture() { + let fixture: NoteBorderStopColorFixture = + serde_json::from_str(NOTE_BORDER_STOP_COLOR_FIXTURE).unwrap(); + + for case in fixture.valid { + assert_eq!( + note_border_representative_hex(&case.input), + Some(case.representative), + "valid fixture mismatch for {:?}", + case.input + ); + } + for input in fixture.invalid { + assert_eq!( + note_border_representative_hex(&input), + None, + "invalid fixture mismatch for {input:?}" + ); + } +} + +#[test] +fn new_gradient_fields_round_trip_and_missing_fields_remain_none() { + let legacy: KeyPosition = serde_json::from_value(serde_json::json!({ + "dx": 0, + "dy": 0, + "width": 60, + "count": 0 + })) + .unwrap(); + assert!(legacy.note_gradient.is_none()); + assert!(legacy.note_glow_gradient.is_none()); + assert!(legacy.note_border_gradient.is_none()); + assert!(legacy.font_gradient.is_none()); + assert!(legacy.active_font_gradient.is_none()); + + let value = serde_json::json!({ + "dx": 0, + "dy": 0, + "width": 60, + "count": 0, + "noteGradient": { + "angle": 15, + "stops": [ + { "color": "#1238", "pos": 0 }, + { "color": "rgba(4,5,6,.5)", "pos": 1 } + ] + }, + "noteGlowGradient": { + "angle": 25, + "stops": [ + { "color": "rgb(7,8,9)", "pos": 0 }, + { "color": "#ABC0", "pos": 1 } + ] + }, + "noteBorderColor": "#112233", + "noteBorderGradient": { + "angle": 45, + "stops": [ + { "color": "rgba(17,34,51,.5)", "pos": 0 }, + { "color": "#ABC", "pos": 1 } + ] + }, + "fontColor": "#112233", + "fontGradient": { + "angle": 90, + "stops": [ + { "color": "#112233", "pos": 0 }, + { "color": "#445566", "pos": 1 } + ] + }, + "activeFontColor": "#778899", + "activeFontGradient": { + "angle": 180, + "stops": [ + { "color": "#778899", "pos": 0 }, + { "color": "#AABBCC", "pos": 1 } + ] + } + }); + let position: KeyPosition = serde_json::from_value(value).unwrap(); + let serialized = serde_json::to_value(&position).unwrap(); + assert!(serialized.get("noteGradient").is_some()); + assert!(serialized.get("noteGlowGradient").is_some()); + assert!(serialized.get("noteBorderGradient").is_some()); + assert!(serialized.get("fontGradient").is_some()); + assert!(serialized.get("activeFontGradient").is_some()); + assert_eq!( + serde_json::from_value::(serialized).unwrap(), + position + ); +} + +#[test] +fn removed_text_outline_fields_scrub_every_collection_in_place() { + let position = serde_json::json!({ + "id": "keep-entry", + "fontStrokeColor": "#111111", + "activeFontStrokeColor": "#222222", + "counter": { + "stroke": { "idle": "#333333", "active": "#444444" }, + "strokeIdleGradient": { "stops": [] }, + "strokeActiveGradient": { "stops": [] }, + "fill": { "idle": "keep-idle", "active": "keep-active" } + } + }); + let mut value = serde_json::json!({ + "keyPositions": { "4key": [position.clone()] }, + "statPositions": { "4key": [position.clone()] }, + "graphPositions": { "4key": [position.clone()] }, + "knobPositions": { "4key": [position] } + }); + + assert!(scrub_removed_text_outline_fields(&mut value)); + for collection in POSITION_COLLECTION_FIELDS { + let entries = value[collection]["4key"].as_array().unwrap(); + assert_eq!(entries.len(), 1); + let position = entries[0].as_object().unwrap(); + assert_eq!(position["id"], "keep-entry"); + assert!(!position.contains_key("fontStrokeColor")); + assert!(!position.contains_key("activeFontStrokeColor")); + let counter = position["counter"].as_object().unwrap(); + assert!(!counter.contains_key("stroke")); + assert!(!counter.contains_key("strokeIdleGradient")); + assert!(!counter.contains_key("strokeActiveGradient")); + assert_eq!(counter["fill"]["idle"], "keep-idle"); + } + assert!(!scrub_removed_text_outline_fields(&mut value)); +} + +#[test] +fn note_gradients_atomically_canonicalize_color_and_alpha_shadows() { + let mut position: KeyPosition = serde_json::from_value(serde_json::json!({ + "dx": 0, + "dy": 0, + "width": 60, + "count": 0, + "noteColor": "stale-body", + "noteOpacity": 75, + "noteOpacityTop": 1, + "noteOpacityBottom": 2, + "noteGradient": { + "angle": 15, + "stops": [ + { "color": "#1238", "pos": 0 }, + { "color": "rgba(4,5,6,.5)", "pos": 1 } + ] + }, + "noteGlowColor": "stale-glow", + "noteGlowOpacity": 60, + "noteGlowOpacityTop": 3, + "noteGlowOpacityBottom": 4, + "noteGlowGradient": { + "angle": 25, + "stops": [ + { "color": "rgb(7,8,9)", "pos": 0 }, + { "color": "#ABC0", "pos": 1 } + ] + } + })) + .unwrap(); + + assert_eq!(position.canonicalize_gradient_pairs(), (true, true)); + assert_eq!( + position.note_color, + NoteColor::Gradient { + top: "#112233".to_string(), + bottom: "#040506".to_string(), + } + ); + assert_eq!(position.note_opacity_top, Some(40)); + assert_eq!(position.note_opacity_bottom, Some(38)); + assert_eq!( + position.note_glow_color, + Some(NoteColor::Gradient { + top: "#070809".to_string(), + bottom: "#AABBCC".to_string(), + }) + ); + assert_eq!(position.note_glow_opacity_top, Some(60)); + assert_eq!(position.note_glow_opacity_bottom, Some(0)); + assert_eq!(position.canonicalize_gradient_pairs(), (false, false)); +} + +#[test] +fn synced_note_glow_canonicalization_refreshes_stale_mirror_without_pair_repair() { + let mut position = KeyPosition { + note_glow_sync_paint: true, + note_color: NoteColor::Gradient { + top: "#112233".to_string(), + bottom: "#445566".to_string(), + }, + note_gradient: serde_json::from_value(serde_json::json!({ + "angle": 180, + "stops": [ + { "color": "#112233", "pos": 0 }, + { "color": "rgba(68, 85, 102, 0.5)", "pos": 1 } + ] + })) + .unwrap(), + note_opacity: 80, + note_opacity_top: Some(80), + note_opacity_bottom: Some(40), + note_glow_color: Some(NoteColor::Solid("stale".to_string())), + note_glow_opacity: 70, + note_glow_opacity_top: Some(70), + note_glow_opacity_bottom: Some(70), + ..KeyPosition::default() + }; + + assert_eq!(position.canonicalize_gradient_pairs(), (true, false)); + assert_eq!(position.note_glow_gradient, position.note_gradient); + assert_eq!(position.note_glow_opacity, position.note_opacity); + assert_eq!( + position.note_glow_color.as_ref(), + Some(&position.note_color) + ); + assert_eq!(position.note_glow_opacity_top, position.note_opacity_top); + assert_eq!( + position.note_glow_opacity_bottom, + position.note_opacity_bottom + ); +} + +#[test] +fn synced_note_glow_canonicalization_is_idempotent_when_mirror_matches() { + let mut position = KeyPosition { + note_glow_sync_paint: true, + note_color: NoteColor::Solid("#112233".to_string()), + note_opacity: 80, + note_opacity_top: Some(70), + note_opacity_bottom: Some(60), + ..KeyPosition::default() + }; + assert!(position.mirror_note_body_to_glow()); + + assert_eq!(position.canonicalize_gradient_pairs(), (false, false)); +} + +#[test] +fn font_gradient_pairs_are_optional_and_canonicalize_representative_colors() { + let mut position = KeyPosition { + font_color: Some("stale-idle".to_string()), + font_gradient: serde_json::from_value(serde_json::json!({ + "angle": 405, + "stops": [ + { "color": "rgba(17, 34, 51, .5)", "pos": 0 }, + { "color": "#445566", "pos": 1 } + ] + })) + .unwrap(), + active_font_color: None, + active_font_gradient: serde_json::from_value(serde_json::json!({ + "angle": -45, + "stops": [ + { "color": "#778899", "pos": 0 }, + { "color": "rgb(170, 187, 204)", "pos": 1 } + ] + })) + .unwrap(), + ..KeyPosition::default() + }; + + assert_eq!(position.canonicalize_gradient_pairs(), (true, true)); + assert_eq!(position.font_color.as_deref(), Some("rgba(17, 34, 51, .5)")); + assert_eq!(position.active_font_color.as_deref(), Some("#778899")); + assert_eq!(position.font_gradient.as_ref().unwrap().angle, 45.0); + assert_eq!(position.active_font_gradient.as_ref().unwrap().angle, 315.0); + assert_eq!(position.canonicalize_gradient_pairs(), (false, false)); + + let serialized_default = serde_json::to_value(KeyPosition::default()).unwrap(); + assert!(serialized_default.get("fontGradient").is_none()); + assert!(serialized_default.get("activeFontGradient").is_none()); +} + +#[test] +fn note_gradient_rejects_invalid_original_stop_before_truncation() { + let mut position = KeyPosition { + note_color: NoteColor::Solid("preserved".to_string()), + note_gradient: serde_json::from_value(serde_json::json!({ + "angle": 90, + "stops": [ + { "color": "#000000", "pos": 0.0 }, + { "color": "#111111", "pos": 0.1 }, + { "color": "#222222", "pos": 0.2 }, + { "color": "#333333", "pos": 0.3 }, + { "color": "#444444", "pos": 0.4 }, + { "color": "#555555", "pos": 0.5 }, + { "color": "#666666", "pos": 0.6 }, + { "color": "#777777", "pos": 0.7 }, + { "color": "invalid-discarded-stop", "pos": 1.0 } + ] + })) + .unwrap(), + ..KeyPosition::default() + }; + + assert_eq!(position.note_gradient.as_ref().unwrap().stops.len(), 8); + assert_eq!(position.canonicalize_gradient_pairs(), (true, true)); + assert!(position.note_gradient.is_none()); + assert_eq!( + position.note_color, + NoteColor::Solid("preserved".to_string()) + ); +} + +#[test] +fn note_border_gradient_canonicalization_uses_hex_and_drops_invalid_stop() { + let mut position: KeyPosition = serde_json::from_value(serde_json::json!({ + "dx": 0, + "dy": 0, + "width": 60, + "count": 0, + "noteBorderColor": "#000000", + "noteBorderGradient": { + "angle": 90, + "stops": [ + { "color": "rgba(17, 34, 51, .5)", "pos": 0 }, + { "color": "#ABC", "pos": 1 } + ] + } + })) + .unwrap(); + + assert_eq!(position.canonicalize_gradient_pairs(), (true, true)); + assert_eq!(position.note_border_color.as_deref(), Some("#112233")); + assert!(position.note_border_gradient.is_some()); + assert_eq!(position.canonicalize_gradient_pairs(), (false, false)); + + position.note_border_color = Some("#445566".to_string()); + position.note_border_gradient = serde_json::from_value(serde_json::json!({ + "angle": 90, + "stops": [ + { "color": "#112233", "pos": 0 }, + { "color": "transparent", "pos": 1 } + ] + })) + .unwrap(); + assert_eq!(position.canonicalize_gradient_pairs(), (true, true)); + assert_eq!(position.note_border_color.as_deref(), Some("#445566")); + assert!(position.note_border_gradient.is_none()); + + position.note_border_gradient = serde_json::from_value(serde_json::json!({ + "angle": 90, + "stops": [ + { "color": "#000000", "pos": 0.0 }, + { "color": "#111111", "pos": 0.1 }, + { "color": "#222222", "pos": 0.2 }, + { "color": "#333333", "pos": 0.3 }, + { "color": "#444444", "pos": 0.4 }, + { "color": "#555555", "pos": 0.5 }, + { "color": "#666666", "pos": 0.6 }, + { "color": "#777777", "pos": 0.7 }, + { "color": "invalid-discarded-stop", "pos": 1.0 } + ] + })) + .unwrap(); + assert_eq!( + position.note_border_gradient.as_ref().unwrap().stops.len(), + 8 + ); + assert_eq!(position.canonicalize_gradient_pairs(), (true, true)); + assert!(position.note_border_gradient.is_none()); +} + +#[test] +fn counter_gradient_escape_differs_from_every_legacy_snapshot_literal() { + let legacy_literals = [ + "#FFFFFF", + "#000000", + "rgba(121, 121, 121, 0.9)", + "transparent", + ]; + let visual_pairs = [ + ("#FFFFFF", "rgba(255,255,255,1)"), + ("#000000", "rgba(0,0,0,1)"), + ("rgba(121, 121, 121, 0.9)", "rgba(121,121,121,0.9)"), + ]; + + for (input, expected) in visual_pairs { + let escaped = compact_canonical_rgba(input); + assert_eq!(escaped, expected); + assert!(legacy_literals.iter().all(|literal| escaped != *literal)); + } +} + +#[test] +fn removed_custom_stroke_evidence_blocks_legacy_default_migration_permanently() { + let legacy_counter = |stroke: serde_json::Value, gradient: bool| { + let mut counter = serde_json::json!({ + "placement": "inside", + "align": "top", + "alignMode": "center", + "fill": { "idle": "#FFFFFF", "active": "#000000" }, + "stroke": stroke, + "gap": 6, + "fontSize": 16, + "fontWeight": 400, + "fontFamily": null, + "fontItalic": false, + "fontUnderline": false, + "fontStrikethrough": false + }); + if gradient { + counter["strokeIdleGradient"] = serde_json::json!({ "stops": [] }); + } + counter + }; + + for counter in [ + legacy_counter( + serde_json::json!({ "idle": "#123456", "active": "#FFFFFF" }), + false, + ), + legacy_counter( + serde_json::json!({ "idle": "#000000", "active": "#FFFFFF" }), + true, + ), + ] { + let mut raw = serde_json::json!({ "keyPositions": { "4key": [{ "counter": counter }] } }); + assert!(scrub_removed_text_outline_fields(&mut raw)); + let mut parsed: KeyCounterSettings = + serde_json::from_value(raw["keyPositions"]["4key"][0]["counter"].clone()).unwrap(); + assert_eq!(parsed.fill.idle, "rgba(255,255,255,1)"); + assert!(!parsed.migrate_legacy_defaults()); + assert_eq!(parsed.align, KeyCounterAlign::Top); + } + + let mut raw = serde_json::json!({ + "keyPositions": { "4key": [{ + "counter": legacy_counter( + serde_json::json!({ "idle": "#000000", "active": "#FFFFFF" }), + false, + ) + }] } + }); + assert!(scrub_removed_text_outline_fields(&mut raw)); + let mut parsed: KeyCounterSettings = + serde_json::from_value(raw["keyPositions"]["4key"][0]["counter"].clone()).unwrap(); + assert!(parsed.migrate_legacy_defaults()); + assert_eq!(parsed.align, KeyCounterAlign::Bottom); +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct PreFeatureKeyPosition { + background_color: Option, + counter: PreFeatureCounterSettings, +} + +#[derive(serde::Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PreUnifiedNotePosition { + dx: f64, + dy: f64, + width: f64, + count: u32, + note_color: NoteColor, + note_opacity: u32, + note_opacity_top: Option, + note_opacity_bottom: Option, + note_glow_color: Option, + note_glow_opacity: u32, + note_glow_opacity_top: Option, + note_glow_opacity_bottom: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct PreFeatureCounterSettings { + fill: KeyCounterColor, + placement: KeyCounterPlacement, + align: KeyCounterAlign, + align_mode: KeyCounterAlignMode, + gap: u32, + font_size: u32, + font_weight: u32, + font_family: Option, + font_italic: bool, + font_underline: bool, + font_strikethrough: bool, +} + +impl PreFeatureCounterSettings { + fn matches_legacy_migration_snapshot(&self) -> bool { + let shared = matches!(self.placement, KeyCounterPlacement::Inside) + && matches!(self.align, KeyCounterAlign::Top) + && matches!(self.align_mode, KeyCounterAlignMode::Center) + && self.gap == 6 + && self.font_size == 16 + && self.font_family.is_none() + && !self.font_italic + && !self.font_underline + && !self.font_strikethrough; + let oldest = + self.fill.idle == "#FFFFFF" && self.fill.active == "#000000" && self.font_weight == 400; + let previous = self.fill.idle == "rgba(121, 121, 121, 0.9)" + && self.fill.active == "#FFFFFF" + && self.font_weight == 700; + shared && (oldest || previous) + } +} + +#[test] +fn pre_feature_shadow_downgrade_ignores_gradients_without_triggering_migration() { + for (legacy_fill, first_stop, expected_escape) in [ + ("#FFFFFF", "#FFFFFF", "rgba(255,255,255,1)"), + ( + "rgba(121, 121, 121, 0.9)", + "rgba(121, 121, 121, 0.9)", + "rgba(121,121,121,0.9)", + ), + ] { + let mut position: KeyPosition = serde_json::from_value(serde_json::json!({ + "dx": 0, + "dy": 0, + "width": 60, + "count": 0, + "backgroundColor": "#102030", + "backgroundGradient": { + "angle": 90, + "stops": [ + { "color": "#102030", "pos": 0 }, + { "color": "#405060", "pos": 1 } + ] + }, + "counter": { + "enabled": true, + "placement": "inside", + "align": "top", + "alignMode": "center", + "fill": { + "idle": legacy_fill, + "active": if legacy_fill == "#FFFFFF" { "#000000" } else { "#FFFFFF" } + }, + "fillIdleGradient": { + "angle": 90, + "stops": [ + { "color": first_stop, "pos": 0 }, + { "color": "#654321", "pos": 1 } + ] + }, + "gap": 6, + "fontSize": 16, + "fontWeight": if legacy_fill == "#FFFFFF" { 400 } else { 700 }, + "fontFamily": null, + "fontItalic": false, + "fontUnderline": false, + "fontStrikethrough": false + } + })) + .unwrap(); + + let (_, pair_repaired) = position.canonicalize_gradient_pairs(); + assert!(pair_repaired); + assert_eq!(position.counter.fill.idle, expected_escape); + assert!(!position.counter.migrate_legacy_defaults()); + + let serialized = serde_json::to_value(&position).unwrap(); + let shadow: PreFeatureKeyPosition = serde_json::from_value(serialized).unwrap(); + assert_eq!(shadow.background_color.as_deref(), Some("#102030")); + assert_eq!(shadow.counter.fill.idle, expected_escape); + assert!(!shadow.counter.matches_legacy_migration_snapshot()); + } +} + +#[test] +fn pre_unification_round_trip_drops_siblings_and_preserves_note_shadows() { + let mut position: KeyPosition = serde_json::from_value(serde_json::json!({ + "dx": 0, + "dy": 0, + "width": 60, + "count": 0, + "noteOpacity": 80, + "noteGradient": { + "angle": 45, + "stops": [ + { "color": "rgba(17,34,51,.5)", "pos": 0 }, + { "color": "#44556640", "pos": 1 } + ] + }, + "noteGlowOpacity": 60, + "noteGlowGradient": { + "angle": 135, + "stops": [ + { "color": "#77889980", "pos": 0 }, + { "color": "rgb(170,187,204)", "pos": 1 } + ] + } + })) + .unwrap(); + assert_eq!(position.canonicalize_gradient_pairs(), (true, true)); + let expected_note_color = position.note_color.clone(); + let expected_note_top = position.note_opacity_top; + let expected_note_bottom = position.note_opacity_bottom; + let expected_glow_color = position.note_glow_color.clone(); + let expected_glow_top = position.note_glow_opacity_top; + let expected_glow_bottom = position.note_glow_opacity_bottom; + + let new_wire = serde_json::to_value(position).unwrap(); + let old: PreUnifiedNotePosition = serde_json::from_value(new_wire).unwrap(); + let old_wire = serde_json::to_value(old).unwrap(); + assert!(old_wire.get("noteGradient").is_none()); + assert!(old_wire.get("noteGlowGradient").is_none()); + + let restored: KeyPosition = serde_json::from_value(old_wire).unwrap(); + assert!(restored.note_gradient.is_none()); + assert!(restored.note_glow_gradient.is_none()); + assert_eq!(restored.note_color, expected_note_color); + assert_eq!(restored.note_opacity_top, expected_note_top); + assert_eq!(restored.note_opacity_bottom, expected_note_bottom); + assert_eq!(restored.note_glow_color, expected_glow_color); + assert_eq!(restored.note_glow_opacity_top, expected_glow_top); + assert_eq!(restored.note_glow_opacity_bottom, expected_glow_bottom); +} + +#[test] +fn counter_migration_without_gradients_preserves_both_legacy_upgrade_branches() { + for snapshot in [ + serde_json::json!({ + "placement": "inside", + "align": "top", + "alignMode": "center", + "fill": { "idle": "#FFFFFF", "active": "#000000" }, + "stroke": { "idle": "#000000", "active": "#FFFFFF" }, + "gap": 6, + "fontSize": 16, + "fontWeight": 400, + "fontFamily": null, + "fontItalic": false, + "fontUnderline": false, + "fontStrikethrough": false + }), + serde_json::json!({ + "placement": "inside", + "align": "top", + "alignMode": "center", + "fill": { + "idle": "rgba(121, 121, 121, 0.9)", + "active": "#FFFFFF" + }, + "stroke": { "idle": "transparent", "active": "transparent" }, + "gap": 6, + "fontSize": 16, + "fontWeight": 700, + "fontFamily": null, + "fontItalic": false, + "fontUnderline": false, + "fontStrikethrough": false + }), + ] { + let mut counter: KeyCounterSettings = serde_json::from_value(snapshot).unwrap(); + + assert!(counter.migrate_legacy_defaults()); + assert_eq!(counter, KeyCounterSettings::default()); + } +} diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs index 6a00fc3fe..7910a1abd 100644 --- a/src-tauri/src/state/app_state.rs +++ b/src-tauri/src/state/app_state.rs @@ -6597,2718 +6597,4 @@ struct OverlayPosition { } #[cfg(test)] -mod tests { - use std::{ - collections::{HashMap, HashSet}, - sync::{ - atomic::{AtomicBool, AtomicUsize, Ordering}, - mpsc, Arc, - }, - thread, - time::{Duration, Instant}, - }; - - use super::{ - acknowledge_editor_flush_handshake, acknowledge_panel_close_request, - apply_panel_bounds_change, begin_panel_close_request, bootstrap_keyboard_state, - canonical_hold_duration_ms, changed_panel_max_height, clamp_overlay_dimension, - collect_authorized_css_paths, collect_frontend_lifecycle_targets, - drop_panel_hidden_with_main, frontend_history_mutation_blocked, - frontend_lifecycle_restore_labels, global_css_watch_path, hide_panel_with_main_transition, - initial_overlay_placement, install_history_handshake, install_lifecycle_handshake, - is_panel_open_url, key_state_payload, main_window_starts_hidden, monitor_scale_is_usable, - next_keyboard_recovery_plan, normalize_stored_overlay_bounds, overlay_close_action, - overlay_reset_fallback_rect, panel_bounds_from_sample, panel_height_bounds, - panel_position_beside_main, publish_panel_hidden_transition, - publish_panel_visibility_transition, resolve_event_age_ms, resolve_panel_window_layout, - restore_panel_with_main_transition, run_panel_close_timeout, - should_create_overlay_on_startup, should_recover_keyboard_daemon, - should_restore_panel_on_startup, stored_bounds_need_monitor_data, - take_cancelable_editor_flush_handshake, take_editor_flush_handshake, take_panel_open_arm, - AppState, EditorFlushAcknowledge, EditorFlushCompletion, EditorFlushHandshake, - EditorFlushRequest, FrontendFlushAction, FrontendHistoryFlushPhase, - FrontendHistoryFlushReady, FrontendLifecycleAction, KeyCounterEventEmitter, - LifecycleHandshakeInstall, LogicalRect, MonitorData, MonitorSpec, - MutationPublicationSequencer, Mutex, OverlayCloseAction, PanelBoundsChange, - PanelBoundsPersistenceController, PanelBoundsPersistenceState, PanelBoundsSample, - PanelCloseRequestState, PanelCloseRequestedPayload, PanelVisibilityEventEmitter, - PanelVisibilityPayload, PanelVisibilityReason, PhysicalPosition, PhysicalSize, - DEFAULT_OVERLAY_HEIGHT, DEFAULT_OVERLAY_WIDTH, HISTORY_FRONTEND_FLUSH_INTERRUPTED, - KEYBOARD_DAEMON_STABLE_RUNTIME, KEYBOARD_RECOVERY_DELAYS_MS, OVERLAY_LABEL, - PANEL_BESIDE_GAP, PANEL_INITIAL_HEIGHT, PANEL_LABEL, PANEL_MIN_HEIGHT, - PANEL_OPEN_ARM_TIMEOUT, PANEL_WIDTH, - }; - use crate::{ - keyboard::KeyboardManager, - models::{ - AppStoreData, CustomCss, CustomTab, EditorCommitOrigin, EditorCommitRequest, - EditorField, EditorFrozenKeySlotV1, EditorOpV1, GestureCommitRequest, - GesturePluginInstancesChange, KeyCounters, KeySlot, OverlayBounds, PanelBounds, - PluginPoint, SavedPluginInstance, TabCss, EDITOR_OPS_VERSION, - }, - state::{ - history::{HistoryAdmissionGate, HistoryDirection, HistoryScope}, - local_asset_path::path_identity_key, - store::{ - AppStore, AuxEditorResetTransactionOptions, AuxEditorTransactionOptions, - PluginInstancesResetScope, - }, - }, - }; - use std::path::Path; - - struct NoopCounterEmitter; - - impl KeyCounterEventEmitter for NoopCounterEmitter { - fn emit_key_counters( - &self, - _counters: &KeyCounters, - _session_id: &str, - _revision: u64, - ) -> anyhow::Result<()> { - Ok(()) - } - - fn emit_key_counter( - &self, - _mode: &str, - _key: &str, - _count: u32, - _session_id: &str, - _revision: u64, - ) -> anyhow::Result<()> { - Ok(()) - } - } - - #[test] - fn preset_mapping_commit_drops_queued_increment_for_replaced_key() { - let directory = tempfile::tempdir().unwrap(); - let store = AppStore::initialize_for_test(directory.path()).unwrap(); - store - .update(|data| data.key_counter_enabled = true) - .unwrap(); - let state = Arc::new(AppState::initialize(store).unwrap()); - let emitter = NoopCounterEmitter; - let mode = state.store.snapshot().selected_key_type; - let replaced_key = state.store.snapshot().keys[&mode][0].canonical(); - let preserved_key = state.store.snapshot().keys[&mode][1].canonical(); - for expected in 1..=7 { - assert_eq!( - state.increment_key_counter_and_emit(&emitter, &mode, &preserved_key), - Some(expected) - ); - } - assert_eq!( - state.store.snapshot().key_counters[&mode][&preserved_key], - 0 - ); - let replacement_key = "QA PRESET REPLACEMENT"; - let commit_state = Arc::clone(&state); - let commit_mode = mode.clone(); - let (barrier_ready_tx, barrier_ready_rx) = mpsc::channel(); - let (release_commit_tx, release_commit_rx) = mpsc::channel(); - let commit = thread::spawn(move || { - let admission = commit_state.store.admit_editor_mutation().unwrap(); - commit_state.commit_preset_editor_transaction_preserving_runtime_counters( - &NoopCounterEmitter, - EditorCommitOrigin::LegacyAdapter("preset_load".to_string()), - &[EditorField::Keys, EditorField::KeyPositions], - admission, - |data| { - barrier_ready_tx.send(()).unwrap(); - release_commit_rx - .recv_timeout(Duration::from_secs(3)) - .unwrap(); - data.keys.get_mut(&commit_mode).unwrap()[0] = KeySlot::from(replacement_key); - Ok(()) - }, - ) - }); - barrier_ready_rx - .recv_timeout(Duration::from_secs(3)) - .unwrap(); - assert_eq!( - state.increment_key_counter_and_emit(&emitter, &mode, &replaced_key), - None - ); - release_commit_tx.send(()).unwrap(); - let (transaction, runtime_applied) = commit.join().unwrap().unwrap(); - - assert!(runtime_applied); - assert!(transaction - .change - .result - .changed_fields - .contains(&EditorField::Keys)); - assert!(!state.snapshot_key_counters()[&mode].contains_key(&replaced_key)); - assert_eq!(state.snapshot_key_counters()[&mode][replacement_key], 0); - assert_eq!(state.snapshot_key_counters()[&mode][&preserved_key], 7); - assert!(!state.store.snapshot().key_counters[&mode].contains_key(&replaced_key)); - assert_eq!( - state.store.snapshot().key_counters[&mode][&preserved_key], - 7 - ); - state.shutdown(); - drop(state); - let reloaded = AppStore::initialize_for_test(directory.path()).unwrap(); - assert!(!reloaded.snapshot().key_counters[&mode].contains_key(&replaced_key)); - assert_eq!(reloaded.snapshot().key_counters[&mode][&preserved_key], 7); - reloaded.flush_and_shutdown().unwrap(); - } - - #[test] - fn startup_overlay_creation_covers_all_visibility_and_obs_combinations() { - assert!(!should_create_overlay_on_startup(false, false)); - assert!(should_create_overlay_on_startup(false, true)); - assert!(!should_create_overlay_on_startup(true, false)); - assert!(!should_create_overlay_on_startup(true, true)); - } - - #[test] - fn key_mapping_commit_preserves_unflushed_runtime_counters() { - let directory = std::env::temp_dir().join(format!( - "dmnote-editor-live-counter-test-{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&directory).unwrap(); - let state = - AppState::initialize(AppStore::initialize_for_test(&directory).unwrap()).unwrap(); - state.key_counter_enabled.store(true, Ordering::SeqCst); - let emitter = NoopCounterEmitter; - let editor = state.store.editor_get(); - let mode = state.store.snapshot().selected_key_type; - let preserved_key = editor.document.keys[&mode][0].canonical(); - - for expected in 1..=7 { - assert_eq!( - state.increment_key_counter_and_emit(&emitter, &mode, &preserved_key), - Some(expected) - ); - } - assert_eq!( - state.store.snapshot().key_counters[&mode][&preserved_key], - 0 - ); - - let replaced_key = editor.document.keys[&mode][1].canonical(); - let replaced_id = editor.document.key_positions[&mode][1].id.clone(); - let request = EditorCommitRequest { - base_revision: editor.revision, - mutation_id: uuid::Uuid::new_v4().to_string(), - multi_key: false, - gesture_id: None, - gesture_ids: Vec::new(), - changes: None, - ops_version: Some(EDITOR_OPS_VERSION), - ops: Some(vec![EditorOpV1::SetKeySlot { - id: replaced_id, - slot: EditorFrozenKeySlotV1::Single("QA NEW KEY".to_string()), - }]), - }; - let admission = state.store.admit_editor_mutation().unwrap(); - let (change, runtime_applied) = state - .commit_editor_document_preserving_runtime_counters(&emitter, request, &admission) - .unwrap(); - drop(admission); - - assert!(runtime_applied); - assert_eq!(change.key_counters[&mode][&preserved_key], 7); - assert_eq!(state.snapshot_key_counters()[&mode][&preserved_key], 7); - let persisted = state.store.snapshot().key_counters; - assert_eq!(persisted[&mode][&preserved_key], 7); - assert_eq!(persisted[&mode]["QA NEW KEY"], 0); - assert!(!persisted[&mode].contains_key(&replaced_key)); - - state.shutdown(); - drop(state); - std::fs::remove_dir_all(directory).unwrap(); - } - - #[test] - fn history_mapping_change_restores_historical_counter_domain() { - let directory = tempfile::tempdir().unwrap(); - let store = AppStore::initialize_for_test(directory.path()).unwrap(); - store - .update(|data| data.key_counter_enabled = true) - .unwrap(); - let state = AppState::initialize(store).unwrap(); - let emitter = NoopCounterEmitter; - let editor = state.store.editor_get(); - let mode = state.store.snapshot().selected_key_type; - let restored_key = editor.document.keys[&mode][0].canonical(); - let replaced_id = editor.document.key_positions[&mode][0].id.clone(); - let replacement_key = "QA HISTORY REPLACEMENT"; - for expected in 1..=12 { - assert_eq!( - state.increment_key_counter_and_emit(&emitter, &mode, &restored_key), - Some(expected) - ); - } - assert!(!state.store.history_status().can_undo); - let request = EditorCommitRequest { - base_revision: editor.revision, - mutation_id: uuid::Uuid::new_v4().to_string(), - multi_key: false, - gesture_id: None, - gesture_ids: Vec::new(), - changes: None, - ops_version: Some(EDITOR_OPS_VERSION), - ops: Some(vec![EditorOpV1::SetKeySlot { - id: replaced_id, - slot: EditorFrozenKeySlotV1::Single(replacement_key.to_string()), - }]), - }; - let admission = state.store.admit_editor_mutation().unwrap(); - let (committed, runtime_applied) = state - .commit_editor_document_preserving_runtime_counters(&emitter, request, &admission) - .unwrap(); - drop(admission); - assert!(runtime_applied); - assert!(!committed.key_counters[&mode].contains_key(&restored_key)); - assert_eq!(committed.key_counters[&mode][replacement_key], 0); - - let operation_id = uuid::Uuid::new_v4().to_string(); - let gate = state.store.history_gate(); - let history_barrier = gate.close(&operation_id).unwrap(); - state.begin_counter_history_barrier(); - let mut counter_guard = state.lock_key_counters_for_history(); - assert_eq!( - state.increment_key_counter_and_emit(&emitter, &mode, replacement_key), - None - ); - let current_counters = counter_guard.clone(); - let undo = state - .store - .apply_history_operation( - HistoryDirection::Undo, - &operation_id, - ¤t_counters, - || {}, - ) - .unwrap(); - let change = undo.change.as_ref().unwrap(); - assert!(state.apply_history_editor_key_runtime_locked(&mut counter_guard, change)); - assert_eq!( - state.increment_key_counter_and_emit(&emitter, &mode, &restored_key), - None - ); - state.finish_counter_history_barrier( - &emitter, - counter_guard, - true, - undo.runtime_publication_generation, - ); - drop(history_barrier); - - let runtime = state.snapshot_key_counters(); - assert_eq!(runtime[&mode][&restored_key], 13); - assert!(!runtime[&mode].contains_key(replacement_key)); - let persisted = state.store.snapshot().key_counters; - assert_eq!(persisted[&mode][&restored_key], 12); - assert!(!persisted[&mode].contains_key(replacement_key)); - - state.shutdown(); - drop(state); - let reloaded = AppStore::initialize_for_test(directory.path()).unwrap(); - assert_eq!(reloaded.snapshot().key_counters[&mode][&restored_key], 13); - assert!(!reloaded.snapshot().key_counters[&mode].contains_key(replacement_key)); - reloaded.flush_and_shutdown().unwrap(); - } - - #[test] - fn custom_tab_create_preserves_unflushed_runtime_counters() { - let directory = std::env::temp_dir().join(format!( - "dmnote-custom-tab-live-counter-test-{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&directory).unwrap(); - let state = - AppState::initialize(AppStore::initialize_for_test(&directory).unwrap()).unwrap(); - state.key_counter_enabled.store(true, Ordering::SeqCst); - let emitter = NoopCounterEmitter; - let snapshot = state.store.snapshot(); - let mode = snapshot.selected_key_type; - let preserved_key = snapshot.keys[&mode][0].canonical(); - - for expected in 1..=7 { - assert_eq!( - state.increment_key_counter_and_emit(&emitter, &mode, &preserved_key), - Some(expected) - ); - } - assert_eq!( - state.store.snapshot().key_counters[&mode][&preserved_key], - 0 - ); - - let tab_id = "qa-counter-tab".to_string(); - let admission = state.store.admit_editor_mutation().unwrap(); - let (transaction, runtime_applied) = state - .commit_editor_transaction_preserving_runtime_counters(&emitter, |runtime_counters| { - state - .store - .commit_aux_editor_transaction_with_runtime_counters_admission( - AuxEditorTransactionOptions { - scope: HistoryScope::CustomTabs, - observed_history_epoch: None, - origin: EditorCommitOrigin::LegacyAdapter( - "custom_tabs_create".to_string(), - ), - touched_fields: &[EditorField::Keys, EditorField::KeyPositions], - }, - admission, - runtime_counters, - |store| { - store.custom_tabs.push(CustomTab { - id: tab_id.clone(), - name: "QA Counter Tab".to_string(), - }); - store.keys.insert(tab_id.clone(), Vec::new()); - store.key_positions.insert(tab_id.clone(), Vec::new()); - store.selected_key_type = tab_id.clone(); - Ok(()) - }, - ) - }) - .unwrap(); - - assert!(runtime_applied); - assert_eq!(state.snapshot_key_counters()[&mode][&preserved_key], 7); - assert_eq!( - state.store.snapshot().key_counters[&mode][&preserved_key], - 7 - ); - assert!(transaction.change.document.keys.contains_key(&tab_id)); - drop(transaction); - - assert_eq!( - state.increment_key_counter_and_emit(&emitter, &mode, &preserved_key), - Some(8) - ); - - let operation_id = uuid::Uuid::new_v4().to_string(); - let gate = state.store.history_gate(); - let barrier = gate.close(&operation_id).unwrap(); - state.begin_counter_history_barrier(); - let mut counter_guard = state.lock_key_counters_for_history(); - let current_counters = counter_guard.clone(); - let undo = state - .store - .apply_history_operation( - HistoryDirection::Undo, - &operation_id, - ¤t_counters, - || {}, - ) - .unwrap(); - assert!(state.apply_history_editor_key_runtime_locked( - &mut counter_guard, - undo.change.as_ref().unwrap(), - )); - state.finish_counter_history_barrier( - &emitter, - counter_guard, - true, - undo.runtime_publication_generation, - ); - drop(barrier); - assert_eq!( - state.store.snapshot().key_counters[&mode][&preserved_key], - 8 - ); - assert_eq!(state.snapshot_key_counters()[&mode][&preserved_key], 8); - - state.shutdown(); - drop(state); - std::fs::remove_dir_all(directory).unwrap(); - } - - #[test] - fn custom_tab_delete_preserves_unflushed_runtime_counters() { - let directory = std::env::temp_dir().join(format!( - "dmnote-custom-tab-delete-live-counter-test-{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&directory).unwrap(); - let state = - AppState::initialize(AppStore::initialize_for_test(&directory).unwrap()).unwrap(); - state.key_counter_enabled.store(true, Ordering::SeqCst); - let emitter = NoopCounterEmitter; - let snapshot = state.store.snapshot(); - let mode = snapshot.selected_key_type; - let preserved_key = snapshot.keys[&mode][0].canonical(); - let tab_id = "qa-counter-delete-tab".to_string(); - let create = state - .store - .commit_aux_editor_transaction( - HistoryScope::CustomTabs, - None, - EditorCommitOrigin::LegacyAdapter("custom_tabs_create".to_string()), - &[EditorField::Keys, EditorField::KeyPositions], - |store| { - store.custom_tabs.push(CustomTab { - id: tab_id.clone(), - name: "QA Counter Delete Tab".to_string(), - }); - store.keys.insert(tab_id.clone(), Vec::new()); - store.key_positions.insert(tab_id.clone(), Vec::new()); - store.selected_key_type = tab_id.clone(); - Ok(()) - }, - ) - .unwrap(); - drop(create); - - for expected in 1..=7 { - assert_eq!( - state.increment_key_counter_and_emit(&emitter, &mode, &preserved_key), - Some(expected) - ); - } - assert_eq!( - state.store.snapshot().key_counters[&mode][&preserved_key], - 0 - ); - - let admission = state.store.admit_editor_mutation().unwrap(); - let (transaction, runtime_applied) = state - .commit_editor_transaction_preserving_runtime_counters(&emitter, |runtime_counters| { - state - .store - .commit_aux_editor_reset_transaction_with_runtime_counters_admission( - AuxEditorResetTransactionOptions { - scope: HistoryScope::CustomTabs, - observed_history_epoch: None, - origin: EditorCommitOrigin::LegacyAdapter( - "custom_tabs_delete".to_string(), - ), - touched_fields: &[ - EditorField::Keys, - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - EditorField::LayerGroups, - ], - plugin_instances_reset: PluginInstancesResetScope::Mode(tab_id.clone()), - }, - admission, - runtime_counters, - |store| { - store.custom_tabs.retain(|tab| tab.id != tab_id); - store.keys.remove(&tab_id); - store.key_positions.remove(&tab_id); - store.stat_positions.remove(&tab_id); - store.graph_positions.remove(&tab_id); - store.knob_positions.remove(&tab_id); - store.layer_groups.remove(&tab_id); - store.key_counters.remove(&tab_id); - store.selected_key_type = mode.clone(); - Ok(()) - }, - ) - }) - .unwrap(); - - assert!(runtime_applied); - assert_eq!(state.snapshot_key_counters()[&mode][&preserved_key], 7); - assert_eq!( - state.store.snapshot().key_counters[&mode][&preserved_key], - 7 - ); - assert!(!transaction.change.document.keys.contains_key(&tab_id)); - drop(transaction); - - let operation_id = uuid::Uuid::new_v4().to_string(); - let gate = state.store.history_gate(); - let barrier = gate.close(&operation_id).unwrap(); - state - .store - .apply_history_operation( - HistoryDirection::Undo, - &operation_id, - &state.snapshot_key_counters(), - || {}, - ) - .unwrap(); - drop(barrier); - assert_eq!( - state.store.snapshot().key_counters[&mode][&preserved_key], - 7 - ); - - state.shutdown(); - drop(state); - std::fs::remove_dir_all(directory).unwrap(); - } - - #[test] - fn custom_tab_restore_history_preserves_unflushed_runtime_counters() { - let directory = std::env::temp_dir().join(format!( - "dmnote-custom-tab-restore-live-counter-test-{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&directory).unwrap(); - let state = - AppState::initialize(AppStore::initialize_for_test(&directory).unwrap()).unwrap(); - state.key_counter_enabled.store(true, Ordering::SeqCst); - let emitter = NoopCounterEmitter; - let snapshot = state.store.snapshot(); - let mode = snapshot.selected_key_type; - let preserved_key = snapshot.keys[&mode][0].canonical(); - let tab_id = "qa-counter-restore-tab".to_string(); - let create = state - .store - .commit_aux_editor_transaction( - HistoryScope::CustomTabs, - None, - EditorCommitOrigin::LegacyAdapter("custom_tabs_create".to_string()), - &[EditorField::Keys, EditorField::KeyPositions], - |store| { - store.custom_tabs.push(CustomTab { - id: tab_id.clone(), - name: "Before Restore".to_string(), - }); - store.keys.insert(tab_id.clone(), Vec::new()); - store.key_positions.insert(tab_id.clone(), Vec::new()); - Ok(()) - }, - ) - .unwrap(); - drop(create); - - for expected in 1..=7 { - assert_eq!( - state.increment_key_counter_and_emit(&emitter, &mode, &preserved_key), - Some(expected) - ); - } - let mut restored_tabs = state.store.snapshot().custom_tabs; - restored_tabs[0].name = "After Restore".to_string(); - let admission = state.store.admit_editor_mutation().unwrap(); - let (transaction, runtime_applied) = state - .commit_editor_transaction_preserving_runtime_counters(&emitter, |runtime_counters| { - state - .store - .commit_aux_editor_transaction_with_runtime_counters_admission( - AuxEditorTransactionOptions { - scope: HistoryScope::CustomTabs, - observed_history_epoch: None, - origin: EditorCommitOrigin::LegacyAdapter( - "custom_tabs_restore".to_string(), - ), - touched_fields: &[], - }, - admission, - runtime_counters, - |store| { - store.custom_tabs = restored_tabs; - Ok(()) - }, - ) - }) - .unwrap(); - - assert!(!runtime_applied); - assert_eq!( - state.store.snapshot().key_counters[&mode][&preserved_key], - 7 - ); - assert_eq!(state.store.snapshot().custom_tabs[0].name, "After Restore"); - drop(transaction); - - assert_eq!( - state.increment_key_counter_and_emit(&emitter, &mode, &preserved_key), - Some(8) - ); - - let operation_id = uuid::Uuid::new_v4().to_string(); - let gate = state.store.history_gate(); - let barrier = gate.close(&operation_id).unwrap(); - state - .store - .apply_history_operation( - HistoryDirection::Undo, - &operation_id, - &state.snapshot_key_counters(), - || {}, - ) - .unwrap(); - drop(barrier); - assert_eq!( - state.store.snapshot().key_counters[&mode][&preserved_key], - 8 - ); - assert_eq!(state.snapshot_key_counters()[&mode][&preserved_key], 8); - assert_eq!(state.store.snapshot().custom_tabs[0].name, "Before Restore"); - - state.shutdown(); - drop(state); - std::fs::remove_dir_all(directory).unwrap(); - } - - #[test] - fn mixed_gesture_history_preserves_live_and_restores_historical_counters() { - let directory = std::env::temp_dir().join(format!( - "dmnote-gesture-live-counter-test-{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&directory).unwrap(); - let state = - AppState::initialize(AppStore::initialize_for_test(&directory).unwrap()).unwrap(); - state.key_counter_enabled.store(true, Ordering::SeqCst); - let emitter = NoopCounterEmitter; - let editor = state.store.editor_get(); - let mode = state.store.snapshot().selected_key_type; - let preserved_key = editor.document.keys[&mode][0].canonical(); - let replaced_key = editor.document.keys[&mode][1].canonical(); - - for expected in 1..=7 { - assert_eq!( - state.increment_key_counter_and_emit(&emitter, &mode, &preserved_key), - Some(expected) - ); - } - for expected in 1..=12 { - assert_eq!( - state.increment_key_counter_and_emit(&emitter, &mode, &replaced_key), - Some(expected) - ); - } - assert_eq!( - state.store.snapshot().key_counters[&mode][&preserved_key], - 0 - ); - - let replaced_id = editor.document.key_positions[&mode][1].id.clone(); - let plugin_id = "qa-mixed-counter"; - let plugin_instance_id = uuid::Uuid::new_v4().to_string(); - let request = GestureCommitRequest { - gesture_id: uuid::Uuid::new_v4().to_string(), - mutation_id: uuid::Uuid::new_v4().to_string(), - editor_base_revision: editor.revision, - plugin_base_revision: state.store.plugin_model_revision(), - observed_history_epoch: Some(state.store.history_status().history_epoch), - authority_generation: 1, - editor_changes: None, - editor_ops_version: Some(EDITOR_OPS_VERSION), - editor_ops: Some(vec![EditorOpV1::SetKeySlot { - id: replaced_id, - slot: EditorFrozenKeySlotV1::Single("QA MIXED KEY".to_string()), - }]), - plugin_changes: vec![GesturePluginInstancesChange { - plugin_id: plugin_id.to_string(), - instances: vec![SavedPluginInstance { - instance_id: Some(plugin_instance_id.clone()), - position: PluginPoint { x: 20.0, y: 30.0 }, - settings: None, - measured_size: None, - tab_id: None, - hidden: false, - z_index: None, - group_id: None, - }], - }], - }; - let admission = state.store.admit_editor_mutation().unwrap(); - let (committed, runtime_applied) = state - .commit_gesture_preserving_runtime_counters(&emitter, request, admission) - .unwrap(); - - assert!(runtime_applied); - assert_eq!(committed.outcome.changed_plugin_ids, [plugin_id]); - let change = committed.outcome.change.as_ref().unwrap(); - assert_eq!(change.key_counters[&mode][&preserved_key], 7); - assert_eq!(state.snapshot_key_counters()[&mode][&preserved_key], 7); - let persisted = state.store.snapshot().key_counters; - assert_eq!(persisted[&mode][&preserved_key], 7); - assert_eq!(persisted[&mode]["QA MIXED KEY"], 0); - assert!(!persisted[&mode].contains_key(&replaced_key)); - let (instances, revision) = state.store.plugin_instances_get(plugin_id).unwrap(); - assert_eq!(revision, 1); - assert_eq!(instances.len(), 1); - assert_eq!( - instances[0].instance_id.as_deref(), - Some(plugin_instance_id.as_str()) - ); - drop(committed); - - for expected in 1..=9 { - assert_eq!( - state.increment_key_counter_and_emit(&emitter, &mode, "QA MIXED KEY"), - Some(expected) - ); - } - assert_eq!( - state.increment_key_counter_and_emit(&emitter, &mode, &preserved_key), - Some(8) - ); - - let operation_id = uuid::Uuid::new_v4().to_string(); - let gate = state.store.history_gate(); - let history_barrier = gate.close(&operation_id).unwrap(); - state.begin_counter_history_barrier(); - let mut counter_guard = state.lock_key_counters_for_history(); - let current_counters = counter_guard.clone(); - let undo = state - .store - .apply_history_operation( - HistoryDirection::Undo, - &operation_id, - ¤t_counters, - || {}, - ) - .unwrap(); - assert!(state.apply_history_editor_key_runtime_locked( - &mut counter_guard, - undo.change.as_ref().unwrap(), - )); - state.finish_counter_history_barrier( - &emitter, - counter_guard, - true, - undo.runtime_publication_generation, - ); - drop(history_barrier); - - let restored = state.snapshot_key_counters(); - assert_eq!(restored[&mode][&replaced_key], 12); - assert_eq!(restored[&mode][&preserved_key], 8); - assert!(!restored[&mode].contains_key("QA MIXED KEY")); - assert!(state - .store - .plugin_instances_get(plugin_id) - .unwrap() - .0 - .is_empty()); - - state.shutdown(); - drop(state); - std::fs::remove_dir_all(directory).unwrap(); - } - - #[test] - fn committed_key_positions_refresh_key_sound_binding_cache() { - let directory = std::env::temp_dir().join(format!( - "dmnote-key-sound-binding-cache-{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&directory).unwrap(); - let state = - AppState::initialize(AppStore::initialize_for_test(&directory).unwrap()).unwrap(); - let initial = state.store.snapshot(); - let mode = initial.selected_key_type.clone(); - let sound_path = format!("/tmp/key-sound-{}.wav", uuid::Uuid::new_v4()); - let committed_path = sound_path.clone(); - let transaction = state - .store - .commit_legacy_editor_transaction( - EditorCommitOrigin::LegacyAdapter("key_sound_binding_cache_test".to_string()), - &[EditorField::KeyPositions], - move |store| { - let position = store - .key_positions - .get_mut(&mode) - .unwrap() - .first_mut() - .unwrap(); - position.sound_enabled = Some(true); - position.sound_path = Some(format!(" {committed_path} ")); - position.sound_volume = Some(150.0); - Ok(()) - }, - ) - .unwrap(); - - assert_ne!( - state.resolve_key_sound_binding(&initial.selected_key_type, &[0]), - Some((sound_path.clone(), 1.5)) - ); - assert!(state.publish_committed_key_sound_bindings(&transaction.change)); - assert_eq!( - state.resolve_key_sound_binding(&initial.selected_key_type, &[0]), - Some((sound_path, 1.5)) - ); - - drop(transaction); - state.shutdown(); - drop(state); - std::fs::remove_dir_all(directory).unwrap(); - } - - // 장치 전환 대기는 번호표 밖 - 앞 번호표가 잡혀 있어도 엔진은 먼저 전환되고 - // persist만 turn을 기다린다 (turn 안에서 기다리면 뒤 번호표 전부가 드라이버를 기다림) - #[test] - fn output_backend_switch_runs_before_publication_turn() { - let directory = std::env::temp_dir().join(format!( - "dmnote-output-backend-turn-{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&directory).unwrap(); - let state = Arc::new( - AppState::initialize(AppStore::initialize_for_test(&directory).unwrap()).unwrap(), - ); - let blocking_ticket = state.issue_mutation_publication().unwrap(); - - let (done_tx, done_rx) = mpsc::channel(); - let worker_state = Arc::clone(&state); - let worker = thread::spawn(move || { - let result = worker_state - .key_sound_set_output_backend(crate::audio::KeySoundOutputBackend::DefaultDevice); - done_tx.send(result.is_ok()).unwrap(); - }); - - // 앞 번호표가 살아 있는 동안 persist는 turn을 기다린다 - assert!(done_rx.recv_timeout(Duration::from_millis(500)).is_err()); - assert!(state.store.snapshot().key_sound_output_backend.is_none()); - - drop(blocking_ticket); - assert!(done_rx - .recv_timeout(Duration::from_secs(5)) - .expect("앞 번호표가 풀리면 persist가 완료된다")); - worker.join().unwrap(); - assert!(state.store.snapshot().key_sound_output_backend.is_some()); - - state.shutdown(); - drop(state); - std::fs::remove_dir_all(directory).unwrap(); - } - - #[test] - fn mutation_publication_preserves_ticket_order_when_first_worker_is_delayed() { - let publication = Arc::new(MutationPublicationSequencer::default()); - let first = publication.issue().unwrap(); - let second = publication.issue().unwrap(); - let order = Arc::new(Mutex::new(Vec::new())); - let (second_waiting_tx, second_waiting_rx) = mpsc::channel(); - let (second_done_tx, second_done_rx) = mpsc::channel(); - let second_order = Arc::clone(&order); - let second_worker = thread::spawn(move || { - second_waiting_tx.send(()).unwrap(); - second.run(|| second_order.lock().push(2)); - second_done_tx.send(()).unwrap(); - }); - second_waiting_rx - .recv_timeout(Duration::from_secs(2)) - .unwrap(); - assert!(matches!( - second_done_rx.recv_timeout(Duration::from_millis(50)), - Err(mpsc::RecvTimeoutError::Timeout) - )); - - let first_order = Arc::clone(&order); - let first_worker = thread::spawn(move || first.run(|| first_order.lock().push(1))); - first_worker.join().unwrap(); - second_done_rx.recv_timeout(Duration::from_secs(2)).unwrap(); - second_worker.join().unwrap(); - assert_eq!(*order.lock(), vec![1, 2]); - } - - // 커밋 커맨드 구조 재현: 뒤 번호표가 먼저 admit한 뒤 turn을 기다리고, 앞 번호표가 - // 그 다음 admit → turn. lease가 잠금이면 앞 번호표가 admit에서 막혀 영구 교착 - #[test] - fn plugin_authority_admission_does_not_block_earlier_publication_turn() { - use crate::state::plugin::PluginRuntimeAuthority; - - let publication = Arc::new(MutationPublicationSequencer::default()); - let authority = Arc::new(PluginRuntimeAuthority::default()); - authority.reset().unwrap(); - let first = publication.issue().unwrap(); - let second = publication.issue().unwrap(); - let order = Arc::new(Mutex::new(Vec::new())); - - let (second_admitted_tx, second_admitted_rx) = mpsc::channel(); - let second_authority = Arc::clone(&authority); - let second_order = Arc::clone(&order); - let second_worker = thread::spawn(move || { - let lease = second_authority.admit(1).unwrap(); - second_admitted_tx.send(()).unwrap(); - second.run(|| { - second_authority.revalidate(lease).unwrap(); - second_order.lock().push(2); - }); - }); - second_admitted_rx - .recv_timeout(Duration::from_secs(2)) - .unwrap(); - - let first_authority = Arc::clone(&authority); - let first_order = Arc::clone(&order); - let first_worker = thread::spawn(move || { - let lease = first_authority.admit(1).unwrap(); - first.run(|| { - first_authority.revalidate(lease).unwrap(); - first_order.lock().push(1); - }); - }); - - let (done_tx, done_rx) = mpsc::channel(); - thread::spawn(move || { - first_worker.join().unwrap(); - second_worker.join().unwrap(); - done_tx.send(()).unwrap(); - }); - done_rx - .recv_timeout(Duration::from_secs(2)) - .expect("admit이 잠금을 들지 않아야 번호표 순서대로 진행된다"); - assert_eq!(*order.lock(), vec![1, 2]); - } - - // 번호표 FIFO가 reset과 커밋을 직렬화한다 - reset 뒤 turn의 revalidate는 거절 - #[test] - fn plugin_authority_revalidate_rejects_commit_admitted_before_reset() { - use crate::state::plugin::PluginRuntimeAuthority; - - let publication = Arc::new(MutationPublicationSequencer::default()); - let authority = PluginRuntimeAuthority::default(); - authority.reset().unwrap(); - let lease = authority.admit(1).unwrap(); - - let reset_ticket = publication.issue().unwrap(); - let commit_ticket = publication.issue().unwrap(); - reset_ticket.run(|| authority.reset().unwrap()); - let rejected = commit_ticket.run(|| authority.revalidate(lease)); - assert_eq!( - rejected.unwrap_err(), - "AUTHORITY_GENERATION_CHANGED".to_string() - ); - } - - #[test] - fn mutation_publication_advances_after_unrun_ticket_is_dropped() { - let publication = Arc::new(MutationPublicationSequencer::default()); - let first = publication.issue().unwrap(); - let second = publication.issue().unwrap(); - - drop(first); - - assert_eq!(publication.state.lock().serving_ticket, 1); - let mut ran = false; - second.run(|| ran = true); - assert!(ran); - } - - #[test] - fn mutation_publication_advances_after_panicking_turn() { - let publication = Arc::new(MutationPublicationSequencer::default()); - let first = publication.issue().unwrap(); - let second = publication.issue().unwrap(); - let order = Arc::new(Mutex::new(Vec::new())); - let first_order = Arc::clone(&order); - let first_worker = thread::spawn(move || { - std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - first.run(|| { - first_order.lock().push(1); - panic!("publication panic test"); - }); - })) - }); - let second_order = Arc::clone(&order); - let second_worker = thread::spawn(move || second.run(|| second_order.lock().push(2))); - - assert!(first_worker.join().unwrap().is_err()); - second_worker.join().unwrap(); - assert_eq!(*order.lock(), vec![1, 2]); - } - - #[test] - fn history_close_drains_while_mutation_publication_is_held() { - let publication = Arc::new(MutationPublicationSequencer::default()); - let ticket = publication.issue().unwrap(); - let gate = Arc::new(HistoryAdmissionGate::default()); - let admission = gate.admit_mutation().unwrap(); - let (publication_held_tx, publication_held_rx) = mpsc::channel(); - let (release_publication_tx, release_publication_rx) = mpsc::channel(); - let mutation = thread::spawn(move || { - ticket.run(|| { - publication_held_tx.send(()).unwrap(); - release_publication_rx.recv().unwrap(); - drop(admission); - }); - }); - publication_held_rx - .recv_timeout(Duration::from_secs(2)) - .unwrap(); - - let barrier = gate.begin_close("history-close-publication-test").unwrap(); - let waiter = barrier.waiter(); - let (drained_tx, drained_rx) = mpsc::channel(); - let drain = thread::spawn(move || { - drained_tx.send(waiter.wait_for_drain()).unwrap(); - }); - assert!(matches!( - drained_rx.recv_timeout(Duration::from_millis(50)), - Err(mpsc::RecvTimeoutError::Timeout) - )); - - release_publication_tx.send(()).unwrap(); - assert_eq!( - drained_rx.recv_timeout(Duration::from_secs(2)).unwrap(), - Ok(()) - ); - mutation.join().unwrap(); - drain.join().unwrap(); - drop(barrier); - } - - #[test] - fn overlay_close_preserves_visibility_while_windows_lifecycle_is_pending() { - assert_eq!( - overlay_close_action(false, true), - OverlayCloseAction::PreserveVisibility - ); - } - - #[test] - fn overlay_close_distinguishes_final_shutdown_from_user_close() { - assert_eq!( - overlay_close_action(true, true), - OverlayCloseAction::AllowClose - ); - assert_eq!( - overlay_close_action(false, false), - OverlayCloseAction::HideAndPersist - ); - } - - #[test] - fn startup_panel_restore_requires_detached_without_obs_or_tray_start() { - assert!(should_restore_panel_on_startup(false, false, true)); - assert!(!should_restore_panel_on_startup(false, false, false)); - assert!(!should_restore_panel_on_startup(true, false, true)); - assert!(!should_restore_panel_on_startup(false, true, true)); - } - - #[test] - fn startup_hidden_main_window_needs_both_tray_and_hidden_flags() { - assert!(main_window_starts_hidden(true, true)); - assert!(!main_window_starts_hidden(false, true)); - assert!(!main_window_starts_hidden(true, false)); - // 메인 창 표시와 패널 복원이 같은 판정을 공유한다 - assert!(should_restore_panel_on_startup( - false, - main_window_starts_hidden(false, true), - true - )); - assert!(!should_restore_panel_on_startup( - false, - main_window_starts_hidden(true, true), - true - )); - } - - #[test] - fn panel_window_contract_uses_fixed_client_width() { - assert_eq!(PANEL_LABEL, "panel"); - assert_eq!(PANEL_WIDTH, 240.0); - assert_eq!(PANEL_INITIAL_HEIGHT, 712.0); - assert_eq!(PANEL_MIN_HEIGHT, 712.0); - // 넉넉한 화면에서는 하한이 그대로 유지됨 - assert_eq!( - panel_height_bounds(Some(1_000.0)), - (PANEL_MIN_HEIGHT, 900.0) - ); - // 하한보다 좁은 작업 영역에서는 하한이 화면에 맞춰 내려감 - clamp 역전 방지 - assert_eq!(panel_height_bounds(Some(600.0)), (540.0, 540.0)); - let (min_height, max_height) = panel_height_bounds(None); - assert_eq!(min_height, PANEL_MIN_HEIGHT); - assert!(max_height >= min_height); - } - - #[test] - fn panel_layout_never_exceeds_a_small_work_area() { - let monitors = MonitorData { - specs: vec![MonitorSpec { - logical_origin_x: 0.0, - logical_origin_y: 0.0, - logical_width: 1_280.0, - logical_height: 680.0, - physical_origin_x: 0.0, - physical_origin_y: 0.0, - physical_width: 1_280.0, - physical_height: 680.0, - scale_factor: 1.0, - }], - primary_index: Some(0), - }; - let layout = resolve_panel_window_layout(None, None, &monitors, None); - - assert!(layout.min_height <= layout.max_height); - assert!(layout.height <= 680.0); - assert_eq!(layout.height, layout.max_height); - } - - fn work_area_spec(origin_x: f64, origin_y: f64, width: f64, height: f64) -> MonitorSpec { - MonitorSpec { - logical_origin_x: origin_x, - logical_origin_y: origin_y, - logical_width: width, - logical_height: height, - physical_origin_x: origin_x, - physical_origin_y: origin_y, - physical_width: width, - physical_height: height, - scale_factor: 1.0, - } - } - - fn main_rect(x: f64, y: f64, width: f64, height: f64) -> LogicalRect { - LogicalRect { - x, - y, - width, - height, - } - } - - #[test] - fn panel_lands_beside_the_main_window_on_the_right() { - let work_area = work_area_spec(0.0, 0.0, 1_920.0, 1_080.0); - let position = - panel_position_beside_main(&main_rect(400.0, 200.0, 900.0, 500.0), 400.0, &work_area); - - assert_eq!(position.x, 400.0 + 900.0 + PANEL_BESIDE_GAP); - // 메인 창 세로 중앙 - assert_eq!(position.y, 250.0); - } - - #[test] - fn panel_flips_to_the_left_when_the_right_side_has_no_room() { - let work_area = work_area_spec(0.0, 0.0, 1_920.0, 1_080.0); - let position = - panel_position_beside_main(&main_rect(1_600.0, 200.0, 300.0, 500.0), 400.0, &work_area); - - assert_eq!(position.x, 1_600.0 - PANEL_BESIDE_GAP - PANEL_WIDTH); - assert_eq!(position.y, 250.0); - } - - #[test] - fn panel_clamps_into_the_work_area_when_neither_side_fits() { - let work_area = work_area_spec(0.0, 0.0, 600.0, 800.0); - let position = - panel_position_beside_main(&main_rect(0.0, 0.0, 600.0, 400.0), 300.0, &work_area); - - assert_eq!(position.x, 600.0 - PANEL_WIDTH); - assert_eq!(position.y, 50.0); - } - - #[test] - fn panel_taller_than_the_work_area_clamps_to_its_top() { - let work_area = work_area_spec(100.0, 50.0, 1_920.0, 1_000.0); - let position = - panel_position_beside_main(&main_rect(200.0, 400.0, 900.0, 500.0), 1_400.0, &work_area); - - assert_eq!(position.x, 200.0 + 900.0 + PANEL_BESIDE_GAP); - assert_eq!(position.y, 50.0); - } - - #[test] - fn panel_layout_keeps_stored_height_and_places_beside_main() { - let monitors = MonitorData { - specs: vec![work_area_spec(0.0, 0.0, 1_920.0, 1_080.0)], - primary_index: Some(0), - }; - let layout = resolve_panel_window_layout( - Some(PanelBounds { - x: 31.0, - y: 47.0, - height: 800.0, - }), - Some(main_rect(300.0, 100.0, 900.0, 500.0)), - &monitors, - None, - ); - - // 높이는 저장값 유지, 위치는 저장값을 무시하고 메인 옆으로 다시 계산 - assert_eq!(layout.height, 800.0); - let position = layout.position.expect("panel should be placed beside main"); - assert_eq!(position.x, 300.0 + 900.0 + PANEL_BESIDE_GAP); - // 세로 중앙이 화면 위로 넘치면 작업 영역 상단에 붙는다 - assert_eq!(position.y, 0.0); - } - - #[test] - fn panel_open_arm_is_consumed_once_and_expires() { - let now = Instant::now(); - let mut slot = None; - assert!(!take_panel_open_arm(&mut slot, now)); - - let mut slot = Some(now); - assert!(take_panel_open_arm( - &mut slot, - now + Duration::from_millis(500) - )); - // 소비된 토큰은 다시 쓸 수 없다 - assert!(!take_panel_open_arm( - &mut slot, - now + Duration::from_millis(600) - )); - - let mut slot = Some(now); - assert!(!take_panel_open_arm( - &mut slot, - now + PANEL_OPEN_ARM_TIMEOUT + Duration::from_millis(1) - )); - assert!(slot.is_none()); - } - - #[test] - fn panel_open_accepts_only_blank_urls() { - assert!(is_panel_open_url("")); - assert!(is_panel_open_url("about:blank")); - assert!(!is_panel_open_url("https://example.com")); - assert!(!is_panel_open_url("tauri://localhost/panel/index.html")); - } - - #[test] - fn settled_panel_bounds_convert_physical_geometry_once() { - let bounds = panel_bounds_from_sample(PanelBoundsSample { - position: PhysicalPosition::new(600, 300), - position_scale_factor: 2.0, - size: PhysicalSize::new(480, 1_600), - size_scale_factor: 2.0, - current_scale_factor: 2.0, - }); - - assert_eq!(bounds.x, 300.0); - assert_eq!(bounds.y, 150.0); - // 표본은 실측 그대로 - 하한은 복원 시 모니터 기준으로 다시 적용됨 - assert_eq!(bounds.height, 800.0); - } - - #[test] - fn panel_bounds_controller_preserves_scale_domains_and_coalesces_events() { - let mut sample = PanelBoundsSample { - position: PhysicalPosition::new(600, 300), - position_scale_factor: 2.0, - size: PhysicalSize::new(480, 1_000), - size_scale_factor: 2.0, - current_scale_factor: 2.0, - }; - apply_panel_bounds_change( - &mut sample, - PanelBoundsChange::ScaleFactorChanged { - position: None, - size: PhysicalSize::new(480, 1_000), - scale_factor: 1.0, - }, - ); - let bounds = panel_bounds_from_sample(sample); - - assert_eq!(bounds.x, 300.0); - assert_eq!(bounds.y, 150.0); - assert_eq!(bounds.height, 1_000.0); - let mut state = PanelBoundsPersistenceState { - latest: Some(sample), - session: 1, - active: true, - ..PanelBoundsPersistenceState::default() - }; - assert!(PanelBoundsPersistenceController::record_change( - &mut state, - 1, - PanelBoundsChange::Moved(PhysicalPosition::new(800, 400)), - )); - assert!(!PanelBoundsPersistenceController::record_change( - &mut state, - 1, - PanelBoundsChange::Resized(PhysicalSize::new(480, 1_200)), - )); - assert_eq!( - (state.generation, state.worker_running, state.dirty), - (2, true, true) - ); - let mut empty_state = PanelBoundsPersistenceState { - session: 1, - active: true, - ..PanelBoundsPersistenceState::default() - }; - assert!(PanelBoundsPersistenceController::record_change( - &mut empty_state, - 1, - PanelBoundsChange::Snapshot(sample), - )); - assert_eq!(empty_state.latest, Some(sample)); - } - - #[test] - fn panel_move_skips_the_disk_write_while_resize_persists() { - let sample = PanelBoundsSample { - position: PhysicalPosition::new(600, 300), - position_scale_factor: 2.0, - size: PhysicalSize::new(480, 1_000), - size_scale_factor: 2.0, - current_scale_factor: 2.0, - }; - let mut state = PanelBoundsPersistenceState { - latest: Some(sample), - session: 3, - active: true, - ..PanelBoundsPersistenceState::default() - }; - - // 이동도 워커는 깨운다 - 모니터 보정이 붙어 있기 때문 - assert!(PanelBoundsPersistenceController::record_change( - &mut state, - 3, - PanelBoundsChange::Moved(PhysicalPosition::new(800, 400)), - )); - let moved = PanelBoundsPersistenceController::take_dirty_work(&mut state) - .expect("move still schedules worker work"); - assert!(!moved.persist); - assert!(!state.persist_dirty); - - // 리사이즈는 복원에 쓰는 높이를 바꾸므로 저장 대상 - PanelBoundsPersistenceController::record_change( - &mut state, - 3, - PanelBoundsChange::Resized(PhysicalSize::new(480, 1_200)), - ); - let resized = PanelBoundsPersistenceController::take_dirty_work(&mut state) - .expect("resize schedules worker work"); - assert!(resized.persist); - - // 저장 실패는 저장 대상 표식까지 되살린다 - assert!(PanelBoundsPersistenceController::restore_failed_work( - &mut state, &resized - )); - assert!(state.dirty); - assert!(state.persist_dirty); - - // 뒤따르는 이동이 이미 잡힌 저장 대상을 지우지 않는다 - PanelBoundsPersistenceController::record_change( - &mut state, - 3, - PanelBoundsChange::Moved(PhysicalPosition::new(900, 500)), - ); - assert!( - PanelBoundsPersistenceController::take_dirty_work(&mut state) - .expect("coalesced work stays scheduled") - .persist - ); - } - - #[test] - fn panel_reset_ignores_default_layout_resize_before_user_resize() { - let mut state = PanelBoundsPersistenceState { - latest: Some(PanelBoundsSample { - position: PhysicalPosition::new(600, 300), - position_scale_factor: 2.0, - size: PhysicalSize::new(480, 2_000), - size_scale_factor: 2.0, - current_scale_factor: 2.0, - }), - unpersisted_default_height: Some(712.0), - default_height_pending: true, - session: 3, - active: true, - ..PanelBoundsPersistenceState::default() - }; - - PanelBoundsPersistenceController::record_change( - &mut state, - 3, - PanelBoundsChange::Resized(PhysicalSize::new(480, 1_600)), - ); - assert!( - !PanelBoundsPersistenceController::take_dirty_work(&mut state) - .expect("intermediate reset size should schedule constraints") - .persist - ); - - PanelBoundsPersistenceController::record_change( - &mut state, - 3, - PanelBoundsChange::Resized(PhysicalSize::new(480, 1_424)), - ); - assert!( - !PanelBoundsPersistenceController::take_dirty_work(&mut state) - .expect("default reset size should schedule constraints") - .persist - ); - assert!(!state.default_height_pending); - - PanelBoundsPersistenceController::record_change( - &mut state, - 3, - PanelBoundsChange::Resized(PhysicalSize::new(480, 1_500)), - ); - assert!( - PanelBoundsPersistenceController::take_dirty_work(&mut state) - .expect("user resize should schedule persistence") - .persist - ); - assert_eq!(state.unpersisted_default_height, None); - } - - #[test] - fn panel_reset_at_default_height_persists_the_next_user_resize() { - let mut state = PanelBoundsPersistenceState { - latest: Some(PanelBoundsSample { - position: PhysicalPosition::new(600, 300), - position_scale_factor: 2.0, - size: PhysicalSize::new(480, 1_424), - size_scale_factor: 2.0, - current_scale_factor: 2.0, - }), - unpersisted_default_height: Some(712.0), - default_height_pending: false, - session: 4, - active: true, - ..PanelBoundsPersistenceState::default() - }; - - PanelBoundsPersistenceController::record_change( - &mut state, - 4, - PanelBoundsChange::Resized(PhysicalSize::new(480, 1_500)), - ); - - assert!( - PanelBoundsPersistenceController::take_dirty_work(&mut state) - .expect("user resize should schedule persistence") - .persist - ); - assert_eq!(state.unpersisted_default_height, None); - } - - #[test] - fn panel_tray_companion_flag_marks_only_windows_we_hid() { - let hidden = AtomicBool::new(false); - - // 보이는 창만 감추고 표식을 남긴다 - assert!(hide_panel_with_main_transition(&hidden, true, || Ok(())).unwrap()); - assert!(hidden.load(Ordering::SeqCst)); - - // 이미 숨은 창은 건드리지 않고 표식도 그대로 - assert!(!hide_panel_with_main_transition(&hidden, false, || panic!( - "hidden window must not be hidden again" - )) - .unwrap()); - assert!(hidden.load(Ordering::SeqCst)); - - // 복원에 성공하면 표식이 지워진다 - assert!(restore_panel_with_main_transition(&hidden, || Ok(())).unwrap()); - assert!(!hidden.load(Ordering::SeqCst)); - - // 우리가 감추지 않은 창은 복원 대상이 아니다 - assert!(!restore_panel_with_main_transition(&hidden, || panic!( - "untouched window must not be shown" - )) - .unwrap()); - } - - #[test] - fn panel_tray_companion_flag_survives_failed_window_calls() { - let hidden = AtomicBool::new(false); - - // hide 실패는 표식을 세우지 않는다 - 세우면 남의 창을 깨운다 - assert!( - hide_panel_with_main_transition(&hidden, true, || Err(anyhow::anyhow!( - "hide unavailable" - ))) - .is_err() - ); - assert!(!hidden.load(Ordering::SeqCst)); - - hide_panel_with_main_transition(&hidden, true, || Ok(())).unwrap(); - // show 실패는 표식을 되살린다 - assert!( - restore_panel_with_main_transition(&hidden, || Err(anyhow::anyhow!( - "show unavailable" - ))) - .is_err() - ); - assert!(hidden.load(Ordering::SeqCst)); - - // 창이 파괴되면 동행 복원 대상도 사라진다 - assert!(drop_panel_hidden_with_main(&hidden)); - assert!(!hidden.load(Ordering::SeqCst)); - assert!(!drop_panel_hidden_with_main(&hidden)); - } - - #[test] - fn panel_bounds_deactivate_ignores_a_stale_session_token() { - let sample = PanelBoundsSample { - position: PhysicalPosition::new(600, 300), - position_scale_factor: 2.0, - size: PhysicalSize::new(480, 1_000), - size_scale_factor: 2.0, - current_scale_factor: 2.0, - }; - let mut state = PanelBoundsPersistenceState { - latest: Some(sample), - applied_max_height: Some(900.0), - session: 2, - generation: 7, - dirty: true, - active: true, - ..PanelBoundsPersistenceState::default() - }; - - assert!(!PanelBoundsPersistenceController::deactivate_state( - &mut state, 1, - )); - assert!(state.active); - assert_eq!(state.session, 2); - assert_eq!(state.generation, 7); - assert!(state.dirty); - assert_eq!(state.latest, Some(sample)); - assert_eq!(state.applied_max_height, Some(900.0)); - } - - #[test] - fn panel_bounds_record_event_ignores_a_stale_session_token() { - let sample = PanelBoundsSample { - position: PhysicalPosition::new(600, 300), - position_scale_factor: 2.0, - size: PhysicalSize::new(480, 1_000), - size_scale_factor: 2.0, - current_scale_factor: 2.0, - }; - let mut state = PanelBoundsPersistenceState { - latest: Some(sample), - session: 2, - generation: 7, - active: true, - ..PanelBoundsPersistenceState::default() - }; - - assert!(!PanelBoundsPersistenceController::record_change( - &mut state, - 1, - PanelBoundsChange::Moved(PhysicalPosition::new(800, 400)), - )); - assert_eq!(state.latest, Some(sample)); - assert_eq!(state.generation, 7); - assert!(!state.dirty); - assert!(!state.worker_running); - } - - #[test] - fn panel_bounds_flush_keeps_event_after_sample_that_old_code_overwrote() { - let initial = PanelBoundsSample { - position: PhysicalPosition::new(600, 300), - position_scale_factor: 2.0, - size: PhysicalSize::new(480, 1_000), - size_scale_factor: 2.0, - current_scale_factor: 2.0, - }; - let state = Mutex::new(PanelBoundsPersistenceState { - latest: Some(initial), - session: 4, - active: true, - ..PanelBoundsPersistenceState::default() - }); - let generation_before_sample = state.lock().generation; - let sampled = state.lock().latest.unwrap(); - - assert!(PanelBoundsPersistenceController::record_change( - &mut state.lock(), - 4, - PanelBoundsChange::Moved(PhysicalPosition::new(800, 400)), - )); - let expected = state.lock().latest.unwrap(); - let mut persisted = Vec::new(); - - PanelBoundsPersistenceController::flush_samples( - &state, - generation_before_sample, - Ok(sampled), - |sample| { - persisted.push(sample); - Ok(()) - }, - ) - .unwrap(); - - assert_eq!(persisted, vec![expected]); - let state = state.lock(); - assert_eq!(state.latest, Some(expected)); - assert!(!state.dirty); - } - - #[test] - fn panel_bounds_flush_and_event_interleave_without_loss_or_duplicate_sample() { - let initial = PanelBoundsSample { - position: PhysicalPosition::new(600, 300), - position_scale_factor: 2.0, - size: PhysicalSize::new(480, 1_000), - size_scale_factor: 2.0, - current_scale_factor: 2.0, - }; - let state = Mutex::new(PanelBoundsPersistenceState { - latest: Some(initial), - session: 4, - active: true, - ..PanelBoundsPersistenceState::default() - }); - let mut persisted = Vec::new(); - - PanelBoundsPersistenceController::flush_samples(&state, 0, Ok(initial), |sample| { - persisted.push(sample); - if persisted.len() == 1 { - assert!(PanelBoundsPersistenceController::record_change( - &mut state.lock(), - 4, - PanelBoundsChange::Moved(PhysicalPosition::new(800, 400)), - )); - } - Ok(()) - }) - .unwrap(); - - let mut expected_latest = initial; - apply_panel_bounds_change( - &mut expected_latest, - PanelBoundsChange::Moved(PhysicalPosition::new(800, 400)), - ); - assert_eq!(persisted, vec![initial, expected_latest]); - let state = state.lock(); - assert_eq!(state.latest, Some(expected_latest)); - assert!(!state.dirty); - } - - #[test] - fn panel_bounds_flush_uses_latest_when_window_read_fails() { - let latest = PanelBoundsSample { - position: PhysicalPosition::new(600, 300), - position_scale_factor: 2.0, - size: PhysicalSize::new(480, 1_000), - size_scale_factor: 2.0, - current_scale_factor: 2.0, - }; - let state = Mutex::new(PanelBoundsPersistenceState { - latest: Some(latest), - session: 5, - active: true, - ..PanelBoundsPersistenceState::default() - }); - let mut persisted = Vec::new(); - - PanelBoundsPersistenceController::flush_samples( - &state, - 0, - Err(anyhow::anyhow!("window bounds unavailable")), - |sample| { - persisted.push(sample); - Ok(()) - }, - ) - .unwrap(); - assert_eq!(persisted, vec![latest]); - - let empty = Mutex::new(PanelBoundsPersistenceState::default()); - assert!(PanelBoundsPersistenceController::flush_samples( - &empty, - 0, - Err(anyhow::anyhow!("window bounds unavailable")), - |_| panic!("missing bounds must not be persisted"), - ) - .is_err()); - } - - #[test] - fn panel_bounds_flush_failure_restores_dirty_state() { - let sample = PanelBoundsSample { - position: PhysicalPosition::new(600, 300), - position_scale_factor: 2.0, - size: PhysicalSize::new(480, 1_000), - size_scale_factor: 2.0, - current_scale_factor: 2.0, - }; - let state = Mutex::new(PanelBoundsPersistenceState { - latest: Some(sample), - session: 6, - active: true, - ..PanelBoundsPersistenceState::default() - }); - - assert!( - PanelBoundsPersistenceController::flush_samples(&state, 0, Ok(sample), |_| Err( - anyhow::anyhow!("disk unavailable") - ),) - .is_err() - ); - assert!(state.lock().dirty); - } - - #[test] - fn panel_bounds_flush_preserves_cleared_default_height() { - let sample = PanelBoundsSample { - position: PhysicalPosition::new(600, 300), - position_scale_factor: 2.0, - size: PhysicalSize::new(480, 1_424), - size_scale_factor: 2.0, - current_scale_factor: 2.0, - }; - let state = Mutex::new(PanelBoundsPersistenceState { - latest: Some(sample), - unpersisted_default_height: Some(712.0), - default_height_pending: false, - session: 7, - active: true, - ..PanelBoundsPersistenceState::default() - }); - - let persisted = - PanelBoundsPersistenceController::flush_samples(&state, 0, Ok(sample), |_| { - panic!("default height must remain cleared") - }) - .unwrap(); - - assert!(!persisted); - assert_eq!(state.lock().unpersisted_default_height, Some(712.0)); - } - - // 기동 복원 회귀: 방금 배치된 메인 창의 논리 좌표를 받으면 세로 중앙이 맞는다 - #[test] - fn panel_restore_centers_on_the_freshly_placed_main_window() { - let monitors = MonitorData { - specs: vec![work_area_spec(0.0, 30.0, 2_560.0, 1_358.0)], - primary_index: Some(0), - }; - let main = LogicalRect { - x: 829.0, - y: 465.0, - width: 902.0, - height: 488.0, - }; - let layout = resolve_panel_window_layout( - Some(PanelBounds { - x: 2_205.0, - y: 185.0, - height: 712.0, - }), - Some(main), - &monitors, - None, - ); - - assert_eq!(layout.height, 712.0); - let position = layout.position.expect("panel should be placed beside main"); - assert_eq!(position.x, 1_747.0); - assert_eq!(position.y, 353.0); - } - - #[test] - fn panel_layout_leaves_placement_to_the_os_without_monitor_data() { - let monitors = MonitorData { - specs: Vec::new(), - primary_index: None, - }; - let layout = resolve_panel_window_layout( - None, - Some(main_rect(300.0, 100.0, 900.0, 500.0)), - &monitors, - None, - ); - - // 메인 좌표는 있어도 기준 화면을 못 고르면 붙일 자리를 계산할 수 없다 - assert!(layout.position.is_none()); - assert_eq!(layout.height, PANEL_INITIAL_HEIGHT); - } - - #[test] - fn panel_follows_the_monitor_that_holds_the_main_window() { - let monitors = MonitorData { - specs: vec![ - work_area_spec(0.0, 0.0, 1_920.0, 1_080.0), - work_area_spec(1_920.0, 0.0, 1_280.0, 800.0), - ], - primary_index: Some(0), - }; - let layout = resolve_panel_window_layout( - Some(PanelBounds { - x: 31.0, - y: 47.0, - height: 800.0, - }), - Some(main_rect(2_000.0, 100.0, 900.0, 500.0)), - &monitors, - None, - ); - - // 주 모니터가 아니라 메인 창이 놓인 보조 모니터의 한계를 따른다 - assert_eq!(layout.max_height, 720.0); - assert_eq!(layout.height, 720.0); - let position = layout.position.expect("panel should be placed beside main"); - assert_eq!(position.x, 2_000.0 + 900.0 + PANEL_BESIDE_GAP); - assert_eq!(position.y, 0.0); - } - - #[test] - fn persisted_panel_height_clamps_and_position_waits_for_main_geometry() { - let monitors = MonitorData { - specs: Vec::new(), - primary_index: None, - }; - let layout = resolve_panel_window_layout( - Some(PanelBounds { - x: 31.0, - y: 47.0, - height: 200.0, - }), - None, - &monitors, - None, - ); - - assert_eq!(layout.height, PANEL_MIN_HEIGHT); - // 메인 좌표를 못 읽으면 OS 기본 배치 - 저장된 x/y는 더 이상 위치가 아니다 - assert!(layout.position.is_none()); - } - - #[test] - fn panel_monitor_constraint_changes_only_for_a_new_valid_height() { - assert_eq!(changed_panel_max_height(Some(900.0), 900.0), None); - assert_eq!(changed_panel_max_height(Some(900.0), 900.4), None); - assert_eq!(changed_panel_max_height(Some(900.0), 720.0), Some(720.0)); - assert_eq!(changed_panel_max_height(None, 720.0), Some(720.0)); - assert_eq!(changed_panel_max_height(Some(900.0), f64::NAN), None); - assert_eq!(changed_panel_max_height(Some(900.0), 0.0), None); - } - - #[derive(Default)] - struct TestPanelVisibilityEmitter { - events: Mutex>, - } - - impl PanelVisibilityEventEmitter for TestPanelVisibilityEmitter { - fn emit_panel_visibility(&self, payload: PanelVisibilityPayload) -> anyhow::Result<()> { - self.events.lock().push(payload); - Ok(()) - } - } - - #[test] - fn panel_visibility_emits_each_open_state_transition_once() { - let visible = AtomicBool::new(false); - let emitter = TestPanelVisibilityEmitter::default(); - - publish_panel_visibility_transition(&visible, &emitter, true, None).unwrap(); - publish_panel_visibility_transition(&visible, &emitter, true, None).unwrap(); - publish_panel_visibility_transition( - &visible, - &emitter, - false, - Some(PanelVisibilityReason::Closed), - ) - .unwrap(); - - assert_eq!( - *emitter.events.lock(), - vec![ - PanelVisibilityPayload { - visible: true, - reason: None, - }, - PanelVisibilityPayload { - visible: false, - reason: Some(PanelVisibilityReason::Closed), - } - ] - ); - } - - #[test] - fn panel_visibility_wire_distinguishes_closed_and_destroyed() { - for (reason, expected) in [ - (PanelVisibilityReason::Closed, "closed"), - (PanelVisibilityReason::Destroyed, "destroyed"), - ] { - let payload = PanelVisibilityPayload { - visible: false, - reason: Some(reason), - }; - assert_eq!( - serde_json::to_value(payload).unwrap(), - serde_json::json!({ "visible": false, "reason": expected }) - ); - } - - assert_eq!( - serde_json::to_value(PanelVisibilityPayload { - visible: true, - reason: None, - }) - .unwrap(), - serde_json::json!({ "visible": true }) - ); - } - - #[test] - fn panel_command_close_reason_wins_if_destroyed_event_arrives_first() { - let visible = AtomicBool::new(true); - let pending_reason = Mutex::new(Some(PanelVisibilityReason::Closed)); - let emitter = TestPanelVisibilityEmitter::default(); - - publish_panel_hidden_transition( - &visible, - &pending_reason, - &emitter, - PanelVisibilityReason::Destroyed, - ) - .unwrap(); - publish_panel_hidden_transition( - &visible, - &pending_reason, - &emitter, - PanelVisibilityReason::Destroyed, - ) - .unwrap(); - - assert_eq!( - *emitter.events.lock(), - vec![PanelVisibilityPayload { - visible: false, - reason: Some(PanelVisibilityReason::Closed), - }] - ); - } - - #[test] - fn panel_close_request_payload_uses_camel_case_request_id() { - let payload = PanelCloseRequestedPayload { - request_id: "close-1".to_string(), - }; - - assert_eq!( - serde_json::to_value(payload).unwrap(), - serde_json::json!({ "requestId": "close-1" }) - ); - } - - #[test] - fn panel_close_ack_cancels_timeout_fallback() { - let state = Mutex::new(PanelCloseRequestState::Idle); - let fallback_calls = Mutex::new(0usize); - - assert!(begin_panel_close_request(&state, "close-1")); - assert!(acknowledge_panel_close_request(&state, "close-1")); - assert!(!run_panel_close_timeout(&state, "close-1", || { - *fallback_calls.lock() += 1; - Ok(()) - }) - .unwrap()); - - assert_eq!(*fallback_calls.lock(), 0); - assert_eq!(*state.lock(), PanelCloseRequestState::Idle); - } - - #[test] - fn panel_close_timeout_is_single_flight_for_repeated_clicks() { - let state = Mutex::new(PanelCloseRequestState::Idle); - let fallback_calls = Mutex::new(0usize); - - assert!(begin_panel_close_request(&state, "close-1")); - assert!(!begin_panel_close_request(&state, "close-2")); - assert!(!acknowledge_panel_close_request(&state, "close-2")); - assert!(run_panel_close_timeout(&state, "close-1", || { - *fallback_calls.lock() += 1; - Ok(()) - }) - .unwrap()); - assert!(!run_panel_close_timeout(&state, "close-1", || { - *fallback_calls.lock() += 1; - Ok(()) - }) - .unwrap()); - - assert_eq!(*fallback_calls.lock(), 1); - assert_eq!(*state.lock(), PanelCloseRequestState::Idle); - } - - #[test] - fn frontend_lifecycle_targets_skip_the_opener_hosted_panel() { - // 패널 창은 메인 문서가 그리는 자식 - 자체 렌더러가 없어 ack를 낼 수 없다 - let open_labels = ["main", OVERLAY_LABEL, PANEL_LABEL]; - let targets = - collect_frontend_lifecycle_targets(|label| open_labels.contains(&label).then_some(())); - let labels = targets - .into_iter() - .map(|(label, ())| label) - .collect::>(); - - assert_eq!(labels, vec!["main".to_string(), OVERLAY_LABEL.to_string()]); - } - - #[test] - fn overlay_only_flush_failure_restores_every_original_handshake_target() { - let target_windows = ["main", OVERLAY_LABEL] - .into_iter() - .map(str::to_string) - .collect::>(); - let mut slot = Some(EditorFlushHandshake { - id: "handshake-1".to_string(), - completion: EditorFlushCompletion::Lifecycle(FrontendLifecycleAction::Quit), - target_windows: target_windows.clone(), - pending_windows: target_windows, - }); - let active = slot.as_mut().expect("handshake should be active"); - assert!(active.pending_windows.remove("main")); - assert_eq!( - active.pending_windows, - HashSet::from([OVERLAY_LABEL.to_string()]) - ); - - let canceled = take_editor_flush_handshake(&mut slot, "handshake-1") - .expect("overlay failure should cancel the handshake"); - - assert_eq!( - frontend_lifecycle_restore_labels(&canceled.target_windows), - vec!["main", OVERLAY_LABEL] - ); - assert!(slot.is_none()); - } - - #[test] - fn history_flush_request_keeps_existing_event_contract() { - let payload = EditorFlushRequest { - handshake_id: "history-1".to_string(), - action: FrontendFlushAction::History, - }; - - assert_eq!( - serde_json::to_value(payload).unwrap(), - serde_json::json!({ - "handshakeId": "history-1", - "action": "history" - }) - ); - } - - #[test] - fn history_flush_completes_only_after_every_window_ack() { - let (sender, mut receiver) = tokio::sync::oneshot::channel(); - let target_windows = ["main", PANEL_LABEL] - .into_iter() - .map(str::to_string) - .collect::>(); - let mut slot = Some(EditorFlushHandshake { - id: "history-1".to_string(), - completion: EditorFlushCompletion::History { - operation_id: "00000000-0000-0000-0000-000000000001".to_string(), - sender: Some(sender), - phase: FrontendHistoryFlushPhase::Collecting, - barrier: None, - }, - target_windows: target_windows.clone(), - pending_windows: target_windows, - }); - let gate = Arc::new(crate::state::history::HistoryAdmissionGate::default()); - - assert!( - acknowledge_editor_flush_handshake(&mut slot, "history-1", "main", &gate,).is_none() - ); - assert!(matches!( - receiver.try_recv(), - Err(tokio::sync::oneshot::error::TryRecvError::Empty) - )); - - let closing = - acknowledge_editor_flush_handshake(&mut slot, "history-1", PANEL_LABEL, &gate) - .expect("last window should begin gate close"); - assert!(matches!( - closing, - EditorFlushAcknowledge::HistoryClosing { .. } - )); - assert!(gate.is_closed()); - assert!(slot.as_ref().is_some_and(|active| { - active.completion.history_phase() == Some(FrontendHistoryFlushPhase::Closing) - })); - assert!(matches!( - receiver.try_recv(), - Err(tokio::sync::oneshot::error::TryRecvError::Empty) - )); - } - - #[test] - fn acknowledged_history_window_blocks_new_mutations_while_other_window_drains() { - let (sender, _receiver) = tokio::sync::oneshot::channel(); - let targets = HashSet::from(["main".to_string(), PANEL_LABEL.to_string()]); - let mut slot = Some(EditorFlushHandshake { - id: "history-1".to_string(), - completion: EditorFlushCompletion::History { - operation_id: "00000000-0000-0000-0000-000000000001".to_string(), - sender: Some(sender), - phase: FrontendHistoryFlushPhase::Collecting, - barrier: None, - }, - target_windows: targets.clone(), - pending_windows: targets, - }); - - let gate = Arc::new(crate::state::history::HistoryAdmissionGate::default()); - assert!( - acknowledge_editor_flush_handshake(&mut slot, "history-1", "main", &gate,).is_none() - ); - assert!(frontend_history_mutation_blocked(&slot, "main")); - assert!(!frontend_history_mutation_blocked(&slot, PANEL_LABEL)); - } - - #[test] - fn history_flush_ready_releases_gate_before_frontend_lock() { - let gate = Arc::new(crate::state::history::HistoryAdmissionGate::default()); - let barrier = gate.close("00000000-0000-0000-0000-000000000001").unwrap(); - let release_count = Arc::new(AtomicUsize::new(0)); - let release_count_for_guard = Arc::clone(&release_count); - let mut ready = FrontendHistoryFlushReady { - barrier: Some(barrier), - complete: Some(Box::new(move || { - release_count_for_guard.fetch_add(1, Ordering::SeqCst); - })), - }; - - let barrier = ready.take_barrier(); - assert!(gate.is_closed()); - drop(barrier); - assert!(!gate.is_closed()); - assert_eq!(release_count.load(Ordering::SeqCst), 0); - - drop(ready); - assert_eq!(release_count.load(Ordering::SeqCst), 1); - } - - #[test] - fn lifecycle_flush_interrupts_history_but_history_cannot_replace_lifecycle() { - let (history_sender, mut history_receiver) = tokio::sync::oneshot::channel(); - let mut slot = Some(EditorFlushHandshake { - id: "history-1".to_string(), - completion: EditorFlushCompletion::History { - operation_id: "00000000-0000-0000-0000-000000000001".to_string(), - sender: Some(history_sender), - phase: FrontendHistoryFlushPhase::Collecting, - barrier: None, - }, - target_windows: HashSet::from(["main".to_string()]), - pending_windows: HashSet::from(["main".to_string()]), - }); - let lifecycle = EditorFlushHandshake { - id: "quit-1".to_string(), - completion: EditorFlushCompletion::Lifecycle(FrontendLifecycleAction::Quit), - target_windows: HashSet::from(["main".to_string()]), - pending_windows: HashSet::from(["main".to_string()]), - }; - - let LifecycleHandshakeInstall::InterruptedHistory(interrupted) = - install_lifecycle_handshake(&mut slot, lifecycle) - else { - panic!("lifecycle should replace history"); - }; - let interrupted = *interrupted; - let EditorFlushCompletion::History { sender, .. } = interrupted.completion else { - panic!("interrupted completion should be history"); - }; - assert!(sender - .expect("history sender should still be present") - .send(Err(HISTORY_FRONTEND_FLUSH_INTERRUPTED.to_string())) - .is_ok()); - assert!(matches!( - history_receiver.try_recv().unwrap(), - Err(error) if error == HISTORY_FRONTEND_FLUSH_INTERRUPTED - )); - - let (next_history_sender, _next_history_receiver) = tokio::sync::oneshot::channel(); - let next_history = EditorFlushHandshake { - id: "history-2".to_string(), - completion: EditorFlushCompletion::History { - operation_id: "00000000-0000-0000-0000-000000000002".to_string(), - sender: Some(next_history_sender), - phase: FrontendHistoryFlushPhase::Collecting, - barrier: None, - }, - target_windows: HashSet::from(["main".to_string()]), - pending_windows: HashSet::from(["main".to_string()]), - }; - assert!(!install_history_handshake(&mut slot, next_history)); - assert!(slot - .as_ref() - .is_some_and(|active| active.completion.is_lifecycle())); - } - - #[test] - fn closing_history_can_time_out_and_reopen_the_gate() { - let (sender, _receiver) = tokio::sync::oneshot::channel(); - let gate = Arc::new(crate::state::history::HistoryAdmissionGate::default()); - let mut slot = Some(EditorFlushHandshake { - id: "history-closing".to_string(), - completion: EditorFlushCompletion::History { - operation_id: "00000000-0000-0000-0000-000000000003".to_string(), - sender: Some(sender), - phase: FrontendHistoryFlushPhase::Collecting, - barrier: None, - }, - target_windows: HashSet::from(["main".to_string()]), - pending_windows: HashSet::from(["main".to_string()]), - }); - - assert!(matches!( - acknowledge_editor_flush_handshake(&mut slot, "history-closing", "main", &gate,), - Some(EditorFlushAcknowledge::HistoryClosing { .. }) - )); - assert!(gate.is_closed()); - - let timed_out = take_cancelable_editor_flush_handshake(&mut slot, "history-closing") - .expect("closing history should remain cancelable"); - drop(timed_out); - - assert!(!gate.is_closed()); - assert!(slot.is_none()); - } - - #[test] - fn running_history_defers_lifecycle_and_blocks_new_history() { - let mut slot = Some(EditorFlushHandshake { - id: "history-running".to_string(), - completion: EditorFlushCompletion::History { - operation_id: "00000000-0000-0000-0000-000000000004".to_string(), - sender: None, - phase: FrontendHistoryFlushPhase::Running, - barrier: None, - }, - target_windows: HashSet::from(["main".to_string()]), - pending_windows: HashSet::new(), - }); - let lifecycle = EditorFlushHandshake { - id: "quit-deferred".to_string(), - completion: EditorFlushCompletion::Lifecycle(FrontendLifecycleAction::Quit), - target_windows: HashSet::from(["main".to_string()]), - pending_windows: HashSet::from(["main".to_string()]), - }; - - assert!(matches!( - install_lifecycle_handshake(&mut slot, lifecycle), - LifecycleHandshakeInstall::DeferredUntilHistoryComplete - )); - assert!(take_cancelable_editor_flush_handshake(&mut slot, "history-running").is_none()); - - let (sender, _receiver) = tokio::sync::oneshot::channel(); - let next_history = EditorFlushHandshake { - id: "history-next".to_string(), - completion: EditorFlushCompletion::History { - operation_id: "00000000-0000-0000-0000-000000000005".to_string(), - sender: Some(sender), - phase: FrontendHistoryFlushPhase::Collecting, - barrier: None, - }, - target_windows: HashSet::from(["main".to_string()]), - pending_windows: HashSet::from(["main".to_string()]), - }; - assert!(!install_history_handshake(&mut slot, next_history)); - assert!(take_cancelable_editor_flush_handshake(&mut slot, "history-old").is_none()); - } - - #[test] - fn bootstrap_keyboard_state_includes_mode_and_registered_event_key_names() { - let manager = KeyboardManager::new( - HashMap::from([("4key".to_string(), vec!["KeyD".into()])]), - "4key", - ); - - assert!(manager.register_key_down("4key", "KeyD")); - assert_eq!( - bootstrap_keyboard_state(&manager), - ("4key".to_string(), vec!["KeyD".to_string()]) - ); - } - - #[test] - fn event_age_uses_daemon_wall_clock_timestamp_when_sane() { - assert_eq!( - resolve_event_age_ms(Some(1_000.0), Some(1_025.5), 3.0), - 25.5 - ); - } - - #[test] - fn event_age_falls_back_for_invalid_wall_clock_delta() { - for input_ts_ms in [Some(2_000.0), Some(f64::NAN), Some(-f64::INFINITY)] { - assert_eq!(resolve_event_age_ms(input_ts_ms, Some(1_000.0), 7.0), 7.0); - } - assert_eq!( - resolve_event_age_ms(Some(1_000.0), Some(11_001.0), 7.0), - 7.0 - ); - assert_eq!(resolve_event_age_ms(None, Some(1_000.0), 7.0), 7.0); - } - - #[test] - fn key_state_payload_exposes_hold_duration_on_up_only() { - let down = serde_json::to_value(key_state_payload( - "A", - "DOWN", - "4key", - 2.0, - true, - Some(15.0), - )) - .unwrap(); - let up = serde_json::to_value(key_state_payload("A", "UP", "4key", 3.0, false, Some(15.0))) - .unwrap(); - let unmatched_up = - serde_json::to_value(key_state_payload("A", "UP", "4key", 3.0, false, None)).unwrap(); - - assert!(down.get("holdDurationMs").is_none()); - assert_eq!(up["holdDurationMs"], serde_json::json!(15.0)); - assert!(unmatched_up.get("holdDurationMs").is_none()); - } - - #[test] - fn canonical_hold_duration_requires_matching_transition_source() { - assert_eq!(canonical_hold_duration_ms(true, Some(15.0)), Some(15.0)); - assert_eq!(canonical_hold_duration_ms(false, Some(15.0)), None); - assert_eq!(canonical_hold_duration_ms(true, None), None); - } - - #[test] - fn keyboard_recovery_backoff_grows_and_stops_at_the_limit() { - let mut current_attempt = 0; - for (index, delay_ms) in KEYBOARD_RECOVERY_DELAYS_MS.into_iter().enumerate() { - let plan = next_keyboard_recovery_plan(current_attempt, Duration::ZERO).unwrap(); - assert_eq!(plan.attempt, index + 1); - assert_eq!(plan.delay, Duration::from_millis(delay_ms)); - current_attempt = plan.attempt; - } - - assert!(next_keyboard_recovery_plan(current_attempt, Duration::ZERO).is_none()); - } - - #[test] - fn stable_keyboard_daemon_resets_the_recovery_budget() { - let plan = next_keyboard_recovery_plan(5, KEYBOARD_DAEMON_STABLE_RUNTIME).unwrap(); - - assert_eq!(plan.attempt, 1); - assert_eq!(plan.delay, Duration::from_millis(250)); - } - - #[test] - fn keyboard_recovery_guard_rejects_teardown_and_stale_tasks() { - assert!(should_recover_keyboard_daemon(false, 7, Some(7), 7)); - assert!(!should_recover_keyboard_daemon(true, 7, Some(7), 7)); - assert!(!should_recover_keyboard_daemon(false, 8, Some(7), 7)); - assert!(!should_recover_keyboard_daemon(false, 7, Some(8), 7)); - assert!(!should_recover_keyboard_daemon(false, 7, None, 7)); - } - - #[test] - fn startup_authorizes_global_and_tab_css_paths_even_when_disabled() { - let mut state = AppStoreData { - use_custom_css: false, - custom_css: CustomCss { - path: Some("/tmp/global.css".to_string()), - content: String::new(), - }, - ..AppStoreData::default() - }; - state.tab_css_overrides.insert( - "4key".to_string(), - TabCss { - path: Some("/tmp/tab.css".to_string()), - content: String::new(), - enabled: false, - }, - ); - - let authorized = collect_authorized_css_paths(&state); - - assert!(authorized.contains(&path_identity_key(Path::new("/tmp/global.css")))); - assert!(authorized.contains(&path_identity_key(Path::new("/tmp/tab.css")))); - assert_eq!(global_css_watch_path(&state), None); - } - - #[test] - fn overlay_dimension_clamp_covers_tall_track_layouts() { - // 트랙 높이 상한(2000) + 키 영역 + 패딩 조합은 이전 상한 2000을 넘어 잘렸음 - assert_eq!(clamp_overlay_dimension(2400.0), 2400.0); - assert_eq!(clamp_overlay_dimension(4096.0), 4096.0); - assert_eq!(clamp_overlay_dimension(5000.0), 4096.0); - assert_eq!(clamp_overlay_dimension(10.0), 100.0); - assert_eq!(clamp_overlay_dimension(705.4), 705.0); - } - - /// physical 3840x2160 단일 모니터 (logical 폭/높이는 scale로 나눈 값) - fn reset_test_monitors(scale: f64) -> MonitorData { - MonitorData { - specs: vec![MonitorSpec { - logical_origin_x: 0.0, - logical_origin_y: 0.0, - logical_width: 3_840.0 / scale, - logical_height: 2_160.0 / scale, - physical_origin_x: 0.0, - physical_origin_y: 0.0, - physical_width: 3_840.0, - physical_height: 2_160.0, - scale_factor: scale, - }], - primary_index: Some(0), - } - } - - #[test] - fn overlay_reset_falls_back_to_stored_rect_when_window_is_absent() { - // 오버레이를 끈 채 재시작하면 창이 없다 - 저장된 위치가 유일한 근거 - let monitors = reset_test_monitors(1.0); - let stored = OverlayBounds { - x: -3200.0, - y: 980.0, - width: 1240.0, - height: 620.0, - }; - let (position, size) = overlay_reset_fallback_rect(Some(&stored), true, &monitors); - assert_eq!((position.x, position.y), (-3200.0, 980.0)); - assert_eq!((size.width, size.height), (1240.0, 620.0)); - } - - #[test] - fn overlay_reset_converts_legacy_physical_stored_rect() { - // overlay_bounds_are_logical은 serde(default) = false라 구버전 store는 physical px다. - // 이를 logical로 오인하면 겹침 판정이 배로 부풀어 엉뚱한 모니터를 고르고, - // defer_overlay_bounds가 마커를 true로 굳혀 변환 기회가 영영 사라진다 - let monitors = reset_test_monitors(2.0); - let legacy = OverlayBounds { - x: 400.0, - y: 200.0, - width: 1720.0, - height: 640.0, - }; - - let (position, size) = overlay_reset_fallback_rect(Some(&legacy), false, &monitors); - assert_eq!((position.x, position.y), (200.0, 100.0)); - assert_eq!((size.width, size.height), (860.0, 320.0)); - - // 마커가 true면 이미 환산된 값이므로 그대로 쓴다 - let (position, size) = overlay_reset_fallback_rect(Some(&legacy), true, &monitors); - assert_eq!((position.x, position.y), (400.0, 200.0)); - assert_eq!((size.width, size.height), (1720.0, 640.0)); - } - - #[test] - fn overlay_reset_defaults_when_legacy_rect_cannot_be_converted() { - // 모니터 정보를 못 얻으면 physical 값을 logical로 오인하느니 기본 크기가 안전하다 - let monitors = MonitorData { - specs: Vec::new(), - primary_index: None, - }; - let legacy = OverlayBounds { - x: 400.0, - y: 200.0, - width: 1720.0, - height: 640.0, - }; - let (position, size) = overlay_reset_fallback_rect(Some(&legacy), false, &monitors); - assert_eq!((position.x, position.y), (0.0, 0.0)); - assert_eq!( - (size.width, size.height), - (DEFAULT_OVERLAY_WIDTH, DEFAULT_OVERLAY_HEIGHT) - ); - } - - #[test] - fn stored_bounds_normalization_respects_the_logical_marker() { - // resize_overlay의 initializing 분기도 같은 정규화를 거친다. - // 마커를 무시하고 physical 좌표를 쓰면 defer_overlay_bounds가 마커를 - // true로 굳혀 좌표가 영구 고착된다 - let monitors = reset_test_monitors(2.0); - let stored = OverlayBounds { - x: 400.0, - y: 200.0, - width: 1720.0, - height: 640.0, - }; - - let converted = normalize_stored_overlay_bounds(Some(&stored), false, &monitors, None) - .expect("physical 좌표는 환산되어야 한다"); - assert_eq!((converted.x, converted.y), (200.0, 100.0)); - assert_eq!((converted.width, converted.height), (860.0, 320.0)); - - let passthrough = normalize_stored_overlay_bounds(Some(&stored), true, &monitors, None) - .expect("logical 좌표는 그대로 쓴다"); - assert_eq!((passthrough.x, passthrough.y), (400.0, 200.0)); - - // 환산 근거가 없으면 None - 호출부가 창의 실제 위치를 유지하도록 - let blind = MonitorData::default(); - assert!(normalize_stored_overlay_bounds(Some(&stored), false, &blind, None).is_none()); - // 모니터가 없어도 창 scale이 살아 있으면 그것을 근거로 환산한다 - let by_window_scale = - normalize_stored_overlay_bounds(Some(&stored), false, &blind, Some(2.0)) - .expect("창 scale이 2차 환산 근거가 되어야 한다"); - assert_eq!((by_window_scale.x, by_window_scale.y), (200.0, 100.0)); - assert_eq!( - (by_window_scale.width, by_window_scale.height), - (860.0, 320.0) - ); - // 창 scale도 병리적이면 근거가 못 된다 - assert!(normalize_stored_overlay_bounds(Some(&stored), false, &blind, Some(0.0)).is_none()); - assert!( - normalize_stored_overlay_bounds(Some(&stored), false, &blind, Some(f64::NAN)).is_none() - ); - - // 깨진 값은 마커와 무관하게 거른다 - let broken = OverlayBounds { - x: f64::NAN, - y: 20.0, - width: 800.0, - height: 300.0, - }; - assert!(normalize_stored_overlay_bounds(Some(&broken), true, &monitors, None).is_none()); - assert!(normalize_stored_overlay_bounds(None, true, &monitors, None).is_none()); - } - - #[test] - fn monitor_data_is_gathered_only_for_unconverted_stored_bounds() { - // 이 판단이 뒤집히면 initializing 분기가 환산 없이 physical 좌표를 써서 - // M1이 그대로 재발한다. 순수 정규화 테스트만으로는 잡히지 않는 배선이다 - let stored = OverlayBounds { - x: 400.0, - y: 200.0, - width: 1720.0, - height: 640.0, - }; - assert!(stored_bounds_need_monitor_data(false, Some(&stored))); - assert!(!stored_bounds_need_monitor_data(true, Some(&stored))); - // 저장값이 없으면 환산할 대상 자체가 없다 - assert!(!stored_bounds_need_monitor_data(false, None)); - assert!(!stored_bounds_need_monitor_data(true, None)); - } - - #[test] - fn a_false_marker_on_logical_bounds_is_a_double_conversion() { - // defer_overlay_bounds가 마커를 무조건 true로 세팅하는 것은 거짓말이 아니다. - // 호출부가 넘기는 값은 전부 logical이며, 마커를 false로 "보존"하면 - // 다음 세션의 ensure_overlay_window가 x/y/w/h를 전부 다시 나눈다. - // 신규 설치 후 위치 초기화(화면 중앙)가 절반 크기로 왼쪽 위에 뜨게 되는 경로 - let monitors = reset_test_monitors(2.0); - let centered = OverlayBounds { - x: 530.0, - y: 380.0, - width: 860.0, - height: 320.0, - }; - - let double_converted = - normalize_stored_overlay_bounds(Some(¢ered), false, &monitors, None) - .expect("마커가 false면 환산 대상이 된다"); - assert_eq!((double_converted.x, double_converted.y), (265.0, 190.0)); - assert_eq!( - (double_converted.width, double_converted.height), - (430.0, 160.0) - ); - - // 마커가 true여야 저장된 그대로 복원된다 - let preserved = normalize_stored_overlay_bounds(Some(¢ered), true, &monitors, None) - .expect("logical 값은 그대로 쓴다"); - assert_eq!((preserved.x, preserved.y), (530.0, 380.0)); - assert_eq!((preserved.width, preserved.height), (860.0, 320.0)); - } - - #[test] - fn pathological_monitor_scale_is_rejected() { - // scale이 0/NaN인 모니터가 spec에 섞이면 logical 필드가 inf/NaN이 되어 - // clamp와 겹침 판정이 통째로 오염된다. from_monitor가 이 술어로 걸러낸다 - assert!(monitor_scale_is_usable(1.0)); - assert!(monitor_scale_is_usable(2.0)); - assert!(!monitor_scale_is_usable(0.0)); - assert!(!monitor_scale_is_usable(-1.0)); - assert!(!monitor_scale_is_usable(f64::NAN)); - assert!(!monitor_scale_is_usable(f64::INFINITY)); - } - - #[test] - fn initial_placement_clamps_with_the_size_being_applied() { - // 초기화 resize는 콘텐츠 크기를 처음 확정하는 순간이라, 저장된 크기로 - // 판정하면 화면 안으로 되돌린다는 목적을 놓친다 - let monitors = reset_test_monitors(2.0); // logical 1920x1080 - let stored = OverlayBounds { - x: 1900.0, - y: 50.0, - width: 860.0, - height: 320.0, - }; - - // 이번에 적용될 크기는 1200x400 - 저장된 860 기준으로 clamp하면 - // 우측 끝이 1060+1200 = 2260이 되어 340px가 화면 밖에 남는다 - let placement = initial_overlay_placement(&stored, 1200.0, 400.0, &monitors); - assert_eq!(placement.x, 1920.0 - 1200.0); - assert!(placement.x + 1200.0 <= 1920.0); - - // 모니터 정보가 없으면 판정 근거가 없으므로 좌표를 그대로 둔다 - let blind = MonitorData::default(); - let untouched = initial_overlay_placement(&stored, 1200.0, 400.0, &blind); - assert_eq!((untouched.x, untouched.y), (1900.0, 50.0)); - } - - #[test] - fn stored_bounds_normalization_rejects_overflowing_conversions() { - // scale 가드는 0보다 크기만 하면 통과시키므로, 극단적으로 작은 scale에서 - // 나눗셈이 inf로 넘친다. 위치는 clamp 대상이 아니라 여기서 걸러야 store로 새지 않는다 - let monitors = MonitorData { - specs: vec![MonitorSpec { - logical_origin_x: 0.0, - logical_origin_y: 0.0, - logical_width: 1_920.0, - logical_height: 1_080.0, - physical_origin_x: 0.0, - physical_origin_y: 0.0, - physical_width: 1_920.0, - physical_height: 1_080.0, - scale_factor: 1e-300, - }], - primary_index: Some(0), - }; - let stored = OverlayBounds { - x: 1e200, - y: 1e200, - width: 1e200, - height: 1e200, - }; - assert!(normalize_stored_overlay_bounds(Some(&stored), false, &monitors, None).is_none()); - } - - #[test] - fn overlay_reset_fallback_repairs_missing_or_broken_stored_rect() { - let monitors = reset_test_monitors(1.0); - let (position, size) = overlay_reset_fallback_rect(None, true, &monitors); - assert_eq!((position.x, position.y), (0.0, 0.0)); - assert_eq!( - (size.width, size.height), - (DEFAULT_OVERLAY_WIDTH, DEFAULT_OVERLAY_HEIGHT) - ); - - // 크기가 0이거나 NaN이면 중앙 정렬 계산이 무의미해진다 - let collapsed = OverlayBounds { - x: 10.0, - y: 20.0, - width: 0.0, - height: 300.0, - }; - let (_, size) = overlay_reset_fallback_rect(Some(&collapsed), true, &monitors); - assert_eq!( - (size.width, size.height), - (DEFAULT_OVERLAY_WIDTH, DEFAULT_OVERLAY_HEIGHT) - ); - - let broken = OverlayBounds { - x: f64::NAN, - y: 20.0, - width: 800.0, - height: 300.0, - }; - let (_, size) = overlay_reset_fallback_rect(Some(&broken), true, &monitors); - assert_eq!( - (size.width, size.height), - (DEFAULT_OVERLAY_WIDTH, DEFAULT_OVERLAY_HEIGHT) - ); - - // 저장된 크기가 한계를 넘으면 잘라 쓴다 - let oversized = OverlayBounds { - x: 0.0, - y: 0.0, - width: 9000.0, - height: 40.0, - }; - let (_, size) = overlay_reset_fallback_rect(Some(&oversized), true, &monitors); - assert_eq!((size.width, size.height), (4096.0, 100.0)); - } -} +mod tests; diff --git a/src-tauri/src/state/app_state/tests.rs b/src-tauri/src/state/app_state/tests.rs new file mode 100644 index 000000000..bbb6c471b --- /dev/null +++ b/src-tauri/src/state/app_state/tests.rs @@ -0,0 +1,2688 @@ + +use std::{ + collections::{HashMap, HashSet}, + sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + mpsc, Arc, + }, + thread, + time::{Duration, Instant}, +}; + +use super::{ + acknowledge_editor_flush_handshake, acknowledge_panel_close_request, apply_panel_bounds_change, + begin_panel_close_request, bootstrap_keyboard_state, canonical_hold_duration_ms, + changed_panel_max_height, clamp_overlay_dimension, collect_authorized_css_paths, + collect_frontend_lifecycle_targets, drop_panel_hidden_with_main, + frontend_history_mutation_blocked, frontend_lifecycle_restore_labels, global_css_watch_path, + hide_panel_with_main_transition, initial_overlay_placement, install_history_handshake, + install_lifecycle_handshake, is_panel_open_url, key_state_payload, main_window_starts_hidden, + monitor_scale_is_usable, next_keyboard_recovery_plan, normalize_stored_overlay_bounds, + overlay_close_action, overlay_reset_fallback_rect, panel_bounds_from_sample, + panel_height_bounds, panel_position_beside_main, publish_panel_hidden_transition, + publish_panel_visibility_transition, resolve_event_age_ms, resolve_panel_window_layout, + restore_panel_with_main_transition, run_panel_close_timeout, should_create_overlay_on_startup, + should_recover_keyboard_daemon, should_restore_panel_on_startup, + stored_bounds_need_monitor_data, take_cancelable_editor_flush_handshake, + take_editor_flush_handshake, take_panel_open_arm, AppState, EditorFlushAcknowledge, + EditorFlushCompletion, EditorFlushHandshake, EditorFlushRequest, FrontendFlushAction, + FrontendHistoryFlushPhase, FrontendHistoryFlushReady, FrontendLifecycleAction, + KeyCounterEventEmitter, LifecycleHandshakeInstall, LogicalRect, MonitorData, MonitorSpec, + MutationPublicationSequencer, Mutex, OverlayCloseAction, PanelBoundsChange, + PanelBoundsPersistenceController, PanelBoundsPersistenceState, PanelBoundsSample, + PanelCloseRequestState, PanelCloseRequestedPayload, PanelVisibilityEventEmitter, + PanelVisibilityPayload, PanelVisibilityReason, PhysicalPosition, PhysicalSize, + DEFAULT_OVERLAY_HEIGHT, DEFAULT_OVERLAY_WIDTH, HISTORY_FRONTEND_FLUSH_INTERRUPTED, + KEYBOARD_DAEMON_STABLE_RUNTIME, KEYBOARD_RECOVERY_DELAYS_MS, OVERLAY_LABEL, PANEL_BESIDE_GAP, + PANEL_INITIAL_HEIGHT, PANEL_LABEL, PANEL_MIN_HEIGHT, PANEL_OPEN_ARM_TIMEOUT, PANEL_WIDTH, +}; +use crate::{ + keyboard::KeyboardManager, + models::{ + AppStoreData, CustomCss, CustomTab, EditorCommitOrigin, EditorCommitRequest, EditorField, + EditorFrozenKeySlotV1, EditorOpV1, GestureCommitRequest, GesturePluginInstancesChange, + KeyCounters, KeySlot, OverlayBounds, PanelBounds, PluginPoint, SavedPluginInstance, TabCss, + EDITOR_OPS_VERSION, + }, + state::{ + history::{HistoryAdmissionGate, HistoryDirection, HistoryScope}, + local_asset_path::path_identity_key, + store::{ + AppStore, AuxEditorResetTransactionOptions, AuxEditorTransactionOptions, + PluginInstancesResetScope, + }, + }, +}; +use std::path::Path; + +struct NoopCounterEmitter; + +impl KeyCounterEventEmitter for NoopCounterEmitter { + fn emit_key_counters( + &self, + _counters: &KeyCounters, + _session_id: &str, + _revision: u64, + ) -> anyhow::Result<()> { + Ok(()) + } + + fn emit_key_counter( + &self, + _mode: &str, + _key: &str, + _count: u32, + _session_id: &str, + _revision: u64, + ) -> anyhow::Result<()> { + Ok(()) + } +} + +#[test] +fn preset_mapping_commit_drops_queued_increment_for_replaced_key() { + let directory = tempfile::tempdir().unwrap(); + let store = AppStore::initialize_for_test(directory.path()).unwrap(); + store + .update(|data| data.key_counter_enabled = true) + .unwrap(); + let state = Arc::new(AppState::initialize(store).unwrap()); + let emitter = NoopCounterEmitter; + let mode = state.store.snapshot().selected_key_type; + let replaced_key = state.store.snapshot().keys[&mode][0].canonical(); + let preserved_key = state.store.snapshot().keys[&mode][1].canonical(); + for expected in 1..=7 { + assert_eq!( + state.increment_key_counter_and_emit(&emitter, &mode, &preserved_key), + Some(expected) + ); + } + assert_eq!( + state.store.snapshot().key_counters[&mode][&preserved_key], + 0 + ); + let replacement_key = "QA PRESET REPLACEMENT"; + let commit_state = Arc::clone(&state); + let commit_mode = mode.clone(); + let (barrier_ready_tx, barrier_ready_rx) = mpsc::channel(); + let (release_commit_tx, release_commit_rx) = mpsc::channel(); + let commit = thread::spawn(move || { + let admission = commit_state.store.admit_editor_mutation().unwrap(); + commit_state.commit_preset_editor_transaction_preserving_runtime_counters( + &NoopCounterEmitter, + EditorCommitOrigin::LegacyAdapter("preset_load".to_string()), + &[EditorField::Keys, EditorField::KeyPositions], + admission, + |data| { + barrier_ready_tx.send(()).unwrap(); + release_commit_rx + .recv_timeout(Duration::from_secs(3)) + .unwrap(); + data.keys.get_mut(&commit_mode).unwrap()[0] = KeySlot::from(replacement_key); + Ok(()) + }, + ) + }); + barrier_ready_rx + .recv_timeout(Duration::from_secs(3)) + .unwrap(); + assert_eq!( + state.increment_key_counter_and_emit(&emitter, &mode, &replaced_key), + None + ); + release_commit_tx.send(()).unwrap(); + let (transaction, runtime_applied) = commit.join().unwrap().unwrap(); + + assert!(runtime_applied); + assert!(transaction + .change + .result + .changed_fields + .contains(&EditorField::Keys)); + assert!(!state.snapshot_key_counters()[&mode].contains_key(&replaced_key)); + assert_eq!(state.snapshot_key_counters()[&mode][replacement_key], 0); + assert_eq!(state.snapshot_key_counters()[&mode][&preserved_key], 7); + assert!(!state.store.snapshot().key_counters[&mode].contains_key(&replaced_key)); + assert_eq!( + state.store.snapshot().key_counters[&mode][&preserved_key], + 7 + ); + state.shutdown(); + drop(state); + let reloaded = AppStore::initialize_for_test(directory.path()).unwrap(); + assert!(!reloaded.snapshot().key_counters[&mode].contains_key(&replaced_key)); + assert_eq!(reloaded.snapshot().key_counters[&mode][&preserved_key], 7); + reloaded.flush_and_shutdown().unwrap(); +} + +#[test] +fn startup_overlay_creation_covers_all_visibility_and_obs_combinations() { + assert!(!should_create_overlay_on_startup(false, false)); + assert!(should_create_overlay_on_startup(false, true)); + assert!(!should_create_overlay_on_startup(true, false)); + assert!(!should_create_overlay_on_startup(true, true)); +} + +#[test] +fn key_mapping_commit_preserves_unflushed_runtime_counters() { + let directory = std::env::temp_dir().join(format!( + "dmnote-editor-live-counter-test-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&directory).unwrap(); + let state = AppState::initialize(AppStore::initialize_for_test(&directory).unwrap()).unwrap(); + state.key_counter_enabled.store(true, Ordering::SeqCst); + let emitter = NoopCounterEmitter; + let editor = state.store.editor_get(); + let mode = state.store.snapshot().selected_key_type; + let preserved_key = editor.document.keys[&mode][0].canonical(); + + for expected in 1..=7 { + assert_eq!( + state.increment_key_counter_and_emit(&emitter, &mode, &preserved_key), + Some(expected) + ); + } + assert_eq!( + state.store.snapshot().key_counters[&mode][&preserved_key], + 0 + ); + + let replaced_key = editor.document.keys[&mode][1].canonical(); + let replaced_id = editor.document.key_positions[&mode][1].id.clone(); + let request = EditorCommitRequest { + base_revision: editor.revision, + mutation_id: uuid::Uuid::new_v4().to_string(), + multi_key: false, + gesture_id: None, + gesture_ids: Vec::new(), + changes: None, + ops_version: Some(EDITOR_OPS_VERSION), + ops: Some(vec![EditorOpV1::SetKeySlot { + id: replaced_id, + slot: EditorFrozenKeySlotV1::Single("QA NEW KEY".to_string()), + }]), + }; + let admission = state.store.admit_editor_mutation().unwrap(); + let (change, runtime_applied) = state + .commit_editor_document_preserving_runtime_counters(&emitter, request, &admission) + .unwrap(); + drop(admission); + + assert!(runtime_applied); + assert_eq!(change.key_counters[&mode][&preserved_key], 7); + assert_eq!(state.snapshot_key_counters()[&mode][&preserved_key], 7); + let persisted = state.store.snapshot().key_counters; + assert_eq!(persisted[&mode][&preserved_key], 7); + assert_eq!(persisted[&mode]["QA NEW KEY"], 0); + assert!(!persisted[&mode].contains_key(&replaced_key)); + + state.shutdown(); + drop(state); + std::fs::remove_dir_all(directory).unwrap(); +} + +#[test] +fn history_mapping_change_restores_historical_counter_domain() { + let directory = tempfile::tempdir().unwrap(); + let store = AppStore::initialize_for_test(directory.path()).unwrap(); + store + .update(|data| data.key_counter_enabled = true) + .unwrap(); + let state = AppState::initialize(store).unwrap(); + let emitter = NoopCounterEmitter; + let editor = state.store.editor_get(); + let mode = state.store.snapshot().selected_key_type; + let restored_key = editor.document.keys[&mode][0].canonical(); + let replaced_id = editor.document.key_positions[&mode][0].id.clone(); + let replacement_key = "QA HISTORY REPLACEMENT"; + for expected in 1..=12 { + assert_eq!( + state.increment_key_counter_and_emit(&emitter, &mode, &restored_key), + Some(expected) + ); + } + assert!(!state.store.history_status().can_undo); + let request = EditorCommitRequest { + base_revision: editor.revision, + mutation_id: uuid::Uuid::new_v4().to_string(), + multi_key: false, + gesture_id: None, + gesture_ids: Vec::new(), + changes: None, + ops_version: Some(EDITOR_OPS_VERSION), + ops: Some(vec![EditorOpV1::SetKeySlot { + id: replaced_id, + slot: EditorFrozenKeySlotV1::Single(replacement_key.to_string()), + }]), + }; + let admission = state.store.admit_editor_mutation().unwrap(); + let (committed, runtime_applied) = state + .commit_editor_document_preserving_runtime_counters(&emitter, request, &admission) + .unwrap(); + drop(admission); + assert!(runtime_applied); + assert!(!committed.key_counters[&mode].contains_key(&restored_key)); + assert_eq!(committed.key_counters[&mode][replacement_key], 0); + + let operation_id = uuid::Uuid::new_v4().to_string(); + let gate = state.store.history_gate(); + let history_barrier = gate.close(&operation_id).unwrap(); + state.begin_counter_history_barrier(); + let mut counter_guard = state.lock_key_counters_for_history(); + assert_eq!( + state.increment_key_counter_and_emit(&emitter, &mode, replacement_key), + None + ); + let current_counters = counter_guard.clone(); + let undo = state + .store + .apply_history_operation( + HistoryDirection::Undo, + &operation_id, + ¤t_counters, + || {}, + ) + .unwrap(); + let change = undo.change.as_ref().unwrap(); + assert!(state.apply_history_editor_key_runtime_locked(&mut counter_guard, change)); + assert_eq!( + state.increment_key_counter_and_emit(&emitter, &mode, &restored_key), + None + ); + state.finish_counter_history_barrier( + &emitter, + counter_guard, + true, + undo.runtime_publication_generation, + ); + drop(history_barrier); + + let runtime = state.snapshot_key_counters(); + assert_eq!(runtime[&mode][&restored_key], 13); + assert!(!runtime[&mode].contains_key(replacement_key)); + let persisted = state.store.snapshot().key_counters; + assert_eq!(persisted[&mode][&restored_key], 12); + assert!(!persisted[&mode].contains_key(replacement_key)); + + state.shutdown(); + drop(state); + let reloaded = AppStore::initialize_for_test(directory.path()).unwrap(); + assert_eq!(reloaded.snapshot().key_counters[&mode][&restored_key], 13); + assert!(!reloaded.snapshot().key_counters[&mode].contains_key(replacement_key)); + reloaded.flush_and_shutdown().unwrap(); +} + +#[test] +fn custom_tab_create_preserves_unflushed_runtime_counters() { + let directory = std::env::temp_dir().join(format!( + "dmnote-custom-tab-live-counter-test-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&directory).unwrap(); + let state = AppState::initialize(AppStore::initialize_for_test(&directory).unwrap()).unwrap(); + state.key_counter_enabled.store(true, Ordering::SeqCst); + let emitter = NoopCounterEmitter; + let snapshot = state.store.snapshot(); + let mode = snapshot.selected_key_type; + let preserved_key = snapshot.keys[&mode][0].canonical(); + + for expected in 1..=7 { + assert_eq!( + state.increment_key_counter_and_emit(&emitter, &mode, &preserved_key), + Some(expected) + ); + } + assert_eq!( + state.store.snapshot().key_counters[&mode][&preserved_key], + 0 + ); + + let tab_id = "qa-counter-tab".to_string(); + let admission = state.store.admit_editor_mutation().unwrap(); + let (transaction, runtime_applied) = state + .commit_editor_transaction_preserving_runtime_counters(&emitter, |runtime_counters| { + state + .store + .commit_aux_editor_transaction_with_runtime_counters_admission( + AuxEditorTransactionOptions { + scope: HistoryScope::CustomTabs, + observed_history_epoch: None, + origin: EditorCommitOrigin::LegacyAdapter("custom_tabs_create".to_string()), + touched_fields: &[EditorField::Keys, EditorField::KeyPositions], + }, + admission, + runtime_counters, + |store| { + store.custom_tabs.push(CustomTab { + id: tab_id.clone(), + name: "QA Counter Tab".to_string(), + }); + store.keys.insert(tab_id.clone(), Vec::new()); + store.key_positions.insert(tab_id.clone(), Vec::new()); + store.selected_key_type = tab_id.clone(); + Ok(()) + }, + ) + }) + .unwrap(); + + assert!(runtime_applied); + assert_eq!(state.snapshot_key_counters()[&mode][&preserved_key], 7); + assert_eq!( + state.store.snapshot().key_counters[&mode][&preserved_key], + 7 + ); + assert!(transaction.change.document.keys.contains_key(&tab_id)); + drop(transaction); + + assert_eq!( + state.increment_key_counter_and_emit(&emitter, &mode, &preserved_key), + Some(8) + ); + + let operation_id = uuid::Uuid::new_v4().to_string(); + let gate = state.store.history_gate(); + let barrier = gate.close(&operation_id).unwrap(); + state.begin_counter_history_barrier(); + let mut counter_guard = state.lock_key_counters_for_history(); + let current_counters = counter_guard.clone(); + let undo = state + .store + .apply_history_operation( + HistoryDirection::Undo, + &operation_id, + ¤t_counters, + || {}, + ) + .unwrap(); + assert!(state.apply_history_editor_key_runtime_locked( + &mut counter_guard, + undo.change.as_ref().unwrap(), + )); + state.finish_counter_history_barrier( + &emitter, + counter_guard, + true, + undo.runtime_publication_generation, + ); + drop(barrier); + assert_eq!( + state.store.snapshot().key_counters[&mode][&preserved_key], + 8 + ); + assert_eq!(state.snapshot_key_counters()[&mode][&preserved_key], 8); + + state.shutdown(); + drop(state); + std::fs::remove_dir_all(directory).unwrap(); +} + +#[test] +fn custom_tab_delete_preserves_unflushed_runtime_counters() { + let directory = std::env::temp_dir().join(format!( + "dmnote-custom-tab-delete-live-counter-test-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&directory).unwrap(); + let state = AppState::initialize(AppStore::initialize_for_test(&directory).unwrap()).unwrap(); + state.key_counter_enabled.store(true, Ordering::SeqCst); + let emitter = NoopCounterEmitter; + let snapshot = state.store.snapshot(); + let mode = snapshot.selected_key_type; + let preserved_key = snapshot.keys[&mode][0].canonical(); + let tab_id = "qa-counter-delete-tab".to_string(); + let create = state + .store + .commit_aux_editor_transaction( + HistoryScope::CustomTabs, + None, + EditorCommitOrigin::LegacyAdapter("custom_tabs_create".to_string()), + &[EditorField::Keys, EditorField::KeyPositions], + |store| { + store.custom_tabs.push(CustomTab { + id: tab_id.clone(), + name: "QA Counter Delete Tab".to_string(), + }); + store.keys.insert(tab_id.clone(), Vec::new()); + store.key_positions.insert(tab_id.clone(), Vec::new()); + store.selected_key_type = tab_id.clone(); + Ok(()) + }, + ) + .unwrap(); + drop(create); + + for expected in 1..=7 { + assert_eq!( + state.increment_key_counter_and_emit(&emitter, &mode, &preserved_key), + Some(expected) + ); + } + assert_eq!( + state.store.snapshot().key_counters[&mode][&preserved_key], + 0 + ); + + let admission = state.store.admit_editor_mutation().unwrap(); + let (transaction, runtime_applied) = state + .commit_editor_transaction_preserving_runtime_counters(&emitter, |runtime_counters| { + state + .store + .commit_aux_editor_reset_transaction_with_runtime_counters_admission( + AuxEditorResetTransactionOptions { + scope: HistoryScope::CustomTabs, + observed_history_epoch: None, + origin: EditorCommitOrigin::LegacyAdapter("custom_tabs_delete".to_string()), + touched_fields: &[ + EditorField::Keys, + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + EditorField::LayerGroups, + ], + plugin_instances_reset: PluginInstancesResetScope::Mode(tab_id.clone()), + }, + admission, + runtime_counters, + |store| { + store.custom_tabs.retain(|tab| tab.id != tab_id); + store.keys.remove(&tab_id); + store.key_positions.remove(&tab_id); + store.stat_positions.remove(&tab_id); + store.graph_positions.remove(&tab_id); + store.knob_positions.remove(&tab_id); + store.layer_groups.remove(&tab_id); + store.key_counters.remove(&tab_id); + store.selected_key_type = mode.clone(); + Ok(()) + }, + ) + }) + .unwrap(); + + assert!(runtime_applied); + assert_eq!(state.snapshot_key_counters()[&mode][&preserved_key], 7); + assert_eq!( + state.store.snapshot().key_counters[&mode][&preserved_key], + 7 + ); + assert!(!transaction.change.document.keys.contains_key(&tab_id)); + drop(transaction); + + let operation_id = uuid::Uuid::new_v4().to_string(); + let gate = state.store.history_gate(); + let barrier = gate.close(&operation_id).unwrap(); + state + .store + .apply_history_operation( + HistoryDirection::Undo, + &operation_id, + &state.snapshot_key_counters(), + || {}, + ) + .unwrap(); + drop(barrier); + assert_eq!( + state.store.snapshot().key_counters[&mode][&preserved_key], + 7 + ); + + state.shutdown(); + drop(state); + std::fs::remove_dir_all(directory).unwrap(); +} + +#[test] +fn custom_tab_restore_history_preserves_unflushed_runtime_counters() { + let directory = std::env::temp_dir().join(format!( + "dmnote-custom-tab-restore-live-counter-test-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&directory).unwrap(); + let state = AppState::initialize(AppStore::initialize_for_test(&directory).unwrap()).unwrap(); + state.key_counter_enabled.store(true, Ordering::SeqCst); + let emitter = NoopCounterEmitter; + let snapshot = state.store.snapshot(); + let mode = snapshot.selected_key_type; + let preserved_key = snapshot.keys[&mode][0].canonical(); + let tab_id = "qa-counter-restore-tab".to_string(); + let create = state + .store + .commit_aux_editor_transaction( + HistoryScope::CustomTabs, + None, + EditorCommitOrigin::LegacyAdapter("custom_tabs_create".to_string()), + &[EditorField::Keys, EditorField::KeyPositions], + |store| { + store.custom_tabs.push(CustomTab { + id: tab_id.clone(), + name: "Before Restore".to_string(), + }); + store.keys.insert(tab_id.clone(), Vec::new()); + store.key_positions.insert(tab_id.clone(), Vec::new()); + Ok(()) + }, + ) + .unwrap(); + drop(create); + + for expected in 1..=7 { + assert_eq!( + state.increment_key_counter_and_emit(&emitter, &mode, &preserved_key), + Some(expected) + ); + } + let mut restored_tabs = state.store.snapshot().custom_tabs; + restored_tabs[0].name = "After Restore".to_string(); + let admission = state.store.admit_editor_mutation().unwrap(); + let (transaction, runtime_applied) = state + .commit_editor_transaction_preserving_runtime_counters(&emitter, |runtime_counters| { + state + .store + .commit_aux_editor_transaction_with_runtime_counters_admission( + AuxEditorTransactionOptions { + scope: HistoryScope::CustomTabs, + observed_history_epoch: None, + origin: EditorCommitOrigin::LegacyAdapter( + "custom_tabs_restore".to_string(), + ), + touched_fields: &[], + }, + admission, + runtime_counters, + |store| { + store.custom_tabs = restored_tabs; + Ok(()) + }, + ) + }) + .unwrap(); + + assert!(!runtime_applied); + assert_eq!( + state.store.snapshot().key_counters[&mode][&preserved_key], + 7 + ); + assert_eq!(state.store.snapshot().custom_tabs[0].name, "After Restore"); + drop(transaction); + + assert_eq!( + state.increment_key_counter_and_emit(&emitter, &mode, &preserved_key), + Some(8) + ); + + let operation_id = uuid::Uuid::new_v4().to_string(); + let gate = state.store.history_gate(); + let barrier = gate.close(&operation_id).unwrap(); + state + .store + .apply_history_operation( + HistoryDirection::Undo, + &operation_id, + &state.snapshot_key_counters(), + || {}, + ) + .unwrap(); + drop(barrier); + assert_eq!( + state.store.snapshot().key_counters[&mode][&preserved_key], + 8 + ); + assert_eq!(state.snapshot_key_counters()[&mode][&preserved_key], 8); + assert_eq!(state.store.snapshot().custom_tabs[0].name, "Before Restore"); + + state.shutdown(); + drop(state); + std::fs::remove_dir_all(directory).unwrap(); +} + +#[test] +fn mixed_gesture_history_preserves_live_and_restores_historical_counters() { + let directory = std::env::temp_dir().join(format!( + "dmnote-gesture-live-counter-test-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&directory).unwrap(); + let state = AppState::initialize(AppStore::initialize_for_test(&directory).unwrap()).unwrap(); + state.key_counter_enabled.store(true, Ordering::SeqCst); + let emitter = NoopCounterEmitter; + let editor = state.store.editor_get(); + let mode = state.store.snapshot().selected_key_type; + let preserved_key = editor.document.keys[&mode][0].canonical(); + let replaced_key = editor.document.keys[&mode][1].canonical(); + + for expected in 1..=7 { + assert_eq!( + state.increment_key_counter_and_emit(&emitter, &mode, &preserved_key), + Some(expected) + ); + } + for expected in 1..=12 { + assert_eq!( + state.increment_key_counter_and_emit(&emitter, &mode, &replaced_key), + Some(expected) + ); + } + assert_eq!( + state.store.snapshot().key_counters[&mode][&preserved_key], + 0 + ); + + let replaced_id = editor.document.key_positions[&mode][1].id.clone(); + let plugin_id = "qa-mixed-counter"; + let plugin_instance_id = uuid::Uuid::new_v4().to_string(); + let request = GestureCommitRequest { + gesture_id: uuid::Uuid::new_v4().to_string(), + mutation_id: uuid::Uuid::new_v4().to_string(), + editor_base_revision: editor.revision, + plugin_base_revision: state.store.plugin_model_revision(), + observed_history_epoch: Some(state.store.history_status().history_epoch), + authority_generation: 1, + editor_changes: None, + editor_ops_version: Some(EDITOR_OPS_VERSION), + editor_ops: Some(vec![EditorOpV1::SetKeySlot { + id: replaced_id, + slot: EditorFrozenKeySlotV1::Single("QA MIXED KEY".to_string()), + }]), + plugin_changes: vec![GesturePluginInstancesChange { + plugin_id: plugin_id.to_string(), + instances: vec![SavedPluginInstance { + instance_id: Some(plugin_instance_id.clone()), + position: PluginPoint { x: 20.0, y: 30.0 }, + settings: None, + measured_size: None, + tab_id: None, + hidden: false, + z_index: None, + group_id: None, + }], + }], + }; + let admission = state.store.admit_editor_mutation().unwrap(); + let (committed, runtime_applied) = state + .commit_gesture_preserving_runtime_counters(&emitter, request, admission) + .unwrap(); + + assert!(runtime_applied); + assert_eq!(committed.outcome.changed_plugin_ids, [plugin_id]); + let change = committed.outcome.change.as_ref().unwrap(); + assert_eq!(change.key_counters[&mode][&preserved_key], 7); + assert_eq!(state.snapshot_key_counters()[&mode][&preserved_key], 7); + let persisted = state.store.snapshot().key_counters; + assert_eq!(persisted[&mode][&preserved_key], 7); + assert_eq!(persisted[&mode]["QA MIXED KEY"], 0); + assert!(!persisted[&mode].contains_key(&replaced_key)); + let (instances, revision) = state.store.plugin_instances_get(plugin_id).unwrap(); + assert_eq!(revision, 1); + assert_eq!(instances.len(), 1); + assert_eq!( + instances[0].instance_id.as_deref(), + Some(plugin_instance_id.as_str()) + ); + drop(committed); + + for expected in 1..=9 { + assert_eq!( + state.increment_key_counter_and_emit(&emitter, &mode, "QA MIXED KEY"), + Some(expected) + ); + } + assert_eq!( + state.increment_key_counter_and_emit(&emitter, &mode, &preserved_key), + Some(8) + ); + + let operation_id = uuid::Uuid::new_v4().to_string(); + let gate = state.store.history_gate(); + let history_barrier = gate.close(&operation_id).unwrap(); + state.begin_counter_history_barrier(); + let mut counter_guard = state.lock_key_counters_for_history(); + let current_counters = counter_guard.clone(); + let undo = state + .store + .apply_history_operation( + HistoryDirection::Undo, + &operation_id, + ¤t_counters, + || {}, + ) + .unwrap(); + assert!(state.apply_history_editor_key_runtime_locked( + &mut counter_guard, + undo.change.as_ref().unwrap(), + )); + state.finish_counter_history_barrier( + &emitter, + counter_guard, + true, + undo.runtime_publication_generation, + ); + drop(history_barrier); + + let restored = state.snapshot_key_counters(); + assert_eq!(restored[&mode][&replaced_key], 12); + assert_eq!(restored[&mode][&preserved_key], 8); + assert!(!restored[&mode].contains_key("QA MIXED KEY")); + assert!(state + .store + .plugin_instances_get(plugin_id) + .unwrap() + .0 + .is_empty()); + + state.shutdown(); + drop(state); + std::fs::remove_dir_all(directory).unwrap(); +} + +#[test] +fn committed_key_positions_refresh_key_sound_binding_cache() { + let directory = std::env::temp_dir().join(format!( + "dmnote-key-sound-binding-cache-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&directory).unwrap(); + let state = AppState::initialize(AppStore::initialize_for_test(&directory).unwrap()).unwrap(); + let initial = state.store.snapshot(); + let mode = initial.selected_key_type.clone(); + let sound_path = format!("/tmp/key-sound-{}.wav", uuid::Uuid::new_v4()); + let committed_path = sound_path.clone(); + let transaction = state + .store + .commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("key_sound_binding_cache_test".to_string()), + &[EditorField::KeyPositions], + move |store| { + let position = store + .key_positions + .get_mut(&mode) + .unwrap() + .first_mut() + .unwrap(); + position.sound_enabled = Some(true); + position.sound_path = Some(format!(" {committed_path} ")); + position.sound_volume = Some(150.0); + Ok(()) + }, + ) + .unwrap(); + + assert_ne!( + state.resolve_key_sound_binding(&initial.selected_key_type, &[0]), + Some((sound_path.clone(), 1.5)) + ); + assert!(state.publish_committed_key_sound_bindings(&transaction.change)); + assert_eq!( + state.resolve_key_sound_binding(&initial.selected_key_type, &[0]), + Some((sound_path, 1.5)) + ); + + drop(transaction); + state.shutdown(); + drop(state); + std::fs::remove_dir_all(directory).unwrap(); +} + +// 장치 전환 대기는 번호표 밖 - 앞 번호표가 잡혀 있어도 엔진은 먼저 전환되고 +// persist만 turn을 기다린다 (turn 안에서 기다리면 뒤 번호표 전부가 드라이버를 기다림) +#[test] +fn output_backend_switch_runs_before_publication_turn() { + let directory = std::env::temp_dir().join(format!( + "dmnote-output-backend-turn-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&directory).unwrap(); + let state = + Arc::new(AppState::initialize(AppStore::initialize_for_test(&directory).unwrap()).unwrap()); + let blocking_ticket = state.issue_mutation_publication().unwrap(); + + let (done_tx, done_rx) = mpsc::channel(); + let worker_state = Arc::clone(&state); + let worker = thread::spawn(move || { + let result = worker_state + .key_sound_set_output_backend(crate::audio::KeySoundOutputBackend::DefaultDevice); + done_tx.send(result.is_ok()).unwrap(); + }); + + // 앞 번호표가 살아 있는 동안 persist는 turn을 기다린다 + assert!(done_rx.recv_timeout(Duration::from_millis(500)).is_err()); + assert!(state.store.snapshot().key_sound_output_backend.is_none()); + + drop(blocking_ticket); + assert!(done_rx + .recv_timeout(Duration::from_secs(5)) + .expect("앞 번호표가 풀리면 persist가 완료된다")); + worker.join().unwrap(); + assert!(state.store.snapshot().key_sound_output_backend.is_some()); + + state.shutdown(); + drop(state); + std::fs::remove_dir_all(directory).unwrap(); +} + +#[test] +fn mutation_publication_preserves_ticket_order_when_first_worker_is_delayed() { + let publication = Arc::new(MutationPublicationSequencer::default()); + let first = publication.issue().unwrap(); + let second = publication.issue().unwrap(); + let order = Arc::new(Mutex::new(Vec::new())); + let (second_waiting_tx, second_waiting_rx) = mpsc::channel(); + let (second_done_tx, second_done_rx) = mpsc::channel(); + let second_order = Arc::clone(&order); + let second_worker = thread::spawn(move || { + second_waiting_tx.send(()).unwrap(); + second.run(|| second_order.lock().push(2)); + second_done_tx.send(()).unwrap(); + }); + second_waiting_rx + .recv_timeout(Duration::from_secs(2)) + .unwrap(); + assert!(matches!( + second_done_rx.recv_timeout(Duration::from_millis(50)), + Err(mpsc::RecvTimeoutError::Timeout) + )); + + let first_order = Arc::clone(&order); + let first_worker = thread::spawn(move || first.run(|| first_order.lock().push(1))); + first_worker.join().unwrap(); + second_done_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + second_worker.join().unwrap(); + assert_eq!(*order.lock(), vec![1, 2]); +} + +// 커밋 커맨드 구조 재현: 뒤 번호표가 먼저 admit한 뒤 turn을 기다리고, 앞 번호표가 +// 그 다음 admit → turn. lease가 잠금이면 앞 번호표가 admit에서 막혀 영구 교착 +#[test] +fn plugin_authority_admission_does_not_block_earlier_publication_turn() { + use crate::state::plugin::PluginRuntimeAuthority; + + let publication = Arc::new(MutationPublicationSequencer::default()); + let authority = Arc::new(PluginRuntimeAuthority::default()); + authority.reset().unwrap(); + let first = publication.issue().unwrap(); + let second = publication.issue().unwrap(); + let order = Arc::new(Mutex::new(Vec::new())); + + let (second_admitted_tx, second_admitted_rx) = mpsc::channel(); + let second_authority = Arc::clone(&authority); + let second_order = Arc::clone(&order); + let second_worker = thread::spawn(move || { + let lease = second_authority.admit(1).unwrap(); + second_admitted_tx.send(()).unwrap(); + second.run(|| { + second_authority.revalidate(lease).unwrap(); + second_order.lock().push(2); + }); + }); + second_admitted_rx + .recv_timeout(Duration::from_secs(2)) + .unwrap(); + + let first_authority = Arc::clone(&authority); + let first_order = Arc::clone(&order); + let first_worker = thread::spawn(move || { + let lease = first_authority.admit(1).unwrap(); + first.run(|| { + first_authority.revalidate(lease).unwrap(); + first_order.lock().push(1); + }); + }); + + let (done_tx, done_rx) = mpsc::channel(); + thread::spawn(move || { + first_worker.join().unwrap(); + second_worker.join().unwrap(); + done_tx.send(()).unwrap(); + }); + done_rx + .recv_timeout(Duration::from_secs(2)) + .expect("admit이 잠금을 들지 않아야 번호표 순서대로 진행된다"); + assert_eq!(*order.lock(), vec![1, 2]); +} + +// 번호표 FIFO가 reset과 커밋을 직렬화한다 - reset 뒤 turn의 revalidate는 거절 +#[test] +fn plugin_authority_revalidate_rejects_commit_admitted_before_reset() { + use crate::state::plugin::PluginRuntimeAuthority; + + let publication = Arc::new(MutationPublicationSequencer::default()); + let authority = PluginRuntimeAuthority::default(); + authority.reset().unwrap(); + let lease = authority.admit(1).unwrap(); + + let reset_ticket = publication.issue().unwrap(); + let commit_ticket = publication.issue().unwrap(); + reset_ticket.run(|| authority.reset().unwrap()); + let rejected = commit_ticket.run(|| authority.revalidate(lease)); + assert_eq!( + rejected.unwrap_err(), + "AUTHORITY_GENERATION_CHANGED".to_string() + ); +} + +#[test] +fn mutation_publication_advances_after_unrun_ticket_is_dropped() { + let publication = Arc::new(MutationPublicationSequencer::default()); + let first = publication.issue().unwrap(); + let second = publication.issue().unwrap(); + + drop(first); + + assert_eq!(publication.state.lock().serving_ticket, 1); + let mut ran = false; + second.run(|| ran = true); + assert!(ran); +} + +#[test] +fn mutation_publication_advances_after_panicking_turn() { + let publication = Arc::new(MutationPublicationSequencer::default()); + let first = publication.issue().unwrap(); + let second = publication.issue().unwrap(); + let order = Arc::new(Mutex::new(Vec::new())); + let first_order = Arc::clone(&order); + let first_worker = thread::spawn(move || { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + first.run(|| { + first_order.lock().push(1); + panic!("publication panic test"); + }); + })) + }); + let second_order = Arc::clone(&order); + let second_worker = thread::spawn(move || second.run(|| second_order.lock().push(2))); + + assert!(first_worker.join().unwrap().is_err()); + second_worker.join().unwrap(); + assert_eq!(*order.lock(), vec![1, 2]); +} + +#[test] +fn history_close_drains_while_mutation_publication_is_held() { + let publication = Arc::new(MutationPublicationSequencer::default()); + let ticket = publication.issue().unwrap(); + let gate = Arc::new(HistoryAdmissionGate::default()); + let admission = gate.admit_mutation().unwrap(); + let (publication_held_tx, publication_held_rx) = mpsc::channel(); + let (release_publication_tx, release_publication_rx) = mpsc::channel(); + let mutation = thread::spawn(move || { + ticket.run(|| { + publication_held_tx.send(()).unwrap(); + release_publication_rx.recv().unwrap(); + drop(admission); + }); + }); + publication_held_rx + .recv_timeout(Duration::from_secs(2)) + .unwrap(); + + let barrier = gate.begin_close("history-close-publication-test").unwrap(); + let waiter = barrier.waiter(); + let (drained_tx, drained_rx) = mpsc::channel(); + let drain = thread::spawn(move || { + drained_tx.send(waiter.wait_for_drain()).unwrap(); + }); + assert!(matches!( + drained_rx.recv_timeout(Duration::from_millis(50)), + Err(mpsc::RecvTimeoutError::Timeout) + )); + + release_publication_tx.send(()).unwrap(); + assert_eq!( + drained_rx.recv_timeout(Duration::from_secs(2)).unwrap(), + Ok(()) + ); + mutation.join().unwrap(); + drain.join().unwrap(); + drop(barrier); +} + +#[test] +fn overlay_close_preserves_visibility_while_windows_lifecycle_is_pending() { + assert_eq!( + overlay_close_action(false, true), + OverlayCloseAction::PreserveVisibility + ); +} + +#[test] +fn overlay_close_distinguishes_final_shutdown_from_user_close() { + assert_eq!( + overlay_close_action(true, true), + OverlayCloseAction::AllowClose + ); + assert_eq!( + overlay_close_action(false, false), + OverlayCloseAction::HideAndPersist + ); +} + +#[test] +fn startup_panel_restore_requires_detached_without_obs_or_tray_start() { + assert!(should_restore_panel_on_startup(false, false, true)); + assert!(!should_restore_panel_on_startup(false, false, false)); + assert!(!should_restore_panel_on_startup(true, false, true)); + assert!(!should_restore_panel_on_startup(false, true, true)); +} + +#[test] +fn startup_hidden_main_window_needs_both_tray_and_hidden_flags() { + assert!(main_window_starts_hidden(true, true)); + assert!(!main_window_starts_hidden(false, true)); + assert!(!main_window_starts_hidden(true, false)); + // 메인 창 표시와 패널 복원이 같은 판정을 공유한다 + assert!(should_restore_panel_on_startup( + false, + main_window_starts_hidden(false, true), + true + )); + assert!(!should_restore_panel_on_startup( + false, + main_window_starts_hidden(true, true), + true + )); +} + +#[test] +fn panel_window_contract_uses_fixed_client_width() { + assert_eq!(PANEL_LABEL, "panel"); + assert_eq!(PANEL_WIDTH, 240.0); + assert_eq!(PANEL_INITIAL_HEIGHT, 712.0); + assert_eq!(PANEL_MIN_HEIGHT, 712.0); + // 넉넉한 화면에서는 하한이 그대로 유지됨 + assert_eq!( + panel_height_bounds(Some(1_000.0)), + (PANEL_MIN_HEIGHT, 900.0) + ); + // 하한보다 좁은 작업 영역에서는 하한이 화면에 맞춰 내려감 - clamp 역전 방지 + assert_eq!(panel_height_bounds(Some(600.0)), (540.0, 540.0)); + let (min_height, max_height) = panel_height_bounds(None); + assert_eq!(min_height, PANEL_MIN_HEIGHT); + assert!(max_height >= min_height); +} + +#[test] +fn panel_layout_never_exceeds_a_small_work_area() { + let monitors = MonitorData { + specs: vec![MonitorSpec { + logical_origin_x: 0.0, + logical_origin_y: 0.0, + logical_width: 1_280.0, + logical_height: 680.0, + physical_origin_x: 0.0, + physical_origin_y: 0.0, + physical_width: 1_280.0, + physical_height: 680.0, + scale_factor: 1.0, + }], + primary_index: Some(0), + }; + let layout = resolve_panel_window_layout(None, None, &monitors, None); + + assert!(layout.min_height <= layout.max_height); + assert!(layout.height <= 680.0); + assert_eq!(layout.height, layout.max_height); +} + +fn work_area_spec(origin_x: f64, origin_y: f64, width: f64, height: f64) -> MonitorSpec { + MonitorSpec { + logical_origin_x: origin_x, + logical_origin_y: origin_y, + logical_width: width, + logical_height: height, + physical_origin_x: origin_x, + physical_origin_y: origin_y, + physical_width: width, + physical_height: height, + scale_factor: 1.0, + } +} + +fn main_rect(x: f64, y: f64, width: f64, height: f64) -> LogicalRect { + LogicalRect { + x, + y, + width, + height, + } +} + +#[test] +fn panel_lands_beside_the_main_window_on_the_right() { + let work_area = work_area_spec(0.0, 0.0, 1_920.0, 1_080.0); + let position = + panel_position_beside_main(&main_rect(400.0, 200.0, 900.0, 500.0), 400.0, &work_area); + + assert_eq!(position.x, 400.0 + 900.0 + PANEL_BESIDE_GAP); + // 메인 창 세로 중앙 + assert_eq!(position.y, 250.0); +} + +#[test] +fn panel_flips_to_the_left_when_the_right_side_has_no_room() { + let work_area = work_area_spec(0.0, 0.0, 1_920.0, 1_080.0); + let position = + panel_position_beside_main(&main_rect(1_600.0, 200.0, 300.0, 500.0), 400.0, &work_area); + + assert_eq!(position.x, 1_600.0 - PANEL_BESIDE_GAP - PANEL_WIDTH); + assert_eq!(position.y, 250.0); +} + +#[test] +fn panel_clamps_into_the_work_area_when_neither_side_fits() { + let work_area = work_area_spec(0.0, 0.0, 600.0, 800.0); + let position = + panel_position_beside_main(&main_rect(0.0, 0.0, 600.0, 400.0), 300.0, &work_area); + + assert_eq!(position.x, 600.0 - PANEL_WIDTH); + assert_eq!(position.y, 50.0); +} + +#[test] +fn panel_taller_than_the_work_area_clamps_to_its_top() { + let work_area = work_area_spec(100.0, 50.0, 1_920.0, 1_000.0); + let position = + panel_position_beside_main(&main_rect(200.0, 400.0, 900.0, 500.0), 1_400.0, &work_area); + + assert_eq!(position.x, 200.0 + 900.0 + PANEL_BESIDE_GAP); + assert_eq!(position.y, 50.0); +} + +#[test] +fn panel_layout_keeps_stored_height_and_places_beside_main() { + let monitors = MonitorData { + specs: vec![work_area_spec(0.0, 0.0, 1_920.0, 1_080.0)], + primary_index: Some(0), + }; + let layout = resolve_panel_window_layout( + Some(PanelBounds { + x: 31.0, + y: 47.0, + height: 800.0, + }), + Some(main_rect(300.0, 100.0, 900.0, 500.0)), + &monitors, + None, + ); + + // 높이는 저장값 유지, 위치는 저장값을 무시하고 메인 옆으로 다시 계산 + assert_eq!(layout.height, 800.0); + let position = layout.position.expect("panel should be placed beside main"); + assert_eq!(position.x, 300.0 + 900.0 + PANEL_BESIDE_GAP); + // 세로 중앙이 화면 위로 넘치면 작업 영역 상단에 붙는다 + assert_eq!(position.y, 0.0); +} + +#[test] +fn panel_open_arm_is_consumed_once_and_expires() { + let now = Instant::now(); + let mut slot = None; + assert!(!take_panel_open_arm(&mut slot, now)); + + let mut slot = Some(now); + assert!(take_panel_open_arm( + &mut slot, + now + Duration::from_millis(500) + )); + // 소비된 토큰은 다시 쓸 수 없다 + assert!(!take_panel_open_arm( + &mut slot, + now + Duration::from_millis(600) + )); + + let mut slot = Some(now); + assert!(!take_panel_open_arm( + &mut slot, + now + PANEL_OPEN_ARM_TIMEOUT + Duration::from_millis(1) + )); + assert!(slot.is_none()); +} + +#[test] +fn panel_open_accepts_only_blank_urls() { + assert!(is_panel_open_url("")); + assert!(is_panel_open_url("about:blank")); + assert!(!is_panel_open_url("https://example.com")); + assert!(!is_panel_open_url("tauri://localhost/panel/index.html")); +} + +#[test] +fn settled_panel_bounds_convert_physical_geometry_once() { + let bounds = panel_bounds_from_sample(PanelBoundsSample { + position: PhysicalPosition::new(600, 300), + position_scale_factor: 2.0, + size: PhysicalSize::new(480, 1_600), + size_scale_factor: 2.0, + current_scale_factor: 2.0, + }); + + assert_eq!(bounds.x, 300.0); + assert_eq!(bounds.y, 150.0); + // 표본은 실측 그대로 - 하한은 복원 시 모니터 기준으로 다시 적용됨 + assert_eq!(bounds.height, 800.0); +} + +#[test] +fn panel_bounds_controller_preserves_scale_domains_and_coalesces_events() { + let mut sample = PanelBoundsSample { + position: PhysicalPosition::new(600, 300), + position_scale_factor: 2.0, + size: PhysicalSize::new(480, 1_000), + size_scale_factor: 2.0, + current_scale_factor: 2.0, + }; + apply_panel_bounds_change( + &mut sample, + PanelBoundsChange::ScaleFactorChanged { + position: None, + size: PhysicalSize::new(480, 1_000), + scale_factor: 1.0, + }, + ); + let bounds = panel_bounds_from_sample(sample); + + assert_eq!(bounds.x, 300.0); + assert_eq!(bounds.y, 150.0); + assert_eq!(bounds.height, 1_000.0); + let mut state = PanelBoundsPersistenceState { + latest: Some(sample), + session: 1, + active: true, + ..PanelBoundsPersistenceState::default() + }; + assert!(PanelBoundsPersistenceController::record_change( + &mut state, + 1, + PanelBoundsChange::Moved(PhysicalPosition::new(800, 400)), + )); + assert!(!PanelBoundsPersistenceController::record_change( + &mut state, + 1, + PanelBoundsChange::Resized(PhysicalSize::new(480, 1_200)), + )); + assert_eq!( + (state.generation, state.worker_running, state.dirty), + (2, true, true) + ); + let mut empty_state = PanelBoundsPersistenceState { + session: 1, + active: true, + ..PanelBoundsPersistenceState::default() + }; + assert!(PanelBoundsPersistenceController::record_change( + &mut empty_state, + 1, + PanelBoundsChange::Snapshot(sample), + )); + assert_eq!(empty_state.latest, Some(sample)); +} + +#[test] +fn panel_move_skips_the_disk_write_while_resize_persists() { + let sample = PanelBoundsSample { + position: PhysicalPosition::new(600, 300), + position_scale_factor: 2.0, + size: PhysicalSize::new(480, 1_000), + size_scale_factor: 2.0, + current_scale_factor: 2.0, + }; + let mut state = PanelBoundsPersistenceState { + latest: Some(sample), + session: 3, + active: true, + ..PanelBoundsPersistenceState::default() + }; + + // 이동도 워커는 깨운다 - 모니터 보정이 붙어 있기 때문 + assert!(PanelBoundsPersistenceController::record_change( + &mut state, + 3, + PanelBoundsChange::Moved(PhysicalPosition::new(800, 400)), + )); + let moved = PanelBoundsPersistenceController::take_dirty_work(&mut state) + .expect("move still schedules worker work"); + assert!(!moved.persist); + assert!(!state.persist_dirty); + + // 리사이즈는 복원에 쓰는 높이를 바꾸므로 저장 대상 + PanelBoundsPersistenceController::record_change( + &mut state, + 3, + PanelBoundsChange::Resized(PhysicalSize::new(480, 1_200)), + ); + let resized = PanelBoundsPersistenceController::take_dirty_work(&mut state) + .expect("resize schedules worker work"); + assert!(resized.persist); + + // 저장 실패는 저장 대상 표식까지 되살린다 + assert!(PanelBoundsPersistenceController::restore_failed_work( + &mut state, &resized + )); + assert!(state.dirty); + assert!(state.persist_dirty); + + // 뒤따르는 이동이 이미 잡힌 저장 대상을 지우지 않는다 + PanelBoundsPersistenceController::record_change( + &mut state, + 3, + PanelBoundsChange::Moved(PhysicalPosition::new(900, 500)), + ); + assert!( + PanelBoundsPersistenceController::take_dirty_work(&mut state) + .expect("coalesced work stays scheduled") + .persist + ); +} + +#[test] +fn panel_reset_ignores_default_layout_resize_before_user_resize() { + let mut state = PanelBoundsPersistenceState { + latest: Some(PanelBoundsSample { + position: PhysicalPosition::new(600, 300), + position_scale_factor: 2.0, + size: PhysicalSize::new(480, 2_000), + size_scale_factor: 2.0, + current_scale_factor: 2.0, + }), + unpersisted_default_height: Some(712.0), + default_height_pending: true, + session: 3, + active: true, + ..PanelBoundsPersistenceState::default() + }; + + PanelBoundsPersistenceController::record_change( + &mut state, + 3, + PanelBoundsChange::Resized(PhysicalSize::new(480, 1_600)), + ); + assert!( + !PanelBoundsPersistenceController::take_dirty_work(&mut state) + .expect("intermediate reset size should schedule constraints") + .persist + ); + + PanelBoundsPersistenceController::record_change( + &mut state, + 3, + PanelBoundsChange::Resized(PhysicalSize::new(480, 1_424)), + ); + assert!( + !PanelBoundsPersistenceController::take_dirty_work(&mut state) + .expect("default reset size should schedule constraints") + .persist + ); + assert!(!state.default_height_pending); + + PanelBoundsPersistenceController::record_change( + &mut state, + 3, + PanelBoundsChange::Resized(PhysicalSize::new(480, 1_500)), + ); + assert!( + PanelBoundsPersistenceController::take_dirty_work(&mut state) + .expect("user resize should schedule persistence") + .persist + ); + assert_eq!(state.unpersisted_default_height, None); +} + +#[test] +fn panel_reset_at_default_height_persists_the_next_user_resize() { + let mut state = PanelBoundsPersistenceState { + latest: Some(PanelBoundsSample { + position: PhysicalPosition::new(600, 300), + position_scale_factor: 2.0, + size: PhysicalSize::new(480, 1_424), + size_scale_factor: 2.0, + current_scale_factor: 2.0, + }), + unpersisted_default_height: Some(712.0), + default_height_pending: false, + session: 4, + active: true, + ..PanelBoundsPersistenceState::default() + }; + + PanelBoundsPersistenceController::record_change( + &mut state, + 4, + PanelBoundsChange::Resized(PhysicalSize::new(480, 1_500)), + ); + + assert!( + PanelBoundsPersistenceController::take_dirty_work(&mut state) + .expect("user resize should schedule persistence") + .persist + ); + assert_eq!(state.unpersisted_default_height, None); +} + +#[test] +fn panel_tray_companion_flag_marks_only_windows_we_hid() { + let hidden = AtomicBool::new(false); + + // 보이는 창만 감추고 표식을 남긴다 + assert!(hide_panel_with_main_transition(&hidden, true, || Ok(())).unwrap()); + assert!(hidden.load(Ordering::SeqCst)); + + // 이미 숨은 창은 건드리지 않고 표식도 그대로 + assert!(!hide_panel_with_main_transition(&hidden, false, || panic!( + "hidden window must not be hidden again" + )) + .unwrap()); + assert!(hidden.load(Ordering::SeqCst)); + + // 복원에 성공하면 표식이 지워진다 + assert!(restore_panel_with_main_transition(&hidden, || Ok(())).unwrap()); + assert!(!hidden.load(Ordering::SeqCst)); + + // 우리가 감추지 않은 창은 복원 대상이 아니다 + assert!(!restore_panel_with_main_transition(&hidden, || panic!( + "untouched window must not be shown" + )) + .unwrap()); +} + +#[test] +fn panel_tray_companion_flag_survives_failed_window_calls() { + let hidden = AtomicBool::new(false); + + // hide 실패는 표식을 세우지 않는다 - 세우면 남의 창을 깨운다 + assert!( + hide_panel_with_main_transition(&hidden, true, || Err(anyhow::anyhow!("hide unavailable"))) + .is_err() + ); + assert!(!hidden.load(Ordering::SeqCst)); + + hide_panel_with_main_transition(&hidden, true, || Ok(())).unwrap(); + // show 실패는 표식을 되살린다 + assert!( + restore_panel_with_main_transition(&hidden, || Err(anyhow::anyhow!("show unavailable"))) + .is_err() + ); + assert!(hidden.load(Ordering::SeqCst)); + + // 창이 파괴되면 동행 복원 대상도 사라진다 + assert!(drop_panel_hidden_with_main(&hidden)); + assert!(!hidden.load(Ordering::SeqCst)); + assert!(!drop_panel_hidden_with_main(&hidden)); +} + +#[test] +fn panel_bounds_deactivate_ignores_a_stale_session_token() { + let sample = PanelBoundsSample { + position: PhysicalPosition::new(600, 300), + position_scale_factor: 2.0, + size: PhysicalSize::new(480, 1_000), + size_scale_factor: 2.0, + current_scale_factor: 2.0, + }; + let mut state = PanelBoundsPersistenceState { + latest: Some(sample), + applied_max_height: Some(900.0), + session: 2, + generation: 7, + dirty: true, + active: true, + ..PanelBoundsPersistenceState::default() + }; + + assert!(!PanelBoundsPersistenceController::deactivate_state( + &mut state, 1, + )); + assert!(state.active); + assert_eq!(state.session, 2); + assert_eq!(state.generation, 7); + assert!(state.dirty); + assert_eq!(state.latest, Some(sample)); + assert_eq!(state.applied_max_height, Some(900.0)); +} + +#[test] +fn panel_bounds_record_event_ignores_a_stale_session_token() { + let sample = PanelBoundsSample { + position: PhysicalPosition::new(600, 300), + position_scale_factor: 2.0, + size: PhysicalSize::new(480, 1_000), + size_scale_factor: 2.0, + current_scale_factor: 2.0, + }; + let mut state = PanelBoundsPersistenceState { + latest: Some(sample), + session: 2, + generation: 7, + active: true, + ..PanelBoundsPersistenceState::default() + }; + + assert!(!PanelBoundsPersistenceController::record_change( + &mut state, + 1, + PanelBoundsChange::Moved(PhysicalPosition::new(800, 400)), + )); + assert_eq!(state.latest, Some(sample)); + assert_eq!(state.generation, 7); + assert!(!state.dirty); + assert!(!state.worker_running); +} + +#[test] +fn panel_bounds_flush_keeps_event_after_sample_that_old_code_overwrote() { + let initial = PanelBoundsSample { + position: PhysicalPosition::new(600, 300), + position_scale_factor: 2.0, + size: PhysicalSize::new(480, 1_000), + size_scale_factor: 2.0, + current_scale_factor: 2.0, + }; + let state = Mutex::new(PanelBoundsPersistenceState { + latest: Some(initial), + session: 4, + active: true, + ..PanelBoundsPersistenceState::default() + }); + let generation_before_sample = state.lock().generation; + let sampled = state.lock().latest.unwrap(); + + assert!(PanelBoundsPersistenceController::record_change( + &mut state.lock(), + 4, + PanelBoundsChange::Moved(PhysicalPosition::new(800, 400)), + )); + let expected = state.lock().latest.unwrap(); + let mut persisted = Vec::new(); + + PanelBoundsPersistenceController::flush_samples( + &state, + generation_before_sample, + Ok(sampled), + |sample| { + persisted.push(sample); + Ok(()) + }, + ) + .unwrap(); + + assert_eq!(persisted, vec![expected]); + let state = state.lock(); + assert_eq!(state.latest, Some(expected)); + assert!(!state.dirty); +} + +#[test] +fn panel_bounds_flush_and_event_interleave_without_loss_or_duplicate_sample() { + let initial = PanelBoundsSample { + position: PhysicalPosition::new(600, 300), + position_scale_factor: 2.0, + size: PhysicalSize::new(480, 1_000), + size_scale_factor: 2.0, + current_scale_factor: 2.0, + }; + let state = Mutex::new(PanelBoundsPersistenceState { + latest: Some(initial), + session: 4, + active: true, + ..PanelBoundsPersistenceState::default() + }); + let mut persisted = Vec::new(); + + PanelBoundsPersistenceController::flush_samples(&state, 0, Ok(initial), |sample| { + persisted.push(sample); + if persisted.len() == 1 { + assert!(PanelBoundsPersistenceController::record_change( + &mut state.lock(), + 4, + PanelBoundsChange::Moved(PhysicalPosition::new(800, 400)), + )); + } + Ok(()) + }) + .unwrap(); + + let mut expected_latest = initial; + apply_panel_bounds_change( + &mut expected_latest, + PanelBoundsChange::Moved(PhysicalPosition::new(800, 400)), + ); + assert_eq!(persisted, vec![initial, expected_latest]); + let state = state.lock(); + assert_eq!(state.latest, Some(expected_latest)); + assert!(!state.dirty); +} + +#[test] +fn panel_bounds_flush_uses_latest_when_window_read_fails() { + let latest = PanelBoundsSample { + position: PhysicalPosition::new(600, 300), + position_scale_factor: 2.0, + size: PhysicalSize::new(480, 1_000), + size_scale_factor: 2.0, + current_scale_factor: 2.0, + }; + let state = Mutex::new(PanelBoundsPersistenceState { + latest: Some(latest), + session: 5, + active: true, + ..PanelBoundsPersistenceState::default() + }); + let mut persisted = Vec::new(); + + PanelBoundsPersistenceController::flush_samples( + &state, + 0, + Err(anyhow::anyhow!("window bounds unavailable")), + |sample| { + persisted.push(sample); + Ok(()) + }, + ) + .unwrap(); + assert_eq!(persisted, vec![latest]); + + let empty = Mutex::new(PanelBoundsPersistenceState::default()); + assert!(PanelBoundsPersistenceController::flush_samples( + &empty, + 0, + Err(anyhow::anyhow!("window bounds unavailable")), + |_| panic!("missing bounds must not be persisted"), + ) + .is_err()); +} + +#[test] +fn panel_bounds_flush_failure_restores_dirty_state() { + let sample = PanelBoundsSample { + position: PhysicalPosition::new(600, 300), + position_scale_factor: 2.0, + size: PhysicalSize::new(480, 1_000), + size_scale_factor: 2.0, + current_scale_factor: 2.0, + }; + let state = Mutex::new(PanelBoundsPersistenceState { + latest: Some(sample), + session: 6, + active: true, + ..PanelBoundsPersistenceState::default() + }); + + assert!( + PanelBoundsPersistenceController::flush_samples(&state, 0, Ok(sample), |_| Err( + anyhow::anyhow!("disk unavailable") + ),) + .is_err() + ); + assert!(state.lock().dirty); +} + +#[test] +fn panel_bounds_flush_preserves_cleared_default_height() { + let sample = PanelBoundsSample { + position: PhysicalPosition::new(600, 300), + position_scale_factor: 2.0, + size: PhysicalSize::new(480, 1_424), + size_scale_factor: 2.0, + current_scale_factor: 2.0, + }; + let state = Mutex::new(PanelBoundsPersistenceState { + latest: Some(sample), + unpersisted_default_height: Some(712.0), + default_height_pending: false, + session: 7, + active: true, + ..PanelBoundsPersistenceState::default() + }); + + let persisted = PanelBoundsPersistenceController::flush_samples(&state, 0, Ok(sample), |_| { + panic!("default height must remain cleared") + }) + .unwrap(); + + assert!(!persisted); + assert_eq!(state.lock().unpersisted_default_height, Some(712.0)); +} + +// 기동 복원 회귀: 방금 배치된 메인 창의 논리 좌표를 받으면 세로 중앙이 맞는다 +#[test] +fn panel_restore_centers_on_the_freshly_placed_main_window() { + let monitors = MonitorData { + specs: vec![work_area_spec(0.0, 30.0, 2_560.0, 1_358.0)], + primary_index: Some(0), + }; + let main = LogicalRect { + x: 829.0, + y: 465.0, + width: 902.0, + height: 488.0, + }; + let layout = resolve_panel_window_layout( + Some(PanelBounds { + x: 2_205.0, + y: 185.0, + height: 712.0, + }), + Some(main), + &monitors, + None, + ); + + assert_eq!(layout.height, 712.0); + let position = layout.position.expect("panel should be placed beside main"); + assert_eq!(position.x, 1_747.0); + assert_eq!(position.y, 353.0); +} + +#[test] +fn panel_layout_leaves_placement_to_the_os_without_monitor_data() { + let monitors = MonitorData { + specs: Vec::new(), + primary_index: None, + }; + let layout = resolve_panel_window_layout( + None, + Some(main_rect(300.0, 100.0, 900.0, 500.0)), + &monitors, + None, + ); + + // 메인 좌표는 있어도 기준 화면을 못 고르면 붙일 자리를 계산할 수 없다 + assert!(layout.position.is_none()); + assert_eq!(layout.height, PANEL_INITIAL_HEIGHT); +} + +#[test] +fn panel_follows_the_monitor_that_holds_the_main_window() { + let monitors = MonitorData { + specs: vec![ + work_area_spec(0.0, 0.0, 1_920.0, 1_080.0), + work_area_spec(1_920.0, 0.0, 1_280.0, 800.0), + ], + primary_index: Some(0), + }; + let layout = resolve_panel_window_layout( + Some(PanelBounds { + x: 31.0, + y: 47.0, + height: 800.0, + }), + Some(main_rect(2_000.0, 100.0, 900.0, 500.0)), + &monitors, + None, + ); + + // 주 모니터가 아니라 메인 창이 놓인 보조 모니터의 한계를 따른다 + assert_eq!(layout.max_height, 720.0); + assert_eq!(layout.height, 720.0); + let position = layout.position.expect("panel should be placed beside main"); + assert_eq!(position.x, 2_000.0 + 900.0 + PANEL_BESIDE_GAP); + assert_eq!(position.y, 0.0); +} + +#[test] +fn persisted_panel_height_clamps_and_position_waits_for_main_geometry() { + let monitors = MonitorData { + specs: Vec::new(), + primary_index: None, + }; + let layout = resolve_panel_window_layout( + Some(PanelBounds { + x: 31.0, + y: 47.0, + height: 200.0, + }), + None, + &monitors, + None, + ); + + assert_eq!(layout.height, PANEL_MIN_HEIGHT); + // 메인 좌표를 못 읽으면 OS 기본 배치 - 저장된 x/y는 더 이상 위치가 아니다 + assert!(layout.position.is_none()); +} + +#[test] +fn panel_monitor_constraint_changes_only_for_a_new_valid_height() { + assert_eq!(changed_panel_max_height(Some(900.0), 900.0), None); + assert_eq!(changed_panel_max_height(Some(900.0), 900.4), None); + assert_eq!(changed_panel_max_height(Some(900.0), 720.0), Some(720.0)); + assert_eq!(changed_panel_max_height(None, 720.0), Some(720.0)); + assert_eq!(changed_panel_max_height(Some(900.0), f64::NAN), None); + assert_eq!(changed_panel_max_height(Some(900.0), 0.0), None); +} + +#[derive(Default)] +struct TestPanelVisibilityEmitter { + events: Mutex>, +} + +impl PanelVisibilityEventEmitter for TestPanelVisibilityEmitter { + fn emit_panel_visibility(&self, payload: PanelVisibilityPayload) -> anyhow::Result<()> { + self.events.lock().push(payload); + Ok(()) + } +} + +#[test] +fn panel_visibility_emits_each_open_state_transition_once() { + let visible = AtomicBool::new(false); + let emitter = TestPanelVisibilityEmitter::default(); + + publish_panel_visibility_transition(&visible, &emitter, true, None).unwrap(); + publish_panel_visibility_transition(&visible, &emitter, true, None).unwrap(); + publish_panel_visibility_transition( + &visible, + &emitter, + false, + Some(PanelVisibilityReason::Closed), + ) + .unwrap(); + + assert_eq!( + *emitter.events.lock(), + vec![ + PanelVisibilityPayload { + visible: true, + reason: None, + }, + PanelVisibilityPayload { + visible: false, + reason: Some(PanelVisibilityReason::Closed), + } + ] + ); +} + +#[test] +fn panel_visibility_wire_distinguishes_closed_and_destroyed() { + for (reason, expected) in [ + (PanelVisibilityReason::Closed, "closed"), + (PanelVisibilityReason::Destroyed, "destroyed"), + ] { + let payload = PanelVisibilityPayload { + visible: false, + reason: Some(reason), + }; + assert_eq!( + serde_json::to_value(payload).unwrap(), + serde_json::json!({ "visible": false, "reason": expected }) + ); + } + + assert_eq!( + serde_json::to_value(PanelVisibilityPayload { + visible: true, + reason: None, + }) + .unwrap(), + serde_json::json!({ "visible": true }) + ); +} + +#[test] +fn panel_command_close_reason_wins_if_destroyed_event_arrives_first() { + let visible = AtomicBool::new(true); + let pending_reason = Mutex::new(Some(PanelVisibilityReason::Closed)); + let emitter = TestPanelVisibilityEmitter::default(); + + publish_panel_hidden_transition( + &visible, + &pending_reason, + &emitter, + PanelVisibilityReason::Destroyed, + ) + .unwrap(); + publish_panel_hidden_transition( + &visible, + &pending_reason, + &emitter, + PanelVisibilityReason::Destroyed, + ) + .unwrap(); + + assert_eq!( + *emitter.events.lock(), + vec![PanelVisibilityPayload { + visible: false, + reason: Some(PanelVisibilityReason::Closed), + }] + ); +} + +#[test] +fn panel_close_request_payload_uses_camel_case_request_id() { + let payload = PanelCloseRequestedPayload { + request_id: "close-1".to_string(), + }; + + assert_eq!( + serde_json::to_value(payload).unwrap(), + serde_json::json!({ "requestId": "close-1" }) + ); +} + +#[test] +fn panel_close_ack_cancels_timeout_fallback() { + let state = Mutex::new(PanelCloseRequestState::Idle); + let fallback_calls = Mutex::new(0usize); + + assert!(begin_panel_close_request(&state, "close-1")); + assert!(acknowledge_panel_close_request(&state, "close-1")); + assert!(!run_panel_close_timeout(&state, "close-1", || { + *fallback_calls.lock() += 1; + Ok(()) + }) + .unwrap()); + + assert_eq!(*fallback_calls.lock(), 0); + assert_eq!(*state.lock(), PanelCloseRequestState::Idle); +} + +#[test] +fn panel_close_timeout_is_single_flight_for_repeated_clicks() { + let state = Mutex::new(PanelCloseRequestState::Idle); + let fallback_calls = Mutex::new(0usize); + + assert!(begin_panel_close_request(&state, "close-1")); + assert!(!begin_panel_close_request(&state, "close-2")); + assert!(!acknowledge_panel_close_request(&state, "close-2")); + assert!(run_panel_close_timeout(&state, "close-1", || { + *fallback_calls.lock() += 1; + Ok(()) + }) + .unwrap()); + assert!(!run_panel_close_timeout(&state, "close-1", || { + *fallback_calls.lock() += 1; + Ok(()) + }) + .unwrap()); + + assert_eq!(*fallback_calls.lock(), 1); + assert_eq!(*state.lock(), PanelCloseRequestState::Idle); +} + +#[test] +fn frontend_lifecycle_targets_skip_the_opener_hosted_panel() { + // 패널 창은 메인 문서가 그리는 자식 - 자체 렌더러가 없어 ack를 낼 수 없다 + let open_labels = ["main", OVERLAY_LABEL, PANEL_LABEL]; + let targets = + collect_frontend_lifecycle_targets(|label| open_labels.contains(&label).then_some(())); + let labels = targets + .into_iter() + .map(|(label, ())| label) + .collect::>(); + + assert_eq!(labels, vec!["main".to_string(), OVERLAY_LABEL.to_string()]); +} + +#[test] +fn overlay_only_flush_failure_restores_every_original_handshake_target() { + let target_windows = ["main", OVERLAY_LABEL] + .into_iter() + .map(str::to_string) + .collect::>(); + let mut slot = Some(EditorFlushHandshake { + id: "handshake-1".to_string(), + completion: EditorFlushCompletion::Lifecycle(FrontendLifecycleAction::Quit), + target_windows: target_windows.clone(), + pending_windows: target_windows, + }); + let active = slot.as_mut().expect("handshake should be active"); + assert!(active.pending_windows.remove("main")); + assert_eq!( + active.pending_windows, + HashSet::from([OVERLAY_LABEL.to_string()]) + ); + + let canceled = take_editor_flush_handshake(&mut slot, "handshake-1") + .expect("overlay failure should cancel the handshake"); + + assert_eq!( + frontend_lifecycle_restore_labels(&canceled.target_windows), + vec!["main", OVERLAY_LABEL] + ); + assert!(slot.is_none()); +} + +#[test] +fn history_flush_request_keeps_existing_event_contract() { + let payload = EditorFlushRequest { + handshake_id: "history-1".to_string(), + action: FrontendFlushAction::History, + }; + + assert_eq!( + serde_json::to_value(payload).unwrap(), + serde_json::json!({ + "handshakeId": "history-1", + "action": "history" + }) + ); +} + +#[test] +fn history_flush_completes_only_after_every_window_ack() { + let (sender, mut receiver) = tokio::sync::oneshot::channel(); + let target_windows = ["main", PANEL_LABEL] + .into_iter() + .map(str::to_string) + .collect::>(); + let mut slot = Some(EditorFlushHandshake { + id: "history-1".to_string(), + completion: EditorFlushCompletion::History { + operation_id: "00000000-0000-0000-0000-000000000001".to_string(), + sender: Some(sender), + phase: FrontendHistoryFlushPhase::Collecting, + barrier: None, + }, + target_windows: target_windows.clone(), + pending_windows: target_windows, + }); + let gate = Arc::new(crate::state::history::HistoryAdmissionGate::default()); + + assert!(acknowledge_editor_flush_handshake(&mut slot, "history-1", "main", &gate,).is_none()); + assert!(matches!( + receiver.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + )); + + let closing = acknowledge_editor_flush_handshake(&mut slot, "history-1", PANEL_LABEL, &gate) + .expect("last window should begin gate close"); + assert!(matches!( + closing, + EditorFlushAcknowledge::HistoryClosing { .. } + )); + assert!(gate.is_closed()); + assert!(slot.as_ref().is_some_and(|active| { + active.completion.history_phase() == Some(FrontendHistoryFlushPhase::Closing) + })); + assert!(matches!( + receiver.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + )); +} + +#[test] +fn acknowledged_history_window_blocks_new_mutations_while_other_window_drains() { + let (sender, _receiver) = tokio::sync::oneshot::channel(); + let targets = HashSet::from(["main".to_string(), PANEL_LABEL.to_string()]); + let mut slot = Some(EditorFlushHandshake { + id: "history-1".to_string(), + completion: EditorFlushCompletion::History { + operation_id: "00000000-0000-0000-0000-000000000001".to_string(), + sender: Some(sender), + phase: FrontendHistoryFlushPhase::Collecting, + barrier: None, + }, + target_windows: targets.clone(), + pending_windows: targets, + }); + + let gate = Arc::new(crate::state::history::HistoryAdmissionGate::default()); + assert!(acknowledge_editor_flush_handshake(&mut slot, "history-1", "main", &gate,).is_none()); + assert!(frontend_history_mutation_blocked(&slot, "main")); + assert!(!frontend_history_mutation_blocked(&slot, PANEL_LABEL)); +} + +#[test] +fn history_flush_ready_releases_gate_before_frontend_lock() { + let gate = Arc::new(crate::state::history::HistoryAdmissionGate::default()); + let barrier = gate.close("00000000-0000-0000-0000-000000000001").unwrap(); + let release_count = Arc::new(AtomicUsize::new(0)); + let release_count_for_guard = Arc::clone(&release_count); + let mut ready = FrontendHistoryFlushReady { + barrier: Some(barrier), + complete: Some(Box::new(move || { + release_count_for_guard.fetch_add(1, Ordering::SeqCst); + })), + }; + + let barrier = ready.take_barrier(); + assert!(gate.is_closed()); + drop(barrier); + assert!(!gate.is_closed()); + assert_eq!(release_count.load(Ordering::SeqCst), 0); + + drop(ready); + assert_eq!(release_count.load(Ordering::SeqCst), 1); +} + +#[test] +fn lifecycle_flush_interrupts_history_but_history_cannot_replace_lifecycle() { + let (history_sender, mut history_receiver) = tokio::sync::oneshot::channel(); + let mut slot = Some(EditorFlushHandshake { + id: "history-1".to_string(), + completion: EditorFlushCompletion::History { + operation_id: "00000000-0000-0000-0000-000000000001".to_string(), + sender: Some(history_sender), + phase: FrontendHistoryFlushPhase::Collecting, + barrier: None, + }, + target_windows: HashSet::from(["main".to_string()]), + pending_windows: HashSet::from(["main".to_string()]), + }); + let lifecycle = EditorFlushHandshake { + id: "quit-1".to_string(), + completion: EditorFlushCompletion::Lifecycle(FrontendLifecycleAction::Quit), + target_windows: HashSet::from(["main".to_string()]), + pending_windows: HashSet::from(["main".to_string()]), + }; + + let LifecycleHandshakeInstall::InterruptedHistory(interrupted) = + install_lifecycle_handshake(&mut slot, lifecycle) + else { + panic!("lifecycle should replace history"); + }; + let interrupted = *interrupted; + let EditorFlushCompletion::History { sender, .. } = interrupted.completion else { + panic!("interrupted completion should be history"); + }; + assert!(sender + .expect("history sender should still be present") + .send(Err(HISTORY_FRONTEND_FLUSH_INTERRUPTED.to_string())) + .is_ok()); + assert!(matches!( + history_receiver.try_recv().unwrap(), + Err(error) if error == HISTORY_FRONTEND_FLUSH_INTERRUPTED + )); + + let (next_history_sender, _next_history_receiver) = tokio::sync::oneshot::channel(); + let next_history = EditorFlushHandshake { + id: "history-2".to_string(), + completion: EditorFlushCompletion::History { + operation_id: "00000000-0000-0000-0000-000000000002".to_string(), + sender: Some(next_history_sender), + phase: FrontendHistoryFlushPhase::Collecting, + barrier: None, + }, + target_windows: HashSet::from(["main".to_string()]), + pending_windows: HashSet::from(["main".to_string()]), + }; + assert!(!install_history_handshake(&mut slot, next_history)); + assert!(slot + .as_ref() + .is_some_and(|active| active.completion.is_lifecycle())); +} + +#[test] +fn closing_history_can_time_out_and_reopen_the_gate() { + let (sender, _receiver) = tokio::sync::oneshot::channel(); + let gate = Arc::new(crate::state::history::HistoryAdmissionGate::default()); + let mut slot = Some(EditorFlushHandshake { + id: "history-closing".to_string(), + completion: EditorFlushCompletion::History { + operation_id: "00000000-0000-0000-0000-000000000003".to_string(), + sender: Some(sender), + phase: FrontendHistoryFlushPhase::Collecting, + barrier: None, + }, + target_windows: HashSet::from(["main".to_string()]), + pending_windows: HashSet::from(["main".to_string()]), + }); + + assert!(matches!( + acknowledge_editor_flush_handshake(&mut slot, "history-closing", "main", &gate,), + Some(EditorFlushAcknowledge::HistoryClosing { .. }) + )); + assert!(gate.is_closed()); + + let timed_out = take_cancelable_editor_flush_handshake(&mut slot, "history-closing") + .expect("closing history should remain cancelable"); + drop(timed_out); + + assert!(!gate.is_closed()); + assert!(slot.is_none()); +} + +#[test] +fn running_history_defers_lifecycle_and_blocks_new_history() { + let mut slot = Some(EditorFlushHandshake { + id: "history-running".to_string(), + completion: EditorFlushCompletion::History { + operation_id: "00000000-0000-0000-0000-000000000004".to_string(), + sender: None, + phase: FrontendHistoryFlushPhase::Running, + barrier: None, + }, + target_windows: HashSet::from(["main".to_string()]), + pending_windows: HashSet::new(), + }); + let lifecycle = EditorFlushHandshake { + id: "quit-deferred".to_string(), + completion: EditorFlushCompletion::Lifecycle(FrontendLifecycleAction::Quit), + target_windows: HashSet::from(["main".to_string()]), + pending_windows: HashSet::from(["main".to_string()]), + }; + + assert!(matches!( + install_lifecycle_handshake(&mut slot, lifecycle), + LifecycleHandshakeInstall::DeferredUntilHistoryComplete + )); + assert!(take_cancelable_editor_flush_handshake(&mut slot, "history-running").is_none()); + + let (sender, _receiver) = tokio::sync::oneshot::channel(); + let next_history = EditorFlushHandshake { + id: "history-next".to_string(), + completion: EditorFlushCompletion::History { + operation_id: "00000000-0000-0000-0000-000000000005".to_string(), + sender: Some(sender), + phase: FrontendHistoryFlushPhase::Collecting, + barrier: None, + }, + target_windows: HashSet::from(["main".to_string()]), + pending_windows: HashSet::from(["main".to_string()]), + }; + assert!(!install_history_handshake(&mut slot, next_history)); + assert!(take_cancelable_editor_flush_handshake(&mut slot, "history-old").is_none()); +} + +#[test] +fn bootstrap_keyboard_state_includes_mode_and_registered_event_key_names() { + let manager = KeyboardManager::new( + HashMap::from([("4key".to_string(), vec!["KeyD".into()])]), + "4key", + ); + + assert!(manager.register_key_down("4key", "KeyD")); + assert_eq!( + bootstrap_keyboard_state(&manager), + ("4key".to_string(), vec!["KeyD".to_string()]) + ); +} + +#[test] +fn event_age_uses_daemon_wall_clock_timestamp_when_sane() { + assert_eq!( + resolve_event_age_ms(Some(1_000.0), Some(1_025.5), 3.0), + 25.5 + ); +} + +#[test] +fn event_age_falls_back_for_invalid_wall_clock_delta() { + for input_ts_ms in [Some(2_000.0), Some(f64::NAN), Some(-f64::INFINITY)] { + assert_eq!(resolve_event_age_ms(input_ts_ms, Some(1_000.0), 7.0), 7.0); + } + assert_eq!( + resolve_event_age_ms(Some(1_000.0), Some(11_001.0), 7.0), + 7.0 + ); + assert_eq!(resolve_event_age_ms(None, Some(1_000.0), 7.0), 7.0); +} + +#[test] +fn key_state_payload_exposes_hold_duration_on_up_only() { + let down = serde_json::to_value(key_state_payload( + "A", + "DOWN", + "4key", + 2.0, + true, + Some(15.0), + )) + .unwrap(); + let up = + serde_json::to_value(key_state_payload("A", "UP", "4key", 3.0, false, Some(15.0))).unwrap(); + let unmatched_up = + serde_json::to_value(key_state_payload("A", "UP", "4key", 3.0, false, None)).unwrap(); + + assert!(down.get("holdDurationMs").is_none()); + assert_eq!(up["holdDurationMs"], serde_json::json!(15.0)); + assert!(unmatched_up.get("holdDurationMs").is_none()); +} + +#[test] +fn canonical_hold_duration_requires_matching_transition_source() { + assert_eq!(canonical_hold_duration_ms(true, Some(15.0)), Some(15.0)); + assert_eq!(canonical_hold_duration_ms(false, Some(15.0)), None); + assert_eq!(canonical_hold_duration_ms(true, None), None); +} + +#[test] +fn keyboard_recovery_backoff_grows_and_stops_at_the_limit() { + let mut current_attempt = 0; + for (index, delay_ms) in KEYBOARD_RECOVERY_DELAYS_MS.into_iter().enumerate() { + let plan = next_keyboard_recovery_plan(current_attempt, Duration::ZERO).unwrap(); + assert_eq!(plan.attempt, index + 1); + assert_eq!(plan.delay, Duration::from_millis(delay_ms)); + current_attempt = plan.attempt; + } + + assert!(next_keyboard_recovery_plan(current_attempt, Duration::ZERO).is_none()); +} + +#[test] +fn stable_keyboard_daemon_resets_the_recovery_budget() { + let plan = next_keyboard_recovery_plan(5, KEYBOARD_DAEMON_STABLE_RUNTIME).unwrap(); + + assert_eq!(plan.attempt, 1); + assert_eq!(plan.delay, Duration::from_millis(250)); +} + +#[test] +fn keyboard_recovery_guard_rejects_teardown_and_stale_tasks() { + assert!(should_recover_keyboard_daemon(false, 7, Some(7), 7)); + assert!(!should_recover_keyboard_daemon(true, 7, Some(7), 7)); + assert!(!should_recover_keyboard_daemon(false, 8, Some(7), 7)); + assert!(!should_recover_keyboard_daemon(false, 7, Some(8), 7)); + assert!(!should_recover_keyboard_daemon(false, 7, None, 7)); +} + +#[test] +fn startup_authorizes_global_and_tab_css_paths_even_when_disabled() { + let mut state = AppStoreData { + use_custom_css: false, + custom_css: CustomCss { + path: Some("/tmp/global.css".to_string()), + content: String::new(), + }, + ..AppStoreData::default() + }; + state.tab_css_overrides.insert( + "4key".to_string(), + TabCss { + path: Some("/tmp/tab.css".to_string()), + content: String::new(), + enabled: false, + }, + ); + + let authorized = collect_authorized_css_paths(&state); + + assert!(authorized.contains(&path_identity_key(Path::new("/tmp/global.css")))); + assert!(authorized.contains(&path_identity_key(Path::new("/tmp/tab.css")))); + assert_eq!(global_css_watch_path(&state), None); +} + +#[test] +fn overlay_dimension_clamp_covers_tall_track_layouts() { + // 트랙 높이 상한(2000) + 키 영역 + 패딩 조합은 이전 상한 2000을 넘어 잘렸음 + assert_eq!(clamp_overlay_dimension(2400.0), 2400.0); + assert_eq!(clamp_overlay_dimension(4096.0), 4096.0); + assert_eq!(clamp_overlay_dimension(5000.0), 4096.0); + assert_eq!(clamp_overlay_dimension(10.0), 100.0); + assert_eq!(clamp_overlay_dimension(705.4), 705.0); +} + +/// physical 3840x2160 단일 모니터 (logical 폭/높이는 scale로 나눈 값) +fn reset_test_monitors(scale: f64) -> MonitorData { + MonitorData { + specs: vec![MonitorSpec { + logical_origin_x: 0.0, + logical_origin_y: 0.0, + logical_width: 3_840.0 / scale, + logical_height: 2_160.0 / scale, + physical_origin_x: 0.0, + physical_origin_y: 0.0, + physical_width: 3_840.0, + physical_height: 2_160.0, + scale_factor: scale, + }], + primary_index: Some(0), + } +} + +#[test] +fn overlay_reset_falls_back_to_stored_rect_when_window_is_absent() { + // 오버레이를 끈 채 재시작하면 창이 없다 - 저장된 위치가 유일한 근거 + let monitors = reset_test_monitors(1.0); + let stored = OverlayBounds { + x: -3200.0, + y: 980.0, + width: 1240.0, + height: 620.0, + }; + let (position, size) = overlay_reset_fallback_rect(Some(&stored), true, &monitors); + assert_eq!((position.x, position.y), (-3200.0, 980.0)); + assert_eq!((size.width, size.height), (1240.0, 620.0)); +} + +#[test] +fn overlay_reset_converts_legacy_physical_stored_rect() { + // overlay_bounds_are_logical은 serde(default) = false라 구버전 store는 physical px다. + // 이를 logical로 오인하면 겹침 판정이 배로 부풀어 엉뚱한 모니터를 고르고, + // defer_overlay_bounds가 마커를 true로 굳혀 변환 기회가 영영 사라진다 + let monitors = reset_test_monitors(2.0); + let legacy = OverlayBounds { + x: 400.0, + y: 200.0, + width: 1720.0, + height: 640.0, + }; + + let (position, size) = overlay_reset_fallback_rect(Some(&legacy), false, &monitors); + assert_eq!((position.x, position.y), (200.0, 100.0)); + assert_eq!((size.width, size.height), (860.0, 320.0)); + + // 마커가 true면 이미 환산된 값이므로 그대로 쓴다 + let (position, size) = overlay_reset_fallback_rect(Some(&legacy), true, &monitors); + assert_eq!((position.x, position.y), (400.0, 200.0)); + assert_eq!((size.width, size.height), (1720.0, 640.0)); +} + +#[test] +fn overlay_reset_defaults_when_legacy_rect_cannot_be_converted() { + // 모니터 정보를 못 얻으면 physical 값을 logical로 오인하느니 기본 크기가 안전하다 + let monitors = MonitorData { + specs: Vec::new(), + primary_index: None, + }; + let legacy = OverlayBounds { + x: 400.0, + y: 200.0, + width: 1720.0, + height: 640.0, + }; + let (position, size) = overlay_reset_fallback_rect(Some(&legacy), false, &monitors); + assert_eq!((position.x, position.y), (0.0, 0.0)); + assert_eq!( + (size.width, size.height), + (DEFAULT_OVERLAY_WIDTH, DEFAULT_OVERLAY_HEIGHT) + ); +} + +#[test] +fn stored_bounds_normalization_respects_the_logical_marker() { + // resize_overlay의 initializing 분기도 같은 정규화를 거친다. + // 마커를 무시하고 physical 좌표를 쓰면 defer_overlay_bounds가 마커를 + // true로 굳혀 좌표가 영구 고착된다 + let monitors = reset_test_monitors(2.0); + let stored = OverlayBounds { + x: 400.0, + y: 200.0, + width: 1720.0, + height: 640.0, + }; + + let converted = normalize_stored_overlay_bounds(Some(&stored), false, &monitors, None) + .expect("physical 좌표는 환산되어야 한다"); + assert_eq!((converted.x, converted.y), (200.0, 100.0)); + assert_eq!((converted.width, converted.height), (860.0, 320.0)); + + let passthrough = normalize_stored_overlay_bounds(Some(&stored), true, &monitors, None) + .expect("logical 좌표는 그대로 쓴다"); + assert_eq!((passthrough.x, passthrough.y), (400.0, 200.0)); + + // 환산 근거가 없으면 None - 호출부가 창의 실제 위치를 유지하도록 + let blind = MonitorData::default(); + assert!(normalize_stored_overlay_bounds(Some(&stored), false, &blind, None).is_none()); + // 모니터가 없어도 창 scale이 살아 있으면 그것을 근거로 환산한다 + let by_window_scale = normalize_stored_overlay_bounds(Some(&stored), false, &blind, Some(2.0)) + .expect("창 scale이 2차 환산 근거가 되어야 한다"); + assert_eq!((by_window_scale.x, by_window_scale.y), (200.0, 100.0)); + assert_eq!( + (by_window_scale.width, by_window_scale.height), + (860.0, 320.0) + ); + // 창 scale도 병리적이면 근거가 못 된다 + assert!(normalize_stored_overlay_bounds(Some(&stored), false, &blind, Some(0.0)).is_none()); + assert!( + normalize_stored_overlay_bounds(Some(&stored), false, &blind, Some(f64::NAN)).is_none() + ); + + // 깨진 값은 마커와 무관하게 거른다 + let broken = OverlayBounds { + x: f64::NAN, + y: 20.0, + width: 800.0, + height: 300.0, + }; + assert!(normalize_stored_overlay_bounds(Some(&broken), true, &monitors, None).is_none()); + assert!(normalize_stored_overlay_bounds(None, true, &monitors, None).is_none()); +} + +#[test] +fn monitor_data_is_gathered_only_for_unconverted_stored_bounds() { + // 이 판단이 뒤집히면 initializing 분기가 환산 없이 physical 좌표를 써서 + // M1이 그대로 재발한다. 순수 정규화 테스트만으로는 잡히지 않는 배선이다 + let stored = OverlayBounds { + x: 400.0, + y: 200.0, + width: 1720.0, + height: 640.0, + }; + assert!(stored_bounds_need_monitor_data(false, Some(&stored))); + assert!(!stored_bounds_need_monitor_data(true, Some(&stored))); + // 저장값이 없으면 환산할 대상 자체가 없다 + assert!(!stored_bounds_need_monitor_data(false, None)); + assert!(!stored_bounds_need_monitor_data(true, None)); +} + +#[test] +fn a_false_marker_on_logical_bounds_is_a_double_conversion() { + // defer_overlay_bounds가 마커를 무조건 true로 세팅하는 것은 거짓말이 아니다. + // 호출부가 넘기는 값은 전부 logical이며, 마커를 false로 "보존"하면 + // 다음 세션의 ensure_overlay_window가 x/y/w/h를 전부 다시 나눈다. + // 신규 설치 후 위치 초기화(화면 중앙)가 절반 크기로 왼쪽 위에 뜨게 되는 경로 + let monitors = reset_test_monitors(2.0); + let centered = OverlayBounds { + x: 530.0, + y: 380.0, + width: 860.0, + height: 320.0, + }; + + let double_converted = normalize_stored_overlay_bounds(Some(¢ered), false, &monitors, None) + .expect("마커가 false면 환산 대상이 된다"); + assert_eq!((double_converted.x, double_converted.y), (265.0, 190.0)); + assert_eq!( + (double_converted.width, double_converted.height), + (430.0, 160.0) + ); + + // 마커가 true여야 저장된 그대로 복원된다 + let preserved = normalize_stored_overlay_bounds(Some(¢ered), true, &monitors, None) + .expect("logical 값은 그대로 쓴다"); + assert_eq!((preserved.x, preserved.y), (530.0, 380.0)); + assert_eq!((preserved.width, preserved.height), (860.0, 320.0)); +} + +#[test] +fn pathological_monitor_scale_is_rejected() { + // scale이 0/NaN인 모니터가 spec에 섞이면 logical 필드가 inf/NaN이 되어 + // clamp와 겹침 판정이 통째로 오염된다. from_monitor가 이 술어로 걸러낸다 + assert!(monitor_scale_is_usable(1.0)); + assert!(monitor_scale_is_usable(2.0)); + assert!(!monitor_scale_is_usable(0.0)); + assert!(!monitor_scale_is_usable(-1.0)); + assert!(!monitor_scale_is_usable(f64::NAN)); + assert!(!monitor_scale_is_usable(f64::INFINITY)); +} + +#[test] +fn initial_placement_clamps_with_the_size_being_applied() { + // 초기화 resize는 콘텐츠 크기를 처음 확정하는 순간이라, 저장된 크기로 + // 판정하면 화면 안으로 되돌린다는 목적을 놓친다 + let monitors = reset_test_monitors(2.0); // logical 1920x1080 + let stored = OverlayBounds { + x: 1900.0, + y: 50.0, + width: 860.0, + height: 320.0, + }; + + // 이번에 적용될 크기는 1200x400 - 저장된 860 기준으로 clamp하면 + // 우측 끝이 1060+1200 = 2260이 되어 340px가 화면 밖에 남는다 + let placement = initial_overlay_placement(&stored, 1200.0, 400.0, &monitors); + assert_eq!(placement.x, 1920.0 - 1200.0); + assert!(placement.x + 1200.0 <= 1920.0); + + // 모니터 정보가 없으면 판정 근거가 없으므로 좌표를 그대로 둔다 + let blind = MonitorData::default(); + let untouched = initial_overlay_placement(&stored, 1200.0, 400.0, &blind); + assert_eq!((untouched.x, untouched.y), (1900.0, 50.0)); +} + +#[test] +fn stored_bounds_normalization_rejects_overflowing_conversions() { + // scale 가드는 0보다 크기만 하면 통과시키므로, 극단적으로 작은 scale에서 + // 나눗셈이 inf로 넘친다. 위치는 clamp 대상이 아니라 여기서 걸러야 store로 새지 않는다 + let monitors = MonitorData { + specs: vec![MonitorSpec { + logical_origin_x: 0.0, + logical_origin_y: 0.0, + logical_width: 1_920.0, + logical_height: 1_080.0, + physical_origin_x: 0.0, + physical_origin_y: 0.0, + physical_width: 1_920.0, + physical_height: 1_080.0, + scale_factor: 1e-300, + }], + primary_index: Some(0), + }; + let stored = OverlayBounds { + x: 1e200, + y: 1e200, + width: 1e200, + height: 1e200, + }; + assert!(normalize_stored_overlay_bounds(Some(&stored), false, &monitors, None).is_none()); +} + +#[test] +fn overlay_reset_fallback_repairs_missing_or_broken_stored_rect() { + let monitors = reset_test_monitors(1.0); + let (position, size) = overlay_reset_fallback_rect(None, true, &monitors); + assert_eq!((position.x, position.y), (0.0, 0.0)); + assert_eq!( + (size.width, size.height), + (DEFAULT_OVERLAY_WIDTH, DEFAULT_OVERLAY_HEIGHT) + ); + + // 크기가 0이거나 NaN이면 중앙 정렬 계산이 무의미해진다 + let collapsed = OverlayBounds { + x: 10.0, + y: 20.0, + width: 0.0, + height: 300.0, + }; + let (_, size) = overlay_reset_fallback_rect(Some(&collapsed), true, &monitors); + assert_eq!( + (size.width, size.height), + (DEFAULT_OVERLAY_WIDTH, DEFAULT_OVERLAY_HEIGHT) + ); + + let broken = OverlayBounds { + x: f64::NAN, + y: 20.0, + width: 800.0, + height: 300.0, + }; + let (_, size) = overlay_reset_fallback_rect(Some(&broken), true, &monitors); + assert_eq!( + (size.width, size.height), + (DEFAULT_OVERLAY_WIDTH, DEFAULT_OVERLAY_HEIGHT) + ); + + // 저장된 크기가 한계를 넘으면 잘라 쓴다 + let oversized = OverlayBounds { + x: 0.0, + y: 0.0, + width: 9000.0, + height: 40.0, + }; + let (_, size) = overlay_reset_fallback_rect(Some(&oversized), true, &monitors); + assert_eq!((size.width, size.height), (4096.0, 100.0)); +} diff --git a/src-tauri/src/state/editor.rs b/src-tauri/src/state/editor.rs index 0d2fd9436..2de3629aa 100644 --- a/src-tauri/src/state/editor.rs +++ b/src-tauri/src/state/editor.rs @@ -2393,3385 +2393,4 @@ pub(crate) fn touched_pair(fields: &[EditorField]) -> (bool, bool) { } #[cfg(test)] -mod tests { - use std::collections::HashMap; - - use crate::models::{ - CustomTab, EditorBoundsV1, EditorCommitRequest, EditorDocumentV1, - EditorElementGroupTargetV1, EditorElementPropertyPatchV1, EditorElementTypeV1, - EditorFrozenKeySlotV1, EditorGroupUpdateV1, EditorOpResultStatusV1, EditorOpResultV1, - EditorOpV1, EditorPatchV1, EditorTargetGroupV1, EditorZUpdateV1, ElementShadowSpec, - GraphPosition, GraphStatType, GraphType, KeyPosition, KnobPosition, LayerGroupDef, - StatPosition, StatType, - }; - - use super::*; - - fn request(keys: KeyMappings) -> EditorCommitRequest { - EditorCommitRequest { - base_revision: 0, - mutation_id: Uuid::new_v4().to_string(), - multi_key: false, - gesture_id: None, - gesture_ids: Vec::new(), - changes: Some(EditorPatchV1 { - keys: Some(keys), - ..EditorPatchV1::default() - }), - ops_version: None, - ops: None, - } - } - - fn ops_request(ops: Vec) -> EditorCommitRequest { - EditorCommitRequest { - base_revision: 0, - mutation_id: Uuid::new_v4().to_string(), - multi_key: false, - gesture_id: None, - gesture_ids: Vec::new(), - changes: None, - ops_version: Some(EDITOR_OPS_VERSION), - ops: Some(ops), - } - } - - fn set_bounds_op(id: impl Into, element_type: EditorElementTypeV1) -> EditorOpV1 { - EditorOpV1::SetBounds { - element_type, - id: id.into(), - bounds: EditorBoundsV1 { - dx: 10.0, - dy: 20.0, - width: 100.0, - height: 50.0, - }, - } - } - - fn delete_element_op(id: impl Into, element_type: EditorElementTypeV1) -> EditorOpV1 { - EditorOpV1::DeleteElement { - element_type, - id: id.into(), - } - } - - fn patch_hidden_op(id: impl Into, element_type: EditorElementTypeV1) -> EditorOpV1 { - EditorOpV1::PatchElement { - element_type, - id: id.into(), - patch: EditorElementPropertyPatchV1::Hidden(true), - } - } - - fn frozen_insert_op(id: impl Into) -> EditorOpV1 { - EditorOpV1::InsertFrozenElements { - mode: "4key".to_string(), - elements: vec![crate::models::EditorFrozenElementV1::Key { - slot: crate::models::EditorFrozenKeySlotV1::Single("FROZEN".to_string()), - position: KeyPosition { - id: id.into(), - ..KeyPosition::default() - }, - }], - groups: Vec::new(), - z_updates: Vec::new(), - } - } - - fn reorder_op(id: impl Into, complete_mode_order: bool) -> EditorOpV1 { - EditorOpV1::ReorderElements { - mode: "4key".to_string(), - complete_mode_order, - z_updates: vec![EditorZUpdateV1 { - element_type: EditorElementTypeV1::Key, - id: id.into(), - z_index: 7, - }], - group_updates: Vec::new(), - } - } - - fn validation_code(error: &EditorCommitError) -> Option<&str> { - error - .details - .as_ref() - .and_then(|details| details.validation_code.as_deref()) - } - - fn default_editor_store() -> AppStoreData { - let mut store = AppStoreData { - keys: crate::defaults::default_keys().clone(), - key_positions: crate::defaults::default_positions().clone(), - ..AppStoreData::default() - }; - crate::state::native_element_id::backfill_store_element_ids(&mut store); - store - } - - fn store_with_custom_modes(count: usize) -> AppStoreData { - let mut store = AppStoreData::default(); - for index in 0..count { - store.custom_tabs.push(CustomTab { - id: format!("custom-{index}"), - name: format!("Custom {index}"), - }); - } - store - } - - fn store_with_each_position_collection() -> AppStoreData { - let mut store = default_editor_store(); - store.stat_positions.insert( - "4key".to_string(), - vec![StatPosition { - stat_type: StatType::Kps, - position: KeyPosition::default(), - }], - ); - store.graph_positions.insert( - "4key".to_string(), - vec![GraphPosition { - stat_type: GraphStatType::Kps, - graph_type: GraphType::Line, - graph_speed: 100, - graph_color: "#123456".to_string(), - show_avg_line: true, - position: KeyPosition::default(), - }], - ); - store.knob_positions.insert( - "4key".to_string(), - vec![KnobPosition { - axis_id: String::new(), - sensitivity: 1.0, - reverse: false, - position: KeyPosition::default(), - }], - ); - crate::state::native_element_id::backfill_store_element_ids(&mut store); - store - } - - fn position_mut<'a>( - document: &'a mut EditorDocumentV1, - collection: &str, - ) -> &'a mut KeyPosition { - match collection { - "keyPositions" => &mut document.key_positions.get_mut("4key").unwrap()[0], - "statPositions" => &mut document.stat_positions.get_mut("4key").unwrap()[0].position, - "graphPositions" => &mut document.graph_positions.get_mut("4key").unwrap()[0].position, - "knobPositions" => &mut document.knob_positions.get_mut("4key").unwrap()[0].position, - _ => unreachable!(), - } - } - - fn valid_shadow() -> ElementShadowSpec { - ElementShadowSpec { - enabled: true, - color: "#123456".to_string(), - offset_x: 0.0, - offset_y: 0.0, - blur: 12.0, - } - } - - #[test] - fn canonical_fingerprint_ignores_hash_map_insertion_order() { - let mut left = HashMap::new(); - left.insert("4key".to_string(), vec![KeySlot::from("A")]); - left.insert("5key".to_string(), vec![KeySlot::from("B")]); - - let mut right = HashMap::new(); - right.insert("5key".to_string(), vec![KeySlot::from("B")]); - right.insert("4key".to_string(), vec![KeySlot::from("A")]); - - assert_eq!( - request_fingerprint(&request(left)).unwrap(), - request_fingerprint(&request(right)).unwrap() - ); - } - - #[test] - fn canonical_fingerprint_includes_multi_key_capability() { - let mut legacy = request(KeyMappings::new()); - let mut capable = legacy.clone(); - capable.multi_key = true; - - assert_ne!( - request_fingerprint(&legacy).unwrap(), - request_fingerprint(&capable).unwrap() - ); - - legacy.multi_key = true; - assert_eq!( - request_fingerprint(&legacy).unwrap(), - request_fingerprint(&capable).unwrap() - ); - } - - #[test] - fn commit_envelope_defaults_multi_key_to_false() { - let request = request(KeyMappings::new()); - let mut wire = serde_json::to_value(request).unwrap(); - wire.as_object_mut().unwrap().remove("multiKey"); - - let decoded: EditorCommitRequest = serde_json::from_value(wire).unwrap(); - - assert!(!decoded.multi_key); - let mut capable = decoded; - capable.multi_key = true; - let encoded = serde_json::to_value(capable).unwrap(); - assert_eq!(encoded["multiKey"], true); - assert!(encoded.get("opsVersion").is_none()); - assert!(encoded.get("ops").is_none()); - } - - #[test] - fn editor_commit_wire_requires_exactly_one_mutation_shape() { - let base = serde_json::json!({ - "baseRevision": 0, - "mutationId": Uuid::new_v4().to_string(), - }); - let changes = serde_json::json!({ "schemaVersion": EDITOR_SCHEMA_VERSION }); - let op = serde_json::json!({ - "kind": "setBounds", - "elementType": "key", - "id": Uuid::new_v4().to_string(), - "bounds": { "dx": 0.0, "dy": 0.0, "width": 1.0, "height": 1.0 }, - }); - - let mut patch_wire = base.clone(); - patch_wire["changes"] = changes.clone(); - let patch = decode_editor_commit_request(patch_wire).unwrap(); - assert!(patch.changes.is_some()); - assert!(patch.ops.is_none()); - - let mut ops_wire = base.clone(); - ops_wire["opsVersion"] = serde_json::json!(EDITOR_OPS_VERSION); - ops_wire["ops"] = serde_json::json!([op.clone()]); - let ops = decode_editor_commit_request(ops_wire).unwrap(); - assert!(ops.changes.is_none()); - assert_eq!(ops.ops.unwrap().len(), 1); - - let encoded = serde_json::to_value(ops_request(vec![set_bounds_op( - Uuid::new_v4().to_string(), - EditorElementTypeV1::Key, - )])) - .unwrap(); - assert_eq!(encoded["ops"][0]["kind"], "setBounds"); - assert_eq!(encoded["ops"][0]["elementType"], "key"); - assert!(encoded.get("changes").is_none()); - - let delete_id = Uuid::new_v4().to_string(); - let delete_wire = serde_json::json!({ - "baseRevision": 0, - "mutationId": Uuid::new_v4().to_string(), - "opsVersion": EDITOR_OPS_VERSION, - "ops": [{ - "kind": "deleteElement", - "elementType": "graph", - "id": delete_id, - }], - }); - let delete = decode_editor_commit_request(delete_wire).unwrap(); - assert_eq!( - delete.ops, - Some(vec![delete_element_op( - delete_id, - EditorElementTypeV1::Graph, - )]) - ); - let encoded_delete = serde_json::to_value(delete).unwrap(); - assert_eq!(encoded_delete["ops"][0]["kind"], "deleteElement"); - assert_eq!(encoded_delete["ops"][0]["elementType"], "graph"); - assert!(encoded_delete["ops"][0].get("bounds").is_none()); - - let mut both = base.clone(); - both["changes"] = changes; - both["opsVersion"] = serde_json::json!(EDITOR_OPS_VERSION); - both["ops"] = serde_json::json!([op.clone()]); - let error = decode_editor_commit_request(both).unwrap_err(); - assert_eq!(validation_code(&error), Some("INVALID_EDITOR_MUTATION")); - - for wire in [ - base.clone(), - serde_json::json!({ - "baseRevision": 0, - "mutationId": Uuid::new_v4().to_string(), - "opsVersion": EDITOR_OPS_VERSION, - }), - serde_json::json!({ - "baseRevision": 0, - "mutationId": Uuid::new_v4().to_string(), - "opsVersion": EDITOR_OPS_VERSION, - "ops": null, - }), - serde_json::json!({ - "baseRevision": 0, - "mutationId": Uuid::new_v4().to_string(), - "changes": null, - "opsVersion": EDITOR_OPS_VERSION, - "ops": [op], - }), - ] { - let error = decode_editor_commit_request(wire).unwrap_err(); - assert_eq!(validation_code(&error), Some("INVALID_EDITOR_MUTATION")); - } - } - - #[test] - fn editor_commit_wire_rejects_unknown_keys_at_each_new_boundary() { - let valid_id = Uuid::new_v4().to_string(); - let valid = serde_json::json!({ - "baseRevision": 0, - "mutationId": Uuid::new_v4().to_string(), - "opsVersion": EDITOR_OPS_VERSION, - "ops": [{ - "kind": "setBounds", - "elementType": "key", - "id": valid_id, - "bounds": { "dx": 0.0, "dy": 0.0, "width": 1.0, "height": 1.0 }, - }], - }); - - let mut unknown_top_level = valid.clone(); - unknown_top_level["mode"] = serde_json::json!("4key"); - let mut unknown_op_key = valid.clone(); - unknown_op_key["ops"][0]["mode"] = serde_json::json!("4key"); - let mut unknown_bounds_key = valid.clone(); - unknown_bounds_key["ops"][0]["bounds"]["x"] = serde_json::json!(0); - - for wire in [unknown_top_level, unknown_op_key, unknown_bounds_key] { - let error = decode_editor_commit_request(wire).unwrap_err(); - assert_eq!(validation_code(&error), Some("INVALID_REQUEST_PAYLOAD")); - } - - for (field, value) in [ - ("kind", serde_json::json!("move")), - ("elementType", serde_json::json!("Key")), - ] { - let mut wire = valid.clone(); - wire["ops"][0][field] = value; - let error = decode_editor_commit_request(wire).unwrap_err(); - assert_eq!(validation_code(&error), Some("INVALID_REQUEST_PAYLOAD")); - } - - let delete_with_bounds = serde_json::json!({ - "baseRevision": 0, - "mutationId": Uuid::new_v4().to_string(), - "opsVersion": EDITOR_OPS_VERSION, - "ops": [{ - "kind": "deleteElement", - "elementType": "key", - "id": Uuid::new_v4().to_string(), - "bounds": { "dx": 0.0, "dy": 0.0, "width": 1.0, "height": 1.0 }, - }], - }); - let error = decode_editor_commit_request(delete_with_bounds).unwrap_err(); - assert_eq!(validation_code(&error), Some("INVALID_REQUEST_PAYLOAD")); - } - - #[test] - fn frozen_insert_wire_accepts_explicit_null_for_skipped_optional_fields() { - let frozen = |counter: serde_json::Value| { - serde_json::json!({ - "baseRevision": 0, - "mutationId": Uuid::new_v4().to_string(), - "opsVersion": EDITOR_OPS_VERSION, - "ops": [{ - "kind": "insertFrozenElements", - "mode": "4key", - "elements": [{ - "elementType": "key", - "slot": "A", - "position": { - "id": Uuid::new_v4().to_string(), - "dx": 0.0, "dy": 0.0, "width": 60.0, "height": 60.0, "count": 0, - "counter": counter, - }, - }], - "groups": [], - "zUpdates": [], - }], - }) - }; - - // 프론트 정규화는 미지정 그라데이션을 null로 보낸다 - None은 재직렬화에서 - // 생략되지만 같은 뜻이므로 통과해야 한다 - decode_editor_commit_request(frozen(serde_json::json!({ - "enabled": true, - "fillIdleGradient": null, - "fillActiveGradient": null, - }))) - .unwrap(); - - // 빈 배열로 명시한 생략 필드(gestureIds)도 같은 규칙 - let mut with_empty_gesture_ids = frozen(serde_json::json!({ "enabled": true })); - with_empty_gesture_ids["gestureIds"] = serde_json::json!([]); - decode_editor_commit_request(with_empty_gesture_ids).unwrap(); - - // 값이 실린 미지의 키는 여전히 거절 - let error = decode_editor_commit_request(frozen(serde_json::json!({ - "enabled": true, - "fillActiveGradient": null, - "fillHoverGradient": { "angle": 0, "stops": [] }, - }))) - .unwrap_err(); - assert_eq!(validation_code(&error), Some("INVALID_REQUEST_PAYLOAD")); - assert!(error.message.contains("fillHoverGradient")); - } - - #[test] - fn property_and_key_slot_wires_are_exact_and_canonical() { - let property = serde_json::to_value(ops_request(vec![patch_hidden_op( - Uuid::new_v4().to_string(), - EditorElementTypeV1::Graph, - )])) - .unwrap(); - assert_eq!(property["ops"][0]["kind"], "patchElement"); - assert_eq!( - property["ops"][0]["patch"], - serde_json::json!({ "property": "hidden", "value": true }) - ); - decode_editor_commit_request(property.clone()).unwrap(); - - let layer_name = serde_json::to_value(ops_request(vec![EditorOpV1::PatchElement { - element_type: EditorElementTypeV1::Knob, - id: Uuid::new_v4().to_string(), - patch: EditorElementPropertyPatchV1::LayerName(None), - }])) - .unwrap(); - assert_eq!( - layer_name["ops"][0]["patch"], - serde_json::json!({ "property": "layerName", "value": null }) - ); - decode_editor_commit_request(layer_name.clone()).unwrap(); - - let graph_type = serde_json::to_value(ops_request(vec![EditorOpV1::PatchElement { - element_type: EditorElementTypeV1::Graph, - id: Uuid::new_v4().to_string(), - patch: EditorElementPropertyPatchV1::GraphType(GraphType::Bar), - }])) - .unwrap(); - assert_eq!( - graph_type["ops"][0]["patch"], - serde_json::json!({ "property": "graphType", "value": "bar" }) - ); - decode_editor_commit_request(graph_type.clone()).unwrap(); - - let graph_color = serde_json::to_value(ops_request(vec![EditorOpV1::PatchElement { - element_type: EditorElementTypeV1::Graph, - id: Uuid::new_v4().to_string(), - patch: EditorElementPropertyPatchV1::GraphColor("not-normalized".to_string()), - }])) - .unwrap(); - assert_eq!( - graph_color["ops"][0]["patch"], - serde_json::json!({ "property": "graphColor", "value": "not-normalized" }) - ); - decode_editor_commit_request(graph_color.clone()).unwrap(); - - let paint = serde_json::to_value(ops_request(vec![EditorOpV1::PatchElement { - element_type: EditorElementTypeV1::Key, - id: Uuid::new_v4().to_string(), - patch: EditorElementPropertyPatchV1::BackgroundPaint( - crate::models::EditorPaintDescriptorV1 { - color: "first".to_string(), - gradient: Some(crate::models::EditorPaintGradientV1 { - angle: 45.0, - stops: vec![ - crate::models::EditorPaintGradientStopV1 { - color: "first".to_string(), - pos: 0.0, - }, - crate::models::EditorPaintGradientStopV1 { - color: "last".to_string(), - pos: 1.0, - }, - ], - }), - }, - ), - }])) - .unwrap(); - assert_eq!( - paint["ops"][0]["patch"], - serde_json::json!({ "property": "backgroundPaint", "value": { "color": "first", "gradient": { "angle": 45.0, "stops": [ { "color": "first", "pos": 0.0 }, { "color": "last", "pos": 1.0 } ] } } }) - ); - decode_editor_commit_request(paint.clone()).unwrap(); - - let literal_properties = [ - ( - EditorElementTypeV1::Graph, - EditorElementPropertyPatchV1::ShowAvgLine(false), - serde_json::json!({ "property": "showAvgLine", "value": false }), - ), - ( - EditorElementTypeV1::Graph, - EditorElementPropertyPatchV1::GraphAnimationEnabled(true), - serde_json::json!({ "property": "graphAnimationEnabled", "value": true }), - ), - ( - EditorElementTypeV1::Graph, - EditorElementPropertyPatchV1::GraphSpeed(u32::MAX), - serde_json::json!({ "property": "graphSpeed", "value": u32::MAX }), - ), - ( - EditorElementTypeV1::Knob, - EditorElementPropertyPatchV1::Reverse(true), - serde_json::json!({ "property": "reverse", "value": true }), - ), - ( - EditorElementTypeV1::Knob, - EditorElementPropertyPatchV1::Sensitivity(-7.25), - serde_json::json!({ "property": "sensitivity", "value": -7.25 }), - ), - ( - EditorElementTypeV1::Knob, - EditorElementPropertyPatchV1::AxisId(" HIDA:raw ".to_string()), - serde_json::json!({ "property": "axisId", "value": " HIDA:raw " }), - ), - ( - EditorElementTypeV1::Stat, - EditorElementPropertyPatchV1::UseInlineStyles(false), - serde_json::json!({ "property": "useInlineStyles", "value": false }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::FontWeight(u32::MAX), - serde_json::json!({ "property": "fontWeight", "value": u32::MAX }), - ), - ( - EditorElementTypeV1::Stat, - EditorElementPropertyPatchV1::FontItalic(false), - serde_json::json!({ "property": "fontItalic", "value": false }), - ), - ( - EditorElementTypeV1::Graph, - EditorElementPropertyPatchV1::FontUnderline(true), - serde_json::json!({ "property": "fontUnderline", "value": true }), - ), - ( - EditorElementTypeV1::Knob, - EditorElementPropertyPatchV1::FontStrikethrough(false), - serde_json::json!({ "property": "fontStrikethrough", "value": false }), - ), - ( - EditorElementTypeV1::Graph, - EditorElementPropertyPatchV1::FontFamily(" raw-font ".to_string()), - serde_json::json!({ "property": "fontFamily", "value": " raw-font " }), - ), - ( - EditorElementTypeV1::Knob, - EditorElementPropertyPatchV1::DisplayText(" raw display ".to_string()), - serde_json::json!({ "property": "displayText", "value": " raw display " }), - ), - ( - EditorElementTypeV1::Graph, - EditorElementPropertyPatchV1::ClassName(" raw class ".to_string()), - serde_json::json!({ "property": "className", "value": " raw class " }), - ), - ( - EditorElementTypeV1::Stat, - EditorElementPropertyPatchV1::FontPaint(crate::models::EditorPaintDescriptorV1 { - color: " raw font color ".to_string(), - gradient: None, - }), - serde_json::json!({ "property": "fontPaint", "value": { "color": " raw font color ", "gradient": null } }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::ActiveFontPaint( - crate::models::EditorPaintDescriptorV1 { - color: String::new(), - gradient: None, - }, - ), - serde_json::json!({ "property": "activeFontPaint", "value": { "color": "", "gradient": null } }), - ), - ( - EditorElementTypeV1::Stat, - EditorElementPropertyPatchV1::Shadow( - crate::models::EditorShadowLeafPatchV1::OffsetX(-12.5), - ), - serde_json::json!({ "property": "shadow", "value": { "leaf": "offsetX", "value": -12.5 } }), - ), - ( - EditorElementTypeV1::Knob, - EditorElementPropertyPatchV1::ActiveShadow( - crate::models::EditorShadowLeafPatchV1::Color(" raw-shadow ".to_string()), - ), - serde_json::json!({ "property": "activeShadow", "value": { "leaf": "color", "value": " raw-shadow " } }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::ShadowEnabled(false), - serde_json::json!({ "property": "shadowEnabled", "value": false }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::BorderWidth(0.5), - serde_json::json!({ "property": "borderWidth", "value": 0.5 }), - ), - ( - EditorElementTypeV1::Knob, - EditorElementPropertyPatchV1::BorderRadius(999.0), - serde_json::json!({ "property": "borderRadius", "value": 999.0 }), - ), - ( - EditorElementTypeV1::Stat, - EditorElementPropertyPatchV1::FontSize(8.5), - serde_json::json!({ "property": "fontSize", "value": 8.5 }), - ), - ( - EditorElementTypeV1::Knob, - EditorElementPropertyPatchV1::InactiveImage(" raw/path.png ".to_string()), - serde_json::json!({ "property": "inactiveImage", "value": " raw/path.png " }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::ActiveImage(" raw/active.png ".to_string()), - serde_json::json!({ "property": "activeImage", "value": " raw/active.png " }), - ), - ( - EditorElementTypeV1::Graph, - EditorElementPropertyPatchV1::IdleTransparent(true), - serde_json::json!({ "property": "idleTransparent", "value": true }), - ), - ( - EditorElementTypeV1::Knob, - EditorElementPropertyPatchV1::ActiveTransparent(false), - serde_json::json!({ "property": "activeTransparent", "value": false }), - ), - ( - EditorElementTypeV1::Graph, - EditorElementPropertyPatchV1::IdleImageFit(crate::models::ImageFit::Contain), - serde_json::json!({ "property": "idleImageFit", "value": "contain" }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::ActiveImageFit(crate::models::ImageFit::None), - serde_json::json!({ "property": "activeImageFit", "value": "none" }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::SoundPath(" raw/sound.wav ".to_string()), - serde_json::json!({ "property": "soundPath", "value": " raw/sound.wav " }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::SoundEnabled(false), - serde_json::json!({ "property": "soundEnabled", "value": false }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::SoundVolume(137.5), - serde_json::json!({ "property": "soundVolume", "value": 137.5 }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::CounterEnabled(false), - serde_json::json!({ "property": "counterEnabled", "value": false }), - ), - ( - EditorElementTypeV1::Stat, - EditorElementPropertyPatchV1::CounterAnimationEnabled(true), - serde_json::json!({ "property": "counterAnimationEnabled", "value": true }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::CounterPlacement( - crate::models::KeyCounterPlacement::Outside, - ), - serde_json::json!({ "property": "counterPlacement", "value": "outside" }), - ), - ( - EditorElementTypeV1::Stat, - EditorElementPropertyPatchV1::CounterAlign(crate::models::KeyCounterAlign::Left), - serde_json::json!({ "property": "counterAlign", "value": "left" }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::CounterAlignMode( - crate::models::KeyCounterAlignMode::Between, - ), - serde_json::json!({ "property": "counterAlignMode", "value": "between" }), - ), - ( - EditorElementTypeV1::Stat, - EditorElementPropertyPatchV1::CounterGap(u32::MAX), - serde_json::json!({ "property": "counterGap", "value": u32::MAX }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::CounterFontSize(72), - serde_json::json!({ "property": "counterFontSize", "value": 72 }), - ), - ( - EditorElementTypeV1::Stat, - EditorElementPropertyPatchV1::CounterFontWeight(900), - serde_json::json!({ "property": "counterFontWeight", "value": 900 }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::CounterFontItalic(true), - serde_json::json!({ "property": "counterFontItalic", "value": true }), - ), - ( - EditorElementTypeV1::Stat, - EditorElementPropertyPatchV1::CounterFontUnderline(true), - serde_json::json!({ "property": "counterFontUnderline", "value": true }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::CounterFontStrikethrough(true), - serde_json::json!({ "property": "counterFontStrikethrough", "value": true }), - ), - ( - EditorElementTypeV1::Stat, - EditorElementPropertyPatchV1::CounterFontFamily(" raw-counter-font ".to_string()), - serde_json::json!({ "property": "counterFontFamily", "value": " raw-counter-font " }), - ), - ( - EditorElementTypeV1::Stat, - EditorElementPropertyPatchV1::CounterFillIdle( - crate::models::EditorCounterFillIntentV1::Solid( - crate::models::EditorCounterFillSolidIntentV1 { - color: " raw solid ".to_string(), - }, - ), - ), - serde_json::json!({ "property": "counterFillIdle", "value": { "color": " raw solid " } }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::CounterFillActive( - crate::models::EditorCounterFillIntentV1::Gradient( - crate::models::EditorCounterFillGradientIntentV1 { - color: "rgba(1,2,3,0.5)".to_string(), - gradient: crate::models::EditorPaintGradientV1 { - angle: 90.0, - stops: vec![ - crate::models::EditorPaintGradientStopV1 { - color: "rgba(1, 2, 3, 0.5)".to_string(), - pos: 0.0, - }, - crate::models::EditorPaintGradientStopV1 { - color: "transparent".to_string(), - pos: 1.0, - }, - ], - }, - }, - ), - ), - serde_json::json!({ "property": "counterFillActive", "value": { "color": "rgba(1,2,3,0.5)", "gradient": { "angle": 90.0, "stops": [ { "color": "rgba(1, 2, 3, 0.5)", "pos": 0.0 }, { "color": "transparent", "pos": 1.0 } ] } } }), - ), - ( - EditorElementTypeV1::Stat, - EditorElementPropertyPatchV1::CounterAnimationPreset( - crate::models::EditorCounterAnimationPresetIntentV1 { - preset_id: "user-motion".to_string(), - apply_preset_id: Some(true), - bezier: Some([0.1, 0.2, 0.8, 0.9]), - scale: Some(1.25), - duration_ms: Some(420), - }, - ), - serde_json::json!({ "property": "counterAnimationPreset", "value": { "presetId": "user-motion", "applyPresetId": true, "bezier": [0.1, 0.2, 0.8, 0.9], "scale": 1.25, "durationMs": 420 } }), - ), - ( - EditorElementTypeV1::Stat, - EditorElementPropertyPatchV1::StatType(StatType::Total), - serde_json::json!({ "property": "statType", "value": "total" }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::NoteEffectEnabled(false), - serde_json::json!({ "property": "noteEffectEnabled", "value": false }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::NoteGlowEnabled(true), - serde_json::json!({ "property": "noteGlowEnabled", "value": true }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::NoteGlowSyncPaint(true), - serde_json::json!({ "property": "noteGlowSyncPaint", "value": true }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::NoteGlowSize(20.5), - serde_json::json!({ "property": "noteGlowSize", "value": 20.5 }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::NotePaint( - crate::models::EditorNotePaintIntentV1::Color( - crate::models::EditorNotePaintColorIntentV1 { - color: crate::models::EditorNoteColorV1::Gradient( - crate::models::EditorNoteGradientColorV1 { - kind: crate::models::EditorNoteGradientColorKindV1::Gradient, - top: "top".to_string(), - bottom: "bottom".to_string(), - }, - ), - }, - ), - ), - serde_json::json!({ "property": "notePaint", "value": { "color": { "type": "gradient", "top": "top", "bottom": "bottom" } } }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::NoteGlowPaint( - crate::models::EditorNotePaintIntentV1::GradientOpacity( - crate::models::EditorNotePaintGradientOpacityIntentV1 { - opacity: 70, - opacity_top: 10, - opacity_bottom: 90, - }, - ), - ), - serde_json::json!({ "property": "noteGlowPaint", "value": { "opacity": 70, "opacityTop": 10, "opacityBottom": 90 } }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::NoteBorderPaint( - crate::models::EditorNoteBorderPaintV1 { - color: "#A1b2C3".to_string(), - opacity: 55, - gradient: None, - }, - ), - serde_json::json!({ "property": "noteBorderPaint", "value": { "color": "#A1b2C3", "opacity": 55 } }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::NoteOffsetX(None), - serde_json::json!({ "property": "noteOffsetX", "value": null }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::NoteOffsetY(Some(-12.5)), - serde_json::json!({ "property": "noteOffsetY", "value": -12.5 }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::NoteWidth(None), - serde_json::json!({ "property": "noteWidth", "value": null }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::NoteBorderWidth(0.0), - serde_json::json!({ "property": "noteBorderWidth", "value": 0.0 }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::NoteBorderRadius(4.5), - serde_json::json!({ "property": "noteBorderRadius", "value": 4.5 }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::NoteAutoYCorrection(false), - serde_json::json!({ "property": "noteAutoYCorrection", "value": false }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::NoteAlignment(crate::models::NoteAlignment::Right), - serde_json::json!({ "property": "noteAlignment", "value": "right" }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::NoteBorderSide( - crate::models::EditorNoteBorderSideV1::All, - ), - serde_json::json!({ "property": "noteBorderSide", "value": "all" }), - ), - ]; - for (element_type, patch, expected) in literal_properties { - let wire = serde_json::to_value(ops_request(vec![EditorOpV1::PatchElement { - element_type, - id: Uuid::new_v4().to_string(), - patch, - }])) - .unwrap(); - assert_eq!(wire["ops"][0]["patch"], expected); - decode_editor_commit_request(wire).unwrap(); - } - - let mut unknown_property = property.clone(); - unknown_property["ops"][0]["patch"]["zIndex"] = serde_json::json!(3); - let mut unknown_op = property; - unknown_op["ops"][0]["mode"] = serde_json::json!("4key"); - let mut missing_property = layer_name.clone(); - missing_property["ops"][0]["patch"] = serde_json::json!({}); - let mut multiple_properties = layer_name; - multiple_properties["ops"][0]["patch"]["hidden"] = serde_json::json!(true); - let mut invalid_graph_type = graph_type; - invalid_graph_type["ops"][0]["patch"]["graphType"] = serde_json::json!("area"); - let mut invalid_graph_color = graph_color.clone(); - invalid_graph_color["ops"][0]["patch"]["graphColor"] = serde_json::json!(42); - let invalid_literal_properties = [ - serde_json::json!({ "property": "showAvgLine", "value": 1 }), - serde_json::json!({ "property": "graphAnimationEnabled", "value": null }), - serde_json::json!({ "property": "graphSpeed", "value": -1 }), - serde_json::json!({ "property": "graphSpeed", "value": 1.5 }), - serde_json::json!({ "property": "reverse", "value": "true" }), - serde_json::json!({ "property": "sensitivity", "value": "1" }), - serde_json::json!({ "property": "axisId", "value": false }), - serde_json::json!({ "axisId": "axis", "hidden": true }), - serde_json::json!({ "axisId": "axis", "unexpected": true }), - serde_json::json!({ "property": "useInlineStyles", "value": null }), - serde_json::json!({ "property": "fontWeight", "value": -1 }), - serde_json::json!({ "property": "fontWeight", "value": 1.5 }), - serde_json::json!({ "property": "fontItalic", "value": null }), - serde_json::json!({ "property": "fontUnderline", "value": 1 }), - serde_json::json!({ "property": "fontStrikethrough", "value": "false" }), - serde_json::json!({ "property": "fontFamily", "value": null }), - serde_json::json!({ "property": "displayText", "value": null }), - serde_json::json!({ "property": "displayText", "value": 1 }), - serde_json::json!({ "displayText": "text", "hidden": true }), - serde_json::json!({ "displayText": "text", "unexpected": true }), - serde_json::json!({ "property": "className", "value": null }), - serde_json::json!({ "property": "className", "value": 1 }), - serde_json::json!({ "className": "class", "hidden": true }), - serde_json::json!({ "className": "class", "unexpected": true }), - serde_json::json!({ "property": "fontPaint", "value": null }), - serde_json::json!({ "property": "fontPaint", "value": { "color": "idle" } }), - serde_json::json!({ "property": "activeFontPaint", "value": false }), - serde_json::json!({ "fontPaint": { "color": "idle", "gradient": null }, "activeFontPaint": { "color": "active", "gradient": null } }), - serde_json::json!({ "property": "activeFontPaint", "value": { "color": "active", "gradient": null, "unexpected": true } }), - serde_json::json!({ "property": "shadow", "value": {} }), - serde_json::json!({ "property": "shadow", "value": { "offsetX": 1, "blur": 2 } }), - serde_json::json!({ "property": "shadow", "value": { "color": "shadow", "unexpected": true } }), - serde_json::json!({ "property": "activeShadow", "value": null }), - serde_json::json!({ "property": "activeShadow", "value": { "leaf": "offsetY", "value": "1" } }), - serde_json::json!({ "property": "shadowEnabled", "value": "false" }), - serde_json::json!({ "shadow": { "blur": 1 }, "shadowEnabled": true }), - serde_json::json!({ "property": "backgroundPaint", "value": {} }), - serde_json::json!({ "property": "backgroundPaint", "value": { "color": "solid" } }), - serde_json::json!({ "property": "backgroundPaint", "value": { "color": "solid", "gradient": null, "unexpected": true } }), - serde_json::json!({ "property": "backgroundPaint", "value": { "color": "first", "gradient": { "stops": [{ "color": "first", "pos": 0 }, { "color": "last", "pos": 1 }] } } }), - serde_json::json!({ "property": "backgroundPaint", "value": { "color": "first", "gradient": { "angle": 45 } } }), - serde_json::json!({ "property": "backgroundPaint", "value": { "color": "first", "gradient": { "angle": 45, "stops": [{ "color": "first", "pos": 0 }, { "color": "last", "pos": 1 }], "unexpected": true } } }), - serde_json::json!({ "property": "backgroundPaint", "value": { "color": "first", "gradient": { "angle": 45, "stops": [{ "color": "first", "pos": 0, "unexpected": true }, { "color": "last", "pos": 1 }] } } }), - serde_json::json!({ "backgroundPaint": { "color": "first", "gradient": null }, "borderPaint": { "color": "border", "gradient": null } }), - serde_json::json!({ "property": "borderWidth", "value": null }), - serde_json::json!({ "property": "borderWidth", "value": "1" }), - serde_json::json!({ "borderWidth": 1, "fontSize": 14 }), - serde_json::json!({ "borderWidth": 1, "unexpected": true }), - serde_json::json!({ "property": "borderRadius", "value": null }), - serde_json::json!({ "property": "borderRadius", "value": "1" }), - serde_json::json!({ "borderRadius": 1, "hidden": false }), - serde_json::json!({ "property": "fontSize", "value": null }), - serde_json::json!({ "property": "fontSize", "value": "14" }), - serde_json::json!({ "fontSize": 14, "unexpected": true }), - serde_json::json!({ "property": "inactiveImage", "value": null }), - serde_json::json!({ "inactiveImage": "path", "hidden": false }), - serde_json::json!({ "inactiveImage": "path", "unexpected": true }), - serde_json::json!({ "property": "activeImage", "value": null }), - serde_json::json!({ "activeImage": "path", "hidden": false }), - serde_json::json!({ "activeImage": "path", "unexpected": true }), - serde_json::json!({ "property": "idleTransparent", "value": null }), - serde_json::json!({ "property": "idleTransparent", "value": "true" }), - serde_json::json!({ "idleTransparent": true, "activeTransparent": false }), - serde_json::json!({ "property": "activeTransparent", "value": 1 }), - serde_json::json!({ "activeTransparent": false, "unexpected": true }), - serde_json::json!({ "property": "idleImageFit", "value": "stretch" }), - serde_json::json!({ "property": "idleImageFit", "value": null }), - serde_json::json!({ "idleImageFit": "cover", "activeImageFit": "contain" }), - serde_json::json!({ "property": "activeImageFit", "value": 1 }), - serde_json::json!({ "activeImageFit": "fill", "unexpected": true }), - serde_json::json!({ "property": "soundPath", "value": null }), - serde_json::json!({ "property": "soundPath", "value": 1 }), - serde_json::json!({ "soundPath": "path", "soundEnabled": true }), - serde_json::json!({ "soundPath": "path", "unexpected": true }), - serde_json::json!({ "property": "soundEnabled", "value": null }), - serde_json::json!({ "property": "soundEnabled", "value": "true" }), - serde_json::json!({ "soundEnabled": true, "unexpected": true }), - serde_json::json!({ "property": "soundVolume", "value": null }), - serde_json::json!({ "property": "soundVolume", "value": "100" }), - serde_json::json!({ "soundVolume": 100, "soundEnabled": true }), - serde_json::json!({ "soundVolume": 100, "unexpected": true }), - serde_json::json!({ "property": "counterEnabled", "value": null }), - serde_json::json!({ "property": "counterAnimationEnabled", "value": "true" }), - serde_json::json!({ "counterEnabled": true, "counterAnimationEnabled": false }), - serde_json::json!({ "property": "counterPlacement", "value": "center" }), - serde_json::json!({ "property": "counterAlign", "value": "center" }), - serde_json::json!({ "property": "counterAlignMode", "value": "outside" }), - serde_json::json!({ "property": "counterGap", "value": -1 }), - serde_json::json!({ "property": "counterGap", "value": 1.5 }), - serde_json::json!({ "property": "counterGap", "value": 4_294_967_296_u64 }), - serde_json::json!({ "counterPlacement": "inside", "counterAlign": "top" }), - serde_json::json!({ "property": "counterFontSize", "value": -1 }), - serde_json::json!({ "property": "counterFontSize", "value": 16.5 }), - serde_json::json!({ "property": "counterFontWeight", "value": "400" }), - serde_json::json!({ "property": "counterFontItalic", "value": null }), - serde_json::json!({ "property": "counterFontUnderline", "value": 1 }), - serde_json::json!({ "property": "counterFontStrikethrough", "value": "false" }), - serde_json::json!({ "counterFontSize": 16, "counterFontWeight": 400 }), - serde_json::json!({ "counterFontSize": 16, "unexpected": true }), - serde_json::json!({ "property": "counterFontFamily", "value": null }), - serde_json::json!({ "property": "counterFontFamily", "value": 1 }), - serde_json::json!({ "counterFontFamily": "font", "counterFontItalic": true }), - serde_json::json!({ "counterFontFamily": "font", "unexpected": true }), - serde_json::json!({ "property": "counterFillIdle", "value": {} }), - serde_json::json!({ "property": "counterFillIdle", "value": { "color": null } }), - serde_json::json!({ "property": "counterFillIdle", "value": { "color": "solid", "gradient": null } }), - serde_json::json!({ "property": "counterFillActive", "value": { "color": "first", "gradient": { "stops": [{ "color": "first", "pos": 0 }, { "color": "last", "pos": 1 }] } } }), - serde_json::json!({ "property": "counterFillActive", "value": { "color": "first", "gradient": { "angle": 45, "stops": [{ "color": "first", "pos": 0 }, { "color": "last", "pos": 1 }], "unexpected": true } } }), - serde_json::json!({ "property": "counterFillIdle", "value": { "color": "solid", "unexpected": true } }), - serde_json::json!({ "counterFillIdle": { "color": "idle" }, "counterFillActive": { "color": "active" } }), - serde_json::json!({ "property": "counterAnimationPreset", "value": null }), - serde_json::json!({ "property": "counterAnimationPreset", "value": {} }), - serde_json::json!({ "property": "counterAnimationPreset", "value": { "presetId": "preset", "enabled": true } }), - serde_json::json!({ "property": "counterAnimationPreset", "value": { "presetId": "preset", "applyPresetId": false } }), - serde_json::json!({ "property": "counterAnimationPreset", "value": { "presetId": "preset", "bezier": [0.1, 0.2, 0.8] } }), - serde_json::json!({ "property": "counterAnimationPreset", "value": { "presetId": "preset", "durationMs": 1.5 } }), - serde_json::json!({ "counterAnimationPreset": { "presetId": "preset" }, "hidden": true }), - serde_json::json!({ "property": "statType", "value": "invalid" }), - serde_json::json!({ "property": "noteEffectEnabled", "value": 1 }), - serde_json::json!({ "property": "noteGlowEnabled", "value": null }), - serde_json::json!({ "property": "noteGlowSyncPaint", "value": null }), - serde_json::json!({ "property": "noteGlowSize", "value": null }), - serde_json::json!({ "property": "noteGlowSize", "value": "20" }), - serde_json::json!({ "noteGlowSize": 20, "noteGlowEnabled": true }), - serde_json::json!({ "noteGlowSize": 20, "unexpected": true }), - serde_json::json!({ "property": "notePaint", "value": {} }), - serde_json::json!({ "property": "notePaint", "value": { "color": { "top": "a", "bottom": "b" } } }), - serde_json::json!({ "property": "notePaint", "value": { "color": { "type": "gradient", "top": "a", "bottom": "b", "unexpected": true } } }), - serde_json::json!({ "property": "notePaint", "value": { "opacity": 50, "opacityTop": 40 } }), - serde_json::json!({ "property": "notePaint", "value": { "color": "x", "opacity": 50 } }), - serde_json::json!({ "property": "noteGlowPaint", "value": { "opacity": "70" } }), - serde_json::json!({ "property": "noteBorderPaint", "value": { "color": "#FFFFFF" } }), - serde_json::json!({ "property": "noteBorderPaint", "value": { "color": "#FFFFFF", "opacity": 100, "unexpected": true } }), - serde_json::json!({ "notePaint": { "color": "x" }, "noteGlowPaint": { "color": "y" } }), - serde_json::json!({ "property": "noteOffsetX", "value": "0" }), - serde_json::json!({ "noteOffsetX": null, "noteOffsetY": null }), - serde_json::json!({ "noteOffsetY": null, "unexpected": true }), - serde_json::json!({ "property": "noteWidth", "value": "20" }), - serde_json::json!({ "noteWidth": null, "hidden": true }), - serde_json::json!({ "property": "noteBorderWidth", "value": null }), - serde_json::json!({ "property": "noteBorderWidth", "value": "1" }), - serde_json::json!({ "property": "noteBorderRadius", "value": null }), - serde_json::json!({ "noteBorderRadius": 4, "unexpected": true }), - serde_json::json!({ "property": "noteAutoYCorrection", "value": "false" }), - serde_json::json!({ "property": "noteAlignment", "value": "bottom" }), - serde_json::json!({ "property": "noteBorderSide", "value": "diagonal" }), - ] - .map(|patch| { - let mut wire = graph_color.clone(); - wire["ops"][0]["patch"] = patch; - wire - }); - for wire in [ - unknown_property, - unknown_op, - missing_property, - multiple_properties, - invalid_graph_type, - invalid_graph_color, - ] - .into_iter() - .chain(invalid_literal_properties) - { - let error = decode_editor_commit_request(wire).unwrap_err(); - assert_eq!(validation_code(&error), Some("INVALID_REQUEST_PAYLOAD")); - } - - let slot = EditorOpV1::SetKeySlot { - id: Uuid::new_v4().to_string(), - slot: EditorFrozenKeySlotV1::Multi(crate::models::EditorFrozenMultiKeySlotV1 { - keys: vec!["A".to_string(), "B".to_string()], - match_mode: crate::models::SlotMatch::Any, - }), - }; - let slot_wire = serde_json::to_value(ops_request(vec![slot])).unwrap(); - assert_eq!(slot_wire["ops"][0]["kind"], "setKeySlot"); - assert_eq!(slot_wire["ops"][0]["slot"]["match"], "any"); - decode_editor_commit_request(slot_wire.clone()).unwrap(); - - let mut unknown_slot = slot_wire; - unknown_slot["ops"][0]["slot"]["unexpected"] = serde_json::json!(true); - let error = decode_editor_commit_request(unknown_slot).unwrap_err(); - assert_eq!(validation_code(&error), Some("INVALID_REQUEST_PAYLOAD")); - - let invalid_slot = ops_request(vec![EditorOpV1::SetKeySlot { - id: Uuid::new_v4().to_string(), - slot: EditorFrozenKeySlotV1::Multi(crate::models::EditorFrozenMultiKeySlotV1 { - keys: vec!["A".to_string(), "A".to_string()], - match_mode: crate::models::SlotMatch::All, - }), - }]); - assert_eq!( - validation_code(&validate_request_envelope(&invalid_slot).unwrap_err()), - Some("INVALID_KEY_SLOT") - ); - } - - #[test] - fn frozen_insert_wire_is_exact_through_nested_full_records() { - let request = ops_request(vec![frozen_insert_op(Uuid::new_v4().to_string())]); - let mut valid = serde_json::to_value(request).unwrap(); - valid["ops"][0]["elements"][0]["position"]["unexpected"] = serde_json::json!(1); - - let error = decode_editor_commit_request(valid).unwrap_err(); - assert_eq!(validation_code(&error), Some("INVALID_REQUEST_PAYLOAD")); - - let mut invalid_slot = serde_json::to_value(ops_request(vec![frozen_insert_op( - Uuid::new_v4().to_string(), - )])) - .unwrap(); - invalid_slot["ops"][0]["elements"][0]["slot"] = serde_json::json!({ - "keys": ["A", "B"], - "match": "all", - "unexpected": true, - }); - let error = decode_editor_commit_request(invalid_slot).unwrap_err(); - assert_eq!(validation_code(&error), Some("INVALID_REQUEST_PAYLOAD")); - } - - #[test] - fn reorder_wire_is_exact_and_requires_explicit_nullable_group_id() { - let target_id = Uuid::new_v4().to_string(); - let op = EditorOpV1::ReorderElements { - mode: "4key".to_string(), - complete_mode_order: true, - z_updates: vec![EditorZUpdateV1 { - element_type: EditorElementTypeV1::Key, - id: target_id.clone(), - z_index: 3, - }], - group_updates: vec![EditorGroupUpdateV1 { - element_type: EditorElementTypeV1::Key, - id: target_id, - group_id: None, - }], - }; - let valid = serde_json::to_value(ops_request(vec![op])).unwrap(); - assert_eq!(valid["ops"][0]["kind"], "reorderElements"); - assert_eq!(valid["ops"][0]["completeModeOrder"], true); - assert!(valid["ops"][0]["groupUpdates"][0]["groupId"].is_null()); - decode_editor_commit_request(valid.clone()).unwrap(); - - let mut missing_group_id = valid.clone(); - missing_group_id["ops"][0]["groupUpdates"][0] - .as_object_mut() - .unwrap() - .remove("groupId"); - let error = decode_editor_commit_request(missing_group_id).unwrap_err(); - assert_eq!(validation_code(&error), Some("INVALID_REQUEST_PAYLOAD")); - - let mut unknown_z = valid.clone(); - unknown_z["ops"][0]["zUpdates"][0]["unexpected"] = serde_json::json!(true); - let mut unknown_group = valid; - unknown_group["ops"][0]["groupUpdates"][0]["unexpected"] = serde_json::json!(true); - for wire in [unknown_z, unknown_group] { - let error = decode_editor_commit_request(wire).unwrap_err(); - assert_eq!(validation_code(&error), Some("INVALID_REQUEST_PAYLOAD")); - } - } - - #[test] - fn group_structural_wire_is_exact_tagged_required_and_sole() { - let target_id = Uuid::new_v4().to_string(); - let target = EditorElementGroupTargetV1 { - element_type: EditorElementTypeV1::Key, - id: target_id.clone(), - }; - let create = EditorOpV1::SetElementGroups { - mode: "4key".to_string(), - targets: vec![target.clone()], - target_group: Some(EditorTargetGroupV1::Create { - id: " legacy group ".to_string(), - name: " Raw Name ".to_string(), - }), - }; - let create_wire = serde_json::to_value(ops_request(vec![create])).unwrap(); - assert_eq!(create_wire["ops"][0]["kind"], "setElementGroups"); - assert_eq!(create_wire["ops"][0]["targetGroup"]["kind"], "create"); - assert_eq!(create_wire["ops"][0]["targetGroup"]["id"], " legacy group "); - decode_editor_commit_request(create_wire.clone()).unwrap(); - - let ungroup = EditorOpV1::SetElementGroups { - mode: "4key".to_string(), - targets: vec![target.clone()], - target_group: None, - }; - let ungroup_wire = serde_json::to_value(ops_request(vec![ungroup])).unwrap(); - assert!(ungroup_wire["ops"][0]["targetGroup"].is_null()); - decode_editor_commit_request(ungroup_wire.clone()).unwrap(); - - let existing = EditorOpV1::SetElementGroups { - mode: "4key".to_string(), - targets: vec![target], - target_group: Some(EditorTargetGroupV1::Existing { - id: "legacy-group".to_string(), - }), - }; - let existing_wire = serde_json::to_value(ops_request(vec![existing])).unwrap(); - assert_eq!( - existing_wire["ops"][0]["targetGroup"], - serde_json::json!({"kind": "existing", "id": "legacy-group"}) - ); - decode_editor_commit_request(existing_wire).unwrap(); - - let rename = EditorOpV1::RenameLayerGroup { - mode: " ".to_string(), - group_id: " ".to_string(), - name: " ".to_string(), - }; - let rename_wire = serde_json::to_value(ops_request(vec![rename])).unwrap(); - assert_eq!(rename_wire["ops"][0]["kind"], "renameLayerGroup"); - decode_editor_commit_request(rename_wire.clone()).unwrap(); - - let mut missing_target_group = ungroup_wire.clone(); - missing_target_group["ops"][0] - .as_object_mut() - .unwrap() - .remove("targetGroup"); - let mut untagged = create_wire.clone(); - untagged["ops"][0]["targetGroup"] = serde_json::json!({ - "id": "group", - "name": "Group", - }); - let mut existing_with_name = create_wire.clone(); - existing_with_name["ops"][0]["targetGroup"] = serde_json::json!({ - "kind": "existing", - "id": "group", - "name": "No rename", - }); - let mut create_without_name = create_wire.clone(); - create_without_name["ops"][0]["targetGroup"] = serde_json::json!({ - "kind": "create", - "id": "group", - }); - let mut target_extra = create_wire.clone(); - target_extra["ops"][0]["targets"][0]["unexpected"] = serde_json::json!(true); - let mut group_extra = create_wire.clone(); - group_extra["ops"][0]["targetGroup"]["unexpected"] = serde_json::json!(true); - let mut rename_extra = rename_wire; - rename_extra["ops"][0]["unexpected"] = serde_json::json!(true); - for invalid in [ - missing_target_group, - untagged, - existing_with_name, - create_without_name, - target_extra, - group_extra, - rename_extra, - ] { - let error = decode_editor_commit_request(invalid).unwrap_err(); - assert_eq!(validation_code(&error), Some("INVALID_REQUEST_PAYLOAD")); - } - - // 빈 native targets 허용 - plugin-only 그룹 편집이 def 생성·정리를 실어야 함 - let empty = EditorOpV1::SetElementGroups { - mode: "4key".to_string(), - targets: Vec::new(), - target_group: None, - }; - validate_request_envelope(&ops_request(vec![empty])).unwrap(); - let at_limit = EditorOpV1::SetElementGroups { - mode: "4key".to_string(), - targets: (0..MAX_RENDER_ITEMS) - .map(|index| EditorElementGroupTargetV1 { - element_type: EditorElementTypeV1::Key, - id: Uuid::from_u128(index as u128 + 1).to_string(), - }) - .collect(), - target_group: None, - }; - validate_request_envelope(&ops_request(vec![at_limit.clone()])).unwrap(); - let mut too_many = at_limit; - let EditorOpV1::SetElementGroups { targets, .. } = &mut too_many else { - unreachable!(); - }; - targets.push(EditorElementGroupTargetV1 { - element_type: EditorElementTypeV1::Key, - id: Uuid::from_u128(MAX_RENDER_ITEMS as u128 + 1).to_string(), - }); - assert_eq!( - validation_code(&validate_request_envelope(&ops_request(vec![too_many])).unwrap_err()), - Some("INVALID_ELEMENT_GROUP_TARGET_COUNT") - ); - let duplicate = EditorOpV1::SetElementGroups { - mode: "4key".to_string(), - targets: vec![ - EditorElementGroupTargetV1 { - element_type: EditorElementTypeV1::Key, - id: target_id.clone(), - }, - EditorElementGroupTargetV1 { - element_type: EditorElementTypeV1::Graph, - id: target_id, - }, - ], - target_group: None, - }; - assert_eq!( - validation_code(&validate_request_envelope(&ops_request(vec![duplicate])).unwrap_err()), - Some("DUPLICATE_ELEMENT_GROUP_TARGET") - ); - - let accepted_uuid = Uuid::new_v4(); - for id in [ - accepted_uuid.simple().to_string(), - accepted_uuid.braced().to_string(), - accepted_uuid.urn().to_string(), - accepted_uuid.hyphenated().to_string().to_uppercase(), - ] { - let op = EditorOpV1::SetElementGroups { - mode: "4key".to_string(), - targets: vec![EditorElementGroupTargetV1 { - element_type: EditorElementTypeV1::Key, - id, - }], - target_group: None, - }; - validate_request_envelope(&ops_request(vec![op])).unwrap(); - } - - let mixed = ops_request(vec![ - EditorOpV1::RenameLayerGroup { - mode: "4key".to_string(), - group_id: "group".to_string(), - name: "Name".to_string(), - }, - set_bounds_op(Uuid::new_v4().to_string(), EditorElementTypeV1::Key), - ]); - assert_eq!( - validation_code(&validate_request_envelope(&mixed).unwrap_err()), - Some("INVALID_GROUP_STRUCTURAL_BATCH") - ); - } - - #[test] - fn reorder_is_a_bounded_sole_op_with_consistent_targets() { - let id = Uuid::new_v4().to_string(); - let mixed = ops_request(vec![ - reorder_op(id.clone(), false), - set_bounds_op(Uuid::new_v4().to_string(), EditorElementTypeV1::Key), - ]); - assert_eq!( - validation_code(&validate_request_envelope(&mixed).unwrap_err()), - Some("INVALID_REORDER_BATCH") - ); - - let empty = EditorOpV1::ReorderElements { - mode: "4key".to_string(), - complete_mode_order: false, - z_updates: Vec::new(), - group_updates: Vec::new(), - }; - assert_eq!( - validation_code(&validate_request_envelope(&ops_request(vec![empty])).unwrap_err()), - Some("EMPTY_REORDER_BATCH") - ); - - let duplicate_z = EditorOpV1::ReorderElements { - mode: "4key".to_string(), - complete_mode_order: true, - z_updates: vec![ - EditorZUpdateV1 { - element_type: EditorElementTypeV1::Key, - id: id.clone(), - z_index: 1, - }, - EditorZUpdateV1 { - element_type: EditorElementTypeV1::Key, - id: id.clone(), - z_index: 2, - }, - ], - group_updates: Vec::new(), - }; - assert_eq!( - validation_code( - &validate_request_envelope(&ops_request(vec![duplicate_z])).unwrap_err() - ), - Some("DUPLICATE_REORDER_Z_TARGET") - ); - - let group_without_z = EditorOpV1::ReorderElements { - mode: "4key".to_string(), - complete_mode_order: true, - z_updates: vec![EditorZUpdateV1 { - element_type: EditorElementTypeV1::Key, - id: id.clone(), - z_index: 1, - }], - group_updates: vec![EditorGroupUpdateV1 { - element_type: EditorElementTypeV1::Key, - id: Uuid::new_v4().to_string(), - group_id: None, - }], - }; - assert_eq!( - validation_code( - &validate_request_envelope(&ops_request(vec![group_without_z])).unwrap_err() - ), - Some("REORDER_GROUP_TARGET_NOT_IN_ORDER") - ); - - let duplicate_group = EditorOpV1::ReorderElements { - mode: "4key".to_string(), - complete_mode_order: true, - z_updates: vec![EditorZUpdateV1 { - element_type: EditorElementTypeV1::Key, - id: id.clone(), - z_index: 1, - }], - group_updates: vec![ - EditorGroupUpdateV1 { - element_type: EditorElementTypeV1::Key, - id: id.clone(), - group_id: None, - }, - EditorGroupUpdateV1 { - element_type: EditorElementTypeV1::Key, - id: id.clone(), - group_id: None, - }, - ], - }; - assert_eq!( - validation_code( - &validate_request_envelope(&ops_request(vec![duplicate_group])).unwrap_err() - ), - Some("DUPLICATE_REORDER_GROUP_TARGET") - ); - - let conflicting_type = EditorOpV1::ReorderElements { - mode: "4key".to_string(), - complete_mode_order: true, - z_updates: vec![EditorZUpdateV1 { - element_type: EditorElementTypeV1::Key, - id: id.clone(), - z_index: 1, - }], - group_updates: vec![EditorGroupUpdateV1 { - element_type: EditorElementTypeV1::Graph, - id: id.clone(), - group_id: None, - }], - }; - assert_eq!( - validation_code( - &validate_request_envelope(&ops_request(vec![conflicting_type])).unwrap_err() - ), - Some("REORDER_TARGET_TYPE_CONFLICT") - ); - - let invalid_group_id = EditorOpV1::ReorderElements { - mode: "4key".to_string(), - complete_mode_order: true, - z_updates: vec![EditorZUpdateV1 { - element_type: EditorElementTypeV1::Key, - id: id.clone(), - z_index: 1, - }], - group_updates: vec![EditorGroupUpdateV1 { - element_type: EditorElementTypeV1::Key, - id: id.clone(), - group_id: Some(String::new()), - }], - }; - assert_eq!( - validation_code( - &validate_request_envelope(&ops_request(vec![invalid_group_id])).unwrap_err() - ), - Some("INVALID_REORDER_GROUP_ID") - ); - - let partial_group = EditorOpV1::ReorderElements { - mode: "4key".to_string(), - complete_mode_order: false, - z_updates: vec![EditorZUpdateV1 { - element_type: EditorElementTypeV1::Key, - id: id.clone(), - z_index: 1, - }], - group_updates: vec![EditorGroupUpdateV1 { - element_type: EditorElementTypeV1::Key, - id: id.clone(), - group_id: None, - }], - }; - assert_eq!( - validation_code( - &validate_request_envelope(&ops_request(vec![partial_group])).unwrap_err() - ), - Some("INVALID_PARTIAL_REORDER_GROUP_UPDATE") - ); - - let at_limit = EditorOpV1::ReorderElements { - mode: "4key".to_string(), - complete_mode_order: false, - z_updates: (0..MAX_RENDER_ITEMS) - .map(|index| EditorZUpdateV1 { - element_type: EditorElementTypeV1::Key, - id: Uuid::from_u128(index as u128 + 1).to_string(), - z_index: index as i32, - }) - .collect(), - group_updates: Vec::new(), - }; - validate_request_envelope(&ops_request(vec![at_limit.clone()])).unwrap(); - let mut too_wide = at_limit; - let EditorOpV1::ReorderElements { z_updates, .. } = &mut too_wide else { - unreachable!(); - }; - z_updates.push(EditorZUpdateV1 { - element_type: EditorElementTypeV1::Key, - id: Uuid::from_u128(MAX_RENDER_ITEMS as u128 + 1).to_string(), - z_index: 0, - }); - assert_eq!( - validation_code(&validate_request_envelope(&ops_request(vec![too_wide])).unwrap_err()), - Some("REORDER_BATCH_TOO_LARGE") - ); - - let too_many_group_updates = EditorOpV1::ReorderElements { - mode: "4key".to_string(), - complete_mode_order: true, - z_updates: vec![EditorZUpdateV1 { - element_type: EditorElementTypeV1::Key, - id: id.clone(), - z_index: 1, - }], - group_updates: (0..=MAX_RENDER_ITEMS) - .map(|index| EditorGroupUpdateV1 { - element_type: EditorElementTypeV1::Key, - id: Uuid::from_u128(index as u128 + 1).to_string(), - group_id: None, - }) - .collect(), - }; - assert_eq!( - validation_code( - &validate_request_envelope(&ops_request(vec![too_many_group_updates])).unwrap_err() - ), - Some("REORDER_BATCH_TOO_LARGE") - ); - } - - #[test] - fn frozen_insert_is_a_bounded_sole_op_with_unique_disjoint_ids() { - let id = Uuid::new_v4().to_string(); - let mixed = ops_request(vec![ - frozen_insert_op(id.clone()), - set_bounds_op(Uuid::new_v4().to_string(), EditorElementTypeV1::Key), - ]); - assert_eq!( - validation_code(&validate_request_envelope(&mixed).unwrap_err()), - Some("INVALID_FROZEN_INSERT_BATCH") - ); - - let duplicate = EditorOpV1::InsertFrozenElements { - mode: "4key".to_string(), - elements: vec![ - crate::models::EditorFrozenElementV1::Key { - slot: crate::models::EditorFrozenKeySlotV1::Single("A".to_string()), - position: KeyPosition { - id: id.clone(), - ..KeyPosition::default() - }, - }, - crate::models::EditorFrozenElementV1::Key { - slot: crate::models::EditorFrozenKeySlotV1::Single("B".to_string()), - position: KeyPosition { - id: id.clone(), - ..KeyPosition::default() - }, - }, - ], - groups: Vec::new(), - z_updates: Vec::new(), - }; - assert_eq!( - validation_code(&validate_request_envelope(&ops_request(vec![duplicate])).unwrap_err()), - Some("DUPLICATE_FROZEN_INSERT_ID") - ); - - let overlap = EditorOpV1::InsertFrozenElements { - mode: "4key".to_string(), - elements: vec![crate::models::EditorFrozenElementV1::Key { - slot: crate::models::EditorFrozenKeySlotV1::Single("A".to_string()), - position: KeyPosition { - id: id.clone(), - ..KeyPosition::default() - }, - }], - groups: Vec::new(), - z_updates: vec![crate::models::EditorZUpdateV1 { - element_type: EditorElementTypeV1::Key, - id, - z_index: 1, - }], - }; - assert_eq!( - validation_code(&validate_request_envelope(&ops_request(vec![overlap])).unwrap_err()), - Some("FROZEN_INSERT_Z_TARGET_OVERLAP") - ); - - let malformed_slot = EditorOpV1::InsertFrozenElements { - mode: "4key".to_string(), - elements: vec![crate::models::EditorFrozenElementV1::Key { - slot: crate::models::EditorFrozenKeySlotV1::Multi( - crate::models::EditorFrozenMultiKeySlotV1 { - keys: vec!["A".to_string()], - match_mode: crate::models::SlotMatch::All, - }, - ), - position: KeyPosition { - id: Uuid::new_v4().to_string(), - ..KeyPosition::default() - }, - }], - groups: Vec::new(), - z_updates: Vec::new(), - }; - assert_eq!( - validation_code( - &validate_request_envelope(&ops_request(vec![malformed_slot])).unwrap_err() - ), - Some("INVALID_FROZEN_KEY_SLOT") - ); - - for match_mode in [crate::models::SlotMatch::All, crate::models::SlotMatch::Any] { - let valid_multi = EditorOpV1::InsertFrozenElements { - mode: "4key".to_string(), - elements: vec![crate::models::EditorFrozenElementV1::Key { - slot: crate::models::EditorFrozenKeySlotV1::Multi( - crate::models::EditorFrozenMultiKeySlotV1 { - keys: vec!["A".to_string(), "B".to_string()], - match_mode, - }, - ), - position: KeyPosition { - id: Uuid::new_v4().to_string(), - ..KeyPosition::default() - }, - }], - groups: Vec::new(), - z_updates: Vec::new(), - }; - validate_request_envelope(&ops_request(vec![valid_multi])).unwrap(); - } - - for members in [ - vec!["A".to_string(), "A".to_string()], - vec!["".to_string(), "B".to_string()], - vec!["A+B".to_string(), "C".to_string()], - vec!["A|B".to_string(), "C".to_string()], - ] { - let invalid_multi = EditorOpV1::InsertFrozenElements { - mode: "4key".to_string(), - elements: vec![crate::models::EditorFrozenElementV1::Key { - slot: crate::models::EditorFrozenKeySlotV1::Multi( - crate::models::EditorFrozenMultiKeySlotV1 { - keys: members, - match_mode: crate::models::SlotMatch::All, - }, - ), - position: KeyPosition { - id: Uuid::new_v4().to_string(), - ..KeyPosition::default() - }, - }], - groups: Vec::new(), - z_updates: Vec::new(), - }; - assert_eq!( - validation_code( - &validate_request_envelope(&ops_request(vec![invalid_multi])).unwrap_err() - ), - Some("INVALID_FROZEN_KEY_SLOT") - ); - } - - let inserted_id = Uuid::from_u128((MAX_RENDER_ITEMS + 1) as u128).to_string(); - let wide_plan = EditorOpV1::InsertFrozenElements { - mode: "4key".to_string(), - elements: vec![crate::models::EditorFrozenElementV1::Key { - slot: crate::models::EditorFrozenKeySlotV1::Single("A".to_string()), - position: KeyPosition { - id: inserted_id, - ..KeyPosition::default() - }, - }], - groups: Vec::new(), - z_updates: (0..MAX_RENDER_ITEMS) - .map(|index| crate::models::EditorZUpdateV1 { - element_type: EditorElementTypeV1::Key, - id: Uuid::from_u128(index as u128 + 1).to_string(), - z_index: index as i32, - }) - .collect(), - }; - validate_request_envelope(&ops_request(vec![wide_plan.clone()])).unwrap(); - - let mut too_wide = wide_plan; - let EditorOpV1::InsertFrozenElements { z_updates, .. } = &mut too_wide else { - unreachable!(); - }; - z_updates.push(crate::models::EditorZUpdateV1 { - element_type: EditorElementTypeV1::Key, - id: Uuid::from_u128((MAX_RENDER_ITEMS + 2) as u128).to_string(), - z_index: 0, - }); - assert_eq!( - validation_code(&validate_request_envelope(&ops_request(vec![too_wide])).unwrap_err()), - Some("FROZEN_INSERT_BATCH_TOO_LARGE") - ); - } - - #[test] - fn editor_ops_enforce_version_count_ids_and_global_target_uniqueness() { - let id = Uuid::new_v4().to_string(); - // 구버전 1과 미래 버전 3 모두 거부, v1 이중 수용 없음 - for unsupported_version in [1, 3] { - let mut unsupported = ops_request(vec![set_bounds_op(&id, EditorElementTypeV1::Key)]); - unsupported.ops_version = Some(unsupported_version); - assert_eq!( - validation_code(&validate_request_envelope(&unsupported).unwrap_err()), - Some("UNSUPPORTED_OPS_VERSION") - ); - } - - let empty = ops_request(Vec::new()); - assert_eq!( - validation_code(&validate_request_envelope(&empty).unwrap_err()), - Some("EMPTY_EDITOR_OPS") - ); - - let at_limit = ops_request( - (0..MAX_EDITOR_OPS) - .map(|index| { - set_bounds_op( - Uuid::from_u128(index as u128 + 1).to_string(), - EditorElementTypeV1::Key, - ) - }) - .collect(), - ); - validate_request_envelope(&at_limit).unwrap(); - - let mut too_many = at_limit; - too_many.ops.as_mut().unwrap().push(set_bounds_op( - Uuid::from_u128(MAX_EDITOR_OPS as u128 + 1).to_string(), - EditorElementTypeV1::Key, - )); - assert_eq!( - validation_code(&validate_request_envelope(&too_many).unwrap_err()), - Some("TOO_MANY_EDITOR_OPS") - ); - - let duplicate = ops_request(vec![ - set_bounds_op(&id, EditorElementTypeV1::Key), - delete_element_op(&id, EditorElementTypeV1::Graph), - ]); - assert_eq!( - validation_code(&validate_request_envelope(&duplicate).unwrap_err()), - Some("DUPLICATE_EDITOR_OP_TARGET") - ); - - let nil = ops_request(vec![set_bounds_op( - Uuid::nil().to_string(), - EditorElementTypeV1::Key, - )]); - assert_eq!( - validation_code(&validate_request_envelope(&nil).unwrap_err()), - Some(crate::state::native_element_id::INVALID_ELEMENT_ID) - ); - } - - #[test] - fn editor_op_bounds_reuse_position_numeric_limits() { - let valid = EditorBoundsV1 { - dx: MAX_ABS_COORDINATE, - dy: -MAX_ABS_COORDINATE, - width: MAX_DIMENSION, - height: 1.0, - }; - validate_editor_op_bounds(0, None, valid).unwrap(); - - for invalid in [ - EditorBoundsV1 { - dx: f64::NAN, - ..valid - }, - EditorBoundsV1 { - dy: MAX_ABS_COORDINATE + 1.0, - ..valid - }, - EditorBoundsV1 { - width: 0.0, - ..valid - }, - EditorBoundsV1 { - height: MAX_DIMENSION + 1.0, - ..valid - }, - ] { - assert!(validate_editor_op_bounds(0, None, invalid).is_err()); - } - - let grandfathered = KeyPosition { - width: MAX_DIMENSION + 2.0, - ..KeyPosition::default() - }; - validate_editor_op_bounds( - 0, - Some(&grandfathered), - EditorBoundsV1 { - width: MAX_DIMENSION + 1.0, - ..position_bounds(&grandfathered) - }, - ) - .unwrap(); - - let mismatch = - validate_editor_op_target_type(0, EditorElementTypeV1::Graph, EditorElementTypeV1::Key) - .unwrap_err(); - assert_eq!(validation_code(&mismatch), Some("ELEMENT_TYPE_MISMATCH")); - assert!(!mismatch.retryable); - } - - #[test] - fn canonical_fingerprint_includes_editor_op_payload() { - let id = Uuid::new_v4().to_string(); - let left = ops_request(vec![set_bounds_op(&id, EditorElementTypeV1::Key)]); - let mut right = left.clone(); - let Some(EditorOpV1::SetBounds { bounds, .. }) = - right.ops.as_mut().and_then(|ops| ops.first_mut()) - else { - unreachable!(); - }; - bounds.width += 1.0; - - assert_ne!( - request_fingerprint(&left).unwrap(), - request_fingerprint(&right).unwrap() - ); - - let delete = ops_request(vec![delete_element_op(&id, EditorElementTypeV1::Key)]); - assert_ne!( - request_fingerprint(&left).unwrap(), - request_fingerprint(&delete).unwrap() - ); - } - - #[test] - fn editor_op_results_use_the_exact_camel_case_wire_values() { - let canonical = EditorBoundsV1 { - dx: 1.0, - dy: 2.0, - width: 3.0, - height: 4.0, - }; - let wire = serde_json::to_value([ - EditorOpResultV1 { - status: EditorOpResultStatusV1::Applied, - bounds: Some(canonical), - }, - EditorOpResultV1 { - status: EditorOpResultStatusV1::NoChange, - bounds: Some(canonical), - }, - EditorOpResultV1 { - status: EditorOpResultStatusV1::TargetMissing, - bounds: None, - }, - ]) - .unwrap(); - - assert_eq!(wire[0]["status"], "applied"); - assert_eq!(wire[1]["status"], "noChange"); - assert_eq!(wire[2]["status"], "targetMissing"); - assert!(wire[2].get("bounds").is_none()); - } - - #[test] - fn member_fanout_limit_accepts_sixteen_and_rejects_new_excess() { - let store = default_editor_store(); - let current = EditorDocumentV1::from_store(&store); - let mut at_limit = current.clone(); - at_limit - .keys - .get_mut("4key") - .unwrap() - .extend((0..MAX_SLOTS_PER_MEMBER).map(|index| KeySlot::Multi { - keys: vec!["SHARED".to_string(), format!("K{index}")], - match_mode: crate::models::SlotMatch::Any, - })); - at_limit - .key_positions - .get_mut("4key") - .unwrap() - .extend(vec![KeyPosition::default(); MAX_SLOTS_PER_MEMBER]); - let mut at_limit_store = store.clone(); - at_limit.apply_to_store(&mut at_limit_store); - - validate_document_transition(¤t, &at_limit, &store, &at_limit_store).unwrap(); - - let mut over_limit = at_limit.clone(); - over_limit - .keys - .get_mut("4key") - .unwrap() - .push(KeySlot::Multi { - keys: vec!["SHARED".to_string(), "EXTRA".to_string()], - match_mode: crate::models::SlotMatch::Any, - }); - over_limit - .key_positions - .get_mut("4key") - .unwrap() - .push(KeyPosition::default()); - let mut over_limit_store = at_limit_store.clone(); - over_limit.apply_to_store(&mut over_limit_store); - - let error = validate_document_transition( - &at_limit, - &over_limit, - &at_limit_store, - &over_limit_store, - ) - .unwrap_err(); - assert_eq!( - error.details.unwrap().validation_code.as_deref(), - Some("TOO_MANY_SLOTS_PER_MEMBER") - ); - - validate_document_transition( - &over_limit, - &over_limit, - &over_limit_store, - &over_limit_store, - ) - .unwrap(); - } - - #[test] - fn counter_sync_uses_canonical_and_separates_any_from_all() { - let keys = HashMap::from([( - "mode".to_string(), - vec![ - KeySlot::Single("A".to_string()), - KeySlot::Multi { - keys: vec!["A".to_string(), "B".to_string()], - match_mode: crate::models::SlotMatch::Any, - }, - KeySlot::Multi { - keys: vec!["A".to_string(), "B".to_string()], - match_mode: crate::models::SlotMatch::All, - }, - ], - )]); - let mut counters = HashMap::from([( - "mode".to_string(), - HashMap::from([("A".to_string(), 7), ("stale".to_string(), 3)]), - )]); - - sync_key_counters(&mut counters, &keys); - - assert_eq!(counters["mode"]["A"], 7); - assert_eq!(counters["mode"]["A|B"], 0); - assert_eq!(counters["mode"]["A+B"], 0); - assert!(!counters["mode"].contains_key("stale")); - } - - #[test] - fn structural_single_field_update_requires_pair() { - let store = AppStoreData::default(); - let current = EditorDocumentV1::from_store(&store); - let mut candidate = current.clone(); - candidate - .keys - .entry("4key".to_string()) - .or_default() - .push(KeySlot::from("A")); - - let error = validate_paired_update(¤t, &candidate, true, false).unwrap_err(); - assert_eq!( - error.error_code, - crate::errors::EditorCommitErrorCode::PairedUpdateRequired - ); - } - - #[test] - fn stage_four_paired_topology_uses_key_position_id_order() { - let store = default_editor_store(); - let current = EditorDocumentV1::from_store(&store); - - let mut position_edit = current.clone(); - position_edit.key_positions.get_mut("4key").unwrap()[0].dx += 1.0; - validate_paired_update(¤t, &position_edit, false, true).unwrap(); - - let mut positions_only_reorder = current.clone(); - positions_only_reorder - .key_positions - .get_mut("4key") - .unwrap() - .swap(0, 1); - let error = - validate_paired_update(¤t, &positions_only_reorder, false, true).unwrap_err(); - assert_eq!( - error.error_code, - crate::errors::EditorCommitErrorCode::PairedUpdateRequired - ); - assert!(!error.retryable); - - let mut paired_reorder = positions_only_reorder; - paired_reorder.keys.get_mut("4key").unwrap().swap(0, 1); - validate_paired_update(¤t, &paired_reorder, true, true).unwrap(); - - let mut keys_only = current.clone(); - keys_only.keys.get_mut("4key").unwrap()[0] = KeySlot::from("Changed"); - validate_paired_update(¤t, &keys_only, true, false).unwrap(); - } - - #[test] - fn unchanged_ghost_mode_is_grandfathered() { - let mut store = AppStoreData::default(); - store - .keys - .insert("ghost".to_string(), vec![KeySlot::from("A")]); - store - .key_positions - .insert("ghost".to_string(), vec![KeyPosition::default()]); - let current = EditorDocumentV1::from_store(&store); - let mut candidate = current.clone(); - candidate.keys.get_mut("ghost").unwrap()[0] = KeySlot::from("B"); - - validate_document_transition(¤t, &candidate, &store, &store).unwrap(); - } - - #[test] - fn new_ghost_mode_is_rejected() { - let store = AppStoreData::default(); - let current = EditorDocumentV1::from_store(&store); - let mut candidate = current.clone(); - candidate - .keys - .insert("ghost".to_string(), vec![KeySlot::from("A")]); - candidate - .key_positions - .insert("ghost".to_string(), vec![KeyPosition::default()]); - - let error = validate_document_transition(¤t, &candidate, &store, &store).unwrap_err(); - assert_eq!( - error.details.unwrap().validation_code.as_deref(), - Some("UNKNOWN_MODE") - ); - } - - #[test] - fn metadata_can_introduce_matching_custom_mode_in_same_transition() { - let store = AppStoreData::default(); - let current = EditorDocumentV1::from_store(&store); - let mut candidate_store = store.clone(); - candidate_store.custom_tabs.push(CustomTab { - id: "custom".to_string(), - name: "Custom".to_string(), - }); - candidate_store - .keys - .insert("custom".to_string(), vec![KeySlot::from("A")]); - candidate_store - .key_positions - .insert("custom".to_string(), vec![KeyPosition::default()]); - let candidate = EditorDocumentV1::from_store(&candidate_store); - - validate_document_transition(¤t, &candidate, &store, &candidate_store).unwrap(); - } - - #[test] - fn history_restore_requires_metadata_for_every_custom_editor_mode() { - let mut store = AppStoreData::default(); - store - .keys - .insert("custom".to_string(), vec![KeySlot::from("A")]); - store - .key_positions - .insert("custom".to_string(), vec![KeyPosition::default()]); - let document = EditorDocumentV1::from_store(&store); - - let error = validate_history_restore_metadata(&document, &[], "4key").unwrap_err(); - - assert_eq!( - error.details.unwrap().validation_code.as_deref(), - Some("CUSTOM_TAB_METADATA_MISSING") - ); - } - - #[test] - fn history_restore_rejects_metadata_without_paired_editor_collections() { - let document = EditorDocumentV1::from_store(&AppStoreData::default()); - let tabs = vec![CustomTab { - id: "custom".to_string(), - name: "Custom".to_string(), - }]; - - let error = validate_history_restore_metadata(&document, &tabs, "custom").unwrap_err(); - - assert_eq!( - error.details.unwrap().validation_code.as_deref(), - Some("CUSTOM_TAB_DOCUMENT_MISSING") - ); - } - - #[test] - fn coordinate_and_dimension_limits_accept_boundary_and_reject_one_past_it() { - let store = default_editor_store(); - let current = EditorDocumentV1::from_store(&store); - let mut boundary = current.clone(); - let position = &mut boundary.key_positions.get_mut("4key").unwrap()[0]; - position.dx = MAX_ABS_COORDINATE; - position.dy = -MAX_ABS_COORDINATE; - position.width = MAX_DIMENSION; - position.height = MAX_DIMENSION; - let mut boundary_store = store.clone(); - boundary.apply_to_store(&mut boundary_store); - validate_document_transition(¤t, &boundary, &store, &boundary_store).unwrap(); - - for (name, value) in [ - ("dx", MAX_ABS_COORDINATE + 1.0), - ("dy", -MAX_ABS_COORDINATE - 1.0), - ("width", MAX_DIMENSION + 1.0), - ("height", 0.0), - ] { - let mut invalid = current.clone(); - let position = &mut invalid.key_positions.get_mut("4key").unwrap()[0]; - match name { - "dx" => position.dx = value, - "dy" => position.dy = value, - "width" => position.width = value, - "height" => position.height = value, - _ => unreachable!(), - } - let mut invalid_store = store.clone(); - invalid.apply_to_store(&mut invalid_store); - assert!( - validate_document_transition(¤t, &invalid, &store, &invalid_store).is_err(), - "{name}={value} should be rejected" - ); - } - } - - #[test] - fn oversized_coordinate_is_grandfathered_only_when_unchanged_or_decreased() { - let mut store = default_editor_store(); - store.key_positions.get_mut("4key").unwrap()[0].dx = MAX_ABS_COORDINATE + 2.0; - let current = EditorDocumentV1::from_store(&store); - - validate_document_transition(¤t, ¤t, &store, &store).unwrap(); - - let mut decreased = current.clone(); - decreased.key_positions.get_mut("4key").unwrap()[0].dx = MAX_ABS_COORDINATE + 1.0; - let mut decreased_store = store.clone(); - decreased.apply_to_store(&mut decreased_store); - validate_document_transition(¤t, &decreased, &store, &decreased_store).unwrap(); - - let mut increased = current.clone(); - increased.key_positions.get_mut("4key").unwrap()[0].dx = MAX_ABS_COORDINATE + 3.0; - let mut increased_store = store.clone(); - increased.apply_to_store(&mut increased_store); - assert!( - validate_document_transition(¤t, &increased, &store, &increased_store).is_err() - ); - } - - #[test] - fn editor_rejects_new_shadow_violations_in_every_position_collection() { - let store = store_with_each_position_collection(); - let current = EditorDocumentV1::from_store(&store); - - for (collection, active, property, expected_path) in [ - ( - "keyPositions", - false, - "blur", - "keyPositions 4key[0].shadow.blur", - ), - ( - "statPositions", - true, - "offsetX", - "statPositions 4key[0].activeShadow.offsetX", - ), - ( - "graphPositions", - false, - "offsetY", - "graphPositions 4key[0].shadow.offsetY", - ), - ( - "knobPositions", - true, - "color", - "knobPositions 4key[0].activeShadow.color", - ), - ] { - let mut candidate = current.clone(); - let mut shadow = valid_shadow(); - match property { - "blur" => shadow.blur = MAX_SHADOW_BLUR + 0.1, - "offsetX" => shadow.offset_x = MIN_SHADOW_OFFSET - 0.1, - "offsetY" => shadow.offset_y = MAX_SHADOW_OFFSET + 0.1, - "color" => shadow.color.clear(), - _ => unreachable!(), - } - let position = position_mut(&mut candidate, collection); - if active { - position.active_shadow = Some(shadow); - } else { - position.shadow = Some(shadow); - } - let mut candidate_store = store.clone(); - candidate.apply_to_store(&mut candidate_store); - - let error = - validate_document_transition(¤t, &candidate, &store, &candidate_store) - .unwrap_err(); - assert_eq!( - error - .details - .as_ref() - .and_then(|details| details.validation_code.as_deref()), - Some("INVALID_ELEMENT_SHADOW") - ); - assert!(error.message.contains(expected_path)); - } - } - - #[test] - fn existing_shadow_violations_are_grandfathered_only_when_unchanged() { - let mut store = default_editor_store(); - let position = &mut store.key_positions.get_mut("4key").unwrap()[0]; - let mut shadow = valid_shadow(); - shadow.blur = MAX_SHADOW_BLUR + 1.0; - position.shadow = Some(shadow); - let current = EditorDocumentV1::from_store(&store); - - let mut unrelated = current.clone(); - unrelated.key_positions.get_mut("4key").unwrap()[0].font_size = Some(18.0); - let mut unrelated_store = store.clone(); - unrelated.apply_to_store(&mut unrelated_store); - validate_document_transition(¤t, &unrelated, &store, &unrelated_store).unwrap(); - - let mut changed_shadow = current.clone(); - changed_shadow.key_positions.get_mut("4key").unwrap()[0] - .shadow - .as_mut() - .unwrap() - .blur += 1.0; - let mut changed_shadow_store = store.clone(); - changed_shadow.apply_to_store(&mut changed_shadow_store); - let shadow_error = - validate_document_transition(¤t, &changed_shadow, &store, &changed_shadow_store) - .unwrap_err(); - assert_eq!( - shadow_error - .details - .as_ref() - .and_then(|details| details.validation_code.as_deref()), - Some("INVALID_ELEMENT_SHADOW") - ); - } - - // 이미지 변환도 그림자와 같은 문서 단위 검증 - 기존 값은 grandfather, 새 범위 밖 값은 거부 - #[test] - fn image_transform_violations_are_rejected_unless_grandfathered() { - let out_of_range = |scale: f64| crate::models::ImageTransform { - offset_x: 0.0, - offset_y: 0.0, - rotation: 0.0, - scale, - }; - let mut store = default_editor_store(); - store.key_positions.get_mut("4key").unwrap()[0].idle_image_transform = - Some(out_of_range(0.05)); - let current = EditorDocumentV1::from_store(&store); - - let mut unrelated = current.clone(); - unrelated.key_positions.get_mut("4key").unwrap()[0].font_size = Some(18.0); - let mut unrelated_store = store.clone(); - unrelated.apply_to_store(&mut unrelated_store); - validate_document_transition(¤t, &unrelated, &store, &unrelated_store).unwrap(); - - let mut changed = current.clone(); - changed.key_positions.get_mut("4key").unwrap()[0].idle_image_transform = - Some(out_of_range(0.04)); - let mut changed_store = store.clone(); - changed.apply_to_store(&mut changed_store); - let error = - validate_document_transition(¤t, &changed, &store, &changed_store).unwrap_err(); - assert_eq!( - error - .details - .as_ref() - .and_then(|details| details.validation_code.as_deref()), - Some("INVALID_IMAGE_TRANSFORM") - ); - assert!(error.message.contains("idleImageTransform.scale")); - } - - #[test] - fn stage_four_grandfathering_ignores_diagnostic_message_changes() { - let key = ViolationKey { - owner: ViolationOwner::Mode { - mode: "ghost".to_string(), - }, - code: "UNKNOWN_MODE", - property_path: ViolationPropertyPath::Collection("keys"), - invalid_value: InvalidValueSignature::None, - }; - let current = [ValidationViolation::new(key.clone(), "same message")] - .into_iter() - .map(|violation| violation.key) - .collect(); - - assert!(is_grandfathered( - ¤t, - &ValidationViolation::new(key, "different diagnostic message"), - &HashMap::new() - )); - } - - #[test] - fn stage_four_stable_id_grandfathers_violation_after_reorder() { - let mut store = default_editor_store(); - let mut shadow = valid_shadow(); - shadow.blur = MAX_SHADOW_BLUR + 1.0; - store.key_positions.get_mut("4key").unwrap()[0].shadow = Some(shadow); - let current = EditorDocumentV1::from_store(&store); - - let mut candidate = current.clone(); - candidate.keys.get_mut("4key").unwrap().swap(0, 1); - candidate.key_positions.get_mut("4key").unwrap().swap(0, 1); - let mut candidate_store = store.clone(); - candidate.apply_to_store(&mut candidate_store); - - validate_paired_update(¤t, &candidate, true, true).unwrap(); - validate_document_transition(¤t, &candidate, &store, &candidate_store).unwrap(); - } - - #[test] - fn stage_four_native_violation_key_omits_mode_for_same_element() { - let mut store = store_with_each_position_collection(); - let mut shadow = valid_shadow(); - shadow.blur = MAX_SHADOW_BLUR + 1.0; - store.stat_positions.get_mut("4key").unwrap()[0] - .position - .shadow = Some(shadow); - let current = EditorDocumentV1::from_store(&store); - let mut candidate = current.clone(); - let moved = candidate - .stat_positions - .get_mut("4key") - .unwrap() - .pop() - .unwrap(); - candidate - .stat_positions - .entry("5key".to_string()) - .or_default() - .push(moved); - let mut candidate_store = store.clone(); - candidate.apply_to_store(&mut candidate_store); - - validate_document_transition(¤t, &candidate, &store, &candidate_store).unwrap(); - } - - #[test] - fn stage_four_same_violation_on_a_different_id_is_rejected() { - let mut store = default_editor_store(); - let mut shadow = valid_shadow(); - shadow.blur = MAX_SHADOW_BLUR + 1.0; - store.key_positions.get_mut("4key").unwrap()[0].shadow = Some(shadow); - let current = EditorDocumentV1::from_store(&store); - let mut candidate = current.clone(); - candidate.key_positions.get_mut("4key").unwrap()[0].id = Uuid::new_v4().to_string(); - let mut candidate_store = store.clone(); - candidate.apply_to_store(&mut candidate_store); - - let error = validate_document_transition(¤t, &candidate, &store, &candidate_store) - .unwrap_err(); - assert_eq!( - error.details.unwrap().validation_code.as_deref(), - Some("INVALID_ELEMENT_SHADOW") - ); - } - - #[test] - fn preset_keying_grandfathers_rekeyed_elements_by_mode_and_index() { - let mut store = default_editor_store(); - store.key_positions.get_mut("4key").unwrap()[0].dx = MAX_ABS_COORDINATE + 1.0; - let current = EditorDocumentV1::from_store(&store); - - // 프리셋 로드는 검증 전에 모든 요소의 id를 재발급한다 - 값이 그대로여도 - // id 조회로는 관용 대상을 찾을 수 없다 - let mut candidate_store = store.clone(); - crate::state::native_element_id::rekey_store_element_ids(&mut candidate_store); - let candidate = EditorDocumentV1::from_store(&candidate_store); - - let error = validate_document_transition(¤t, &candidate, &store, &candidate_store) - .unwrap_err(); - assert_eq!( - error.details.unwrap().validation_code.as_deref(), - Some("COORDINATE_OUT_OF_RANGE") - ); - - validate_document_transition_with_keying( - ¤t, - &candidate, - &store, - &candidate_store, - GrandfatherKeying::ModeIndex, - ) - .unwrap(); - } - - #[test] - fn preset_keying_grandfathers_structural_and_label_violations_too() { - let mut store = default_editor_store(); - // 그림자 위반과 과길이 라벨을 함께 가진 관용 store - store.key_positions.get_mut("4key").unwrap()[0].shadow = Some(ElementShadowSpec { - color: String::new(), - ..valid_shadow() - }); - store.keys.get_mut("4key").unwrap()[0] = KeySlot::from("x".repeat(MAX_KEY_LABEL_BYTES + 1)); - let current = EditorDocumentV1::from_store(&store); - - let mut candidate_store = store.clone(); - crate::state::native_element_id::rekey_store_element_ids(&mut candidate_store); - let candidate = EditorDocumentV1::from_store(&candidate_store); - - // ID 기준으로는 관용 상대를 못 찾아 거부된다 - assert!( - validate_document_transition(¤t, &candidate, &store, &candidate_store).is_err() - ); - - // 자리 기준이면 그림자·라벨 관용이 모두 유지된다 - validate_document_transition_with_keying( - ¤t, - &candidate, - &store, - &candidate_store, - GrandfatherKeying::ModeIndex, - ) - .unwrap(); - } - - #[test] - fn preset_keying_still_rejects_newly_raised_metrics() { - let mut store = default_editor_store(); - store.key_positions.get_mut("4key").unwrap()[0].dx = MAX_ABS_COORDINATE + 1.0; - let current = EditorDocumentV1::from_store(&store); - - let mut candidate_store = store.clone(); - crate::state::native_element_id::rekey_store_element_ids(&mut candidate_store); - candidate_store.key_positions.get_mut("4key").unwrap()[1].dx = MAX_ABS_COORDINATE + 1.0; - let candidate = EditorDocumentV1::from_store(&candidate_store); - - // (mode,index) 관용은 같은 자리의 기존 위반만 물려받는다 - 멀쩡하던 - // 자리가 새로 초과되면 프리셋 경로에서도 거부한다 - let error = validate_document_transition_with_keying( - ¤t, - &candidate, - &store, - &candidate_store, - GrandfatherKeying::ModeIndex, - ) - .unwrap_err(); - assert_eq!( - error.details.unwrap().validation_code.as_deref(), - Some("COORDINATE_OUT_OF_RANGE") - ); - } - - #[test] - fn legacy_preset_keying_accepts_finite_historical_bounds_in_every_collection() { - let store = store_with_each_position_collection(); - let current = EditorDocumentV1::from_store(&store); - - for collection in [ - "keyPositions", - "statPositions", - "graphPositions", - "knobPositions", - ] { - let mut candidate = current.clone(); - let position = position_mut(&mut candidate, collection); - position.dx = MAX_ABS_COORDINATE + 7_232.0; - position.width = MAX_DIMENSION + 7_232.0; - let mut candidate_store = store.clone(); - candidate.apply_to_store(&mut candidate_store); - crate::state::native_element_id::rekey_store_element_ids(&mut candidate_store); - let candidate = EditorDocumentV1::from_store(&candidate_store); - - let strict = validate_document_transition_with_keying( - ¤t, - &candidate, - &store, - &candidate_store, - GrandfatherKeying::ModeIndex, - ) - .unwrap_err(); - assert!(matches!( - strict.details.unwrap().validation_code.as_deref(), - Some("COORDINATE_OUT_OF_RANGE" | "DIMENSION_OUT_OF_RANGE") - )); - validate_document_transition_with_keying( - ¤t, - &candidate, - &store, - &candidate_store, - GrandfatherKeying::LegacyPresetModeIndex, - ) - .unwrap(); - } - - let mut invalid = current.clone(); - invalid.key_positions.get_mut("4key").unwrap()[0].dx = f64::NAN; - let mut invalid_store = store.clone(); - invalid.apply_to_store(&mut invalid_store); - assert!(validate_document_transition_with_keying( - ¤t, - &invalid, - &store, - &invalid_store, - GrandfatherKeying::LegacyPresetModeIndex, - ) - .is_err()); - } - - #[test] - fn legacy_preset_keying_still_rejects_non_positive_dimensions() { - let store = store_with_each_position_collection(); - let current = EditorDocumentV1::from_store(&store); - - for (width, height) in [(0.0, 60.0), (-1.0, 60.0), (60.0, 0.0)] { - let mut candidate = current.clone(); - let position = position_mut(&mut candidate, "keyPositions"); - position.width = width; - position.height = height; - let mut candidate_store = store.clone(); - candidate.apply_to_store(&mut candidate_store); - crate::state::native_element_id::rekey_store_element_ids(&mut candidate_store); - let candidate = EditorDocumentV1::from_store(&candidate_store); - - let error = validate_document_transition_with_keying( - ¤t, - &candidate, - &store, - &candidate_store, - GrandfatherKeying::LegacyPresetModeIndex, - ) - .unwrap_err(); - assert_eq!( - error.details.unwrap().validation_code.as_deref(), - Some("DIMENSION_OUT_OF_RANGE") - ); - } - } - - #[test] - fn unconditional_structural_violation_is_rejected_even_when_unchanged() { - let mut store = default_editor_store(); - store.keys.get_mut("4key").unwrap().pop(); - let document = EditorDocumentV1::from_store(&store); - - let error = validate_document_transition(&document, &document, &store, &store).unwrap_err(); - assert_eq!( - error.details.unwrap().validation_code.as_deref(), - Some("KEY_POSITION_LENGTH_MISMATCH") - ); - } - - #[test] - fn stage_four_per_owner_limits_follow_stable_ids_across_reorder() { - let mut label_store = default_editor_store(); - label_store.keys.get_mut("4key").unwrap()[0] = - KeySlot::from("x".repeat(MAX_KEY_LABEL_BYTES + 1)); - let current_labels = EditorDocumentV1::from_store(&label_store); - validate_document_transition(¤t_labels, ¤t_labels, &label_store, &label_store) - .unwrap(); - - let mut moved_label = current_labels.clone(); - moved_label.keys.get_mut("4key").unwrap().swap(0, 1); - moved_label - .key_positions - .get_mut("4key") - .unwrap() - .swap(0, 1); - let mut moved_label_store = label_store.clone(); - moved_label.apply_to_store(&mut moved_label_store); - validate_document_transition( - ¤t_labels, - &moved_label, - &label_store, - &moved_label_store, - ) - .unwrap(); - - let mut coordinate_store = default_editor_store(); - coordinate_store.key_positions.get_mut("4key").unwrap()[0].dx = MAX_ABS_COORDINATE + 1.0; - let current_coordinates = EditorDocumentV1::from_store(&coordinate_store); - validate_document_transition( - ¤t_coordinates, - ¤t_coordinates, - &coordinate_store, - &coordinate_store, - ) - .unwrap(); - - let mut moved_coordinate = current_coordinates.clone(); - moved_coordinate - .key_positions - .get_mut("4key") - .unwrap() - .swap(0, 1); - moved_coordinate.keys.get_mut("4key").unwrap().swap(0, 1); - let mut moved_coordinate_store = coordinate_store.clone(); - moved_coordinate.apply_to_store(&mut moved_coordinate_store); - validate_document_transition( - ¤t_coordinates, - &moved_coordinate, - &coordinate_store, - &moved_coordinate_store, - ) - .unwrap(); - } - - #[test] - fn stage_four_new_element_has_no_metric_allowance() { - let store = default_editor_store(); - let current = EditorDocumentV1::from_store(&store); - let mut candidate = current.clone(); - candidate - .keys - .get_mut("4key") - .unwrap() - .push(KeySlot::from("NEW")); - candidate - .key_positions - .get_mut("4key") - .unwrap() - .push(KeyPosition { - id: Uuid::new_v4().to_string(), - dx: MAX_ABS_COORDINATE + 1.0, - ..KeyPosition::default() - }); - let mut candidate_store = store.clone(); - candidate.apply_to_store(&mut candidate_store); - - let error = validate_document_transition(¤t, &candidate, &store, &candidate_store) - .unwrap_err(); - assert_eq!( - error.details.unwrap().validation_code.as_deref(), - Some("COORDINATE_OUT_OF_RANGE") - ); - } - - #[test] - fn stage_four_deleted_element_is_excluded_from_per_owner_comparison() { - let mut store = default_editor_store(); - store.key_positions.get_mut("4key").unwrap()[0].dx = MAX_ABS_COORDINATE + 1.0; - let current = EditorDocumentV1::from_store(&store); - let deleted_id = current.key_positions["4key"][0].id.clone(); - let mut candidate = current.clone(); - candidate.keys.get_mut("4key").unwrap().remove(0); - candidate.key_positions.get_mut("4key").unwrap().remove(0); - let mut candidate_store = store.clone(); - candidate.apply_to_store(&mut candidate_store); - - validate_document_transition(¤t, &candidate, &store, &candidate_store).unwrap(); - assert!(candidate.key_positions["4key"] - .iter() - .all(|position| position.id != deleted_id)); - } - - #[test] - fn stage_four_multi_key_label_allowances_are_consumed_once() { - let mut store = default_editor_store(); - store.keys.get_mut("4key").unwrap()[0] = KeySlot::Multi { - keys: vec![ - "x".repeat(MAX_KEY_LABEL_BYTES + 100), - "y".repeat(MAX_KEY_LABEL_BYTES + 200), - ], - match_mode: crate::models::SlotMatch::Any, - }; - let current = EditorDocumentV1::from_store(&store); - - let mut non_increasing = current.clone(); - non_increasing.keys.get_mut("4key").unwrap()[0] = KeySlot::Multi { - keys: vec![ - "a".repeat(MAX_KEY_LABEL_BYTES + 150), - "b".repeat(MAX_KEY_LABEL_BYTES + 50), - ], - match_mode: crate::models::SlotMatch::Any, - }; - let mut non_increasing_store = store.clone(); - non_increasing.apply_to_store(&mut non_increasing_store); - validate_document_transition(¤t, &non_increasing, &store, &non_increasing_store) - .unwrap(); - - let mut duplicated_allowance = non_increasing.clone(); - let KeySlot::Multi { keys, .. } = - &mut duplicated_allowance.keys.get_mut("4key").unwrap()[0] - else { - unreachable!() - }; - keys.push("c".repeat(MAX_KEY_LABEL_BYTES + 25)); - let mut duplicated_store = store.clone(); - duplicated_allowance.apply_to_store(&mut duplicated_store); - let error = validate_document_transition( - ¤t, - &duplicated_allowance, - &store, - &duplicated_store, - ) - .unwrap_err(); - assert_eq!( - error.details.unwrap().validation_code.as_deref(), - Some("KEY_LABEL_TOO_LONG") - ); - } - - #[test] - fn stage_four_group_name_limit_follows_group_id_after_reorder() { - let mut store = default_editor_store(); - store.layer_groups.insert( - "4key".to_string(), - vec![ - LayerGroupDef { - id: "oversized".to_string(), - name: "x".repeat(MAX_GROUP_NAME_BYTES + 1), - }, - LayerGroupDef { - id: "normal".to_string(), - name: "Normal".to_string(), - }, - ], - ); - let current = EditorDocumentV1::from_store(&store); - let mut reordered = current.clone(); - reordered.layer_groups.get_mut("4key").unwrap().swap(0, 1); - let mut reordered_store = store.clone(); - reordered.apply_to_store(&mut reordered_store); - validate_document_transition(¤t, &reordered, &store, &reordered_store).unwrap(); - - let mut changed_id = reordered; - changed_id.layer_groups.get_mut("4key").unwrap()[1].id = "new-id".to_string(); - let mut changed_id_store = store.clone(); - changed_id.apply_to_store(&mut changed_id_store); - let error = validate_document_transition(¤t, &changed_id, &store, &changed_id_store) - .unwrap_err(); - assert_eq!( - error.details.unwrap().validation_code.as_deref(), - Some("GROUP_NAME_TOO_LONG") - ); - } - - #[test] - fn aggregate_render_limit_compares_total_candidate_and_current_counts() { - let mut store = store_with_custom_modes(8); - for index in 0..8 { - let mode = format!("custom-{index}"); - store - .keys - .insert(mode.clone(), vec![KeySlot::default(); 512]); - store - .key_positions - .insert(mode, vec![KeyPosition::default(); 512]); - } - store.stat_positions.insert( - "custom-0".to_string(), - vec![ - StatPosition { - stat_type: StatType::Kps, - position: KeyPosition::default(), - }; - 2 - ], - ); - let current = EditorDocumentV1::from_store(&store); - - let mut same_total = current.clone(); - same_total.stat_positions.get_mut("custom-0").unwrap().pop(); - same_total.graph_positions.insert( - "custom-0".to_string(), - vec![GraphPosition { - stat_type: GraphStatType::Kps, - graph_type: GraphType::Line, - graph_speed: 100, - graph_color: "#123456".to_string(), - show_avg_line: true, - position: KeyPosition::default(), - }], - ); - let mut same_total_store = store.clone(); - same_total.apply_to_store(&mut same_total_store); - validate_document_transition(¤t, &same_total, &store, &same_total_store).unwrap(); - - let mut increased = same_total.clone(); - increased - .stat_positions - .get_mut("custom-0") - .unwrap() - .push(StatPosition { - stat_type: StatType::Kps, - position: KeyPosition::default(), - }); - let mut increased_store = same_total_store.clone(); - increased.apply_to_store(&mut increased_store); - let error = validate_document_transition(¤t, &increased, &store, &increased_store) - .unwrap_err(); - assert_eq!( - error.details.unwrap().validation_code.as_deref(), - Some("TOO_MANY_RENDER_ITEMS") - ); - } - - #[test] - fn violation_categories_keep_their_existing_grandfathering_decisions() { - let mut mode_store = AppStoreData::default(); - mode_store - .keys - .insert("ghost".to_string(), vec![KeySlot::from("A")]); - mode_store - .key_positions - .insert("ghost".to_string(), vec![KeyPosition::default()]); - let mode_document = EditorDocumentV1::from_store(&mode_store); - validate_document_transition(&mode_document, &mode_document, &mode_store, &mode_store) - .unwrap(); - - let mut pair_store = default_editor_store(); - pair_store.keys.get_mut("4key").unwrap().pop(); - let pair_document = EditorDocumentV1::from_store(&pair_store); - let pair_error = - validate_document_transition(&pair_document, &pair_document, &pair_store, &pair_store) - .unwrap_err(); - assert_eq!( - pair_error.details.unwrap().validation_code.as_deref(), - Some("KEY_POSITION_LENGTH_MISMATCH") - ); - - let mut group_store = default_editor_store(); - group_store.layer_groups.insert( - "4key".to_string(), - vec![LayerGroupDef { - id: String::new(), - name: "Group".to_string(), - }], - ); - let group_document = EditorDocumentV1::from_store(&group_store); - validate_document_transition(&group_document, &group_document, &group_store, &group_store) - .unwrap(); - - let mut element_store = default_editor_store(); - let mut shadow = valid_shadow(); - shadow.blur = MAX_SHADOW_BLUR + 1.0; - element_store.key_positions.get_mut("4key").unwrap()[0].shadow = Some(shadow); - let element_document = EditorDocumentV1::from_store(&element_store); - validate_document_transition( - &element_document, - &element_document, - &element_store, - &element_store, - ) - .unwrap(); - } - - #[test] - fn oversized_per_mode_collection_is_grandfathered_only_when_non_increasing() { - let mut store = store_with_custom_modes(1); - store.keys.insert( - "custom-0".to_string(), - (0..514) - .map(|index| KeySlot::from(format!("Key{index}"))) - .collect(), - ); - store - .key_positions - .insert("custom-0".to_string(), vec![KeyPosition::default(); 514]); - let current = EditorDocumentV1::from_store(&store); - validate_document_transition(¤t, ¤t, &store, &store).unwrap(); - - let mut decreased = current.clone(); - decreased.keys.get_mut("custom-0").unwrap().pop(); - decreased.key_positions.get_mut("custom-0").unwrap().pop(); - let mut decreased_store = store.clone(); - decreased.apply_to_store(&mut decreased_store); - validate_document_transition(¤t, &decreased, &store, &decreased_store).unwrap(); - - let mut increased = current.clone(); - increased - .keys - .get_mut("custom-0") - .unwrap() - .push(KeySlot::from("Extra")); - increased - .key_positions - .get_mut("custom-0") - .unwrap() - .push(KeyPosition::default()); - let mut increased_store = store.clone(); - increased.apply_to_store(&mut increased_store); - assert!( - validate_document_transition(¤t, &increased, &store, &increased_store).is_err() - ); - } - - #[test] - fn oversized_mode_count_is_grandfathered_only_when_non_increasing() { - let mut store = store_with_custom_modes(65); - for index in 0..65 { - store.keys.insert(format!("custom-{index}"), Vec::new()); - store - .key_positions - .insert(format!("custom-{index}"), Vec::new()); - } - let current = EditorDocumentV1::from_store(&store); - validate_document_transition(¤t, ¤t, &store, &store).unwrap(); - - let mut decreased = current.clone(); - decreased.keys.remove("custom-64"); - decreased.key_positions.remove("custom-64"); - let mut decreased_store = store.clone(); - decreased.apply_to_store(&mut decreased_store); - validate_document_transition(¤t, &decreased, &store, &decreased_store).unwrap(); - - let mut increased_store = store.clone(); - increased_store.custom_tabs.push(CustomTab { - id: "custom-65".to_string(), - name: "Custom 65".to_string(), - }); - let mut increased = current.clone(); - increased.keys.insert("custom-65".to_string(), Vec::new()); - increased - .key_positions - .insert("custom-65".to_string(), Vec::new()); - increased.apply_to_store(&mut increased_store); - assert!( - validate_document_transition(¤t, &increased, &store, &increased_store).is_err() - ); - } - - #[test] - fn count_limits_accept_exact_boundaries_and_reject_boundary_plus_one() { - let mode_store = store_with_custom_modes(65); - let empty = EditorDocumentV1::from_store(&mode_store); - let mut modes_at_limit = empty.clone(); - for index in 0..64 { - modes_at_limit - .keys - .insert(format!("custom-{index}"), Vec::new()); - modes_at_limit - .key_positions - .insert(format!("custom-{index}"), Vec::new()); - } - let mut modes_at_limit_store = mode_store.clone(); - modes_at_limit.apply_to_store(&mut modes_at_limit_store); - validate_document_transition(&empty, &modes_at_limit, &mode_store, &modes_at_limit_store) - .unwrap(); - let mut too_many_modes = modes_at_limit.clone(); - too_many_modes - .keys - .insert("custom-64".to_string(), Vec::new()); - too_many_modes - .key_positions - .insert("custom-64".to_string(), Vec::new()); - let mut too_many_modes_store = mode_store.clone(); - too_many_modes.apply_to_store(&mut too_many_modes_store); - assert!(validate_document_transition( - &empty, - &too_many_modes, - &mode_store, - &too_many_modes_store, - ) - .is_err()); - - let collection_store = store_with_custom_modes(1); - let collection_empty = EditorDocumentV1::from_store(&collection_store); - let mut collection_at_limit = collection_empty.clone(); - collection_at_limit.keys.insert( - "custom-0".to_string(), - (0..512) - .map(|index| KeySlot::from(format!("Key{index}"))) - .collect(), - ); - collection_at_limit - .key_positions - .insert("custom-0".to_string(), vec![KeyPosition::default(); 512]); - let mut collection_at_limit_store = collection_store.clone(); - collection_at_limit.apply_to_store(&mut collection_at_limit_store); - validate_document_transition( - &collection_empty, - &collection_at_limit, - &collection_store, - &collection_at_limit_store, - ) - .unwrap(); - let mut collection_too_large = collection_at_limit.clone(); - collection_too_large - .keys - .get_mut("custom-0") - .unwrap() - .push(KeySlot::from("Extra")); - collection_too_large - .key_positions - .get_mut("custom-0") - .unwrap() - .push(KeyPosition::default()); - let mut collection_too_large_store = collection_store.clone(); - collection_too_large.apply_to_store(&mut collection_too_large_store); - assert!(validate_document_transition( - &collection_empty, - &collection_too_large, - &collection_store, - &collection_too_large_store, - ) - .is_err()); - - let render_store = store_with_custom_modes(8); - let render_empty = EditorDocumentV1::from_store(&render_store); - let mut render_at_limit = render_empty.clone(); - for index in 0..8 { - let mode = format!("custom-{index}"); - render_at_limit - .keys - .insert(mode.clone(), vec![KeySlot::default(); 512]); - render_at_limit - .key_positions - .insert(mode, vec![KeyPosition::default(); 512]); - } - let mut render_at_limit_store = render_store.clone(); - render_at_limit.apply_to_store(&mut render_at_limit_store); - validate_document_transition( - &render_empty, - &render_at_limit, - &render_store, - &render_at_limit_store, - ) - .unwrap(); - let mut too_many_render_items = render_at_limit.clone(); - too_many_render_items.stat_positions.insert( - "custom-0".to_string(), - vec![StatPosition { - stat_type: StatType::Kps, - position: KeyPosition::default(), - }], - ); - let mut too_many_render_items_store = render_store.clone(); - too_many_render_items.apply_to_store(&mut too_many_render_items_store); - assert!(validate_document_transition( - &render_empty, - &too_many_render_items, - &render_store, - &too_many_render_items_store, - ) - .is_err()); - - let group_store = store_with_custom_modes(9); - let group_empty = EditorDocumentV1::from_store(&group_store); - let mut groups_at_limit = group_empty.clone(); - for index in 0..8 { - groups_at_limit.layer_groups.insert( - format!("custom-{index}"), - (0..512) - .map(|group| LayerGroupDef { - id: format!("g-{index}-{group}"), - name: "Group".to_string(), - }) - .collect(), - ); - } - let mut groups_at_limit_store = group_store.clone(); - groups_at_limit.apply_to_store(&mut groups_at_limit_store); - validate_document_transition( - &group_empty, - &groups_at_limit, - &group_store, - &groups_at_limit_store, - ) - .unwrap(); - let mut too_many_groups = groups_at_limit.clone(); - too_many_groups.layer_groups.insert( - "custom-8".to_string(), - vec![LayerGroupDef { - id: "extra-group".to_string(), - name: "Group".to_string(), - }], - ); - let mut too_many_groups_store = group_store.clone(); - too_many_groups.apply_to_store(&mut too_many_groups_store); - assert!(validate_document_transition( - &group_empty, - &too_many_groups, - &group_store, - &too_many_groups_store, - ) - .is_err()); - } - - #[test] - fn revision_and_request_id_wire_limits_are_enforced() { - assert!(validate_revision(MAX_SAFE_WIRE_REVISION).is_ok()); - assert!(validate_revision(MAX_SAFE_WIRE_REVISION + 1).is_err()); - assert!(next_revision(MAX_SAFE_WIRE_REVISION).is_err()); - - let invalid = EditorCommitRequest { - base_revision: 0, - mutation_id: "not-a-uuid".to_string(), - multi_key: false, - gesture_id: None, - gesture_ids: Vec::new(), - changes: Some(EditorPatchV1::default()), - ops_version: None, - ops: None, - }; - assert!(validate_request_envelope(&invalid).is_err()); - - let mut oversized_gesture = request(KeyMappings::new()); - oversized_gesture.gesture_id = Some("가".repeat(22)); - assert_eq!(oversized_gesture.gesture_id.as_ref().unwrap().len(), 66); - assert!(validate_request_envelope(&oversized_gesture).is_err()); - - let mut oversized_merged_gesture = request(KeyMappings::new()); - oversized_merged_gesture.gesture_ids = vec!["가".repeat(22)]; - assert!(validate_request_envelope(&oversized_merged_gesture).is_err()); - - let mut malformed_gesture = request(KeyMappings::new()); - malformed_gesture.gesture_ids = vec!["not-a-uuid".to_string()]; - let malformed_error = validate_request_envelope(&malformed_gesture).unwrap_err(); - assert_eq!( - malformed_error.error_code, - crate::errors::EditorCommitErrorCode::InvalidGestureId - ); - assert!(!malformed_error.retryable); - - let mut too_many_gestures = request(KeyMappings::new()); - too_many_gestures.gesture_ids = (0..=MAX_GESTURE_IDS) - .map(|index| Uuid::from_u128(index as u128 + 1).to_string()) - .collect(); - let count_error = validate_request_envelope(&too_many_gestures).unwrap_err(); - assert_eq!( - count_error.error_code, - crate::errors::EditorCommitErrorCode::TooManyGestureIds - ); - assert!(!count_error.retryable); - - let mut representative_overflow = request(KeyMappings::new()); - representative_overflow.gesture_ids = (0..MAX_GESTURE_IDS) - .map(|index| Uuid::from_u128(index as u128 + 1).to_string()) - .collect(); - representative_overflow.gesture_id = Some(Uuid::from_u128(u128::MAX).to_string()); - assert!(validate_request_envelope(&representative_overflow).is_err()); - } - - #[test] - fn oversized_render_and_group_totals_only_allow_non_increasing_changes() { - let mut render_store = store_with_custom_modes(8); - for index in 0..8 { - let mode = format!("custom-{index}"); - render_store - .keys - .insert(mode.clone(), vec![KeySlot::default(); 512]); - render_store - .key_positions - .insert(mode, vec![KeyPosition::default(); 512]); - } - render_store.stat_positions.insert( - "custom-0".to_string(), - vec![ - StatPosition { - stat_type: StatType::Kps, - position: KeyPosition::default(), - }; - 2 - ], - ); - let current_render = EditorDocumentV1::from_store(&render_store); - validate_document_transition( - ¤t_render, - ¤t_render, - &render_store, - &render_store, - ) - .unwrap(); - let mut less_render = current_render.clone(); - less_render - .stat_positions - .get_mut("custom-0") - .unwrap() - .pop(); - let mut less_render_store = render_store.clone(); - less_render.apply_to_store(&mut less_render_store); - validate_document_transition( - ¤t_render, - &less_render, - &render_store, - &less_render_store, - ) - .unwrap(); - let mut more_render = current_render.clone(); - more_render - .stat_positions - .get_mut("custom-0") - .unwrap() - .push(StatPosition { - stat_type: StatType::Kps, - position: KeyPosition::default(), - }); - let mut more_render_store = render_store.clone(); - more_render.apply_to_store(&mut more_render_store); - assert!(validate_document_transition( - ¤t_render, - &more_render, - &render_store, - &more_render_store, - ) - .is_err()); - - let mut group_store = store_with_custom_modes(9); - for index in 0..8 { - group_store.layer_groups.insert( - format!("custom-{index}"), - (0..512) - .map(|group| LayerGroupDef { - id: format!("g-{index}-{group}"), - name: "Group".to_string(), - }) - .collect(), - ); - } - group_store.layer_groups.insert( - "custom-8".to_string(), - vec![ - LayerGroupDef { - id: "g-8-0".to_string(), - name: "Group".to_string(), - }, - LayerGroupDef { - id: "g-8-1".to_string(), - name: "Group".to_string(), - }, - ], - ); - let current_groups = EditorDocumentV1::from_store(&group_store); - validate_document_transition(¤t_groups, ¤t_groups, &group_store, &group_store) - .unwrap(); - let mut less_groups = current_groups.clone(); - less_groups.layer_groups.get_mut("custom-8").unwrap().pop(); - let mut less_group_store = group_store.clone(); - less_groups.apply_to_store(&mut less_group_store); - validate_document_transition( - ¤t_groups, - &less_groups, - &group_store, - &less_group_store, - ) - .unwrap(); - let mut more_groups = current_groups.clone(); - more_groups - .layer_groups - .get_mut("custom-8") - .unwrap() - .push(LayerGroupDef { - id: "g-8-2".to_string(), - name: "Group".to_string(), - }); - let mut more_group_store = group_store.clone(); - more_groups.apply_to_store(&mut more_group_store); - assert!(validate_document_transition( - ¤t_groups, - &more_groups, - &group_store, - &more_group_store, - ) - .is_err()); - } - - #[test] - fn pair_violations_stay_unconditional_but_group_references_follow_element_ids() { - let mut pair_store = default_editor_store(); - pair_store.keys.get_mut("4key").unwrap().pop(); - let pair_document = EditorDocumentV1::from_store(&pair_store); - assert!(validate_document_transition( - &pair_document, - &pair_document, - &pair_store, - &pair_store, - ) - .is_err()); - - let mut reference_store = default_editor_store(); - reference_store.key_positions.get_mut("4key").unwrap()[0].group_id = - Some("missing".to_string()); - let reference_document = EditorDocumentV1::from_store(&reference_store); - let mut reordered_reference = reference_document.clone(); - reordered_reference.keys.get_mut("4key").unwrap().swap(0, 1); - reordered_reference - .key_positions - .get_mut("4key") - .unwrap() - .swap(0, 1); - let mut reordered_store = reference_store.clone(); - reordered_reference.apply_to_store(&mut reordered_store); - assert!(validate_document_transition( - &reference_document, - &reordered_reference, - &reference_store, - &reordered_store, - ) - .is_ok()); - } - - #[test] - fn compact_request_size_boundaries_are_exact() { - fn sized_request(target_bytes: usize) -> EditorCommitRequest { - let mut empty_keys = KeyMappings::new(); - empty_keys.insert("4key".to_string(), vec![KeySlot::default()]); - let empty = request(empty_keys); - let overhead = serde_json::to_vec(&empty).unwrap().len(); - assert!(target_bytes >= overhead); - - let mut keys = KeyMappings::new(); - keys.insert( - "4key".to_string(), - vec![KeySlot::from("x".repeat(target_bytes - overhead))], - ); - let request = EditorCommitRequest { - mutation_id: empty.mutation_id, - ..request(keys) - }; - assert_eq!(serde_json::to_vec(&request).unwrap().len(), target_bytes); - request - } - - assert_eq!( - request_payload_size(&sized_request(REQUEST_WARNING_BYTES - 1)).unwrap(), - REQUEST_WARNING_BYTES - 1 - ); - assert_eq!( - request_payload_size(&sized_request(REQUEST_WARNING_BYTES)).unwrap(), - REQUEST_WARNING_BYTES - ); - assert_eq!( - request_payload_size(&sized_request(MAX_REQUEST_BYTES)).unwrap(), - MAX_REQUEST_BYTES - ); - let error = request_payload_size(&sized_request(MAX_REQUEST_BYTES + 1)).unwrap_err(); - assert_eq!( - error.details.unwrap().validation_code.as_deref(), - Some("REQUEST_TOO_LARGE") - ); - } -} +mod tests; diff --git a/src-tauri/src/state/editor/tests.rs b/src-tauri/src/state/editor/tests.rs new file mode 100644 index 000000000..e14c2f0a0 --- /dev/null +++ b/src-tauri/src/state/editor/tests.rs @@ -0,0 +1,3342 @@ + +use std::collections::HashMap; + +use crate::models::{ + CustomTab, EditorBoundsV1, EditorCommitRequest, EditorDocumentV1, EditorElementGroupTargetV1, + EditorElementPropertyPatchV1, EditorElementTypeV1, EditorFrozenKeySlotV1, EditorGroupUpdateV1, + EditorOpResultStatusV1, EditorOpResultV1, EditorOpV1, EditorPatchV1, EditorTargetGroupV1, + EditorZUpdateV1, ElementShadowSpec, GraphPosition, GraphStatType, GraphType, KeyPosition, + KnobPosition, LayerGroupDef, StatPosition, StatType, +}; + +use super::*; + +fn request(keys: KeyMappings) -> EditorCommitRequest { + EditorCommitRequest { + base_revision: 0, + mutation_id: Uuid::new_v4().to_string(), + multi_key: false, + gesture_id: None, + gesture_ids: Vec::new(), + changes: Some(EditorPatchV1 { + keys: Some(keys), + ..EditorPatchV1::default() + }), + ops_version: None, + ops: None, + } +} + +fn ops_request(ops: Vec) -> EditorCommitRequest { + EditorCommitRequest { + base_revision: 0, + mutation_id: Uuid::new_v4().to_string(), + multi_key: false, + gesture_id: None, + gesture_ids: Vec::new(), + changes: None, + ops_version: Some(EDITOR_OPS_VERSION), + ops: Some(ops), + } +} + +fn set_bounds_op(id: impl Into, element_type: EditorElementTypeV1) -> EditorOpV1 { + EditorOpV1::SetBounds { + element_type, + id: id.into(), + bounds: EditorBoundsV1 { + dx: 10.0, + dy: 20.0, + width: 100.0, + height: 50.0, + }, + } +} + +fn delete_element_op(id: impl Into, element_type: EditorElementTypeV1) -> EditorOpV1 { + EditorOpV1::DeleteElement { + element_type, + id: id.into(), + } +} + +fn patch_hidden_op(id: impl Into, element_type: EditorElementTypeV1) -> EditorOpV1 { + EditorOpV1::PatchElement { + element_type, + id: id.into(), + patch: EditorElementPropertyPatchV1::Hidden(true), + } +} + +fn frozen_insert_op(id: impl Into) -> EditorOpV1 { + EditorOpV1::InsertFrozenElements { + mode: "4key".to_string(), + elements: vec![crate::models::EditorFrozenElementV1::Key { + slot: crate::models::EditorFrozenKeySlotV1::Single("FROZEN".to_string()), + position: KeyPosition { + id: id.into(), + ..KeyPosition::default() + }, + }], + groups: Vec::new(), + z_updates: Vec::new(), + } +} + +fn reorder_op(id: impl Into, complete_mode_order: bool) -> EditorOpV1 { + EditorOpV1::ReorderElements { + mode: "4key".to_string(), + complete_mode_order, + z_updates: vec![EditorZUpdateV1 { + element_type: EditorElementTypeV1::Key, + id: id.into(), + z_index: 7, + }], + group_updates: Vec::new(), + } +} + +fn validation_code(error: &EditorCommitError) -> Option<&str> { + error + .details + .as_ref() + .and_then(|details| details.validation_code.as_deref()) +} + +fn default_editor_store() -> AppStoreData { + let mut store = AppStoreData { + keys: crate::defaults::default_keys().clone(), + key_positions: crate::defaults::default_positions().clone(), + ..AppStoreData::default() + }; + crate::state::native_element_id::backfill_store_element_ids(&mut store); + store +} + +fn store_with_custom_modes(count: usize) -> AppStoreData { + let mut store = AppStoreData::default(); + for index in 0..count { + store.custom_tabs.push(CustomTab { + id: format!("custom-{index}"), + name: format!("Custom {index}"), + }); + } + store +} + +fn store_with_each_position_collection() -> AppStoreData { + let mut store = default_editor_store(); + store.stat_positions.insert( + "4key".to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: KeyPosition::default(), + }], + ); + store.graph_positions.insert( + "4key".to_string(), + vec![GraphPosition { + stat_type: GraphStatType::Kps, + graph_type: GraphType::Line, + graph_speed: 100, + graph_color: "#123456".to_string(), + show_avg_line: true, + position: KeyPosition::default(), + }], + ); + store.knob_positions.insert( + "4key".to_string(), + vec![KnobPosition { + axis_id: String::new(), + sensitivity: 1.0, + reverse: false, + position: KeyPosition::default(), + }], + ); + crate::state::native_element_id::backfill_store_element_ids(&mut store); + store +} + +fn position_mut<'a>(document: &'a mut EditorDocumentV1, collection: &str) -> &'a mut KeyPosition { + match collection { + "keyPositions" => &mut document.key_positions.get_mut("4key").unwrap()[0], + "statPositions" => &mut document.stat_positions.get_mut("4key").unwrap()[0].position, + "graphPositions" => &mut document.graph_positions.get_mut("4key").unwrap()[0].position, + "knobPositions" => &mut document.knob_positions.get_mut("4key").unwrap()[0].position, + _ => unreachable!(), + } +} + +fn valid_shadow() -> ElementShadowSpec { + ElementShadowSpec { + enabled: true, + color: "#123456".to_string(), + offset_x: 0.0, + offset_y: 0.0, + blur: 12.0, + } +} + +#[test] +fn canonical_fingerprint_ignores_hash_map_insertion_order() { + let mut left = HashMap::new(); + left.insert("4key".to_string(), vec![KeySlot::from("A")]); + left.insert("5key".to_string(), vec![KeySlot::from("B")]); + + let mut right = HashMap::new(); + right.insert("5key".to_string(), vec![KeySlot::from("B")]); + right.insert("4key".to_string(), vec![KeySlot::from("A")]); + + assert_eq!( + request_fingerprint(&request(left)).unwrap(), + request_fingerprint(&request(right)).unwrap() + ); +} + +#[test] +fn canonical_fingerprint_includes_multi_key_capability() { + let mut legacy = request(KeyMappings::new()); + let mut capable = legacy.clone(); + capable.multi_key = true; + + assert_ne!( + request_fingerprint(&legacy).unwrap(), + request_fingerprint(&capable).unwrap() + ); + + legacy.multi_key = true; + assert_eq!( + request_fingerprint(&legacy).unwrap(), + request_fingerprint(&capable).unwrap() + ); +} + +#[test] +fn commit_envelope_defaults_multi_key_to_false() { + let request = request(KeyMappings::new()); + let mut wire = serde_json::to_value(request).unwrap(); + wire.as_object_mut().unwrap().remove("multiKey"); + + let decoded: EditorCommitRequest = serde_json::from_value(wire).unwrap(); + + assert!(!decoded.multi_key); + let mut capable = decoded; + capable.multi_key = true; + let encoded = serde_json::to_value(capable).unwrap(); + assert_eq!(encoded["multiKey"], true); + assert!(encoded.get("opsVersion").is_none()); + assert!(encoded.get("ops").is_none()); +} + +#[test] +fn editor_commit_wire_requires_exactly_one_mutation_shape() { + let base = serde_json::json!({ + "baseRevision": 0, + "mutationId": Uuid::new_v4().to_string(), + }); + let changes = serde_json::json!({ "schemaVersion": EDITOR_SCHEMA_VERSION }); + let op = serde_json::json!({ + "kind": "setBounds", + "elementType": "key", + "id": Uuid::new_v4().to_string(), + "bounds": { "dx": 0.0, "dy": 0.0, "width": 1.0, "height": 1.0 }, + }); + + let mut patch_wire = base.clone(); + patch_wire["changes"] = changes.clone(); + let patch = decode_editor_commit_request(patch_wire).unwrap(); + assert!(patch.changes.is_some()); + assert!(patch.ops.is_none()); + + let mut ops_wire = base.clone(); + ops_wire["opsVersion"] = serde_json::json!(EDITOR_OPS_VERSION); + ops_wire["ops"] = serde_json::json!([op.clone()]); + let ops = decode_editor_commit_request(ops_wire).unwrap(); + assert!(ops.changes.is_none()); + assert_eq!(ops.ops.unwrap().len(), 1); + + let encoded = serde_json::to_value(ops_request(vec![set_bounds_op( + Uuid::new_v4().to_string(), + EditorElementTypeV1::Key, + )])) + .unwrap(); + assert_eq!(encoded["ops"][0]["kind"], "setBounds"); + assert_eq!(encoded["ops"][0]["elementType"], "key"); + assert!(encoded.get("changes").is_none()); + + let delete_id = Uuid::new_v4().to_string(); + let delete_wire = serde_json::json!({ + "baseRevision": 0, + "mutationId": Uuid::new_v4().to_string(), + "opsVersion": EDITOR_OPS_VERSION, + "ops": [{ + "kind": "deleteElement", + "elementType": "graph", + "id": delete_id, + }], + }); + let delete = decode_editor_commit_request(delete_wire).unwrap(); + assert_eq!( + delete.ops, + Some(vec![delete_element_op( + delete_id, + EditorElementTypeV1::Graph, + )]) + ); + let encoded_delete = serde_json::to_value(delete).unwrap(); + assert_eq!(encoded_delete["ops"][0]["kind"], "deleteElement"); + assert_eq!(encoded_delete["ops"][0]["elementType"], "graph"); + assert!(encoded_delete["ops"][0].get("bounds").is_none()); + + let mut both = base.clone(); + both["changes"] = changes; + both["opsVersion"] = serde_json::json!(EDITOR_OPS_VERSION); + both["ops"] = serde_json::json!([op.clone()]); + let error = decode_editor_commit_request(both).unwrap_err(); + assert_eq!(validation_code(&error), Some("INVALID_EDITOR_MUTATION")); + + for wire in [ + base.clone(), + serde_json::json!({ + "baseRevision": 0, + "mutationId": Uuid::new_v4().to_string(), + "opsVersion": EDITOR_OPS_VERSION, + }), + serde_json::json!({ + "baseRevision": 0, + "mutationId": Uuid::new_v4().to_string(), + "opsVersion": EDITOR_OPS_VERSION, + "ops": null, + }), + serde_json::json!({ + "baseRevision": 0, + "mutationId": Uuid::new_v4().to_string(), + "changes": null, + "opsVersion": EDITOR_OPS_VERSION, + "ops": [op], + }), + ] { + let error = decode_editor_commit_request(wire).unwrap_err(); + assert_eq!(validation_code(&error), Some("INVALID_EDITOR_MUTATION")); + } +} + +#[test] +fn editor_commit_wire_rejects_unknown_keys_at_each_new_boundary() { + let valid_id = Uuid::new_v4().to_string(); + let valid = serde_json::json!({ + "baseRevision": 0, + "mutationId": Uuid::new_v4().to_string(), + "opsVersion": EDITOR_OPS_VERSION, + "ops": [{ + "kind": "setBounds", + "elementType": "key", + "id": valid_id, + "bounds": { "dx": 0.0, "dy": 0.0, "width": 1.0, "height": 1.0 }, + }], + }); + + let mut unknown_top_level = valid.clone(); + unknown_top_level["mode"] = serde_json::json!("4key"); + let mut unknown_op_key = valid.clone(); + unknown_op_key["ops"][0]["mode"] = serde_json::json!("4key"); + let mut unknown_bounds_key = valid.clone(); + unknown_bounds_key["ops"][0]["bounds"]["x"] = serde_json::json!(0); + + for wire in [unknown_top_level, unknown_op_key, unknown_bounds_key] { + let error = decode_editor_commit_request(wire).unwrap_err(); + assert_eq!(validation_code(&error), Some("INVALID_REQUEST_PAYLOAD")); + } + + for (field, value) in [ + ("kind", serde_json::json!("move")), + ("elementType", serde_json::json!("Key")), + ] { + let mut wire = valid.clone(); + wire["ops"][0][field] = value; + let error = decode_editor_commit_request(wire).unwrap_err(); + assert_eq!(validation_code(&error), Some("INVALID_REQUEST_PAYLOAD")); + } + + let delete_with_bounds = serde_json::json!({ + "baseRevision": 0, + "mutationId": Uuid::new_v4().to_string(), + "opsVersion": EDITOR_OPS_VERSION, + "ops": [{ + "kind": "deleteElement", + "elementType": "key", + "id": Uuid::new_v4().to_string(), + "bounds": { "dx": 0.0, "dy": 0.0, "width": 1.0, "height": 1.0 }, + }], + }); + let error = decode_editor_commit_request(delete_with_bounds).unwrap_err(); + assert_eq!(validation_code(&error), Some("INVALID_REQUEST_PAYLOAD")); +} + +#[test] +fn frozen_insert_wire_accepts_explicit_null_for_skipped_optional_fields() { + let frozen = |counter: serde_json::Value| { + serde_json::json!({ + "baseRevision": 0, + "mutationId": Uuid::new_v4().to_string(), + "opsVersion": EDITOR_OPS_VERSION, + "ops": [{ + "kind": "insertFrozenElements", + "mode": "4key", + "elements": [{ + "elementType": "key", + "slot": "A", + "position": { + "id": Uuid::new_v4().to_string(), + "dx": 0.0, "dy": 0.0, "width": 60.0, "height": 60.0, "count": 0, + "counter": counter, + }, + }], + "groups": [], + "zUpdates": [], + }], + }) + }; + + // 프론트 정규화는 미지정 그라데이션을 null로 보낸다 - None은 재직렬화에서 + // 생략되지만 같은 뜻이므로 통과해야 한다 + decode_editor_commit_request(frozen(serde_json::json!({ + "enabled": true, + "fillIdleGradient": null, + "fillActiveGradient": null, + }))) + .unwrap(); + + // 빈 배열로 명시한 생략 필드(gestureIds)도 같은 규칙 + let mut with_empty_gesture_ids = frozen(serde_json::json!({ "enabled": true })); + with_empty_gesture_ids["gestureIds"] = serde_json::json!([]); + decode_editor_commit_request(with_empty_gesture_ids).unwrap(); + + // 값이 실린 미지의 키는 여전히 거절 + let error = decode_editor_commit_request(frozen(serde_json::json!({ + "enabled": true, + "fillActiveGradient": null, + "fillHoverGradient": { "angle": 0, "stops": [] }, + }))) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("INVALID_REQUEST_PAYLOAD")); + assert!(error.message.contains("fillHoverGradient")); +} + +#[test] +fn property_and_key_slot_wires_are_exact_and_canonical() { + let property = serde_json::to_value(ops_request(vec![patch_hidden_op( + Uuid::new_v4().to_string(), + EditorElementTypeV1::Graph, + )])) + .unwrap(); + assert_eq!(property["ops"][0]["kind"], "patchElement"); + assert_eq!( + property["ops"][0]["patch"], + serde_json::json!({ "property": "hidden", "value": true }) + ); + decode_editor_commit_request(property.clone()).unwrap(); + + let layer_name = serde_json::to_value(ops_request(vec![EditorOpV1::PatchElement { + element_type: EditorElementTypeV1::Knob, + id: Uuid::new_v4().to_string(), + patch: EditorElementPropertyPatchV1::LayerName(None), + }])) + .unwrap(); + assert_eq!( + layer_name["ops"][0]["patch"], + serde_json::json!({ "property": "layerName", "value": null }) + ); + decode_editor_commit_request(layer_name.clone()).unwrap(); + + let graph_type = serde_json::to_value(ops_request(vec![EditorOpV1::PatchElement { + element_type: EditorElementTypeV1::Graph, + id: Uuid::new_v4().to_string(), + patch: EditorElementPropertyPatchV1::GraphType(GraphType::Bar), + }])) + .unwrap(); + assert_eq!( + graph_type["ops"][0]["patch"], + serde_json::json!({ "property": "graphType", "value": "bar" }) + ); + decode_editor_commit_request(graph_type.clone()).unwrap(); + + let graph_color = serde_json::to_value(ops_request(vec![EditorOpV1::PatchElement { + element_type: EditorElementTypeV1::Graph, + id: Uuid::new_v4().to_string(), + patch: EditorElementPropertyPatchV1::GraphColor("not-normalized".to_string()), + }])) + .unwrap(); + assert_eq!( + graph_color["ops"][0]["patch"], + serde_json::json!({ "property": "graphColor", "value": "not-normalized" }) + ); + decode_editor_commit_request(graph_color.clone()).unwrap(); + + let paint = serde_json::to_value(ops_request(vec![EditorOpV1::PatchElement { + element_type: EditorElementTypeV1::Key, + id: Uuid::new_v4().to_string(), + patch: EditorElementPropertyPatchV1::BackgroundPaint( + crate::models::EditorPaintDescriptorV1 { + color: "first".to_string(), + gradient: Some(crate::models::EditorPaintGradientV1 { + angle: 45.0, + stops: vec![ + crate::models::EditorPaintGradientStopV1 { + color: "first".to_string(), + pos: 0.0, + }, + crate::models::EditorPaintGradientStopV1 { + color: "last".to_string(), + pos: 1.0, + }, + ], + }), + }, + ), + }])) + .unwrap(); + assert_eq!( + paint["ops"][0]["patch"], + serde_json::json!({ "property": "backgroundPaint", "value": { "color": "first", "gradient": { "angle": 45.0, "stops": [ { "color": "first", "pos": 0.0 }, { "color": "last", "pos": 1.0 } ] } } }) + ); + decode_editor_commit_request(paint.clone()).unwrap(); + + let literal_properties = [ + ( + EditorElementTypeV1::Graph, + EditorElementPropertyPatchV1::ShowAvgLine(false), + serde_json::json!({ "property": "showAvgLine", "value": false }), + ), + ( + EditorElementTypeV1::Graph, + EditorElementPropertyPatchV1::GraphAnimationEnabled(true), + serde_json::json!({ "property": "graphAnimationEnabled", "value": true }), + ), + ( + EditorElementTypeV1::Graph, + EditorElementPropertyPatchV1::GraphSpeed(u32::MAX), + serde_json::json!({ "property": "graphSpeed", "value": u32::MAX }), + ), + ( + EditorElementTypeV1::Knob, + EditorElementPropertyPatchV1::Reverse(true), + serde_json::json!({ "property": "reverse", "value": true }), + ), + ( + EditorElementTypeV1::Knob, + EditorElementPropertyPatchV1::Sensitivity(-7.25), + serde_json::json!({ "property": "sensitivity", "value": -7.25 }), + ), + ( + EditorElementTypeV1::Knob, + EditorElementPropertyPatchV1::AxisId(" HIDA:raw ".to_string()), + serde_json::json!({ "property": "axisId", "value": " HIDA:raw " }), + ), + ( + EditorElementTypeV1::Stat, + EditorElementPropertyPatchV1::UseInlineStyles(false), + serde_json::json!({ "property": "useInlineStyles", "value": false }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::FontWeight(u32::MAX), + serde_json::json!({ "property": "fontWeight", "value": u32::MAX }), + ), + ( + EditorElementTypeV1::Stat, + EditorElementPropertyPatchV1::FontItalic(false), + serde_json::json!({ "property": "fontItalic", "value": false }), + ), + ( + EditorElementTypeV1::Graph, + EditorElementPropertyPatchV1::FontUnderline(true), + serde_json::json!({ "property": "fontUnderline", "value": true }), + ), + ( + EditorElementTypeV1::Knob, + EditorElementPropertyPatchV1::FontStrikethrough(false), + serde_json::json!({ "property": "fontStrikethrough", "value": false }), + ), + ( + EditorElementTypeV1::Graph, + EditorElementPropertyPatchV1::FontFamily(" raw-font ".to_string()), + serde_json::json!({ "property": "fontFamily", "value": " raw-font " }), + ), + ( + EditorElementTypeV1::Knob, + EditorElementPropertyPatchV1::DisplayText(" raw display ".to_string()), + serde_json::json!({ "property": "displayText", "value": " raw display " }), + ), + ( + EditorElementTypeV1::Graph, + EditorElementPropertyPatchV1::ClassName(" raw class ".to_string()), + serde_json::json!({ "property": "className", "value": " raw class " }), + ), + ( + EditorElementTypeV1::Stat, + EditorElementPropertyPatchV1::FontPaint(crate::models::EditorPaintDescriptorV1 { + color: " raw font color ".to_string(), + gradient: None, + }), + serde_json::json!({ "property": "fontPaint", "value": { "color": " raw font color ", "gradient": null } }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::ActiveFontPaint(crate::models::EditorPaintDescriptorV1 { + color: String::new(), + gradient: None, + }), + serde_json::json!({ "property": "activeFontPaint", "value": { "color": "", "gradient": null } }), + ), + ( + EditorElementTypeV1::Stat, + EditorElementPropertyPatchV1::Shadow(crate::models::EditorShadowLeafPatchV1::OffsetX( + -12.5, + )), + serde_json::json!({ "property": "shadow", "value": { "leaf": "offsetX", "value": -12.5 } }), + ), + ( + EditorElementTypeV1::Knob, + EditorElementPropertyPatchV1::ActiveShadow( + crate::models::EditorShadowLeafPatchV1::Color(" raw-shadow ".to_string()), + ), + serde_json::json!({ "property": "activeShadow", "value": { "leaf": "color", "value": " raw-shadow " } }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::ShadowEnabled(false), + serde_json::json!({ "property": "shadowEnabled", "value": false }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::BorderWidth(0.5), + serde_json::json!({ "property": "borderWidth", "value": 0.5 }), + ), + ( + EditorElementTypeV1::Knob, + EditorElementPropertyPatchV1::BorderRadius(999.0), + serde_json::json!({ "property": "borderRadius", "value": 999.0 }), + ), + ( + EditorElementTypeV1::Stat, + EditorElementPropertyPatchV1::FontSize(8.5), + serde_json::json!({ "property": "fontSize", "value": 8.5 }), + ), + ( + EditorElementTypeV1::Knob, + EditorElementPropertyPatchV1::InactiveImage(" raw/path.png ".to_string()), + serde_json::json!({ "property": "inactiveImage", "value": " raw/path.png " }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::ActiveImage(" raw/active.png ".to_string()), + serde_json::json!({ "property": "activeImage", "value": " raw/active.png " }), + ), + ( + EditorElementTypeV1::Graph, + EditorElementPropertyPatchV1::IdleTransparent(true), + serde_json::json!({ "property": "idleTransparent", "value": true }), + ), + ( + EditorElementTypeV1::Knob, + EditorElementPropertyPatchV1::ActiveTransparent(false), + serde_json::json!({ "property": "activeTransparent", "value": false }), + ), + ( + EditorElementTypeV1::Graph, + EditorElementPropertyPatchV1::IdleImageFit(crate::models::ImageFit::Contain), + serde_json::json!({ "property": "idleImageFit", "value": "contain" }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::ActiveImageFit(crate::models::ImageFit::None), + serde_json::json!({ "property": "activeImageFit", "value": "none" }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::SoundPath(" raw/sound.wav ".to_string()), + serde_json::json!({ "property": "soundPath", "value": " raw/sound.wav " }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::SoundEnabled(false), + serde_json::json!({ "property": "soundEnabled", "value": false }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::SoundVolume(137.5), + serde_json::json!({ "property": "soundVolume", "value": 137.5 }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::CounterEnabled(false), + serde_json::json!({ "property": "counterEnabled", "value": false }), + ), + ( + EditorElementTypeV1::Stat, + EditorElementPropertyPatchV1::CounterAnimationEnabled(true), + serde_json::json!({ "property": "counterAnimationEnabled", "value": true }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::CounterPlacement( + crate::models::KeyCounterPlacement::Outside, + ), + serde_json::json!({ "property": "counterPlacement", "value": "outside" }), + ), + ( + EditorElementTypeV1::Stat, + EditorElementPropertyPatchV1::CounterAlign(crate::models::KeyCounterAlign::Left), + serde_json::json!({ "property": "counterAlign", "value": "left" }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::CounterAlignMode( + crate::models::KeyCounterAlignMode::Between, + ), + serde_json::json!({ "property": "counterAlignMode", "value": "between" }), + ), + ( + EditorElementTypeV1::Stat, + EditorElementPropertyPatchV1::CounterGap(u32::MAX), + serde_json::json!({ "property": "counterGap", "value": u32::MAX }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::CounterFontSize(72), + serde_json::json!({ "property": "counterFontSize", "value": 72 }), + ), + ( + EditorElementTypeV1::Stat, + EditorElementPropertyPatchV1::CounterFontWeight(900), + serde_json::json!({ "property": "counterFontWeight", "value": 900 }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::CounterFontItalic(true), + serde_json::json!({ "property": "counterFontItalic", "value": true }), + ), + ( + EditorElementTypeV1::Stat, + EditorElementPropertyPatchV1::CounterFontUnderline(true), + serde_json::json!({ "property": "counterFontUnderline", "value": true }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::CounterFontStrikethrough(true), + serde_json::json!({ "property": "counterFontStrikethrough", "value": true }), + ), + ( + EditorElementTypeV1::Stat, + EditorElementPropertyPatchV1::CounterFontFamily(" raw-counter-font ".to_string()), + serde_json::json!({ "property": "counterFontFamily", "value": " raw-counter-font " }), + ), + ( + EditorElementTypeV1::Stat, + EditorElementPropertyPatchV1::CounterFillIdle( + crate::models::EditorCounterFillIntentV1::Solid( + crate::models::EditorCounterFillSolidIntentV1 { + color: " raw solid ".to_string(), + }, + ), + ), + serde_json::json!({ "property": "counterFillIdle", "value": { "color": " raw solid " } }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::CounterFillActive( + crate::models::EditorCounterFillIntentV1::Gradient( + crate::models::EditorCounterFillGradientIntentV1 { + color: "rgba(1,2,3,0.5)".to_string(), + gradient: crate::models::EditorPaintGradientV1 { + angle: 90.0, + stops: vec![ + crate::models::EditorPaintGradientStopV1 { + color: "rgba(1, 2, 3, 0.5)".to_string(), + pos: 0.0, + }, + crate::models::EditorPaintGradientStopV1 { + color: "transparent".to_string(), + pos: 1.0, + }, + ], + }, + }, + ), + ), + serde_json::json!({ "property": "counterFillActive", "value": { "color": "rgba(1,2,3,0.5)", "gradient": { "angle": 90.0, "stops": [ { "color": "rgba(1, 2, 3, 0.5)", "pos": 0.0 }, { "color": "transparent", "pos": 1.0 } ] } } }), + ), + ( + EditorElementTypeV1::Stat, + EditorElementPropertyPatchV1::CounterAnimationPreset( + crate::models::EditorCounterAnimationPresetIntentV1 { + preset_id: "user-motion".to_string(), + apply_preset_id: Some(true), + bezier: Some([0.1, 0.2, 0.8, 0.9]), + scale: Some(1.25), + duration_ms: Some(420), + }, + ), + serde_json::json!({ "property": "counterAnimationPreset", "value": { "presetId": "user-motion", "applyPresetId": true, "bezier": [0.1, 0.2, 0.8, 0.9], "scale": 1.25, "durationMs": 420 } }), + ), + ( + EditorElementTypeV1::Stat, + EditorElementPropertyPatchV1::StatType(StatType::Total), + serde_json::json!({ "property": "statType", "value": "total" }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::NoteEffectEnabled(false), + serde_json::json!({ "property": "noteEffectEnabled", "value": false }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::NoteGlowEnabled(true), + serde_json::json!({ "property": "noteGlowEnabled", "value": true }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::NoteGlowSyncPaint(true), + serde_json::json!({ "property": "noteGlowSyncPaint", "value": true }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::NoteGlowSize(20.5), + serde_json::json!({ "property": "noteGlowSize", "value": 20.5 }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::NotePaint(crate::models::EditorNotePaintIntentV1::Color( + crate::models::EditorNotePaintColorIntentV1 { + color: crate::models::EditorNoteColorV1::Gradient( + crate::models::EditorNoteGradientColorV1 { + kind: crate::models::EditorNoteGradientColorKindV1::Gradient, + top: "top".to_string(), + bottom: "bottom".to_string(), + }, + ), + }, + )), + serde_json::json!({ "property": "notePaint", "value": { "color": { "type": "gradient", "top": "top", "bottom": "bottom" } } }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::NoteGlowPaint( + crate::models::EditorNotePaintIntentV1::GradientOpacity( + crate::models::EditorNotePaintGradientOpacityIntentV1 { + opacity: 70, + opacity_top: 10, + opacity_bottom: 90, + }, + ), + ), + serde_json::json!({ "property": "noteGlowPaint", "value": { "opacity": 70, "opacityTop": 10, "opacityBottom": 90 } }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::NoteBorderPaint(crate::models::EditorNoteBorderPaintV1 { + color: "#A1b2C3".to_string(), + opacity: 55, + gradient: None, + }), + serde_json::json!({ "property": "noteBorderPaint", "value": { "color": "#A1b2C3", "opacity": 55 } }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::NoteOffsetX(None), + serde_json::json!({ "property": "noteOffsetX", "value": null }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::NoteOffsetY(Some(-12.5)), + serde_json::json!({ "property": "noteOffsetY", "value": -12.5 }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::NoteWidth(None), + serde_json::json!({ "property": "noteWidth", "value": null }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::NoteBorderWidth(0.0), + serde_json::json!({ "property": "noteBorderWidth", "value": 0.0 }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::NoteBorderRadius(4.5), + serde_json::json!({ "property": "noteBorderRadius", "value": 4.5 }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::NoteAutoYCorrection(false), + serde_json::json!({ "property": "noteAutoYCorrection", "value": false }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::NoteAlignment(crate::models::NoteAlignment::Right), + serde_json::json!({ "property": "noteAlignment", "value": "right" }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::NoteBorderSide( + crate::models::EditorNoteBorderSideV1::All, + ), + serde_json::json!({ "property": "noteBorderSide", "value": "all" }), + ), + ]; + for (element_type, patch, expected) in literal_properties { + let wire = serde_json::to_value(ops_request(vec![EditorOpV1::PatchElement { + element_type, + id: Uuid::new_v4().to_string(), + patch, + }])) + .unwrap(); + assert_eq!(wire["ops"][0]["patch"], expected); + decode_editor_commit_request(wire).unwrap(); + } + + let mut unknown_property = property.clone(); + unknown_property["ops"][0]["patch"]["zIndex"] = serde_json::json!(3); + let mut unknown_op = property; + unknown_op["ops"][0]["mode"] = serde_json::json!("4key"); + let mut missing_property = layer_name.clone(); + missing_property["ops"][0]["patch"] = serde_json::json!({}); + let mut multiple_properties = layer_name; + multiple_properties["ops"][0]["patch"]["hidden"] = serde_json::json!(true); + let mut invalid_graph_type = graph_type; + invalid_graph_type["ops"][0]["patch"]["graphType"] = serde_json::json!("area"); + let mut invalid_graph_color = graph_color.clone(); + invalid_graph_color["ops"][0]["patch"]["graphColor"] = serde_json::json!(42); + let invalid_literal_properties = [ + serde_json::json!({ "property": "showAvgLine", "value": 1 }), + serde_json::json!({ "property": "graphAnimationEnabled", "value": null }), + serde_json::json!({ "property": "graphSpeed", "value": -1 }), + serde_json::json!({ "property": "graphSpeed", "value": 1.5 }), + serde_json::json!({ "property": "reverse", "value": "true" }), + serde_json::json!({ "property": "sensitivity", "value": "1" }), + serde_json::json!({ "property": "axisId", "value": false }), + serde_json::json!({ "axisId": "axis", "hidden": true }), + serde_json::json!({ "axisId": "axis", "unexpected": true }), + serde_json::json!({ "property": "useInlineStyles", "value": null }), + serde_json::json!({ "property": "fontWeight", "value": -1 }), + serde_json::json!({ "property": "fontWeight", "value": 1.5 }), + serde_json::json!({ "property": "fontItalic", "value": null }), + serde_json::json!({ "property": "fontUnderline", "value": 1 }), + serde_json::json!({ "property": "fontStrikethrough", "value": "false" }), + serde_json::json!({ "property": "fontFamily", "value": null }), + serde_json::json!({ "property": "displayText", "value": null }), + serde_json::json!({ "property": "displayText", "value": 1 }), + serde_json::json!({ "displayText": "text", "hidden": true }), + serde_json::json!({ "displayText": "text", "unexpected": true }), + serde_json::json!({ "property": "className", "value": null }), + serde_json::json!({ "property": "className", "value": 1 }), + serde_json::json!({ "className": "class", "hidden": true }), + serde_json::json!({ "className": "class", "unexpected": true }), + serde_json::json!({ "property": "fontPaint", "value": null }), + serde_json::json!({ "property": "fontPaint", "value": { "color": "idle" } }), + serde_json::json!({ "property": "activeFontPaint", "value": false }), + serde_json::json!({ "fontPaint": { "color": "idle", "gradient": null }, "activeFontPaint": { "color": "active", "gradient": null } }), + serde_json::json!({ "property": "activeFontPaint", "value": { "color": "active", "gradient": null, "unexpected": true } }), + serde_json::json!({ "property": "shadow", "value": {} }), + serde_json::json!({ "property": "shadow", "value": { "offsetX": 1, "blur": 2 } }), + serde_json::json!({ "property": "shadow", "value": { "color": "shadow", "unexpected": true } }), + serde_json::json!({ "property": "activeShadow", "value": null }), + serde_json::json!({ "property": "activeShadow", "value": { "leaf": "offsetY", "value": "1" } }), + serde_json::json!({ "property": "shadowEnabled", "value": "false" }), + serde_json::json!({ "shadow": { "blur": 1 }, "shadowEnabled": true }), + serde_json::json!({ "property": "backgroundPaint", "value": {} }), + serde_json::json!({ "property": "backgroundPaint", "value": { "color": "solid" } }), + serde_json::json!({ "property": "backgroundPaint", "value": { "color": "solid", "gradient": null, "unexpected": true } }), + serde_json::json!({ "property": "backgroundPaint", "value": { "color": "first", "gradient": { "stops": [{ "color": "first", "pos": 0 }, { "color": "last", "pos": 1 }] } } }), + serde_json::json!({ "property": "backgroundPaint", "value": { "color": "first", "gradient": { "angle": 45 } } }), + serde_json::json!({ "property": "backgroundPaint", "value": { "color": "first", "gradient": { "angle": 45, "stops": [{ "color": "first", "pos": 0 }, { "color": "last", "pos": 1 }], "unexpected": true } } }), + serde_json::json!({ "property": "backgroundPaint", "value": { "color": "first", "gradient": { "angle": 45, "stops": [{ "color": "first", "pos": 0, "unexpected": true }, { "color": "last", "pos": 1 }] } } }), + serde_json::json!({ "backgroundPaint": { "color": "first", "gradient": null }, "borderPaint": { "color": "border", "gradient": null } }), + serde_json::json!({ "property": "borderWidth", "value": null }), + serde_json::json!({ "property": "borderWidth", "value": "1" }), + serde_json::json!({ "borderWidth": 1, "fontSize": 14 }), + serde_json::json!({ "borderWidth": 1, "unexpected": true }), + serde_json::json!({ "property": "borderRadius", "value": null }), + serde_json::json!({ "property": "borderRadius", "value": "1" }), + serde_json::json!({ "borderRadius": 1, "hidden": false }), + serde_json::json!({ "property": "fontSize", "value": null }), + serde_json::json!({ "property": "fontSize", "value": "14" }), + serde_json::json!({ "fontSize": 14, "unexpected": true }), + serde_json::json!({ "property": "inactiveImage", "value": null }), + serde_json::json!({ "inactiveImage": "path", "hidden": false }), + serde_json::json!({ "inactiveImage": "path", "unexpected": true }), + serde_json::json!({ "property": "activeImage", "value": null }), + serde_json::json!({ "activeImage": "path", "hidden": false }), + serde_json::json!({ "activeImage": "path", "unexpected": true }), + serde_json::json!({ "property": "idleTransparent", "value": null }), + serde_json::json!({ "property": "idleTransparent", "value": "true" }), + serde_json::json!({ "idleTransparent": true, "activeTransparent": false }), + serde_json::json!({ "property": "activeTransparent", "value": 1 }), + serde_json::json!({ "activeTransparent": false, "unexpected": true }), + serde_json::json!({ "property": "idleImageFit", "value": "stretch" }), + serde_json::json!({ "property": "idleImageFit", "value": null }), + serde_json::json!({ "idleImageFit": "cover", "activeImageFit": "contain" }), + serde_json::json!({ "property": "activeImageFit", "value": 1 }), + serde_json::json!({ "activeImageFit": "fill", "unexpected": true }), + serde_json::json!({ "property": "soundPath", "value": null }), + serde_json::json!({ "property": "soundPath", "value": 1 }), + serde_json::json!({ "soundPath": "path", "soundEnabled": true }), + serde_json::json!({ "soundPath": "path", "unexpected": true }), + serde_json::json!({ "property": "soundEnabled", "value": null }), + serde_json::json!({ "property": "soundEnabled", "value": "true" }), + serde_json::json!({ "soundEnabled": true, "unexpected": true }), + serde_json::json!({ "property": "soundVolume", "value": null }), + serde_json::json!({ "property": "soundVolume", "value": "100" }), + serde_json::json!({ "soundVolume": 100, "soundEnabled": true }), + serde_json::json!({ "soundVolume": 100, "unexpected": true }), + serde_json::json!({ "property": "counterEnabled", "value": null }), + serde_json::json!({ "property": "counterAnimationEnabled", "value": "true" }), + serde_json::json!({ "counterEnabled": true, "counterAnimationEnabled": false }), + serde_json::json!({ "property": "counterPlacement", "value": "center" }), + serde_json::json!({ "property": "counterAlign", "value": "center" }), + serde_json::json!({ "property": "counterAlignMode", "value": "outside" }), + serde_json::json!({ "property": "counterGap", "value": -1 }), + serde_json::json!({ "property": "counterGap", "value": 1.5 }), + serde_json::json!({ "property": "counterGap", "value": 4_294_967_296_u64 }), + serde_json::json!({ "counterPlacement": "inside", "counterAlign": "top" }), + serde_json::json!({ "property": "counterFontSize", "value": -1 }), + serde_json::json!({ "property": "counterFontSize", "value": 16.5 }), + serde_json::json!({ "property": "counterFontWeight", "value": "400" }), + serde_json::json!({ "property": "counterFontItalic", "value": null }), + serde_json::json!({ "property": "counterFontUnderline", "value": 1 }), + serde_json::json!({ "property": "counterFontStrikethrough", "value": "false" }), + serde_json::json!({ "counterFontSize": 16, "counterFontWeight": 400 }), + serde_json::json!({ "counterFontSize": 16, "unexpected": true }), + serde_json::json!({ "property": "counterFontFamily", "value": null }), + serde_json::json!({ "property": "counterFontFamily", "value": 1 }), + serde_json::json!({ "counterFontFamily": "font", "counterFontItalic": true }), + serde_json::json!({ "counterFontFamily": "font", "unexpected": true }), + serde_json::json!({ "property": "counterFillIdle", "value": {} }), + serde_json::json!({ "property": "counterFillIdle", "value": { "color": null } }), + serde_json::json!({ "property": "counterFillIdle", "value": { "color": "solid", "gradient": null } }), + serde_json::json!({ "property": "counterFillActive", "value": { "color": "first", "gradient": { "stops": [{ "color": "first", "pos": 0 }, { "color": "last", "pos": 1 }] } } }), + serde_json::json!({ "property": "counterFillActive", "value": { "color": "first", "gradient": { "angle": 45, "stops": [{ "color": "first", "pos": 0 }, { "color": "last", "pos": 1 }], "unexpected": true } } }), + serde_json::json!({ "property": "counterFillIdle", "value": { "color": "solid", "unexpected": true } }), + serde_json::json!({ "counterFillIdle": { "color": "idle" }, "counterFillActive": { "color": "active" } }), + serde_json::json!({ "property": "counterAnimationPreset", "value": null }), + serde_json::json!({ "property": "counterAnimationPreset", "value": {} }), + serde_json::json!({ "property": "counterAnimationPreset", "value": { "presetId": "preset", "enabled": true } }), + serde_json::json!({ "property": "counterAnimationPreset", "value": { "presetId": "preset", "applyPresetId": false } }), + serde_json::json!({ "property": "counterAnimationPreset", "value": { "presetId": "preset", "bezier": [0.1, 0.2, 0.8] } }), + serde_json::json!({ "property": "counterAnimationPreset", "value": { "presetId": "preset", "durationMs": 1.5 } }), + serde_json::json!({ "counterAnimationPreset": { "presetId": "preset" }, "hidden": true }), + serde_json::json!({ "property": "statType", "value": "invalid" }), + serde_json::json!({ "property": "noteEffectEnabled", "value": 1 }), + serde_json::json!({ "property": "noteGlowEnabled", "value": null }), + serde_json::json!({ "property": "noteGlowSyncPaint", "value": null }), + serde_json::json!({ "property": "noteGlowSize", "value": null }), + serde_json::json!({ "property": "noteGlowSize", "value": "20" }), + serde_json::json!({ "noteGlowSize": 20, "noteGlowEnabled": true }), + serde_json::json!({ "noteGlowSize": 20, "unexpected": true }), + serde_json::json!({ "property": "notePaint", "value": {} }), + serde_json::json!({ "property": "notePaint", "value": { "color": { "top": "a", "bottom": "b" } } }), + serde_json::json!({ "property": "notePaint", "value": { "color": { "type": "gradient", "top": "a", "bottom": "b", "unexpected": true } } }), + serde_json::json!({ "property": "notePaint", "value": { "opacity": 50, "opacityTop": 40 } }), + serde_json::json!({ "property": "notePaint", "value": { "color": "x", "opacity": 50 } }), + serde_json::json!({ "property": "noteGlowPaint", "value": { "opacity": "70" } }), + serde_json::json!({ "property": "noteBorderPaint", "value": { "color": "#FFFFFF" } }), + serde_json::json!({ "property": "noteBorderPaint", "value": { "color": "#FFFFFF", "opacity": 100, "unexpected": true } }), + serde_json::json!({ "notePaint": { "color": "x" }, "noteGlowPaint": { "color": "y" } }), + serde_json::json!({ "property": "noteOffsetX", "value": "0" }), + serde_json::json!({ "noteOffsetX": null, "noteOffsetY": null }), + serde_json::json!({ "noteOffsetY": null, "unexpected": true }), + serde_json::json!({ "property": "noteWidth", "value": "20" }), + serde_json::json!({ "noteWidth": null, "hidden": true }), + serde_json::json!({ "property": "noteBorderWidth", "value": null }), + serde_json::json!({ "property": "noteBorderWidth", "value": "1" }), + serde_json::json!({ "property": "noteBorderRadius", "value": null }), + serde_json::json!({ "noteBorderRadius": 4, "unexpected": true }), + serde_json::json!({ "property": "noteAutoYCorrection", "value": "false" }), + serde_json::json!({ "property": "noteAlignment", "value": "bottom" }), + serde_json::json!({ "property": "noteBorderSide", "value": "diagonal" }), + ] + .map(|patch| { + let mut wire = graph_color.clone(); + wire["ops"][0]["patch"] = patch; + wire + }); + for wire in [ + unknown_property, + unknown_op, + missing_property, + multiple_properties, + invalid_graph_type, + invalid_graph_color, + ] + .into_iter() + .chain(invalid_literal_properties) + { + let error = decode_editor_commit_request(wire).unwrap_err(); + assert_eq!(validation_code(&error), Some("INVALID_REQUEST_PAYLOAD")); + } + + let slot = EditorOpV1::SetKeySlot { + id: Uuid::new_v4().to_string(), + slot: EditorFrozenKeySlotV1::Multi(crate::models::EditorFrozenMultiKeySlotV1 { + keys: vec!["A".to_string(), "B".to_string()], + match_mode: crate::models::SlotMatch::Any, + }), + }; + let slot_wire = serde_json::to_value(ops_request(vec![slot])).unwrap(); + assert_eq!(slot_wire["ops"][0]["kind"], "setKeySlot"); + assert_eq!(slot_wire["ops"][0]["slot"]["match"], "any"); + decode_editor_commit_request(slot_wire.clone()).unwrap(); + + let mut unknown_slot = slot_wire; + unknown_slot["ops"][0]["slot"]["unexpected"] = serde_json::json!(true); + let error = decode_editor_commit_request(unknown_slot).unwrap_err(); + assert_eq!(validation_code(&error), Some("INVALID_REQUEST_PAYLOAD")); + + let invalid_slot = ops_request(vec![EditorOpV1::SetKeySlot { + id: Uuid::new_v4().to_string(), + slot: EditorFrozenKeySlotV1::Multi(crate::models::EditorFrozenMultiKeySlotV1 { + keys: vec!["A".to_string(), "A".to_string()], + match_mode: crate::models::SlotMatch::All, + }), + }]); + assert_eq!( + validation_code(&validate_request_envelope(&invalid_slot).unwrap_err()), + Some("INVALID_KEY_SLOT") + ); +} + +#[test] +fn frozen_insert_wire_is_exact_through_nested_full_records() { + let request = ops_request(vec![frozen_insert_op(Uuid::new_v4().to_string())]); + let mut valid = serde_json::to_value(request).unwrap(); + valid["ops"][0]["elements"][0]["position"]["unexpected"] = serde_json::json!(1); + + let error = decode_editor_commit_request(valid).unwrap_err(); + assert_eq!(validation_code(&error), Some("INVALID_REQUEST_PAYLOAD")); + + let mut invalid_slot = serde_json::to_value(ops_request(vec![frozen_insert_op( + Uuid::new_v4().to_string(), + )])) + .unwrap(); + invalid_slot["ops"][0]["elements"][0]["slot"] = serde_json::json!({ + "keys": ["A", "B"], + "match": "all", + "unexpected": true, + }); + let error = decode_editor_commit_request(invalid_slot).unwrap_err(); + assert_eq!(validation_code(&error), Some("INVALID_REQUEST_PAYLOAD")); +} + +#[test] +fn reorder_wire_is_exact_and_requires_explicit_nullable_group_id() { + let target_id = Uuid::new_v4().to_string(); + let op = EditorOpV1::ReorderElements { + mode: "4key".to_string(), + complete_mode_order: true, + z_updates: vec![EditorZUpdateV1 { + element_type: EditorElementTypeV1::Key, + id: target_id.clone(), + z_index: 3, + }], + group_updates: vec![EditorGroupUpdateV1 { + element_type: EditorElementTypeV1::Key, + id: target_id, + group_id: None, + }], + }; + let valid = serde_json::to_value(ops_request(vec![op])).unwrap(); + assert_eq!(valid["ops"][0]["kind"], "reorderElements"); + assert_eq!(valid["ops"][0]["completeModeOrder"], true); + assert!(valid["ops"][0]["groupUpdates"][0]["groupId"].is_null()); + decode_editor_commit_request(valid.clone()).unwrap(); + + let mut missing_group_id = valid.clone(); + missing_group_id["ops"][0]["groupUpdates"][0] + .as_object_mut() + .unwrap() + .remove("groupId"); + let error = decode_editor_commit_request(missing_group_id).unwrap_err(); + assert_eq!(validation_code(&error), Some("INVALID_REQUEST_PAYLOAD")); + + let mut unknown_z = valid.clone(); + unknown_z["ops"][0]["zUpdates"][0]["unexpected"] = serde_json::json!(true); + let mut unknown_group = valid; + unknown_group["ops"][0]["groupUpdates"][0]["unexpected"] = serde_json::json!(true); + for wire in [unknown_z, unknown_group] { + let error = decode_editor_commit_request(wire).unwrap_err(); + assert_eq!(validation_code(&error), Some("INVALID_REQUEST_PAYLOAD")); + } +} + +#[test] +fn group_structural_wire_is_exact_tagged_required_and_sole() { + let target_id = Uuid::new_v4().to_string(); + let target = EditorElementGroupTargetV1 { + element_type: EditorElementTypeV1::Key, + id: target_id.clone(), + }; + let create = EditorOpV1::SetElementGroups { + mode: "4key".to_string(), + targets: vec![target.clone()], + target_group: Some(EditorTargetGroupV1::Create { + id: " legacy group ".to_string(), + name: " Raw Name ".to_string(), + }), + }; + let create_wire = serde_json::to_value(ops_request(vec![create])).unwrap(); + assert_eq!(create_wire["ops"][0]["kind"], "setElementGroups"); + assert_eq!(create_wire["ops"][0]["targetGroup"]["kind"], "create"); + assert_eq!(create_wire["ops"][0]["targetGroup"]["id"], " legacy group "); + decode_editor_commit_request(create_wire.clone()).unwrap(); + + let ungroup = EditorOpV1::SetElementGroups { + mode: "4key".to_string(), + targets: vec![target.clone()], + target_group: None, + }; + let ungroup_wire = serde_json::to_value(ops_request(vec![ungroup])).unwrap(); + assert!(ungroup_wire["ops"][0]["targetGroup"].is_null()); + decode_editor_commit_request(ungroup_wire.clone()).unwrap(); + + let existing = EditorOpV1::SetElementGroups { + mode: "4key".to_string(), + targets: vec![target], + target_group: Some(EditorTargetGroupV1::Existing { + id: "legacy-group".to_string(), + }), + }; + let existing_wire = serde_json::to_value(ops_request(vec![existing])).unwrap(); + assert_eq!( + existing_wire["ops"][0]["targetGroup"], + serde_json::json!({"kind": "existing", "id": "legacy-group"}) + ); + decode_editor_commit_request(existing_wire).unwrap(); + + let rename = EditorOpV1::RenameLayerGroup { + mode: " ".to_string(), + group_id: " ".to_string(), + name: " ".to_string(), + }; + let rename_wire = serde_json::to_value(ops_request(vec![rename])).unwrap(); + assert_eq!(rename_wire["ops"][0]["kind"], "renameLayerGroup"); + decode_editor_commit_request(rename_wire.clone()).unwrap(); + + let mut missing_target_group = ungroup_wire.clone(); + missing_target_group["ops"][0] + .as_object_mut() + .unwrap() + .remove("targetGroup"); + let mut untagged = create_wire.clone(); + untagged["ops"][0]["targetGroup"] = serde_json::json!({ + "id": "group", + "name": "Group", + }); + let mut existing_with_name = create_wire.clone(); + existing_with_name["ops"][0]["targetGroup"] = serde_json::json!({ + "kind": "existing", + "id": "group", + "name": "No rename", + }); + let mut create_without_name = create_wire.clone(); + create_without_name["ops"][0]["targetGroup"] = serde_json::json!({ + "kind": "create", + "id": "group", + }); + let mut target_extra = create_wire.clone(); + target_extra["ops"][0]["targets"][0]["unexpected"] = serde_json::json!(true); + let mut group_extra = create_wire.clone(); + group_extra["ops"][0]["targetGroup"]["unexpected"] = serde_json::json!(true); + let mut rename_extra = rename_wire; + rename_extra["ops"][0]["unexpected"] = serde_json::json!(true); + for invalid in [ + missing_target_group, + untagged, + existing_with_name, + create_without_name, + target_extra, + group_extra, + rename_extra, + ] { + let error = decode_editor_commit_request(invalid).unwrap_err(); + assert_eq!(validation_code(&error), Some("INVALID_REQUEST_PAYLOAD")); + } + + // 빈 native targets 허용 - plugin-only 그룹 편집이 def 생성·정리를 실어야 함 + let empty = EditorOpV1::SetElementGroups { + mode: "4key".to_string(), + targets: Vec::new(), + target_group: None, + }; + validate_request_envelope(&ops_request(vec![empty])).unwrap(); + let at_limit = EditorOpV1::SetElementGroups { + mode: "4key".to_string(), + targets: (0..MAX_RENDER_ITEMS) + .map(|index| EditorElementGroupTargetV1 { + element_type: EditorElementTypeV1::Key, + id: Uuid::from_u128(index as u128 + 1).to_string(), + }) + .collect(), + target_group: None, + }; + validate_request_envelope(&ops_request(vec![at_limit.clone()])).unwrap(); + let mut too_many = at_limit; + let EditorOpV1::SetElementGroups { targets, .. } = &mut too_many else { + unreachable!(); + }; + targets.push(EditorElementGroupTargetV1 { + element_type: EditorElementTypeV1::Key, + id: Uuid::from_u128(MAX_RENDER_ITEMS as u128 + 1).to_string(), + }); + assert_eq!( + validation_code(&validate_request_envelope(&ops_request(vec![too_many])).unwrap_err()), + Some("INVALID_ELEMENT_GROUP_TARGET_COUNT") + ); + let duplicate = EditorOpV1::SetElementGroups { + mode: "4key".to_string(), + targets: vec![ + EditorElementGroupTargetV1 { + element_type: EditorElementTypeV1::Key, + id: target_id.clone(), + }, + EditorElementGroupTargetV1 { + element_type: EditorElementTypeV1::Graph, + id: target_id, + }, + ], + target_group: None, + }; + assert_eq!( + validation_code(&validate_request_envelope(&ops_request(vec![duplicate])).unwrap_err()), + Some("DUPLICATE_ELEMENT_GROUP_TARGET") + ); + + let accepted_uuid = Uuid::new_v4(); + for id in [ + accepted_uuid.simple().to_string(), + accepted_uuid.braced().to_string(), + accepted_uuid.urn().to_string(), + accepted_uuid.hyphenated().to_string().to_uppercase(), + ] { + let op = EditorOpV1::SetElementGroups { + mode: "4key".to_string(), + targets: vec![EditorElementGroupTargetV1 { + element_type: EditorElementTypeV1::Key, + id, + }], + target_group: None, + }; + validate_request_envelope(&ops_request(vec![op])).unwrap(); + } + + let mixed = ops_request(vec![ + EditorOpV1::RenameLayerGroup { + mode: "4key".to_string(), + group_id: "group".to_string(), + name: "Name".to_string(), + }, + set_bounds_op(Uuid::new_v4().to_string(), EditorElementTypeV1::Key), + ]); + assert_eq!( + validation_code(&validate_request_envelope(&mixed).unwrap_err()), + Some("INVALID_GROUP_STRUCTURAL_BATCH") + ); +} + +#[test] +fn reorder_is_a_bounded_sole_op_with_consistent_targets() { + let id = Uuid::new_v4().to_string(); + let mixed = ops_request(vec![ + reorder_op(id.clone(), false), + set_bounds_op(Uuid::new_v4().to_string(), EditorElementTypeV1::Key), + ]); + assert_eq!( + validation_code(&validate_request_envelope(&mixed).unwrap_err()), + Some("INVALID_REORDER_BATCH") + ); + + let empty = EditorOpV1::ReorderElements { + mode: "4key".to_string(), + complete_mode_order: false, + z_updates: Vec::new(), + group_updates: Vec::new(), + }; + assert_eq!( + validation_code(&validate_request_envelope(&ops_request(vec![empty])).unwrap_err()), + Some("EMPTY_REORDER_BATCH") + ); + + let duplicate_z = EditorOpV1::ReorderElements { + mode: "4key".to_string(), + complete_mode_order: true, + z_updates: vec![ + EditorZUpdateV1 { + element_type: EditorElementTypeV1::Key, + id: id.clone(), + z_index: 1, + }, + EditorZUpdateV1 { + element_type: EditorElementTypeV1::Key, + id: id.clone(), + z_index: 2, + }, + ], + group_updates: Vec::new(), + }; + assert_eq!( + validation_code(&validate_request_envelope(&ops_request(vec![duplicate_z])).unwrap_err()), + Some("DUPLICATE_REORDER_Z_TARGET") + ); + + let group_without_z = EditorOpV1::ReorderElements { + mode: "4key".to_string(), + complete_mode_order: true, + z_updates: vec![EditorZUpdateV1 { + element_type: EditorElementTypeV1::Key, + id: id.clone(), + z_index: 1, + }], + group_updates: vec![EditorGroupUpdateV1 { + element_type: EditorElementTypeV1::Key, + id: Uuid::new_v4().to_string(), + group_id: None, + }], + }; + assert_eq!( + validation_code( + &validate_request_envelope(&ops_request(vec![group_without_z])).unwrap_err() + ), + Some("REORDER_GROUP_TARGET_NOT_IN_ORDER") + ); + + let duplicate_group = EditorOpV1::ReorderElements { + mode: "4key".to_string(), + complete_mode_order: true, + z_updates: vec![EditorZUpdateV1 { + element_type: EditorElementTypeV1::Key, + id: id.clone(), + z_index: 1, + }], + group_updates: vec![ + EditorGroupUpdateV1 { + element_type: EditorElementTypeV1::Key, + id: id.clone(), + group_id: None, + }, + EditorGroupUpdateV1 { + element_type: EditorElementTypeV1::Key, + id: id.clone(), + group_id: None, + }, + ], + }; + assert_eq!( + validation_code( + &validate_request_envelope(&ops_request(vec![duplicate_group])).unwrap_err() + ), + Some("DUPLICATE_REORDER_GROUP_TARGET") + ); + + let conflicting_type = EditorOpV1::ReorderElements { + mode: "4key".to_string(), + complete_mode_order: true, + z_updates: vec![EditorZUpdateV1 { + element_type: EditorElementTypeV1::Key, + id: id.clone(), + z_index: 1, + }], + group_updates: vec![EditorGroupUpdateV1 { + element_type: EditorElementTypeV1::Graph, + id: id.clone(), + group_id: None, + }], + }; + assert_eq!( + validation_code( + &validate_request_envelope(&ops_request(vec![conflicting_type])).unwrap_err() + ), + Some("REORDER_TARGET_TYPE_CONFLICT") + ); + + let invalid_group_id = EditorOpV1::ReorderElements { + mode: "4key".to_string(), + complete_mode_order: true, + z_updates: vec![EditorZUpdateV1 { + element_type: EditorElementTypeV1::Key, + id: id.clone(), + z_index: 1, + }], + group_updates: vec![EditorGroupUpdateV1 { + element_type: EditorElementTypeV1::Key, + id: id.clone(), + group_id: Some(String::new()), + }], + }; + assert_eq!( + validation_code( + &validate_request_envelope(&ops_request(vec![invalid_group_id])).unwrap_err() + ), + Some("INVALID_REORDER_GROUP_ID") + ); + + let partial_group = EditorOpV1::ReorderElements { + mode: "4key".to_string(), + complete_mode_order: false, + z_updates: vec![EditorZUpdateV1 { + element_type: EditorElementTypeV1::Key, + id: id.clone(), + z_index: 1, + }], + group_updates: vec![EditorGroupUpdateV1 { + element_type: EditorElementTypeV1::Key, + id: id.clone(), + group_id: None, + }], + }; + assert_eq!( + validation_code(&validate_request_envelope(&ops_request(vec![partial_group])).unwrap_err()), + Some("INVALID_PARTIAL_REORDER_GROUP_UPDATE") + ); + + let at_limit = EditorOpV1::ReorderElements { + mode: "4key".to_string(), + complete_mode_order: false, + z_updates: (0..MAX_RENDER_ITEMS) + .map(|index| EditorZUpdateV1 { + element_type: EditorElementTypeV1::Key, + id: Uuid::from_u128(index as u128 + 1).to_string(), + z_index: index as i32, + }) + .collect(), + group_updates: Vec::new(), + }; + validate_request_envelope(&ops_request(vec![at_limit.clone()])).unwrap(); + let mut too_wide = at_limit; + let EditorOpV1::ReorderElements { z_updates, .. } = &mut too_wide else { + unreachable!(); + }; + z_updates.push(EditorZUpdateV1 { + element_type: EditorElementTypeV1::Key, + id: Uuid::from_u128(MAX_RENDER_ITEMS as u128 + 1).to_string(), + z_index: 0, + }); + assert_eq!( + validation_code(&validate_request_envelope(&ops_request(vec![too_wide])).unwrap_err()), + Some("REORDER_BATCH_TOO_LARGE") + ); + + let too_many_group_updates = EditorOpV1::ReorderElements { + mode: "4key".to_string(), + complete_mode_order: true, + z_updates: vec![EditorZUpdateV1 { + element_type: EditorElementTypeV1::Key, + id: id.clone(), + z_index: 1, + }], + group_updates: (0..=MAX_RENDER_ITEMS) + .map(|index| EditorGroupUpdateV1 { + element_type: EditorElementTypeV1::Key, + id: Uuid::from_u128(index as u128 + 1).to_string(), + group_id: None, + }) + .collect(), + }; + assert_eq!( + validation_code( + &validate_request_envelope(&ops_request(vec![too_many_group_updates])).unwrap_err() + ), + Some("REORDER_BATCH_TOO_LARGE") + ); +} + +#[test] +fn frozen_insert_is_a_bounded_sole_op_with_unique_disjoint_ids() { + let id = Uuid::new_v4().to_string(); + let mixed = ops_request(vec![ + frozen_insert_op(id.clone()), + set_bounds_op(Uuid::new_v4().to_string(), EditorElementTypeV1::Key), + ]); + assert_eq!( + validation_code(&validate_request_envelope(&mixed).unwrap_err()), + Some("INVALID_FROZEN_INSERT_BATCH") + ); + + let duplicate = EditorOpV1::InsertFrozenElements { + mode: "4key".to_string(), + elements: vec![ + crate::models::EditorFrozenElementV1::Key { + slot: crate::models::EditorFrozenKeySlotV1::Single("A".to_string()), + position: KeyPosition { + id: id.clone(), + ..KeyPosition::default() + }, + }, + crate::models::EditorFrozenElementV1::Key { + slot: crate::models::EditorFrozenKeySlotV1::Single("B".to_string()), + position: KeyPosition { + id: id.clone(), + ..KeyPosition::default() + }, + }, + ], + groups: Vec::new(), + z_updates: Vec::new(), + }; + assert_eq!( + validation_code(&validate_request_envelope(&ops_request(vec![duplicate])).unwrap_err()), + Some("DUPLICATE_FROZEN_INSERT_ID") + ); + + let overlap = EditorOpV1::InsertFrozenElements { + mode: "4key".to_string(), + elements: vec![crate::models::EditorFrozenElementV1::Key { + slot: crate::models::EditorFrozenKeySlotV1::Single("A".to_string()), + position: KeyPosition { + id: id.clone(), + ..KeyPosition::default() + }, + }], + groups: Vec::new(), + z_updates: vec![crate::models::EditorZUpdateV1 { + element_type: EditorElementTypeV1::Key, + id, + z_index: 1, + }], + }; + assert_eq!( + validation_code(&validate_request_envelope(&ops_request(vec![overlap])).unwrap_err()), + Some("FROZEN_INSERT_Z_TARGET_OVERLAP") + ); + + let malformed_slot = EditorOpV1::InsertFrozenElements { + mode: "4key".to_string(), + elements: vec![crate::models::EditorFrozenElementV1::Key { + slot: crate::models::EditorFrozenKeySlotV1::Multi( + crate::models::EditorFrozenMultiKeySlotV1 { + keys: vec!["A".to_string()], + match_mode: crate::models::SlotMatch::All, + }, + ), + position: KeyPosition { + id: Uuid::new_v4().to_string(), + ..KeyPosition::default() + }, + }], + groups: Vec::new(), + z_updates: Vec::new(), + }; + assert_eq!( + validation_code( + &validate_request_envelope(&ops_request(vec![malformed_slot])).unwrap_err() + ), + Some("INVALID_FROZEN_KEY_SLOT") + ); + + for match_mode in [crate::models::SlotMatch::All, crate::models::SlotMatch::Any] { + let valid_multi = EditorOpV1::InsertFrozenElements { + mode: "4key".to_string(), + elements: vec![crate::models::EditorFrozenElementV1::Key { + slot: crate::models::EditorFrozenKeySlotV1::Multi( + crate::models::EditorFrozenMultiKeySlotV1 { + keys: vec!["A".to_string(), "B".to_string()], + match_mode, + }, + ), + position: KeyPosition { + id: Uuid::new_v4().to_string(), + ..KeyPosition::default() + }, + }], + groups: Vec::new(), + z_updates: Vec::new(), + }; + validate_request_envelope(&ops_request(vec![valid_multi])).unwrap(); + } + + for members in [ + vec!["A".to_string(), "A".to_string()], + vec!["".to_string(), "B".to_string()], + vec!["A+B".to_string(), "C".to_string()], + vec!["A|B".to_string(), "C".to_string()], + ] { + let invalid_multi = EditorOpV1::InsertFrozenElements { + mode: "4key".to_string(), + elements: vec![crate::models::EditorFrozenElementV1::Key { + slot: crate::models::EditorFrozenKeySlotV1::Multi( + crate::models::EditorFrozenMultiKeySlotV1 { + keys: members, + match_mode: crate::models::SlotMatch::All, + }, + ), + position: KeyPosition { + id: Uuid::new_v4().to_string(), + ..KeyPosition::default() + }, + }], + groups: Vec::new(), + z_updates: Vec::new(), + }; + assert_eq!( + validation_code( + &validate_request_envelope(&ops_request(vec![invalid_multi])).unwrap_err() + ), + Some("INVALID_FROZEN_KEY_SLOT") + ); + } + + let inserted_id = Uuid::from_u128((MAX_RENDER_ITEMS + 1) as u128).to_string(); + let wide_plan = EditorOpV1::InsertFrozenElements { + mode: "4key".to_string(), + elements: vec![crate::models::EditorFrozenElementV1::Key { + slot: crate::models::EditorFrozenKeySlotV1::Single("A".to_string()), + position: KeyPosition { + id: inserted_id, + ..KeyPosition::default() + }, + }], + groups: Vec::new(), + z_updates: (0..MAX_RENDER_ITEMS) + .map(|index| crate::models::EditorZUpdateV1 { + element_type: EditorElementTypeV1::Key, + id: Uuid::from_u128(index as u128 + 1).to_string(), + z_index: index as i32, + }) + .collect(), + }; + validate_request_envelope(&ops_request(vec![wide_plan.clone()])).unwrap(); + + let mut too_wide = wide_plan; + let EditorOpV1::InsertFrozenElements { z_updates, .. } = &mut too_wide else { + unreachable!(); + }; + z_updates.push(crate::models::EditorZUpdateV1 { + element_type: EditorElementTypeV1::Key, + id: Uuid::from_u128((MAX_RENDER_ITEMS + 2) as u128).to_string(), + z_index: 0, + }); + assert_eq!( + validation_code(&validate_request_envelope(&ops_request(vec![too_wide])).unwrap_err()), + Some("FROZEN_INSERT_BATCH_TOO_LARGE") + ); +} + +#[test] +fn editor_ops_enforce_version_count_ids_and_global_target_uniqueness() { + let id = Uuid::new_v4().to_string(); + // 구버전 1과 미래 버전 3 모두 거부, v1 이중 수용 없음 + for unsupported_version in [1, 3] { + let mut unsupported = ops_request(vec![set_bounds_op(&id, EditorElementTypeV1::Key)]); + unsupported.ops_version = Some(unsupported_version); + assert_eq!( + validation_code(&validate_request_envelope(&unsupported).unwrap_err()), + Some("UNSUPPORTED_OPS_VERSION") + ); + } + + let empty = ops_request(Vec::new()); + assert_eq!( + validation_code(&validate_request_envelope(&empty).unwrap_err()), + Some("EMPTY_EDITOR_OPS") + ); + + let at_limit = ops_request( + (0..MAX_EDITOR_OPS) + .map(|index| { + set_bounds_op( + Uuid::from_u128(index as u128 + 1).to_string(), + EditorElementTypeV1::Key, + ) + }) + .collect(), + ); + validate_request_envelope(&at_limit).unwrap(); + + let mut too_many = at_limit; + too_many.ops.as_mut().unwrap().push(set_bounds_op( + Uuid::from_u128(MAX_EDITOR_OPS as u128 + 1).to_string(), + EditorElementTypeV1::Key, + )); + assert_eq!( + validation_code(&validate_request_envelope(&too_many).unwrap_err()), + Some("TOO_MANY_EDITOR_OPS") + ); + + let duplicate = ops_request(vec![ + set_bounds_op(&id, EditorElementTypeV1::Key), + delete_element_op(&id, EditorElementTypeV1::Graph), + ]); + assert_eq!( + validation_code(&validate_request_envelope(&duplicate).unwrap_err()), + Some("DUPLICATE_EDITOR_OP_TARGET") + ); + + let nil = ops_request(vec![set_bounds_op( + Uuid::nil().to_string(), + EditorElementTypeV1::Key, + )]); + assert_eq!( + validation_code(&validate_request_envelope(&nil).unwrap_err()), + Some(crate::state::native_element_id::INVALID_ELEMENT_ID) + ); +} + +#[test] +fn editor_op_bounds_reuse_position_numeric_limits() { + let valid = EditorBoundsV1 { + dx: MAX_ABS_COORDINATE, + dy: -MAX_ABS_COORDINATE, + width: MAX_DIMENSION, + height: 1.0, + }; + validate_editor_op_bounds(0, None, valid).unwrap(); + + for invalid in [ + EditorBoundsV1 { + dx: f64::NAN, + ..valid + }, + EditorBoundsV1 { + dy: MAX_ABS_COORDINATE + 1.0, + ..valid + }, + EditorBoundsV1 { + width: 0.0, + ..valid + }, + EditorBoundsV1 { + height: MAX_DIMENSION + 1.0, + ..valid + }, + ] { + assert!(validate_editor_op_bounds(0, None, invalid).is_err()); + } + + let grandfathered = KeyPosition { + width: MAX_DIMENSION + 2.0, + ..KeyPosition::default() + }; + validate_editor_op_bounds( + 0, + Some(&grandfathered), + EditorBoundsV1 { + width: MAX_DIMENSION + 1.0, + ..position_bounds(&grandfathered) + }, + ) + .unwrap(); + + let mismatch = + validate_editor_op_target_type(0, EditorElementTypeV1::Graph, EditorElementTypeV1::Key) + .unwrap_err(); + assert_eq!(validation_code(&mismatch), Some("ELEMENT_TYPE_MISMATCH")); + assert!(!mismatch.retryable); +} + +#[test] +fn canonical_fingerprint_includes_editor_op_payload() { + let id = Uuid::new_v4().to_string(); + let left = ops_request(vec![set_bounds_op(&id, EditorElementTypeV1::Key)]); + let mut right = left.clone(); + let Some(EditorOpV1::SetBounds { bounds, .. }) = + right.ops.as_mut().and_then(|ops| ops.first_mut()) + else { + unreachable!(); + }; + bounds.width += 1.0; + + assert_ne!( + request_fingerprint(&left).unwrap(), + request_fingerprint(&right).unwrap() + ); + + let delete = ops_request(vec![delete_element_op(&id, EditorElementTypeV1::Key)]); + assert_ne!( + request_fingerprint(&left).unwrap(), + request_fingerprint(&delete).unwrap() + ); +} + +#[test] +fn editor_op_results_use_the_exact_camel_case_wire_values() { + let canonical = EditorBoundsV1 { + dx: 1.0, + dy: 2.0, + width: 3.0, + height: 4.0, + }; + let wire = serde_json::to_value([ + EditorOpResultV1 { + status: EditorOpResultStatusV1::Applied, + bounds: Some(canonical), + }, + EditorOpResultV1 { + status: EditorOpResultStatusV1::NoChange, + bounds: Some(canonical), + }, + EditorOpResultV1 { + status: EditorOpResultStatusV1::TargetMissing, + bounds: None, + }, + ]) + .unwrap(); + + assert_eq!(wire[0]["status"], "applied"); + assert_eq!(wire[1]["status"], "noChange"); + assert_eq!(wire[2]["status"], "targetMissing"); + assert!(wire[2].get("bounds").is_none()); +} + +#[test] +fn member_fanout_limit_accepts_sixteen_and_rejects_new_excess() { + let store = default_editor_store(); + let current = EditorDocumentV1::from_store(&store); + let mut at_limit = current.clone(); + at_limit + .keys + .get_mut("4key") + .unwrap() + .extend((0..MAX_SLOTS_PER_MEMBER).map(|index| KeySlot::Multi { + keys: vec!["SHARED".to_string(), format!("K{index}")], + match_mode: crate::models::SlotMatch::Any, + })); + at_limit + .key_positions + .get_mut("4key") + .unwrap() + .extend(vec![KeyPosition::default(); MAX_SLOTS_PER_MEMBER]); + let mut at_limit_store = store.clone(); + at_limit.apply_to_store(&mut at_limit_store); + + validate_document_transition(¤t, &at_limit, &store, &at_limit_store).unwrap(); + + let mut over_limit = at_limit.clone(); + over_limit + .keys + .get_mut("4key") + .unwrap() + .push(KeySlot::Multi { + keys: vec!["SHARED".to_string(), "EXTRA".to_string()], + match_mode: crate::models::SlotMatch::Any, + }); + over_limit + .key_positions + .get_mut("4key") + .unwrap() + .push(KeyPosition::default()); + let mut over_limit_store = at_limit_store.clone(); + over_limit.apply_to_store(&mut over_limit_store); + + let error = + validate_document_transition(&at_limit, &over_limit, &at_limit_store, &over_limit_store) + .unwrap_err(); + assert_eq!( + error.details.unwrap().validation_code.as_deref(), + Some("TOO_MANY_SLOTS_PER_MEMBER") + ); + + validate_document_transition( + &over_limit, + &over_limit, + &over_limit_store, + &over_limit_store, + ) + .unwrap(); +} + +#[test] +fn counter_sync_uses_canonical_and_separates_any_from_all() { + let keys = HashMap::from([( + "mode".to_string(), + vec![ + KeySlot::Single("A".to_string()), + KeySlot::Multi { + keys: vec!["A".to_string(), "B".to_string()], + match_mode: crate::models::SlotMatch::Any, + }, + KeySlot::Multi { + keys: vec!["A".to_string(), "B".to_string()], + match_mode: crate::models::SlotMatch::All, + }, + ], + )]); + let mut counters = HashMap::from([( + "mode".to_string(), + HashMap::from([("A".to_string(), 7), ("stale".to_string(), 3)]), + )]); + + sync_key_counters(&mut counters, &keys); + + assert_eq!(counters["mode"]["A"], 7); + assert_eq!(counters["mode"]["A|B"], 0); + assert_eq!(counters["mode"]["A+B"], 0); + assert!(!counters["mode"].contains_key("stale")); +} + +#[test] +fn structural_single_field_update_requires_pair() { + let store = AppStoreData::default(); + let current = EditorDocumentV1::from_store(&store); + let mut candidate = current.clone(); + candidate + .keys + .entry("4key".to_string()) + .or_default() + .push(KeySlot::from("A")); + + let error = validate_paired_update(¤t, &candidate, true, false).unwrap_err(); + assert_eq!( + error.error_code, + crate::errors::EditorCommitErrorCode::PairedUpdateRequired + ); +} + +#[test] +fn stage_four_paired_topology_uses_key_position_id_order() { + let store = default_editor_store(); + let current = EditorDocumentV1::from_store(&store); + + let mut position_edit = current.clone(); + position_edit.key_positions.get_mut("4key").unwrap()[0].dx += 1.0; + validate_paired_update(¤t, &position_edit, false, true).unwrap(); + + let mut positions_only_reorder = current.clone(); + positions_only_reorder + .key_positions + .get_mut("4key") + .unwrap() + .swap(0, 1); + let error = validate_paired_update(¤t, &positions_only_reorder, false, true).unwrap_err(); + assert_eq!( + error.error_code, + crate::errors::EditorCommitErrorCode::PairedUpdateRequired + ); + assert!(!error.retryable); + + let mut paired_reorder = positions_only_reorder; + paired_reorder.keys.get_mut("4key").unwrap().swap(0, 1); + validate_paired_update(¤t, &paired_reorder, true, true).unwrap(); + + let mut keys_only = current.clone(); + keys_only.keys.get_mut("4key").unwrap()[0] = KeySlot::from("Changed"); + validate_paired_update(¤t, &keys_only, true, false).unwrap(); +} + +#[test] +fn unchanged_ghost_mode_is_grandfathered() { + let mut store = AppStoreData::default(); + store + .keys + .insert("ghost".to_string(), vec![KeySlot::from("A")]); + store + .key_positions + .insert("ghost".to_string(), vec![KeyPosition::default()]); + let current = EditorDocumentV1::from_store(&store); + let mut candidate = current.clone(); + candidate.keys.get_mut("ghost").unwrap()[0] = KeySlot::from("B"); + + validate_document_transition(¤t, &candidate, &store, &store).unwrap(); +} + +#[test] +fn new_ghost_mode_is_rejected() { + let store = AppStoreData::default(); + let current = EditorDocumentV1::from_store(&store); + let mut candidate = current.clone(); + candidate + .keys + .insert("ghost".to_string(), vec![KeySlot::from("A")]); + candidate + .key_positions + .insert("ghost".to_string(), vec![KeyPosition::default()]); + + let error = validate_document_transition(¤t, &candidate, &store, &store).unwrap_err(); + assert_eq!( + error.details.unwrap().validation_code.as_deref(), + Some("UNKNOWN_MODE") + ); +} + +#[test] +fn metadata_can_introduce_matching_custom_mode_in_same_transition() { + let store = AppStoreData::default(); + let current = EditorDocumentV1::from_store(&store); + let mut candidate_store = store.clone(); + candidate_store.custom_tabs.push(CustomTab { + id: "custom".to_string(), + name: "Custom".to_string(), + }); + candidate_store + .keys + .insert("custom".to_string(), vec![KeySlot::from("A")]); + candidate_store + .key_positions + .insert("custom".to_string(), vec![KeyPosition::default()]); + let candidate = EditorDocumentV1::from_store(&candidate_store); + + validate_document_transition(¤t, &candidate, &store, &candidate_store).unwrap(); +} + +#[test] +fn history_restore_requires_metadata_for_every_custom_editor_mode() { + let mut store = AppStoreData::default(); + store + .keys + .insert("custom".to_string(), vec![KeySlot::from("A")]); + store + .key_positions + .insert("custom".to_string(), vec![KeyPosition::default()]); + let document = EditorDocumentV1::from_store(&store); + + let error = validate_history_restore_metadata(&document, &[], "4key").unwrap_err(); + + assert_eq!( + error.details.unwrap().validation_code.as_deref(), + Some("CUSTOM_TAB_METADATA_MISSING") + ); +} + +#[test] +fn history_restore_rejects_metadata_without_paired_editor_collections() { + let document = EditorDocumentV1::from_store(&AppStoreData::default()); + let tabs = vec![CustomTab { + id: "custom".to_string(), + name: "Custom".to_string(), + }]; + + let error = validate_history_restore_metadata(&document, &tabs, "custom").unwrap_err(); + + assert_eq!( + error.details.unwrap().validation_code.as_deref(), + Some("CUSTOM_TAB_DOCUMENT_MISSING") + ); +} + +#[test] +fn coordinate_and_dimension_limits_accept_boundary_and_reject_one_past_it() { + let store = default_editor_store(); + let current = EditorDocumentV1::from_store(&store); + let mut boundary = current.clone(); + let position = &mut boundary.key_positions.get_mut("4key").unwrap()[0]; + position.dx = MAX_ABS_COORDINATE; + position.dy = -MAX_ABS_COORDINATE; + position.width = MAX_DIMENSION; + position.height = MAX_DIMENSION; + let mut boundary_store = store.clone(); + boundary.apply_to_store(&mut boundary_store); + validate_document_transition(¤t, &boundary, &store, &boundary_store).unwrap(); + + for (name, value) in [ + ("dx", MAX_ABS_COORDINATE + 1.0), + ("dy", -MAX_ABS_COORDINATE - 1.0), + ("width", MAX_DIMENSION + 1.0), + ("height", 0.0), + ] { + let mut invalid = current.clone(); + let position = &mut invalid.key_positions.get_mut("4key").unwrap()[0]; + match name { + "dx" => position.dx = value, + "dy" => position.dy = value, + "width" => position.width = value, + "height" => position.height = value, + _ => unreachable!(), + } + let mut invalid_store = store.clone(); + invalid.apply_to_store(&mut invalid_store); + assert!( + validate_document_transition(¤t, &invalid, &store, &invalid_store).is_err(), + "{name}={value} should be rejected" + ); + } +} + +#[test] +fn oversized_coordinate_is_grandfathered_only_when_unchanged_or_decreased() { + let mut store = default_editor_store(); + store.key_positions.get_mut("4key").unwrap()[0].dx = MAX_ABS_COORDINATE + 2.0; + let current = EditorDocumentV1::from_store(&store); + + validate_document_transition(¤t, ¤t, &store, &store).unwrap(); + + let mut decreased = current.clone(); + decreased.key_positions.get_mut("4key").unwrap()[0].dx = MAX_ABS_COORDINATE + 1.0; + let mut decreased_store = store.clone(); + decreased.apply_to_store(&mut decreased_store); + validate_document_transition(¤t, &decreased, &store, &decreased_store).unwrap(); + + let mut increased = current.clone(); + increased.key_positions.get_mut("4key").unwrap()[0].dx = MAX_ABS_COORDINATE + 3.0; + let mut increased_store = store.clone(); + increased.apply_to_store(&mut increased_store); + assert!(validate_document_transition(¤t, &increased, &store, &increased_store).is_err()); +} + +#[test] +fn editor_rejects_new_shadow_violations_in_every_position_collection() { + let store = store_with_each_position_collection(); + let current = EditorDocumentV1::from_store(&store); + + for (collection, active, property, expected_path) in [ + ( + "keyPositions", + false, + "blur", + "keyPositions 4key[0].shadow.blur", + ), + ( + "statPositions", + true, + "offsetX", + "statPositions 4key[0].activeShadow.offsetX", + ), + ( + "graphPositions", + false, + "offsetY", + "graphPositions 4key[0].shadow.offsetY", + ), + ( + "knobPositions", + true, + "color", + "knobPositions 4key[0].activeShadow.color", + ), + ] { + let mut candidate = current.clone(); + let mut shadow = valid_shadow(); + match property { + "blur" => shadow.blur = MAX_SHADOW_BLUR + 0.1, + "offsetX" => shadow.offset_x = MIN_SHADOW_OFFSET - 0.1, + "offsetY" => shadow.offset_y = MAX_SHADOW_OFFSET + 0.1, + "color" => shadow.color.clear(), + _ => unreachable!(), + } + let position = position_mut(&mut candidate, collection); + if active { + position.active_shadow = Some(shadow); + } else { + position.shadow = Some(shadow); + } + let mut candidate_store = store.clone(); + candidate.apply_to_store(&mut candidate_store); + + let error = validate_document_transition(¤t, &candidate, &store, &candidate_store) + .unwrap_err(); + assert_eq!( + error + .details + .as_ref() + .and_then(|details| details.validation_code.as_deref()), + Some("INVALID_ELEMENT_SHADOW") + ); + assert!(error.message.contains(expected_path)); + } +} + +#[test] +fn existing_shadow_violations_are_grandfathered_only_when_unchanged() { + let mut store = default_editor_store(); + let position = &mut store.key_positions.get_mut("4key").unwrap()[0]; + let mut shadow = valid_shadow(); + shadow.blur = MAX_SHADOW_BLUR + 1.0; + position.shadow = Some(shadow); + let current = EditorDocumentV1::from_store(&store); + + let mut unrelated = current.clone(); + unrelated.key_positions.get_mut("4key").unwrap()[0].font_size = Some(18.0); + let mut unrelated_store = store.clone(); + unrelated.apply_to_store(&mut unrelated_store); + validate_document_transition(¤t, &unrelated, &store, &unrelated_store).unwrap(); + + let mut changed_shadow = current.clone(); + changed_shadow.key_positions.get_mut("4key").unwrap()[0] + .shadow + .as_mut() + .unwrap() + .blur += 1.0; + let mut changed_shadow_store = store.clone(); + changed_shadow.apply_to_store(&mut changed_shadow_store); + let shadow_error = + validate_document_transition(¤t, &changed_shadow, &store, &changed_shadow_store) + .unwrap_err(); + assert_eq!( + shadow_error + .details + .as_ref() + .and_then(|details| details.validation_code.as_deref()), + Some("INVALID_ELEMENT_SHADOW") + ); +} + +// 이미지 변환도 그림자와 같은 문서 단위 검증 - 기존 값은 grandfather, 새 범위 밖 값은 거부 +#[test] +fn image_transform_violations_are_rejected_unless_grandfathered() { + let out_of_range = |scale: f64| crate::models::ImageTransform { + offset_x: 0.0, + offset_y: 0.0, + rotation: 0.0, + scale, + }; + let mut store = default_editor_store(); + store.key_positions.get_mut("4key").unwrap()[0].idle_image_transform = Some(out_of_range(0.05)); + let current = EditorDocumentV1::from_store(&store); + + let mut unrelated = current.clone(); + unrelated.key_positions.get_mut("4key").unwrap()[0].font_size = Some(18.0); + let mut unrelated_store = store.clone(); + unrelated.apply_to_store(&mut unrelated_store); + validate_document_transition(¤t, &unrelated, &store, &unrelated_store).unwrap(); + + let mut changed = current.clone(); + changed.key_positions.get_mut("4key").unwrap()[0].idle_image_transform = + Some(out_of_range(0.04)); + let mut changed_store = store.clone(); + changed.apply_to_store(&mut changed_store); + let error = + validate_document_transition(¤t, &changed, &store, &changed_store).unwrap_err(); + assert_eq!( + error + .details + .as_ref() + .and_then(|details| details.validation_code.as_deref()), + Some("INVALID_IMAGE_TRANSFORM") + ); + assert!(error.message.contains("idleImageTransform.scale")); +} + +#[test] +fn stage_four_grandfathering_ignores_diagnostic_message_changes() { + let key = ViolationKey { + owner: ViolationOwner::Mode { + mode: "ghost".to_string(), + }, + code: "UNKNOWN_MODE", + property_path: ViolationPropertyPath::Collection("keys"), + invalid_value: InvalidValueSignature::None, + }; + let current = [ValidationViolation::new(key.clone(), "same message")] + .into_iter() + .map(|violation| violation.key) + .collect(); + + assert!(is_grandfathered( + ¤t, + &ValidationViolation::new(key, "different diagnostic message"), + &HashMap::new() + )); +} + +#[test] +fn stage_four_stable_id_grandfathers_violation_after_reorder() { + let mut store = default_editor_store(); + let mut shadow = valid_shadow(); + shadow.blur = MAX_SHADOW_BLUR + 1.0; + store.key_positions.get_mut("4key").unwrap()[0].shadow = Some(shadow); + let current = EditorDocumentV1::from_store(&store); + + let mut candidate = current.clone(); + candidate.keys.get_mut("4key").unwrap().swap(0, 1); + candidate.key_positions.get_mut("4key").unwrap().swap(0, 1); + let mut candidate_store = store.clone(); + candidate.apply_to_store(&mut candidate_store); + + validate_paired_update(¤t, &candidate, true, true).unwrap(); + validate_document_transition(¤t, &candidate, &store, &candidate_store).unwrap(); +} + +#[test] +fn stage_four_native_violation_key_omits_mode_for_same_element() { + let mut store = store_with_each_position_collection(); + let mut shadow = valid_shadow(); + shadow.blur = MAX_SHADOW_BLUR + 1.0; + store.stat_positions.get_mut("4key").unwrap()[0] + .position + .shadow = Some(shadow); + let current = EditorDocumentV1::from_store(&store); + let mut candidate = current.clone(); + let moved = candidate + .stat_positions + .get_mut("4key") + .unwrap() + .pop() + .unwrap(); + candidate + .stat_positions + .entry("5key".to_string()) + .or_default() + .push(moved); + let mut candidate_store = store.clone(); + candidate.apply_to_store(&mut candidate_store); + + validate_document_transition(¤t, &candidate, &store, &candidate_store).unwrap(); +} + +#[test] +fn stage_four_same_violation_on_a_different_id_is_rejected() { + let mut store = default_editor_store(); + let mut shadow = valid_shadow(); + shadow.blur = MAX_SHADOW_BLUR + 1.0; + store.key_positions.get_mut("4key").unwrap()[0].shadow = Some(shadow); + let current = EditorDocumentV1::from_store(&store); + let mut candidate = current.clone(); + candidate.key_positions.get_mut("4key").unwrap()[0].id = Uuid::new_v4().to_string(); + let mut candidate_store = store.clone(); + candidate.apply_to_store(&mut candidate_store); + + let error = + validate_document_transition(¤t, &candidate, &store, &candidate_store).unwrap_err(); + assert_eq!( + error.details.unwrap().validation_code.as_deref(), + Some("INVALID_ELEMENT_SHADOW") + ); +} + +#[test] +fn preset_keying_grandfathers_rekeyed_elements_by_mode_and_index() { + let mut store = default_editor_store(); + store.key_positions.get_mut("4key").unwrap()[0].dx = MAX_ABS_COORDINATE + 1.0; + let current = EditorDocumentV1::from_store(&store); + + // 프리셋 로드는 검증 전에 모든 요소의 id를 재발급한다 - 값이 그대로여도 + // id 조회로는 관용 대상을 찾을 수 없다 + let mut candidate_store = store.clone(); + crate::state::native_element_id::rekey_store_element_ids(&mut candidate_store); + let candidate = EditorDocumentV1::from_store(&candidate_store); + + let error = + validate_document_transition(¤t, &candidate, &store, &candidate_store).unwrap_err(); + assert_eq!( + error.details.unwrap().validation_code.as_deref(), + Some("COORDINATE_OUT_OF_RANGE") + ); + + validate_document_transition_with_keying( + ¤t, + &candidate, + &store, + &candidate_store, + GrandfatherKeying::ModeIndex, + ) + .unwrap(); +} + +#[test] +fn preset_keying_grandfathers_structural_and_label_violations_too() { + let mut store = default_editor_store(); + // 그림자 위반과 과길이 라벨을 함께 가진 관용 store + store.key_positions.get_mut("4key").unwrap()[0].shadow = Some(ElementShadowSpec { + color: String::new(), + ..valid_shadow() + }); + store.keys.get_mut("4key").unwrap()[0] = KeySlot::from("x".repeat(MAX_KEY_LABEL_BYTES + 1)); + let current = EditorDocumentV1::from_store(&store); + + let mut candidate_store = store.clone(); + crate::state::native_element_id::rekey_store_element_ids(&mut candidate_store); + let candidate = EditorDocumentV1::from_store(&candidate_store); + + // ID 기준으로는 관용 상대를 못 찾아 거부된다 + assert!(validate_document_transition(¤t, &candidate, &store, &candidate_store).is_err()); + + // 자리 기준이면 그림자·라벨 관용이 모두 유지된다 + validate_document_transition_with_keying( + ¤t, + &candidate, + &store, + &candidate_store, + GrandfatherKeying::ModeIndex, + ) + .unwrap(); +} + +#[test] +fn preset_keying_still_rejects_newly_raised_metrics() { + let mut store = default_editor_store(); + store.key_positions.get_mut("4key").unwrap()[0].dx = MAX_ABS_COORDINATE + 1.0; + let current = EditorDocumentV1::from_store(&store); + + let mut candidate_store = store.clone(); + crate::state::native_element_id::rekey_store_element_ids(&mut candidate_store); + candidate_store.key_positions.get_mut("4key").unwrap()[1].dx = MAX_ABS_COORDINATE + 1.0; + let candidate = EditorDocumentV1::from_store(&candidate_store); + + // (mode,index) 관용은 같은 자리의 기존 위반만 물려받는다 - 멀쩡하던 + // 자리가 새로 초과되면 프리셋 경로에서도 거부한다 + let error = validate_document_transition_with_keying( + ¤t, + &candidate, + &store, + &candidate_store, + GrandfatherKeying::ModeIndex, + ) + .unwrap_err(); + assert_eq!( + error.details.unwrap().validation_code.as_deref(), + Some("COORDINATE_OUT_OF_RANGE") + ); +} + +#[test] +fn legacy_preset_keying_accepts_finite_historical_bounds_in_every_collection() { + let store = store_with_each_position_collection(); + let current = EditorDocumentV1::from_store(&store); + + for collection in [ + "keyPositions", + "statPositions", + "graphPositions", + "knobPositions", + ] { + let mut candidate = current.clone(); + let position = position_mut(&mut candidate, collection); + position.dx = MAX_ABS_COORDINATE + 7_232.0; + position.width = MAX_DIMENSION + 7_232.0; + let mut candidate_store = store.clone(); + candidate.apply_to_store(&mut candidate_store); + crate::state::native_element_id::rekey_store_element_ids(&mut candidate_store); + let candidate = EditorDocumentV1::from_store(&candidate_store); + + let strict = validate_document_transition_with_keying( + ¤t, + &candidate, + &store, + &candidate_store, + GrandfatherKeying::ModeIndex, + ) + .unwrap_err(); + assert!(matches!( + strict.details.unwrap().validation_code.as_deref(), + Some("COORDINATE_OUT_OF_RANGE" | "DIMENSION_OUT_OF_RANGE") + )); + validate_document_transition_with_keying( + ¤t, + &candidate, + &store, + &candidate_store, + GrandfatherKeying::LegacyPresetModeIndex, + ) + .unwrap(); + } + + let mut invalid = current.clone(); + invalid.key_positions.get_mut("4key").unwrap()[0].dx = f64::NAN; + let mut invalid_store = store.clone(); + invalid.apply_to_store(&mut invalid_store); + assert!(validate_document_transition_with_keying( + ¤t, + &invalid, + &store, + &invalid_store, + GrandfatherKeying::LegacyPresetModeIndex, + ) + .is_err()); +} + +#[test] +fn legacy_preset_keying_still_rejects_non_positive_dimensions() { + let store = store_with_each_position_collection(); + let current = EditorDocumentV1::from_store(&store); + + for (width, height) in [(0.0, 60.0), (-1.0, 60.0), (60.0, 0.0)] { + let mut candidate = current.clone(); + let position = position_mut(&mut candidate, "keyPositions"); + position.width = width; + position.height = height; + let mut candidate_store = store.clone(); + candidate.apply_to_store(&mut candidate_store); + crate::state::native_element_id::rekey_store_element_ids(&mut candidate_store); + let candidate = EditorDocumentV1::from_store(&candidate_store); + + let error = validate_document_transition_with_keying( + ¤t, + &candidate, + &store, + &candidate_store, + GrandfatherKeying::LegacyPresetModeIndex, + ) + .unwrap_err(); + assert_eq!( + error.details.unwrap().validation_code.as_deref(), + Some("DIMENSION_OUT_OF_RANGE") + ); + } +} + +#[test] +fn unconditional_structural_violation_is_rejected_even_when_unchanged() { + let mut store = default_editor_store(); + store.keys.get_mut("4key").unwrap().pop(); + let document = EditorDocumentV1::from_store(&store); + + let error = validate_document_transition(&document, &document, &store, &store).unwrap_err(); + assert_eq!( + error.details.unwrap().validation_code.as_deref(), + Some("KEY_POSITION_LENGTH_MISMATCH") + ); +} + +#[test] +fn stage_four_per_owner_limits_follow_stable_ids_across_reorder() { + let mut label_store = default_editor_store(); + label_store.keys.get_mut("4key").unwrap()[0] = + KeySlot::from("x".repeat(MAX_KEY_LABEL_BYTES + 1)); + let current_labels = EditorDocumentV1::from_store(&label_store); + validate_document_transition(¤t_labels, ¤t_labels, &label_store, &label_store) + .unwrap(); + + let mut moved_label = current_labels.clone(); + moved_label.keys.get_mut("4key").unwrap().swap(0, 1); + moved_label + .key_positions + .get_mut("4key") + .unwrap() + .swap(0, 1); + let mut moved_label_store = label_store.clone(); + moved_label.apply_to_store(&mut moved_label_store); + validate_document_transition( + ¤t_labels, + &moved_label, + &label_store, + &moved_label_store, + ) + .unwrap(); + + let mut coordinate_store = default_editor_store(); + coordinate_store.key_positions.get_mut("4key").unwrap()[0].dx = MAX_ABS_COORDINATE + 1.0; + let current_coordinates = EditorDocumentV1::from_store(&coordinate_store); + validate_document_transition( + ¤t_coordinates, + ¤t_coordinates, + &coordinate_store, + &coordinate_store, + ) + .unwrap(); + + let mut moved_coordinate = current_coordinates.clone(); + moved_coordinate + .key_positions + .get_mut("4key") + .unwrap() + .swap(0, 1); + moved_coordinate.keys.get_mut("4key").unwrap().swap(0, 1); + let mut moved_coordinate_store = coordinate_store.clone(); + moved_coordinate.apply_to_store(&mut moved_coordinate_store); + validate_document_transition( + ¤t_coordinates, + &moved_coordinate, + &coordinate_store, + &moved_coordinate_store, + ) + .unwrap(); +} + +#[test] +fn stage_four_new_element_has_no_metric_allowance() { + let store = default_editor_store(); + let current = EditorDocumentV1::from_store(&store); + let mut candidate = current.clone(); + candidate + .keys + .get_mut("4key") + .unwrap() + .push(KeySlot::from("NEW")); + candidate + .key_positions + .get_mut("4key") + .unwrap() + .push(KeyPosition { + id: Uuid::new_v4().to_string(), + dx: MAX_ABS_COORDINATE + 1.0, + ..KeyPosition::default() + }); + let mut candidate_store = store.clone(); + candidate.apply_to_store(&mut candidate_store); + + let error = + validate_document_transition(¤t, &candidate, &store, &candidate_store).unwrap_err(); + assert_eq!( + error.details.unwrap().validation_code.as_deref(), + Some("COORDINATE_OUT_OF_RANGE") + ); +} + +#[test] +fn stage_four_deleted_element_is_excluded_from_per_owner_comparison() { + let mut store = default_editor_store(); + store.key_positions.get_mut("4key").unwrap()[0].dx = MAX_ABS_COORDINATE + 1.0; + let current = EditorDocumentV1::from_store(&store); + let deleted_id = current.key_positions["4key"][0].id.clone(); + let mut candidate = current.clone(); + candidate.keys.get_mut("4key").unwrap().remove(0); + candidate.key_positions.get_mut("4key").unwrap().remove(0); + let mut candidate_store = store.clone(); + candidate.apply_to_store(&mut candidate_store); + + validate_document_transition(¤t, &candidate, &store, &candidate_store).unwrap(); + assert!(candidate.key_positions["4key"] + .iter() + .all(|position| position.id != deleted_id)); +} + +#[test] +fn stage_four_multi_key_label_allowances_are_consumed_once() { + let mut store = default_editor_store(); + store.keys.get_mut("4key").unwrap()[0] = KeySlot::Multi { + keys: vec![ + "x".repeat(MAX_KEY_LABEL_BYTES + 100), + "y".repeat(MAX_KEY_LABEL_BYTES + 200), + ], + match_mode: crate::models::SlotMatch::Any, + }; + let current = EditorDocumentV1::from_store(&store); + + let mut non_increasing = current.clone(); + non_increasing.keys.get_mut("4key").unwrap()[0] = KeySlot::Multi { + keys: vec![ + "a".repeat(MAX_KEY_LABEL_BYTES + 150), + "b".repeat(MAX_KEY_LABEL_BYTES + 50), + ], + match_mode: crate::models::SlotMatch::Any, + }; + let mut non_increasing_store = store.clone(); + non_increasing.apply_to_store(&mut non_increasing_store); + validate_document_transition(¤t, &non_increasing, &store, &non_increasing_store).unwrap(); + + let mut duplicated_allowance = non_increasing.clone(); + let KeySlot::Multi { keys, .. } = &mut duplicated_allowance.keys.get_mut("4key").unwrap()[0] + else { + unreachable!() + }; + keys.push("c".repeat(MAX_KEY_LABEL_BYTES + 25)); + let mut duplicated_store = store.clone(); + duplicated_allowance.apply_to_store(&mut duplicated_store); + let error = + validate_document_transition(¤t, &duplicated_allowance, &store, &duplicated_store) + .unwrap_err(); + assert_eq!( + error.details.unwrap().validation_code.as_deref(), + Some("KEY_LABEL_TOO_LONG") + ); +} + +#[test] +fn stage_four_group_name_limit_follows_group_id_after_reorder() { + let mut store = default_editor_store(); + store.layer_groups.insert( + "4key".to_string(), + vec![ + LayerGroupDef { + id: "oversized".to_string(), + name: "x".repeat(MAX_GROUP_NAME_BYTES + 1), + }, + LayerGroupDef { + id: "normal".to_string(), + name: "Normal".to_string(), + }, + ], + ); + let current = EditorDocumentV1::from_store(&store); + let mut reordered = current.clone(); + reordered.layer_groups.get_mut("4key").unwrap().swap(0, 1); + let mut reordered_store = store.clone(); + reordered.apply_to_store(&mut reordered_store); + validate_document_transition(¤t, &reordered, &store, &reordered_store).unwrap(); + + let mut changed_id = reordered; + changed_id.layer_groups.get_mut("4key").unwrap()[1].id = "new-id".to_string(); + let mut changed_id_store = store.clone(); + changed_id.apply_to_store(&mut changed_id_store); + let error = + validate_document_transition(¤t, &changed_id, &store, &changed_id_store).unwrap_err(); + assert_eq!( + error.details.unwrap().validation_code.as_deref(), + Some("GROUP_NAME_TOO_LONG") + ); +} + +#[test] +fn aggregate_render_limit_compares_total_candidate_and_current_counts() { + let mut store = store_with_custom_modes(8); + for index in 0..8 { + let mode = format!("custom-{index}"); + store + .keys + .insert(mode.clone(), vec![KeySlot::default(); 512]); + store + .key_positions + .insert(mode, vec![KeyPosition::default(); 512]); + } + store.stat_positions.insert( + "custom-0".to_string(), + vec![ + StatPosition { + stat_type: StatType::Kps, + position: KeyPosition::default(), + }; + 2 + ], + ); + let current = EditorDocumentV1::from_store(&store); + + let mut same_total = current.clone(); + same_total.stat_positions.get_mut("custom-0").unwrap().pop(); + same_total.graph_positions.insert( + "custom-0".to_string(), + vec![GraphPosition { + stat_type: GraphStatType::Kps, + graph_type: GraphType::Line, + graph_speed: 100, + graph_color: "#123456".to_string(), + show_avg_line: true, + position: KeyPosition::default(), + }], + ); + let mut same_total_store = store.clone(); + same_total.apply_to_store(&mut same_total_store); + validate_document_transition(¤t, &same_total, &store, &same_total_store).unwrap(); + + let mut increased = same_total.clone(); + increased + .stat_positions + .get_mut("custom-0") + .unwrap() + .push(StatPosition { + stat_type: StatType::Kps, + position: KeyPosition::default(), + }); + let mut increased_store = same_total_store.clone(); + increased.apply_to_store(&mut increased_store); + let error = + validate_document_transition(¤t, &increased, &store, &increased_store).unwrap_err(); + assert_eq!( + error.details.unwrap().validation_code.as_deref(), + Some("TOO_MANY_RENDER_ITEMS") + ); +} + +#[test] +fn violation_categories_keep_their_existing_grandfathering_decisions() { + let mut mode_store = AppStoreData::default(); + mode_store + .keys + .insert("ghost".to_string(), vec![KeySlot::from("A")]); + mode_store + .key_positions + .insert("ghost".to_string(), vec![KeyPosition::default()]); + let mode_document = EditorDocumentV1::from_store(&mode_store); + validate_document_transition(&mode_document, &mode_document, &mode_store, &mode_store).unwrap(); + + let mut pair_store = default_editor_store(); + pair_store.keys.get_mut("4key").unwrap().pop(); + let pair_document = EditorDocumentV1::from_store(&pair_store); + let pair_error = + validate_document_transition(&pair_document, &pair_document, &pair_store, &pair_store) + .unwrap_err(); + assert_eq!( + pair_error.details.unwrap().validation_code.as_deref(), + Some("KEY_POSITION_LENGTH_MISMATCH") + ); + + let mut group_store = default_editor_store(); + group_store.layer_groups.insert( + "4key".to_string(), + vec![LayerGroupDef { + id: String::new(), + name: "Group".to_string(), + }], + ); + let group_document = EditorDocumentV1::from_store(&group_store); + validate_document_transition(&group_document, &group_document, &group_store, &group_store) + .unwrap(); + + let mut element_store = default_editor_store(); + let mut shadow = valid_shadow(); + shadow.blur = MAX_SHADOW_BLUR + 1.0; + element_store.key_positions.get_mut("4key").unwrap()[0].shadow = Some(shadow); + let element_document = EditorDocumentV1::from_store(&element_store); + validate_document_transition( + &element_document, + &element_document, + &element_store, + &element_store, + ) + .unwrap(); +} + +#[test] +fn oversized_per_mode_collection_is_grandfathered_only_when_non_increasing() { + let mut store = store_with_custom_modes(1); + store.keys.insert( + "custom-0".to_string(), + (0..514) + .map(|index| KeySlot::from(format!("Key{index}"))) + .collect(), + ); + store + .key_positions + .insert("custom-0".to_string(), vec![KeyPosition::default(); 514]); + let current = EditorDocumentV1::from_store(&store); + validate_document_transition(¤t, ¤t, &store, &store).unwrap(); + + let mut decreased = current.clone(); + decreased.keys.get_mut("custom-0").unwrap().pop(); + decreased.key_positions.get_mut("custom-0").unwrap().pop(); + let mut decreased_store = store.clone(); + decreased.apply_to_store(&mut decreased_store); + validate_document_transition(¤t, &decreased, &store, &decreased_store).unwrap(); + + let mut increased = current.clone(); + increased + .keys + .get_mut("custom-0") + .unwrap() + .push(KeySlot::from("Extra")); + increased + .key_positions + .get_mut("custom-0") + .unwrap() + .push(KeyPosition::default()); + let mut increased_store = store.clone(); + increased.apply_to_store(&mut increased_store); + assert!(validate_document_transition(¤t, &increased, &store, &increased_store).is_err()); +} + +#[test] +fn oversized_mode_count_is_grandfathered_only_when_non_increasing() { + let mut store = store_with_custom_modes(65); + for index in 0..65 { + store.keys.insert(format!("custom-{index}"), Vec::new()); + store + .key_positions + .insert(format!("custom-{index}"), Vec::new()); + } + let current = EditorDocumentV1::from_store(&store); + validate_document_transition(¤t, ¤t, &store, &store).unwrap(); + + let mut decreased = current.clone(); + decreased.keys.remove("custom-64"); + decreased.key_positions.remove("custom-64"); + let mut decreased_store = store.clone(); + decreased.apply_to_store(&mut decreased_store); + validate_document_transition(¤t, &decreased, &store, &decreased_store).unwrap(); + + let mut increased_store = store.clone(); + increased_store.custom_tabs.push(CustomTab { + id: "custom-65".to_string(), + name: "Custom 65".to_string(), + }); + let mut increased = current.clone(); + increased.keys.insert("custom-65".to_string(), Vec::new()); + increased + .key_positions + .insert("custom-65".to_string(), Vec::new()); + increased.apply_to_store(&mut increased_store); + assert!(validate_document_transition(¤t, &increased, &store, &increased_store).is_err()); +} + +#[test] +fn count_limits_accept_exact_boundaries_and_reject_boundary_plus_one() { + let mode_store = store_with_custom_modes(65); + let empty = EditorDocumentV1::from_store(&mode_store); + let mut modes_at_limit = empty.clone(); + for index in 0..64 { + modes_at_limit + .keys + .insert(format!("custom-{index}"), Vec::new()); + modes_at_limit + .key_positions + .insert(format!("custom-{index}"), Vec::new()); + } + let mut modes_at_limit_store = mode_store.clone(); + modes_at_limit.apply_to_store(&mut modes_at_limit_store); + validate_document_transition(&empty, &modes_at_limit, &mode_store, &modes_at_limit_store) + .unwrap(); + let mut too_many_modes = modes_at_limit.clone(); + too_many_modes + .keys + .insert("custom-64".to_string(), Vec::new()); + too_many_modes + .key_positions + .insert("custom-64".to_string(), Vec::new()); + let mut too_many_modes_store = mode_store.clone(); + too_many_modes.apply_to_store(&mut too_many_modes_store); + assert!(validate_document_transition( + &empty, + &too_many_modes, + &mode_store, + &too_many_modes_store, + ) + .is_err()); + + let collection_store = store_with_custom_modes(1); + let collection_empty = EditorDocumentV1::from_store(&collection_store); + let mut collection_at_limit = collection_empty.clone(); + collection_at_limit.keys.insert( + "custom-0".to_string(), + (0..512) + .map(|index| KeySlot::from(format!("Key{index}"))) + .collect(), + ); + collection_at_limit + .key_positions + .insert("custom-0".to_string(), vec![KeyPosition::default(); 512]); + let mut collection_at_limit_store = collection_store.clone(); + collection_at_limit.apply_to_store(&mut collection_at_limit_store); + validate_document_transition( + &collection_empty, + &collection_at_limit, + &collection_store, + &collection_at_limit_store, + ) + .unwrap(); + let mut collection_too_large = collection_at_limit.clone(); + collection_too_large + .keys + .get_mut("custom-0") + .unwrap() + .push(KeySlot::from("Extra")); + collection_too_large + .key_positions + .get_mut("custom-0") + .unwrap() + .push(KeyPosition::default()); + let mut collection_too_large_store = collection_store.clone(); + collection_too_large.apply_to_store(&mut collection_too_large_store); + assert!(validate_document_transition( + &collection_empty, + &collection_too_large, + &collection_store, + &collection_too_large_store, + ) + .is_err()); + + let render_store = store_with_custom_modes(8); + let render_empty = EditorDocumentV1::from_store(&render_store); + let mut render_at_limit = render_empty.clone(); + for index in 0..8 { + let mode = format!("custom-{index}"); + render_at_limit + .keys + .insert(mode.clone(), vec![KeySlot::default(); 512]); + render_at_limit + .key_positions + .insert(mode, vec![KeyPosition::default(); 512]); + } + let mut render_at_limit_store = render_store.clone(); + render_at_limit.apply_to_store(&mut render_at_limit_store); + validate_document_transition( + &render_empty, + &render_at_limit, + &render_store, + &render_at_limit_store, + ) + .unwrap(); + let mut too_many_render_items = render_at_limit.clone(); + too_many_render_items.stat_positions.insert( + "custom-0".to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: KeyPosition::default(), + }], + ); + let mut too_many_render_items_store = render_store.clone(); + too_many_render_items.apply_to_store(&mut too_many_render_items_store); + assert!(validate_document_transition( + &render_empty, + &too_many_render_items, + &render_store, + &too_many_render_items_store, + ) + .is_err()); + + let group_store = store_with_custom_modes(9); + let group_empty = EditorDocumentV1::from_store(&group_store); + let mut groups_at_limit = group_empty.clone(); + for index in 0..8 { + groups_at_limit.layer_groups.insert( + format!("custom-{index}"), + (0..512) + .map(|group| LayerGroupDef { + id: format!("g-{index}-{group}"), + name: "Group".to_string(), + }) + .collect(), + ); + } + let mut groups_at_limit_store = group_store.clone(); + groups_at_limit.apply_to_store(&mut groups_at_limit_store); + validate_document_transition( + &group_empty, + &groups_at_limit, + &group_store, + &groups_at_limit_store, + ) + .unwrap(); + let mut too_many_groups = groups_at_limit.clone(); + too_many_groups.layer_groups.insert( + "custom-8".to_string(), + vec![LayerGroupDef { + id: "extra-group".to_string(), + name: "Group".to_string(), + }], + ); + let mut too_many_groups_store = group_store.clone(); + too_many_groups.apply_to_store(&mut too_many_groups_store); + assert!(validate_document_transition( + &group_empty, + &too_many_groups, + &group_store, + &too_many_groups_store, + ) + .is_err()); +} + +#[test] +fn revision_and_request_id_wire_limits_are_enforced() { + assert!(validate_revision(MAX_SAFE_WIRE_REVISION).is_ok()); + assert!(validate_revision(MAX_SAFE_WIRE_REVISION + 1).is_err()); + assert!(next_revision(MAX_SAFE_WIRE_REVISION).is_err()); + + let invalid = EditorCommitRequest { + base_revision: 0, + mutation_id: "not-a-uuid".to_string(), + multi_key: false, + gesture_id: None, + gesture_ids: Vec::new(), + changes: Some(EditorPatchV1::default()), + ops_version: None, + ops: None, + }; + assert!(validate_request_envelope(&invalid).is_err()); + + let mut oversized_gesture = request(KeyMappings::new()); + oversized_gesture.gesture_id = Some("가".repeat(22)); + assert_eq!(oversized_gesture.gesture_id.as_ref().unwrap().len(), 66); + assert!(validate_request_envelope(&oversized_gesture).is_err()); + + let mut oversized_merged_gesture = request(KeyMappings::new()); + oversized_merged_gesture.gesture_ids = vec!["가".repeat(22)]; + assert!(validate_request_envelope(&oversized_merged_gesture).is_err()); + + let mut malformed_gesture = request(KeyMappings::new()); + malformed_gesture.gesture_ids = vec!["not-a-uuid".to_string()]; + let malformed_error = validate_request_envelope(&malformed_gesture).unwrap_err(); + assert_eq!( + malformed_error.error_code, + crate::errors::EditorCommitErrorCode::InvalidGestureId + ); + assert!(!malformed_error.retryable); + + let mut too_many_gestures = request(KeyMappings::new()); + too_many_gestures.gesture_ids = (0..=MAX_GESTURE_IDS) + .map(|index| Uuid::from_u128(index as u128 + 1).to_string()) + .collect(); + let count_error = validate_request_envelope(&too_many_gestures).unwrap_err(); + assert_eq!( + count_error.error_code, + crate::errors::EditorCommitErrorCode::TooManyGestureIds + ); + assert!(!count_error.retryable); + + let mut representative_overflow = request(KeyMappings::new()); + representative_overflow.gesture_ids = (0..MAX_GESTURE_IDS) + .map(|index| Uuid::from_u128(index as u128 + 1).to_string()) + .collect(); + representative_overflow.gesture_id = Some(Uuid::from_u128(u128::MAX).to_string()); + assert!(validate_request_envelope(&representative_overflow).is_err()); +} + +#[test] +fn oversized_render_and_group_totals_only_allow_non_increasing_changes() { + let mut render_store = store_with_custom_modes(8); + for index in 0..8 { + let mode = format!("custom-{index}"); + render_store + .keys + .insert(mode.clone(), vec![KeySlot::default(); 512]); + render_store + .key_positions + .insert(mode, vec![KeyPosition::default(); 512]); + } + render_store.stat_positions.insert( + "custom-0".to_string(), + vec![ + StatPosition { + stat_type: StatType::Kps, + position: KeyPosition::default(), + }; + 2 + ], + ); + let current_render = EditorDocumentV1::from_store(&render_store); + validate_document_transition( + ¤t_render, + ¤t_render, + &render_store, + &render_store, + ) + .unwrap(); + let mut less_render = current_render.clone(); + less_render + .stat_positions + .get_mut("custom-0") + .unwrap() + .pop(); + let mut less_render_store = render_store.clone(); + less_render.apply_to_store(&mut less_render_store); + validate_document_transition( + ¤t_render, + &less_render, + &render_store, + &less_render_store, + ) + .unwrap(); + let mut more_render = current_render.clone(); + more_render + .stat_positions + .get_mut("custom-0") + .unwrap() + .push(StatPosition { + stat_type: StatType::Kps, + position: KeyPosition::default(), + }); + let mut more_render_store = render_store.clone(); + more_render.apply_to_store(&mut more_render_store); + assert!(validate_document_transition( + ¤t_render, + &more_render, + &render_store, + &more_render_store, + ) + .is_err()); + + let mut group_store = store_with_custom_modes(9); + for index in 0..8 { + group_store.layer_groups.insert( + format!("custom-{index}"), + (0..512) + .map(|group| LayerGroupDef { + id: format!("g-{index}-{group}"), + name: "Group".to_string(), + }) + .collect(), + ); + } + group_store.layer_groups.insert( + "custom-8".to_string(), + vec![ + LayerGroupDef { + id: "g-8-0".to_string(), + name: "Group".to_string(), + }, + LayerGroupDef { + id: "g-8-1".to_string(), + name: "Group".to_string(), + }, + ], + ); + let current_groups = EditorDocumentV1::from_store(&group_store); + validate_document_transition(¤t_groups, ¤t_groups, &group_store, &group_store) + .unwrap(); + let mut less_groups = current_groups.clone(); + less_groups.layer_groups.get_mut("custom-8").unwrap().pop(); + let mut less_group_store = group_store.clone(); + less_groups.apply_to_store(&mut less_group_store); + validate_document_transition( + ¤t_groups, + &less_groups, + &group_store, + &less_group_store, + ) + .unwrap(); + let mut more_groups = current_groups.clone(); + more_groups + .layer_groups + .get_mut("custom-8") + .unwrap() + .push(LayerGroupDef { + id: "g-8-2".to_string(), + name: "Group".to_string(), + }); + let mut more_group_store = group_store.clone(); + more_groups.apply_to_store(&mut more_group_store); + assert!(validate_document_transition( + ¤t_groups, + &more_groups, + &group_store, + &more_group_store, + ) + .is_err()); +} + +#[test] +fn pair_violations_stay_unconditional_but_group_references_follow_element_ids() { + let mut pair_store = default_editor_store(); + pair_store.keys.get_mut("4key").unwrap().pop(); + let pair_document = EditorDocumentV1::from_store(&pair_store); + assert!( + validate_document_transition(&pair_document, &pair_document, &pair_store, &pair_store,) + .is_err() + ); + + let mut reference_store = default_editor_store(); + reference_store.key_positions.get_mut("4key").unwrap()[0].group_id = + Some("missing".to_string()); + let reference_document = EditorDocumentV1::from_store(&reference_store); + let mut reordered_reference = reference_document.clone(); + reordered_reference.keys.get_mut("4key").unwrap().swap(0, 1); + reordered_reference + .key_positions + .get_mut("4key") + .unwrap() + .swap(0, 1); + let mut reordered_store = reference_store.clone(); + reordered_reference.apply_to_store(&mut reordered_store); + assert!(validate_document_transition( + &reference_document, + &reordered_reference, + &reference_store, + &reordered_store, + ) + .is_ok()); +} + +#[test] +fn compact_request_size_boundaries_are_exact() { + fn sized_request(target_bytes: usize) -> EditorCommitRequest { + let mut empty_keys = KeyMappings::new(); + empty_keys.insert("4key".to_string(), vec![KeySlot::default()]); + let empty = request(empty_keys); + let overhead = serde_json::to_vec(&empty).unwrap().len(); + assert!(target_bytes >= overhead); + + let mut keys = KeyMappings::new(); + keys.insert( + "4key".to_string(), + vec![KeySlot::from("x".repeat(target_bytes - overhead))], + ); + let request = EditorCommitRequest { + mutation_id: empty.mutation_id, + ..request(keys) + }; + assert_eq!(serde_json::to_vec(&request).unwrap().len(), target_bytes); + request + } + + assert_eq!( + request_payload_size(&sized_request(REQUEST_WARNING_BYTES - 1)).unwrap(), + REQUEST_WARNING_BYTES - 1 + ); + assert_eq!( + request_payload_size(&sized_request(REQUEST_WARNING_BYTES)).unwrap(), + REQUEST_WARNING_BYTES + ); + assert_eq!( + request_payload_size(&sized_request(MAX_REQUEST_BYTES)).unwrap(), + MAX_REQUEST_BYTES + ); + let error = request_payload_size(&sized_request(MAX_REQUEST_BYTES + 1)).unwrap_err(); + assert_eq!( + error.details.unwrap().validation_code.as_deref(), + Some("REQUEST_TOO_LARGE") + ); +} diff --git a/src-tauri/src/state/editor_ops.rs b/src-tauri/src/state/editor_ops.rs index c532f47f4..cd6265070 100644 --- a/src-tauri/src/state/editor_ops.rs +++ b/src-tauri/src/state/editor_ops.rs @@ -2937,7218 +2937,4 @@ pub(crate) fn prepare_editor_ops_transition_with_plugin_refs( } #[cfg(test)] -mod tests { - use crate::{ - defaults::default_keys, - models::{ - AppStoreData, EditorElementGroupTargetV1, EditorElementPropertyPatchV1, - EditorFrozenElementV1, EditorFrozenGroupV1, EditorFrozenKeySlotV1, EditorGroupUpdateV1, - EditorOpResultStatusV1, EditorOpV1, EditorTargetGroupV1, EditorZUpdateV1, - GraphPosition, GraphStatType, GraphType, KeyPosition, KnobPosition, StatPosition, - StatType, - }, - state::native_element_id::backfill_store_element_ids, - }; - - use super::*; - - fn base_store() -> AppStoreData { - let mut store = AppStoreData { - keys: default_keys().clone(), - key_positions: default_keys() - .iter() - .map(|(mode, keys)| (mode.clone(), vec![KeyPosition::default(); keys.len()])) - .collect(), - ..AppStoreData::default() - }; - backfill_store_element_ids(&mut store); - store - } - - fn insert_op(store: &AppStoreData) -> EditorOpV1 { - let mut position = KeyPosition { - id: uuid::Uuid::new_v4().to_string(), - dx: 100.0, - dy: 120.0, - z_index: Some(50), - group_id: Some("frozen-group".to_string()), - ..KeyPosition::default() - }; - position.width = 90.0; - EditorOpV1::InsertFrozenElements { - mode: "4key".to_string(), - elements: vec![EditorFrozenElementV1::Key { - slot: EditorFrozenKeySlotV1::Single("FROZEN".to_string()), - position, - }], - groups: vec![EditorFrozenGroupV1 { - id: "frozen-group".to_string(), - name: "Frozen Group".to_string(), - }], - z_updates: vec![EditorZUpdateV1 { - element_type: EditorElementTypeV1::Key, - id: store.key_positions["4key"][0].id.clone(), - z_index: 1, - }], - } - } - - fn store_with_every_reorder_type() -> AppStoreData { - let mut store = base_store(); - store.stat_positions.insert( - "4key".to_string(), - vec![StatPosition { - stat_type: StatType::Kps, - position: KeyPosition::default(), - }], - ); - store.graph_positions.insert( - "4key".to_string(), - vec![GraphPosition { - stat_type: GraphStatType::Kps, - graph_type: GraphType::Line, - graph_speed: 100, - graph_color: "#123456".to_string(), - show_avg_line: true, - position: KeyPosition::default(), - }], - ); - store.knob_positions.insert( - "4key".to_string(), - vec![KnobPosition { - axis_id: "axis".to_string(), - sensitivity: 1.0, - reverse: false, - position: KeyPosition::default(), - }], - ); - backfill_store_element_ids(&mut store); - store - } - - fn complete_reorder_op(store: &AppStoreData) -> EditorOpV1 { - let mut z_updates = Vec::new(); - for (element_type, ids) in [ - ( - EditorElementTypeV1::Key, - store.key_positions["4key"] - .iter() - .map(|position| position.id.clone()) - .collect::>(), - ), - ( - EditorElementTypeV1::Stat, - store.stat_positions["4key"] - .iter() - .map(|position| position.position.id.clone()) - .collect(), - ), - ( - EditorElementTypeV1::Graph, - store.graph_positions["4key"] - .iter() - .map(|position| position.position.id.clone()) - .collect(), - ), - ( - EditorElementTypeV1::Knob, - store.knob_positions["4key"] - .iter() - .map(|position| position.position.id.clone()) - .collect(), - ), - ] { - for id in ids { - z_updates.push(EditorZUpdateV1 { - element_type, - id, - z_index: z_updates.len() as i32 + 10, - }); - } - } - EditorOpV1::ReorderElements { - mode: "4key".to_string(), - complete_mode_order: true, - z_updates, - group_updates: Vec::new(), - } - } - - fn patch_hidden_op( - element_type: EditorElementTypeV1, - id: impl Into, - hidden: bool, - ) -> EditorOpV1 { - EditorOpV1::PatchElement { - element_type, - id: id.into(), - patch: EditorElementPropertyPatchV1::Hidden(hidden), - } - } - - fn patch_layer_name_op( - element_type: EditorElementTypeV1, - id: impl Into, - layer_name: Option<&str>, - ) -> EditorOpV1 { - EditorOpV1::PatchElement { - element_type, - id: id.into(), - patch: EditorElementPropertyPatchV1::LayerName(layer_name.map(str::to_string)), - } - } - - fn patch_graph_type_op( - element_type: EditorElementTypeV1, - id: impl Into, - graph_type: GraphType, - ) -> EditorOpV1 { - EditorOpV1::PatchElement { - element_type, - id: id.into(), - patch: EditorElementPropertyPatchV1::GraphType(graph_type), - } - } - - fn patch_graph_color_op( - element_type: EditorElementTypeV1, - id: impl Into, - graph_color: impl Into, - ) -> EditorOpV1 { - EditorOpV1::PatchElement { - element_type, - id: id.into(), - patch: EditorElementPropertyPatchV1::GraphColor(graph_color.into()), - } - } - - fn patch_property_op( - element_type: EditorElementTypeV1, - id: impl Into, - patch: EditorElementPropertyPatchV1, - ) -> EditorOpV1 { - EditorOpV1::PatchElement { - element_type, - id: id.into(), - patch, - } - } - - fn validation_code(error: &EditorCommitError) -> Option<&str> { - error - .details - .as_ref() - .and_then(|details| details.validation_code.as_deref()) - } - - fn assert_note_body_glow_paint_equal(position: &KeyPosition) { - assert_eq!(position.note_glow_gradient, position.note_gradient); - assert_eq!(position.note_glow_opacity, position.note_opacity); - assert_eq!( - position.note_glow_color.as_ref(), - Some(&position.note_color) - ); - assert_eq!(position.note_glow_opacity_top, position.note_opacity_top); - assert_eq!( - position.note_glow_opacity_bottom, - position.note_opacity_bottom - ); - } - - fn paint_descriptor( - color: &str, - gradient: Option<(f64, &[(&str, f64)])>, - ) -> crate::models::EditorPaintDescriptorV1 { - crate::models::EditorPaintDescriptorV1 { - color: color.to_string(), - gradient: gradient.map(|(angle, stops)| crate::models::EditorPaintGradientV1 { - angle, - stops: stops - .iter() - .map(|(color, pos)| crate::models::EditorPaintGradientStopV1 { - color: (*color).to_string(), - pos: *pos, - }) - .collect(), - }), - } - } - - fn counter_fill_solid(color: &str) -> EditorCounterFillIntentV1 { - EditorCounterFillIntentV1::Solid(crate::models::EditorCounterFillSolidIntentV1 { - color: color.to_string(), - }) - } - - fn counter_fill_gradient( - color: &str, - angle: f64, - stops: &[(&str, f64)], - ) -> EditorCounterFillIntentV1 { - EditorCounterFillIntentV1::Gradient(crate::models::EditorCounterFillGradientIntentV1 { - color: color.to_string(), - gradient: crate::models::EditorPaintGradientV1 { - angle, - stops: stops - .iter() - .map(|(color, pos)| crate::models::EditorPaintGradientStopV1 { - color: (*color).to_string(), - pos: *pos, - }) - .collect(), - }, - }) - } - - fn shadow_leaf_color(color: &str) -> EditorShadowLeafPatchV1 { - EditorShadowLeafPatchV1::Color(color.to_string()) - } - - fn shadow_leaf_offset_x(offset_x: f64) -> EditorShadowLeafPatchV1 { - EditorShadowLeafPatchV1::OffsetX(offset_x) - } - - fn shadow_leaf_offset_y(offset_y: f64) -> EditorShadowLeafPatchV1 { - EditorShadowLeafPatchV1::OffsetY(offset_y) - } - - fn shadow_leaf_blur(blur: f64) -> EditorShadowLeafPatchV1 { - EditorShadowLeafPatchV1::Blur(blur) - } - - struct PatchTargetMatrixRow { - name: &'static str, - allowed: &'static [EditorElementTypeV1], - patch: EditorElementPropertyPatchV1, - } - - // 속성 계열 × elementType 허용 매트릭스 - TS validator(src/types/editor.ts)와 동일 집합 - fn patch_target_type_matrix() -> Vec { - use crate::models::{ - EditorNoteBorderSideV1, ImageFit, ImageMode, ImageTransformLeafPatchV1, - KeyCounterAlign, KeyCounterAlignMode, KeyCounterPlacement, NoteAlignment, StatType, - }; - use EditorElementPropertyPatchV1 as Patch; - use EditorElementTypeV1::{Graph, Key, Knob, Stat}; - - const ALL: &[EditorElementTypeV1] = &[Key, Stat, Graph, Knob]; - const GRAPH_ONLY: &[EditorElementTypeV1] = &[Graph]; - const KNOB_ONLY: &[EditorElementTypeV1] = &[Knob]; - const STAT_ONLY: &[EditorElementTypeV1] = &[Stat]; - const KEY_ONLY: &[EditorElementTypeV1] = &[Key]; - const KEY_STAT: &[EditorElementTypeV1] = &[Key, Stat]; - const KEY_KNOB: &[EditorElementTypeV1] = &[Key, Knob]; - const KEY_STAT_KNOB: &[EditorElementTypeV1] = &[Key, Stat, Knob]; - - let row = |name, allowed, patch| PatchTargetMatrixRow { - name, - allowed, - patch, - }; - let note_opacity = |opacity| { - EditorNotePaintIntentV1::Opacity(crate::models::EditorNotePaintOpacityIntentV1 { - opacity, - }) - }; - vec![ - row("hidden", ALL, Patch::Hidden(true)), - row( - "layerName", - ALL, - Patch::LayerName(Some("layer".to_string())), - ), - row("graphType", GRAPH_ONLY, Patch::GraphType(GraphType::Bar)), - row( - "graphColor", - GRAPH_ONLY, - Patch::GraphColor("#336699".to_string()), - ), - row("showAvgLine", GRAPH_ONLY, Patch::ShowAvgLine(false)), - row( - "graphAnimationEnabled", - GRAPH_ONLY, - Patch::GraphAnimationEnabled(true), - ), - row("graphSpeed", GRAPH_ONLY, Patch::GraphSpeed(120)), - row("reverse", KNOB_ONLY, Patch::Reverse(true)), - row("sensitivity", KNOB_ONLY, Patch::Sensitivity(1.5)), - row("axisId", KNOB_ONLY, Patch::AxisId("axis-2".to_string())), - row("useInlineStyles", ALL, Patch::UseInlineStyles(true)), - row("fontWeight", ALL, Patch::FontWeight(700)), - row("fontBold", ALL, Patch::FontBold(true)), - row("fontItalic", ALL, Patch::FontItalic(true)), - row("fontUnderline", ALL, Patch::FontUnderline(true)), - row("fontStrikethrough", ALL, Patch::FontStrikethrough(true)), - row("fontFamily", ALL, Patch::FontFamily("Sans".to_string())), - row("displayText", ALL, Patch::DisplayText("A".to_string())), - row("className", ALL, Patch::ClassName("custom".to_string())), - row( - "fontPaint", - KEY_STAT, - Patch::FontPaint(paint_descriptor("#ffffff", None)), - ), - row( - "activeFontPaint", - KEY_ONLY, - Patch::ActiveFontPaint(paint_descriptor("#ff0000", None)), - ), - row( - "shadow", - KEY_STAT_KNOB, - Patch::Shadow(shadow_leaf_color("#000000")), - ), - row( - "activeShadow", - KEY_KNOB, - Patch::ActiveShadow(shadow_leaf_color("#000000")), - ), - row("shadowEnabled", KEY_STAT_KNOB, Patch::ShadowEnabled(true)), - row( - "backgroundPaint", - ALL, - Patch::BackgroundPaint(paint_descriptor("#112233", None)), - ), - row( - "activeBackgroundPaint", - KEY_KNOB, - Patch::ActiveBackgroundPaint(paint_descriptor("#112233", None)), - ), - row( - "borderPaint", - ALL, - Patch::BorderPaint(paint_descriptor("#112233", None)), - ), - row( - "activeBorderPaint", - KEY_KNOB, - Patch::ActiveBorderPaint(paint_descriptor("#112233", None)), - ), - row("borderWidth", ALL, Patch::BorderWidth(2.0)), - row("borderRadius", ALL, Patch::BorderRadius(8.0)), - row("fontSize", ALL, Patch::FontSize(16.0)), - row( - "inactiveImage", - ALL, - Patch::InactiveImage("idle.png".to_string()), - ), - row( - "activeImage", - KEY_KNOB, - Patch::ActiveImage("active.png".to_string()), - ), - row("idleTransparent", ALL, Patch::IdleTransparent(true)), - row( - "activeTransparent", - KEY_KNOB, - Patch::ActiveTransparent(true), - ), - row("idleImageFit", ALL, Patch::IdleImageFit(ImageFit::Contain)), - row( - "activeImageFit", - KEY_KNOB, - Patch::ActiveImageFit(ImageFit::Fill), - ), - row("imageMode", KEY_ONLY, Patch::ImageMode(ImageMode::Overlay)), - row( - "idleImageTransform", - KEY_ONLY, - Patch::IdleImageTransform(Some(ImageTransformLeafPatchV1::OffsetX(10.0))), - ), - row( - "activeImageTransform", - KEY_ONLY, - Patch::ActiveImageTransform(Some(ImageTransformLeafPatchV1::Scale(1.5))), - ), - row( - "soundPath", - KEY_ONLY, - Patch::SoundPath("sound.mp3".to_string()), - ), - row("soundEnabled", KEY_ONLY, Patch::SoundEnabled(true)), - row("soundVolume", KEY_ONLY, Patch::SoundVolume(80.0)), - row("counterEnabled", KEY_STAT, Patch::CounterEnabled(true)), - row( - "counterAnimationEnabled", - KEY_STAT, - Patch::CounterAnimationEnabled(true), - ), - row( - "counterPlacement", - KEY_STAT, - Patch::CounterPlacement(KeyCounterPlacement::Outside), - ), - row( - "counterAlign", - KEY_STAT, - Patch::CounterAlign(KeyCounterAlign::Top), - ), - row( - "counterAlignMode", - KEY_STAT, - Patch::CounterAlignMode(KeyCounterAlignMode::Between), - ), - row("counterGap", KEY_STAT, Patch::CounterGap(4)), - row("counterFontSize", KEY_STAT, Patch::CounterFontSize(14)), - row("counterFontWeight", KEY_STAT, Patch::CounterFontWeight(500)), - row("counterFontBold", KEY_STAT, Patch::CounterFontBold(true)), - row( - "counterFontItalic", - KEY_STAT, - Patch::CounterFontItalic(true), - ), - row( - "counterFontUnderline", - KEY_STAT, - Patch::CounterFontUnderline(true), - ), - row( - "counterFontStrikethrough", - KEY_STAT, - Patch::CounterFontStrikethrough(true), - ), - row( - "counterFontFamily", - KEY_STAT, - Patch::CounterFontFamily("Sans".to_string()), - ), - row( - "counterFillIdle", - KEY_STAT, - Patch::CounterFillIdle(counter_fill_solid("#ffffff")), - ), - row( - "counterFillActive", - KEY_ONLY, - Patch::CounterFillActive(counter_fill_solid("#ffffff")), - ), - row( - "counterAnimationPreset", - KEY_STAT, - Patch::CounterAnimationPreset(EditorCounterAnimationPresetIntentV1 { - preset_id: "builtin-ease-out".to_string(), - apply_preset_id: None, - bezier: None, - scale: None, - duration_ms: None, - }), - ), - row("statType", STAT_ONLY, Patch::StatType(StatType::Total)), - row( - "noteEffectEnabled", - KEY_ONLY, - Patch::NoteEffectEnabled(true), - ), - row("noteGlowEnabled", KEY_ONLY, Patch::NoteGlowEnabled(true)), - row( - "noteGlowSyncPaint", - KEY_ONLY, - Patch::NoteGlowSyncPaint(true), - ), - row("noteGlowSize", KEY_ONLY, Patch::NoteGlowSize(10.0)), - row("notePaint", KEY_ONLY, Patch::NotePaint(note_opacity(80))), - row( - "noteGlowPaint", - KEY_ONLY, - Patch::NoteGlowPaint(note_opacity(60)), - ), - row( - "noteBorderPaint", - KEY_ONLY, - Patch::NoteBorderPaint(crate::models::EditorNoteBorderPaintV1 { - color: "#112233".to_string(), - opacity: 80, - gradient: None, - }), - ), - row("noteOffsetX", KEY_ONLY, Patch::NoteOffsetX(Some(10.0))), - row("noteOffsetY", KEY_ONLY, Patch::NoteOffsetY(Some(-10.0))), - row("noteWidth", KEY_ONLY, Patch::NoteWidth(Some(40.0))), - row("noteBorderWidth", KEY_ONLY, Patch::NoteBorderWidth(2.0)), - row("noteBorderRadius", KEY_ONLY, Patch::NoteBorderRadius(8.0)), - row( - "noteAutoYCorrection", - KEY_ONLY, - Patch::NoteAutoYCorrection(true), - ), - row( - "noteAlignment", - KEY_ONLY, - Patch::NoteAlignment(NoteAlignment::Left), - ), - row( - "noteBorderSide", - KEY_ONLY, - Patch::NoteBorderSide(EditorNoteBorderSideV1::Vertical), - ), - ] - } - - #[test] - fn patch_target_type_matrix_pins_all_variant_constraints() { - let store = store_with_every_reorder_type(); - let target_id = |element_type: EditorElementTypeV1| match element_type { - EditorElementTypeV1::Key => store.key_positions["4key"][0].id.clone(), - EditorElementTypeV1::Stat => store.stat_positions["4key"][0].position.id.clone(), - EditorElementTypeV1::Graph => store.graph_positions["4key"][0].position.id.clone(), - EditorElementTypeV1::Knob => store.knob_positions["4key"][0].position.id.clone(), - }; - let rows = patch_target_type_matrix(); - - // 전수 고정. 개수 리터럴은 강제가 아니다 - variant를 추가하고 매트릭스 - // 행을 빠뜨리면 개수만 맞춰도 통과한다. wire 태그 집합과 대조해 - // "모든 variant가 매트릭스에 있다"를 실제로 강제한다 - let row_tags = rows - .iter() - .map(|row| { - serde_json::to_value(&row.patch).expect("patch must serialize")["property"] - .as_str() - .expect("adjacently tagged patch must carry property") - .to_string() - }) - .collect::>(); - let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../tests/fixtures/editor-property-tags.json" - )) - .expect("property tag fixture must be valid json"); - let wire_tags = fixture["properties"] - .as_array() - .expect("fixture.properties must be an array") - .iter() - .map(|tag| { - tag.as_str() - .expect("property tag must be a string") - .to_string() - }) - .collect::>(); - assert_eq!(row_tags, wire_tags, "매트릭스 행과 wire 태그 집합이 다르다"); - assert_eq!(rows.len(), row_tags.len(), "매트릭스에 중복 variant가 있다"); - let distinct = rows - .iter() - .map(|row| std::mem::discriminant(&row.patch)) - .collect::>(); - assert_eq!(distinct.len(), rows.len()); - - for row in &rows { - for element_type in [ - EditorElementTypeV1::Key, - EditorElementTypeV1::Stat, - EditorElementTypeV1::Graph, - EditorElementTypeV1::Knob, - ] { - // 선언 타입과 실제 요소 타입을 일치시켜 계열 제약만 판정되게 고정 - let ops = [patch_property_op( - element_type, - target_id(element_type), - row.patch.clone(), - )]; - let result = prepare_editor_ops_transition(&store, &ops); - if row.allowed.contains(&element_type) { - // 값은 전부 유효하므로 전체 성공이 곧 타입 검증 통과 증명 - assert!( - result.is_ok(), - "{} on {element_type:?} must pass: {:?}", - row.name, - result.err(), - ); - } else { - let Err(error) = result else { - panic!("{} on {element_type:?} must be rejected", row.name); - }; - assert_eq!( - validation_code(&error), - Some("ELEMENT_TYPE_MISMATCH"), - "{} on {element_type:?}", - row.name, - ); - } - } - } - } - - #[test] - fn frozen_insert_appends_key_pair_group_and_existing_z_as_one_transition() { - let store = base_store(); - let op = insert_op(&store); - let before_len = store.key_positions["4key"].len(); - - let transition = prepare_editor_ops_transition(&store, std::slice::from_ref(&op)).unwrap(); - - assert_eq!( - transition.op_results, - [EditorOpResultV1 { - status: EditorOpResultStatusV1::Applied, - bounds: None, - }] - ); - assert_eq!(transition.candidate.keys["4key"].len(), before_len + 1); - assert_eq!( - transition.candidate.key_positions["4key"].len(), - before_len + 1 - ); - assert_eq!( - transition.candidate.keys["4key"].last().unwrap(), - &crate::models::KeySlot::Single("FROZEN".to_string()) - ); - assert_eq!( - transition.candidate.key_positions["4key"][0].z_index, - Some(1) - ); - assert_eq!(transition.candidate.layer_groups["4key"].len(), 1); - assert_eq!( - transition.changed_fields, - [ - EditorField::Keys, - EditorField::KeyPositions, - EditorField::LayerGroups, - ] - ); - - let replay = - prepare_editor_ops_transition(&transition.scratch, std::slice::from_ref(&op)).unwrap(); - assert!(replay.changed_fields.is_empty()); - assert_eq!( - replay.op_results[0].status, - EditorOpResultStatusV1::NoChange - ); - } - - #[test] - fn frozen_insert_group_needs_a_native_or_plugin_member() { - let store = base_store(); - // 삽입 요소가 그룹을 참조하지 않으면 거절 - let orphan = EditorOpV1::InsertFrozenElements { - mode: "4key".to_string(), - elements: vec![], - groups: vec![EditorFrozenGroupV1 { - id: "plugin-only-group".to_string(), - name: "Plugin Only".to_string(), - }], - z_updates: vec![EditorZUpdateV1 { - element_type: EditorElementTypeV1::Key, - id: store.key_positions["4key"][0].id.clone(), - z_index: 1, - }], - }; - let error = - prepare_editor_ops_transition(&store, std::slice::from_ref(&orphan)).unwrap_err(); - assert_eq!( - error - .details - .as_ref() - .and_then(|details| details.validation_code.as_deref()), - Some("UNREFERENCED_FROZEN_GROUP") - ); - - // 동봉 plugin_changes가 그 그룹의 멤버를 실었다면 생존해야 한다. - // 구조 op는 단독 배치가 강제되므로 setElementGroups로 분리할 수 없다 - let mut plugin_refs = PluginGroupRefs::new(); - plugin_refs - .entry("4key".to_string()) - .or_default() - .insert("plugin-only-group".to_string()); - let transition = prepare_editor_ops_transition_with_plugin_refs( - &store, - std::slice::from_ref(&orphan), - &plugin_refs, - ) - .unwrap(); - assert_eq!( - transition.candidate.layer_groups["4key"] - .iter() - .map(|group| group.id.as_str()) - .collect::>(), - ["plugin-only-group"] - ); - } - - #[test] - fn frozen_insert_rejects_partial_or_different_existing_plan() { - let store = base_store(); - let op = insert_op(&store); - let mut partial = prepare_editor_ops_transition(&store, std::slice::from_ref(&op)) - .unwrap() - .scratch; - partial - .key_positions - .get_mut("4key") - .unwrap() - .last_mut() - .unwrap() - .width += 1.0; - - let error = prepare_editor_ops_transition(&partial, &[op]).unwrap_err(); - assert_eq!(validation_code(&error), Some("FROZEN_INSERT_CONFLICT")); - assert_eq!(partial.key_positions["4key"].last().unwrap().width, 91.0); - } - - #[test] - fn frozen_insert_appends_every_native_element_kind() { - let store = base_store(); - let make_position = || KeyPosition { - id: uuid::Uuid::new_v4().to_string(), - ..KeyPosition::default() - }; - let op = EditorOpV1::InsertFrozenElements { - mode: "4key".to_string(), - elements: vec![ - EditorFrozenElementV1::Key { - slot: EditorFrozenKeySlotV1::Single("A".to_string()), - position: make_position(), - }, - EditorFrozenElementV1::Stat { - position: StatPosition { - stat_type: StatType::Kps, - position: make_position(), - }, - }, - EditorFrozenElementV1::Graph { - position: GraphPosition { - stat_type: GraphStatType::Kps, - graph_type: GraphType::Line, - graph_speed: 100, - graph_color: "#123456".to_string(), - show_avg_line: true, - position: make_position(), - }, - }, - EditorFrozenElementV1::Knob { - position: KnobPosition { - axis_id: "axis".to_string(), - sensitivity: 1.0, - reverse: false, - position: make_position(), - }, - }, - ], - groups: Vec::new(), - z_updates: Vec::new(), - }; - - let transition = prepare_editor_ops_transition(&store, &[op]).unwrap(); - assert_eq!( - transition.changed_fields, - [ - EditorField::Keys, - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - ] - ); - assert_eq!(transition.candidate.stat_positions["4key"].len(), 1); - assert_eq!(transition.candidate.graph_positions["4key"].len(), 1); - assert_eq!(transition.candidate.knob_positions["4key"].len(), 1); - } - - #[test] - fn frozen_insert_rejects_preexisting_group_as_partial_plan() { - let mut store = base_store(); - store.layer_groups.insert( - "4key".to_string(), - vec![LayerGroupDef { - id: "frozen-group".to_string(), - name: "Frozen Group".to_string(), - }], - ); - let before = store.clone(); - - let error = prepare_editor_ops_transition(&store, &[insert_op(&store)]).unwrap_err(); - assert_eq!(validation_code(&error), Some("FROZEN_INSERT_CONFLICT")); - assert_eq!(store, before); - } - - #[test] - fn frozen_insert_z_targets_are_exact_existing_type_and_mode() { - let store = base_store(); - let op = insert_op(&store); - let mut missing = op.clone(); - let EditorOpV1::InsertFrozenElements { z_updates, .. } = &mut missing else { - unreachable!(); - }; - z_updates[0].id = uuid::Uuid::new_v4().to_string(); - assert_eq!( - validation_code(&prepare_editor_ops_transition(&store, &[missing]).unwrap_err()), - Some("FROZEN_INSERT_TARGET_MISSING") - ); - - let mut wrong_type = op.clone(); - let EditorOpV1::InsertFrozenElements { z_updates, .. } = &mut wrong_type else { - unreachable!(); - }; - z_updates[0].element_type = EditorElementTypeV1::Graph; - assert_eq!( - validation_code(&prepare_editor_ops_transition(&store, &[wrong_type]).unwrap_err()), - Some("ELEMENT_TYPE_MISMATCH") - ); - - let mut wrong_mode = op; - let EditorOpV1::InsertFrozenElements { mode, .. } = &mut wrong_mode else { - unreachable!(); - }; - *mode = "5key".to_string(); - assert_eq!( - validation_code(&prepare_editor_ops_transition(&store, &[wrong_mode]).unwrap_err()), - Some("FROZEN_INSERT_TARGET_MODE_MISMATCH") - ); - } - - #[test] - fn frozen_insert_does_not_repair_a_malformed_key_pair() { - let mut store = base_store(); - let op = insert_op(&store); - store.keys.remove("4key"); - let before = store.clone(); - - let error = prepare_editor_ops_transition(&store, &[op]).unwrap_err(); - assert!(matches!( - validation_code(&error), - Some("KEY_POSITION_MODE_MISMATCH" | "KEY_POSITION_LENGTH_MISMATCH") - )); - assert_eq!(store, before); - } - - #[test] - fn frozen_insert_z_only_exact_plan_is_no_change() { - let store = base_store(); - let target = &store.key_positions["4key"][0]; - let op = EditorOpV1::InsertFrozenElements { - mode: "4key".to_string(), - elements: Vec::new(), - groups: Vec::new(), - z_updates: vec![EditorZUpdateV1 { - element_type: EditorElementTypeV1::Key, - id: target.id.clone(), - z_index: target.z_index.unwrap_or_default(), - }], - }; - let transition = prepare_editor_ops_transition(&store, &[op]).unwrap(); - assert!(transition.changed_fields.is_empty()); - assert_eq!( - transition.op_results[0].status, - EditorOpResultStatusV1::NoChange - ); - } - - #[test] - fn complete_reorder_updates_every_native_type_and_cleans_empty_groups() { - let mut store = store_with_every_reorder_type(); - let keys_before = store.keys.clone(); - let target_id = store.key_positions["4key"][0].id.clone(); - let reassigned_id = store.key_positions["4key"][1].id.clone(); - store.key_positions.get_mut("4key").unwrap()[0].group_id = Some("source-group".to_string()); - store.layer_groups.insert( - "4key".to_string(), - vec![ - LayerGroupDef { - id: "source-group".to_string(), - name: "Source".to_string(), - }, - LayerGroupDef { - id: "empty-group".to_string(), - name: "Empty".to_string(), - }, - LayerGroupDef { - id: "target-group".to_string(), - name: "Target".to_string(), - }, - ], - ); - let mut op = complete_reorder_op(&store); - let EditorOpV1::ReorderElements { group_updates, .. } = &mut op else { - unreachable!(); - }; - group_updates.push(EditorGroupUpdateV1 { - element_type: EditorElementTypeV1::Key, - id: target_id, - group_id: None, - }); - group_updates.push(EditorGroupUpdateV1 { - element_type: EditorElementTypeV1::Key, - id: reassigned_id, - group_id: Some("target-group".to_string()), - }); - - let transition = prepare_editor_ops_transition(&store, std::slice::from_ref(&op)).unwrap(); - assert_eq!( - transition.op_results, - [EditorOpResultV1 { - status: EditorOpResultStatusV1::Applied, - bounds: None, - }] - ); - assert_eq!(transition.candidate.keys, keys_before); - assert_eq!( - transition.candidate.layer_groups["4key"], - [LayerGroupDef { - id: "target-group".to_string(), - name: "Target".to_string(), - }] - ); - assert_eq!( - transition.changed_fields, - [ - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - EditorField::LayerGroups, - ] - ); - - let replay = - prepare_editor_ops_transition(&transition.scratch, std::slice::from_ref(&op)).unwrap(); - assert!(replay.changed_fields.is_empty()); - assert_eq!( - replay.op_results[0].status, - EditorOpResultStatusV1::NoChange - ); - } - - #[test] - fn group_structural_transition_is_frozen_atomic_and_cleans_only_empty_definitions() { - let mut store = store_with_every_reorder_type(); - let key_id = store.key_positions["4key"][0].id.clone(); - let newcomer_id = store.key_positions["4key"][1].id.clone(); - let stat_id = store.stat_positions["4key"][0].position.id.clone(); - let graph_id = store.graph_positions["4key"][0].position.id.clone(); - let knob_id = store.knob_positions["4key"][0].position.id.clone(); - store.key_positions.get_mut("4key").unwrap()[0].group_id = Some("source-group".to_string()); - store.key_positions.get_mut("4key").unwrap()[1].group_id = Some("source-group".to_string()); - store.stat_positions.get_mut("4key").unwrap()[0] - .position - .group_id = Some("source-group".to_string()); - store.graph_positions.get_mut("4key").unwrap()[0] - .position - .group_id = Some("source-group".to_string()); - store.knob_positions.get_mut("4key").unwrap()[0] - .position - .group_id = Some("source-group".to_string()); - store.layer_groups.insert( - "4key".to_string(), - vec![ - LayerGroupDef { - id: "source-group".to_string(), - name: " Source ".to_string(), - }, - LayerGroupDef { - id: "empty-group".to_string(), - name: "Empty".to_string(), - }, - ], - ); - let targets = vec![ - EditorElementGroupTargetV1 { - element_type: EditorElementTypeV1::Key, - id: key_id.clone(), - }, - EditorElementGroupTargetV1 { - element_type: EditorElementTypeV1::Stat, - id: stat_id.clone(), - }, - EditorElementGroupTargetV1 { - element_type: EditorElementTypeV1::Graph, - id: graph_id.clone(), - }, - EditorElementGroupTargetV1 { - element_type: EditorElementTypeV1::Knob, - id: knob_id.clone(), - }, - ]; - let create = EditorOpV1::SetElementGroups { - mode: "4key".to_string(), - targets: targets.clone(), - target_group: Some(EditorTargetGroupV1::Create { - id: " target group ".to_string(), - name: " Target Name ".to_string(), - }), - }; - - let transition = - prepare_editor_ops_transition(&store, std::slice::from_ref(&create)).unwrap(); - assert_eq!( - transition.op_results[0].status, - EditorOpResultStatusV1::Applied - ); - assert_eq!( - transition.changed_fields, - [ - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - EditorField::LayerGroups, - ] - ); - assert_eq!( - transition.candidate.key_positions["4key"][0] - .group_id - .as_deref(), - Some(" target group ") - ); - assert_eq!( - transition.candidate.stat_positions["4key"][0] - .position - .group_id - .as_deref(), - Some(" target group ") - ); - assert_eq!( - transition.candidate.graph_positions["4key"][0] - .position - .group_id - .as_deref(), - Some(" target group ") - ); - assert_eq!( - transition.candidate.knob_positions["4key"][0] - .position - .group_id - .as_deref(), - Some(" target group ") - ); - assert_eq!( - transition.candidate.key_positions["4key"][1] - .group_id - .as_deref(), - Some("source-group") - ); - assert_eq!( - transition.candidate.layer_groups["4key"], - [ - LayerGroupDef { - id: "source-group".to_string(), - name: " Source ".to_string(), - }, - LayerGroupDef { - id: " target group ".to_string(), - name: " Target Name ".to_string(), - }, - ] - ); - - let existing = EditorOpV1::SetElementGroups { - mode: "4key".to_string(), - targets: targets.clone(), - target_group: Some(EditorTargetGroupV1::Existing { - id: " target group ".to_string(), - }), - }; - let no_change = - prepare_editor_ops_transition(&transition.scratch, std::slice::from_ref(&existing)) - .unwrap(); - assert_eq!( - no_change.op_results[0].status, - EditorOpResultStatusV1::NoChange - ); - assert!(no_change.changed_fields.is_empty()); - - let mut cleanup_store = transition.scratch.clone(); - cleanup_store - .layer_groups - .get_mut("4key") - .unwrap() - .push(LayerGroupDef { - id: "cleanup-only".to_string(), - name: "Cleanup".to_string(), - }); - let cleanup = - prepare_editor_ops_transition(&cleanup_store, std::slice::from_ref(&existing)).unwrap(); - assert_eq!( - cleanup.op_results[0].status, - EditorOpResultStatusV1::Applied - ); - assert_eq!(cleanup.changed_fields, [EditorField::LayerGroups]); - - let ungroup = EditorOpV1::SetElementGroups { - mode: "4key".to_string(), - targets, - target_group: None, - }; - let ungrouped = prepare_editor_ops_transition(&transition.scratch, &[ungroup]).unwrap(); - assert_eq!( - ungrouped.op_results[0].status, - EditorOpResultStatusV1::Applied - ); - assert!(ungrouped.candidate.layer_groups["4key"] - .iter() - .all(|group| group.id != " target group ")); - assert_eq!( - ungrouped.candidate.key_positions["4key"][1] - .group_id - .as_deref(), - Some("source-group") - ); - assert_eq!( - ungrouped.candidate.key_positions["4key"] - .iter() - .find(|position| position.id == newcomer_id) - .unwrap() - .group_id - .as_deref(), - Some("source-group") - ); - } - - #[test] - fn group_structural_transition_rejects_conflicts_and_reports_missing_atomically() { - let mut store = store_with_every_reorder_type(); - let key_id = store.key_positions["4key"][0].id.clone(); - store.key_positions.get_mut("4key").unwrap()[0].group_id = - Some("existing-group".to_string()); - store.layer_groups.insert( - "4key".to_string(), - vec![LayerGroupDef { - id: "existing-group".to_string(), - name: "Existing".to_string(), - }], - ); - let before = EditorDocumentV1::from_store(&store); - let target = EditorElementGroupTargetV1 { - element_type: EditorElementTypeV1::Key, - id: key_id.clone(), - }; - - let missing = EditorOpV1::SetElementGroups { - mode: "4key".to_string(), - targets: vec![ - target.clone(), - EditorElementGroupTargetV1 { - element_type: EditorElementTypeV1::Graph, - id: uuid::Uuid::new_v4().to_string(), - }, - ], - target_group: None, - }; - let missing_transition = prepare_editor_ops_transition(&store, &[missing]).unwrap(); - assert_eq!( - missing_transition.op_results[0].status, - EditorOpResultStatusV1::TargetMissing - ); - assert_eq!(missing_transition.candidate, before); - assert!(missing_transition.changed_fields.is_empty()); - - for invalid in [ - EditorOpV1::SetElementGroups { - mode: "4key".to_string(), - targets: vec![EditorElementGroupTargetV1 { - element_type: EditorElementTypeV1::Graph, - id: key_id.clone(), - }], - target_group: None, - }, - EditorOpV1::SetElementGroups { - mode: "5key".to_string(), - targets: vec![target.clone()], - target_group: None, - }, - ] { - let error = prepare_editor_ops_transition(&store, &[invalid]).unwrap_err(); - assert!(matches!( - validation_code(&error), - Some("ELEMENT_TYPE_MISMATCH" | "ELEMENT_GROUP_TARGET_MODE_MISMATCH") - )); - assert_eq!(EditorDocumentV1::from_store(&store), before); - } - - for name in ["Existing", "Different"] { - let collision = EditorOpV1::SetElementGroups { - mode: "4key".to_string(), - targets: vec![target.clone()], - target_group: Some(EditorTargetGroupV1::Create { - id: "existing-group".to_string(), - name: name.to_string(), - }), - }; - let error = prepare_editor_ops_transition(&store, &[collision]).unwrap_err(); - assert_eq!(validation_code(&error), Some("LAYER_GROUP_ALREADY_EXISTS")); - } - - let unknown_group = EditorOpV1::SetElementGroups { - mode: "4key".to_string(), - targets: vec![target], - target_group: Some(EditorTargetGroupV1::Existing { - id: "missing-group".to_string(), - }), - }; - let unknown = prepare_editor_ops_transition(&store, &[unknown_group]).unwrap(); - assert_eq!( - unknown.op_results[0].status, - EditorOpResultStatusV1::TargetMissing - ); - assert_eq!(unknown.candidate, before); - } - - #[test] - fn rename_layer_group_is_mode_scoped_raw_and_reports_missing() { - let mut store = store_with_every_reorder_type(); - store.layer_groups.insert( - "4key".to_string(), - vec![LayerGroupDef { - id: "legacy group".to_string(), - name: "Old".to_string(), - }], - ); - store.layer_groups.insert( - "5key".to_string(), - vec![LayerGroupDef { - id: "legacy group".to_string(), - name: "Other Mode".to_string(), - }], - ); - let rename = EditorOpV1::RenameLayerGroup { - mode: "4key".to_string(), - group_id: "legacy group".to_string(), - name: " Raw Name ".to_string(), - }; - - let applied = prepare_editor_ops_transition(&store, std::slice::from_ref(&rename)).unwrap(); - assert_eq!( - applied.op_results[0].status, - EditorOpResultStatusV1::Applied - ); - assert_eq!(applied.changed_fields, [EditorField::LayerGroups]); - assert_eq!(applied.candidate.layer_groups["4key"][0].name, " Raw Name "); - assert_eq!(applied.candidate.layer_groups["5key"][0].name, "Other Mode"); - - let replay = - prepare_editor_ops_transition(&applied.scratch, std::slice::from_ref(&rename)).unwrap(); - assert_eq!( - replay.op_results[0].status, - EditorOpResultStatusV1::NoChange - ); - assert!(replay.changed_fields.is_empty()); - - let missing = EditorOpV1::RenameLayerGroup { - mode: "4key".to_string(), - group_id: "missing".to_string(), - name: "Name".to_string(), - }; - let missing_transition = prepare_editor_ops_transition(&store, &[missing]).unwrap(); - assert_eq!( - missing_transition.op_results[0].status, - EditorOpResultStatusV1::TargetMissing - ); - assert!(missing_transition.changed_fields.is_empty()); - } - - #[test] - fn partial_reorder_preserves_groups_and_treats_raw_z_storage_as_state() { - let mut store = base_store(); - let id = store.key_positions["4key"][0].id.clone(); - store.key_positions.get_mut("4key").unwrap()[0].z_index = None; - store.layer_groups.insert( - "4key".to_string(), - vec![LayerGroupDef { - id: "empty-group".to_string(), - name: "Empty".to_string(), - }], - ); - let op = EditorOpV1::ReorderElements { - mode: "4key".to_string(), - complete_mode_order: false, - z_updates: vec![EditorZUpdateV1 { - element_type: EditorElementTypeV1::Key, - id, - z_index: 0, - }], - group_updates: Vec::new(), - }; - - let applied = prepare_editor_ops_transition(&store, std::slice::from_ref(&op)).unwrap(); - assert_eq!( - applied.op_results[0].status, - EditorOpResultStatusV1::Applied - ); - assert_eq!(applied.candidate.key_positions["4key"][0].z_index, Some(0)); - assert_eq!(applied.candidate.layer_groups, store.layer_groups); - - let replay = - prepare_editor_ops_transition(&applied.scratch, std::slice::from_ref(&op)).unwrap(); - assert_eq!( - replay.op_results[0].status, - EditorOpResultStatusV1::NoChange - ); - assert!(replay.changed_fields.is_empty()); - } - - #[test] - fn reorder_rejects_incomplete_missing_wrong_mode_type_and_group_atomically() { - let mut store = store_with_every_reorder_type(); - store.layer_groups.insert( - "4key".to_string(), - vec![LayerGroupDef { - id: "known-group".to_string(), - name: "Known".to_string(), - }], - ); - let before = store.clone(); - let complete = complete_reorder_op(&store); - - let mut incomplete = complete.clone(); - let EditorOpV1::ReorderElements { z_updates, .. } = &mut incomplete else { - unreachable!(); - }; - z_updates.pop(); - assert_eq!( - validation_code(&prepare_editor_ops_transition(&store, &[incomplete]).unwrap_err()), - Some("INCOMPLETE_REORDER_PLAN") - ); - - let mut missing = complete.clone(); - let EditorOpV1::ReorderElements { z_updates, .. } = &mut missing else { - unreachable!(); - }; - z_updates[0].id = uuid::Uuid::new_v4().to_string(); - assert_eq!( - validation_code(&prepare_editor_ops_transition(&store, &[missing]).unwrap_err()), - Some("REORDER_TARGET_MISSING") - ); - - let mut wrong_mode = complete.clone(); - let EditorOpV1::ReorderElements { mode, .. } = &mut wrong_mode else { - unreachable!(); - }; - *mode = "5key".to_string(); - assert_eq!( - validation_code(&prepare_editor_ops_transition(&store, &[wrong_mode]).unwrap_err()), - Some("REORDER_TARGET_MODE_MISMATCH") - ); - - let mut wrong_type = complete.clone(); - let EditorOpV1::ReorderElements { z_updates, .. } = &mut wrong_type else { - unreachable!(); - }; - z_updates[0].element_type = EditorElementTypeV1::Graph; - assert_eq!( - validation_code(&prepare_editor_ops_transition(&store, &[wrong_type]).unwrap_err()), - Some("ELEMENT_TYPE_MISMATCH") - ); - - let mut unknown_group = complete; - let EditorOpV1::ReorderElements { - z_updates, - group_updates, - .. - } = &mut unknown_group - else { - unreachable!(); - }; - group_updates.push(EditorGroupUpdateV1 { - element_type: z_updates[0].element_type, - id: z_updates[0].id.clone(), - group_id: Some("unknown-group".to_string()), - }); - assert_eq!( - validation_code(&prepare_editor_ops_transition(&store, &[unknown_group]).unwrap_err()), - Some("REORDER_GROUP_MISSING") - ); - assert_eq!(store, before); - } - - #[test] - fn reorder_does_not_repair_a_malformed_key_pair() { - let mut store = base_store(); - let id = store.key_positions["4key"][0].id.clone(); - let op = EditorOpV1::ReorderElements { - mode: "4key".to_string(), - complete_mode_order: false, - z_updates: vec![EditorZUpdateV1 { - element_type: EditorElementTypeV1::Key, - id, - z_index: 99, - }], - group_updates: Vec::new(), - }; - store.keys.remove("4key"); - let before = store.clone(); - - let error = prepare_editor_ops_transition(&store, &[op]).unwrap_err(); - assert!(matches!( - validation_code(&error), - Some("KEY_POSITION_MODE_MISMATCH" | "KEY_POSITION_LENGTH_MISMATCH") - )); - assert_eq!(store, before); - } - - #[test] - fn property_patches_apply_all_native_types_in_order_and_skip_missing_targets() { - let store = store_with_every_reorder_type(); - let key_id = store.key_positions["4key"][0].id.clone(); - let stat_id = store.stat_positions["4key"][0].position.id.clone(); - let graph_id = store.graph_positions["4key"][0].position.id.clone(); - let knob_id = store.knob_positions["4key"][0].position.id.clone(); - let missing_id = uuid::Uuid::new_v4().to_string(); - let ops = vec![ - patch_hidden_op(EditorElementTypeV1::Key, key_id, true), - patch_hidden_op(EditorElementTypeV1::Stat, stat_id, true), - patch_hidden_op(EditorElementTypeV1::Graph, graph_id, true), - patch_hidden_op(EditorElementTypeV1::Knob, knob_id, true), - patch_hidden_op(EditorElementTypeV1::Key, missing_id, true), - ]; - - let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); - assert!(transition.candidate.key_positions["4key"][0].hidden); - assert!( - transition.candidate.stat_positions["4key"][0] - .position - .hidden - ); - assert!( - transition.candidate.graph_positions["4key"][0] - .position - .hidden - ); - assert!( - transition.candidate.knob_positions["4key"][0] - .position - .hidden - ); - assert_eq!( - transition - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - assert_eq!( - transition.changed_fields, - [ - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - ] - ); - - let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); - assert!(replay.changed_fields.is_empty()); - assert_eq!( - replay - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::TargetMissing, - ] - ); - } - - #[test] - fn layer_name_patches_apply_exact_values_and_clear_without_touching_other_fields() { - let store = store_with_every_reorder_type(); - let key_id = store.key_positions["4key"][0].id.clone(); - let stat_id = store.stat_positions["4key"][0].position.id.clone(); - let graph_id = store.graph_positions["4key"][0].position.id.clone(); - let knob_id = store.knob_positions["4key"][0].position.id.clone(); - let missing_id = uuid::Uuid::new_v4().to_string(); - let already_clear = prepare_editor_ops_transition( - &store, - &[patch_layer_name_op(EditorElementTypeV1::Key, &key_id, None)], - ) - .unwrap(); - assert_eq!( - already_clear.op_results[0].status, - EditorOpResultStatusV1::NoChange - ); - assert!(already_clear.changed_fields.is_empty()); - - let ops = vec![ - patch_layer_name_op(EditorElementTypeV1::Key, &key_id, Some("Key layer")), - patch_layer_name_op(EditorElementTypeV1::Stat, stat_id, Some("Stat layer")), - patch_layer_name_op(EditorElementTypeV1::Graph, graph_id, Some("Graph layer")), - patch_layer_name_op(EditorElementTypeV1::Knob, knob_id, Some("Knob layer")), - patch_layer_name_op(EditorElementTypeV1::Key, missing_id, None), - ]; - - let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); - assert_eq!( - transition.candidate.key_positions["4key"][0] - .layer_name - .as_deref(), - Some("Key layer") - ); - assert_eq!( - transition.candidate.stat_positions["4key"][0] - .position - .layer_name - .as_deref(), - Some("Stat layer") - ); - assert_eq!( - transition.candidate.graph_positions["4key"][0] - .position - .layer_name - .as_deref(), - Some("Graph layer") - ); - assert_eq!( - transition.candidate.knob_positions["4key"][0] - .position - .layer_name - .as_deref(), - Some("Knob layer") - ); - assert_eq!( - transition - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - assert_eq!( - transition.changed_fields, - [ - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - ] - ); - - let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); - assert!(replay.changed_fields.is_empty()); - assert_eq!( - replay.op_results[0].status, - EditorOpResultStatusV1::NoChange - ); - - let cleared = prepare_editor_ops_transition( - &transition.scratch, - &[patch_layer_name_op(EditorElementTypeV1::Key, key_id, None)], - ) - .unwrap(); - assert_eq!(cleared.candidate.key_positions["4key"][0].layer_name, None); - assert_eq!( - cleared.op_results[0].status, - EditorOpResultStatusV1::Applied - ); - } - - #[test] - fn graph_type_patch_is_graph_only_and_preserves_other_graph_fields() { - let store = store_with_every_reorder_type(); - let graph = &store.graph_positions["4key"][0]; - let graph_id = graph.position.id.clone(); - let missing_id = uuid::Uuid::new_v4().to_string(); - let ops = [ - patch_graph_type_op(EditorElementTypeV1::Graph, &graph_id, GraphType::Bar), - patch_graph_type_op(EditorElementTypeV1::Graph, missing_id, GraphType::Line), - ]; - - let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); - let changed = &transition.candidate.graph_positions["4key"][0]; - assert_eq!(changed.graph_type, GraphType::Bar); - assert_eq!(changed.stat_type, graph.stat_type); - assert_eq!(changed.graph_speed, graph.graph_speed); - assert_eq!(changed.graph_color, graph.graph_color); - assert_eq!(changed.show_avg_line, graph.show_avg_line); - assert_eq!(changed.position, graph.position); - assert_eq!(transition.changed_fields, [EditorField::GraphPositions]); - assert_eq!( - transition - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); - assert!(replay.changed_fields.is_empty()); - assert_eq!( - replay.op_results[0].status, - EditorOpResultStatusV1::NoChange - ); - - let key_id = store.key_positions["4key"][0].id.clone(); - let error = prepare_editor_ops_transition( - &store, - &[ - patch_hidden_op(EditorElementTypeV1::Key, &key_id, true), - patch_graph_type_op(EditorElementTypeV1::Key, key_id, GraphType::Bar), - ], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); - } - - #[test] - fn graph_color_patch_preserves_raw_string_and_other_graph_fields() { - let store = store_with_every_reorder_type(); - let graph = &store.graph_positions["4key"][0]; - let graph_id = graph.position.id.clone(); - let raw_color = "color(display-p3 1 0 0 / 0.5)"; - let ops = [ - patch_graph_color_op(EditorElementTypeV1::Graph, &graph_id, raw_color), - patch_graph_color_op( - EditorElementTypeV1::Graph, - uuid::Uuid::new_v4().to_string(), - "", - ), - ]; - - let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); - let changed = &transition.candidate.graph_positions["4key"][0]; - assert_eq!(changed.graph_color, raw_color); - assert_eq!(changed.stat_type, graph.stat_type); - assert_eq!(changed.graph_type, graph.graph_type); - assert_eq!(changed.graph_speed, graph.graph_speed); - assert_eq!(changed.show_avg_line, graph.show_avg_line); - assert_eq!(changed.position, graph.position); - assert_eq!(transition.changed_fields, [EditorField::GraphPositions]); - assert_eq!( - transition - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); - assert!(replay.changed_fields.is_empty()); - assert_eq!( - replay.op_results[0].status, - EditorOpResultStatusV1::NoChange - ); - - let key_id = store.key_positions["4key"][0].id.clone(); - let error = prepare_editor_ops_transition( - &store, - &[ - patch_hidden_op(EditorElementTypeV1::Key, &key_id, true), - patch_graph_color_op(EditorElementTypeV1::Key, key_id, "#FFFFFF"), - ], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); - } - - #[test] - fn graph_and_knob_literal_patches_are_typed_leaf_intents() { - let mut store = store_with_every_reorder_type(); - let graph_template = store.graph_positions["4key"][0].clone(); - let knob_template = store.knob_positions["4key"][0].clone(); - let graph_ids = (0..3) - .map(|_| uuid::Uuid::new_v4().to_string()) - .collect::>(); - let knob_ids = (0..3) - .map(|_| uuid::Uuid::new_v4().to_string()) - .collect::>(); - store.graph_positions.insert( - "4key".to_string(), - graph_ids - .iter() - .map(|id| GraphPosition { - position: KeyPosition { - id: id.clone(), - graph_animation_enabled: None, - ..graph_template.position.clone() - }, - ..graph_template.clone() - }) - .collect(), - ); - store.knob_positions.insert( - "4key".to_string(), - knob_ids - .iter() - .map(|id| KnobPosition { - position: KeyPosition { - id: id.clone(), - ..knob_template.position.clone() - }, - ..knob_template.clone() - }) - .collect(), - ); - let ops = vec![ - patch_property_op( - EditorElementTypeV1::Graph, - &graph_ids[0], - EditorElementPropertyPatchV1::ShowAvgLine(!graph_template.show_avg_line), - ), - patch_property_op( - EditorElementTypeV1::Graph, - &graph_ids[1], - EditorElementPropertyPatchV1::GraphAnimationEnabled(true), - ), - patch_property_op( - EditorElementTypeV1::Graph, - &graph_ids[2], - EditorElementPropertyPatchV1::GraphSpeed(u32::MAX), - ), - patch_property_op( - EditorElementTypeV1::Knob, - &knob_ids[0], - EditorElementPropertyPatchV1::Reverse(!knob_template.reverse), - ), - patch_property_op( - EditorElementTypeV1::Knob, - &knob_ids[1], - EditorElementPropertyPatchV1::Sensitivity(-7.25), - ), - patch_property_op( - EditorElementTypeV1::Knob, - &knob_ids[2], - EditorElementPropertyPatchV1::AxisId(" HIDA:raw ".to_string()), - ), - patch_property_op( - EditorElementTypeV1::Knob, - uuid::Uuid::new_v4().to_string(), - EditorElementPropertyPatchV1::AxisId("missing-axis".to_string()), - ), - ]; - - let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); - let graphs = &transition.candidate.graph_positions["4key"]; - let knobs = &transition.candidate.knob_positions["4key"]; - assert_eq!(graphs[0].show_avg_line, !graph_template.show_avg_line); - assert_eq!(graphs[0].graph_color, graph_template.graph_color); - assert_eq!(graphs[1].position.graph_animation_enabled, Some(true)); - assert_eq!(graphs[1].graph_speed, graph_template.graph_speed); - assert_eq!(graphs[2].graph_speed, u32::MAX); - assert_eq!(graphs[2].graph_type, graph_template.graph_type); - assert_eq!(knobs[0].reverse, !knob_template.reverse); - assert_eq!(knobs[0].sensitivity, knob_template.sensitivity); - assert_eq!(knobs[1].sensitivity, -7.25); - assert_eq!(knobs[1].axis_id, knob_template.axis_id); - assert_eq!(knobs[2].axis_id, " HIDA:raw "); - assert_eq!(knobs[2].reverse, knob_template.reverse); - assert_eq!(knobs[2].sensitivity, knob_template.sensitivity); - assert_eq!( - transition.changed_fields, - [EditorField::GraphPositions, EditorField::KnobPositions] - ); - assert_eq!( - transition - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); - assert!(replay.changed_fields.is_empty()); - assert_eq!( - replay - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - let key_id = store.key_positions["4key"][0].id.clone(); - for patch in [ - EditorElementPropertyPatchV1::ShowAvgLine(false), - EditorElementPropertyPatchV1::GraphAnimationEnabled(false), - EditorElementPropertyPatchV1::GraphSpeed(0), - EditorElementPropertyPatchV1::Reverse(false), - EditorElementPropertyPatchV1::Sensitivity(0.0), - EditorElementPropertyPatchV1::AxisId(String::new()), - ] { - let error = prepare_editor_ops_transition( - &store, - &[ - patch_hidden_op(EditorElementTypeV1::Key, &key_id, true), - patch_property_op(EditorElementTypeV1::Key, &key_id, patch), - ], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); - } - - let invalid = patch_property_op( - EditorElementTypeV1::Knob, - &knob_ids[0], - EditorElementPropertyPatchV1::Sensitivity(f64::INFINITY), - ); - let error = prepare_editor_ops_transition(&store, &[invalid]).unwrap_err(); - assert_eq!(validation_code(&error), Some("INVALID_NUMBER")); - } - - #[test] - fn inline_style_patch_preserves_raw_option_semantics_for_every_element_type() { - let mut store = store_with_every_reorder_type(); - store.key_positions.get_mut("4key").unwrap()[0].use_inline_styles = None; - store.stat_positions.get_mut("4key").unwrap()[0] - .position - .use_inline_styles = None; - store.graph_positions.get_mut("4key").unwrap()[0] - .position - .use_inline_styles = None; - store.knob_positions.get_mut("4key").unwrap()[0] - .position - .use_inline_styles = None; - let targets = [ - ( - EditorElementTypeV1::Key, - store.key_positions["4key"][0].id.clone(), - ), - ( - EditorElementTypeV1::Stat, - store.stat_positions["4key"][0].position.id.clone(), - ), - ( - EditorElementTypeV1::Graph, - store.graph_positions["4key"][0].position.id.clone(), - ), - ( - EditorElementTypeV1::Knob, - store.knob_positions["4key"][0].position.id.clone(), - ), - ]; - let patch = EditorElementPropertyPatchV1::UseInlineStyles(false); - let mut ops = targets - .iter() - .map(|(element_type, id)| patch_property_op(*element_type, id, patch.clone())) - .collect::>(); - ops.push(patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - patch.clone(), - )); - - let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); - assert_eq!( - transition.candidate.key_positions["4key"][0].use_inline_styles, - Some(false) - ); - assert_eq!( - transition.candidate.stat_positions["4key"][0] - .position - .use_inline_styles, - Some(false) - ); - assert_eq!( - transition.candidate.graph_positions["4key"][0] - .position - .use_inline_styles, - Some(false) - ); - assert_eq!( - transition.candidate.knob_positions["4key"][0] - .position - .use_inline_styles, - Some(false) - ); - assert_eq!( - transition.changed_fields, - [ - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - ] - ); - assert_eq!( - transition - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); - assert!(replay.changed_fields.is_empty()); - assert_eq!( - replay - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - let error = prepare_editor_ops_transition( - &store, - &[ - patch_property_op(EditorElementTypeV1::Key, &targets[0].1, patch.clone()), - patch_property_op(EditorElementTypeV1::Stat, &targets[2].1, patch), - ], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); - assert_eq!(store.key_positions["4key"][0].use_inline_styles, None); - } - - #[test] - fn font_weight_patch_pins_implicit_bold_before_changing_weight() { - let mut store = store_with_every_reorder_type(); - { - let key = &mut store.key_positions.get_mut("4key").unwrap()[0]; - key.font_weight = Some(700); - key.font_bold = None; - key.counter.font_weight = 400; - key.counter.font_bold = None; - } - { - let stat = &mut store.stat_positions.get_mut("4key").unwrap()[0].position; - stat.font_weight = None; - stat.font_bold = None; - } - let key_id = store.key_positions["4key"][0].id.clone(); - let stat_id = store.stat_positions["4key"][0].position.id.clone(); - let ops = vec![ - patch_property_op( - EditorElementTypeV1::Key, - &key_id, - EditorElementPropertyPatchV1::FontWeight(500), - ), - patch_property_op( - EditorElementTypeV1::Key, - &key_id, - EditorElementPropertyPatchV1::CounterFontWeight(700), - ), - patch_property_op( - EditorElementTypeV1::Stat, - &stat_id, - EditorElementPropertyPatchV1::FontWeight(600), - ), - ]; - - let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); - let key = &transition.candidate.key_positions["4key"][0]; - // 레거시 (700, None)은 Bold였으므로 고정하고 굵기만 바꾼다 - assert_eq!(key.font_weight, Some(500)); - assert_eq!(key.font_bold, Some(true)); - // 카운터 (400, None)은 non-bold 고정 - (700, Some(false))라 재시작 시 레거시로 오인되지 않는다 - assert_eq!(key.counter.font_weight, 700); - assert_eq!(key.counter.font_bold, Some(false)); - // 미설정 키 텍스트의 암묵 상태는 기본 Bold - let stat = &transition.candidate.stat_positions["4key"][0].position; - assert_eq!(stat.font_weight, Some(600)); - assert_eq!(stat.font_bold, Some(true)); - } - - #[test] - fn font_literal_patches_preserve_raw_options_across_native_types() { - let mut store = store_with_every_reorder_type(); - store.key_positions.get_mut("4key").unwrap()[0].font_weight = None; - store.key_positions.get_mut("4key").unwrap()[0] - .counter - .font_weight = 123; - store.stat_positions.get_mut("4key").unwrap()[0] - .position - .font_italic = None; - store.stat_positions.get_mut("4key").unwrap()[0] - .position - .counter - .font_italic = true; - store.graph_positions.get_mut("4key").unwrap()[0] - .position - .font_underline = None; - store.graph_positions.get_mut("4key").unwrap()[0] - .position - .counter - .font_underline = true; - store.knob_positions.get_mut("4key").unwrap()[0] - .position - .font_strikethrough = None; - store.knob_positions.get_mut("4key").unwrap()[0] - .position - .counter - .font_strikethrough = true; - let key_id = store.key_positions["4key"][0].id.clone(); - let stat_id = store.stat_positions["4key"][0].position.id.clone(); - let graph_id = store.graph_positions["4key"][0].position.id.clone(); - let knob_id = store.knob_positions["4key"][0].position.id.clone(); - let ops = vec![ - patch_property_op( - EditorElementTypeV1::Key, - &key_id, - EditorElementPropertyPatchV1::FontWeight(400), - ), - patch_property_op( - EditorElementTypeV1::Stat, - &stat_id, - EditorElementPropertyPatchV1::FontItalic(false), - ), - patch_property_op( - EditorElementTypeV1::Graph, - &graph_id, - EditorElementPropertyPatchV1::FontUnderline(false), - ), - patch_property_op( - EditorElementTypeV1::Knob, - &knob_id, - EditorElementPropertyPatchV1::FontStrikethrough(false), - ), - patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - EditorElementPropertyPatchV1::FontWeight(u32::MAX), - ), - ]; - - let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); - assert_eq!( - transition.candidate.key_positions["4key"][0].font_weight, - Some(400) - ); - assert_eq!( - transition.candidate.stat_positions["4key"][0] - .position - .font_italic, - Some(false) - ); - assert_eq!( - transition.candidate.graph_positions["4key"][0] - .position - .font_underline, - Some(false) - ); - assert_eq!( - transition.candidate.knob_positions["4key"][0] - .position - .font_strikethrough, - Some(false) - ); - assert_eq!( - transition.candidate.key_positions["4key"][0] - .counter - .font_weight, - 123 - ); - assert!( - transition.candidate.stat_positions["4key"][0] - .position - .counter - .font_italic - ); - assert!( - transition.candidate.graph_positions["4key"][0] - .position - .counter - .font_underline - ); - assert!( - transition.candidate.knob_positions["4key"][0] - .position - .counter - .font_strikethrough - ); - assert_eq!( - transition.changed_fields, - [ - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - ] - ); - assert_eq!( - transition - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); - assert!(replay.changed_fields.is_empty()); - assert_eq!( - replay - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - let error = prepare_editor_ops_transition( - &store, - &[ - ops[0].clone(), - patch_property_op( - EditorElementTypeV1::Stat, - &graph_id, - EditorElementPropertyPatchV1::FontItalic(true), - ), - ], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); - assert_eq!(store.key_positions["4key"][0].font_weight, None); - } - - #[test] - fn font_family_patch_preserves_nested_counter_and_raw_strings_across_native_types() { - let mut store = store_with_every_reorder_type(); - store.key_positions.get_mut("4key").unwrap()[0] - .counter - .font_family = Some("counter-key".to_string()); - store.stat_positions.get_mut("4key").unwrap()[0] - .position - .counter - .font_family = Some("counter-stat".to_string()); - store.graph_positions.get_mut("4key").unwrap()[0] - .position - .counter - .font_family = Some("counter-graph".to_string()); - store.knob_positions.get_mut("4key").unwrap()[0] - .position - .counter - .font_family = Some("counter-knob".to_string()); - let targets = [ - ( - EditorElementTypeV1::Key, - store.key_positions["4key"][0].id.clone(), - " raw-key ", - ), - ( - EditorElementTypeV1::Stat, - store.stat_positions["4key"][0].position.id.clone(), - "raw-stat", - ), - ( - EditorElementTypeV1::Graph, - store.graph_positions["4key"][0].position.id.clone(), - "raw-graph", - ), - ( - EditorElementTypeV1::Knob, - store.knob_positions["4key"][0].position.id.clone(), - "raw-knob", - ), - ]; - let ops = targets - .iter() - .map(|(element_type, id, font_family)| { - patch_property_op( - *element_type, - id, - EditorElementPropertyPatchV1::FontFamily((*font_family).to_string()), - ) - }) - .chain(std::iter::once(patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - EditorElementPropertyPatchV1::FontFamily("missing".to_string()), - ))) - .collect::>(); - - let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); - assert_eq!( - transition.candidate.key_positions["4key"][0] - .font_family - .as_deref(), - Some(" raw-key ") - ); - assert_eq!( - transition.candidate.stat_positions["4key"][0] - .position - .font_family - .as_deref(), - Some("raw-stat") - ); - assert_eq!( - transition.candidate.graph_positions["4key"][0] - .position - .font_family - .as_deref(), - Some("raw-graph") - ); - assert_eq!( - transition.candidate.knob_positions["4key"][0] - .position - .font_family - .as_deref(), - Some("raw-knob") - ); - assert_eq!( - transition.candidate.key_positions["4key"][0] - .counter - .font_family - .as_deref(), - Some("counter-key") - ); - assert_eq!( - transition.candidate.stat_positions["4key"][0] - .position - .counter - .font_family - .as_deref(), - Some("counter-stat") - ); - assert_eq!( - transition.candidate.graph_positions["4key"][0] - .position - .counter - .font_family - .as_deref(), - Some("counter-graph") - ); - assert_eq!( - transition.candidate.knob_positions["4key"][0] - .position - .counter - .font_family - .as_deref(), - Some("counter-knob") - ); - assert_eq!( - transition.changed_fields, - [ - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - ] - ); - assert_eq!( - transition - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); - assert!(replay.changed_fields.is_empty()); - assert_eq!( - replay - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - let error = prepare_editor_ops_transition( - &store, - &[ - ops[0].clone(), - patch_property_op( - EditorElementTypeV1::Stat, - &targets[2].1, - EditorElementPropertyPatchV1::FontFamily("wrong-type".to_string()), - ), - ], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); - assert_eq!(store.key_positions["4key"][0].font_family, None); - } - - #[test] - fn display_text_patch_materializes_raw_strings_across_native_types() { - let mut store = store_with_every_reorder_type(); - for position in [ - &mut store.key_positions.get_mut("4key").unwrap()[0], - &mut store.stat_positions.get_mut("4key").unwrap()[0].position, - &mut store.graph_positions.get_mut("4key").unwrap()[0].position, - &mut store.knob_positions.get_mut("4key").unwrap()[0].position, - ] { - position.display_text = None; - position.layer_name = Some("layer-sibling".to_string()); - position.font_family = Some("font-sibling".to_string()); - position.counter.font_family = Some("counter-font-sibling".to_string()); - position.inactive_image = Some("image-sibling".to_string()); - } - let targets = [ - ( - EditorElementTypeV1::Key, - store.key_positions["4key"][0].id.clone(), - "", - ), - ( - EditorElementTypeV1::Stat, - store.stat_positions["4key"][0].position.id.clone(), - " raw stat ", - ), - ( - EditorElementTypeV1::Graph, - store.graph_positions["4key"][0].position.id.clone(), - "raw graph", - ), - ( - EditorElementTypeV1::Knob, - store.knob_positions["4key"][0].position.id.clone(), - "raw knob", - ), - ]; - let ops = targets - .iter() - .map(|(element_type, id, display_text)| { - patch_property_op( - *element_type, - id, - EditorElementPropertyPatchV1::DisplayText((*display_text).to_string()), - ) - }) - .chain(std::iter::once(patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - EditorElementPropertyPatchV1::DisplayText("missing".to_string()), - ))) - .collect::>(); - - let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); - let actual = [ - &transition.candidate.key_positions["4key"][0], - &transition.candidate.stat_positions["4key"][0].position, - &transition.candidate.graph_positions["4key"][0].position, - &transition.candidate.knob_positions["4key"][0].position, - ]; - for (position, expected) in actual - .into_iter() - .zip(targets.iter().map(|target| target.2)) - { - assert_eq!(position.display_text.as_deref(), Some(expected)); - assert_eq!(position.layer_name.as_deref(), Some("layer-sibling")); - assert_eq!(position.font_family.as_deref(), Some("font-sibling")); - assert_eq!( - position.counter.font_family.as_deref(), - Some("counter-font-sibling") - ); - assert_eq!(position.inactive_image.as_deref(), Some("image-sibling")); - } - assert_eq!( - transition.changed_fields, - [ - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - ] - ); - assert_eq!( - transition - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); - assert!(replay.changed_fields.is_empty()); - assert_eq!( - replay - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - let error = prepare_editor_ops_transition( - &store, - &[ - ops[0].clone(), - patch_property_op( - EditorElementTypeV1::Stat, - &targets[2].1, - EditorElementPropertyPatchV1::DisplayText("wrong-type".to_string()), - ), - ], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); - assert_eq!(store.key_positions["4key"][0].display_text, None); - } - - #[test] - fn class_name_patch_materializes_raw_strings_across_native_types() { - let mut store = store_with_every_reorder_type(); - for position in [ - &mut store.key_positions.get_mut("4key").unwrap()[0], - &mut store.stat_positions.get_mut("4key").unwrap()[0].position, - &mut store.graph_positions.get_mut("4key").unwrap()[0].position, - &mut store.knob_positions.get_mut("4key").unwrap()[0].position, - ] { - position.class_name = None; - position.display_text = Some("display-sibling".to_string()); - position.layer_name = Some("layer-sibling".to_string()); - position.font_family = Some("font-sibling".to_string()); - position.counter.font_family = Some("counter-font-sibling".to_string()); - } - let targets = [ - ( - EditorElementTypeV1::Key, - store.key_positions["4key"][0].id.clone(), - "", - ), - ( - EditorElementTypeV1::Stat, - store.stat_positions["4key"][0].position.id.clone(), - " raw stat class ", - ), - ( - EditorElementTypeV1::Graph, - store.graph_positions["4key"][0].position.id.clone(), - "raw graph class", - ), - ( - EditorElementTypeV1::Knob, - store.knob_positions["4key"][0].position.id.clone(), - "raw knob class", - ), - ]; - let ops = targets - .iter() - .map(|(element_type, id, class_name)| { - patch_property_op( - *element_type, - id, - EditorElementPropertyPatchV1::ClassName((*class_name).to_string()), - ) - }) - .chain(std::iter::once(patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - EditorElementPropertyPatchV1::ClassName("missing".to_string()), - ))) - .collect::>(); - - let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); - let actual = [ - &transition.candidate.key_positions["4key"][0], - &transition.candidate.stat_positions["4key"][0].position, - &transition.candidate.graph_positions["4key"][0].position, - &transition.candidate.knob_positions["4key"][0].position, - ]; - for (position, expected) in actual - .into_iter() - .zip(targets.iter().map(|target| target.2)) - { - assert_eq!(position.class_name.as_deref(), Some(expected)); - assert_eq!(position.display_text.as_deref(), Some("display-sibling")); - assert_eq!(position.layer_name.as_deref(), Some("layer-sibling")); - assert_eq!(position.font_family.as_deref(), Some("font-sibling")); - assert_eq!( - position.counter.font_family.as_deref(), - Some("counter-font-sibling") - ); - } - assert_eq!( - transition.changed_fields, - [ - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - ] - ); - assert_eq!( - transition - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); - assert!(replay.changed_fields.is_empty()); - assert_eq!( - replay - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - let error = prepare_editor_ops_transition( - &store, - &[ - ops[0].clone(), - patch_property_op( - EditorElementTypeV1::Stat, - &targets[2].1, - EditorElementPropertyPatchV1::ClassName("wrong-type".to_string()), - ), - ], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); - assert_eq!(store.key_positions["4key"][0].class_name, None); - } - - #[test] - fn numeric_style_patches_preserve_raw_f64_ranges_and_siblings() { - let mut store = store_with_every_reorder_type(); - for position in [ - &mut store.key_positions.get_mut("4key").unwrap()[0], - &mut store.stat_positions.get_mut("4key").unwrap()[0].position, - &mut store.graph_positions.get_mut("4key").unwrap()[0].position, - &mut store.knob_positions.get_mut("4key").unwrap()[0].position, - ] { - position.border_width = None; - position.border_radius = None; - position.font_size = None; - position.class_name = Some("class-sibling".to_string()); - position.display_text = Some("display-sibling".to_string()); - position.counter.font_size = 33; - } - let key_id = store.key_positions["4key"][0].id.clone(); - let stat_id = store.stat_positions["4key"][0].position.id.clone(); - let graph_id = store.graph_positions["4key"][0].position.id.clone(); - let knob_id = store.knob_positions["4key"][0].position.id.clone(); - let ops = vec![ - patch_property_op( - EditorElementTypeV1::Key, - &key_id, - EditorElementPropertyPatchV1::BorderWidth(0.0), - ), - patch_property_op( - EditorElementTypeV1::Stat, - &stat_id, - EditorElementPropertyPatchV1::BorderRadius(100.0), - ), - patch_property_op( - EditorElementTypeV1::Graph, - &graph_id, - EditorElementPropertyPatchV1::FontSize(8.0), - ), - patch_property_op( - EditorElementTypeV1::Knob, - &knob_id, - EditorElementPropertyPatchV1::BorderRadius(999.0), - ), - patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - EditorElementPropertyPatchV1::BorderWidth(1.0), - ), - ]; - - let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); - let actual = [ - &transition.candidate.key_positions["4key"][0], - &transition.candidate.stat_positions["4key"][0].position, - &transition.candidate.graph_positions["4key"][0].position, - &transition.candidate.knob_positions["4key"][0].position, - ]; - for position in actual { - assert_eq!(position.class_name.as_deref(), Some("class-sibling")); - assert_eq!(position.display_text.as_deref(), Some("display-sibling")); - assert_eq!(position.counter.font_size, 33); - } - assert_eq!( - transition.candidate.key_positions["4key"][0].border_width, - Some(0.0) - ); - assert_eq!( - transition.candidate.stat_positions["4key"][0] - .position - .border_radius, - Some(100.0) - ); - assert_eq!( - transition.candidate.graph_positions["4key"][0] - .position - .font_size, - Some(8.0) - ); - assert_eq!( - transition.candidate.knob_positions["4key"][0] - .position - .border_radius, - Some(999.0) - ); - assert_eq!( - transition.changed_fields, - [ - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - ] - ); - assert_eq!( - transition - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); - assert!(replay.changed_fields.is_empty()); - assert_eq!( - replay - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - let upper_boundaries = prepare_editor_ops_transition( - &store, - &[ - patch_property_op( - EditorElementTypeV1::Stat, - &stat_id, - EditorElementPropertyPatchV1::BorderWidth(20.0), - ), - patch_property_op( - EditorElementTypeV1::Graph, - &graph_id, - EditorElementPropertyPatchV1::FontSize(72.0), - ), - ], - ) - .unwrap(); - assert_eq!( - upper_boundaries.candidate.stat_positions["4key"][0] - .position - .border_width, - Some(20.0) - ); - assert_eq!( - upper_boundaries.candidate.graph_positions["4key"][0] - .position - .font_size, - Some(72.0) - ); - - let error = prepare_editor_ops_transition( - &store, - &[ - ops[0].clone(), - patch_property_op( - EditorElementTypeV1::Stat, - &graph_id, - EditorElementPropertyPatchV1::FontSize(16.0), - ), - ], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); - assert_eq!(store.key_positions["4key"][0].border_width, None); - - let invalid = [ - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::BorderWidth(-0.1), - "BORDER_WIDTH_OUT_OF_RANGE", - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::BorderWidth(20.1), - "BORDER_WIDTH_OUT_OF_RANGE", - ), - ( - EditorElementTypeV1::Stat, - EditorElementPropertyPatchV1::BorderRadius(100.1), - "BORDER_RADIUS_OUT_OF_RANGE", - ), - ( - EditorElementTypeV1::Knob, - EditorElementPropertyPatchV1::BorderRadius(999.1), - "BORDER_RADIUS_OUT_OF_RANGE", - ), - ( - EditorElementTypeV1::Graph, - EditorElementPropertyPatchV1::FontSize(7.9), - "FONT_SIZE_OUT_OF_RANGE", - ), - ( - EditorElementTypeV1::Graph, - EditorElementPropertyPatchV1::FontSize(72.1), - "FONT_SIZE_OUT_OF_RANGE", - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::BorderWidth(f64::NAN), - "BORDER_WIDTH_OUT_OF_RANGE", - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::FontSize(f64::INFINITY), - "FONT_SIZE_OUT_OF_RANGE", - ), - ]; - for (element_type, patch, expected_code) in invalid { - let error = prepare_editor_ops_transition( - &store, - &[ - ops[0].clone(), - patch_property_op(element_type, uuid::Uuid::new_v4().to_string(), patch), - ], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some(expected_code)); - assert_eq!(store.key_positions["4key"][0].border_width, None); - } - } - - #[test] - fn inactive_image_patch_preserves_asset_siblings_and_raw_empty_across_native_types() { - let mut store = store_with_every_reorder_type(); - for position in [ - &mut store.key_positions.get_mut("4key").unwrap()[0], - &mut store.stat_positions.get_mut("4key").unwrap()[0].position, - &mut store.graph_positions.get_mut("4key").unwrap()[0].position, - &mut store.knob_positions.get_mut("4key").unwrap()[0].position, - ] { - position.inactive_image = None; - position.active_image = Some("active-sibling.png".to_string()); - position.sound_path = Some("sound-sibling.wav".to_string()); - } - let targets = [ - ( - EditorElementTypeV1::Key, - store.key_positions["4key"][0].id.clone(), - ), - ( - EditorElementTypeV1::Stat, - store.stat_positions["4key"][0].position.id.clone(), - ), - ( - EditorElementTypeV1::Graph, - store.graph_positions["4key"][0].position.id.clone(), - ), - ( - EditorElementTypeV1::Knob, - store.knob_positions["4key"][0].position.id.clone(), - ), - ]; - let raw = prepare_editor_ops_transition( - &store, - &[patch_property_op( - EditorElementTypeV1::Key, - &targets[0].1, - EditorElementPropertyPatchV1::InactiveImage(" raw/path.png ".to_string()), - )], - ) - .unwrap(); - assert_eq!( - raw.candidate.key_positions["4key"][0] - .inactive_image - .as_deref(), - Some(" raw/path.png ") - ); - let patch = EditorElementPropertyPatchV1::InactiveImage(String::new()); - let ops = targets - .iter() - .map(|(element_type, id)| patch_property_op(*element_type, id, patch.clone())) - .chain(std::iter::once(patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - patch.clone(), - ))) - .collect::>(); - - let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); - for position in [ - &transition.candidate.key_positions["4key"][0], - &transition.candidate.stat_positions["4key"][0].position, - &transition.candidate.graph_positions["4key"][0].position, - &transition.candidate.knob_positions["4key"][0].position, - ] { - assert_eq!(position.inactive_image.as_deref(), Some("")); - assert_eq!(position.active_image.as_deref(), Some("active-sibling.png")); - assert_eq!(position.sound_path.as_deref(), Some("sound-sibling.wav")); - } - assert_eq!( - transition.changed_fields, - [ - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - ] - ); - assert_eq!( - transition - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); - assert!(replay.changed_fields.is_empty()); - assert_eq!( - replay - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - let error = prepare_editor_ops_transition( - &store, - &[ - ops[0].clone(), - patch_property_op( - EditorElementTypeV1::Stat, - &targets[2].1, - EditorElementPropertyPatchV1::InactiveImage("wrong".to_string()), - ), - ], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); - assert_eq!(store.key_positions["4key"][0].inactive_image, None); - } - - #[test] - fn active_image_patch_is_key_or_knob_only_and_preserves_asset_siblings() { - let mut store = store_with_every_reorder_type(); - store.key_positions.get_mut("4key").unwrap()[0].active_image = None; - store.knob_positions.get_mut("4key").unwrap()[0] - .position - .active_image = None; - for position in [ - &mut store.key_positions.get_mut("4key").unwrap()[0], - &mut store.knob_positions.get_mut("4key").unwrap()[0].position, - ] { - position.inactive_image = Some("idle-sibling.png".to_string()); - position.active_image_fit = Some(crate::models::ImageFit::Contain); - position.active_transparent = true; - position.counter.enabled = false; - } - let key_id = store.key_positions["4key"][0].id.clone(); - let knob_id = store.knob_positions["4key"][0].position.id.clone(); - let missing_id = uuid::Uuid::new_v4().to_string(); - let patch = EditorElementPropertyPatchV1::ActiveImage(String::new()); - let ops = vec![ - patch_property_op(EditorElementTypeV1::Key, &key_id, patch.clone()), - patch_property_op(EditorElementTypeV1::Knob, &knob_id, patch.clone()), - patch_property_op(EditorElementTypeV1::Key, &missing_id, patch.clone()), - ]; - - let raw = prepare_editor_ops_transition( - &store, - &[patch_property_op( - EditorElementTypeV1::Knob, - &knob_id, - EditorElementPropertyPatchV1::ActiveImage(" raw/active.png ".to_string()), - )], - ) - .unwrap(); - assert_eq!( - raw.candidate.knob_positions["4key"][0] - .position - .active_image - .as_deref(), - Some(" raw/active.png ") - ); - - let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); - for position in [ - &transition.candidate.key_positions["4key"][0], - &transition.candidate.knob_positions["4key"][0].position, - ] { - assert_eq!(position.active_image.as_deref(), Some("")); - assert_eq!(position.inactive_image.as_deref(), Some("idle-sibling.png")); - assert_eq!( - position.active_image_fit, - Some(crate::models::ImageFit::Contain) - ); - assert!(position.active_transparent); - assert!(!position.counter.enabled); - } - assert_eq!( - transition.changed_fields, - [EditorField::KeyPositions, EditorField::KnobPositions] - ); - assert_eq!( - transition - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); - assert!(replay.changed_fields.is_empty()); - assert_eq!( - replay - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - for element_type in [EditorElementTypeV1::Stat, EditorElementTypeV1::Graph] { - let error = prepare_editor_ops_transition( - &store, - &[ - ops[0].clone(), - patch_property_op( - element_type, - uuid::Uuid::new_v4().to_string(), - patch.clone(), - ), - ], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); - assert_eq!(store.key_positions["4key"][0].active_image, None); - } - } - - #[test] - fn image_transparency_patches_preserve_opposite_state_and_asset_siblings() { - let mut store = store_with_every_reorder_type(); - for position in [ - &mut store.key_positions.get_mut("4key").unwrap()[0], - &mut store.stat_positions.get_mut("4key").unwrap()[0].position, - &mut store.graph_positions.get_mut("4key").unwrap()[0].position, - &mut store.knob_positions.get_mut("4key").unwrap()[0].position, - ] { - position.idle_transparent = false; - position.active_transparent = false; - position.inactive_image = Some("idle-sibling.png".to_string()); - position.active_image = Some("active-sibling.png".to_string()); - position.image_fit = Some(crate::models::ImageFit::Fill); - position.idle_image_fit = Some(crate::models::ImageFit::Contain); - position.active_image_fit = Some(crate::models::ImageFit::None); - position.counter.enabled = true; - } - let targets = [ - ( - EditorElementTypeV1::Key, - store.key_positions["4key"][0].id.clone(), - ), - ( - EditorElementTypeV1::Stat, - store.stat_positions["4key"][0].position.id.clone(), - ), - ( - EditorElementTypeV1::Graph, - store.graph_positions["4key"][0].position.id.clone(), - ), - ( - EditorElementTypeV1::Knob, - store.knob_positions["4key"][0].position.id.clone(), - ), - ]; - let idle_patch = EditorElementPropertyPatchV1::IdleTransparent(true); - let idle_ops = targets - .iter() - .map(|(element_type, id)| patch_property_op(*element_type, id, idle_patch.clone())) - .chain(std::iter::once(patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - idle_patch.clone(), - ))) - .collect::>(); - - let idle = prepare_editor_ops_transition(&store, &idle_ops).unwrap(); - assert_eq!( - idle.changed_fields, - [ - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - ] - ); - assert_eq!( - idle.op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - for position in [ - &idle.candidate.key_positions["4key"][0], - &idle.candidate.stat_positions["4key"][0].position, - &idle.candidate.graph_positions["4key"][0].position, - &idle.candidate.knob_positions["4key"][0].position, - ] { - assert!(position.idle_transparent); - assert!(!position.active_transparent); - assert_eq!(position.inactive_image.as_deref(), Some("idle-sibling.png")); - assert_eq!(position.active_image.as_deref(), Some("active-sibling.png")); - assert_eq!(position.image_fit, Some(crate::models::ImageFit::Fill)); - assert_eq!( - position.idle_image_fit, - Some(crate::models::ImageFit::Contain) - ); - assert_eq!( - position.active_image_fit, - Some(crate::models::ImageFit::None) - ); - assert!(position.counter.enabled); - } - let idle_replay = prepare_editor_ops_transition(&idle.scratch, &idle_ops).unwrap(); - assert!(idle_replay.changed_fields.is_empty()); - assert_eq!( - idle_replay - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - let active_patch = EditorElementPropertyPatchV1::ActiveTransparent(true); - let active_ops = vec![ - patch_property_op( - EditorElementTypeV1::Key, - &targets[0].1, - active_patch.clone(), - ), - patch_property_op( - EditorElementTypeV1::Knob, - &targets[3].1, - active_patch.clone(), - ), - patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - active_patch.clone(), - ), - ]; - let active = prepare_editor_ops_transition(&idle.scratch, &active_ops).unwrap(); - assert_eq!( - active - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - assert!(active.candidate.key_positions["4key"][0].active_transparent); - assert!( - active.candidate.knob_positions["4key"][0] - .position - .active_transparent - ); - assert!(active.candidate.key_positions["4key"][0].idle_transparent); - assert!( - active.candidate.knob_positions["4key"][0] - .position - .idle_transparent - ); - - for element_type in [EditorElementTypeV1::Stat, EditorElementTypeV1::Graph] { - let error = prepare_editor_ops_transition( - &store, - &[ - idle_ops[0].clone(), - patch_property_op( - element_type, - uuid::Uuid::new_v4().to_string(), - active_patch.clone(), - ), - ], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); - assert!(!store.key_positions["4key"][0].idle_transparent); - } - } - - #[test] - fn image_fit_patches_materialize_state_specific_values_and_preserve_siblings() { - let mut store = store_with_every_reorder_type(); - for position in [ - &mut store.key_positions.get_mut("4key").unwrap()[0], - &mut store.stat_positions.get_mut("4key").unwrap()[0].position, - &mut store.graph_positions.get_mut("4key").unwrap()[0].position, - &mut store.knob_positions.get_mut("4key").unwrap()[0].position, - ] { - position.image_fit = Some(crate::models::ImageFit::Cover); - position.idle_image_fit = None; - position.active_image_fit = None; - position.idle_transparent = true; - position.active_transparent = true; - position.inactive_image = Some("idle-sibling.png".to_string()); - position.active_image = Some("active-sibling.png".to_string()); - position.counter.enabled = true; - } - let targets = [ - ( - EditorElementTypeV1::Key, - store.key_positions["4key"][0].id.clone(), - ), - ( - EditorElementTypeV1::Stat, - store.stat_positions["4key"][0].position.id.clone(), - ), - ( - EditorElementTypeV1::Graph, - store.graph_positions["4key"][0].position.id.clone(), - ), - ( - EditorElementTypeV1::Knob, - store.knob_positions["4key"][0].position.id.clone(), - ), - ]; - let idle_patch = EditorElementPropertyPatchV1::IdleImageFit(crate::models::ImageFit::Cover); - let idle_ops = targets - .iter() - .map(|(element_type, id)| patch_property_op(*element_type, id, idle_patch.clone())) - .chain(std::iter::once(patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - idle_patch.clone(), - ))) - .collect::>(); - - let idle = prepare_editor_ops_transition(&store, &idle_ops).unwrap(); - assert_eq!( - idle.op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - for position in [ - &idle.candidate.key_positions["4key"][0], - &idle.candidate.stat_positions["4key"][0].position, - &idle.candidate.graph_positions["4key"][0].position, - &idle.candidate.knob_positions["4key"][0].position, - ] { - assert_eq!(position.image_fit, Some(crate::models::ImageFit::Cover)); - assert_eq!( - position.idle_image_fit, - Some(crate::models::ImageFit::Cover) - ); - assert_eq!(position.active_image_fit, None); - assert!(position.idle_transparent); - assert!(position.active_transparent); - assert_eq!(position.inactive_image.as_deref(), Some("idle-sibling.png")); - assert_eq!(position.active_image.as_deref(), Some("active-sibling.png")); - assert!(position.counter.enabled); - } - let idle_replay = prepare_editor_ops_transition(&idle.scratch, &idle_ops).unwrap(); - assert!(idle_replay.changed_fields.is_empty()); - assert_eq!( - idle_replay - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - let active_patch = - EditorElementPropertyPatchV1::ActiveImageFit(crate::models::ImageFit::Cover); - let active_ops = vec![ - patch_property_op( - EditorElementTypeV1::Key, - &targets[0].1, - active_patch.clone(), - ), - patch_property_op( - EditorElementTypeV1::Knob, - &targets[3].1, - active_patch.clone(), - ), - patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - active_patch.clone(), - ), - ]; - let active = prepare_editor_ops_transition(&idle.scratch, &active_ops).unwrap(); - assert_eq!( - active - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - assert_eq!( - active.candidate.key_positions["4key"][0].active_image_fit, - Some(crate::models::ImageFit::Cover) - ); - assert_eq!( - active.candidate.knob_positions["4key"][0] - .position - .active_image_fit, - Some(crate::models::ImageFit::Cover) - ); - - for element_type in [EditorElementTypeV1::Stat, EditorElementTypeV1::Graph] { - let error = prepare_editor_ops_transition( - &store, - &[ - idle_ops[0].clone(), - patch_property_op( - element_type, - uuid::Uuid::new_v4().to_string(), - active_patch.clone(), - ), - ], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); - assert_eq!(store.key_positions["4key"][0].idle_image_fit, None); - } - } - - #[test] - fn image_transform_patches_seed_noop_reset_and_store_sparse_mode() { - let store = store_with_every_reorder_type(); - let key_id = store.key_positions["4key"][0].id.clone(); - let ops = vec![ - patch_property_op( - EditorElementTypeV1::Key, - &key_id, - EditorElementPropertyPatchV1::ImageMode(ImageMode::Overlay), - ), - patch_property_op( - EditorElementTypeV1::Key, - &key_id, - EditorElementPropertyPatchV1::IdleImageTransform(Some( - ImageTransformLeafPatchV1::OffsetX(125.0), - )), - ), - patch_property_op( - EditorElementTypeV1::Key, - &key_id, - EditorElementPropertyPatchV1::ActiveImageTransform(Some( - ImageTransformLeafPatchV1::Scale(1.5), - )), - ), - ]; - - let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); - let position = &transition.candidate.key_positions["4key"][0]; - assert_eq!(position.image_mode, Some(ImageMode::Overlay)); - assert_eq!( - position.idle_image_transform, - Some(ImageTransform { - offset_x: 125.0, - ..ImageTransform::default() - }) - ); - assert_eq!( - position.active_image_transform, - Some(ImageTransform { - scale: 1.5, - ..ImageTransform::default() - }) - ); - assert_eq!( - transition - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - ] - ); - - let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); - assert!(replay.changed_fields.is_empty()); - assert!(replay - .op_results - .iter() - .all(|result| result.status == EditorOpResultStatusV1::NoChange)); - - let reset_ops = [ - patch_property_op( - EditorElementTypeV1::Key, - &key_id, - EditorElementPropertyPatchV1::ImageMode(ImageMode::Replace), - ), - patch_property_op( - EditorElementTypeV1::Key, - &key_id, - EditorElementPropertyPatchV1::IdleImageTransform(None), - ), - patch_property_op( - EditorElementTypeV1::Key, - &key_id, - EditorElementPropertyPatchV1::ActiveImageTransform(None), - ), - ]; - let reset = prepare_editor_ops_transition(&transition.scratch, &reset_ops).unwrap(); - let position = &reset.candidate.key_positions["4key"][0]; - assert_eq!(position.image_mode, None); - assert_eq!(position.idle_image_transform, None); - assert_eq!(position.active_image_transform, None); - assert!(reset - .op_results - .iter() - .all(|result| result.status == EditorOpResultStatusV1::Applied)); - - let reset_replay = prepare_editor_ops_transition(&reset.scratch, &reset_ops).unwrap(); - assert!(reset_replay.changed_fields.is_empty()); - assert!(reset_replay - .op_results - .iter() - .all(|result| result.status == EditorOpResultStatusV1::NoChange)); - } - - #[test] - fn image_transform_properties_reject_non_keys_and_invalid_ranges() { - let store = store_with_every_reorder_type(); - for patch in [ - EditorElementPropertyPatchV1::ImageMode(ImageMode::Overlay), - EditorElementPropertyPatchV1::IdleImageTransform(Some( - ImageTransformLeafPatchV1::Rotation(45.0), - )), - EditorElementPropertyPatchV1::ActiveImageTransform(None), - ] { - for (element_type, id) in [ - ( - EditorElementTypeV1::Stat, - store.stat_positions["4key"][0].position.id.clone(), - ), - ( - EditorElementTypeV1::Graph, - store.graph_positions["4key"][0].position.id.clone(), - ), - ( - EditorElementTypeV1::Knob, - store.knob_positions["4key"][0].position.id.clone(), - ), - ] { - let error = prepare_editor_ops_transition( - &store, - &[patch_property_op(element_type, id, patch.clone())], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); - } - } - - let key_id = store.key_positions["4key"][0].id.clone(); - for patch in [ - ImageTransformLeafPatchV1::OffsetX(-500.1), - ImageTransformLeafPatchV1::OffsetY(f64::INFINITY), - ImageTransformLeafPatchV1::Rotation(180.1), - ImageTransformLeafPatchV1::Scale(0.09), - ImageTransformLeafPatchV1::Scale(f64::NAN), - ] { - let error = prepare_editor_ops_transition( - &store, - &[patch_property_op( - EditorElementTypeV1::Key, - &key_id, - EditorElementPropertyPatchV1::IdleImageTransform(Some(patch)), - )], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some("INVALID_IMAGE_TRANSFORM")); - } - } - - #[test] - fn sound_path_patch_is_key_only_and_preserves_sound_and_asset_siblings() { - let mut store = store_with_every_reorder_type(); - for position in store.key_positions.get_mut("4key").unwrap() { - position.sound_path = None; - position.sound_enabled = Some(true); - position.sound_volume = Some(137.5); - position.inactive_image = Some("idle-sibling.png".to_string()); - position.active_image = Some("active-sibling.png".to_string()); - position.counter.enabled = false; - } - let key_ids = store.key_positions["4key"] - .iter() - .take(2) - .map(|position| position.id.clone()) - .collect::>(); - let patch = EditorElementPropertyPatchV1::SoundPath(String::new()); - let ops = key_ids - .iter() - .map(|id| patch_property_op(EditorElementTypeV1::Key, id, patch.clone())) - .chain(std::iter::once(patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - patch.clone(), - ))) - .collect::>(); - - let raw = prepare_editor_ops_transition( - &store, - &[patch_property_op( - EditorElementTypeV1::Key, - &key_ids[0], - EditorElementPropertyPatchV1::SoundPath(" raw/sound.wav ".to_string()), - )], - ) - .unwrap(); - assert_eq!( - raw.candidate.key_positions["4key"][0].sound_path.as_deref(), - Some(" raw/sound.wav ") - ); - - let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); - for position in transition.candidate.key_positions["4key"].iter().take(2) { - assert_eq!(position.sound_path.as_deref(), Some("")); - assert_eq!(position.sound_enabled, Some(true)); - assert_eq!(position.sound_volume, Some(137.5)); - assert_eq!(position.inactive_image.as_deref(), Some("idle-sibling.png")); - assert_eq!(position.active_image.as_deref(), Some("active-sibling.png")); - assert!(!position.counter.enabled); - } - assert_eq!(transition.changed_fields, [EditorField::KeyPositions]); - assert_eq!( - transition - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); - assert!(replay.changed_fields.is_empty()); - assert_eq!( - replay - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - for element_type in [ - EditorElementTypeV1::Stat, - EditorElementTypeV1::Graph, - EditorElementTypeV1::Knob, - ] { - let error = prepare_editor_ops_transition( - &store, - &[ - ops[0].clone(), - patch_property_op( - element_type, - uuid::Uuid::new_v4().to_string(), - patch.clone(), - ), - ], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); - assert!(store.key_positions["4key"] - .iter() - .all(|position| position.sound_path.is_none())); - } - } - - #[test] - fn sound_enabled_patch_materializes_false_and_preserves_siblings_atomically() { - let mut store = store_with_every_reorder_type(); - let key_ids = store.key_positions["4key"] - .iter() - .take(2) - .map(|position| position.id.clone()) - .collect::>(); - for (index, position) in store - .key_positions - .get_mut("4key") - .unwrap() - .iter_mut() - .take(2) - .enumerate() - { - position.sound_enabled = if index == 0 { None } else { Some(false) }; - position.sound_path = Some("sounds/sibling.wav".to_string()); - position.sound_volume = Some(137.5); - position.inactive_image = Some("idle-sibling.png".to_string()); - position.active_image = Some("active-sibling.png".to_string()); - position.counter.enabled = false; - } - let patch = EditorElementPropertyPatchV1::SoundEnabled(false); - let ops = vec![ - patch_property_op(EditorElementTypeV1::Key, &key_ids[0], patch.clone()), - patch_property_op(EditorElementTypeV1::Key, &key_ids[1], patch.clone()), - patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - patch.clone(), - ), - ]; - - let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); - for position in transition.candidate.key_positions["4key"].iter().take(2) { - assert_eq!(position.sound_enabled, Some(false)); - assert_eq!(position.sound_path.as_deref(), Some("sounds/sibling.wav")); - assert_eq!(position.sound_volume, Some(137.5)); - assert_eq!(position.inactive_image.as_deref(), Some("idle-sibling.png")); - assert_eq!(position.active_image.as_deref(), Some("active-sibling.png")); - assert!(!position.counter.enabled); - } - assert_eq!(transition.changed_fields, [EditorField::KeyPositions]); - assert_eq!( - transition - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); - assert!(replay.changed_fields.is_empty()); - assert_eq!( - replay - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - for element_type in [ - EditorElementTypeV1::Stat, - EditorElementTypeV1::Graph, - EditorElementTypeV1::Knob, - ] { - let error = prepare_editor_ops_transition( - &store, - &[ - ops[0].clone(), - patch_property_op( - element_type, - uuid::Uuid::new_v4().to_string(), - patch.clone(), - ), - ], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); - assert_eq!(store.key_positions["4key"][0].sound_enabled, None); - } - } - - #[test] - fn sound_volume_patch_materializes_default_and_validates_range_atomically() { - let mut store = store_with_every_reorder_type(); - let key_ids = store.key_positions["4key"] - .iter() - .take(2) - .map(|position| position.id.clone()) - .collect::>(); - for (index, position) in store - .key_positions - .get_mut("4key") - .unwrap() - .iter_mut() - .take(2) - .enumerate() - { - position.sound_volume = if index == 0 { None } else { Some(100.0) }; - position.sound_path = Some("sounds/sibling.wav".to_string()); - position.sound_enabled = Some(true); - position.inactive_image = Some("idle-sibling.png".to_string()); - position.active_image = Some("active-sibling.png".to_string()); - position.counter.enabled = false; - } - let patch = EditorElementPropertyPatchV1::SoundVolume(100.0); - let ops = vec![ - patch_property_op(EditorElementTypeV1::Key, &key_ids[0], patch.clone()), - patch_property_op(EditorElementTypeV1::Key, &key_ids[1], patch.clone()), - patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - patch.clone(), - ), - ]; - - let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); - for position in transition.candidate.key_positions["4key"].iter().take(2) { - assert_eq!(position.sound_volume, Some(100.0)); - assert_eq!(position.sound_path.as_deref(), Some("sounds/sibling.wav")); - assert_eq!(position.sound_enabled, Some(true)); - assert_eq!(position.inactive_image.as_deref(), Some("idle-sibling.png")); - assert_eq!(position.active_image.as_deref(), Some("active-sibling.png")); - assert!(!position.counter.enabled); - } - assert_eq!(transition.changed_fields, [EditorField::KeyPositions]); - assert_eq!( - transition - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::TargetMissing, - ] - ); - let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); - assert!(replay.changed_fields.is_empty()); - assert_eq!( - replay - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - for element_type in [ - EditorElementTypeV1::Stat, - EditorElementTypeV1::Graph, - EditorElementTypeV1::Knob, - ] { - let error = prepare_editor_ops_transition( - &store, - &[ - ops[0].clone(), - patch_property_op( - element_type, - uuid::Uuid::new_v4().to_string(), - patch.clone(), - ), - ], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); - assert_eq!(store.key_positions["4key"][0].sound_volume, None); - } - for invalid in [-1.0, 200.1, f64::NAN, f64::INFINITY] { - let error = prepare_editor_ops_transition( - &store, - &[ - ops[0].clone(), - patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - EditorElementPropertyPatchV1::SoundVolume(invalid), - ), - ], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some("SOUND_VOLUME_OUT_OF_RANGE")); - assert_eq!(store.key_positions["4key"][0].sound_volume, None); - } - for boundary in [0.0, 200.0] { - prepare_editor_ops_transition( - &store, - &[patch_property_op( - EditorElementTypeV1::Key, - &key_ids[0], - EditorElementPropertyPatchV1::SoundVolume(boundary), - )], - ) - .unwrap(); - } - } - - #[test] - fn counter_boolean_patches_preserve_nested_siblings_and_reject_wrong_types_atomically() { - let mut store = store_with_every_reorder_type(); - let key_id = store.key_positions["4key"][0].id.clone(); - let stat_id = store.stat_positions["4key"][0].position.id.clone(); - let missing_id = uuid::Uuid::new_v4().to_string(); - { - let counter = &mut store.key_positions.get_mut("4key").unwrap()[0].counter; - counter.enabled = true; - counter.placement = crate::models::KeyCounterPlacement::Outside; - counter.animation.enabled = true; - counter.animation.preset_id = Some("builtin-linear".to_string()); - counter.animation.scale = 1.75; - } - { - let counter = &mut store.stat_positions.get_mut("4key").unwrap()[0] - .position - .counter; - counter.enabled = true; - counter.placement = crate::models::KeyCounterPlacement::Outside; - counter.animation.enabled = true; - counter.animation.preset_id = Some("builtin-linear".to_string()); - counter.animation.scale = 1.75; - } - let counter_enabled = EditorElementPropertyPatchV1::CounterEnabled(false); - let animation_enabled = EditorElementPropertyPatchV1::CounterAnimationEnabled(false); - let ops = vec![ - patch_property_op(EditorElementTypeV1::Key, &key_id, counter_enabled.clone()), - patch_property_op( - EditorElementTypeV1::Stat, - &stat_id, - animation_enabled.clone(), - ), - patch_property_op( - EditorElementTypeV1::Key, - &missing_id, - counter_enabled.clone(), - ), - ]; - - let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); - let key_counter = &transition.candidate.key_positions["4key"][0].counter; - assert!(!key_counter.enabled); - assert!(key_counter.animation.enabled); - assert_eq!( - key_counter.animation.preset_id.as_deref(), - Some("builtin-linear") - ); - assert_eq!(key_counter.animation.scale, 1.75); - assert!(matches!( - key_counter.placement, - crate::models::KeyCounterPlacement::Outside - )); - let stat_counter = &transition.candidate.stat_positions["4key"][0] - .position - .counter; - assert!(stat_counter.enabled); - assert!(!stat_counter.animation.enabled); - assert_eq!( - stat_counter.animation.preset_id.as_deref(), - Some("builtin-linear") - ); - assert_eq!(stat_counter.animation.scale, 1.75); - assert!(matches!( - stat_counter.placement, - crate::models::KeyCounterPlacement::Outside - )); - assert_eq!( - transition - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); - assert!(replay.changed_fields.is_empty()); - assert_eq!( - replay - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - let cross_ops = vec![ - patch_property_op(EditorElementTypeV1::Key, &key_id, animation_enabled.clone()), - patch_property_op(EditorElementTypeV1::Stat, &stat_id, counter_enabled.clone()), - patch_property_op( - EditorElementTypeV1::Stat, - uuid::Uuid::new_v4().to_string(), - animation_enabled.clone(), - ), - ]; - let cross = prepare_editor_ops_transition(&store, &cross_ops).unwrap(); - assert!( - !cross.candidate.key_positions["4key"][0] - .counter - .animation - .enabled - ); - assert!( - !cross.candidate.stat_positions["4key"][0] - .position - .counter - .enabled - ); - assert_eq!( - cross - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - let cross_replay = prepare_editor_ops_transition(&cross.scratch, &cross_ops).unwrap(); - assert_eq!( - cross_replay - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - for (element_type, patch) in [ - (EditorElementTypeV1::Graph, counter_enabled), - (EditorElementTypeV1::Knob, animation_enabled), - ] { - let error = prepare_editor_ops_transition( - &store, - &[ - ops[0].clone(), - patch_property_op(element_type, uuid::Uuid::new_v4().to_string(), patch), - ], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); - assert!(store.key_positions["4key"][0].counter.enabled); - assert!( - store.stat_positions["4key"][0] - .position - .counter - .animation - .enabled - ); - } - } - - #[test] - fn counter_layout_patches_change_one_leaf_and_preserve_hidden_siblings_atomically() { - let mut store = store_with_every_reorder_type(); - let key_ids = store.key_positions["4key"] - .iter() - .take(2) - .map(|position| position.id.clone()) - .collect::>(); - let mut second_stat = store.stat_positions["4key"][0].clone(); - second_stat.position.id = uuid::Uuid::new_v4().to_string(); - store - .stat_positions - .get_mut("4key") - .unwrap() - .push(second_stat); - let stat_ids = store.stat_positions["4key"] - .iter() - .take(2) - .map(|position| position.position.id.clone()) - .collect::>(); - for counter in store.key_positions.get_mut("4key").unwrap()[..2] - .iter_mut() - .map(|position| &mut position.counter) - .chain( - store.stat_positions.get_mut("4key").unwrap()[..2] - .iter_mut() - .map(|position| &mut position.position.counter), - ) - { - counter.enabled = false; - counter.placement = crate::models::KeyCounterPlacement::Inside; - counter.align = crate::models::KeyCounterAlign::Bottom; - counter.align_mode = crate::models::KeyCounterAlignMode::Between; - counter.gap = 7; - counter.fill.idle = "fill-sibling".to_string(); - counter.fill.active = "active-fill-sibling".to_string(); - counter.font_family = Some("font-sibling".to_string()); - counter.animation.enabled = false; - counter.animation.preset_id = Some("builtin-linear".to_string()); - counter.animation.scale = 1.75; - } - let originals = [ - store.key_positions["4key"][0].counter.clone(), - store.key_positions["4key"][1].counter.clone(), - store.stat_positions["4key"][0].position.counter.clone(), - store.stat_positions["4key"][1].position.counter.clone(), - ]; - let placement = EditorElementPropertyPatchV1::CounterPlacement( - crate::models::KeyCounterPlacement::Outside, - ); - let align = - EditorElementPropertyPatchV1::CounterAlign(crate::models::KeyCounterAlign::Right); - let align_mode = EditorElementPropertyPatchV1::CounterAlignMode( - crate::models::KeyCounterAlignMode::Center, - ); - let gap = EditorElementPropertyPatchV1::CounterGap(u32::MAX); - let ops = vec![ - patch_property_op(EditorElementTypeV1::Key, &key_ids[0], placement.clone()), - patch_property_op(EditorElementTypeV1::Key, &key_ids[1], align.clone()), - patch_property_op(EditorElementTypeV1::Stat, &stat_ids[0], align_mode.clone()), - patch_property_op(EditorElementTypeV1::Stat, &stat_ids[1], gap.clone()), - patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - gap.clone(), - ), - ]; - - let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); - let actual = [ - transition.candidate.key_positions["4key"][0] - .counter - .clone(), - transition.candidate.key_positions["4key"][1] - .counter - .clone(), - transition.candidate.stat_positions["4key"][0] - .position - .counter - .clone(), - transition.candidate.stat_positions["4key"][1] - .position - .counter - .clone(), - ]; - let mut expected = originals.clone(); - expected[0].placement = crate::models::KeyCounterPlacement::Outside; - expected[1].align = crate::models::KeyCounterAlign::Right; - expected[2].align_mode = crate::models::KeyCounterAlignMode::Center; - expected[3].gap = u32::MAX; - assert_eq!(actual, expected); - assert!(matches!( - actual[0].align_mode, - crate::models::KeyCounterAlignMode::Between - )); - assert_eq!( - transition.changed_fields, - [EditorField::KeyPositions, EditorField::StatPositions] - ); - assert_eq!( - transition - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); - assert!(replay.changed_fields.is_empty()); - assert_eq!( - replay - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - for (element_type, patch) in [ - (EditorElementTypeV1::Graph, placement), - (EditorElementTypeV1::Knob, align), - (EditorElementTypeV1::Graph, align_mode), - (EditorElementTypeV1::Knob, gap), - ] { - let error = prepare_editor_ops_transition( - &store, - &[ - ops[0].clone(), - patch_property_op(element_type, uuid::Uuid::new_v4().to_string(), patch), - ], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); - assert_eq!(store.key_positions["4key"][0].counter, originals[0]); - } - } - - #[test] - fn counter_typography_patches_preserve_siblings_and_validate_types_and_ranges_atomically() { - let mut store = store_with_every_reorder_type(); - let key_ids = store.key_positions["4key"] - .iter() - .take(2) - .map(|position| position.id.clone()) - .collect::>(); - let first_stat = store.stat_positions["4key"][0].clone(); - for _ in 0..3 { - let mut stat = first_stat.clone(); - stat.position.id = uuid::Uuid::new_v4().to_string(); - store.stat_positions.get_mut("4key").unwrap().push(stat); - } - let stat_ids = store.stat_positions["4key"] - .iter() - .take(4) - .map(|position| position.position.id.clone()) - .collect::>(); - for counter in store.key_positions.get_mut("4key").unwrap()[..2] - .iter_mut() - .map(|position| &mut position.counter) - .chain( - store.stat_positions.get_mut("4key").unwrap()[..4] - .iter_mut() - .map(|position| &mut position.position.counter), - ) - { - counter.font_size = 16; - counter.font_weight = 400; - counter.font_italic = false; - counter.font_underline = false; - counter.font_strikethrough = false; - counter.font_family = Some("font-sibling".to_string()); - counter.fill.idle = "fill-sibling".to_string(); - counter.fill.active = "active-fill-sibling".to_string(); - counter.placement = crate::models::KeyCounterPlacement::Outside; - counter.animation.preset_id = Some("builtin-linear".to_string()); - } - store.stat_positions.get_mut("4key").unwrap()[3] - .position - .counter - .font_family = None; - store.stat_positions.get_mut("4key").unwrap()[3] - .position - .font_family = Some("top-level-font-sibling".to_string()); - let originals = [ - store.key_positions["4key"][0].counter.clone(), - store.key_positions["4key"][1].counter.clone(), - store.stat_positions["4key"][0].position.counter.clone(), - store.stat_positions["4key"][1].position.counter.clone(), - store.stat_positions["4key"][2].position.counter.clone(), - store.stat_positions["4key"][3].position.counter.clone(), - ]; - let patches = [ - EditorElementPropertyPatchV1::CounterFontSize(72), - EditorElementPropertyPatchV1::CounterFontWeight(900), - EditorElementPropertyPatchV1::CounterFontItalic(true), - EditorElementPropertyPatchV1::CounterFontUnderline(true), - EditorElementPropertyPatchV1::CounterFontStrikethrough(true), - EditorElementPropertyPatchV1::CounterFontFamily(" raw-counter-font ".to_string()), - ]; - let ops = vec![ - patch_property_op(EditorElementTypeV1::Key, &key_ids[0], patches[0].clone()), - patch_property_op(EditorElementTypeV1::Key, &key_ids[1], patches[1].clone()), - patch_property_op(EditorElementTypeV1::Stat, &stat_ids[0], patches[2].clone()), - patch_property_op(EditorElementTypeV1::Stat, &stat_ids[1], patches[3].clone()), - patch_property_op(EditorElementTypeV1::Stat, &stat_ids[2], patches[4].clone()), - patch_property_op(EditorElementTypeV1::Stat, &stat_ids[3], patches[5].clone()), - patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - patches[0].clone(), - ), - ]; - - let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); - let actual = [ - transition.candidate.key_positions["4key"][0] - .counter - .clone(), - transition.candidate.key_positions["4key"][1] - .counter - .clone(), - transition.candidate.stat_positions["4key"][0] - .position - .counter - .clone(), - transition.candidate.stat_positions["4key"][1] - .position - .counter - .clone(), - transition.candidate.stat_positions["4key"][2] - .position - .counter - .clone(), - transition.candidate.stat_positions["4key"][3] - .position - .counter - .clone(), - ]; - let mut expected = originals.clone(); - expected[0].font_size = 72; - expected[1].font_weight = 900; - expected[2].font_italic = true; - expected[3].font_underline = true; - expected[4].font_strikethrough = true; - expected[5].font_family = Some(" raw-counter-font ".to_string()); - assert_eq!(actual, expected); - assert_eq!( - transition.candidate.stat_positions["4key"][3] - .position - .font_family - .as_deref(), - Some("top-level-font-sibling") - ); - assert_eq!( - transition.changed_fields, - [EditorField::KeyPositions, EditorField::StatPositions] - ); - assert_eq!( - transition - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); - assert!(replay.changed_fields.is_empty()); - assert_eq!( - replay - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - for (element_type, patch) in [ - (EditorElementTypeV1::Graph, patches[0].clone()), - (EditorElementTypeV1::Knob, patches[1].clone()), - (EditorElementTypeV1::Graph, patches[2].clone()), - (EditorElementTypeV1::Knob, patches[3].clone()), - (EditorElementTypeV1::Graph, patches[4].clone()), - (EditorElementTypeV1::Knob, patches[5].clone()), - ] { - let error = prepare_editor_ops_transition( - &store, - &[ - ops[0].clone(), - patch_property_op(element_type, uuid::Uuid::new_v4().to_string(), patch), - ], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); - assert_eq!(store.key_positions["4key"][0].counter, originals[0]); - } - - for (patch, code) in [ - ( - EditorElementPropertyPatchV1::CounterFontSize(7), - "COUNTER_FONT_SIZE_OUT_OF_RANGE", - ), - ( - EditorElementPropertyPatchV1::CounterFontSize(73), - "COUNTER_FONT_SIZE_OUT_OF_RANGE", - ), - ( - EditorElementPropertyPatchV1::CounterFontWeight(99), - "COUNTER_FONT_WEIGHT_OUT_OF_RANGE", - ), - ( - EditorElementPropertyPatchV1::CounterFontWeight(901), - "COUNTER_FONT_WEIGHT_OUT_OF_RANGE", - ), - ] { - let error = prepare_editor_ops_transition( - &store, - &[ - ops[0].clone(), - patch_property_op(EditorElementTypeV1::Stat, &stat_ids[0], patch), - ], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some(code)); - assert_eq!(store.key_positions["4key"][0].counter, originals[0]); - } - - let lower_boundaries = prepare_editor_ops_transition( - &store, - &[ - patch_property_op( - EditorElementTypeV1::Key, - &key_ids[0], - EditorElementPropertyPatchV1::CounterFontSize(8), - ), - patch_property_op( - EditorElementTypeV1::Stat, - &stat_ids[0], - EditorElementPropertyPatchV1::CounterFontWeight(100), - ), - ], - ) - .unwrap(); - assert_eq!( - lower_boundaries.candidate.key_positions["4key"][0] - .counter - .font_size, - 8 - ); - assert_eq!( - lower_boundaries.candidate.stat_positions["4key"][0] - .position - .counter - .font_weight, - 100 - ); - } - - #[test] - fn counter_fill_intents_apply_atomic_pairs_and_reject_noncanonical_plans() { - let mut store = store_with_every_reorder_type(); - let key_ids = store.key_positions["4key"] - .iter() - .take(2) - .map(|position| position.id.clone()) - .collect::>(); - let stat_id = store.stat_positions["4key"][0].position.id.clone(); - for counter in store.key_positions.get_mut("4key").unwrap()[..2] - .iter_mut() - .map(|position| &mut position.counter) - .chain(std::iter::once( - &mut store.stat_positions.get_mut("4key").unwrap()[0] - .position - .counter, - )) - { - counter.fill.idle = "idle-before".to_string(); - counter.fill.active = "active-before".to_string(); - counter.fill_idle_gradient = Some(GradientSpec::from_canonical_parts( - 15.0, - vec![ - crate::models::GradientStop { - color: "old-idle".to_string(), - pos: 0.0, - }, - crate::models::GradientStop { - color: "old-idle-end".to_string(), - pos: 1.0, - }, - ], - )); - counter.fill_active_gradient = Some(GradientSpec::from_canonical_parts( - 30.0, - vec![ - crate::models::GradientStop { - color: "old-active".to_string(), - pos: 0.0, - }, - crate::models::GradientStop { - color: "old-active-end".to_string(), - pos: 1.0, - }, - ], - )); - counter.gap = 19; - counter.font_family = Some("font-sibling".to_string()); - counter.animation.preset_id = Some("builtin-linear".to_string()); - } - let originals = [ - store.key_positions["4key"][0].counter.clone(), - store.key_positions["4key"][1].counter.clone(), - store.stat_positions["4key"][0].position.counter.clone(), - ]; - let idle_gradient = counter_fill_gradient( - "rgba(170,187,204,1)", - 45.0, - &[("#ABC", 0.0), ("#112233", 1.0)], - ); - let active_gradient = counter_fill_gradient( - "rgba(1,2,3,0.5)", - 90.0, - &[("rgba(1, 2, 3, 0.5)", 0.0), ("transparent", 1.0)], - ); - let ops = vec![ - patch_property_op( - EditorElementTypeV1::Key, - &key_ids[0], - EditorElementPropertyPatchV1::CounterFillIdle(idle_gradient.clone()), - ), - patch_property_op( - EditorElementTypeV1::Key, - &key_ids[1], - EditorElementPropertyPatchV1::CounterFillActive(active_gradient.clone()), - ), - patch_property_op( - EditorElementTypeV1::Stat, - &stat_id, - EditorElementPropertyPatchV1::CounterFillIdle(counter_fill_solid(" raw solid ")), - ), - patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - EditorElementPropertyPatchV1::CounterFillIdle(counter_fill_solid("missing")), - ), - ]; - - let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); - assert_eq!( - transition.changed_fields, - [EditorField::KeyPositions, EditorField::StatPositions] - ); - assert_eq!( - transition - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - let actual = [ - transition.candidate.key_positions["4key"][0] - .counter - .clone(), - transition.candidate.key_positions["4key"][1] - .counter - .clone(), - transition.candidate.stat_positions["4key"][0] - .position - .counter - .clone(), - ]; - let mut expected = originals.clone(); - expected[0].fill.idle = "rgba(170,187,204,1)".to_string(); - expected[0].fill_idle_gradient = match &idle_gradient { - EditorCounterFillIntentV1::Gradient(intent) => Some(intent.gradient.to_gradient_spec()), - EditorCounterFillIntentV1::Solid(_) => unreachable!(), - }; - expected[1].fill.active = "rgba(1,2,3,0.5)".to_string(); - expected[1].fill_active_gradient = match &active_gradient { - EditorCounterFillIntentV1::Gradient(intent) => Some(intent.gradient.to_gradient_spec()), - EditorCounterFillIntentV1::Solid(_) => unreachable!(), - }; - expected[2].fill.idle = " raw solid ".to_string(); - expected[2].fill_idle_gradient = None; - assert_eq!(actual, expected); - assert_eq!(actual[0].fill.active, originals[0].fill.active); - assert_eq!( - actual[0].fill_active_gradient, - originals[0].fill_active_gradient - ); - assert_eq!(actual[1].fill.idle, originals[1].fill.idle); - assert_eq!( - actual[1].fill_idle_gradient, - originals[1].fill_idle_gradient - ); - - let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); - assert!(replay.changed_fields.is_empty()); - assert_eq!( - replay - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - for (invalid, code) in [ - ( - counter_fill_gradient("#ABC", 45.0, &[("#ABC", 0.0), ("#fff", 1.0)]), - "COUNTER_FILL_COLOR_GRADIENT_MISMATCH", - ), - ( - counter_fill_gradient("rgba(170,187,204,1)", -0.0, &[("#ABC", 0.0), ("#fff", 1.0)]), - "INVALID_PAINT_GRADIENT", - ), - ] { - let error = prepare_editor_ops_transition( - &store, - &[ - ops[0].clone(), - patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - EditorElementPropertyPatchV1::CounterFillIdle(invalid), - ), - ], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some(code)); - assert_eq!(store.key_positions["4key"][0].counter, originals[0]); - } - - for (element_type, patch) in [ - ( - EditorElementTypeV1::Graph, - EditorElementPropertyPatchV1::CounterFillIdle(counter_fill_solid("idle")), - ), - ( - EditorElementTypeV1::Knob, - EditorElementPropertyPatchV1::CounterFillIdle(counter_fill_solid("idle")), - ), - ( - EditorElementTypeV1::Stat, - EditorElementPropertyPatchV1::CounterFillActive(counter_fill_solid("active")), - ), - ] { - let error = prepare_editor_ops_transition( - &store, - &[ - ops[0].clone(), - patch_property_op(element_type, uuid::Uuid::new_v4().to_string(), patch), - ], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); - assert_eq!(store.key_positions["4key"][0].counter, originals[0]); - } - } - - #[test] - fn counter_animation_preset_patch_uses_current_library_and_preserves_unmasked_fields() { - let mut store = store_with_every_reorder_type(); - let preset = CounterAnimationPreset { - id: "user-current".to_string(), - name: "Current".to_string(), - source: crate::models::CounterAnimationSource::User, - label_key: None, - bezier: [0.1, 0.2, 0.8, 0.9], - scale: 1.25, - duration_ms: 420, - }; - store.counter_animation_presets.push(preset.clone()); - let key_id = store.key_positions["4key"][0].id.clone(); - let stat_id = store.stat_positions["4key"][0].position.id.clone(); - let missing_id = uuid::Uuid::new_v4().to_string(); - { - let animation = &mut store.key_positions.get_mut("4key").unwrap()[0] - .counter - .animation; - animation.enabled = false; - animation.preset_id = Some("builtin-ease-out".to_string()); - animation.bezier = [0.3, 0.4, 0.5, 0.6]; - animation.scale = 0.75; - animation.duration_ms = 777; - } - let partial = EditorElementPropertyPatchV1::CounterAnimationPreset( - EditorCounterAnimationPresetIntentV1 { - preset_id: preset.id.clone(), - apply_preset_id: None, - bezier: None, - scale: Some(preset.scale), - duration_ms: None, - }, - ); - let full = EditorElementPropertyPatchV1::CounterAnimationPreset( - EditorCounterAnimationPresetIntentV1 { - preset_id: preset.id.clone(), - apply_preset_id: Some(true), - bezier: Some(preset.bezier), - scale: Some(preset.scale), - duration_ms: Some(preset.duration_ms), - }, - ); - let ops = vec![ - patch_property_op(EditorElementTypeV1::Key, &key_id, partial.clone()), - patch_property_op(EditorElementTypeV1::Stat, &stat_id, full.clone()), - patch_property_op(EditorElementTypeV1::Key, &missing_id, full.clone()), - ]; - - let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); - let key_animation = &transition.candidate.key_positions["4key"][0] - .counter - .animation; - assert!(!key_animation.enabled); - assert_eq!(key_animation.preset_id.as_deref(), Some("builtin-ease-out")); - assert_eq!(key_animation.bezier, [0.3, 0.4, 0.5, 0.6]); - assert_eq!(key_animation.scale, preset.scale); - assert_eq!(key_animation.duration_ms, 777); - let stat_animation = &transition.candidate.stat_positions["4key"][0] - .position - .counter - .animation; - assert_eq!( - stat_animation.preset_id.as_deref(), - Some(preset.id.as_str()) - ); - assert_eq!(stat_animation.bezier, preset.bezier); - assert_eq!(stat_animation.scale, preset.scale); - assert_eq!(stat_animation.duration_ms, preset.duration_ms); - assert_eq!( - transition - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); - assert!(replay.changed_fields.is_empty()); - assert_eq!( - replay - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - let id_only = EditorElementPropertyPatchV1::CounterAnimationPreset( - EditorCounterAnimationPresetIntentV1 { - preset_id: preset.id.clone(), - apply_preset_id: None, - bezier: None, - scale: None, - duration_ms: None, - }, - ); - prepare_editor_ops_transition( - &store, - &[patch_property_op( - EditorElementTypeV1::Key, - &key_id, - id_only, - )], - ) - .unwrap(); - - let stale = EditorElementPropertyPatchV1::CounterAnimationPreset( - EditorCounterAnimationPresetIntentV1 { - preset_id: preset.id.clone(), - apply_preset_id: None, - bezier: Some(preset.bezier), - scale: Some(preset.scale + 0.01), - duration_ms: Some(preset.duration_ms), - }, - ); - let error = prepare_editor_ops_transition( - &store, - &[ - ops[0].clone(), - patch_property_op(EditorElementTypeV1::Stat, &stat_id, stale), - ], - ) - .unwrap_err(); - assert_eq!( - validation_code(&error), - Some("COUNTER_ANIMATION_PRESET_MISMATCH") - ); - assert_eq!( - store.key_positions["4key"][0] - .counter - .animation - .preset_id - .as_deref(), - Some("builtin-ease-out") - ); - - let missing_preset = EditorElementPropertyPatchV1::CounterAnimationPreset( - EditorCounterAnimationPresetIntentV1 { - preset_id: "user-deleted".to_string(), - apply_preset_id: Some(true), - bezier: None, - scale: None, - duration_ms: None, - }, - ); - let error = prepare_editor_ops_transition( - &store, - &[patch_property_op( - EditorElementTypeV1::Key, - &key_id, - missing_preset, - )], - ) - .unwrap_err(); - assert_eq!( - validation_code(&error), - Some("COUNTER_ANIMATION_PRESET_NOT_FOUND") - ); - - for invalid in [ - EditorCounterAnimationPresetIntentV1 { - preset_id: preset.id.clone(), - apply_preset_id: None, - bezier: Some([f64::NAN, 0.2, 0.8, 0.9]), - scale: None, - duration_ms: None, - }, - EditorCounterAnimationPresetIntentV1 { - preset_id: preset.id.clone(), - apply_preset_id: None, - bezier: Some([-0.1, 0.2, 0.8, 0.9]), - scale: None, - duration_ms: None, - }, - EditorCounterAnimationPresetIntentV1 { - preset_id: preset.id.clone(), - apply_preset_id: None, - bezier: None, - scale: Some(f64::INFINITY), - duration_ms: None, - }, - EditorCounterAnimationPresetIntentV1 { - preset_id: preset.id.clone(), - apply_preset_id: None, - bezier: None, - scale: None, - duration_ms: Some(0), - }, - EditorCounterAnimationPresetIntentV1 { - preset_id: preset.id.clone(), - apply_preset_id: None, - bezier: None, - scale: None, - duration_ms: Some(5001), - }, - ] { - let error = prepare_editor_ops_transition( - &store, - &[patch_property_op( - EditorElementTypeV1::Key, - &key_id, - EditorElementPropertyPatchV1::CounterAnimationPreset(invalid), - )], - ) - .unwrap_err(); - assert_eq!( - validation_code(&error), - Some("INVALID_COUNTER_ANIMATION_PRESET_VALUE") - ); - } - - for element_type in [EditorElementTypeV1::Graph, EditorElementTypeV1::Knob] { - let error = prepare_editor_ops_transition( - &store, - &[patch_property_op( - element_type, - uuid::Uuid::new_v4().to_string(), - full.clone(), - )], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); - } - } - - #[test] - fn stat_type_patch_is_stat_only_and_preserves_embedded_position() { - let mut store = store_with_every_reorder_type(); - store.graph_positions.get_mut("4key").unwrap()[0].stat_type = GraphStatType::KpsMax; - let stat = store.stat_positions["4key"][0].clone(); - let graph = store.graph_positions["4key"][0].clone(); - let stat_id = stat.position.id.clone(); - let missing_id = uuid::Uuid::new_v4().to_string(); - let patch = EditorElementPropertyPatchV1::StatType(StatType::Total); - let ops = vec![ - patch_property_op(EditorElementTypeV1::Stat, &stat_id, patch.clone()), - patch_property_op(EditorElementTypeV1::Stat, missing_id, patch.clone()), - ]; - - let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); - assert_eq!( - transition.candidate.stat_positions["4key"][0].stat_type, - StatType::Total - ); - assert_eq!( - transition.candidate.stat_positions["4key"][0].position, - stat.position - ); - assert_eq!(transition.candidate.graph_positions["4key"][0], graph); - assert_eq!(transition.changed_fields, [EditorField::StatPositions]); - assert_eq!( - transition - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); - assert!(replay.changed_fields.is_empty()); - assert_eq!( - replay - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - let error = prepare_editor_ops_transition( - &store, - &[ - ops[0].clone(), - patch_property_op(EditorElementTypeV1::Graph, &stat_id, patch), - ], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); - assert_eq!(store.stat_positions["4key"][0], stat); - assert_eq!(store.graph_positions["4key"][0], graph); - } - - #[test] - fn note_literal_patches_are_key_only_and_preserve_border_side_raw_state() { - let mut store = base_store(); - let ids = store - .key_positions - .values() - .flat_map(|positions| positions.iter().map(|position| position.id.clone())) - .take(5) - .collect::>(); - assert_eq!(ids.len(), 5); - let legacy_id = store - .key_positions - .values_mut() - .flat_map(|positions| positions.iter_mut()) - .find(|position| !ids.contains(&position.id)) - .map(|position| { - position.note_border_side = Some("diagonal".to_string()); - position.id.clone() - }) - .expect("default modes contain an untargeted key"); - let ops = vec![ - patch_property_op( - EditorElementTypeV1::Key, - &ids[0], - EditorElementPropertyPatchV1::NoteEffectEnabled(false), - ), - patch_property_op( - EditorElementTypeV1::Key, - &ids[1], - EditorElementPropertyPatchV1::NoteGlowEnabled(true), - ), - patch_property_op( - EditorElementTypeV1::Key, - &ids[2], - EditorElementPropertyPatchV1::NoteAutoYCorrection(false), - ), - patch_property_op( - EditorElementTypeV1::Key, - &ids[3], - EditorElementPropertyPatchV1::NoteAlignment(crate::models::NoteAlignment::Right), - ), - patch_property_op( - EditorElementTypeV1::Key, - &ids[4], - EditorElementPropertyPatchV1::NoteBorderSide( - crate::models::EditorNoteBorderSideV1::All, - ), - ), - patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - EditorElementPropertyPatchV1::NoteGlowEnabled(true), - ), - ]; - - let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); - let changed = transition - .candidate - .key_positions - .values() - .flat_map(|positions| positions.iter()) - .map(|position| (position.id.as_str(), position)) - .collect::>(); - assert!(!changed[ids[0].as_str()].note_effect_enabled); - assert!(changed[ids[1].as_str()].note_glow_enabled); - assert!(!changed[ids[2].as_str()].note_auto_y_correction); - assert_eq!( - changed[ids[3].as_str()].note_alignment, - crate::models::NoteAlignment::Right - ); - assert_eq!( - changed[ids[4].as_str()].note_border_side.as_deref(), - Some("all") - ); - assert_eq!( - changed[legacy_id.as_str()].note_border_side.as_deref(), - Some("diagonal") - ); - assert_eq!(transition.changed_fields, [EditorField::KeyPositions]); - assert_eq!( - transition - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); - assert!(replay.changed_fields.is_empty()); - assert_eq!( - replay - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - for (element_type, id) in [ - (EditorElementTypeV1::Stat, ids[1].clone()), - (EditorElementTypeV1::Graph, uuid::Uuid::new_v4().to_string()), - ] { - let error = prepare_editor_ops_transition( - &store, - &[ - ops[0].clone(), - patch_property_op( - element_type, - id, - EditorElementPropertyPatchV1::NoteGlowEnabled(true), - ), - ], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); - } - assert!(store.key_positions.values().flatten().all(|position| { - position.note_effect_enabled - && !position.note_glow_enabled - && position.note_auto_y_correction - && (position.id == legacy_id || position.note_border_side.is_none()) - })); - } - - #[test] - fn note_glow_sync_enable_mirrors_body_paint_and_replays_as_no_change() { - let mut store = base_store(); - let id = store.key_positions["4key"][0].id.clone(); - let position = &mut store.key_positions.get_mut("4key").unwrap()[0]; - position.note_color = NoteColor::Gradient { - top: "#112233".to_string(), - bottom: "#445566".to_string(), - }; - position.note_gradient = serde_json::from_value(serde_json::json!({ - "angle": 180, - "stops": [ - { "color": "#112233", "pos": 0 }, - { "color": "rgba(68, 85, 102, 0.5)", "pos": 1 } - ] - })) - .unwrap(); - position.note_opacity = 80; - position.note_opacity_top = Some(80); - position.note_opacity_bottom = Some(40); - position.note_glow_color = Some(NoteColor::Solid("stale".to_string())); - position.note_glow_opacity = 70; - - let op = patch_property_op( - EditorElementTypeV1::Key, - &id, - EditorElementPropertyPatchV1::NoteGlowSyncPaint(true), - ); - let transition = prepare_editor_ops_transition(&store, std::slice::from_ref(&op)).unwrap(); - let changed = &transition.candidate.key_positions["4key"][0]; - assert!(changed.note_glow_sync_paint); - assert_note_body_glow_paint_equal(changed); - assert_eq!( - transition.op_results[0].status, - EditorOpResultStatusV1::Applied - ); - - let replay = prepare_editor_ops_transition(&transition.scratch, &[op]).unwrap(); - assert_eq!( - replay.op_results[0].status, - EditorOpResultStatusV1::NoChange - ); - } - - #[test] - fn synced_note_paint_variants_mirror_every_body_paint_field() { - let mut store = base_store(); - let id = store.key_positions["4key"][0].id.clone(); - let position = &mut store.key_positions.get_mut("4key").unwrap()[0]; - position.note_glow_sync_paint = true; - position.note_color = NoteColor::Gradient { - top: "#010203".to_string(), - bottom: "#040506".to_string(), - }; - position.note_gradient = serde_json::from_value(serde_json::json!({ - "angle": 45, - "stops": [ - { "color": "#010203", "pos": 0 }, - { "color": "#040506", "pos": 1 } - ] - })) - .unwrap(); - position.note_opacity = 90; - position.note_opacity_top = Some(90); - position.note_opacity_bottom = Some(90); - assert!(position.mirror_note_body_to_glow()); - - let descriptor = EditorElementPropertyPatchV1::NotePaint( - EditorNotePaintIntentV1::Descriptor(crate::models::EditorNotePaintDescriptorIntentV1 { - color: EditorNoteColorV1::Gradient(crate::models::EditorNoteGradientColorV1 { - kind: crate::models::EditorNoteGradientColorKindV1::Gradient, - top: "#112233".to_string(), - bottom: "#445566".to_string(), - }), - opacity: 60, - gradient: Some(crate::models::EditorPaintGradientV1 { - angle: 90.0, - stops: vec![ - crate::models::EditorPaintGradientStopV1 { - color: "#112233".to_string(), - pos: 0.0, - }, - crate::models::EditorPaintGradientStopV1 { - color: "rgba(68, 85, 102, 0.5)".to_string(), - pos: 1.0, - }, - ], - }), - }), - ); - let color = EditorElementPropertyPatchV1::NotePaint(EditorNotePaintIntentV1::Color( - crate::models::EditorNotePaintColorIntentV1 { - color: EditorNoteColorV1::Solid("#AABBCC".to_string()), - }, - )); - let opacity = EditorElementPropertyPatchV1::NotePaint(EditorNotePaintIntentV1::Opacity( - crate::models::EditorNotePaintOpacityIntentV1 { opacity: 55 }, - )); - let gradient_opacity = - EditorElementPropertyPatchV1::NotePaint(EditorNotePaintIntentV1::GradientOpacity( - crate::models::EditorNotePaintGradientOpacityIntentV1 { - opacity: 70, - opacity_top: 65, - opacity_bottom: 35, - }, - )); - - for patch in [descriptor, color, opacity, gradient_opacity] { - let transition = prepare_editor_ops_transition( - &store, - &[patch_property_op(EditorElementTypeV1::Key, &id, patch)], - ) - .unwrap(); - let changed = &transition.candidate.key_positions["4key"][0]; - assert_note_body_glow_paint_equal(changed); - assert_eq!( - transition.op_results[0].status, - EditorOpResultStatusV1::Applied - ); - } - } - - #[test] - fn synced_note_glow_paint_rejects_the_whole_transition() { - let mut store = base_store(); - let id = store.key_positions["4key"][0].id.clone(); - let position = &mut store.key_positions.get_mut("4key").unwrap()[0]; - position.note_glow_sync_paint = true; - assert!(position.mirror_note_body_to_glow()); - let original = store.clone(); - - let error = prepare_editor_ops_transition( - &store, - &[ - patch_property_op( - EditorElementTypeV1::Key, - &id, - EditorElementPropertyPatchV1::Hidden(true), - ), - patch_property_op( - EditorElementTypeV1::Key, - &id, - EditorElementPropertyPatchV1::NoteGlowPaint(EditorNotePaintIntentV1::Opacity( - crate::models::EditorNotePaintOpacityIntentV1 { opacity: 70 }, - )), - ), - ], - ) - .unwrap_err(); - - assert_eq!(validation_code(&error), Some("NOTE_GLOW_PAINT_SYNC_LOCKED")); - assert_eq!(store, original); - } - - #[test] - fn note_glow_sync_disable_preserves_the_mirrored_paint() { - let mut store = base_store(); - let id = store.key_positions["4key"][0].id.clone(); - let position = &mut store.key_positions.get_mut("4key").unwrap()[0]; - position.note_glow_sync_paint = true; - position.note_color = NoteColor::Solid("#123456".to_string()); - position.note_opacity = 73; - position.note_opacity_top = Some(63); - position.note_opacity_bottom = Some(53); - assert!(position.mirror_note_body_to_glow()); - let before = position.clone(); - - let transition = prepare_editor_ops_transition( - &store, - &[patch_property_op( - EditorElementTypeV1::Key, - &id, - EditorElementPropertyPatchV1::NoteGlowSyncPaint(false), - )], - ) - .unwrap(); - let changed = &transition.candidate.key_positions["4key"][0]; - assert!(!changed.note_glow_sync_paint); - assert_eq!(changed.note_glow_gradient, before.note_glow_gradient); - assert_eq!(changed.note_glow_opacity, before.note_glow_opacity); - assert_eq!(changed.note_glow_color, before.note_glow_color); - assert_eq!(changed.note_glow_opacity_top, before.note_glow_opacity_top); - assert_eq!( - changed.note_glow_opacity_bottom, - before.note_glow_opacity_bottom - ); - } - - #[test] - fn unsynced_note_paint_preserves_glow_paint() { - let mut store = base_store(); - let id = store.key_positions["4key"][0].id.clone(); - let position = &mut store.key_positions.get_mut("4key").unwrap()[0]; - position.note_glow_sync_paint = false; - position.note_glow_color = Some(NoteColor::Solid("glow".to_string())); - position.note_glow_opacity = 71; - position.note_glow_opacity_top = Some(61); - position.note_glow_opacity_bottom = Some(51); - let before = position.clone(); - - let transition = prepare_editor_ops_transition( - &store, - &[patch_property_op( - EditorElementTypeV1::Key, - &id, - EditorElementPropertyPatchV1::NotePaint(EditorNotePaintIntentV1::Color( - crate::models::EditorNotePaintColorIntentV1 { - color: EditorNoteColorV1::Solid("#AABBCC".to_string()), - }, - )), - )], - ) - .unwrap(); - let changed = &transition.candidate.key_positions["4key"][0]; - assert_eq!(changed.note_glow_gradient, before.note_glow_gradient); - assert_eq!(changed.note_glow_opacity, before.note_glow_opacity); - assert_eq!(changed.note_glow_color, before.note_glow_color); - assert_eq!(changed.note_glow_opacity_top, before.note_glow_opacity_top); - assert_eq!( - changed.note_glow_opacity_bottom, - before.note_glow_opacity_bottom - ); - } - - #[test] - fn note_glow_size_is_key_only_bounded_and_preserves_note_siblings() { - let mut store = base_store(); - let id = store.key_positions["4key"][0].id.clone(); - let position = &mut store.key_positions.get_mut("4key").unwrap()[0]; - position.note_glow_size = 20.0; - position.note_glow_enabled = true; - position.note_glow_opacity = 71; - position.note_glow_color = - Some(crate::models::NoteColor::Solid("glow-sibling".to_string())); - position.note_color = crate::models::NoteColor::Solid("note-sibling".to_string()); - position.note_border_width = Some(2.5); - let original = position.clone(); - let patch = EditorElementPropertyPatchV1::NoteGlowSize(0.5); - let ops = vec![ - patch_property_op(EditorElementTypeV1::Key, &id, patch.clone()), - patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - patch.clone(), - ), - ]; - - let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); - let changed = &transition.candidate.key_positions["4key"][0]; - assert_eq!(changed.note_glow_size, 0.5); - assert_eq!(changed.note_glow_enabled, original.note_glow_enabled); - assert_eq!(changed.note_glow_opacity, original.note_glow_opacity); - assert_eq!(changed.note_glow_color, original.note_glow_color); - assert_eq!(changed.note_color, original.note_color); - assert_eq!(changed.note_border_width, original.note_border_width); - assert_eq!(transition.changed_fields, [EditorField::KeyPositions]); - assert_eq!( - transition - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); - assert!(replay.changed_fields.is_empty()); - assert_eq!( - replay - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - for value in [-0.1, 50.1, f64::NAN, f64::INFINITY] { - let error = prepare_editor_ops_transition( - &store, - &[ - ops[0].clone(), - patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - EditorElementPropertyPatchV1::NoteGlowSize(value), - ), - ], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some("NOTE_GLOW_SIZE_OUT_OF_RANGE")); - assert_eq!(store.key_positions["4key"][0], original); - } - - let lower = prepare_editor_ops_transition( - &store, - &[patch_property_op( - EditorElementTypeV1::Key, - &id, - EditorElementPropertyPatchV1::NoteGlowSize(0.0), - )], - ) - .unwrap(); - assert_eq!(lower.candidate.key_positions["4key"][0].note_glow_size, 0.0); - let upper = prepare_editor_ops_transition( - &store, - &[patch_property_op( - EditorElementTypeV1::Key, - &id, - EditorElementPropertyPatchV1::NoteGlowSize(50.0), - )], - ) - .unwrap(); - assert_eq!( - upper.candidate.key_positions["4key"][0].note_glow_size, - 50.0 - ); - - for element_type in [ - EditorElementTypeV1::Stat, - EditorElementTypeV1::Graph, - EditorElementTypeV1::Knob, - ] { - let error = prepare_editor_ops_transition( - &store, - &[ - ops[0].clone(), - patch_property_op( - element_type, - uuid::Uuid::new_v4().to_string(), - patch.clone(), - ), - ], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); - assert_eq!(store.key_positions["4key"][0], original); - } - } - - #[test] - fn note_gradient_descriptor_and_legacy_transitions_are_atomic() { - let mut store = base_store(); - let id = store.key_positions["4key"][0].id.clone(); - let position = &mut store.key_positions.get_mut("4key").unwrap()[0]; - position.note_color = NoteColor::Solid("legacy".to_string()); - position.note_opacity = 90; - position.note_opacity_top = Some(30); - position.note_opacity_bottom = Some(70); - - let gradient = crate::models::EditorPaintGradientV1 { - angle: 45.0, - stops: vec![ - crate::models::EditorPaintGradientStopV1 { - color: "rgba(17,34,51,.5)".to_string(), - pos: 0.0, - }, - crate::models::EditorPaintGradientStopV1 { - color: "#44556640".to_string(), - pos: 1.0, - }, - ], - }; - let descriptor = EditorElementPropertyPatchV1::NotePaint( - EditorNotePaintIntentV1::Descriptor(crate::models::EditorNotePaintDescriptorIntentV1 { - color: EditorNoteColorV1::Gradient(crate::models::EditorNoteGradientColorV1 { - kind: crate::models::EditorNoteGradientColorKindV1::Gradient, - top: "#112233".to_string(), - bottom: "#445566".to_string(), - }), - opacity: 80, - gradient: Some(gradient.clone()), - }), - ); - let first = prepare_editor_ops_transition( - &store, - &[patch_property_op( - EditorElementTypeV1::Key, - &id, - descriptor.clone(), - )], - ) - .unwrap(); - let position = &first.candidate.key_positions["4key"][0]; - assert_eq!(position.note_gradient.as_ref().unwrap().angle, 45.0); - assert_eq!(position.note_opacity, 80); - assert_eq!(position.note_opacity_top, Some(40)); - assert_eq!(position.note_opacity_bottom, Some(20)); - assert_eq!( - position.note_color, - NoteColor::Gradient { - top: "#112233".to_string(), - bottom: "#445566".to_string(), - } - ); - - let replay = prepare_editor_ops_transition( - &first.scratch, - &[patch_property_op(EditorElementTypeV1::Key, &id, descriptor)], - ) - .unwrap(); - assert_eq!( - replay.op_results[0].status, - EditorOpResultStatusV1::NoChange - ); - - let opacity = EditorElementPropertyPatchV1::NotePaint(EditorNotePaintIntentV1::Opacity( - crate::models::EditorNotePaintOpacityIntentV1 { opacity: 40 }, - )); - let second = prepare_editor_ops_transition( - &first.scratch, - &[patch_property_op(EditorElementTypeV1::Key, &id, opacity)], - ) - .unwrap(); - let position = &second.candidate.key_positions["4key"][0]; - assert!(position.note_gradient.is_some()); - assert_eq!(position.note_opacity_top, Some(20)); - assert_eq!(position.note_opacity_bottom, Some(10)); - - let legacy_endpoints = - EditorElementPropertyPatchV1::NotePaint(EditorNotePaintIntentV1::GradientOpacity( - crate::models::EditorNotePaintGradientOpacityIntentV1 { - opacity: 55, - opacity_top: 11, - opacity_bottom: 44, - }, - )); - let third = prepare_editor_ops_transition( - &second.scratch, - &[patch_property_op( - EditorElementTypeV1::Key, - &id, - legacy_endpoints, - )], - ) - .unwrap(); - let position = &third.candidate.key_positions["4key"][0]; - assert!(position.note_gradient.is_none()); - assert_eq!(position.note_opacity, 55); - assert_eq!(position.note_opacity_top, Some(11)); - assert_eq!(position.note_opacity_bottom, Some(44)); - - let solid = EditorElementPropertyPatchV1::NoteGlowPaint( - EditorNotePaintIntentV1::Descriptor(crate::models::EditorNotePaintDescriptorIntentV1 { - color: EditorNoteColorV1::Solid("#AABBCC".to_string()), - opacity: 35, - gradient: None, - }), - ); - let fourth = prepare_editor_ops_transition( - &third.scratch, - &[patch_property_op(EditorElementTypeV1::Key, &id, solid)], - ) - .unwrap(); - let position = &fourth.candidate.key_positions["4key"][0]; - assert!(position.note_glow_gradient.is_none()); - assert_eq!( - position.note_glow_color, - Some(NoteColor::Solid("#AABBCC".to_string())) - ); - assert_eq!(position.note_glow_opacity, 35); - assert_eq!(position.note_glow_opacity_top, Some(35)); - assert_eq!(position.note_glow_opacity_bottom, Some(35)); - } - - #[test] - fn note_gradient_descriptor_rejects_semantic_mismatches() { - let store = base_store(); - let id = store.key_positions["4key"][0].id.clone(); - let gradient = |first: &str| crate::models::EditorPaintGradientV1 { - angle: 45.0, - stops: vec![ - crate::models::EditorPaintGradientStopV1 { - color: first.to_string(), - pos: 0.0, - }, - crate::models::EditorPaintGradientStopV1 { - color: "#445566".to_string(), - pos: 1.0, - }, - ], - }; - let descriptor = |color, opacity, gradient| { - EditorElementPropertyPatchV1::NotePaint(EditorNotePaintIntentV1::Descriptor( - crate::models::EditorNotePaintDescriptorIntentV1 { - color, - opacity, - gradient, - }, - )) - }; - - let cases = [ - ( - descriptor( - EditorNoteColorV1::Gradient(crate::models::EditorNoteGradientColorV1 { - kind: crate::models::EditorNoteGradientColorKindV1::Gradient, - top: "#000000".to_string(), - bottom: "#445566".to_string(), - }), - 80, - Some(gradient("#112233")), - ), - "PAINT_COLOR_GRADIENT_MISMATCH", - ), - ( - descriptor( - EditorNoteColorV1::Gradient(crate::models::EditorNoteGradientColorV1 { - kind: crate::models::EditorNoteGradientColorKindV1::Gradient, - top: "#112233".to_string(), - bottom: "#445566".to_string(), - }), - 80, - None, - ), - "PAINT_COLOR_GRADIENT_MISMATCH", - ), - ( - descriptor( - EditorNoteColorV1::Gradient(crate::models::EditorNoteGradientColorV1 { - kind: crate::models::EditorNoteGradientColorKindV1::Gradient, - top: "#112233".to_string(), - bottom: "#445566".to_string(), - }), - 80, - Some(gradient("transparent")), - ), - "INVALID_PAINT_GRADIENT", - ), - ( - descriptor(EditorNoteColorV1::Solid("#112233".to_string()), 101, None), - "NOTE_OPACITY_OUT_OF_RANGE", - ), - ]; - - for (patch, expected_code) in cases { - let error = prepare_editor_ops_transition( - &store, - &[patch_property_op(EditorElementTypeV1::Key, &id, patch)], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some(expected_code)); - } - } - - #[test] - fn note_paint_masks_preserve_siblings_materialize_options_and_reject_invalid_plans() { - let mut store = base_store(); - let ids = [ - store.key_positions["4key"][0].id.clone(), - store.key_positions["4key"][1].id.clone(), - store.key_positions["4key"][2].id.clone(), - store.key_positions["4key"][3].id.clone(), - store.key_positions["5key"][0].id.clone(), - ]; - let positions = store.key_positions.get_mut("4key").unwrap(); - positions[0].note_color = NoteColor::Solid("old-note".to_string()); - positions[0].note_opacity = 41; - positions[0].note_opacity_top = Some(42); - positions[0].note_opacity_bottom = Some(43); - positions[1].note_opacity = 50; - positions[1].note_opacity_top = Some(11); - positions[1].note_opacity_bottom = Some(22); - positions[2].note_glow_color = None; - positions[2].note_glow_opacity = 71; - positions[2].note_glow_opacity_top = Some(72); - positions[2].note_glow_opacity_bottom = Some(73); - positions[3].note_border_color = None; - positions[3].note_border_opacity = 100; - positions[3].note_border_gradient = serde_json::from_value(serde_json::json!({ - "angle": 45, - "stops": [ - { "color": "#010203", "pos": 0 }, - { "color": "#040506", "pos": 1 } - ] - })) - .unwrap(); - let full = &mut store.key_positions.get_mut("5key").unwrap()[0]; - full.note_glow_opacity = 65; - full.note_glow_opacity_top = None; - full.note_glow_opacity_bottom = None; - full.note_glow_color = Some(NoteColor::Solid("glow-sibling".to_string())); - let original = store.clone(); - - let note_color = EditorElementPropertyPatchV1::NotePaint(EditorNotePaintIntentV1::Color( - crate::models::EditorNotePaintColorIntentV1 { - color: EditorNoteColorV1::Gradient(crate::models::EditorNoteGradientColorV1 { - kind: crate::models::EditorNoteGradientColorKindV1::Gradient, - top: "top".to_string(), - bottom: "bottom".to_string(), - }), - }, - )); - let note_opacity = - EditorElementPropertyPatchV1::NotePaint(EditorNotePaintIntentV1::Opacity( - crate::models::EditorNotePaintOpacityIntentV1 { opacity: 60 }, - )); - let glow_color = EditorElementPropertyPatchV1::NoteGlowPaint( - EditorNotePaintIntentV1::Color(crate::models::EditorNotePaintColorIntentV1 { - color: EditorNoteColorV1::Solid(String::new()), - }), - ); - let border = - EditorElementPropertyPatchV1::NoteBorderPaint(crate::models::EditorNoteBorderPaintV1 { - color: "#FFFFFF".to_string(), - opacity: 100, - gradient: None, - }); - let glow_tuple = - EditorElementPropertyPatchV1::NoteGlowPaint(EditorNotePaintIntentV1::GradientOpacity( - crate::models::EditorNotePaintGradientOpacityIntentV1 { - opacity: 65, - opacity_top: 10, - opacity_bottom: 90, - }, - )); - let ops = vec![ - patch_property_op(EditorElementTypeV1::Key, &ids[0], note_color.clone()), - patch_property_op(EditorElementTypeV1::Key, &ids[1], note_opacity.clone()), - patch_property_op(EditorElementTypeV1::Key, &ids[2], glow_color.clone()), - patch_property_op(EditorElementTypeV1::Key, &ids[3], border.clone()), - patch_property_op(EditorElementTypeV1::Key, &ids[4], glow_tuple.clone()), - patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - note_color.clone(), - ), - ]; - - let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); - assert_eq!(transition.changed_fields, [EditorField::KeyPositions]); - assert_eq!( - transition - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - let positions = &transition.candidate.key_positions["4key"]; - assert_eq!( - positions[0].note_color, - NoteColor::Gradient { - top: "top".to_string(), - bottom: "bottom".to_string(), - } - ); - assert_eq!(positions[0].note_opacity, 41); - assert_eq!(positions[0].note_opacity_top, Some(42)); - assert_eq!(positions[0].note_opacity_bottom, Some(43)); - assert_eq!(positions[1].note_opacity, 60); - assert_eq!(positions[1].note_opacity_top, Some(11)); - assert_eq!(positions[1].note_opacity_bottom, Some(22)); - assert_eq!( - positions[2].note_glow_color, - Some(NoteColor::Solid(String::new())) - ); - assert_eq!(positions[2].note_glow_opacity, 71); - assert_eq!(positions[2].note_glow_opacity_top, Some(72)); - assert_eq!(positions[2].note_glow_opacity_bottom, Some(73)); - assert_eq!(positions[3].note_border_color.as_deref(), Some("#FFFFFF")); - assert_eq!(positions[3].note_border_opacity, 100); - assert!(positions[3].note_border_gradient.is_none()); - let full = &transition.candidate.key_positions["5key"][0]; - assert_eq!(full.note_glow_opacity, 65); - assert_eq!(full.note_glow_opacity_top, Some(10)); - assert_eq!(full.note_glow_opacity_bottom, Some(90)); - assert_eq!( - full.note_glow_color, - Some(NoteColor::Solid("glow-sibling".to_string())) - ); - - let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); - assert!(replay.changed_fields.is_empty()); - assert_eq!( - replay - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - for (invalid, expected_code) in [ - ( - EditorElementPropertyPatchV1::NotePaint(EditorNotePaintIntentV1::Opacity( - crate::models::EditorNotePaintOpacityIntentV1 { opacity: 101 }, - )), - "NOTE_OPACITY_OUT_OF_RANGE", - ), - ( - EditorElementPropertyPatchV1::NoteBorderPaint( - crate::models::EditorNoteBorderPaintV1 { - color: "rgba(1,2,3,1)".to_string(), - opacity: 100, - gradient: None, - }, - ), - "INVALID_NOTE_BORDER_COLOR", - ), - ] { - let error = prepare_editor_ops_transition( - &store, - &[ - ops[0].clone(), - patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - invalid, - ), - ], - ) - .unwrap_err(); - assert_eq!(store, original); - assert_eq!( - error.error_code, - crate::errors::EditorCommitErrorCode::ValidationFailed - ); - assert_eq!(validation_code(&error), Some(expected_code)); - } - for element_type in [ - EditorElementTypeV1::Stat, - EditorElementTypeV1::Graph, - EditorElementTypeV1::Knob, - ] { - let error = prepare_editor_ops_transition( - &store, - &[ - ops[0].clone(), - patch_property_op( - element_type, - uuid::Uuid::new_v4().to_string(), - glow_color.clone(), - ), - ], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); - assert_eq!(store, original); - } - } - - #[test] - fn note_border_gradient_patch_is_atomic_canonical_and_strict() { - let mut store = base_store(); - let id = store.key_positions["4key"][0].id.clone(); - let position = &mut store.key_positions.get_mut("4key").unwrap()[0]; - position.note_border_color = Some("#000000".to_string()); - position.note_border_opacity = 25; - position.note_border_side = Some("vertical".to_string()); - let original = store.clone(); - - let gradient = crate::models::EditorPaintGradientV1 { - angle: 135.0, - stops: vec![ - crate::models::EditorPaintGradientStopV1 { - color: "rgba(17, 34, 51, .5)".to_string(), - pos: 0.0, - }, - crate::models::EditorPaintGradientStopV1 { - color: "#ABC8".to_string(), - pos: 1.0, - }, - ], - }; - let patch = - EditorElementPropertyPatchV1::NoteBorderPaint(crate::models::EditorNoteBorderPaintV1 { - color: "#112233".to_string(), - opacity: 73, - gradient: Some(gradient.clone()), - }); - let ops = [patch_property_op( - EditorElementTypeV1::Key, - &id, - patch.clone(), - )]; - - let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); - assert_eq!(transition.changed_fields, [EditorField::KeyPositions]); - assert_eq!( - transition.op_results[0].status, - EditorOpResultStatusV1::Applied - ); - let position = &transition.candidate.key_positions["4key"][0]; - assert_eq!(position.note_border_color.as_deref(), Some("#112233")); - assert_eq!(position.note_border_opacity, 73); - assert_eq!( - position.note_border_gradient, - Some(gradient.to_gradient_spec()) - ); - assert_eq!(position.note_border_side.as_deref(), Some("vertical")); - - let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); - assert!(replay.changed_fields.is_empty()); - assert_eq!( - replay.op_results[0].status, - EditorOpResultStatusV1::NoChange - ); - - let invalid_cases = [ - ( - "#AABBCC", - crate::models::EditorPaintGradientV1 { - angle: 90.0, - stops: vec![ - crate::models::EditorPaintGradientStopV1 { - color: "#112233".to_string(), - pos: 0.0, - }, - crate::models::EditorPaintGradientStopV1 { - color: "#445566".to_string(), - pos: 1.0, - }, - ], - }, - "PAINT_COLOR_GRADIENT_MISMATCH", - ), - ( - "#112233", - crate::models::EditorPaintGradientV1 { - angle: 90.0, - stops: vec![ - crate::models::EditorPaintGradientStopV1 { - color: "#112233".to_string(), - pos: 0.0, - }, - crate::models::EditorPaintGradientStopV1 { - color: "transparent".to_string(), - pos: 1.0, - }, - ], - }, - "INVALID_PAINT_GRADIENT", - ), - ( - "#112233", - crate::models::EditorPaintGradientV1 { - angle: -0.0, - stops: vec![ - crate::models::EditorPaintGradientStopV1 { - color: "#112233".to_string(), - pos: 0.0, - }, - crate::models::EditorPaintGradientStopV1 { - color: "#445566".to_string(), - pos: 1.0, - }, - ], - }, - "INVALID_PAINT_GRADIENT", - ), - ]; - for (color, gradient, code) in invalid_cases { - let error = prepare_editor_ops_transition( - &store, - &[patch_property_op( - EditorElementTypeV1::Key, - &id, - EditorElementPropertyPatchV1::NoteBorderPaint( - crate::models::EditorNoteBorderPaintV1 { - color: color.to_string(), - opacity: 73, - gradient: Some(gradient), - }, - ), - )], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some(code)); - assert_eq!(store, original); - } - } - - #[test] - fn note_numeric_patches_preserve_raw_options_bounds_and_siblings() { - let mut store = base_store(); - let ids = [ - store.key_positions["4key"][0].id.clone(), - store.key_positions["4key"][1].id.clone(), - store.key_positions["4key"][2].id.clone(), - store.key_positions["4key"][3].id.clone(), - store.key_positions["5key"][0].id.clone(), - ]; - for position in store.key_positions.values_mut().flatten() { - position.note_color = crate::models::NoteColor::Solid("note-sibling".to_string()); - position.note_glow_size = 17.5; - position.note_glow_opacity = 63; - } - store.key_positions.get_mut("4key").unwrap()[0].note_offset_x = Some(7.5); - store.key_positions.get_mut("4key").unwrap()[1].note_offset_y = None; - store.key_positions.get_mut("4key").unwrap()[2].note_width = Some(31.5); - store.key_positions.get_mut("4key").unwrap()[3].note_border_width = None; - store.key_positions.get_mut("5key").unwrap()[0].note_border_radius = None; - let original = store.clone(); - - let ops = vec![ - patch_property_op( - EditorElementTypeV1::Key, - &ids[0], - EditorElementPropertyPatchV1::NoteOffsetX(None), - ), - patch_property_op( - EditorElementTypeV1::Key, - &ids[1], - EditorElementPropertyPatchV1::NoteOffsetY(Some(-12.5)), - ), - patch_property_op( - EditorElementTypeV1::Key, - &ids[2], - EditorElementPropertyPatchV1::NoteWidth(None), - ), - patch_property_op( - EditorElementTypeV1::Key, - &ids[3], - EditorElementPropertyPatchV1::NoteBorderWidth(0.0), - ), - patch_property_op( - EditorElementTypeV1::Key, - &ids[4], - EditorElementPropertyPatchV1::NoteBorderRadius(4.0), - ), - patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - EditorElementPropertyPatchV1::NoteWidth(Some(20.0)), - ), - ]; - - let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); - assert_eq!(transition.changed_fields, [EditorField::KeyPositions]); - assert_eq!( - transition - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - let mut expected = original.clone(); - expected.key_positions.get_mut("4key").unwrap()[0].note_offset_x = None; - expected.key_positions.get_mut("4key").unwrap()[1].note_offset_y = Some(-12.5); - expected.key_positions.get_mut("4key").unwrap()[2].note_width = None; - expected.key_positions.get_mut("4key").unwrap()[3].note_border_width = Some(0.0); - expected.key_positions.get_mut("5key").unwrap()[0].note_border_radius = Some(4.0); - assert_eq!(transition.scratch, expected); - - let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); - assert!(replay.changed_fields.is_empty()); - assert_eq!( - replay - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - let offset_zero = prepare_editor_ops_transition( - &store, - &[patch_property_op( - EditorElementTypeV1::Key, - &ids[1], - EditorElementPropertyPatchV1::NoteOffsetY(Some(0.0)), - )], - ) - .unwrap(); - assert_eq!( - offset_zero.op_results[0].status, - EditorOpResultStatusV1::Applied - ); - assert_eq!( - offset_zero.candidate.key_positions["4key"][1].note_offset_y, - Some(0.0) - ); - - let boundary_ops = vec![ - patch_property_op( - EditorElementTypeV1::Key, - &ids[0], - EditorElementPropertyPatchV1::NoteOffsetX(Some(-500.0)), - ), - patch_property_op( - EditorElementTypeV1::Key, - &ids[1], - EditorElementPropertyPatchV1::NoteOffsetY(Some(500.0)), - ), - patch_property_op( - EditorElementTypeV1::Key, - &ids[2], - EditorElementPropertyPatchV1::NoteWidth(Some(f64::MIN_POSITIVE)), - ), - patch_property_op( - EditorElementTypeV1::Key, - &ids[3], - EditorElementPropertyPatchV1::NoteBorderWidth(20.0), - ), - patch_property_op( - EditorElementTypeV1::Key, - &ids[4], - EditorElementPropertyPatchV1::NoteBorderRadius(100.0), - ), - ]; - prepare_editor_ops_transition(&store, &boundary_ops).unwrap(); - let lower_radius = prepare_editor_ops_transition( - &store, - &[patch_property_op( - EditorElementTypeV1::Key, - &ids[4], - EditorElementPropertyPatchV1::NoteBorderRadius(1.0), - )], - ) - .unwrap(); - assert_eq!( - lower_radius.candidate.key_positions["5key"][0].note_border_radius, - Some(1.0) - ); - - let invalid_patches = [ - ( - EditorElementPropertyPatchV1::NoteOffsetX(Some(-500.1)), - "NOTE_OFFSET_X_OUT_OF_RANGE", - ), - ( - EditorElementPropertyPatchV1::NoteOffsetY(Some(500.1)), - "NOTE_OFFSET_Y_OUT_OF_RANGE", - ), - ( - EditorElementPropertyPatchV1::NoteOffsetY(Some(f64::INFINITY)), - "NOTE_OFFSET_Y_OUT_OF_RANGE", - ), - ( - EditorElementPropertyPatchV1::NoteWidth(Some(0.0)), - "NOTE_WIDTH_OUT_OF_RANGE", - ), - ( - EditorElementPropertyPatchV1::NoteWidth(Some(f64::NAN)), - "NOTE_WIDTH_OUT_OF_RANGE", - ), - ( - EditorElementPropertyPatchV1::NoteBorderWidth(-0.1), - "NOTE_BORDER_WIDTH_OUT_OF_RANGE", - ), - ( - EditorElementPropertyPatchV1::NoteBorderWidth(20.1), - "NOTE_BORDER_WIDTH_OUT_OF_RANGE", - ), - ( - EditorElementPropertyPatchV1::NoteBorderRadius(-0.1), - "NOTE_BORDER_RADIUS_OUT_OF_RANGE", - ), - ( - EditorElementPropertyPatchV1::NoteBorderRadius(100.1), - "NOTE_BORDER_RADIUS_OUT_OF_RANGE", - ), - ]; - for (patch, code) in invalid_patches { - let error = prepare_editor_ops_transition( - &store, - &[ - ops[0].clone(), - patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - patch, - ), - ], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some(code)); - assert_eq!(store, original); - } - - for element_type in [ - EditorElementTypeV1::Stat, - EditorElementTypeV1::Graph, - EditorElementTypeV1::Knob, - ] { - let error = prepare_editor_ops_transition( - &store, - &[ - ops[0].clone(), - patch_property_op( - element_type, - uuid::Uuid::new_v4().to_string(), - EditorElementPropertyPatchV1::NoteWidth(Some(20.0)), - ), - ], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); - assert_eq!(store, original); - } - } - - #[test] - fn paint_descriptors_apply_pairs_preserve_active_fallbacks_and_reject_invalid_plans() { - let mut store = store_with_every_reorder_type(); - let key_id = store.key_positions["4key"][0].id.clone(); - let stat_id = store.stat_positions["4key"][0].position.id.clone(); - let graph_id = store.graph_positions["4key"][0].position.id.clone(); - let knob_id = store.knob_positions["4key"][0].position.id.clone(); - - let key = &mut store.key_positions.get_mut("4key").unwrap()[0]; - key.background_color = Some(" key idle ".to_string()); - key.background_gradient = None; - key.active_background_color = None; - key.active_background_gradient = None; - key.border_color = Some("key-border-sibling".to_string()); - key.font_color = Some("key-font-sibling".to_string()); - - let stat = &mut store.stat_positions.get_mut("4key").unwrap()[0].position; - stat.border_color = Some("stat-old".to_string()); - stat.border_gradient = None; - stat.active_border_color = Some("stat-active-sibling".to_string()); - - let graph = &mut store.graph_positions.get_mut("4key").unwrap()[0].position; - graph.background_color = None; - graph.background_gradient = None; - graph.active_background_color = Some("graph-active-sibling".to_string()); - - let knob = &mut store.knob_positions.get_mut("4key").unwrap()[0].position; - knob.border_color = Some("stale-knob-base".to_string()); - knob.border_gradient = Some(GradientSpec::from_canonical_parts( - 90.0, - vec![ - crate::models::GradientStop { - color: "knob-old".to_string(), - pos: 0.0, - }, - crate::models::GradientStop { - color: "knob-end".to_string(), - pos: 1.0, - }, - ], - )); - knob.active_border_color = Some(" ".to_string()); - knob.active_border_gradient = None; - knob.background_color = Some("knob-background-sibling".to_string()); - - let original = store.clone(); - let gradient_stops = [("new-key", 0.0), ("new-end", 0.5), ("tail", 1.0)]; - let ops = vec![ - patch_property_op( - EditorElementTypeV1::Key, - &key_id, - EditorElementPropertyPatchV1::BackgroundPaint(paint_descriptor( - "new-key", - Some((45.0, &gradient_stops)), - )), - ), - patch_property_op( - EditorElementTypeV1::Stat, - &stat_id, - EditorElementPropertyPatchV1::BorderPaint(paint_descriptor("stat-new", None)), - ), - patch_property_op( - EditorElementTypeV1::Graph, - &graph_id, - EditorElementPropertyPatchV1::BackgroundPaint(paint_descriptor("", None)), - ), - patch_property_op( - EditorElementTypeV1::Knob, - &knob_id, - EditorElementPropertyPatchV1::BorderPaint(paint_descriptor("knob-new", None)), - ), - patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - EditorElementPropertyPatchV1::ActiveBackgroundPaint(paint_descriptor( - "missing", None, - )), - ), - ]; - - let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); - assert_eq!( - transition.changed_fields, - [ - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - ] - ); - assert_eq!( - transition - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - let key = &transition.candidate.key_positions["4key"][0]; - assert_eq!(key.background_color.as_deref(), Some("new-key")); - assert_eq!(key.background_gradient.as_ref().unwrap().angle, 45.0); - assert_eq!(key.active_background_color.as_deref(), Some(" key idle ")); - assert!(key.active_background_gradient.is_none()); - assert_eq!( - key.border_color, - original.key_positions["4key"][0].border_color - ); - assert_eq!(key.font_color, original.key_positions["4key"][0].font_color); - - let stat = &transition.candidate.stat_positions["4key"][0].position; - assert_eq!(stat.border_color.as_deref(), Some("stat-new")); - assert!(stat.border_gradient.is_none()); - assert_eq!( - stat.active_border_color.as_deref(), - Some("stat-active-sibling") - ); - - let graph = &transition.candidate.graph_positions["4key"][0].position; - assert_eq!(graph.background_color.as_deref(), Some("")); - assert_eq!( - graph.active_background_color.as_deref(), - Some("graph-active-sibling") - ); - - let knob = &transition.candidate.knob_positions["4key"][0].position; - assert_eq!(knob.border_color.as_deref(), Some("knob-new")); - assert!(knob.border_gradient.is_none()); - assert_eq!(knob.active_border_color.as_deref(), Some("stale-knob-base")); - assert_eq!( - knob.active_border_gradient, - original.knob_positions["4key"][0].position.border_gradient - ); - assert_eq!( - knob.background_color.as_deref(), - Some("knob-background-sibling") - ); - - let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); - assert!(replay.changed_fields.is_empty()); - assert_eq!( - replay - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - let fallback_only_op = patch_property_op( - EditorElementTypeV1::Key, - &key_id, - EditorElementPropertyPatchV1::BackgroundPaint(paint_descriptor(" key idle ", None)), - ); - let fallback_only = - prepare_editor_ops_transition(&store, std::slice::from_ref(&fallback_only_op)).unwrap(); - assert_eq!( - fallback_only.op_results[0].status, - EditorOpResultStatusV1::Applied - ); - assert_eq!(fallback_only.changed_fields, [EditorField::KeyPositions]); - assert_eq!( - fallback_only.candidate.key_positions["4key"][0] - .background_color - .as_deref(), - Some(" key idle ") - ); - assert_eq!( - fallback_only.candidate.key_positions["4key"][0] - .active_background_color - .as_deref(), - Some(" key idle ") - ); - let fallback_replay = prepare_editor_ops_transition( - &fallback_only.scratch, - std::slice::from_ref(&fallback_only_op), - ) - .unwrap(); - assert_eq!( - fallback_replay.op_results[0].status, - EditorOpResultStatusV1::NoChange - ); - assert!(fallback_replay.changed_fields.is_empty()); - - let key_active_id = store.key_positions["4key"][1].id.clone(); - let active_ops = [ - patch_property_op( - EditorElementTypeV1::Key, - key_active_id, - EditorElementPropertyPatchV1::ActiveBackgroundPaint(paint_descriptor( - "active-key", - None, - )), - ), - patch_property_op( - EditorElementTypeV1::Knob, - &knob_id, - EditorElementPropertyPatchV1::ActiveBorderPaint(paint_descriptor( - "active-knob", - None, - )), - ), - ]; - let active = prepare_editor_ops_transition(&transition.scratch, &active_ops).unwrap(); - assert_eq!( - active.candidate.key_positions["4key"][1] - .active_background_color - .as_deref(), - Some("active-key") - ); - assert_eq!( - active.candidate.knob_positions["4key"][0] - .position - .active_border_color - .as_deref(), - Some("active-knob") - ); - - let mut explicit_active_store = store.clone(); - explicit_active_store.key_positions.get_mut("4key").unwrap()[1].active_background_color = - Some("explicit-active".to_string()); - explicit_active_store.key_positions.get_mut("4key").unwrap()[1] - .active_background_gradient = None; - explicit_active_store.key_positions.get_mut("4key").unwrap()[1].background_color = - Some("old-idle".to_string()); - let explicit_active = prepare_editor_ops_transition( - &explicit_active_store, - &[patch_property_op( - EditorElementTypeV1::Key, - &explicit_active_store.key_positions["4key"][1].id, - EditorElementPropertyPatchV1::BackgroundPaint(paint_descriptor("new-idle", None)), - )], - ) - .unwrap(); - assert_eq!( - explicit_active.candidate.key_positions["4key"][1] - .active_background_color - .as_deref(), - Some("explicit-active") - ); - - for (descriptor, code) in [ - ( - paint_descriptor("a", Some((360.0, &[("a", 0.0), ("b", 1.0)]))), - "INVALID_PAINT_GRADIENT", - ), - ( - paint_descriptor("a", Some((-0.0, &[("a", 0.0), ("b", 1.0)]))), - "INVALID_PAINT_GRADIENT", - ), - ( - paint_descriptor("a", Some((90.0, &[("a", 0.5), ("b", 0.25)]))), - "INVALID_PAINT_GRADIENT", - ), - ( - paint_descriptor("wrong", Some((90.0, &[("a", 0.0), ("b", 1.0)]))), - "PAINT_COLOR_GRADIENT_MISMATCH", - ), - ( - paint_descriptor("a", Some((90.0, &[("a", 0.0)]))), - "INVALID_PAINT_GRADIENT", - ), - ( - paint_descriptor( - "a", - Some(( - 90.0, - &[ - ("a", 0.0), - ("b", 0.1), - ("c", 0.2), - ("d", 0.3), - ("e", 0.4), - ("f", 0.5), - ("g", 0.6), - ("h", 0.7), - ("i", 1.0), - ], - )), - ), - "INVALID_PAINT_GRADIENT", - ), - ( - paint_descriptor("a", Some((90.0, &[("a", -0.0), ("b", 1.0)]))), - "INVALID_PAINT_GRADIENT", - ), - ( - paint_descriptor("a", Some((90.0, &[("a", 0.0), ("b", 1.1)]))), - "INVALID_PAINT_GRADIENT", - ), - ] { - let error = prepare_editor_ops_transition( - &store, - &[ - ops[0].clone(), - patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - EditorElementPropertyPatchV1::BackgroundPaint(descriptor), - ), - ], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some(code)); - assert_eq!(store, original); - } - - let canonical_boundaries = prepare_editor_ops_transition( - &store, - &[patch_property_op( - EditorElementTypeV1::Key, - &store.key_positions["4key"][2].id, - EditorElementPropertyPatchV1::BorderPaint(paint_descriptor( - "a", - Some((0.0, &[("a", 0.0), ("equal", 0.0), ("b", 1.0)])), - )), - )], - ) - .unwrap(); - assert_eq!( - canonical_boundaries.candidate.key_positions["4key"][2] - .border_gradient - .as_ref() - .unwrap() - .stops - .len(), - 3 - ); - - for element_type in [EditorElementTypeV1::Stat, EditorElementTypeV1::Graph] { - let error = prepare_editor_ops_transition( - &store, - &[ - ops[0].clone(), - patch_property_op( - element_type, - uuid::Uuid::new_v4().to_string(), - EditorElementPropertyPatchV1::ActiveBorderPaint(paint_descriptor( - "active", None, - )), - ), - ], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); - assert_eq!(store, original); - } - } - - #[test] - fn blank_font_paint_normalizes_to_none_and_blank_gradient_stops_are_rejected() { - let mut store = store_with_every_reorder_type(); - let key_id = store.key_positions["4key"][0].id.clone(); - { - let key = &mut store.key_positions.get_mut("4key").unwrap()[0]; - key.font_color = Some("stale-idle".to_string()); - key.font_gradient = None; - key.active_font_color = Some("stale-active".to_string()); - key.active_font_gradient = None; - } - let original = store.clone(); - - // 빈 solid는 커밋 시 None - 로드 정규화(normalize_blank_font_colors)와 수렴 - let ops = vec![ - patch_property_op( - EditorElementTypeV1::Key, - &key_id, - EditorElementPropertyPatchV1::FontPaint(paint_descriptor(" ", None)), - ), - patch_property_op( - EditorElementTypeV1::Key, - &key_id, - EditorElementPropertyPatchV1::ActiveFontPaint(paint_descriptor("", None)), - ), - ]; - let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); - assert_eq!(transition.changed_fields, [EditorField::KeyPositions]); - let position = &transition.candidate.key_positions["4key"][0]; - assert!(position.font_color.is_none()); - assert!(position.font_gradient.is_none()); - assert!(position.active_font_color.is_none()); - assert!(position.active_font_gradient.is_none()); - - // 빈 stop 색 gradient는 커밋 거부 - 로드 시 대표색 진동 차단 - let error = prepare_editor_ops_transition( - &store, - &[patch_property_op( - EditorElementTypeV1::Key, - &key_id, - EditorElementPropertyPatchV1::FontPaint(paint_descriptor( - " ", - Some((45.0, &[(" ", 0.0), ("#445566", 1.0)])), - )), - )], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some("INVALID_PAINT_GRADIENT")); - assert_eq!(store, original); - } - - #[test] - fn font_paint_patches_preserve_state_pairs_and_enforce_target_types() { - let mut store = store_with_every_reorder_type(); - let key_ids = store.key_positions["4key"] - .iter() - .map(|position| position.id.clone()) - .collect::>(); - let stat_id = store.stat_positions["4key"][0].position.id.clone(); - let graph_id = store.graph_positions["4key"][0].position.id.clone(); - let knob_id = store.knob_positions["4key"][0].position.id.clone(); - - let idle_gradient = paint_descriptor( - "idle-first", - Some((30.0, &[("idle-first", 0.0), ("idle-last", 1.0)])), - ) - .gradient - .unwrap() - .to_gradient_spec(); - let active_gradient = paint_descriptor( - "active-first", - Some((75.0, &[("active-first", 0.0), ("active-last", 1.0)])), - ) - .gradient - .unwrap() - .to_gradient_spec(); - let keys = store.key_positions.get_mut("4key").unwrap(); - keys[0].font_color = None; - keys[0].font_gradient = Some(idle_gradient.clone()); - keys[0].active_font_color = None; - keys[0].active_font_gradient = None; - keys[1].font_color = Some("idle-first".to_string()); - keys[1].font_gradient = Some(idle_gradient.clone()); - keys[1].active_font_color = Some("active-solid".to_string()); - keys[1].active_font_gradient = None; - keys[2].font_color = Some("idle-solid".to_string()); - keys[2].font_gradient = None; - keys[2].active_font_color = None; - keys[2].active_font_gradient = Some(active_gradient.clone()); - keys[3].font_color = Some("same-idle".to_string()); - keys[3].font_gradient = None; - keys[3].active_font_color = None; - keys[3].active_font_gradient = None; - - let original = store.clone(); - let ops = vec![ - patch_property_op( - EditorElementTypeV1::Key, - &key_ids[0], - EditorElementPropertyPatchV1::FontPaint(paint_descriptor("next-zero", None)), - ), - patch_property_op( - EditorElementTypeV1::Key, - &key_ids[1], - EditorElementPropertyPatchV1::FontPaint(paint_descriptor("next-one", None)), - ), - patch_property_op( - EditorElementTypeV1::Key, - &key_ids[2], - EditorElementPropertyPatchV1::FontPaint(paint_descriptor("next-two", None)), - ), - patch_property_op( - EditorElementTypeV1::Key, - &key_ids[3], - EditorElementPropertyPatchV1::FontPaint(paint_descriptor("same-idle", None)), - ), - patch_property_op( - EditorElementTypeV1::Stat, - &stat_id, - EditorElementPropertyPatchV1::FontPaint(paint_descriptor( - "stat-first", - Some((90.0, &[("stat-first", 0.0), ("stat-last", 1.0)])), - )), - ), - ]; - - let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); - assert_eq!( - transition.changed_fields, - [EditorField::KeyPositions, EditorField::StatPositions] - ); - assert_eq!( - transition - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - ] - ); - - let keys = &transition.candidate.key_positions["4key"]; - assert_eq!(keys[0].active_font_color, None); - assert_eq!(keys[0].active_font_gradient, Some(idle_gradient.clone())); - assert_eq!(keys[1].active_font_color.as_deref(), Some("active-solid")); - assert_eq!(keys[1].active_font_gradient, None); - assert_eq!(keys[2].active_font_color, None); - assert_eq!(keys[2].active_font_gradient, Some(active_gradient)); - assert_eq!(keys[3].active_font_color.as_deref(), Some("same-idle")); - assert_eq!(keys[3].active_font_gradient, None); - assert_eq!( - transition.candidate.stat_positions["4key"][0] - .position - .font_color - .as_deref(), - Some("stat-first") - ); - assert!(transition.candidate.stat_positions["4key"][0] - .position - .active_font_color - .is_none()); - - let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); - assert!(replay.changed_fields.is_empty()); - assert_eq!( - replay - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - ] - ); - - let active_store = store.clone(); - let active = prepare_editor_ops_transition( - &active_store, - &[patch_property_op( - EditorElementTypeV1::Key, - &key_ids[0], - EditorElementPropertyPatchV1::ActiveFontPaint(paint_descriptor( - "active-direct", - Some((15.0, &[("active-direct", 0.0), ("active-end", 1.0)])), - )), - )], - ) - .unwrap(); - assert_eq!( - active.candidate.key_positions["4key"][0] - .active_font_color - .as_deref(), - Some("active-direct") - ); - assert!(active.candidate.key_positions["4key"][0] - .active_font_gradient - .is_some()); - - for (element_type, id) in [ - (EditorElementTypeV1::Stat, &stat_id), - (EditorElementTypeV1::Graph, &graph_id), - (EditorElementTypeV1::Knob, &knob_id), - ] { - let error = prepare_editor_ops_transition( - &store, - &[ - ops[0].clone(), - patch_property_op( - element_type, - id, - EditorElementPropertyPatchV1::ActiveFontPaint(paint_descriptor( - "wrong-type", - None, - )), - ), - ], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); - assert_eq!(store, original); - } - - for (element_type, id) in [ - (EditorElementTypeV1::Graph, &graph_id), - (EditorElementTypeV1::Knob, &knob_id), - ] { - let error = prepare_editor_ops_transition( - &store, - &[patch_property_op( - element_type, - id, - EditorElementPropertyPatchV1::FontPaint(paint_descriptor("wrong-type", None)), - )], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); - } - - for patch in [ - EditorElementPropertyPatchV1::FontPaint(paint_descriptor( - "mismatch", - Some((45.0, &[("first", 0.0), ("last", 1.0)])), - )), - EditorElementPropertyPatchV1::ActiveFontPaint(paint_descriptor( - "mismatch", - Some((45.0, &[("first", 0.0), ("last", 1.0)])), - )), - ] { - let error = prepare_editor_ops_transition( - &store, - &[patch_property_op( - EditorElementTypeV1::Key, - &key_ids[0], - patch, - )], - ) - .unwrap_err(); - assert_eq!( - validation_code(&error), - Some("PAINT_COLOR_GRADIENT_MISMATCH") - ); - } - } - - #[test] - fn shadow_masks_seed_current_defaults_preserve_siblings_and_reject_invalid_plans() { - let mut store = store_with_every_reorder_type(); - let key_id = store.key_positions["4key"][0].id.clone(); - let stat_id = store.stat_positions["4key"][0].position.id.clone(); - let knob_id = store.knob_positions["4key"][0].position.id.clone(); - - let key = &mut store.key_positions.get_mut("4key").unwrap()[0]; - key.inactive_image = Some(" image.png ".to_string()); - key.shadow = None; - key.active_shadow = Some(ElementShadowSpec { - enabled: true, - color: "key-active-sibling".to_string(), - offset_x: 7.5, - offset_y: -8.5, - blur: 19.5, - }); - let stat = &mut store.stat_positions.get_mut("4key").unwrap()[0].position; - stat.shadow = Some(ElementShadowSpec { - enabled: false, - color: "stat-color-sibling".to_string(), - offset_x: 1.5, - offset_y: 2.5, - blur: 3.5, - }); - stat.active_shadow = Some(ElementShadowSpec { - enabled: true, - color: "stat-active-sentinel".to_string(), - offset_x: 10.0, - offset_y: 11.0, - blur: 12.0, - }); - let knob = &mut store.knob_positions.get_mut("4key").unwrap()[0].position; - knob.shadow = None; - knob.active_shadow = None; - knob.idle_transparent = true; - knob.active_transparent = false; - knob.border_width = Some(2.0); - knob.inactive_image = None; - knob.active_image = Some("active.png".to_string()); - let original = store.clone(); - - let ops = vec![ - patch_property_op( - EditorElementTypeV1::Key, - &key_id, - EditorElementPropertyPatchV1::Shadow(shadow_leaf_color("new-shadow")), - ), - patch_property_op( - EditorElementTypeV1::Stat, - &stat_id, - EditorElementPropertyPatchV1::Shadow(shadow_leaf_offset_x(-100.0)), - ), - patch_property_op( - EditorElementTypeV1::Knob, - &knob_id, - EditorElementPropertyPatchV1::ShadowEnabled(true), - ), - patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - EditorElementPropertyPatchV1::ActiveShadow(shadow_leaf_blur(100.0)), - ), - ]; - let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); - assert_eq!( - transition.changed_fields, - [ - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::KnobPositions, - ] - ); - assert_eq!( - transition - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::Applied, - EditorOpResultStatusV1::TargetMissing, - ] - ); - let key = transition.candidate.key_positions["4key"][0] - .shadow - .as_ref() - .unwrap(); - assert!(!key.enabled); - assert_eq!(key.color, "new-shadow"); - assert_eq!((key.offset_x, key.offset_y, key.blur), (0.0, 4.0, 10.0)); - assert_eq!( - transition.candidate.key_positions["4key"][0].active_shadow, - original.key_positions["4key"][0].active_shadow - ); - let stat = transition.candidate.stat_positions["4key"][0] - .position - .shadow - .as_ref() - .unwrap(); - assert_eq!(stat.offset_x, -100.0); - assert!(!stat.enabled); - assert_eq!(stat.color, "stat-color-sibling"); - assert_eq!((stat.offset_y, stat.blur), (2.5, 3.5)); - assert_eq!( - transition.candidate.stat_positions["4key"][0] - .position - .active_shadow, - original.stat_positions["4key"][0].position.active_shadow - ); - let knob = &transition.candidate.knob_positions["4key"][0].position; - let idle = knob.shadow.as_ref().unwrap(); - let active = knob.active_shadow.as_ref().unwrap(); - assert!(idle.enabled && active.enabled); - assert_eq!(idle.color, "rgba(0, 0, 0, 0.28)"); - assert_eq!((idle.offset_x, idle.offset_y, idle.blur), (0.0, 4.0, 10.0)); - assert_eq!(active.color, "rgba(0, 0, 0, 0.32)"); - assert_eq!( - (active.offset_x, active.offset_y, active.blur), - (0.0, 3.0, 8.0) - ); - - let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); - assert!(replay.changed_fields.is_empty()); - assert_eq!( - replay - .op_results - .iter() - .map(|result| result.status) - .collect::>(), - [ - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::NoChange, - EditorOpResultStatusV1::TargetMissing, - ] - ); - - let mut stat_master_store = store.clone(); - stat_master_store.stat_positions.get_mut("4key").unwrap()[0] - .position - .shadow = None; - let stat_active_before = stat_master_store.stat_positions["4key"][0] - .position - .active_shadow - .clone(); - let stat_master = prepare_editor_ops_transition( - &stat_master_store, - &[patch_property_op( - EditorElementTypeV1::Stat, - &stat_id, - EditorElementPropertyPatchV1::ShadowEnabled(false), - )], - ) - .unwrap(); - assert!( - !stat_master.candidate.stat_positions["4key"][0] - .position - .shadow - .as_ref() - .unwrap() - .enabled - ); - assert_eq!( - stat_master.candidate.stat_positions["4key"][0] - .position - .active_shadow, - stat_active_before - ); - - for (element_type, active, configure) in [ - (EditorElementTypeV1::Key, false, "idle-image"), - (EditorElementTypeV1::Key, true, "active-image"), - (EditorElementTypeV1::Knob, false, "idle-transparent"), - (EditorElementTypeV1::Knob, true, "active-transparent"), - (EditorElementTypeV1::Knob, false, "idle-border"), - (EditorElementTypeV1::Knob, true, "active-border"), - ] { - let mut seeded_store = store_with_every_reorder_type(); - let (id, position) = match element_type { - EditorElementTypeV1::Key => { - let position = &mut seeded_store.key_positions.get_mut("4key").unwrap()[0]; - (position.id.clone(), position) - } - EditorElementTypeV1::Knob => { - let position = - &mut seeded_store.knob_positions.get_mut("4key").unwrap()[0].position; - (position.id.clone(), position) - } - _ => unreachable!(), - }; - position.shadow = None; - position.active_shadow = None; - position.inactive_image = None; - position.active_image = None; - position.idle_transparent = false; - position.active_transparent = false; - position.border_width = None; - match configure { - "idle-image" => position.inactive_image = Some("idle.png".to_string()), - "active-image" => position.active_image = Some("active.png".to_string()), - "idle-transparent" => position.idle_transparent = true, - "active-transparent" => position.active_transparent = true, - "idle-border" | "active-border" => position.border_width = Some(1.0), - _ => unreachable!(), - } - let patch = if active { - EditorElementPropertyPatchV1::ActiveShadow(shadow_leaf_blur(8.0)) - } else { - EditorElementPropertyPatchV1::Shadow(shadow_leaf_blur(10.0)) - }; - let op = patch_property_op(element_type, id, patch); - let seeded = - prepare_editor_ops_transition(&seeded_store, std::slice::from_ref(&op)).unwrap(); - assert_eq!( - seeded.op_results[0].status, - EditorOpResultStatusV1::Applied, - "{configure}" - ); - let position = match element_type { - EditorElementTypeV1::Key => &seeded.candidate.key_positions["4key"][0], - EditorElementTypeV1::Knob => &seeded.candidate.knob_positions["4key"][0].position, - _ => unreachable!(), - }; - let shadow = if active { - position.active_shadow.as_ref().unwrap() - } else { - position.shadow.as_ref().unwrap() - }; - assert!(!shadow.enabled, "{configure}"); - let replay = prepare_editor_ops_transition(&seeded.scratch, &[op]).unwrap(); - assert_eq!( - replay.op_results[0].status, - EditorOpResultStatusV1::NoChange, - "{configure}" - ); - } - - for (patch, code) in [ - (shadow_leaf_color(""), "INVALID_ELEMENT_SHADOW"), - (shadow_leaf_offset_x(100.1), "INVALID_ELEMENT_SHADOW"), - (shadow_leaf_offset_y(f64::NAN), "INVALID_ELEMENT_SHADOW"), - (shadow_leaf_blur(-0.1), "INVALID_ELEMENT_SHADOW"), - ] { - let error = prepare_editor_ops_transition( - &store, - &[ - ops[0].clone(), - patch_property_op( - EditorElementTypeV1::Key, - uuid::Uuid::new_v4().to_string(), - EditorElementPropertyPatchV1::Shadow(patch), - ), - ], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some(code)); - assert_eq!(store, original); - } - - for (element_type, patch) in [ - ( - EditorElementTypeV1::Graph, - EditorElementPropertyPatchV1::Shadow(shadow_leaf_blur(4.0)), - ), - ( - EditorElementTypeV1::Graph, - EditorElementPropertyPatchV1::ShadowEnabled(false), - ), - ( - EditorElementTypeV1::Stat, - EditorElementPropertyPatchV1::ActiveShadow(shadow_leaf_offset_y(1.0)), - ), - ] { - let error = prepare_editor_ops_transition( - &store, - &[ - ops[0].clone(), - patch_property_op(element_type, uuid::Uuid::new_v4().to_string(), patch), - ], - ) - .unwrap_err(); - assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); - assert_eq!(store, original); - } - } - - #[test] - fn set_key_slot_follows_position_id_and_preserves_the_pair() { - let mut store = base_store(); - store.keys.get_mut("4key").unwrap().swap(0, 1); - store.key_positions.get_mut("4key").unwrap().swap(0, 1); - let id = store.key_positions["4key"][1].id.clone(); - let op = EditorOpV1::SetKeySlot { - id, - slot: EditorFrozenKeySlotV1::Multi(crate::models::EditorFrozenMultiKeySlotV1 { - keys: vec!["A".to_string(), "B".to_string()], - match_mode: crate::models::SlotMatch::All, - }), - }; - - let transition = prepare_editor_ops_transition(&store, std::slice::from_ref(&op)).unwrap(); - assert_eq!( - transition.candidate.keys["4key"][1], - crate::models::KeySlot::Multi { - keys: vec!["A".to_string(), "B".to_string()], - match_mode: crate::models::SlotMatch::All, - } - ); - assert_eq!(transition.candidate.key_positions, store.key_positions); - assert_eq!(transition.changed_fields, [EditorField::Keys]); - assert_eq!( - transition.op_results[0].status, - EditorOpResultStatusV1::Applied - ); - - let replay = prepare_editor_ops_transition(&transition.scratch, &[op]).unwrap(); - assert_eq!( - replay.op_results[0].status, - EditorOpResultStatusV1::NoChange - ); - assert!(replay.changed_fields.is_empty()); - } - - #[test] - fn property_and_slot_type_mismatches_reject_the_whole_request() { - let store = store_with_every_reorder_type(); - let before = store.clone(); - let key_id = store.key_positions["4key"][0].id.clone(); - let stat_id = store.stat_positions["4key"][0].position.id.clone(); - - let property_error = prepare_editor_ops_transition( - &store, - &[ - patch_hidden_op(EditorElementTypeV1::Key, key_id, true), - patch_layer_name_op( - EditorElementTypeV1::Graph, - stat_id.clone(), - Some("Wrong type"), - ), - ], - ) - .unwrap_err(); - assert_eq!( - validation_code(&property_error), - Some("ELEMENT_TYPE_MISMATCH") - ); - - let slot_error = prepare_editor_ops_transition( - &store, - &[EditorOpV1::SetKeySlot { - id: stat_id, - slot: EditorFrozenKeySlotV1::Single("A".to_string()), - }], - ) - .unwrap_err(); - assert_eq!(validation_code(&slot_error), Some("ELEMENT_TYPE_MISMATCH")); - assert_eq!(store, before); - } -} +mod tests; diff --git a/src-tauri/src/state/editor_ops/tests.rs b/src-tauri/src/state/editor_ops/tests.rs new file mode 100644 index 000000000..e0e6397e3 --- /dev/null +++ b/src-tauri/src/state/editor_ops/tests.rs @@ -0,0 +1,7195 @@ + +use crate::{ + defaults::default_keys, + models::{ + AppStoreData, EditorElementGroupTargetV1, EditorElementPropertyPatchV1, + EditorFrozenElementV1, EditorFrozenGroupV1, EditorFrozenKeySlotV1, EditorGroupUpdateV1, + EditorOpResultStatusV1, EditorOpV1, EditorTargetGroupV1, EditorZUpdateV1, GraphPosition, + GraphStatType, GraphType, KeyPosition, KnobPosition, StatPosition, StatType, + }, + state::native_element_id::backfill_store_element_ids, +}; + +use super::*; + +fn base_store() -> AppStoreData { + let mut store = AppStoreData { + keys: default_keys().clone(), + key_positions: default_keys() + .iter() + .map(|(mode, keys)| (mode.clone(), vec![KeyPosition::default(); keys.len()])) + .collect(), + ..AppStoreData::default() + }; + backfill_store_element_ids(&mut store); + store +} + +fn insert_op(store: &AppStoreData) -> EditorOpV1 { + let mut position = KeyPosition { + id: uuid::Uuid::new_v4().to_string(), + dx: 100.0, + dy: 120.0, + z_index: Some(50), + group_id: Some("frozen-group".to_string()), + ..KeyPosition::default() + }; + position.width = 90.0; + EditorOpV1::InsertFrozenElements { + mode: "4key".to_string(), + elements: vec![EditorFrozenElementV1::Key { + slot: EditorFrozenKeySlotV1::Single("FROZEN".to_string()), + position, + }], + groups: vec![EditorFrozenGroupV1 { + id: "frozen-group".to_string(), + name: "Frozen Group".to_string(), + }], + z_updates: vec![EditorZUpdateV1 { + element_type: EditorElementTypeV1::Key, + id: store.key_positions["4key"][0].id.clone(), + z_index: 1, + }], + } +} + +fn store_with_every_reorder_type() -> AppStoreData { + let mut store = base_store(); + store.stat_positions.insert( + "4key".to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: KeyPosition::default(), + }], + ); + store.graph_positions.insert( + "4key".to_string(), + vec![GraphPosition { + stat_type: GraphStatType::Kps, + graph_type: GraphType::Line, + graph_speed: 100, + graph_color: "#123456".to_string(), + show_avg_line: true, + position: KeyPosition::default(), + }], + ); + store.knob_positions.insert( + "4key".to_string(), + vec![KnobPosition { + axis_id: "axis".to_string(), + sensitivity: 1.0, + reverse: false, + position: KeyPosition::default(), + }], + ); + backfill_store_element_ids(&mut store); + store +} + +fn complete_reorder_op(store: &AppStoreData) -> EditorOpV1 { + let mut z_updates = Vec::new(); + for (element_type, ids) in [ + ( + EditorElementTypeV1::Key, + store.key_positions["4key"] + .iter() + .map(|position| position.id.clone()) + .collect::>(), + ), + ( + EditorElementTypeV1::Stat, + store.stat_positions["4key"] + .iter() + .map(|position| position.position.id.clone()) + .collect(), + ), + ( + EditorElementTypeV1::Graph, + store.graph_positions["4key"] + .iter() + .map(|position| position.position.id.clone()) + .collect(), + ), + ( + EditorElementTypeV1::Knob, + store.knob_positions["4key"] + .iter() + .map(|position| position.position.id.clone()) + .collect(), + ), + ] { + for id in ids { + z_updates.push(EditorZUpdateV1 { + element_type, + id, + z_index: z_updates.len() as i32 + 10, + }); + } + } + EditorOpV1::ReorderElements { + mode: "4key".to_string(), + complete_mode_order: true, + z_updates, + group_updates: Vec::new(), + } +} + +fn patch_hidden_op( + element_type: EditorElementTypeV1, + id: impl Into, + hidden: bool, +) -> EditorOpV1 { + EditorOpV1::PatchElement { + element_type, + id: id.into(), + patch: EditorElementPropertyPatchV1::Hidden(hidden), + } +} + +fn patch_layer_name_op( + element_type: EditorElementTypeV1, + id: impl Into, + layer_name: Option<&str>, +) -> EditorOpV1 { + EditorOpV1::PatchElement { + element_type, + id: id.into(), + patch: EditorElementPropertyPatchV1::LayerName(layer_name.map(str::to_string)), + } +} + +fn patch_graph_type_op( + element_type: EditorElementTypeV1, + id: impl Into, + graph_type: GraphType, +) -> EditorOpV1 { + EditorOpV1::PatchElement { + element_type, + id: id.into(), + patch: EditorElementPropertyPatchV1::GraphType(graph_type), + } +} + +fn patch_graph_color_op( + element_type: EditorElementTypeV1, + id: impl Into, + graph_color: impl Into, +) -> EditorOpV1 { + EditorOpV1::PatchElement { + element_type, + id: id.into(), + patch: EditorElementPropertyPatchV1::GraphColor(graph_color.into()), + } +} + +fn patch_property_op( + element_type: EditorElementTypeV1, + id: impl Into, + patch: EditorElementPropertyPatchV1, +) -> EditorOpV1 { + EditorOpV1::PatchElement { + element_type, + id: id.into(), + patch, + } +} + +fn validation_code(error: &EditorCommitError) -> Option<&str> { + error + .details + .as_ref() + .and_then(|details| details.validation_code.as_deref()) +} + +fn assert_note_body_glow_paint_equal(position: &KeyPosition) { + assert_eq!(position.note_glow_gradient, position.note_gradient); + assert_eq!(position.note_glow_opacity, position.note_opacity); + assert_eq!( + position.note_glow_color.as_ref(), + Some(&position.note_color) + ); + assert_eq!(position.note_glow_opacity_top, position.note_opacity_top); + assert_eq!( + position.note_glow_opacity_bottom, + position.note_opacity_bottom + ); +} + +fn paint_descriptor( + color: &str, + gradient: Option<(f64, &[(&str, f64)])>, +) -> crate::models::EditorPaintDescriptorV1 { + crate::models::EditorPaintDescriptorV1 { + color: color.to_string(), + gradient: gradient.map(|(angle, stops)| crate::models::EditorPaintGradientV1 { + angle, + stops: stops + .iter() + .map(|(color, pos)| crate::models::EditorPaintGradientStopV1 { + color: (*color).to_string(), + pos: *pos, + }) + .collect(), + }), + } +} + +fn counter_fill_solid(color: &str) -> EditorCounterFillIntentV1 { + EditorCounterFillIntentV1::Solid(crate::models::EditorCounterFillSolidIntentV1 { + color: color.to_string(), + }) +} + +fn counter_fill_gradient( + color: &str, + angle: f64, + stops: &[(&str, f64)], +) -> EditorCounterFillIntentV1 { + EditorCounterFillIntentV1::Gradient(crate::models::EditorCounterFillGradientIntentV1 { + color: color.to_string(), + gradient: crate::models::EditorPaintGradientV1 { + angle, + stops: stops + .iter() + .map(|(color, pos)| crate::models::EditorPaintGradientStopV1 { + color: (*color).to_string(), + pos: *pos, + }) + .collect(), + }, + }) +} + +fn shadow_leaf_color(color: &str) -> EditorShadowLeafPatchV1 { + EditorShadowLeafPatchV1::Color(color.to_string()) +} + +fn shadow_leaf_offset_x(offset_x: f64) -> EditorShadowLeafPatchV1 { + EditorShadowLeafPatchV1::OffsetX(offset_x) +} + +fn shadow_leaf_offset_y(offset_y: f64) -> EditorShadowLeafPatchV1 { + EditorShadowLeafPatchV1::OffsetY(offset_y) +} + +fn shadow_leaf_blur(blur: f64) -> EditorShadowLeafPatchV1 { + EditorShadowLeafPatchV1::Blur(blur) +} + +struct PatchTargetMatrixRow { + name: &'static str, + allowed: &'static [EditorElementTypeV1], + patch: EditorElementPropertyPatchV1, +} + +// 속성 계열 × elementType 허용 매트릭스 - TS validator(src/types/editor.ts)와 동일 집합 +fn patch_target_type_matrix() -> Vec { + use crate::models::{ + EditorNoteBorderSideV1, ImageFit, ImageMode, ImageTransformLeafPatchV1, KeyCounterAlign, + KeyCounterAlignMode, KeyCounterPlacement, NoteAlignment, StatType, + }; + use EditorElementPropertyPatchV1 as Patch; + use EditorElementTypeV1::{Graph, Key, Knob, Stat}; + + const ALL: &[EditorElementTypeV1] = &[Key, Stat, Graph, Knob]; + const GRAPH_ONLY: &[EditorElementTypeV1] = &[Graph]; + const KNOB_ONLY: &[EditorElementTypeV1] = &[Knob]; + const STAT_ONLY: &[EditorElementTypeV1] = &[Stat]; + const KEY_ONLY: &[EditorElementTypeV1] = &[Key]; + const KEY_STAT: &[EditorElementTypeV1] = &[Key, Stat]; + const KEY_KNOB: &[EditorElementTypeV1] = &[Key, Knob]; + const KEY_STAT_KNOB: &[EditorElementTypeV1] = &[Key, Stat, Knob]; + + let row = |name, allowed, patch| PatchTargetMatrixRow { + name, + allowed, + patch, + }; + let note_opacity = |opacity| { + EditorNotePaintIntentV1::Opacity(crate::models::EditorNotePaintOpacityIntentV1 { opacity }) + }; + vec![ + row("hidden", ALL, Patch::Hidden(true)), + row( + "layerName", + ALL, + Patch::LayerName(Some("layer".to_string())), + ), + row("graphType", GRAPH_ONLY, Patch::GraphType(GraphType::Bar)), + row( + "graphColor", + GRAPH_ONLY, + Patch::GraphColor("#336699".to_string()), + ), + row("showAvgLine", GRAPH_ONLY, Patch::ShowAvgLine(false)), + row( + "graphAnimationEnabled", + GRAPH_ONLY, + Patch::GraphAnimationEnabled(true), + ), + row("graphSpeed", GRAPH_ONLY, Patch::GraphSpeed(120)), + row("reverse", KNOB_ONLY, Patch::Reverse(true)), + row("sensitivity", KNOB_ONLY, Patch::Sensitivity(1.5)), + row("axisId", KNOB_ONLY, Patch::AxisId("axis-2".to_string())), + row("useInlineStyles", ALL, Patch::UseInlineStyles(true)), + row("fontWeight", ALL, Patch::FontWeight(700)), + row("fontBold", ALL, Patch::FontBold(true)), + row("fontItalic", ALL, Patch::FontItalic(true)), + row("fontUnderline", ALL, Patch::FontUnderline(true)), + row("fontStrikethrough", ALL, Patch::FontStrikethrough(true)), + row("fontFamily", ALL, Patch::FontFamily("Sans".to_string())), + row("displayText", ALL, Patch::DisplayText("A".to_string())), + row("className", ALL, Patch::ClassName("custom".to_string())), + row( + "fontPaint", + KEY_STAT, + Patch::FontPaint(paint_descriptor("#ffffff", None)), + ), + row( + "activeFontPaint", + KEY_ONLY, + Patch::ActiveFontPaint(paint_descriptor("#ff0000", None)), + ), + row( + "shadow", + KEY_STAT_KNOB, + Patch::Shadow(shadow_leaf_color("#000000")), + ), + row( + "activeShadow", + KEY_KNOB, + Patch::ActiveShadow(shadow_leaf_color("#000000")), + ), + row("shadowEnabled", KEY_STAT_KNOB, Patch::ShadowEnabled(true)), + row( + "backgroundPaint", + ALL, + Patch::BackgroundPaint(paint_descriptor("#112233", None)), + ), + row( + "activeBackgroundPaint", + KEY_KNOB, + Patch::ActiveBackgroundPaint(paint_descriptor("#112233", None)), + ), + row( + "borderPaint", + ALL, + Patch::BorderPaint(paint_descriptor("#112233", None)), + ), + row( + "activeBorderPaint", + KEY_KNOB, + Patch::ActiveBorderPaint(paint_descriptor("#112233", None)), + ), + row("borderWidth", ALL, Patch::BorderWidth(2.0)), + row("borderRadius", ALL, Patch::BorderRadius(8.0)), + row("fontSize", ALL, Patch::FontSize(16.0)), + row( + "inactiveImage", + ALL, + Patch::InactiveImage("idle.png".to_string()), + ), + row( + "activeImage", + KEY_KNOB, + Patch::ActiveImage("active.png".to_string()), + ), + row("idleTransparent", ALL, Patch::IdleTransparent(true)), + row( + "activeTransparent", + KEY_KNOB, + Patch::ActiveTransparent(true), + ), + row("idleImageFit", ALL, Patch::IdleImageFit(ImageFit::Contain)), + row( + "activeImageFit", + KEY_KNOB, + Patch::ActiveImageFit(ImageFit::Fill), + ), + row("imageMode", KEY_ONLY, Patch::ImageMode(ImageMode::Overlay)), + row( + "idleImageTransform", + KEY_ONLY, + Patch::IdleImageTransform(Some(ImageTransformLeafPatchV1::OffsetX(10.0))), + ), + row( + "activeImageTransform", + KEY_ONLY, + Patch::ActiveImageTransform(Some(ImageTransformLeafPatchV1::Scale(1.5))), + ), + row( + "soundPath", + KEY_ONLY, + Patch::SoundPath("sound.mp3".to_string()), + ), + row("soundEnabled", KEY_ONLY, Patch::SoundEnabled(true)), + row("soundVolume", KEY_ONLY, Patch::SoundVolume(80.0)), + row("counterEnabled", KEY_STAT, Patch::CounterEnabled(true)), + row( + "counterAnimationEnabled", + KEY_STAT, + Patch::CounterAnimationEnabled(true), + ), + row( + "counterPlacement", + KEY_STAT, + Patch::CounterPlacement(KeyCounterPlacement::Outside), + ), + row( + "counterAlign", + KEY_STAT, + Patch::CounterAlign(KeyCounterAlign::Top), + ), + row( + "counterAlignMode", + KEY_STAT, + Patch::CounterAlignMode(KeyCounterAlignMode::Between), + ), + row("counterGap", KEY_STAT, Patch::CounterGap(4)), + row("counterFontSize", KEY_STAT, Patch::CounterFontSize(14)), + row("counterFontWeight", KEY_STAT, Patch::CounterFontWeight(500)), + row("counterFontBold", KEY_STAT, Patch::CounterFontBold(true)), + row( + "counterFontItalic", + KEY_STAT, + Patch::CounterFontItalic(true), + ), + row( + "counterFontUnderline", + KEY_STAT, + Patch::CounterFontUnderline(true), + ), + row( + "counterFontStrikethrough", + KEY_STAT, + Patch::CounterFontStrikethrough(true), + ), + row( + "counterFontFamily", + KEY_STAT, + Patch::CounterFontFamily("Sans".to_string()), + ), + row( + "counterFillIdle", + KEY_STAT, + Patch::CounterFillIdle(counter_fill_solid("#ffffff")), + ), + row( + "counterFillActive", + KEY_ONLY, + Patch::CounterFillActive(counter_fill_solid("#ffffff")), + ), + row( + "counterAnimationPreset", + KEY_STAT, + Patch::CounterAnimationPreset(EditorCounterAnimationPresetIntentV1 { + preset_id: "builtin-ease-out".to_string(), + apply_preset_id: None, + bezier: None, + scale: None, + duration_ms: None, + }), + ), + row("statType", STAT_ONLY, Patch::StatType(StatType::Total)), + row( + "noteEffectEnabled", + KEY_ONLY, + Patch::NoteEffectEnabled(true), + ), + row("noteGlowEnabled", KEY_ONLY, Patch::NoteGlowEnabled(true)), + row( + "noteGlowSyncPaint", + KEY_ONLY, + Patch::NoteGlowSyncPaint(true), + ), + row("noteGlowSize", KEY_ONLY, Patch::NoteGlowSize(10.0)), + row("notePaint", KEY_ONLY, Patch::NotePaint(note_opacity(80))), + row( + "noteGlowPaint", + KEY_ONLY, + Patch::NoteGlowPaint(note_opacity(60)), + ), + row( + "noteBorderPaint", + KEY_ONLY, + Patch::NoteBorderPaint(crate::models::EditorNoteBorderPaintV1 { + color: "#112233".to_string(), + opacity: 80, + gradient: None, + }), + ), + row("noteOffsetX", KEY_ONLY, Patch::NoteOffsetX(Some(10.0))), + row("noteOffsetY", KEY_ONLY, Patch::NoteOffsetY(Some(-10.0))), + row("noteWidth", KEY_ONLY, Patch::NoteWidth(Some(40.0))), + row("noteBorderWidth", KEY_ONLY, Patch::NoteBorderWidth(2.0)), + row("noteBorderRadius", KEY_ONLY, Patch::NoteBorderRadius(8.0)), + row( + "noteAutoYCorrection", + KEY_ONLY, + Patch::NoteAutoYCorrection(true), + ), + row( + "noteAlignment", + KEY_ONLY, + Patch::NoteAlignment(NoteAlignment::Left), + ), + row( + "noteBorderSide", + KEY_ONLY, + Patch::NoteBorderSide(EditorNoteBorderSideV1::Vertical), + ), + ] +} + +#[test] +fn patch_target_type_matrix_pins_all_variant_constraints() { + let store = store_with_every_reorder_type(); + let target_id = |element_type: EditorElementTypeV1| match element_type { + EditorElementTypeV1::Key => store.key_positions["4key"][0].id.clone(), + EditorElementTypeV1::Stat => store.stat_positions["4key"][0].position.id.clone(), + EditorElementTypeV1::Graph => store.graph_positions["4key"][0].position.id.clone(), + EditorElementTypeV1::Knob => store.knob_positions["4key"][0].position.id.clone(), + }; + let rows = patch_target_type_matrix(); + + // 전수 고정. 개수 리터럴은 강제가 아니다 - variant를 추가하고 매트릭스 + // 행을 빠뜨리면 개수만 맞춰도 통과한다. wire 태그 집합과 대조해 + // "모든 variant가 매트릭스에 있다"를 실제로 강제한다 + let row_tags = rows + .iter() + .map(|row| { + serde_json::to_value(&row.patch).expect("patch must serialize")["property"] + .as_str() + .expect("adjacently tagged patch must carry property") + .to_string() + }) + .collect::>(); + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../../tests/fixtures/editor-property-tags.json" + )) + .expect("property tag fixture must be valid json"); + let wire_tags = fixture["properties"] + .as_array() + .expect("fixture.properties must be an array") + .iter() + .map(|tag| { + tag.as_str() + .expect("property tag must be a string") + .to_string() + }) + .collect::>(); + assert_eq!(row_tags, wire_tags, "매트릭스 행과 wire 태그 집합이 다르다"); + assert_eq!(rows.len(), row_tags.len(), "매트릭스에 중복 variant가 있다"); + let distinct = rows + .iter() + .map(|row| std::mem::discriminant(&row.patch)) + .collect::>(); + assert_eq!(distinct.len(), rows.len()); + + for row in &rows { + for element_type in [ + EditorElementTypeV1::Key, + EditorElementTypeV1::Stat, + EditorElementTypeV1::Graph, + EditorElementTypeV1::Knob, + ] { + // 선언 타입과 실제 요소 타입을 일치시켜 계열 제약만 판정되게 고정 + let ops = [patch_property_op( + element_type, + target_id(element_type), + row.patch.clone(), + )]; + let result = prepare_editor_ops_transition(&store, &ops); + if row.allowed.contains(&element_type) { + // 값은 전부 유효하므로 전체 성공이 곧 타입 검증 통과 증명 + assert!( + result.is_ok(), + "{} on {element_type:?} must pass: {:?}", + row.name, + result.err(), + ); + } else { + let Err(error) = result else { + panic!("{} on {element_type:?} must be rejected", row.name); + }; + assert_eq!( + validation_code(&error), + Some("ELEMENT_TYPE_MISMATCH"), + "{} on {element_type:?}", + row.name, + ); + } + } + } +} + +#[test] +fn frozen_insert_appends_key_pair_group_and_existing_z_as_one_transition() { + let store = base_store(); + let op = insert_op(&store); + let before_len = store.key_positions["4key"].len(); + + let transition = prepare_editor_ops_transition(&store, std::slice::from_ref(&op)).unwrap(); + + assert_eq!( + transition.op_results, + [EditorOpResultV1 { + status: EditorOpResultStatusV1::Applied, + bounds: None, + }] + ); + assert_eq!(transition.candidate.keys["4key"].len(), before_len + 1); + assert_eq!( + transition.candidate.key_positions["4key"].len(), + before_len + 1 + ); + assert_eq!( + transition.candidate.keys["4key"].last().unwrap(), + &crate::models::KeySlot::Single("FROZEN".to_string()) + ); + assert_eq!( + transition.candidate.key_positions["4key"][0].z_index, + Some(1) + ); + assert_eq!(transition.candidate.layer_groups["4key"].len(), 1); + assert_eq!( + transition.changed_fields, + [ + EditorField::Keys, + EditorField::KeyPositions, + EditorField::LayerGroups, + ] + ); + + let replay = + prepare_editor_ops_transition(&transition.scratch, std::slice::from_ref(&op)).unwrap(); + assert!(replay.changed_fields.is_empty()); + assert_eq!( + replay.op_results[0].status, + EditorOpResultStatusV1::NoChange + ); +} + +#[test] +fn frozen_insert_group_needs_a_native_or_plugin_member() { + let store = base_store(); + // 삽입 요소가 그룹을 참조하지 않으면 거절 + let orphan = EditorOpV1::InsertFrozenElements { + mode: "4key".to_string(), + elements: vec![], + groups: vec![EditorFrozenGroupV1 { + id: "plugin-only-group".to_string(), + name: "Plugin Only".to_string(), + }], + z_updates: vec![EditorZUpdateV1 { + element_type: EditorElementTypeV1::Key, + id: store.key_positions["4key"][0].id.clone(), + z_index: 1, + }], + }; + let error = prepare_editor_ops_transition(&store, std::slice::from_ref(&orphan)).unwrap_err(); + assert_eq!( + error + .details + .as_ref() + .and_then(|details| details.validation_code.as_deref()), + Some("UNREFERENCED_FROZEN_GROUP") + ); + + // 동봉 plugin_changes가 그 그룹의 멤버를 실었다면 생존해야 한다. + // 구조 op는 단독 배치가 강제되므로 setElementGroups로 분리할 수 없다 + let mut plugin_refs = PluginGroupRefs::new(); + plugin_refs + .entry("4key".to_string()) + .or_default() + .insert("plugin-only-group".to_string()); + let transition = prepare_editor_ops_transition_with_plugin_refs( + &store, + std::slice::from_ref(&orphan), + &plugin_refs, + ) + .unwrap(); + assert_eq!( + transition.candidate.layer_groups["4key"] + .iter() + .map(|group| group.id.as_str()) + .collect::>(), + ["plugin-only-group"] + ); +} + +#[test] +fn frozen_insert_rejects_partial_or_different_existing_plan() { + let store = base_store(); + let op = insert_op(&store); + let mut partial = prepare_editor_ops_transition(&store, std::slice::from_ref(&op)) + .unwrap() + .scratch; + partial + .key_positions + .get_mut("4key") + .unwrap() + .last_mut() + .unwrap() + .width += 1.0; + + let error = prepare_editor_ops_transition(&partial, &[op]).unwrap_err(); + assert_eq!(validation_code(&error), Some("FROZEN_INSERT_CONFLICT")); + assert_eq!(partial.key_positions["4key"].last().unwrap().width, 91.0); +} + +#[test] +fn frozen_insert_appends_every_native_element_kind() { + let store = base_store(); + let make_position = || KeyPosition { + id: uuid::Uuid::new_v4().to_string(), + ..KeyPosition::default() + }; + let op = EditorOpV1::InsertFrozenElements { + mode: "4key".to_string(), + elements: vec![ + EditorFrozenElementV1::Key { + slot: EditorFrozenKeySlotV1::Single("A".to_string()), + position: make_position(), + }, + EditorFrozenElementV1::Stat { + position: StatPosition { + stat_type: StatType::Kps, + position: make_position(), + }, + }, + EditorFrozenElementV1::Graph { + position: GraphPosition { + stat_type: GraphStatType::Kps, + graph_type: GraphType::Line, + graph_speed: 100, + graph_color: "#123456".to_string(), + show_avg_line: true, + position: make_position(), + }, + }, + EditorFrozenElementV1::Knob { + position: KnobPosition { + axis_id: "axis".to_string(), + sensitivity: 1.0, + reverse: false, + position: make_position(), + }, + }, + ], + groups: Vec::new(), + z_updates: Vec::new(), + }; + + let transition = prepare_editor_ops_transition(&store, &[op]).unwrap(); + assert_eq!( + transition.changed_fields, + [ + EditorField::Keys, + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + ] + ); + assert_eq!(transition.candidate.stat_positions["4key"].len(), 1); + assert_eq!(transition.candidate.graph_positions["4key"].len(), 1); + assert_eq!(transition.candidate.knob_positions["4key"].len(), 1); +} + +#[test] +fn frozen_insert_rejects_preexisting_group_as_partial_plan() { + let mut store = base_store(); + store.layer_groups.insert( + "4key".to_string(), + vec![LayerGroupDef { + id: "frozen-group".to_string(), + name: "Frozen Group".to_string(), + }], + ); + let before = store.clone(); + + let error = prepare_editor_ops_transition(&store, &[insert_op(&store)]).unwrap_err(); + assert_eq!(validation_code(&error), Some("FROZEN_INSERT_CONFLICT")); + assert_eq!(store, before); +} + +#[test] +fn frozen_insert_z_targets_are_exact_existing_type_and_mode() { + let store = base_store(); + let op = insert_op(&store); + let mut missing = op.clone(); + let EditorOpV1::InsertFrozenElements { z_updates, .. } = &mut missing else { + unreachable!(); + }; + z_updates[0].id = uuid::Uuid::new_v4().to_string(); + assert_eq!( + validation_code(&prepare_editor_ops_transition(&store, &[missing]).unwrap_err()), + Some("FROZEN_INSERT_TARGET_MISSING") + ); + + let mut wrong_type = op.clone(); + let EditorOpV1::InsertFrozenElements { z_updates, .. } = &mut wrong_type else { + unreachable!(); + }; + z_updates[0].element_type = EditorElementTypeV1::Graph; + assert_eq!( + validation_code(&prepare_editor_ops_transition(&store, &[wrong_type]).unwrap_err()), + Some("ELEMENT_TYPE_MISMATCH") + ); + + let mut wrong_mode = op; + let EditorOpV1::InsertFrozenElements { mode, .. } = &mut wrong_mode else { + unreachable!(); + }; + *mode = "5key".to_string(); + assert_eq!( + validation_code(&prepare_editor_ops_transition(&store, &[wrong_mode]).unwrap_err()), + Some("FROZEN_INSERT_TARGET_MODE_MISMATCH") + ); +} + +#[test] +fn frozen_insert_does_not_repair_a_malformed_key_pair() { + let mut store = base_store(); + let op = insert_op(&store); + store.keys.remove("4key"); + let before = store.clone(); + + let error = prepare_editor_ops_transition(&store, &[op]).unwrap_err(); + assert!(matches!( + validation_code(&error), + Some("KEY_POSITION_MODE_MISMATCH" | "KEY_POSITION_LENGTH_MISMATCH") + )); + assert_eq!(store, before); +} + +#[test] +fn frozen_insert_z_only_exact_plan_is_no_change() { + let store = base_store(); + let target = &store.key_positions["4key"][0]; + let op = EditorOpV1::InsertFrozenElements { + mode: "4key".to_string(), + elements: Vec::new(), + groups: Vec::new(), + z_updates: vec![EditorZUpdateV1 { + element_type: EditorElementTypeV1::Key, + id: target.id.clone(), + z_index: target.z_index.unwrap_or_default(), + }], + }; + let transition = prepare_editor_ops_transition(&store, &[op]).unwrap(); + assert!(transition.changed_fields.is_empty()); + assert_eq!( + transition.op_results[0].status, + EditorOpResultStatusV1::NoChange + ); +} + +#[test] +fn complete_reorder_updates_every_native_type_and_cleans_empty_groups() { + let mut store = store_with_every_reorder_type(); + let keys_before = store.keys.clone(); + let target_id = store.key_positions["4key"][0].id.clone(); + let reassigned_id = store.key_positions["4key"][1].id.clone(); + store.key_positions.get_mut("4key").unwrap()[0].group_id = Some("source-group".to_string()); + store.layer_groups.insert( + "4key".to_string(), + vec![ + LayerGroupDef { + id: "source-group".to_string(), + name: "Source".to_string(), + }, + LayerGroupDef { + id: "empty-group".to_string(), + name: "Empty".to_string(), + }, + LayerGroupDef { + id: "target-group".to_string(), + name: "Target".to_string(), + }, + ], + ); + let mut op = complete_reorder_op(&store); + let EditorOpV1::ReorderElements { group_updates, .. } = &mut op else { + unreachable!(); + }; + group_updates.push(EditorGroupUpdateV1 { + element_type: EditorElementTypeV1::Key, + id: target_id, + group_id: None, + }); + group_updates.push(EditorGroupUpdateV1 { + element_type: EditorElementTypeV1::Key, + id: reassigned_id, + group_id: Some("target-group".to_string()), + }); + + let transition = prepare_editor_ops_transition(&store, std::slice::from_ref(&op)).unwrap(); + assert_eq!( + transition.op_results, + [EditorOpResultV1 { + status: EditorOpResultStatusV1::Applied, + bounds: None, + }] + ); + assert_eq!(transition.candidate.keys, keys_before); + assert_eq!( + transition.candidate.layer_groups["4key"], + [LayerGroupDef { + id: "target-group".to_string(), + name: "Target".to_string(), + }] + ); + assert_eq!( + transition.changed_fields, + [ + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + EditorField::LayerGroups, + ] + ); + + let replay = + prepare_editor_ops_transition(&transition.scratch, std::slice::from_ref(&op)).unwrap(); + assert!(replay.changed_fields.is_empty()); + assert_eq!( + replay.op_results[0].status, + EditorOpResultStatusV1::NoChange + ); +} + +#[test] +fn group_structural_transition_is_frozen_atomic_and_cleans_only_empty_definitions() { + let mut store = store_with_every_reorder_type(); + let key_id = store.key_positions["4key"][0].id.clone(); + let newcomer_id = store.key_positions["4key"][1].id.clone(); + let stat_id = store.stat_positions["4key"][0].position.id.clone(); + let graph_id = store.graph_positions["4key"][0].position.id.clone(); + let knob_id = store.knob_positions["4key"][0].position.id.clone(); + store.key_positions.get_mut("4key").unwrap()[0].group_id = Some("source-group".to_string()); + store.key_positions.get_mut("4key").unwrap()[1].group_id = Some("source-group".to_string()); + store.stat_positions.get_mut("4key").unwrap()[0] + .position + .group_id = Some("source-group".to_string()); + store.graph_positions.get_mut("4key").unwrap()[0] + .position + .group_id = Some("source-group".to_string()); + store.knob_positions.get_mut("4key").unwrap()[0] + .position + .group_id = Some("source-group".to_string()); + store.layer_groups.insert( + "4key".to_string(), + vec![ + LayerGroupDef { + id: "source-group".to_string(), + name: " Source ".to_string(), + }, + LayerGroupDef { + id: "empty-group".to_string(), + name: "Empty".to_string(), + }, + ], + ); + let targets = vec![ + EditorElementGroupTargetV1 { + element_type: EditorElementTypeV1::Key, + id: key_id.clone(), + }, + EditorElementGroupTargetV1 { + element_type: EditorElementTypeV1::Stat, + id: stat_id.clone(), + }, + EditorElementGroupTargetV1 { + element_type: EditorElementTypeV1::Graph, + id: graph_id.clone(), + }, + EditorElementGroupTargetV1 { + element_type: EditorElementTypeV1::Knob, + id: knob_id.clone(), + }, + ]; + let create = EditorOpV1::SetElementGroups { + mode: "4key".to_string(), + targets: targets.clone(), + target_group: Some(EditorTargetGroupV1::Create { + id: " target group ".to_string(), + name: " Target Name ".to_string(), + }), + }; + + let transition = prepare_editor_ops_transition(&store, std::slice::from_ref(&create)).unwrap(); + assert_eq!( + transition.op_results[0].status, + EditorOpResultStatusV1::Applied + ); + assert_eq!( + transition.changed_fields, + [ + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + EditorField::LayerGroups, + ] + ); + assert_eq!( + transition.candidate.key_positions["4key"][0] + .group_id + .as_deref(), + Some(" target group ") + ); + assert_eq!( + transition.candidate.stat_positions["4key"][0] + .position + .group_id + .as_deref(), + Some(" target group ") + ); + assert_eq!( + transition.candidate.graph_positions["4key"][0] + .position + .group_id + .as_deref(), + Some(" target group ") + ); + assert_eq!( + transition.candidate.knob_positions["4key"][0] + .position + .group_id + .as_deref(), + Some(" target group ") + ); + assert_eq!( + transition.candidate.key_positions["4key"][1] + .group_id + .as_deref(), + Some("source-group") + ); + assert_eq!( + transition.candidate.layer_groups["4key"], + [ + LayerGroupDef { + id: "source-group".to_string(), + name: " Source ".to_string(), + }, + LayerGroupDef { + id: " target group ".to_string(), + name: " Target Name ".to_string(), + }, + ] + ); + + let existing = EditorOpV1::SetElementGroups { + mode: "4key".to_string(), + targets: targets.clone(), + target_group: Some(EditorTargetGroupV1::Existing { + id: " target group ".to_string(), + }), + }; + let no_change = + prepare_editor_ops_transition(&transition.scratch, std::slice::from_ref(&existing)) + .unwrap(); + assert_eq!( + no_change.op_results[0].status, + EditorOpResultStatusV1::NoChange + ); + assert!(no_change.changed_fields.is_empty()); + + let mut cleanup_store = transition.scratch.clone(); + cleanup_store + .layer_groups + .get_mut("4key") + .unwrap() + .push(LayerGroupDef { + id: "cleanup-only".to_string(), + name: "Cleanup".to_string(), + }); + let cleanup = + prepare_editor_ops_transition(&cleanup_store, std::slice::from_ref(&existing)).unwrap(); + assert_eq!( + cleanup.op_results[0].status, + EditorOpResultStatusV1::Applied + ); + assert_eq!(cleanup.changed_fields, [EditorField::LayerGroups]); + + let ungroup = EditorOpV1::SetElementGroups { + mode: "4key".to_string(), + targets, + target_group: None, + }; + let ungrouped = prepare_editor_ops_transition(&transition.scratch, &[ungroup]).unwrap(); + assert_eq!( + ungrouped.op_results[0].status, + EditorOpResultStatusV1::Applied + ); + assert!(ungrouped.candidate.layer_groups["4key"] + .iter() + .all(|group| group.id != " target group ")); + assert_eq!( + ungrouped.candidate.key_positions["4key"][1] + .group_id + .as_deref(), + Some("source-group") + ); + assert_eq!( + ungrouped.candidate.key_positions["4key"] + .iter() + .find(|position| position.id == newcomer_id) + .unwrap() + .group_id + .as_deref(), + Some("source-group") + ); +} + +#[test] +fn group_structural_transition_rejects_conflicts_and_reports_missing_atomically() { + let mut store = store_with_every_reorder_type(); + let key_id = store.key_positions["4key"][0].id.clone(); + store.key_positions.get_mut("4key").unwrap()[0].group_id = Some("existing-group".to_string()); + store.layer_groups.insert( + "4key".to_string(), + vec![LayerGroupDef { + id: "existing-group".to_string(), + name: "Existing".to_string(), + }], + ); + let before = EditorDocumentV1::from_store(&store); + let target = EditorElementGroupTargetV1 { + element_type: EditorElementTypeV1::Key, + id: key_id.clone(), + }; + + let missing = EditorOpV1::SetElementGroups { + mode: "4key".to_string(), + targets: vec![ + target.clone(), + EditorElementGroupTargetV1 { + element_type: EditorElementTypeV1::Graph, + id: uuid::Uuid::new_v4().to_string(), + }, + ], + target_group: None, + }; + let missing_transition = prepare_editor_ops_transition(&store, &[missing]).unwrap(); + assert_eq!( + missing_transition.op_results[0].status, + EditorOpResultStatusV1::TargetMissing + ); + assert_eq!(missing_transition.candidate, before); + assert!(missing_transition.changed_fields.is_empty()); + + for invalid in [ + EditorOpV1::SetElementGroups { + mode: "4key".to_string(), + targets: vec![EditorElementGroupTargetV1 { + element_type: EditorElementTypeV1::Graph, + id: key_id.clone(), + }], + target_group: None, + }, + EditorOpV1::SetElementGroups { + mode: "5key".to_string(), + targets: vec![target.clone()], + target_group: None, + }, + ] { + let error = prepare_editor_ops_transition(&store, &[invalid]).unwrap_err(); + assert!(matches!( + validation_code(&error), + Some("ELEMENT_TYPE_MISMATCH" | "ELEMENT_GROUP_TARGET_MODE_MISMATCH") + )); + assert_eq!(EditorDocumentV1::from_store(&store), before); + } + + for name in ["Existing", "Different"] { + let collision = EditorOpV1::SetElementGroups { + mode: "4key".to_string(), + targets: vec![target.clone()], + target_group: Some(EditorTargetGroupV1::Create { + id: "existing-group".to_string(), + name: name.to_string(), + }), + }; + let error = prepare_editor_ops_transition(&store, &[collision]).unwrap_err(); + assert_eq!(validation_code(&error), Some("LAYER_GROUP_ALREADY_EXISTS")); + } + + let unknown_group = EditorOpV1::SetElementGroups { + mode: "4key".to_string(), + targets: vec![target], + target_group: Some(EditorTargetGroupV1::Existing { + id: "missing-group".to_string(), + }), + }; + let unknown = prepare_editor_ops_transition(&store, &[unknown_group]).unwrap(); + assert_eq!( + unknown.op_results[0].status, + EditorOpResultStatusV1::TargetMissing + ); + assert_eq!(unknown.candidate, before); +} + +#[test] +fn rename_layer_group_is_mode_scoped_raw_and_reports_missing() { + let mut store = store_with_every_reorder_type(); + store.layer_groups.insert( + "4key".to_string(), + vec![LayerGroupDef { + id: "legacy group".to_string(), + name: "Old".to_string(), + }], + ); + store.layer_groups.insert( + "5key".to_string(), + vec![LayerGroupDef { + id: "legacy group".to_string(), + name: "Other Mode".to_string(), + }], + ); + let rename = EditorOpV1::RenameLayerGroup { + mode: "4key".to_string(), + group_id: "legacy group".to_string(), + name: " Raw Name ".to_string(), + }; + + let applied = prepare_editor_ops_transition(&store, std::slice::from_ref(&rename)).unwrap(); + assert_eq!( + applied.op_results[0].status, + EditorOpResultStatusV1::Applied + ); + assert_eq!(applied.changed_fields, [EditorField::LayerGroups]); + assert_eq!(applied.candidate.layer_groups["4key"][0].name, " Raw Name "); + assert_eq!(applied.candidate.layer_groups["5key"][0].name, "Other Mode"); + + let replay = + prepare_editor_ops_transition(&applied.scratch, std::slice::from_ref(&rename)).unwrap(); + assert_eq!( + replay.op_results[0].status, + EditorOpResultStatusV1::NoChange + ); + assert!(replay.changed_fields.is_empty()); + + let missing = EditorOpV1::RenameLayerGroup { + mode: "4key".to_string(), + group_id: "missing".to_string(), + name: "Name".to_string(), + }; + let missing_transition = prepare_editor_ops_transition(&store, &[missing]).unwrap(); + assert_eq!( + missing_transition.op_results[0].status, + EditorOpResultStatusV1::TargetMissing + ); + assert!(missing_transition.changed_fields.is_empty()); +} + +#[test] +fn partial_reorder_preserves_groups_and_treats_raw_z_storage_as_state() { + let mut store = base_store(); + let id = store.key_positions["4key"][0].id.clone(); + store.key_positions.get_mut("4key").unwrap()[0].z_index = None; + store.layer_groups.insert( + "4key".to_string(), + vec![LayerGroupDef { + id: "empty-group".to_string(), + name: "Empty".to_string(), + }], + ); + let op = EditorOpV1::ReorderElements { + mode: "4key".to_string(), + complete_mode_order: false, + z_updates: vec![EditorZUpdateV1 { + element_type: EditorElementTypeV1::Key, + id, + z_index: 0, + }], + group_updates: Vec::new(), + }; + + let applied = prepare_editor_ops_transition(&store, std::slice::from_ref(&op)).unwrap(); + assert_eq!( + applied.op_results[0].status, + EditorOpResultStatusV1::Applied + ); + assert_eq!(applied.candidate.key_positions["4key"][0].z_index, Some(0)); + assert_eq!(applied.candidate.layer_groups, store.layer_groups); + + let replay = + prepare_editor_ops_transition(&applied.scratch, std::slice::from_ref(&op)).unwrap(); + assert_eq!( + replay.op_results[0].status, + EditorOpResultStatusV1::NoChange + ); + assert!(replay.changed_fields.is_empty()); +} + +#[test] +fn reorder_rejects_incomplete_missing_wrong_mode_type_and_group_atomically() { + let mut store = store_with_every_reorder_type(); + store.layer_groups.insert( + "4key".to_string(), + vec![LayerGroupDef { + id: "known-group".to_string(), + name: "Known".to_string(), + }], + ); + let before = store.clone(); + let complete = complete_reorder_op(&store); + + let mut incomplete = complete.clone(); + let EditorOpV1::ReorderElements { z_updates, .. } = &mut incomplete else { + unreachable!(); + }; + z_updates.pop(); + assert_eq!( + validation_code(&prepare_editor_ops_transition(&store, &[incomplete]).unwrap_err()), + Some("INCOMPLETE_REORDER_PLAN") + ); + + let mut missing = complete.clone(); + let EditorOpV1::ReorderElements { z_updates, .. } = &mut missing else { + unreachable!(); + }; + z_updates[0].id = uuid::Uuid::new_v4().to_string(); + assert_eq!( + validation_code(&prepare_editor_ops_transition(&store, &[missing]).unwrap_err()), + Some("REORDER_TARGET_MISSING") + ); + + let mut wrong_mode = complete.clone(); + let EditorOpV1::ReorderElements { mode, .. } = &mut wrong_mode else { + unreachable!(); + }; + *mode = "5key".to_string(); + assert_eq!( + validation_code(&prepare_editor_ops_transition(&store, &[wrong_mode]).unwrap_err()), + Some("REORDER_TARGET_MODE_MISMATCH") + ); + + let mut wrong_type = complete.clone(); + let EditorOpV1::ReorderElements { z_updates, .. } = &mut wrong_type else { + unreachable!(); + }; + z_updates[0].element_type = EditorElementTypeV1::Graph; + assert_eq!( + validation_code(&prepare_editor_ops_transition(&store, &[wrong_type]).unwrap_err()), + Some("ELEMENT_TYPE_MISMATCH") + ); + + let mut unknown_group = complete; + let EditorOpV1::ReorderElements { + z_updates, + group_updates, + .. + } = &mut unknown_group + else { + unreachable!(); + }; + group_updates.push(EditorGroupUpdateV1 { + element_type: z_updates[0].element_type, + id: z_updates[0].id.clone(), + group_id: Some("unknown-group".to_string()), + }); + assert_eq!( + validation_code(&prepare_editor_ops_transition(&store, &[unknown_group]).unwrap_err()), + Some("REORDER_GROUP_MISSING") + ); + assert_eq!(store, before); +} + +#[test] +fn reorder_does_not_repair_a_malformed_key_pair() { + let mut store = base_store(); + let id = store.key_positions["4key"][0].id.clone(); + let op = EditorOpV1::ReorderElements { + mode: "4key".to_string(), + complete_mode_order: false, + z_updates: vec![EditorZUpdateV1 { + element_type: EditorElementTypeV1::Key, + id, + z_index: 99, + }], + group_updates: Vec::new(), + }; + store.keys.remove("4key"); + let before = store.clone(); + + let error = prepare_editor_ops_transition(&store, &[op]).unwrap_err(); + assert!(matches!( + validation_code(&error), + Some("KEY_POSITION_MODE_MISMATCH" | "KEY_POSITION_LENGTH_MISMATCH") + )); + assert_eq!(store, before); +} + +#[test] +fn property_patches_apply_all_native_types_in_order_and_skip_missing_targets() { + let store = store_with_every_reorder_type(); + let key_id = store.key_positions["4key"][0].id.clone(); + let stat_id = store.stat_positions["4key"][0].position.id.clone(); + let graph_id = store.graph_positions["4key"][0].position.id.clone(); + let knob_id = store.knob_positions["4key"][0].position.id.clone(); + let missing_id = uuid::Uuid::new_v4().to_string(); + let ops = vec![ + patch_hidden_op(EditorElementTypeV1::Key, key_id, true), + patch_hidden_op(EditorElementTypeV1::Stat, stat_id, true), + patch_hidden_op(EditorElementTypeV1::Graph, graph_id, true), + patch_hidden_op(EditorElementTypeV1::Knob, knob_id, true), + patch_hidden_op(EditorElementTypeV1::Key, missing_id, true), + ]; + + let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); + assert!(transition.candidate.key_positions["4key"][0].hidden); + assert!( + transition.candidate.stat_positions["4key"][0] + .position + .hidden + ); + assert!( + transition.candidate.graph_positions["4key"][0] + .position + .hidden + ); + assert!( + transition.candidate.knob_positions["4key"][0] + .position + .hidden + ); + assert_eq!( + transition + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + assert_eq!( + transition.changed_fields, + [ + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + ] + ); + + let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); + assert!(replay.changed_fields.is_empty()); + assert_eq!( + replay + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::TargetMissing, + ] + ); +} + +#[test] +fn layer_name_patches_apply_exact_values_and_clear_without_touching_other_fields() { + let store = store_with_every_reorder_type(); + let key_id = store.key_positions["4key"][0].id.clone(); + let stat_id = store.stat_positions["4key"][0].position.id.clone(); + let graph_id = store.graph_positions["4key"][0].position.id.clone(); + let knob_id = store.knob_positions["4key"][0].position.id.clone(); + let missing_id = uuid::Uuid::new_v4().to_string(); + let already_clear = prepare_editor_ops_transition( + &store, + &[patch_layer_name_op(EditorElementTypeV1::Key, &key_id, None)], + ) + .unwrap(); + assert_eq!( + already_clear.op_results[0].status, + EditorOpResultStatusV1::NoChange + ); + assert!(already_clear.changed_fields.is_empty()); + + let ops = vec![ + patch_layer_name_op(EditorElementTypeV1::Key, &key_id, Some("Key layer")), + patch_layer_name_op(EditorElementTypeV1::Stat, stat_id, Some("Stat layer")), + patch_layer_name_op(EditorElementTypeV1::Graph, graph_id, Some("Graph layer")), + patch_layer_name_op(EditorElementTypeV1::Knob, knob_id, Some("Knob layer")), + patch_layer_name_op(EditorElementTypeV1::Key, missing_id, None), + ]; + + let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); + assert_eq!( + transition.candidate.key_positions["4key"][0] + .layer_name + .as_deref(), + Some("Key layer") + ); + assert_eq!( + transition.candidate.stat_positions["4key"][0] + .position + .layer_name + .as_deref(), + Some("Stat layer") + ); + assert_eq!( + transition.candidate.graph_positions["4key"][0] + .position + .layer_name + .as_deref(), + Some("Graph layer") + ); + assert_eq!( + transition.candidate.knob_positions["4key"][0] + .position + .layer_name + .as_deref(), + Some("Knob layer") + ); + assert_eq!( + transition + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + assert_eq!( + transition.changed_fields, + [ + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + ] + ); + + let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); + assert!(replay.changed_fields.is_empty()); + assert_eq!( + replay.op_results[0].status, + EditorOpResultStatusV1::NoChange + ); + + let cleared = prepare_editor_ops_transition( + &transition.scratch, + &[patch_layer_name_op(EditorElementTypeV1::Key, key_id, None)], + ) + .unwrap(); + assert_eq!(cleared.candidate.key_positions["4key"][0].layer_name, None); + assert_eq!( + cleared.op_results[0].status, + EditorOpResultStatusV1::Applied + ); +} + +#[test] +fn graph_type_patch_is_graph_only_and_preserves_other_graph_fields() { + let store = store_with_every_reorder_type(); + let graph = &store.graph_positions["4key"][0]; + let graph_id = graph.position.id.clone(); + let missing_id = uuid::Uuid::new_v4().to_string(); + let ops = [ + patch_graph_type_op(EditorElementTypeV1::Graph, &graph_id, GraphType::Bar), + patch_graph_type_op(EditorElementTypeV1::Graph, missing_id, GraphType::Line), + ]; + + let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); + let changed = &transition.candidate.graph_positions["4key"][0]; + assert_eq!(changed.graph_type, GraphType::Bar); + assert_eq!(changed.stat_type, graph.stat_type); + assert_eq!(changed.graph_speed, graph.graph_speed); + assert_eq!(changed.graph_color, graph.graph_color); + assert_eq!(changed.show_avg_line, graph.show_avg_line); + assert_eq!(changed.position, graph.position); + assert_eq!(transition.changed_fields, [EditorField::GraphPositions]); + assert_eq!( + transition + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); + assert!(replay.changed_fields.is_empty()); + assert_eq!( + replay.op_results[0].status, + EditorOpResultStatusV1::NoChange + ); + + let key_id = store.key_positions["4key"][0].id.clone(); + let error = prepare_editor_ops_transition( + &store, + &[ + patch_hidden_op(EditorElementTypeV1::Key, &key_id, true), + patch_graph_type_op(EditorElementTypeV1::Key, key_id, GraphType::Bar), + ], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); +} + +#[test] +fn graph_color_patch_preserves_raw_string_and_other_graph_fields() { + let store = store_with_every_reorder_type(); + let graph = &store.graph_positions["4key"][0]; + let graph_id = graph.position.id.clone(); + let raw_color = "color(display-p3 1 0 0 / 0.5)"; + let ops = [ + patch_graph_color_op(EditorElementTypeV1::Graph, &graph_id, raw_color), + patch_graph_color_op( + EditorElementTypeV1::Graph, + uuid::Uuid::new_v4().to_string(), + "", + ), + ]; + + let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); + let changed = &transition.candidate.graph_positions["4key"][0]; + assert_eq!(changed.graph_color, raw_color); + assert_eq!(changed.stat_type, graph.stat_type); + assert_eq!(changed.graph_type, graph.graph_type); + assert_eq!(changed.graph_speed, graph.graph_speed); + assert_eq!(changed.show_avg_line, graph.show_avg_line); + assert_eq!(changed.position, graph.position); + assert_eq!(transition.changed_fields, [EditorField::GraphPositions]); + assert_eq!( + transition + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); + assert!(replay.changed_fields.is_empty()); + assert_eq!( + replay.op_results[0].status, + EditorOpResultStatusV1::NoChange + ); + + let key_id = store.key_positions["4key"][0].id.clone(); + let error = prepare_editor_ops_transition( + &store, + &[ + patch_hidden_op(EditorElementTypeV1::Key, &key_id, true), + patch_graph_color_op(EditorElementTypeV1::Key, key_id, "#FFFFFF"), + ], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); +} + +#[test] +fn graph_and_knob_literal_patches_are_typed_leaf_intents() { + let mut store = store_with_every_reorder_type(); + let graph_template = store.graph_positions["4key"][0].clone(); + let knob_template = store.knob_positions["4key"][0].clone(); + let graph_ids = (0..3) + .map(|_| uuid::Uuid::new_v4().to_string()) + .collect::>(); + let knob_ids = (0..3) + .map(|_| uuid::Uuid::new_v4().to_string()) + .collect::>(); + store.graph_positions.insert( + "4key".to_string(), + graph_ids + .iter() + .map(|id| GraphPosition { + position: KeyPosition { + id: id.clone(), + graph_animation_enabled: None, + ..graph_template.position.clone() + }, + ..graph_template.clone() + }) + .collect(), + ); + store.knob_positions.insert( + "4key".to_string(), + knob_ids + .iter() + .map(|id| KnobPosition { + position: KeyPosition { + id: id.clone(), + ..knob_template.position.clone() + }, + ..knob_template.clone() + }) + .collect(), + ); + let ops = vec![ + patch_property_op( + EditorElementTypeV1::Graph, + &graph_ids[0], + EditorElementPropertyPatchV1::ShowAvgLine(!graph_template.show_avg_line), + ), + patch_property_op( + EditorElementTypeV1::Graph, + &graph_ids[1], + EditorElementPropertyPatchV1::GraphAnimationEnabled(true), + ), + patch_property_op( + EditorElementTypeV1::Graph, + &graph_ids[2], + EditorElementPropertyPatchV1::GraphSpeed(u32::MAX), + ), + patch_property_op( + EditorElementTypeV1::Knob, + &knob_ids[0], + EditorElementPropertyPatchV1::Reverse(!knob_template.reverse), + ), + patch_property_op( + EditorElementTypeV1::Knob, + &knob_ids[1], + EditorElementPropertyPatchV1::Sensitivity(-7.25), + ), + patch_property_op( + EditorElementTypeV1::Knob, + &knob_ids[2], + EditorElementPropertyPatchV1::AxisId(" HIDA:raw ".to_string()), + ), + patch_property_op( + EditorElementTypeV1::Knob, + uuid::Uuid::new_v4().to_string(), + EditorElementPropertyPatchV1::AxisId("missing-axis".to_string()), + ), + ]; + + let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); + let graphs = &transition.candidate.graph_positions["4key"]; + let knobs = &transition.candidate.knob_positions["4key"]; + assert_eq!(graphs[0].show_avg_line, !graph_template.show_avg_line); + assert_eq!(graphs[0].graph_color, graph_template.graph_color); + assert_eq!(graphs[1].position.graph_animation_enabled, Some(true)); + assert_eq!(graphs[1].graph_speed, graph_template.graph_speed); + assert_eq!(graphs[2].graph_speed, u32::MAX); + assert_eq!(graphs[2].graph_type, graph_template.graph_type); + assert_eq!(knobs[0].reverse, !knob_template.reverse); + assert_eq!(knobs[0].sensitivity, knob_template.sensitivity); + assert_eq!(knobs[1].sensitivity, -7.25); + assert_eq!(knobs[1].axis_id, knob_template.axis_id); + assert_eq!(knobs[2].axis_id, " HIDA:raw "); + assert_eq!(knobs[2].reverse, knob_template.reverse); + assert_eq!(knobs[2].sensitivity, knob_template.sensitivity); + assert_eq!( + transition.changed_fields, + [EditorField::GraphPositions, EditorField::KnobPositions] + ); + assert_eq!( + transition + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); + assert!(replay.changed_fields.is_empty()); + assert_eq!( + replay + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + let key_id = store.key_positions["4key"][0].id.clone(); + for patch in [ + EditorElementPropertyPatchV1::ShowAvgLine(false), + EditorElementPropertyPatchV1::GraphAnimationEnabled(false), + EditorElementPropertyPatchV1::GraphSpeed(0), + EditorElementPropertyPatchV1::Reverse(false), + EditorElementPropertyPatchV1::Sensitivity(0.0), + EditorElementPropertyPatchV1::AxisId(String::new()), + ] { + let error = prepare_editor_ops_transition( + &store, + &[ + patch_hidden_op(EditorElementTypeV1::Key, &key_id, true), + patch_property_op(EditorElementTypeV1::Key, &key_id, patch), + ], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); + } + + let invalid = patch_property_op( + EditorElementTypeV1::Knob, + &knob_ids[0], + EditorElementPropertyPatchV1::Sensitivity(f64::INFINITY), + ); + let error = prepare_editor_ops_transition(&store, &[invalid]).unwrap_err(); + assert_eq!(validation_code(&error), Some("INVALID_NUMBER")); +} + +#[test] +fn inline_style_patch_preserves_raw_option_semantics_for_every_element_type() { + let mut store = store_with_every_reorder_type(); + store.key_positions.get_mut("4key").unwrap()[0].use_inline_styles = None; + store.stat_positions.get_mut("4key").unwrap()[0] + .position + .use_inline_styles = None; + store.graph_positions.get_mut("4key").unwrap()[0] + .position + .use_inline_styles = None; + store.knob_positions.get_mut("4key").unwrap()[0] + .position + .use_inline_styles = None; + let targets = [ + ( + EditorElementTypeV1::Key, + store.key_positions["4key"][0].id.clone(), + ), + ( + EditorElementTypeV1::Stat, + store.stat_positions["4key"][0].position.id.clone(), + ), + ( + EditorElementTypeV1::Graph, + store.graph_positions["4key"][0].position.id.clone(), + ), + ( + EditorElementTypeV1::Knob, + store.knob_positions["4key"][0].position.id.clone(), + ), + ]; + let patch = EditorElementPropertyPatchV1::UseInlineStyles(false); + let mut ops = targets + .iter() + .map(|(element_type, id)| patch_property_op(*element_type, id, patch.clone())) + .collect::>(); + ops.push(patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + patch.clone(), + )); + + let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); + assert_eq!( + transition.candidate.key_positions["4key"][0].use_inline_styles, + Some(false) + ); + assert_eq!( + transition.candidate.stat_positions["4key"][0] + .position + .use_inline_styles, + Some(false) + ); + assert_eq!( + transition.candidate.graph_positions["4key"][0] + .position + .use_inline_styles, + Some(false) + ); + assert_eq!( + transition.candidate.knob_positions["4key"][0] + .position + .use_inline_styles, + Some(false) + ); + assert_eq!( + transition.changed_fields, + [ + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + ] + ); + assert_eq!( + transition + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); + assert!(replay.changed_fields.is_empty()); + assert_eq!( + replay + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + let error = prepare_editor_ops_transition( + &store, + &[ + patch_property_op(EditorElementTypeV1::Key, &targets[0].1, patch.clone()), + patch_property_op(EditorElementTypeV1::Stat, &targets[2].1, patch), + ], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); + assert_eq!(store.key_positions["4key"][0].use_inline_styles, None); +} + +#[test] +fn font_weight_patch_pins_implicit_bold_before_changing_weight() { + let mut store = store_with_every_reorder_type(); + { + let key = &mut store.key_positions.get_mut("4key").unwrap()[0]; + key.font_weight = Some(700); + key.font_bold = None; + key.counter.font_weight = 400; + key.counter.font_bold = None; + } + { + let stat = &mut store.stat_positions.get_mut("4key").unwrap()[0].position; + stat.font_weight = None; + stat.font_bold = None; + } + let key_id = store.key_positions["4key"][0].id.clone(); + let stat_id = store.stat_positions["4key"][0].position.id.clone(); + let ops = vec![ + patch_property_op( + EditorElementTypeV1::Key, + &key_id, + EditorElementPropertyPatchV1::FontWeight(500), + ), + patch_property_op( + EditorElementTypeV1::Key, + &key_id, + EditorElementPropertyPatchV1::CounterFontWeight(700), + ), + patch_property_op( + EditorElementTypeV1::Stat, + &stat_id, + EditorElementPropertyPatchV1::FontWeight(600), + ), + ]; + + let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); + let key = &transition.candidate.key_positions["4key"][0]; + // 레거시 (700, None)은 Bold였으므로 고정하고 굵기만 바꾼다 + assert_eq!(key.font_weight, Some(500)); + assert_eq!(key.font_bold, Some(true)); + // 카운터 (400, None)은 non-bold 고정 - (700, Some(false))라 재시작 시 레거시로 오인되지 않는다 + assert_eq!(key.counter.font_weight, 700); + assert_eq!(key.counter.font_bold, Some(false)); + // 미설정 키 텍스트의 암묵 상태는 기본 Bold + let stat = &transition.candidate.stat_positions["4key"][0].position; + assert_eq!(stat.font_weight, Some(600)); + assert_eq!(stat.font_bold, Some(true)); +} + +#[test] +fn font_literal_patches_preserve_raw_options_across_native_types() { + let mut store = store_with_every_reorder_type(); + store.key_positions.get_mut("4key").unwrap()[0].font_weight = None; + store.key_positions.get_mut("4key").unwrap()[0] + .counter + .font_weight = 123; + store.stat_positions.get_mut("4key").unwrap()[0] + .position + .font_italic = None; + store.stat_positions.get_mut("4key").unwrap()[0] + .position + .counter + .font_italic = true; + store.graph_positions.get_mut("4key").unwrap()[0] + .position + .font_underline = None; + store.graph_positions.get_mut("4key").unwrap()[0] + .position + .counter + .font_underline = true; + store.knob_positions.get_mut("4key").unwrap()[0] + .position + .font_strikethrough = None; + store.knob_positions.get_mut("4key").unwrap()[0] + .position + .counter + .font_strikethrough = true; + let key_id = store.key_positions["4key"][0].id.clone(); + let stat_id = store.stat_positions["4key"][0].position.id.clone(); + let graph_id = store.graph_positions["4key"][0].position.id.clone(); + let knob_id = store.knob_positions["4key"][0].position.id.clone(); + let ops = vec![ + patch_property_op( + EditorElementTypeV1::Key, + &key_id, + EditorElementPropertyPatchV1::FontWeight(400), + ), + patch_property_op( + EditorElementTypeV1::Stat, + &stat_id, + EditorElementPropertyPatchV1::FontItalic(false), + ), + patch_property_op( + EditorElementTypeV1::Graph, + &graph_id, + EditorElementPropertyPatchV1::FontUnderline(false), + ), + patch_property_op( + EditorElementTypeV1::Knob, + &knob_id, + EditorElementPropertyPatchV1::FontStrikethrough(false), + ), + patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + EditorElementPropertyPatchV1::FontWeight(u32::MAX), + ), + ]; + + let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); + assert_eq!( + transition.candidate.key_positions["4key"][0].font_weight, + Some(400) + ); + assert_eq!( + transition.candidate.stat_positions["4key"][0] + .position + .font_italic, + Some(false) + ); + assert_eq!( + transition.candidate.graph_positions["4key"][0] + .position + .font_underline, + Some(false) + ); + assert_eq!( + transition.candidate.knob_positions["4key"][0] + .position + .font_strikethrough, + Some(false) + ); + assert_eq!( + transition.candidate.key_positions["4key"][0] + .counter + .font_weight, + 123 + ); + assert!( + transition.candidate.stat_positions["4key"][0] + .position + .counter + .font_italic + ); + assert!( + transition.candidate.graph_positions["4key"][0] + .position + .counter + .font_underline + ); + assert!( + transition.candidate.knob_positions["4key"][0] + .position + .counter + .font_strikethrough + ); + assert_eq!( + transition.changed_fields, + [ + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + ] + ); + assert_eq!( + transition + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); + assert!(replay.changed_fields.is_empty()); + assert_eq!( + replay + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + let error = prepare_editor_ops_transition( + &store, + &[ + ops[0].clone(), + patch_property_op( + EditorElementTypeV1::Stat, + &graph_id, + EditorElementPropertyPatchV1::FontItalic(true), + ), + ], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); + assert_eq!(store.key_positions["4key"][0].font_weight, None); +} + +#[test] +fn font_family_patch_preserves_nested_counter_and_raw_strings_across_native_types() { + let mut store = store_with_every_reorder_type(); + store.key_positions.get_mut("4key").unwrap()[0] + .counter + .font_family = Some("counter-key".to_string()); + store.stat_positions.get_mut("4key").unwrap()[0] + .position + .counter + .font_family = Some("counter-stat".to_string()); + store.graph_positions.get_mut("4key").unwrap()[0] + .position + .counter + .font_family = Some("counter-graph".to_string()); + store.knob_positions.get_mut("4key").unwrap()[0] + .position + .counter + .font_family = Some("counter-knob".to_string()); + let targets = [ + ( + EditorElementTypeV1::Key, + store.key_positions["4key"][0].id.clone(), + " raw-key ", + ), + ( + EditorElementTypeV1::Stat, + store.stat_positions["4key"][0].position.id.clone(), + "raw-stat", + ), + ( + EditorElementTypeV1::Graph, + store.graph_positions["4key"][0].position.id.clone(), + "raw-graph", + ), + ( + EditorElementTypeV1::Knob, + store.knob_positions["4key"][0].position.id.clone(), + "raw-knob", + ), + ]; + let ops = targets + .iter() + .map(|(element_type, id, font_family)| { + patch_property_op( + *element_type, + id, + EditorElementPropertyPatchV1::FontFamily((*font_family).to_string()), + ) + }) + .chain(std::iter::once(patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + EditorElementPropertyPatchV1::FontFamily("missing".to_string()), + ))) + .collect::>(); + + let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); + assert_eq!( + transition.candidate.key_positions["4key"][0] + .font_family + .as_deref(), + Some(" raw-key ") + ); + assert_eq!( + transition.candidate.stat_positions["4key"][0] + .position + .font_family + .as_deref(), + Some("raw-stat") + ); + assert_eq!( + transition.candidate.graph_positions["4key"][0] + .position + .font_family + .as_deref(), + Some("raw-graph") + ); + assert_eq!( + transition.candidate.knob_positions["4key"][0] + .position + .font_family + .as_deref(), + Some("raw-knob") + ); + assert_eq!( + transition.candidate.key_positions["4key"][0] + .counter + .font_family + .as_deref(), + Some("counter-key") + ); + assert_eq!( + transition.candidate.stat_positions["4key"][0] + .position + .counter + .font_family + .as_deref(), + Some("counter-stat") + ); + assert_eq!( + transition.candidate.graph_positions["4key"][0] + .position + .counter + .font_family + .as_deref(), + Some("counter-graph") + ); + assert_eq!( + transition.candidate.knob_positions["4key"][0] + .position + .counter + .font_family + .as_deref(), + Some("counter-knob") + ); + assert_eq!( + transition.changed_fields, + [ + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + ] + ); + assert_eq!( + transition + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); + assert!(replay.changed_fields.is_empty()); + assert_eq!( + replay + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + let error = prepare_editor_ops_transition( + &store, + &[ + ops[0].clone(), + patch_property_op( + EditorElementTypeV1::Stat, + &targets[2].1, + EditorElementPropertyPatchV1::FontFamily("wrong-type".to_string()), + ), + ], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); + assert_eq!(store.key_positions["4key"][0].font_family, None); +} + +#[test] +fn display_text_patch_materializes_raw_strings_across_native_types() { + let mut store = store_with_every_reorder_type(); + for position in [ + &mut store.key_positions.get_mut("4key").unwrap()[0], + &mut store.stat_positions.get_mut("4key").unwrap()[0].position, + &mut store.graph_positions.get_mut("4key").unwrap()[0].position, + &mut store.knob_positions.get_mut("4key").unwrap()[0].position, + ] { + position.display_text = None; + position.layer_name = Some("layer-sibling".to_string()); + position.font_family = Some("font-sibling".to_string()); + position.counter.font_family = Some("counter-font-sibling".to_string()); + position.inactive_image = Some("image-sibling".to_string()); + } + let targets = [ + ( + EditorElementTypeV1::Key, + store.key_positions["4key"][0].id.clone(), + "", + ), + ( + EditorElementTypeV1::Stat, + store.stat_positions["4key"][0].position.id.clone(), + " raw stat ", + ), + ( + EditorElementTypeV1::Graph, + store.graph_positions["4key"][0].position.id.clone(), + "raw graph", + ), + ( + EditorElementTypeV1::Knob, + store.knob_positions["4key"][0].position.id.clone(), + "raw knob", + ), + ]; + let ops = targets + .iter() + .map(|(element_type, id, display_text)| { + patch_property_op( + *element_type, + id, + EditorElementPropertyPatchV1::DisplayText((*display_text).to_string()), + ) + }) + .chain(std::iter::once(patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + EditorElementPropertyPatchV1::DisplayText("missing".to_string()), + ))) + .collect::>(); + + let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); + let actual = [ + &transition.candidate.key_positions["4key"][0], + &transition.candidate.stat_positions["4key"][0].position, + &transition.candidate.graph_positions["4key"][0].position, + &transition.candidate.knob_positions["4key"][0].position, + ]; + for (position, expected) in actual + .into_iter() + .zip(targets.iter().map(|target| target.2)) + { + assert_eq!(position.display_text.as_deref(), Some(expected)); + assert_eq!(position.layer_name.as_deref(), Some("layer-sibling")); + assert_eq!(position.font_family.as_deref(), Some("font-sibling")); + assert_eq!( + position.counter.font_family.as_deref(), + Some("counter-font-sibling") + ); + assert_eq!(position.inactive_image.as_deref(), Some("image-sibling")); + } + assert_eq!( + transition.changed_fields, + [ + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + ] + ); + assert_eq!( + transition + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); + assert!(replay.changed_fields.is_empty()); + assert_eq!( + replay + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + let error = prepare_editor_ops_transition( + &store, + &[ + ops[0].clone(), + patch_property_op( + EditorElementTypeV1::Stat, + &targets[2].1, + EditorElementPropertyPatchV1::DisplayText("wrong-type".to_string()), + ), + ], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); + assert_eq!(store.key_positions["4key"][0].display_text, None); +} + +#[test] +fn class_name_patch_materializes_raw_strings_across_native_types() { + let mut store = store_with_every_reorder_type(); + for position in [ + &mut store.key_positions.get_mut("4key").unwrap()[0], + &mut store.stat_positions.get_mut("4key").unwrap()[0].position, + &mut store.graph_positions.get_mut("4key").unwrap()[0].position, + &mut store.knob_positions.get_mut("4key").unwrap()[0].position, + ] { + position.class_name = None; + position.display_text = Some("display-sibling".to_string()); + position.layer_name = Some("layer-sibling".to_string()); + position.font_family = Some("font-sibling".to_string()); + position.counter.font_family = Some("counter-font-sibling".to_string()); + } + let targets = [ + ( + EditorElementTypeV1::Key, + store.key_positions["4key"][0].id.clone(), + "", + ), + ( + EditorElementTypeV1::Stat, + store.stat_positions["4key"][0].position.id.clone(), + " raw stat class ", + ), + ( + EditorElementTypeV1::Graph, + store.graph_positions["4key"][0].position.id.clone(), + "raw graph class", + ), + ( + EditorElementTypeV1::Knob, + store.knob_positions["4key"][0].position.id.clone(), + "raw knob class", + ), + ]; + let ops = targets + .iter() + .map(|(element_type, id, class_name)| { + patch_property_op( + *element_type, + id, + EditorElementPropertyPatchV1::ClassName((*class_name).to_string()), + ) + }) + .chain(std::iter::once(patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + EditorElementPropertyPatchV1::ClassName("missing".to_string()), + ))) + .collect::>(); + + let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); + let actual = [ + &transition.candidate.key_positions["4key"][0], + &transition.candidate.stat_positions["4key"][0].position, + &transition.candidate.graph_positions["4key"][0].position, + &transition.candidate.knob_positions["4key"][0].position, + ]; + for (position, expected) in actual + .into_iter() + .zip(targets.iter().map(|target| target.2)) + { + assert_eq!(position.class_name.as_deref(), Some(expected)); + assert_eq!(position.display_text.as_deref(), Some("display-sibling")); + assert_eq!(position.layer_name.as_deref(), Some("layer-sibling")); + assert_eq!(position.font_family.as_deref(), Some("font-sibling")); + assert_eq!( + position.counter.font_family.as_deref(), + Some("counter-font-sibling") + ); + } + assert_eq!( + transition.changed_fields, + [ + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + ] + ); + assert_eq!( + transition + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); + assert!(replay.changed_fields.is_empty()); + assert_eq!( + replay + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + let error = prepare_editor_ops_transition( + &store, + &[ + ops[0].clone(), + patch_property_op( + EditorElementTypeV1::Stat, + &targets[2].1, + EditorElementPropertyPatchV1::ClassName("wrong-type".to_string()), + ), + ], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); + assert_eq!(store.key_positions["4key"][0].class_name, None); +} + +#[test] +fn numeric_style_patches_preserve_raw_f64_ranges_and_siblings() { + let mut store = store_with_every_reorder_type(); + for position in [ + &mut store.key_positions.get_mut("4key").unwrap()[0], + &mut store.stat_positions.get_mut("4key").unwrap()[0].position, + &mut store.graph_positions.get_mut("4key").unwrap()[0].position, + &mut store.knob_positions.get_mut("4key").unwrap()[0].position, + ] { + position.border_width = None; + position.border_radius = None; + position.font_size = None; + position.class_name = Some("class-sibling".to_string()); + position.display_text = Some("display-sibling".to_string()); + position.counter.font_size = 33; + } + let key_id = store.key_positions["4key"][0].id.clone(); + let stat_id = store.stat_positions["4key"][0].position.id.clone(); + let graph_id = store.graph_positions["4key"][0].position.id.clone(); + let knob_id = store.knob_positions["4key"][0].position.id.clone(); + let ops = vec![ + patch_property_op( + EditorElementTypeV1::Key, + &key_id, + EditorElementPropertyPatchV1::BorderWidth(0.0), + ), + patch_property_op( + EditorElementTypeV1::Stat, + &stat_id, + EditorElementPropertyPatchV1::BorderRadius(100.0), + ), + patch_property_op( + EditorElementTypeV1::Graph, + &graph_id, + EditorElementPropertyPatchV1::FontSize(8.0), + ), + patch_property_op( + EditorElementTypeV1::Knob, + &knob_id, + EditorElementPropertyPatchV1::BorderRadius(999.0), + ), + patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + EditorElementPropertyPatchV1::BorderWidth(1.0), + ), + ]; + + let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); + let actual = [ + &transition.candidate.key_positions["4key"][0], + &transition.candidate.stat_positions["4key"][0].position, + &transition.candidate.graph_positions["4key"][0].position, + &transition.candidate.knob_positions["4key"][0].position, + ]; + for position in actual { + assert_eq!(position.class_name.as_deref(), Some("class-sibling")); + assert_eq!(position.display_text.as_deref(), Some("display-sibling")); + assert_eq!(position.counter.font_size, 33); + } + assert_eq!( + transition.candidate.key_positions["4key"][0].border_width, + Some(0.0) + ); + assert_eq!( + transition.candidate.stat_positions["4key"][0] + .position + .border_radius, + Some(100.0) + ); + assert_eq!( + transition.candidate.graph_positions["4key"][0] + .position + .font_size, + Some(8.0) + ); + assert_eq!( + transition.candidate.knob_positions["4key"][0] + .position + .border_radius, + Some(999.0) + ); + assert_eq!( + transition.changed_fields, + [ + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + ] + ); + assert_eq!( + transition + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); + assert!(replay.changed_fields.is_empty()); + assert_eq!( + replay + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + let upper_boundaries = prepare_editor_ops_transition( + &store, + &[ + patch_property_op( + EditorElementTypeV1::Stat, + &stat_id, + EditorElementPropertyPatchV1::BorderWidth(20.0), + ), + patch_property_op( + EditorElementTypeV1::Graph, + &graph_id, + EditorElementPropertyPatchV1::FontSize(72.0), + ), + ], + ) + .unwrap(); + assert_eq!( + upper_boundaries.candidate.stat_positions["4key"][0] + .position + .border_width, + Some(20.0) + ); + assert_eq!( + upper_boundaries.candidate.graph_positions["4key"][0] + .position + .font_size, + Some(72.0) + ); + + let error = prepare_editor_ops_transition( + &store, + &[ + ops[0].clone(), + patch_property_op( + EditorElementTypeV1::Stat, + &graph_id, + EditorElementPropertyPatchV1::FontSize(16.0), + ), + ], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); + assert_eq!(store.key_positions["4key"][0].border_width, None); + + let invalid = [ + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::BorderWidth(-0.1), + "BORDER_WIDTH_OUT_OF_RANGE", + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::BorderWidth(20.1), + "BORDER_WIDTH_OUT_OF_RANGE", + ), + ( + EditorElementTypeV1::Stat, + EditorElementPropertyPatchV1::BorderRadius(100.1), + "BORDER_RADIUS_OUT_OF_RANGE", + ), + ( + EditorElementTypeV1::Knob, + EditorElementPropertyPatchV1::BorderRadius(999.1), + "BORDER_RADIUS_OUT_OF_RANGE", + ), + ( + EditorElementTypeV1::Graph, + EditorElementPropertyPatchV1::FontSize(7.9), + "FONT_SIZE_OUT_OF_RANGE", + ), + ( + EditorElementTypeV1::Graph, + EditorElementPropertyPatchV1::FontSize(72.1), + "FONT_SIZE_OUT_OF_RANGE", + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::BorderWidth(f64::NAN), + "BORDER_WIDTH_OUT_OF_RANGE", + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::FontSize(f64::INFINITY), + "FONT_SIZE_OUT_OF_RANGE", + ), + ]; + for (element_type, patch, expected_code) in invalid { + let error = prepare_editor_ops_transition( + &store, + &[ + ops[0].clone(), + patch_property_op(element_type, uuid::Uuid::new_v4().to_string(), patch), + ], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some(expected_code)); + assert_eq!(store.key_positions["4key"][0].border_width, None); + } +} + +#[test] +fn inactive_image_patch_preserves_asset_siblings_and_raw_empty_across_native_types() { + let mut store = store_with_every_reorder_type(); + for position in [ + &mut store.key_positions.get_mut("4key").unwrap()[0], + &mut store.stat_positions.get_mut("4key").unwrap()[0].position, + &mut store.graph_positions.get_mut("4key").unwrap()[0].position, + &mut store.knob_positions.get_mut("4key").unwrap()[0].position, + ] { + position.inactive_image = None; + position.active_image = Some("active-sibling.png".to_string()); + position.sound_path = Some("sound-sibling.wav".to_string()); + } + let targets = [ + ( + EditorElementTypeV1::Key, + store.key_positions["4key"][0].id.clone(), + ), + ( + EditorElementTypeV1::Stat, + store.stat_positions["4key"][0].position.id.clone(), + ), + ( + EditorElementTypeV1::Graph, + store.graph_positions["4key"][0].position.id.clone(), + ), + ( + EditorElementTypeV1::Knob, + store.knob_positions["4key"][0].position.id.clone(), + ), + ]; + let raw = prepare_editor_ops_transition( + &store, + &[patch_property_op( + EditorElementTypeV1::Key, + &targets[0].1, + EditorElementPropertyPatchV1::InactiveImage(" raw/path.png ".to_string()), + )], + ) + .unwrap(); + assert_eq!( + raw.candidate.key_positions["4key"][0] + .inactive_image + .as_deref(), + Some(" raw/path.png ") + ); + let patch = EditorElementPropertyPatchV1::InactiveImage(String::new()); + let ops = targets + .iter() + .map(|(element_type, id)| patch_property_op(*element_type, id, patch.clone())) + .chain(std::iter::once(patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + patch.clone(), + ))) + .collect::>(); + + let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); + for position in [ + &transition.candidate.key_positions["4key"][0], + &transition.candidate.stat_positions["4key"][0].position, + &transition.candidate.graph_positions["4key"][0].position, + &transition.candidate.knob_positions["4key"][0].position, + ] { + assert_eq!(position.inactive_image.as_deref(), Some("")); + assert_eq!(position.active_image.as_deref(), Some("active-sibling.png")); + assert_eq!(position.sound_path.as_deref(), Some("sound-sibling.wav")); + } + assert_eq!( + transition.changed_fields, + [ + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + ] + ); + assert_eq!( + transition + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); + assert!(replay.changed_fields.is_empty()); + assert_eq!( + replay + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + let error = prepare_editor_ops_transition( + &store, + &[ + ops[0].clone(), + patch_property_op( + EditorElementTypeV1::Stat, + &targets[2].1, + EditorElementPropertyPatchV1::InactiveImage("wrong".to_string()), + ), + ], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); + assert_eq!(store.key_positions["4key"][0].inactive_image, None); +} + +#[test] +fn active_image_patch_is_key_or_knob_only_and_preserves_asset_siblings() { + let mut store = store_with_every_reorder_type(); + store.key_positions.get_mut("4key").unwrap()[0].active_image = None; + store.knob_positions.get_mut("4key").unwrap()[0] + .position + .active_image = None; + for position in [ + &mut store.key_positions.get_mut("4key").unwrap()[0], + &mut store.knob_positions.get_mut("4key").unwrap()[0].position, + ] { + position.inactive_image = Some("idle-sibling.png".to_string()); + position.active_image_fit = Some(crate::models::ImageFit::Contain); + position.active_transparent = true; + position.counter.enabled = false; + } + let key_id = store.key_positions["4key"][0].id.clone(); + let knob_id = store.knob_positions["4key"][0].position.id.clone(); + let missing_id = uuid::Uuid::new_v4().to_string(); + let patch = EditorElementPropertyPatchV1::ActiveImage(String::new()); + let ops = vec![ + patch_property_op(EditorElementTypeV1::Key, &key_id, patch.clone()), + patch_property_op(EditorElementTypeV1::Knob, &knob_id, patch.clone()), + patch_property_op(EditorElementTypeV1::Key, &missing_id, patch.clone()), + ]; + + let raw = prepare_editor_ops_transition( + &store, + &[patch_property_op( + EditorElementTypeV1::Knob, + &knob_id, + EditorElementPropertyPatchV1::ActiveImage(" raw/active.png ".to_string()), + )], + ) + .unwrap(); + assert_eq!( + raw.candidate.knob_positions["4key"][0] + .position + .active_image + .as_deref(), + Some(" raw/active.png ") + ); + + let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); + for position in [ + &transition.candidate.key_positions["4key"][0], + &transition.candidate.knob_positions["4key"][0].position, + ] { + assert_eq!(position.active_image.as_deref(), Some("")); + assert_eq!(position.inactive_image.as_deref(), Some("idle-sibling.png")); + assert_eq!( + position.active_image_fit, + Some(crate::models::ImageFit::Contain) + ); + assert!(position.active_transparent); + assert!(!position.counter.enabled); + } + assert_eq!( + transition.changed_fields, + [EditorField::KeyPositions, EditorField::KnobPositions] + ); + assert_eq!( + transition + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); + assert!(replay.changed_fields.is_empty()); + assert_eq!( + replay + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + for element_type in [EditorElementTypeV1::Stat, EditorElementTypeV1::Graph] { + let error = prepare_editor_ops_transition( + &store, + &[ + ops[0].clone(), + patch_property_op( + element_type, + uuid::Uuid::new_v4().to_string(), + patch.clone(), + ), + ], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); + assert_eq!(store.key_positions["4key"][0].active_image, None); + } +} + +#[test] +fn image_transparency_patches_preserve_opposite_state_and_asset_siblings() { + let mut store = store_with_every_reorder_type(); + for position in [ + &mut store.key_positions.get_mut("4key").unwrap()[0], + &mut store.stat_positions.get_mut("4key").unwrap()[0].position, + &mut store.graph_positions.get_mut("4key").unwrap()[0].position, + &mut store.knob_positions.get_mut("4key").unwrap()[0].position, + ] { + position.idle_transparent = false; + position.active_transparent = false; + position.inactive_image = Some("idle-sibling.png".to_string()); + position.active_image = Some("active-sibling.png".to_string()); + position.image_fit = Some(crate::models::ImageFit::Fill); + position.idle_image_fit = Some(crate::models::ImageFit::Contain); + position.active_image_fit = Some(crate::models::ImageFit::None); + position.counter.enabled = true; + } + let targets = [ + ( + EditorElementTypeV1::Key, + store.key_positions["4key"][0].id.clone(), + ), + ( + EditorElementTypeV1::Stat, + store.stat_positions["4key"][0].position.id.clone(), + ), + ( + EditorElementTypeV1::Graph, + store.graph_positions["4key"][0].position.id.clone(), + ), + ( + EditorElementTypeV1::Knob, + store.knob_positions["4key"][0].position.id.clone(), + ), + ]; + let idle_patch = EditorElementPropertyPatchV1::IdleTransparent(true); + let idle_ops = targets + .iter() + .map(|(element_type, id)| patch_property_op(*element_type, id, idle_patch.clone())) + .chain(std::iter::once(patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + idle_patch.clone(), + ))) + .collect::>(); + + let idle = prepare_editor_ops_transition(&store, &idle_ops).unwrap(); + assert_eq!( + idle.changed_fields, + [ + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + ] + ); + assert_eq!( + idle.op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + for position in [ + &idle.candidate.key_positions["4key"][0], + &idle.candidate.stat_positions["4key"][0].position, + &idle.candidate.graph_positions["4key"][0].position, + &idle.candidate.knob_positions["4key"][0].position, + ] { + assert!(position.idle_transparent); + assert!(!position.active_transparent); + assert_eq!(position.inactive_image.as_deref(), Some("idle-sibling.png")); + assert_eq!(position.active_image.as_deref(), Some("active-sibling.png")); + assert_eq!(position.image_fit, Some(crate::models::ImageFit::Fill)); + assert_eq!( + position.idle_image_fit, + Some(crate::models::ImageFit::Contain) + ); + assert_eq!( + position.active_image_fit, + Some(crate::models::ImageFit::None) + ); + assert!(position.counter.enabled); + } + let idle_replay = prepare_editor_ops_transition(&idle.scratch, &idle_ops).unwrap(); + assert!(idle_replay.changed_fields.is_empty()); + assert_eq!( + idle_replay + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + let active_patch = EditorElementPropertyPatchV1::ActiveTransparent(true); + let active_ops = vec![ + patch_property_op( + EditorElementTypeV1::Key, + &targets[0].1, + active_patch.clone(), + ), + patch_property_op( + EditorElementTypeV1::Knob, + &targets[3].1, + active_patch.clone(), + ), + patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + active_patch.clone(), + ), + ]; + let active = prepare_editor_ops_transition(&idle.scratch, &active_ops).unwrap(); + assert_eq!( + active + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + assert!(active.candidate.key_positions["4key"][0].active_transparent); + assert!( + active.candidate.knob_positions["4key"][0] + .position + .active_transparent + ); + assert!(active.candidate.key_positions["4key"][0].idle_transparent); + assert!( + active.candidate.knob_positions["4key"][0] + .position + .idle_transparent + ); + + for element_type in [EditorElementTypeV1::Stat, EditorElementTypeV1::Graph] { + let error = prepare_editor_ops_transition( + &store, + &[ + idle_ops[0].clone(), + patch_property_op( + element_type, + uuid::Uuid::new_v4().to_string(), + active_patch.clone(), + ), + ], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); + assert!(!store.key_positions["4key"][0].idle_transparent); + } +} + +#[test] +fn image_fit_patches_materialize_state_specific_values_and_preserve_siblings() { + let mut store = store_with_every_reorder_type(); + for position in [ + &mut store.key_positions.get_mut("4key").unwrap()[0], + &mut store.stat_positions.get_mut("4key").unwrap()[0].position, + &mut store.graph_positions.get_mut("4key").unwrap()[0].position, + &mut store.knob_positions.get_mut("4key").unwrap()[0].position, + ] { + position.image_fit = Some(crate::models::ImageFit::Cover); + position.idle_image_fit = None; + position.active_image_fit = None; + position.idle_transparent = true; + position.active_transparent = true; + position.inactive_image = Some("idle-sibling.png".to_string()); + position.active_image = Some("active-sibling.png".to_string()); + position.counter.enabled = true; + } + let targets = [ + ( + EditorElementTypeV1::Key, + store.key_positions["4key"][0].id.clone(), + ), + ( + EditorElementTypeV1::Stat, + store.stat_positions["4key"][0].position.id.clone(), + ), + ( + EditorElementTypeV1::Graph, + store.graph_positions["4key"][0].position.id.clone(), + ), + ( + EditorElementTypeV1::Knob, + store.knob_positions["4key"][0].position.id.clone(), + ), + ]; + let idle_patch = EditorElementPropertyPatchV1::IdleImageFit(crate::models::ImageFit::Cover); + let idle_ops = targets + .iter() + .map(|(element_type, id)| patch_property_op(*element_type, id, idle_patch.clone())) + .chain(std::iter::once(patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + idle_patch.clone(), + ))) + .collect::>(); + + let idle = prepare_editor_ops_transition(&store, &idle_ops).unwrap(); + assert_eq!( + idle.op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + for position in [ + &idle.candidate.key_positions["4key"][0], + &idle.candidate.stat_positions["4key"][0].position, + &idle.candidate.graph_positions["4key"][0].position, + &idle.candidate.knob_positions["4key"][0].position, + ] { + assert_eq!(position.image_fit, Some(crate::models::ImageFit::Cover)); + assert_eq!( + position.idle_image_fit, + Some(crate::models::ImageFit::Cover) + ); + assert_eq!(position.active_image_fit, None); + assert!(position.idle_transparent); + assert!(position.active_transparent); + assert_eq!(position.inactive_image.as_deref(), Some("idle-sibling.png")); + assert_eq!(position.active_image.as_deref(), Some("active-sibling.png")); + assert!(position.counter.enabled); + } + let idle_replay = prepare_editor_ops_transition(&idle.scratch, &idle_ops).unwrap(); + assert!(idle_replay.changed_fields.is_empty()); + assert_eq!( + idle_replay + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + let active_patch = EditorElementPropertyPatchV1::ActiveImageFit(crate::models::ImageFit::Cover); + let active_ops = vec![ + patch_property_op( + EditorElementTypeV1::Key, + &targets[0].1, + active_patch.clone(), + ), + patch_property_op( + EditorElementTypeV1::Knob, + &targets[3].1, + active_patch.clone(), + ), + patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + active_patch.clone(), + ), + ]; + let active = prepare_editor_ops_transition(&idle.scratch, &active_ops).unwrap(); + assert_eq!( + active + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + assert_eq!( + active.candidate.key_positions["4key"][0].active_image_fit, + Some(crate::models::ImageFit::Cover) + ); + assert_eq!( + active.candidate.knob_positions["4key"][0] + .position + .active_image_fit, + Some(crate::models::ImageFit::Cover) + ); + + for element_type in [EditorElementTypeV1::Stat, EditorElementTypeV1::Graph] { + let error = prepare_editor_ops_transition( + &store, + &[ + idle_ops[0].clone(), + patch_property_op( + element_type, + uuid::Uuid::new_v4().to_string(), + active_patch.clone(), + ), + ], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); + assert_eq!(store.key_positions["4key"][0].idle_image_fit, None); + } +} + +#[test] +fn image_transform_patches_seed_noop_reset_and_store_sparse_mode() { + let store = store_with_every_reorder_type(); + let key_id = store.key_positions["4key"][0].id.clone(); + let ops = vec![ + patch_property_op( + EditorElementTypeV1::Key, + &key_id, + EditorElementPropertyPatchV1::ImageMode(ImageMode::Overlay), + ), + patch_property_op( + EditorElementTypeV1::Key, + &key_id, + EditorElementPropertyPatchV1::IdleImageTransform(Some( + ImageTransformLeafPatchV1::OffsetX(125.0), + )), + ), + patch_property_op( + EditorElementTypeV1::Key, + &key_id, + EditorElementPropertyPatchV1::ActiveImageTransform(Some( + ImageTransformLeafPatchV1::Scale(1.5), + )), + ), + ]; + + let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); + let position = &transition.candidate.key_positions["4key"][0]; + assert_eq!(position.image_mode, Some(ImageMode::Overlay)); + assert_eq!( + position.idle_image_transform, + Some(ImageTransform { + offset_x: 125.0, + ..ImageTransform::default() + }) + ); + assert_eq!( + position.active_image_transform, + Some(ImageTransform { + scale: 1.5, + ..ImageTransform::default() + }) + ); + assert_eq!( + transition + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + ] + ); + + let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); + assert!(replay.changed_fields.is_empty()); + assert!(replay + .op_results + .iter() + .all(|result| result.status == EditorOpResultStatusV1::NoChange)); + + let reset_ops = [ + patch_property_op( + EditorElementTypeV1::Key, + &key_id, + EditorElementPropertyPatchV1::ImageMode(ImageMode::Replace), + ), + patch_property_op( + EditorElementTypeV1::Key, + &key_id, + EditorElementPropertyPatchV1::IdleImageTransform(None), + ), + patch_property_op( + EditorElementTypeV1::Key, + &key_id, + EditorElementPropertyPatchV1::ActiveImageTransform(None), + ), + ]; + let reset = prepare_editor_ops_transition(&transition.scratch, &reset_ops).unwrap(); + let position = &reset.candidate.key_positions["4key"][0]; + assert_eq!(position.image_mode, None); + assert_eq!(position.idle_image_transform, None); + assert_eq!(position.active_image_transform, None); + assert!(reset + .op_results + .iter() + .all(|result| result.status == EditorOpResultStatusV1::Applied)); + + let reset_replay = prepare_editor_ops_transition(&reset.scratch, &reset_ops).unwrap(); + assert!(reset_replay.changed_fields.is_empty()); + assert!(reset_replay + .op_results + .iter() + .all(|result| result.status == EditorOpResultStatusV1::NoChange)); +} + +#[test] +fn image_transform_properties_reject_non_keys_and_invalid_ranges() { + let store = store_with_every_reorder_type(); + for patch in [ + EditorElementPropertyPatchV1::ImageMode(ImageMode::Overlay), + EditorElementPropertyPatchV1::IdleImageTransform(Some( + ImageTransformLeafPatchV1::Rotation(45.0), + )), + EditorElementPropertyPatchV1::ActiveImageTransform(None), + ] { + for (element_type, id) in [ + ( + EditorElementTypeV1::Stat, + store.stat_positions["4key"][0].position.id.clone(), + ), + ( + EditorElementTypeV1::Graph, + store.graph_positions["4key"][0].position.id.clone(), + ), + ( + EditorElementTypeV1::Knob, + store.knob_positions["4key"][0].position.id.clone(), + ), + ] { + let error = prepare_editor_ops_transition( + &store, + &[patch_property_op(element_type, id, patch.clone())], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); + } + } + + let key_id = store.key_positions["4key"][0].id.clone(); + for patch in [ + ImageTransformLeafPatchV1::OffsetX(-500.1), + ImageTransformLeafPatchV1::OffsetY(f64::INFINITY), + ImageTransformLeafPatchV1::Rotation(180.1), + ImageTransformLeafPatchV1::Scale(0.09), + ImageTransformLeafPatchV1::Scale(f64::NAN), + ] { + let error = prepare_editor_ops_transition( + &store, + &[patch_property_op( + EditorElementTypeV1::Key, + &key_id, + EditorElementPropertyPatchV1::IdleImageTransform(Some(patch)), + )], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("INVALID_IMAGE_TRANSFORM")); + } +} + +#[test] +fn sound_path_patch_is_key_only_and_preserves_sound_and_asset_siblings() { + let mut store = store_with_every_reorder_type(); + for position in store.key_positions.get_mut("4key").unwrap() { + position.sound_path = None; + position.sound_enabled = Some(true); + position.sound_volume = Some(137.5); + position.inactive_image = Some("idle-sibling.png".to_string()); + position.active_image = Some("active-sibling.png".to_string()); + position.counter.enabled = false; + } + let key_ids = store.key_positions["4key"] + .iter() + .take(2) + .map(|position| position.id.clone()) + .collect::>(); + let patch = EditorElementPropertyPatchV1::SoundPath(String::new()); + let ops = key_ids + .iter() + .map(|id| patch_property_op(EditorElementTypeV1::Key, id, patch.clone())) + .chain(std::iter::once(patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + patch.clone(), + ))) + .collect::>(); + + let raw = prepare_editor_ops_transition( + &store, + &[patch_property_op( + EditorElementTypeV1::Key, + &key_ids[0], + EditorElementPropertyPatchV1::SoundPath(" raw/sound.wav ".to_string()), + )], + ) + .unwrap(); + assert_eq!( + raw.candidate.key_positions["4key"][0].sound_path.as_deref(), + Some(" raw/sound.wav ") + ); + + let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); + for position in transition.candidate.key_positions["4key"].iter().take(2) { + assert_eq!(position.sound_path.as_deref(), Some("")); + assert_eq!(position.sound_enabled, Some(true)); + assert_eq!(position.sound_volume, Some(137.5)); + assert_eq!(position.inactive_image.as_deref(), Some("idle-sibling.png")); + assert_eq!(position.active_image.as_deref(), Some("active-sibling.png")); + assert!(!position.counter.enabled); + } + assert_eq!(transition.changed_fields, [EditorField::KeyPositions]); + assert_eq!( + transition + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); + assert!(replay.changed_fields.is_empty()); + assert_eq!( + replay + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + for element_type in [ + EditorElementTypeV1::Stat, + EditorElementTypeV1::Graph, + EditorElementTypeV1::Knob, + ] { + let error = prepare_editor_ops_transition( + &store, + &[ + ops[0].clone(), + patch_property_op( + element_type, + uuid::Uuid::new_v4().to_string(), + patch.clone(), + ), + ], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); + assert!(store.key_positions["4key"] + .iter() + .all(|position| position.sound_path.is_none())); + } +} + +#[test] +fn sound_enabled_patch_materializes_false_and_preserves_siblings_atomically() { + let mut store = store_with_every_reorder_type(); + let key_ids = store.key_positions["4key"] + .iter() + .take(2) + .map(|position| position.id.clone()) + .collect::>(); + for (index, position) in store + .key_positions + .get_mut("4key") + .unwrap() + .iter_mut() + .take(2) + .enumerate() + { + position.sound_enabled = if index == 0 { None } else { Some(false) }; + position.sound_path = Some("sounds/sibling.wav".to_string()); + position.sound_volume = Some(137.5); + position.inactive_image = Some("idle-sibling.png".to_string()); + position.active_image = Some("active-sibling.png".to_string()); + position.counter.enabled = false; + } + let patch = EditorElementPropertyPatchV1::SoundEnabled(false); + let ops = vec![ + patch_property_op(EditorElementTypeV1::Key, &key_ids[0], patch.clone()), + patch_property_op(EditorElementTypeV1::Key, &key_ids[1], patch.clone()), + patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + patch.clone(), + ), + ]; + + let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); + for position in transition.candidate.key_positions["4key"].iter().take(2) { + assert_eq!(position.sound_enabled, Some(false)); + assert_eq!(position.sound_path.as_deref(), Some("sounds/sibling.wav")); + assert_eq!(position.sound_volume, Some(137.5)); + assert_eq!(position.inactive_image.as_deref(), Some("idle-sibling.png")); + assert_eq!(position.active_image.as_deref(), Some("active-sibling.png")); + assert!(!position.counter.enabled); + } + assert_eq!(transition.changed_fields, [EditorField::KeyPositions]); + assert_eq!( + transition + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); + assert!(replay.changed_fields.is_empty()); + assert_eq!( + replay + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + for element_type in [ + EditorElementTypeV1::Stat, + EditorElementTypeV1::Graph, + EditorElementTypeV1::Knob, + ] { + let error = prepare_editor_ops_transition( + &store, + &[ + ops[0].clone(), + patch_property_op( + element_type, + uuid::Uuid::new_v4().to_string(), + patch.clone(), + ), + ], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); + assert_eq!(store.key_positions["4key"][0].sound_enabled, None); + } +} + +#[test] +fn sound_volume_patch_materializes_default_and_validates_range_atomically() { + let mut store = store_with_every_reorder_type(); + let key_ids = store.key_positions["4key"] + .iter() + .take(2) + .map(|position| position.id.clone()) + .collect::>(); + for (index, position) in store + .key_positions + .get_mut("4key") + .unwrap() + .iter_mut() + .take(2) + .enumerate() + { + position.sound_volume = if index == 0 { None } else { Some(100.0) }; + position.sound_path = Some("sounds/sibling.wav".to_string()); + position.sound_enabled = Some(true); + position.inactive_image = Some("idle-sibling.png".to_string()); + position.active_image = Some("active-sibling.png".to_string()); + position.counter.enabled = false; + } + let patch = EditorElementPropertyPatchV1::SoundVolume(100.0); + let ops = vec![ + patch_property_op(EditorElementTypeV1::Key, &key_ids[0], patch.clone()), + patch_property_op(EditorElementTypeV1::Key, &key_ids[1], patch.clone()), + patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + patch.clone(), + ), + ]; + + let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); + for position in transition.candidate.key_positions["4key"].iter().take(2) { + assert_eq!(position.sound_volume, Some(100.0)); + assert_eq!(position.sound_path.as_deref(), Some("sounds/sibling.wav")); + assert_eq!(position.sound_enabled, Some(true)); + assert_eq!(position.inactive_image.as_deref(), Some("idle-sibling.png")); + assert_eq!(position.active_image.as_deref(), Some("active-sibling.png")); + assert!(!position.counter.enabled); + } + assert_eq!(transition.changed_fields, [EditorField::KeyPositions]); + assert_eq!( + transition + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::TargetMissing, + ] + ); + let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); + assert!(replay.changed_fields.is_empty()); + assert_eq!( + replay + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + for element_type in [ + EditorElementTypeV1::Stat, + EditorElementTypeV1::Graph, + EditorElementTypeV1::Knob, + ] { + let error = prepare_editor_ops_transition( + &store, + &[ + ops[0].clone(), + patch_property_op( + element_type, + uuid::Uuid::new_v4().to_string(), + patch.clone(), + ), + ], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); + assert_eq!(store.key_positions["4key"][0].sound_volume, None); + } + for invalid in [-1.0, 200.1, f64::NAN, f64::INFINITY] { + let error = prepare_editor_ops_transition( + &store, + &[ + ops[0].clone(), + patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + EditorElementPropertyPatchV1::SoundVolume(invalid), + ), + ], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("SOUND_VOLUME_OUT_OF_RANGE")); + assert_eq!(store.key_positions["4key"][0].sound_volume, None); + } + for boundary in [0.0, 200.0] { + prepare_editor_ops_transition( + &store, + &[patch_property_op( + EditorElementTypeV1::Key, + &key_ids[0], + EditorElementPropertyPatchV1::SoundVolume(boundary), + )], + ) + .unwrap(); + } +} + +#[test] +fn counter_boolean_patches_preserve_nested_siblings_and_reject_wrong_types_atomically() { + let mut store = store_with_every_reorder_type(); + let key_id = store.key_positions["4key"][0].id.clone(); + let stat_id = store.stat_positions["4key"][0].position.id.clone(); + let missing_id = uuid::Uuid::new_v4().to_string(); + { + let counter = &mut store.key_positions.get_mut("4key").unwrap()[0].counter; + counter.enabled = true; + counter.placement = crate::models::KeyCounterPlacement::Outside; + counter.animation.enabled = true; + counter.animation.preset_id = Some("builtin-linear".to_string()); + counter.animation.scale = 1.75; + } + { + let counter = &mut store.stat_positions.get_mut("4key").unwrap()[0] + .position + .counter; + counter.enabled = true; + counter.placement = crate::models::KeyCounterPlacement::Outside; + counter.animation.enabled = true; + counter.animation.preset_id = Some("builtin-linear".to_string()); + counter.animation.scale = 1.75; + } + let counter_enabled = EditorElementPropertyPatchV1::CounterEnabled(false); + let animation_enabled = EditorElementPropertyPatchV1::CounterAnimationEnabled(false); + let ops = vec![ + patch_property_op(EditorElementTypeV1::Key, &key_id, counter_enabled.clone()), + patch_property_op( + EditorElementTypeV1::Stat, + &stat_id, + animation_enabled.clone(), + ), + patch_property_op( + EditorElementTypeV1::Key, + &missing_id, + counter_enabled.clone(), + ), + ]; + + let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); + let key_counter = &transition.candidate.key_positions["4key"][0].counter; + assert!(!key_counter.enabled); + assert!(key_counter.animation.enabled); + assert_eq!( + key_counter.animation.preset_id.as_deref(), + Some("builtin-linear") + ); + assert_eq!(key_counter.animation.scale, 1.75); + assert!(matches!( + key_counter.placement, + crate::models::KeyCounterPlacement::Outside + )); + let stat_counter = &transition.candidate.stat_positions["4key"][0] + .position + .counter; + assert!(stat_counter.enabled); + assert!(!stat_counter.animation.enabled); + assert_eq!( + stat_counter.animation.preset_id.as_deref(), + Some("builtin-linear") + ); + assert_eq!(stat_counter.animation.scale, 1.75); + assert!(matches!( + stat_counter.placement, + crate::models::KeyCounterPlacement::Outside + )); + assert_eq!( + transition + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); + assert!(replay.changed_fields.is_empty()); + assert_eq!( + replay + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + let cross_ops = vec![ + patch_property_op(EditorElementTypeV1::Key, &key_id, animation_enabled.clone()), + patch_property_op(EditorElementTypeV1::Stat, &stat_id, counter_enabled.clone()), + patch_property_op( + EditorElementTypeV1::Stat, + uuid::Uuid::new_v4().to_string(), + animation_enabled.clone(), + ), + ]; + let cross = prepare_editor_ops_transition(&store, &cross_ops).unwrap(); + assert!( + !cross.candidate.key_positions["4key"][0] + .counter + .animation + .enabled + ); + assert!( + !cross.candidate.stat_positions["4key"][0] + .position + .counter + .enabled + ); + assert_eq!( + cross + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + let cross_replay = prepare_editor_ops_transition(&cross.scratch, &cross_ops).unwrap(); + assert_eq!( + cross_replay + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + for (element_type, patch) in [ + (EditorElementTypeV1::Graph, counter_enabled), + (EditorElementTypeV1::Knob, animation_enabled), + ] { + let error = prepare_editor_ops_transition( + &store, + &[ + ops[0].clone(), + patch_property_op(element_type, uuid::Uuid::new_v4().to_string(), patch), + ], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); + assert!(store.key_positions["4key"][0].counter.enabled); + assert!( + store.stat_positions["4key"][0] + .position + .counter + .animation + .enabled + ); + } +} + +#[test] +fn counter_layout_patches_change_one_leaf_and_preserve_hidden_siblings_atomically() { + let mut store = store_with_every_reorder_type(); + let key_ids = store.key_positions["4key"] + .iter() + .take(2) + .map(|position| position.id.clone()) + .collect::>(); + let mut second_stat = store.stat_positions["4key"][0].clone(); + second_stat.position.id = uuid::Uuid::new_v4().to_string(); + store + .stat_positions + .get_mut("4key") + .unwrap() + .push(second_stat); + let stat_ids = store.stat_positions["4key"] + .iter() + .take(2) + .map(|position| position.position.id.clone()) + .collect::>(); + for counter in store.key_positions.get_mut("4key").unwrap()[..2] + .iter_mut() + .map(|position| &mut position.counter) + .chain( + store.stat_positions.get_mut("4key").unwrap()[..2] + .iter_mut() + .map(|position| &mut position.position.counter), + ) + { + counter.enabled = false; + counter.placement = crate::models::KeyCounterPlacement::Inside; + counter.align = crate::models::KeyCounterAlign::Bottom; + counter.align_mode = crate::models::KeyCounterAlignMode::Between; + counter.gap = 7; + counter.fill.idle = "fill-sibling".to_string(); + counter.fill.active = "active-fill-sibling".to_string(); + counter.font_family = Some("font-sibling".to_string()); + counter.animation.enabled = false; + counter.animation.preset_id = Some("builtin-linear".to_string()); + counter.animation.scale = 1.75; + } + let originals = [ + store.key_positions["4key"][0].counter.clone(), + store.key_positions["4key"][1].counter.clone(), + store.stat_positions["4key"][0].position.counter.clone(), + store.stat_positions["4key"][1].position.counter.clone(), + ]; + let placement = + EditorElementPropertyPatchV1::CounterPlacement(crate::models::KeyCounterPlacement::Outside); + let align = EditorElementPropertyPatchV1::CounterAlign(crate::models::KeyCounterAlign::Right); + let align_mode = + EditorElementPropertyPatchV1::CounterAlignMode(crate::models::KeyCounterAlignMode::Center); + let gap = EditorElementPropertyPatchV1::CounterGap(u32::MAX); + let ops = vec![ + patch_property_op(EditorElementTypeV1::Key, &key_ids[0], placement.clone()), + patch_property_op(EditorElementTypeV1::Key, &key_ids[1], align.clone()), + patch_property_op(EditorElementTypeV1::Stat, &stat_ids[0], align_mode.clone()), + patch_property_op(EditorElementTypeV1::Stat, &stat_ids[1], gap.clone()), + patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + gap.clone(), + ), + ]; + + let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); + let actual = [ + transition.candidate.key_positions["4key"][0] + .counter + .clone(), + transition.candidate.key_positions["4key"][1] + .counter + .clone(), + transition.candidate.stat_positions["4key"][0] + .position + .counter + .clone(), + transition.candidate.stat_positions["4key"][1] + .position + .counter + .clone(), + ]; + let mut expected = originals.clone(); + expected[0].placement = crate::models::KeyCounterPlacement::Outside; + expected[1].align = crate::models::KeyCounterAlign::Right; + expected[2].align_mode = crate::models::KeyCounterAlignMode::Center; + expected[3].gap = u32::MAX; + assert_eq!(actual, expected); + assert!(matches!( + actual[0].align_mode, + crate::models::KeyCounterAlignMode::Between + )); + assert_eq!( + transition.changed_fields, + [EditorField::KeyPositions, EditorField::StatPositions] + ); + assert_eq!( + transition + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); + assert!(replay.changed_fields.is_empty()); + assert_eq!( + replay + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + for (element_type, patch) in [ + (EditorElementTypeV1::Graph, placement), + (EditorElementTypeV1::Knob, align), + (EditorElementTypeV1::Graph, align_mode), + (EditorElementTypeV1::Knob, gap), + ] { + let error = prepare_editor_ops_transition( + &store, + &[ + ops[0].clone(), + patch_property_op(element_type, uuid::Uuid::new_v4().to_string(), patch), + ], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); + assert_eq!(store.key_positions["4key"][0].counter, originals[0]); + } +} + +#[test] +fn counter_typography_patches_preserve_siblings_and_validate_types_and_ranges_atomically() { + let mut store = store_with_every_reorder_type(); + let key_ids = store.key_positions["4key"] + .iter() + .take(2) + .map(|position| position.id.clone()) + .collect::>(); + let first_stat = store.stat_positions["4key"][0].clone(); + for _ in 0..3 { + let mut stat = first_stat.clone(); + stat.position.id = uuid::Uuid::new_v4().to_string(); + store.stat_positions.get_mut("4key").unwrap().push(stat); + } + let stat_ids = store.stat_positions["4key"] + .iter() + .take(4) + .map(|position| position.position.id.clone()) + .collect::>(); + for counter in store.key_positions.get_mut("4key").unwrap()[..2] + .iter_mut() + .map(|position| &mut position.counter) + .chain( + store.stat_positions.get_mut("4key").unwrap()[..4] + .iter_mut() + .map(|position| &mut position.position.counter), + ) + { + counter.font_size = 16; + counter.font_weight = 400; + counter.font_italic = false; + counter.font_underline = false; + counter.font_strikethrough = false; + counter.font_family = Some("font-sibling".to_string()); + counter.fill.idle = "fill-sibling".to_string(); + counter.fill.active = "active-fill-sibling".to_string(); + counter.placement = crate::models::KeyCounterPlacement::Outside; + counter.animation.preset_id = Some("builtin-linear".to_string()); + } + store.stat_positions.get_mut("4key").unwrap()[3] + .position + .counter + .font_family = None; + store.stat_positions.get_mut("4key").unwrap()[3] + .position + .font_family = Some("top-level-font-sibling".to_string()); + let originals = [ + store.key_positions["4key"][0].counter.clone(), + store.key_positions["4key"][1].counter.clone(), + store.stat_positions["4key"][0].position.counter.clone(), + store.stat_positions["4key"][1].position.counter.clone(), + store.stat_positions["4key"][2].position.counter.clone(), + store.stat_positions["4key"][3].position.counter.clone(), + ]; + let patches = [ + EditorElementPropertyPatchV1::CounterFontSize(72), + EditorElementPropertyPatchV1::CounterFontWeight(900), + EditorElementPropertyPatchV1::CounterFontItalic(true), + EditorElementPropertyPatchV1::CounterFontUnderline(true), + EditorElementPropertyPatchV1::CounterFontStrikethrough(true), + EditorElementPropertyPatchV1::CounterFontFamily(" raw-counter-font ".to_string()), + ]; + let ops = vec![ + patch_property_op(EditorElementTypeV1::Key, &key_ids[0], patches[0].clone()), + patch_property_op(EditorElementTypeV1::Key, &key_ids[1], patches[1].clone()), + patch_property_op(EditorElementTypeV1::Stat, &stat_ids[0], patches[2].clone()), + patch_property_op(EditorElementTypeV1::Stat, &stat_ids[1], patches[3].clone()), + patch_property_op(EditorElementTypeV1::Stat, &stat_ids[2], patches[4].clone()), + patch_property_op(EditorElementTypeV1::Stat, &stat_ids[3], patches[5].clone()), + patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + patches[0].clone(), + ), + ]; + + let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); + let actual = [ + transition.candidate.key_positions["4key"][0] + .counter + .clone(), + transition.candidate.key_positions["4key"][1] + .counter + .clone(), + transition.candidate.stat_positions["4key"][0] + .position + .counter + .clone(), + transition.candidate.stat_positions["4key"][1] + .position + .counter + .clone(), + transition.candidate.stat_positions["4key"][2] + .position + .counter + .clone(), + transition.candidate.stat_positions["4key"][3] + .position + .counter + .clone(), + ]; + let mut expected = originals.clone(); + expected[0].font_size = 72; + expected[1].font_weight = 900; + expected[2].font_italic = true; + expected[3].font_underline = true; + expected[4].font_strikethrough = true; + expected[5].font_family = Some(" raw-counter-font ".to_string()); + assert_eq!(actual, expected); + assert_eq!( + transition.candidate.stat_positions["4key"][3] + .position + .font_family + .as_deref(), + Some("top-level-font-sibling") + ); + assert_eq!( + transition.changed_fields, + [EditorField::KeyPositions, EditorField::StatPositions] + ); + assert_eq!( + transition + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); + assert!(replay.changed_fields.is_empty()); + assert_eq!( + replay + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + for (element_type, patch) in [ + (EditorElementTypeV1::Graph, patches[0].clone()), + (EditorElementTypeV1::Knob, patches[1].clone()), + (EditorElementTypeV1::Graph, patches[2].clone()), + (EditorElementTypeV1::Knob, patches[3].clone()), + (EditorElementTypeV1::Graph, patches[4].clone()), + (EditorElementTypeV1::Knob, patches[5].clone()), + ] { + let error = prepare_editor_ops_transition( + &store, + &[ + ops[0].clone(), + patch_property_op(element_type, uuid::Uuid::new_v4().to_string(), patch), + ], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); + assert_eq!(store.key_positions["4key"][0].counter, originals[0]); + } + + for (patch, code) in [ + ( + EditorElementPropertyPatchV1::CounterFontSize(7), + "COUNTER_FONT_SIZE_OUT_OF_RANGE", + ), + ( + EditorElementPropertyPatchV1::CounterFontSize(73), + "COUNTER_FONT_SIZE_OUT_OF_RANGE", + ), + ( + EditorElementPropertyPatchV1::CounterFontWeight(99), + "COUNTER_FONT_WEIGHT_OUT_OF_RANGE", + ), + ( + EditorElementPropertyPatchV1::CounterFontWeight(901), + "COUNTER_FONT_WEIGHT_OUT_OF_RANGE", + ), + ] { + let error = prepare_editor_ops_transition( + &store, + &[ + ops[0].clone(), + patch_property_op(EditorElementTypeV1::Stat, &stat_ids[0], patch), + ], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some(code)); + assert_eq!(store.key_positions["4key"][0].counter, originals[0]); + } + + let lower_boundaries = prepare_editor_ops_transition( + &store, + &[ + patch_property_op( + EditorElementTypeV1::Key, + &key_ids[0], + EditorElementPropertyPatchV1::CounterFontSize(8), + ), + patch_property_op( + EditorElementTypeV1::Stat, + &stat_ids[0], + EditorElementPropertyPatchV1::CounterFontWeight(100), + ), + ], + ) + .unwrap(); + assert_eq!( + lower_boundaries.candidate.key_positions["4key"][0] + .counter + .font_size, + 8 + ); + assert_eq!( + lower_boundaries.candidate.stat_positions["4key"][0] + .position + .counter + .font_weight, + 100 + ); +} + +#[test] +fn counter_fill_intents_apply_atomic_pairs_and_reject_noncanonical_plans() { + let mut store = store_with_every_reorder_type(); + let key_ids = store.key_positions["4key"] + .iter() + .take(2) + .map(|position| position.id.clone()) + .collect::>(); + let stat_id = store.stat_positions["4key"][0].position.id.clone(); + for counter in store.key_positions.get_mut("4key").unwrap()[..2] + .iter_mut() + .map(|position| &mut position.counter) + .chain(std::iter::once( + &mut store.stat_positions.get_mut("4key").unwrap()[0] + .position + .counter, + )) + { + counter.fill.idle = "idle-before".to_string(); + counter.fill.active = "active-before".to_string(); + counter.fill_idle_gradient = Some(GradientSpec::from_canonical_parts( + 15.0, + vec![ + crate::models::GradientStop { + color: "old-idle".to_string(), + pos: 0.0, + }, + crate::models::GradientStop { + color: "old-idle-end".to_string(), + pos: 1.0, + }, + ], + )); + counter.fill_active_gradient = Some(GradientSpec::from_canonical_parts( + 30.0, + vec![ + crate::models::GradientStop { + color: "old-active".to_string(), + pos: 0.0, + }, + crate::models::GradientStop { + color: "old-active-end".to_string(), + pos: 1.0, + }, + ], + )); + counter.gap = 19; + counter.font_family = Some("font-sibling".to_string()); + counter.animation.preset_id = Some("builtin-linear".to_string()); + } + let originals = [ + store.key_positions["4key"][0].counter.clone(), + store.key_positions["4key"][1].counter.clone(), + store.stat_positions["4key"][0].position.counter.clone(), + ]; + let idle_gradient = counter_fill_gradient( + "rgba(170,187,204,1)", + 45.0, + &[("#ABC", 0.0), ("#112233", 1.0)], + ); + let active_gradient = counter_fill_gradient( + "rgba(1,2,3,0.5)", + 90.0, + &[("rgba(1, 2, 3, 0.5)", 0.0), ("transparent", 1.0)], + ); + let ops = vec![ + patch_property_op( + EditorElementTypeV1::Key, + &key_ids[0], + EditorElementPropertyPatchV1::CounterFillIdle(idle_gradient.clone()), + ), + patch_property_op( + EditorElementTypeV1::Key, + &key_ids[1], + EditorElementPropertyPatchV1::CounterFillActive(active_gradient.clone()), + ), + patch_property_op( + EditorElementTypeV1::Stat, + &stat_id, + EditorElementPropertyPatchV1::CounterFillIdle(counter_fill_solid(" raw solid ")), + ), + patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + EditorElementPropertyPatchV1::CounterFillIdle(counter_fill_solid("missing")), + ), + ]; + + let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); + assert_eq!( + transition.changed_fields, + [EditorField::KeyPositions, EditorField::StatPositions] + ); + assert_eq!( + transition + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + let actual = [ + transition.candidate.key_positions["4key"][0] + .counter + .clone(), + transition.candidate.key_positions["4key"][1] + .counter + .clone(), + transition.candidate.stat_positions["4key"][0] + .position + .counter + .clone(), + ]; + let mut expected = originals.clone(); + expected[0].fill.idle = "rgba(170,187,204,1)".to_string(); + expected[0].fill_idle_gradient = match &idle_gradient { + EditorCounterFillIntentV1::Gradient(intent) => Some(intent.gradient.to_gradient_spec()), + EditorCounterFillIntentV1::Solid(_) => unreachable!(), + }; + expected[1].fill.active = "rgba(1,2,3,0.5)".to_string(); + expected[1].fill_active_gradient = match &active_gradient { + EditorCounterFillIntentV1::Gradient(intent) => Some(intent.gradient.to_gradient_spec()), + EditorCounterFillIntentV1::Solid(_) => unreachable!(), + }; + expected[2].fill.idle = " raw solid ".to_string(); + expected[2].fill_idle_gradient = None; + assert_eq!(actual, expected); + assert_eq!(actual[0].fill.active, originals[0].fill.active); + assert_eq!( + actual[0].fill_active_gradient, + originals[0].fill_active_gradient + ); + assert_eq!(actual[1].fill.idle, originals[1].fill.idle); + assert_eq!( + actual[1].fill_idle_gradient, + originals[1].fill_idle_gradient + ); + + let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); + assert!(replay.changed_fields.is_empty()); + assert_eq!( + replay + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + for (invalid, code) in [ + ( + counter_fill_gradient("#ABC", 45.0, &[("#ABC", 0.0), ("#fff", 1.0)]), + "COUNTER_FILL_COLOR_GRADIENT_MISMATCH", + ), + ( + counter_fill_gradient("rgba(170,187,204,1)", -0.0, &[("#ABC", 0.0), ("#fff", 1.0)]), + "INVALID_PAINT_GRADIENT", + ), + ] { + let error = prepare_editor_ops_transition( + &store, + &[ + ops[0].clone(), + patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + EditorElementPropertyPatchV1::CounterFillIdle(invalid), + ), + ], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some(code)); + assert_eq!(store.key_positions["4key"][0].counter, originals[0]); + } + + for (element_type, patch) in [ + ( + EditorElementTypeV1::Graph, + EditorElementPropertyPatchV1::CounterFillIdle(counter_fill_solid("idle")), + ), + ( + EditorElementTypeV1::Knob, + EditorElementPropertyPatchV1::CounterFillIdle(counter_fill_solid("idle")), + ), + ( + EditorElementTypeV1::Stat, + EditorElementPropertyPatchV1::CounterFillActive(counter_fill_solid("active")), + ), + ] { + let error = prepare_editor_ops_transition( + &store, + &[ + ops[0].clone(), + patch_property_op(element_type, uuid::Uuid::new_v4().to_string(), patch), + ], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); + assert_eq!(store.key_positions["4key"][0].counter, originals[0]); + } +} + +#[test] +fn counter_animation_preset_patch_uses_current_library_and_preserves_unmasked_fields() { + let mut store = store_with_every_reorder_type(); + let preset = CounterAnimationPreset { + id: "user-current".to_string(), + name: "Current".to_string(), + source: crate::models::CounterAnimationSource::User, + label_key: None, + bezier: [0.1, 0.2, 0.8, 0.9], + scale: 1.25, + duration_ms: 420, + }; + store.counter_animation_presets.push(preset.clone()); + let key_id = store.key_positions["4key"][0].id.clone(); + let stat_id = store.stat_positions["4key"][0].position.id.clone(); + let missing_id = uuid::Uuid::new_v4().to_string(); + { + let animation = &mut store.key_positions.get_mut("4key").unwrap()[0] + .counter + .animation; + animation.enabled = false; + animation.preset_id = Some("builtin-ease-out".to_string()); + animation.bezier = [0.3, 0.4, 0.5, 0.6]; + animation.scale = 0.75; + animation.duration_ms = 777; + } + let partial = EditorElementPropertyPatchV1::CounterAnimationPreset( + EditorCounterAnimationPresetIntentV1 { + preset_id: preset.id.clone(), + apply_preset_id: None, + bezier: None, + scale: Some(preset.scale), + duration_ms: None, + }, + ); + let full = EditorElementPropertyPatchV1::CounterAnimationPreset( + EditorCounterAnimationPresetIntentV1 { + preset_id: preset.id.clone(), + apply_preset_id: Some(true), + bezier: Some(preset.bezier), + scale: Some(preset.scale), + duration_ms: Some(preset.duration_ms), + }, + ); + let ops = vec![ + patch_property_op(EditorElementTypeV1::Key, &key_id, partial.clone()), + patch_property_op(EditorElementTypeV1::Stat, &stat_id, full.clone()), + patch_property_op(EditorElementTypeV1::Key, &missing_id, full.clone()), + ]; + + let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); + let key_animation = &transition.candidate.key_positions["4key"][0] + .counter + .animation; + assert!(!key_animation.enabled); + assert_eq!(key_animation.preset_id.as_deref(), Some("builtin-ease-out")); + assert_eq!(key_animation.bezier, [0.3, 0.4, 0.5, 0.6]); + assert_eq!(key_animation.scale, preset.scale); + assert_eq!(key_animation.duration_ms, 777); + let stat_animation = &transition.candidate.stat_positions["4key"][0] + .position + .counter + .animation; + assert_eq!( + stat_animation.preset_id.as_deref(), + Some(preset.id.as_str()) + ); + assert_eq!(stat_animation.bezier, preset.bezier); + assert_eq!(stat_animation.scale, preset.scale); + assert_eq!(stat_animation.duration_ms, preset.duration_ms); + assert_eq!( + transition + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); + assert!(replay.changed_fields.is_empty()); + assert_eq!( + replay + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + let id_only = EditorElementPropertyPatchV1::CounterAnimationPreset( + EditorCounterAnimationPresetIntentV1 { + preset_id: preset.id.clone(), + apply_preset_id: None, + bezier: None, + scale: None, + duration_ms: None, + }, + ); + prepare_editor_ops_transition( + &store, + &[patch_property_op( + EditorElementTypeV1::Key, + &key_id, + id_only, + )], + ) + .unwrap(); + + let stale = EditorElementPropertyPatchV1::CounterAnimationPreset( + EditorCounterAnimationPresetIntentV1 { + preset_id: preset.id.clone(), + apply_preset_id: None, + bezier: Some(preset.bezier), + scale: Some(preset.scale + 0.01), + duration_ms: Some(preset.duration_ms), + }, + ); + let error = prepare_editor_ops_transition( + &store, + &[ + ops[0].clone(), + patch_property_op(EditorElementTypeV1::Stat, &stat_id, stale), + ], + ) + .unwrap_err(); + assert_eq!( + validation_code(&error), + Some("COUNTER_ANIMATION_PRESET_MISMATCH") + ); + assert_eq!( + store.key_positions["4key"][0] + .counter + .animation + .preset_id + .as_deref(), + Some("builtin-ease-out") + ); + + let missing_preset = EditorElementPropertyPatchV1::CounterAnimationPreset( + EditorCounterAnimationPresetIntentV1 { + preset_id: "user-deleted".to_string(), + apply_preset_id: Some(true), + bezier: None, + scale: None, + duration_ms: None, + }, + ); + let error = prepare_editor_ops_transition( + &store, + &[patch_property_op( + EditorElementTypeV1::Key, + &key_id, + missing_preset, + )], + ) + .unwrap_err(); + assert_eq!( + validation_code(&error), + Some("COUNTER_ANIMATION_PRESET_NOT_FOUND") + ); + + for invalid in [ + EditorCounterAnimationPresetIntentV1 { + preset_id: preset.id.clone(), + apply_preset_id: None, + bezier: Some([f64::NAN, 0.2, 0.8, 0.9]), + scale: None, + duration_ms: None, + }, + EditorCounterAnimationPresetIntentV1 { + preset_id: preset.id.clone(), + apply_preset_id: None, + bezier: Some([-0.1, 0.2, 0.8, 0.9]), + scale: None, + duration_ms: None, + }, + EditorCounterAnimationPresetIntentV1 { + preset_id: preset.id.clone(), + apply_preset_id: None, + bezier: None, + scale: Some(f64::INFINITY), + duration_ms: None, + }, + EditorCounterAnimationPresetIntentV1 { + preset_id: preset.id.clone(), + apply_preset_id: None, + bezier: None, + scale: None, + duration_ms: Some(0), + }, + EditorCounterAnimationPresetIntentV1 { + preset_id: preset.id.clone(), + apply_preset_id: None, + bezier: None, + scale: None, + duration_ms: Some(5001), + }, + ] { + let error = prepare_editor_ops_transition( + &store, + &[patch_property_op( + EditorElementTypeV1::Key, + &key_id, + EditorElementPropertyPatchV1::CounterAnimationPreset(invalid), + )], + ) + .unwrap_err(); + assert_eq!( + validation_code(&error), + Some("INVALID_COUNTER_ANIMATION_PRESET_VALUE") + ); + } + + for element_type in [EditorElementTypeV1::Graph, EditorElementTypeV1::Knob] { + let error = prepare_editor_ops_transition( + &store, + &[patch_property_op( + element_type, + uuid::Uuid::new_v4().to_string(), + full.clone(), + )], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); + } +} + +#[test] +fn stat_type_patch_is_stat_only_and_preserves_embedded_position() { + let mut store = store_with_every_reorder_type(); + store.graph_positions.get_mut("4key").unwrap()[0].stat_type = GraphStatType::KpsMax; + let stat = store.stat_positions["4key"][0].clone(); + let graph = store.graph_positions["4key"][0].clone(); + let stat_id = stat.position.id.clone(); + let missing_id = uuid::Uuid::new_v4().to_string(); + let patch = EditorElementPropertyPatchV1::StatType(StatType::Total); + let ops = vec![ + patch_property_op(EditorElementTypeV1::Stat, &stat_id, patch.clone()), + patch_property_op(EditorElementTypeV1::Stat, missing_id, patch.clone()), + ]; + + let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); + assert_eq!( + transition.candidate.stat_positions["4key"][0].stat_type, + StatType::Total + ); + assert_eq!( + transition.candidate.stat_positions["4key"][0].position, + stat.position + ); + assert_eq!(transition.candidate.graph_positions["4key"][0], graph); + assert_eq!(transition.changed_fields, [EditorField::StatPositions]); + assert_eq!( + transition + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); + assert!(replay.changed_fields.is_empty()); + assert_eq!( + replay + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + let error = prepare_editor_ops_transition( + &store, + &[ + ops[0].clone(), + patch_property_op(EditorElementTypeV1::Graph, &stat_id, patch), + ], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); + assert_eq!(store.stat_positions["4key"][0], stat); + assert_eq!(store.graph_positions["4key"][0], graph); +} + +#[test] +fn note_literal_patches_are_key_only_and_preserve_border_side_raw_state() { + let mut store = base_store(); + let ids = store + .key_positions + .values() + .flat_map(|positions| positions.iter().map(|position| position.id.clone())) + .take(5) + .collect::>(); + assert_eq!(ids.len(), 5); + let legacy_id = store + .key_positions + .values_mut() + .flat_map(|positions| positions.iter_mut()) + .find(|position| !ids.contains(&position.id)) + .map(|position| { + position.note_border_side = Some("diagonal".to_string()); + position.id.clone() + }) + .expect("default modes contain an untargeted key"); + let ops = vec![ + patch_property_op( + EditorElementTypeV1::Key, + &ids[0], + EditorElementPropertyPatchV1::NoteEffectEnabled(false), + ), + patch_property_op( + EditorElementTypeV1::Key, + &ids[1], + EditorElementPropertyPatchV1::NoteGlowEnabled(true), + ), + patch_property_op( + EditorElementTypeV1::Key, + &ids[2], + EditorElementPropertyPatchV1::NoteAutoYCorrection(false), + ), + patch_property_op( + EditorElementTypeV1::Key, + &ids[3], + EditorElementPropertyPatchV1::NoteAlignment(crate::models::NoteAlignment::Right), + ), + patch_property_op( + EditorElementTypeV1::Key, + &ids[4], + EditorElementPropertyPatchV1::NoteBorderSide( + crate::models::EditorNoteBorderSideV1::All, + ), + ), + patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + EditorElementPropertyPatchV1::NoteGlowEnabled(true), + ), + ]; + + let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); + let changed = transition + .candidate + .key_positions + .values() + .flat_map(|positions| positions.iter()) + .map(|position| (position.id.as_str(), position)) + .collect::>(); + assert!(!changed[ids[0].as_str()].note_effect_enabled); + assert!(changed[ids[1].as_str()].note_glow_enabled); + assert!(!changed[ids[2].as_str()].note_auto_y_correction); + assert_eq!( + changed[ids[3].as_str()].note_alignment, + crate::models::NoteAlignment::Right + ); + assert_eq!( + changed[ids[4].as_str()].note_border_side.as_deref(), + Some("all") + ); + assert_eq!( + changed[legacy_id.as_str()].note_border_side.as_deref(), + Some("diagonal") + ); + assert_eq!(transition.changed_fields, [EditorField::KeyPositions]); + assert_eq!( + transition + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); + assert!(replay.changed_fields.is_empty()); + assert_eq!( + replay + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + for (element_type, id) in [ + (EditorElementTypeV1::Stat, ids[1].clone()), + (EditorElementTypeV1::Graph, uuid::Uuid::new_v4().to_string()), + ] { + let error = prepare_editor_ops_transition( + &store, + &[ + ops[0].clone(), + patch_property_op( + element_type, + id, + EditorElementPropertyPatchV1::NoteGlowEnabled(true), + ), + ], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); + } + assert!(store.key_positions.values().flatten().all(|position| { + position.note_effect_enabled + && !position.note_glow_enabled + && position.note_auto_y_correction + && (position.id == legacy_id || position.note_border_side.is_none()) + })); +} + +#[test] +fn note_glow_sync_enable_mirrors_body_paint_and_replays_as_no_change() { + let mut store = base_store(); + let id = store.key_positions["4key"][0].id.clone(); + let position = &mut store.key_positions.get_mut("4key").unwrap()[0]; + position.note_color = NoteColor::Gradient { + top: "#112233".to_string(), + bottom: "#445566".to_string(), + }; + position.note_gradient = serde_json::from_value(serde_json::json!({ + "angle": 180, + "stops": [ + { "color": "#112233", "pos": 0 }, + { "color": "rgba(68, 85, 102, 0.5)", "pos": 1 } + ] + })) + .unwrap(); + position.note_opacity = 80; + position.note_opacity_top = Some(80); + position.note_opacity_bottom = Some(40); + position.note_glow_color = Some(NoteColor::Solid("stale".to_string())); + position.note_glow_opacity = 70; + + let op = patch_property_op( + EditorElementTypeV1::Key, + &id, + EditorElementPropertyPatchV1::NoteGlowSyncPaint(true), + ); + let transition = prepare_editor_ops_transition(&store, std::slice::from_ref(&op)).unwrap(); + let changed = &transition.candidate.key_positions["4key"][0]; + assert!(changed.note_glow_sync_paint); + assert_note_body_glow_paint_equal(changed); + assert_eq!( + transition.op_results[0].status, + EditorOpResultStatusV1::Applied + ); + + let replay = prepare_editor_ops_transition(&transition.scratch, &[op]).unwrap(); + assert_eq!( + replay.op_results[0].status, + EditorOpResultStatusV1::NoChange + ); +} + +#[test] +fn synced_note_paint_variants_mirror_every_body_paint_field() { + let mut store = base_store(); + let id = store.key_positions["4key"][0].id.clone(); + let position = &mut store.key_positions.get_mut("4key").unwrap()[0]; + position.note_glow_sync_paint = true; + position.note_color = NoteColor::Gradient { + top: "#010203".to_string(), + bottom: "#040506".to_string(), + }; + position.note_gradient = serde_json::from_value(serde_json::json!({ + "angle": 45, + "stops": [ + { "color": "#010203", "pos": 0 }, + { "color": "#040506", "pos": 1 } + ] + })) + .unwrap(); + position.note_opacity = 90; + position.note_opacity_top = Some(90); + position.note_opacity_bottom = Some(90); + assert!(position.mirror_note_body_to_glow()); + + let descriptor = EditorElementPropertyPatchV1::NotePaint(EditorNotePaintIntentV1::Descriptor( + crate::models::EditorNotePaintDescriptorIntentV1 { + color: EditorNoteColorV1::Gradient(crate::models::EditorNoteGradientColorV1 { + kind: crate::models::EditorNoteGradientColorKindV1::Gradient, + top: "#112233".to_string(), + bottom: "#445566".to_string(), + }), + opacity: 60, + gradient: Some(crate::models::EditorPaintGradientV1 { + angle: 90.0, + stops: vec![ + crate::models::EditorPaintGradientStopV1 { + color: "#112233".to_string(), + pos: 0.0, + }, + crate::models::EditorPaintGradientStopV1 { + color: "rgba(68, 85, 102, 0.5)".to_string(), + pos: 1.0, + }, + ], + }), + }, + )); + let color = EditorElementPropertyPatchV1::NotePaint(EditorNotePaintIntentV1::Color( + crate::models::EditorNotePaintColorIntentV1 { + color: EditorNoteColorV1::Solid("#AABBCC".to_string()), + }, + )); + let opacity = EditorElementPropertyPatchV1::NotePaint(EditorNotePaintIntentV1::Opacity( + crate::models::EditorNotePaintOpacityIntentV1 { opacity: 55 }, + )); + let gradient_opacity = + EditorElementPropertyPatchV1::NotePaint(EditorNotePaintIntentV1::GradientOpacity( + crate::models::EditorNotePaintGradientOpacityIntentV1 { + opacity: 70, + opacity_top: 65, + opacity_bottom: 35, + }, + )); + + for patch in [descriptor, color, opacity, gradient_opacity] { + let transition = prepare_editor_ops_transition( + &store, + &[patch_property_op(EditorElementTypeV1::Key, &id, patch)], + ) + .unwrap(); + let changed = &transition.candidate.key_positions["4key"][0]; + assert_note_body_glow_paint_equal(changed); + assert_eq!( + transition.op_results[0].status, + EditorOpResultStatusV1::Applied + ); + } +} + +#[test] +fn synced_note_glow_paint_rejects_the_whole_transition() { + let mut store = base_store(); + let id = store.key_positions["4key"][0].id.clone(); + let position = &mut store.key_positions.get_mut("4key").unwrap()[0]; + position.note_glow_sync_paint = true; + assert!(position.mirror_note_body_to_glow()); + let original = store.clone(); + + let error = prepare_editor_ops_transition( + &store, + &[ + patch_property_op( + EditorElementTypeV1::Key, + &id, + EditorElementPropertyPatchV1::Hidden(true), + ), + patch_property_op( + EditorElementTypeV1::Key, + &id, + EditorElementPropertyPatchV1::NoteGlowPaint(EditorNotePaintIntentV1::Opacity( + crate::models::EditorNotePaintOpacityIntentV1 { opacity: 70 }, + )), + ), + ], + ) + .unwrap_err(); + + assert_eq!(validation_code(&error), Some("NOTE_GLOW_PAINT_SYNC_LOCKED")); + assert_eq!(store, original); +} + +#[test] +fn note_glow_sync_disable_preserves_the_mirrored_paint() { + let mut store = base_store(); + let id = store.key_positions["4key"][0].id.clone(); + let position = &mut store.key_positions.get_mut("4key").unwrap()[0]; + position.note_glow_sync_paint = true; + position.note_color = NoteColor::Solid("#123456".to_string()); + position.note_opacity = 73; + position.note_opacity_top = Some(63); + position.note_opacity_bottom = Some(53); + assert!(position.mirror_note_body_to_glow()); + let before = position.clone(); + + let transition = prepare_editor_ops_transition( + &store, + &[patch_property_op( + EditorElementTypeV1::Key, + &id, + EditorElementPropertyPatchV1::NoteGlowSyncPaint(false), + )], + ) + .unwrap(); + let changed = &transition.candidate.key_positions["4key"][0]; + assert!(!changed.note_glow_sync_paint); + assert_eq!(changed.note_glow_gradient, before.note_glow_gradient); + assert_eq!(changed.note_glow_opacity, before.note_glow_opacity); + assert_eq!(changed.note_glow_color, before.note_glow_color); + assert_eq!(changed.note_glow_opacity_top, before.note_glow_opacity_top); + assert_eq!( + changed.note_glow_opacity_bottom, + before.note_glow_opacity_bottom + ); +} + +#[test] +fn unsynced_note_paint_preserves_glow_paint() { + let mut store = base_store(); + let id = store.key_positions["4key"][0].id.clone(); + let position = &mut store.key_positions.get_mut("4key").unwrap()[0]; + position.note_glow_sync_paint = false; + position.note_glow_color = Some(NoteColor::Solid("glow".to_string())); + position.note_glow_opacity = 71; + position.note_glow_opacity_top = Some(61); + position.note_glow_opacity_bottom = Some(51); + let before = position.clone(); + + let transition = prepare_editor_ops_transition( + &store, + &[patch_property_op( + EditorElementTypeV1::Key, + &id, + EditorElementPropertyPatchV1::NotePaint(EditorNotePaintIntentV1::Color( + crate::models::EditorNotePaintColorIntentV1 { + color: EditorNoteColorV1::Solid("#AABBCC".to_string()), + }, + )), + )], + ) + .unwrap(); + let changed = &transition.candidate.key_positions["4key"][0]; + assert_eq!(changed.note_glow_gradient, before.note_glow_gradient); + assert_eq!(changed.note_glow_opacity, before.note_glow_opacity); + assert_eq!(changed.note_glow_color, before.note_glow_color); + assert_eq!(changed.note_glow_opacity_top, before.note_glow_opacity_top); + assert_eq!( + changed.note_glow_opacity_bottom, + before.note_glow_opacity_bottom + ); +} + +#[test] +fn note_glow_size_is_key_only_bounded_and_preserves_note_siblings() { + let mut store = base_store(); + let id = store.key_positions["4key"][0].id.clone(); + let position = &mut store.key_positions.get_mut("4key").unwrap()[0]; + position.note_glow_size = 20.0; + position.note_glow_enabled = true; + position.note_glow_opacity = 71; + position.note_glow_color = Some(crate::models::NoteColor::Solid("glow-sibling".to_string())); + position.note_color = crate::models::NoteColor::Solid("note-sibling".to_string()); + position.note_border_width = Some(2.5); + let original = position.clone(); + let patch = EditorElementPropertyPatchV1::NoteGlowSize(0.5); + let ops = vec![ + patch_property_op(EditorElementTypeV1::Key, &id, patch.clone()), + patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + patch.clone(), + ), + ]; + + let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); + let changed = &transition.candidate.key_positions["4key"][0]; + assert_eq!(changed.note_glow_size, 0.5); + assert_eq!(changed.note_glow_enabled, original.note_glow_enabled); + assert_eq!(changed.note_glow_opacity, original.note_glow_opacity); + assert_eq!(changed.note_glow_color, original.note_glow_color); + assert_eq!(changed.note_color, original.note_color); + assert_eq!(changed.note_border_width, original.note_border_width); + assert_eq!(transition.changed_fields, [EditorField::KeyPositions]); + assert_eq!( + transition + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); + assert!(replay.changed_fields.is_empty()); + assert_eq!( + replay + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + for value in [-0.1, 50.1, f64::NAN, f64::INFINITY] { + let error = prepare_editor_ops_transition( + &store, + &[ + ops[0].clone(), + patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + EditorElementPropertyPatchV1::NoteGlowSize(value), + ), + ], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("NOTE_GLOW_SIZE_OUT_OF_RANGE")); + assert_eq!(store.key_positions["4key"][0], original); + } + + let lower = prepare_editor_ops_transition( + &store, + &[patch_property_op( + EditorElementTypeV1::Key, + &id, + EditorElementPropertyPatchV1::NoteGlowSize(0.0), + )], + ) + .unwrap(); + assert_eq!(lower.candidate.key_positions["4key"][0].note_glow_size, 0.0); + let upper = prepare_editor_ops_transition( + &store, + &[patch_property_op( + EditorElementTypeV1::Key, + &id, + EditorElementPropertyPatchV1::NoteGlowSize(50.0), + )], + ) + .unwrap(); + assert_eq!( + upper.candidate.key_positions["4key"][0].note_glow_size, + 50.0 + ); + + for element_type in [ + EditorElementTypeV1::Stat, + EditorElementTypeV1::Graph, + EditorElementTypeV1::Knob, + ] { + let error = prepare_editor_ops_transition( + &store, + &[ + ops[0].clone(), + patch_property_op( + element_type, + uuid::Uuid::new_v4().to_string(), + patch.clone(), + ), + ], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); + assert_eq!(store.key_positions["4key"][0], original); + } +} + +#[test] +fn note_gradient_descriptor_and_legacy_transitions_are_atomic() { + let mut store = base_store(); + let id = store.key_positions["4key"][0].id.clone(); + let position = &mut store.key_positions.get_mut("4key").unwrap()[0]; + position.note_color = NoteColor::Solid("legacy".to_string()); + position.note_opacity = 90; + position.note_opacity_top = Some(30); + position.note_opacity_bottom = Some(70); + + let gradient = crate::models::EditorPaintGradientV1 { + angle: 45.0, + stops: vec![ + crate::models::EditorPaintGradientStopV1 { + color: "rgba(17,34,51,.5)".to_string(), + pos: 0.0, + }, + crate::models::EditorPaintGradientStopV1 { + color: "#44556640".to_string(), + pos: 1.0, + }, + ], + }; + let descriptor = EditorElementPropertyPatchV1::NotePaint(EditorNotePaintIntentV1::Descriptor( + crate::models::EditorNotePaintDescriptorIntentV1 { + color: EditorNoteColorV1::Gradient(crate::models::EditorNoteGradientColorV1 { + kind: crate::models::EditorNoteGradientColorKindV1::Gradient, + top: "#112233".to_string(), + bottom: "#445566".to_string(), + }), + opacity: 80, + gradient: Some(gradient.clone()), + }, + )); + let first = prepare_editor_ops_transition( + &store, + &[patch_property_op( + EditorElementTypeV1::Key, + &id, + descriptor.clone(), + )], + ) + .unwrap(); + let position = &first.candidate.key_positions["4key"][0]; + assert_eq!(position.note_gradient.as_ref().unwrap().angle, 45.0); + assert_eq!(position.note_opacity, 80); + assert_eq!(position.note_opacity_top, Some(40)); + assert_eq!(position.note_opacity_bottom, Some(20)); + assert_eq!( + position.note_color, + NoteColor::Gradient { + top: "#112233".to_string(), + bottom: "#445566".to_string(), + } + ); + + let replay = prepare_editor_ops_transition( + &first.scratch, + &[patch_property_op(EditorElementTypeV1::Key, &id, descriptor)], + ) + .unwrap(); + assert_eq!( + replay.op_results[0].status, + EditorOpResultStatusV1::NoChange + ); + + let opacity = EditorElementPropertyPatchV1::NotePaint(EditorNotePaintIntentV1::Opacity( + crate::models::EditorNotePaintOpacityIntentV1 { opacity: 40 }, + )); + let second = prepare_editor_ops_transition( + &first.scratch, + &[patch_property_op(EditorElementTypeV1::Key, &id, opacity)], + ) + .unwrap(); + let position = &second.candidate.key_positions["4key"][0]; + assert!(position.note_gradient.is_some()); + assert_eq!(position.note_opacity_top, Some(20)); + assert_eq!(position.note_opacity_bottom, Some(10)); + + let legacy_endpoints = + EditorElementPropertyPatchV1::NotePaint(EditorNotePaintIntentV1::GradientOpacity( + crate::models::EditorNotePaintGradientOpacityIntentV1 { + opacity: 55, + opacity_top: 11, + opacity_bottom: 44, + }, + )); + let third = prepare_editor_ops_transition( + &second.scratch, + &[patch_property_op( + EditorElementTypeV1::Key, + &id, + legacy_endpoints, + )], + ) + .unwrap(); + let position = &third.candidate.key_positions["4key"][0]; + assert!(position.note_gradient.is_none()); + assert_eq!(position.note_opacity, 55); + assert_eq!(position.note_opacity_top, Some(11)); + assert_eq!(position.note_opacity_bottom, Some(44)); + + let solid = EditorElementPropertyPatchV1::NoteGlowPaint(EditorNotePaintIntentV1::Descriptor( + crate::models::EditorNotePaintDescriptorIntentV1 { + color: EditorNoteColorV1::Solid("#AABBCC".to_string()), + opacity: 35, + gradient: None, + }, + )); + let fourth = prepare_editor_ops_transition( + &third.scratch, + &[patch_property_op(EditorElementTypeV1::Key, &id, solid)], + ) + .unwrap(); + let position = &fourth.candidate.key_positions["4key"][0]; + assert!(position.note_glow_gradient.is_none()); + assert_eq!( + position.note_glow_color, + Some(NoteColor::Solid("#AABBCC".to_string())) + ); + assert_eq!(position.note_glow_opacity, 35); + assert_eq!(position.note_glow_opacity_top, Some(35)); + assert_eq!(position.note_glow_opacity_bottom, Some(35)); +} + +#[test] +fn note_gradient_descriptor_rejects_semantic_mismatches() { + let store = base_store(); + let id = store.key_positions["4key"][0].id.clone(); + let gradient = |first: &str| crate::models::EditorPaintGradientV1 { + angle: 45.0, + stops: vec![ + crate::models::EditorPaintGradientStopV1 { + color: first.to_string(), + pos: 0.0, + }, + crate::models::EditorPaintGradientStopV1 { + color: "#445566".to_string(), + pos: 1.0, + }, + ], + }; + let descriptor = |color, opacity, gradient| { + EditorElementPropertyPatchV1::NotePaint(EditorNotePaintIntentV1::Descriptor( + crate::models::EditorNotePaintDescriptorIntentV1 { + color, + opacity, + gradient, + }, + )) + }; + + let cases = [ + ( + descriptor( + EditorNoteColorV1::Gradient(crate::models::EditorNoteGradientColorV1 { + kind: crate::models::EditorNoteGradientColorKindV1::Gradient, + top: "#000000".to_string(), + bottom: "#445566".to_string(), + }), + 80, + Some(gradient("#112233")), + ), + "PAINT_COLOR_GRADIENT_MISMATCH", + ), + ( + descriptor( + EditorNoteColorV1::Gradient(crate::models::EditorNoteGradientColorV1 { + kind: crate::models::EditorNoteGradientColorKindV1::Gradient, + top: "#112233".to_string(), + bottom: "#445566".to_string(), + }), + 80, + None, + ), + "PAINT_COLOR_GRADIENT_MISMATCH", + ), + ( + descriptor( + EditorNoteColorV1::Gradient(crate::models::EditorNoteGradientColorV1 { + kind: crate::models::EditorNoteGradientColorKindV1::Gradient, + top: "#112233".to_string(), + bottom: "#445566".to_string(), + }), + 80, + Some(gradient("transparent")), + ), + "INVALID_PAINT_GRADIENT", + ), + ( + descriptor(EditorNoteColorV1::Solid("#112233".to_string()), 101, None), + "NOTE_OPACITY_OUT_OF_RANGE", + ), + ]; + + for (patch, expected_code) in cases { + let error = prepare_editor_ops_transition( + &store, + &[patch_property_op(EditorElementTypeV1::Key, &id, patch)], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some(expected_code)); + } +} + +#[test] +fn note_paint_masks_preserve_siblings_materialize_options_and_reject_invalid_plans() { + let mut store = base_store(); + let ids = [ + store.key_positions["4key"][0].id.clone(), + store.key_positions["4key"][1].id.clone(), + store.key_positions["4key"][2].id.clone(), + store.key_positions["4key"][3].id.clone(), + store.key_positions["5key"][0].id.clone(), + ]; + let positions = store.key_positions.get_mut("4key").unwrap(); + positions[0].note_color = NoteColor::Solid("old-note".to_string()); + positions[0].note_opacity = 41; + positions[0].note_opacity_top = Some(42); + positions[0].note_opacity_bottom = Some(43); + positions[1].note_opacity = 50; + positions[1].note_opacity_top = Some(11); + positions[1].note_opacity_bottom = Some(22); + positions[2].note_glow_color = None; + positions[2].note_glow_opacity = 71; + positions[2].note_glow_opacity_top = Some(72); + positions[2].note_glow_opacity_bottom = Some(73); + positions[3].note_border_color = None; + positions[3].note_border_opacity = 100; + positions[3].note_border_gradient = serde_json::from_value(serde_json::json!({ + "angle": 45, + "stops": [ + { "color": "#010203", "pos": 0 }, + { "color": "#040506", "pos": 1 } + ] + })) + .unwrap(); + let full = &mut store.key_positions.get_mut("5key").unwrap()[0]; + full.note_glow_opacity = 65; + full.note_glow_opacity_top = None; + full.note_glow_opacity_bottom = None; + full.note_glow_color = Some(NoteColor::Solid("glow-sibling".to_string())); + let original = store.clone(); + + let note_color = EditorElementPropertyPatchV1::NotePaint(EditorNotePaintIntentV1::Color( + crate::models::EditorNotePaintColorIntentV1 { + color: EditorNoteColorV1::Gradient(crate::models::EditorNoteGradientColorV1 { + kind: crate::models::EditorNoteGradientColorKindV1::Gradient, + top: "top".to_string(), + bottom: "bottom".to_string(), + }), + }, + )); + let note_opacity = EditorElementPropertyPatchV1::NotePaint(EditorNotePaintIntentV1::Opacity( + crate::models::EditorNotePaintOpacityIntentV1 { opacity: 60 }, + )); + let glow_color = EditorElementPropertyPatchV1::NoteGlowPaint(EditorNotePaintIntentV1::Color( + crate::models::EditorNotePaintColorIntentV1 { + color: EditorNoteColorV1::Solid(String::new()), + }, + )); + let border = + EditorElementPropertyPatchV1::NoteBorderPaint(crate::models::EditorNoteBorderPaintV1 { + color: "#FFFFFF".to_string(), + opacity: 100, + gradient: None, + }); + let glow_tuple = + EditorElementPropertyPatchV1::NoteGlowPaint(EditorNotePaintIntentV1::GradientOpacity( + crate::models::EditorNotePaintGradientOpacityIntentV1 { + opacity: 65, + opacity_top: 10, + opacity_bottom: 90, + }, + )); + let ops = vec![ + patch_property_op(EditorElementTypeV1::Key, &ids[0], note_color.clone()), + patch_property_op(EditorElementTypeV1::Key, &ids[1], note_opacity.clone()), + patch_property_op(EditorElementTypeV1::Key, &ids[2], glow_color.clone()), + patch_property_op(EditorElementTypeV1::Key, &ids[3], border.clone()), + patch_property_op(EditorElementTypeV1::Key, &ids[4], glow_tuple.clone()), + patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + note_color.clone(), + ), + ]; + + let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); + assert_eq!(transition.changed_fields, [EditorField::KeyPositions]); + assert_eq!( + transition + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + let positions = &transition.candidate.key_positions["4key"]; + assert_eq!( + positions[0].note_color, + NoteColor::Gradient { + top: "top".to_string(), + bottom: "bottom".to_string(), + } + ); + assert_eq!(positions[0].note_opacity, 41); + assert_eq!(positions[0].note_opacity_top, Some(42)); + assert_eq!(positions[0].note_opacity_bottom, Some(43)); + assert_eq!(positions[1].note_opacity, 60); + assert_eq!(positions[1].note_opacity_top, Some(11)); + assert_eq!(positions[1].note_opacity_bottom, Some(22)); + assert_eq!( + positions[2].note_glow_color, + Some(NoteColor::Solid(String::new())) + ); + assert_eq!(positions[2].note_glow_opacity, 71); + assert_eq!(positions[2].note_glow_opacity_top, Some(72)); + assert_eq!(positions[2].note_glow_opacity_bottom, Some(73)); + assert_eq!(positions[3].note_border_color.as_deref(), Some("#FFFFFF")); + assert_eq!(positions[3].note_border_opacity, 100); + assert!(positions[3].note_border_gradient.is_none()); + let full = &transition.candidate.key_positions["5key"][0]; + assert_eq!(full.note_glow_opacity, 65); + assert_eq!(full.note_glow_opacity_top, Some(10)); + assert_eq!(full.note_glow_opacity_bottom, Some(90)); + assert_eq!( + full.note_glow_color, + Some(NoteColor::Solid("glow-sibling".to_string())) + ); + + let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); + assert!(replay.changed_fields.is_empty()); + assert_eq!( + replay + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + for (invalid, expected_code) in [ + ( + EditorElementPropertyPatchV1::NotePaint(EditorNotePaintIntentV1::Opacity( + crate::models::EditorNotePaintOpacityIntentV1 { opacity: 101 }, + )), + "NOTE_OPACITY_OUT_OF_RANGE", + ), + ( + EditorElementPropertyPatchV1::NoteBorderPaint(crate::models::EditorNoteBorderPaintV1 { + color: "rgba(1,2,3,1)".to_string(), + opacity: 100, + gradient: None, + }), + "INVALID_NOTE_BORDER_COLOR", + ), + ] { + let error = prepare_editor_ops_transition( + &store, + &[ + ops[0].clone(), + patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + invalid, + ), + ], + ) + .unwrap_err(); + assert_eq!(store, original); + assert_eq!( + error.error_code, + crate::errors::EditorCommitErrorCode::ValidationFailed + ); + assert_eq!(validation_code(&error), Some(expected_code)); + } + for element_type in [ + EditorElementTypeV1::Stat, + EditorElementTypeV1::Graph, + EditorElementTypeV1::Knob, + ] { + let error = prepare_editor_ops_transition( + &store, + &[ + ops[0].clone(), + patch_property_op( + element_type, + uuid::Uuid::new_v4().to_string(), + glow_color.clone(), + ), + ], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); + assert_eq!(store, original); + } +} + +#[test] +fn note_border_gradient_patch_is_atomic_canonical_and_strict() { + let mut store = base_store(); + let id = store.key_positions["4key"][0].id.clone(); + let position = &mut store.key_positions.get_mut("4key").unwrap()[0]; + position.note_border_color = Some("#000000".to_string()); + position.note_border_opacity = 25; + position.note_border_side = Some("vertical".to_string()); + let original = store.clone(); + + let gradient = crate::models::EditorPaintGradientV1 { + angle: 135.0, + stops: vec![ + crate::models::EditorPaintGradientStopV1 { + color: "rgba(17, 34, 51, .5)".to_string(), + pos: 0.0, + }, + crate::models::EditorPaintGradientStopV1 { + color: "#ABC8".to_string(), + pos: 1.0, + }, + ], + }; + let patch = + EditorElementPropertyPatchV1::NoteBorderPaint(crate::models::EditorNoteBorderPaintV1 { + color: "#112233".to_string(), + opacity: 73, + gradient: Some(gradient.clone()), + }); + let ops = [patch_property_op( + EditorElementTypeV1::Key, + &id, + patch.clone(), + )]; + + let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); + assert_eq!(transition.changed_fields, [EditorField::KeyPositions]); + assert_eq!( + transition.op_results[0].status, + EditorOpResultStatusV1::Applied + ); + let position = &transition.candidate.key_positions["4key"][0]; + assert_eq!(position.note_border_color.as_deref(), Some("#112233")); + assert_eq!(position.note_border_opacity, 73); + assert_eq!( + position.note_border_gradient, + Some(gradient.to_gradient_spec()) + ); + assert_eq!(position.note_border_side.as_deref(), Some("vertical")); + + let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); + assert!(replay.changed_fields.is_empty()); + assert_eq!( + replay.op_results[0].status, + EditorOpResultStatusV1::NoChange + ); + + let invalid_cases = [ + ( + "#AABBCC", + crate::models::EditorPaintGradientV1 { + angle: 90.0, + stops: vec![ + crate::models::EditorPaintGradientStopV1 { + color: "#112233".to_string(), + pos: 0.0, + }, + crate::models::EditorPaintGradientStopV1 { + color: "#445566".to_string(), + pos: 1.0, + }, + ], + }, + "PAINT_COLOR_GRADIENT_MISMATCH", + ), + ( + "#112233", + crate::models::EditorPaintGradientV1 { + angle: 90.0, + stops: vec![ + crate::models::EditorPaintGradientStopV1 { + color: "#112233".to_string(), + pos: 0.0, + }, + crate::models::EditorPaintGradientStopV1 { + color: "transparent".to_string(), + pos: 1.0, + }, + ], + }, + "INVALID_PAINT_GRADIENT", + ), + ( + "#112233", + crate::models::EditorPaintGradientV1 { + angle: -0.0, + stops: vec![ + crate::models::EditorPaintGradientStopV1 { + color: "#112233".to_string(), + pos: 0.0, + }, + crate::models::EditorPaintGradientStopV1 { + color: "#445566".to_string(), + pos: 1.0, + }, + ], + }, + "INVALID_PAINT_GRADIENT", + ), + ]; + for (color, gradient, code) in invalid_cases { + let error = prepare_editor_ops_transition( + &store, + &[patch_property_op( + EditorElementTypeV1::Key, + &id, + EditorElementPropertyPatchV1::NoteBorderPaint( + crate::models::EditorNoteBorderPaintV1 { + color: color.to_string(), + opacity: 73, + gradient: Some(gradient), + }, + ), + )], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some(code)); + assert_eq!(store, original); + } +} + +#[test] +fn note_numeric_patches_preserve_raw_options_bounds_and_siblings() { + let mut store = base_store(); + let ids = [ + store.key_positions["4key"][0].id.clone(), + store.key_positions["4key"][1].id.clone(), + store.key_positions["4key"][2].id.clone(), + store.key_positions["4key"][3].id.clone(), + store.key_positions["5key"][0].id.clone(), + ]; + for position in store.key_positions.values_mut().flatten() { + position.note_color = crate::models::NoteColor::Solid("note-sibling".to_string()); + position.note_glow_size = 17.5; + position.note_glow_opacity = 63; + } + store.key_positions.get_mut("4key").unwrap()[0].note_offset_x = Some(7.5); + store.key_positions.get_mut("4key").unwrap()[1].note_offset_y = None; + store.key_positions.get_mut("4key").unwrap()[2].note_width = Some(31.5); + store.key_positions.get_mut("4key").unwrap()[3].note_border_width = None; + store.key_positions.get_mut("5key").unwrap()[0].note_border_radius = None; + let original = store.clone(); + + let ops = vec![ + patch_property_op( + EditorElementTypeV1::Key, + &ids[0], + EditorElementPropertyPatchV1::NoteOffsetX(None), + ), + patch_property_op( + EditorElementTypeV1::Key, + &ids[1], + EditorElementPropertyPatchV1::NoteOffsetY(Some(-12.5)), + ), + patch_property_op( + EditorElementTypeV1::Key, + &ids[2], + EditorElementPropertyPatchV1::NoteWidth(None), + ), + patch_property_op( + EditorElementTypeV1::Key, + &ids[3], + EditorElementPropertyPatchV1::NoteBorderWidth(0.0), + ), + patch_property_op( + EditorElementTypeV1::Key, + &ids[4], + EditorElementPropertyPatchV1::NoteBorderRadius(4.0), + ), + patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + EditorElementPropertyPatchV1::NoteWidth(Some(20.0)), + ), + ]; + + let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); + assert_eq!(transition.changed_fields, [EditorField::KeyPositions]); + assert_eq!( + transition + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + let mut expected = original.clone(); + expected.key_positions.get_mut("4key").unwrap()[0].note_offset_x = None; + expected.key_positions.get_mut("4key").unwrap()[1].note_offset_y = Some(-12.5); + expected.key_positions.get_mut("4key").unwrap()[2].note_width = None; + expected.key_positions.get_mut("4key").unwrap()[3].note_border_width = Some(0.0); + expected.key_positions.get_mut("5key").unwrap()[0].note_border_radius = Some(4.0); + assert_eq!(transition.scratch, expected); + + let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); + assert!(replay.changed_fields.is_empty()); + assert_eq!( + replay + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + let offset_zero = prepare_editor_ops_transition( + &store, + &[patch_property_op( + EditorElementTypeV1::Key, + &ids[1], + EditorElementPropertyPatchV1::NoteOffsetY(Some(0.0)), + )], + ) + .unwrap(); + assert_eq!( + offset_zero.op_results[0].status, + EditorOpResultStatusV1::Applied + ); + assert_eq!( + offset_zero.candidate.key_positions["4key"][1].note_offset_y, + Some(0.0) + ); + + let boundary_ops = vec![ + patch_property_op( + EditorElementTypeV1::Key, + &ids[0], + EditorElementPropertyPatchV1::NoteOffsetX(Some(-500.0)), + ), + patch_property_op( + EditorElementTypeV1::Key, + &ids[1], + EditorElementPropertyPatchV1::NoteOffsetY(Some(500.0)), + ), + patch_property_op( + EditorElementTypeV1::Key, + &ids[2], + EditorElementPropertyPatchV1::NoteWidth(Some(f64::MIN_POSITIVE)), + ), + patch_property_op( + EditorElementTypeV1::Key, + &ids[3], + EditorElementPropertyPatchV1::NoteBorderWidth(20.0), + ), + patch_property_op( + EditorElementTypeV1::Key, + &ids[4], + EditorElementPropertyPatchV1::NoteBorderRadius(100.0), + ), + ]; + prepare_editor_ops_transition(&store, &boundary_ops).unwrap(); + let lower_radius = prepare_editor_ops_transition( + &store, + &[patch_property_op( + EditorElementTypeV1::Key, + &ids[4], + EditorElementPropertyPatchV1::NoteBorderRadius(1.0), + )], + ) + .unwrap(); + assert_eq!( + lower_radius.candidate.key_positions["5key"][0].note_border_radius, + Some(1.0) + ); + + let invalid_patches = [ + ( + EditorElementPropertyPatchV1::NoteOffsetX(Some(-500.1)), + "NOTE_OFFSET_X_OUT_OF_RANGE", + ), + ( + EditorElementPropertyPatchV1::NoteOffsetY(Some(500.1)), + "NOTE_OFFSET_Y_OUT_OF_RANGE", + ), + ( + EditorElementPropertyPatchV1::NoteOffsetY(Some(f64::INFINITY)), + "NOTE_OFFSET_Y_OUT_OF_RANGE", + ), + ( + EditorElementPropertyPatchV1::NoteWidth(Some(0.0)), + "NOTE_WIDTH_OUT_OF_RANGE", + ), + ( + EditorElementPropertyPatchV1::NoteWidth(Some(f64::NAN)), + "NOTE_WIDTH_OUT_OF_RANGE", + ), + ( + EditorElementPropertyPatchV1::NoteBorderWidth(-0.1), + "NOTE_BORDER_WIDTH_OUT_OF_RANGE", + ), + ( + EditorElementPropertyPatchV1::NoteBorderWidth(20.1), + "NOTE_BORDER_WIDTH_OUT_OF_RANGE", + ), + ( + EditorElementPropertyPatchV1::NoteBorderRadius(-0.1), + "NOTE_BORDER_RADIUS_OUT_OF_RANGE", + ), + ( + EditorElementPropertyPatchV1::NoteBorderRadius(100.1), + "NOTE_BORDER_RADIUS_OUT_OF_RANGE", + ), + ]; + for (patch, code) in invalid_patches { + let error = prepare_editor_ops_transition( + &store, + &[ + ops[0].clone(), + patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + patch, + ), + ], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some(code)); + assert_eq!(store, original); + } + + for element_type in [ + EditorElementTypeV1::Stat, + EditorElementTypeV1::Graph, + EditorElementTypeV1::Knob, + ] { + let error = prepare_editor_ops_transition( + &store, + &[ + ops[0].clone(), + patch_property_op( + element_type, + uuid::Uuid::new_v4().to_string(), + EditorElementPropertyPatchV1::NoteWidth(Some(20.0)), + ), + ], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); + assert_eq!(store, original); + } +} + +#[test] +fn paint_descriptors_apply_pairs_preserve_active_fallbacks_and_reject_invalid_plans() { + let mut store = store_with_every_reorder_type(); + let key_id = store.key_positions["4key"][0].id.clone(); + let stat_id = store.stat_positions["4key"][0].position.id.clone(); + let graph_id = store.graph_positions["4key"][0].position.id.clone(); + let knob_id = store.knob_positions["4key"][0].position.id.clone(); + + let key = &mut store.key_positions.get_mut("4key").unwrap()[0]; + key.background_color = Some(" key idle ".to_string()); + key.background_gradient = None; + key.active_background_color = None; + key.active_background_gradient = None; + key.border_color = Some("key-border-sibling".to_string()); + key.font_color = Some("key-font-sibling".to_string()); + + let stat = &mut store.stat_positions.get_mut("4key").unwrap()[0].position; + stat.border_color = Some("stat-old".to_string()); + stat.border_gradient = None; + stat.active_border_color = Some("stat-active-sibling".to_string()); + + let graph = &mut store.graph_positions.get_mut("4key").unwrap()[0].position; + graph.background_color = None; + graph.background_gradient = None; + graph.active_background_color = Some("graph-active-sibling".to_string()); + + let knob = &mut store.knob_positions.get_mut("4key").unwrap()[0].position; + knob.border_color = Some("stale-knob-base".to_string()); + knob.border_gradient = Some(GradientSpec::from_canonical_parts( + 90.0, + vec![ + crate::models::GradientStop { + color: "knob-old".to_string(), + pos: 0.0, + }, + crate::models::GradientStop { + color: "knob-end".to_string(), + pos: 1.0, + }, + ], + )); + knob.active_border_color = Some(" ".to_string()); + knob.active_border_gradient = None; + knob.background_color = Some("knob-background-sibling".to_string()); + + let original = store.clone(); + let gradient_stops = [("new-key", 0.0), ("new-end", 0.5), ("tail", 1.0)]; + let ops = vec![ + patch_property_op( + EditorElementTypeV1::Key, + &key_id, + EditorElementPropertyPatchV1::BackgroundPaint(paint_descriptor( + "new-key", + Some((45.0, &gradient_stops)), + )), + ), + patch_property_op( + EditorElementTypeV1::Stat, + &stat_id, + EditorElementPropertyPatchV1::BorderPaint(paint_descriptor("stat-new", None)), + ), + patch_property_op( + EditorElementTypeV1::Graph, + &graph_id, + EditorElementPropertyPatchV1::BackgroundPaint(paint_descriptor("", None)), + ), + patch_property_op( + EditorElementTypeV1::Knob, + &knob_id, + EditorElementPropertyPatchV1::BorderPaint(paint_descriptor("knob-new", None)), + ), + patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + EditorElementPropertyPatchV1::ActiveBackgroundPaint(paint_descriptor("missing", None)), + ), + ]; + + let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); + assert_eq!( + transition.changed_fields, + [ + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + ] + ); + assert_eq!( + transition + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + let key = &transition.candidate.key_positions["4key"][0]; + assert_eq!(key.background_color.as_deref(), Some("new-key")); + assert_eq!(key.background_gradient.as_ref().unwrap().angle, 45.0); + assert_eq!(key.active_background_color.as_deref(), Some(" key idle ")); + assert!(key.active_background_gradient.is_none()); + assert_eq!( + key.border_color, + original.key_positions["4key"][0].border_color + ); + assert_eq!(key.font_color, original.key_positions["4key"][0].font_color); + + let stat = &transition.candidate.stat_positions["4key"][0].position; + assert_eq!(stat.border_color.as_deref(), Some("stat-new")); + assert!(stat.border_gradient.is_none()); + assert_eq!( + stat.active_border_color.as_deref(), + Some("stat-active-sibling") + ); + + let graph = &transition.candidate.graph_positions["4key"][0].position; + assert_eq!(graph.background_color.as_deref(), Some("")); + assert_eq!( + graph.active_background_color.as_deref(), + Some("graph-active-sibling") + ); + + let knob = &transition.candidate.knob_positions["4key"][0].position; + assert_eq!(knob.border_color.as_deref(), Some("knob-new")); + assert!(knob.border_gradient.is_none()); + assert_eq!(knob.active_border_color.as_deref(), Some("stale-knob-base")); + assert_eq!( + knob.active_border_gradient, + original.knob_positions["4key"][0].position.border_gradient + ); + assert_eq!( + knob.background_color.as_deref(), + Some("knob-background-sibling") + ); + + let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); + assert!(replay.changed_fields.is_empty()); + assert_eq!( + replay + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + let fallback_only_op = patch_property_op( + EditorElementTypeV1::Key, + &key_id, + EditorElementPropertyPatchV1::BackgroundPaint(paint_descriptor(" key idle ", None)), + ); + let fallback_only = + prepare_editor_ops_transition(&store, std::slice::from_ref(&fallback_only_op)).unwrap(); + assert_eq!( + fallback_only.op_results[0].status, + EditorOpResultStatusV1::Applied + ); + assert_eq!(fallback_only.changed_fields, [EditorField::KeyPositions]); + assert_eq!( + fallback_only.candidate.key_positions["4key"][0] + .background_color + .as_deref(), + Some(" key idle ") + ); + assert_eq!( + fallback_only.candidate.key_positions["4key"][0] + .active_background_color + .as_deref(), + Some(" key idle ") + ); + let fallback_replay = prepare_editor_ops_transition( + &fallback_only.scratch, + std::slice::from_ref(&fallback_only_op), + ) + .unwrap(); + assert_eq!( + fallback_replay.op_results[0].status, + EditorOpResultStatusV1::NoChange + ); + assert!(fallback_replay.changed_fields.is_empty()); + + let key_active_id = store.key_positions["4key"][1].id.clone(); + let active_ops = [ + patch_property_op( + EditorElementTypeV1::Key, + key_active_id, + EditorElementPropertyPatchV1::ActiveBackgroundPaint(paint_descriptor( + "active-key", + None, + )), + ), + patch_property_op( + EditorElementTypeV1::Knob, + &knob_id, + EditorElementPropertyPatchV1::ActiveBorderPaint(paint_descriptor("active-knob", None)), + ), + ]; + let active = prepare_editor_ops_transition(&transition.scratch, &active_ops).unwrap(); + assert_eq!( + active.candidate.key_positions["4key"][1] + .active_background_color + .as_deref(), + Some("active-key") + ); + assert_eq!( + active.candidate.knob_positions["4key"][0] + .position + .active_border_color + .as_deref(), + Some("active-knob") + ); + + let mut explicit_active_store = store.clone(); + explicit_active_store.key_positions.get_mut("4key").unwrap()[1].active_background_color = + Some("explicit-active".to_string()); + explicit_active_store.key_positions.get_mut("4key").unwrap()[1].active_background_gradient = + None; + explicit_active_store.key_positions.get_mut("4key").unwrap()[1].background_color = + Some("old-idle".to_string()); + let explicit_active = prepare_editor_ops_transition( + &explicit_active_store, + &[patch_property_op( + EditorElementTypeV1::Key, + &explicit_active_store.key_positions["4key"][1].id, + EditorElementPropertyPatchV1::BackgroundPaint(paint_descriptor("new-idle", None)), + )], + ) + .unwrap(); + assert_eq!( + explicit_active.candidate.key_positions["4key"][1] + .active_background_color + .as_deref(), + Some("explicit-active") + ); + + for (descriptor, code) in [ + ( + paint_descriptor("a", Some((360.0, &[("a", 0.0), ("b", 1.0)]))), + "INVALID_PAINT_GRADIENT", + ), + ( + paint_descriptor("a", Some((-0.0, &[("a", 0.0), ("b", 1.0)]))), + "INVALID_PAINT_GRADIENT", + ), + ( + paint_descriptor("a", Some((90.0, &[("a", 0.5), ("b", 0.25)]))), + "INVALID_PAINT_GRADIENT", + ), + ( + paint_descriptor("wrong", Some((90.0, &[("a", 0.0), ("b", 1.0)]))), + "PAINT_COLOR_GRADIENT_MISMATCH", + ), + ( + paint_descriptor("a", Some((90.0, &[("a", 0.0)]))), + "INVALID_PAINT_GRADIENT", + ), + ( + paint_descriptor( + "a", + Some(( + 90.0, + &[ + ("a", 0.0), + ("b", 0.1), + ("c", 0.2), + ("d", 0.3), + ("e", 0.4), + ("f", 0.5), + ("g", 0.6), + ("h", 0.7), + ("i", 1.0), + ], + )), + ), + "INVALID_PAINT_GRADIENT", + ), + ( + paint_descriptor("a", Some((90.0, &[("a", -0.0), ("b", 1.0)]))), + "INVALID_PAINT_GRADIENT", + ), + ( + paint_descriptor("a", Some((90.0, &[("a", 0.0), ("b", 1.1)]))), + "INVALID_PAINT_GRADIENT", + ), + ] { + let error = prepare_editor_ops_transition( + &store, + &[ + ops[0].clone(), + patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + EditorElementPropertyPatchV1::BackgroundPaint(descriptor), + ), + ], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some(code)); + assert_eq!(store, original); + } + + let canonical_boundaries = prepare_editor_ops_transition( + &store, + &[patch_property_op( + EditorElementTypeV1::Key, + &store.key_positions["4key"][2].id, + EditorElementPropertyPatchV1::BorderPaint(paint_descriptor( + "a", + Some((0.0, &[("a", 0.0), ("equal", 0.0), ("b", 1.0)])), + )), + )], + ) + .unwrap(); + assert_eq!( + canonical_boundaries.candidate.key_positions["4key"][2] + .border_gradient + .as_ref() + .unwrap() + .stops + .len(), + 3 + ); + + for element_type in [EditorElementTypeV1::Stat, EditorElementTypeV1::Graph] { + let error = prepare_editor_ops_transition( + &store, + &[ + ops[0].clone(), + patch_property_op( + element_type, + uuid::Uuid::new_v4().to_string(), + EditorElementPropertyPatchV1::ActiveBorderPaint(paint_descriptor( + "active", None, + )), + ), + ], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); + assert_eq!(store, original); + } +} + +#[test] +fn blank_font_paint_normalizes_to_none_and_blank_gradient_stops_are_rejected() { + let mut store = store_with_every_reorder_type(); + let key_id = store.key_positions["4key"][0].id.clone(); + { + let key = &mut store.key_positions.get_mut("4key").unwrap()[0]; + key.font_color = Some("stale-idle".to_string()); + key.font_gradient = None; + key.active_font_color = Some("stale-active".to_string()); + key.active_font_gradient = None; + } + let original = store.clone(); + + // 빈 solid는 커밋 시 None - 로드 정규화(normalize_blank_font_colors)와 수렴 + let ops = vec![ + patch_property_op( + EditorElementTypeV1::Key, + &key_id, + EditorElementPropertyPatchV1::FontPaint(paint_descriptor(" ", None)), + ), + patch_property_op( + EditorElementTypeV1::Key, + &key_id, + EditorElementPropertyPatchV1::ActiveFontPaint(paint_descriptor("", None)), + ), + ]; + let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); + assert_eq!(transition.changed_fields, [EditorField::KeyPositions]); + let position = &transition.candidate.key_positions["4key"][0]; + assert!(position.font_color.is_none()); + assert!(position.font_gradient.is_none()); + assert!(position.active_font_color.is_none()); + assert!(position.active_font_gradient.is_none()); + + // 빈 stop 색 gradient는 커밋 거부 - 로드 시 대표색 진동 차단 + let error = prepare_editor_ops_transition( + &store, + &[patch_property_op( + EditorElementTypeV1::Key, + &key_id, + EditorElementPropertyPatchV1::FontPaint(paint_descriptor( + " ", + Some((45.0, &[(" ", 0.0), ("#445566", 1.0)])), + )), + )], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("INVALID_PAINT_GRADIENT")); + assert_eq!(store, original); +} + +#[test] +fn font_paint_patches_preserve_state_pairs_and_enforce_target_types() { + let mut store = store_with_every_reorder_type(); + let key_ids = store.key_positions["4key"] + .iter() + .map(|position| position.id.clone()) + .collect::>(); + let stat_id = store.stat_positions["4key"][0].position.id.clone(); + let graph_id = store.graph_positions["4key"][0].position.id.clone(); + let knob_id = store.knob_positions["4key"][0].position.id.clone(); + + let idle_gradient = paint_descriptor( + "idle-first", + Some((30.0, &[("idle-first", 0.0), ("idle-last", 1.0)])), + ) + .gradient + .unwrap() + .to_gradient_spec(); + let active_gradient = paint_descriptor( + "active-first", + Some((75.0, &[("active-first", 0.0), ("active-last", 1.0)])), + ) + .gradient + .unwrap() + .to_gradient_spec(); + let keys = store.key_positions.get_mut("4key").unwrap(); + keys[0].font_color = None; + keys[0].font_gradient = Some(idle_gradient.clone()); + keys[0].active_font_color = None; + keys[0].active_font_gradient = None; + keys[1].font_color = Some("idle-first".to_string()); + keys[1].font_gradient = Some(idle_gradient.clone()); + keys[1].active_font_color = Some("active-solid".to_string()); + keys[1].active_font_gradient = None; + keys[2].font_color = Some("idle-solid".to_string()); + keys[2].font_gradient = None; + keys[2].active_font_color = None; + keys[2].active_font_gradient = Some(active_gradient.clone()); + keys[3].font_color = Some("same-idle".to_string()); + keys[3].font_gradient = None; + keys[3].active_font_color = None; + keys[3].active_font_gradient = None; + + let original = store.clone(); + let ops = vec![ + patch_property_op( + EditorElementTypeV1::Key, + &key_ids[0], + EditorElementPropertyPatchV1::FontPaint(paint_descriptor("next-zero", None)), + ), + patch_property_op( + EditorElementTypeV1::Key, + &key_ids[1], + EditorElementPropertyPatchV1::FontPaint(paint_descriptor("next-one", None)), + ), + patch_property_op( + EditorElementTypeV1::Key, + &key_ids[2], + EditorElementPropertyPatchV1::FontPaint(paint_descriptor("next-two", None)), + ), + patch_property_op( + EditorElementTypeV1::Key, + &key_ids[3], + EditorElementPropertyPatchV1::FontPaint(paint_descriptor("same-idle", None)), + ), + patch_property_op( + EditorElementTypeV1::Stat, + &stat_id, + EditorElementPropertyPatchV1::FontPaint(paint_descriptor( + "stat-first", + Some((90.0, &[("stat-first", 0.0), ("stat-last", 1.0)])), + )), + ), + ]; + + let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); + assert_eq!( + transition.changed_fields, + [EditorField::KeyPositions, EditorField::StatPositions] + ); + assert_eq!( + transition + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + ] + ); + + let keys = &transition.candidate.key_positions["4key"]; + assert_eq!(keys[0].active_font_color, None); + assert_eq!(keys[0].active_font_gradient, Some(idle_gradient.clone())); + assert_eq!(keys[1].active_font_color.as_deref(), Some("active-solid")); + assert_eq!(keys[1].active_font_gradient, None); + assert_eq!(keys[2].active_font_color, None); + assert_eq!(keys[2].active_font_gradient, Some(active_gradient)); + assert_eq!(keys[3].active_font_color.as_deref(), Some("same-idle")); + assert_eq!(keys[3].active_font_gradient, None); + assert_eq!( + transition.candidate.stat_positions["4key"][0] + .position + .font_color + .as_deref(), + Some("stat-first") + ); + assert!(transition.candidate.stat_positions["4key"][0] + .position + .active_font_color + .is_none()); + + let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); + assert!(replay.changed_fields.is_empty()); + assert_eq!( + replay + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + ] + ); + + let active_store = store.clone(); + let active = prepare_editor_ops_transition( + &active_store, + &[patch_property_op( + EditorElementTypeV1::Key, + &key_ids[0], + EditorElementPropertyPatchV1::ActiveFontPaint(paint_descriptor( + "active-direct", + Some((15.0, &[("active-direct", 0.0), ("active-end", 1.0)])), + )), + )], + ) + .unwrap(); + assert_eq!( + active.candidate.key_positions["4key"][0] + .active_font_color + .as_deref(), + Some("active-direct") + ); + assert!(active.candidate.key_positions["4key"][0] + .active_font_gradient + .is_some()); + + for (element_type, id) in [ + (EditorElementTypeV1::Stat, &stat_id), + (EditorElementTypeV1::Graph, &graph_id), + (EditorElementTypeV1::Knob, &knob_id), + ] { + let error = prepare_editor_ops_transition( + &store, + &[ + ops[0].clone(), + patch_property_op( + element_type, + id, + EditorElementPropertyPatchV1::ActiveFontPaint(paint_descriptor( + "wrong-type", + None, + )), + ), + ], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); + assert_eq!(store, original); + } + + for (element_type, id) in [ + (EditorElementTypeV1::Graph, &graph_id), + (EditorElementTypeV1::Knob, &knob_id), + ] { + let error = prepare_editor_ops_transition( + &store, + &[patch_property_op( + element_type, + id, + EditorElementPropertyPatchV1::FontPaint(paint_descriptor("wrong-type", None)), + )], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); + } + + for patch in [ + EditorElementPropertyPatchV1::FontPaint(paint_descriptor( + "mismatch", + Some((45.0, &[("first", 0.0), ("last", 1.0)])), + )), + EditorElementPropertyPatchV1::ActiveFontPaint(paint_descriptor( + "mismatch", + Some((45.0, &[("first", 0.0), ("last", 1.0)])), + )), + ] { + let error = prepare_editor_ops_transition( + &store, + &[patch_property_op( + EditorElementTypeV1::Key, + &key_ids[0], + patch, + )], + ) + .unwrap_err(); + assert_eq!( + validation_code(&error), + Some("PAINT_COLOR_GRADIENT_MISMATCH") + ); + } +} + +#[test] +fn shadow_masks_seed_current_defaults_preserve_siblings_and_reject_invalid_plans() { + let mut store = store_with_every_reorder_type(); + let key_id = store.key_positions["4key"][0].id.clone(); + let stat_id = store.stat_positions["4key"][0].position.id.clone(); + let knob_id = store.knob_positions["4key"][0].position.id.clone(); + + let key = &mut store.key_positions.get_mut("4key").unwrap()[0]; + key.inactive_image = Some(" image.png ".to_string()); + key.shadow = None; + key.active_shadow = Some(ElementShadowSpec { + enabled: true, + color: "key-active-sibling".to_string(), + offset_x: 7.5, + offset_y: -8.5, + blur: 19.5, + }); + let stat = &mut store.stat_positions.get_mut("4key").unwrap()[0].position; + stat.shadow = Some(ElementShadowSpec { + enabled: false, + color: "stat-color-sibling".to_string(), + offset_x: 1.5, + offset_y: 2.5, + blur: 3.5, + }); + stat.active_shadow = Some(ElementShadowSpec { + enabled: true, + color: "stat-active-sentinel".to_string(), + offset_x: 10.0, + offset_y: 11.0, + blur: 12.0, + }); + let knob = &mut store.knob_positions.get_mut("4key").unwrap()[0].position; + knob.shadow = None; + knob.active_shadow = None; + knob.idle_transparent = true; + knob.active_transparent = false; + knob.border_width = Some(2.0); + knob.inactive_image = None; + knob.active_image = Some("active.png".to_string()); + let original = store.clone(); + + let ops = vec![ + patch_property_op( + EditorElementTypeV1::Key, + &key_id, + EditorElementPropertyPatchV1::Shadow(shadow_leaf_color("new-shadow")), + ), + patch_property_op( + EditorElementTypeV1::Stat, + &stat_id, + EditorElementPropertyPatchV1::Shadow(shadow_leaf_offset_x(-100.0)), + ), + patch_property_op( + EditorElementTypeV1::Knob, + &knob_id, + EditorElementPropertyPatchV1::ShadowEnabled(true), + ), + patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + EditorElementPropertyPatchV1::ActiveShadow(shadow_leaf_blur(100.0)), + ), + ]; + let transition = prepare_editor_ops_transition(&store, &ops).unwrap(); + assert_eq!( + transition.changed_fields, + [ + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::KnobPositions, + ] + ); + assert_eq!( + transition + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::TargetMissing, + ] + ); + let key = transition.candidate.key_positions["4key"][0] + .shadow + .as_ref() + .unwrap(); + assert!(!key.enabled); + assert_eq!(key.color, "new-shadow"); + assert_eq!((key.offset_x, key.offset_y, key.blur), (0.0, 4.0, 10.0)); + assert_eq!( + transition.candidate.key_positions["4key"][0].active_shadow, + original.key_positions["4key"][0].active_shadow + ); + let stat = transition.candidate.stat_positions["4key"][0] + .position + .shadow + .as_ref() + .unwrap(); + assert_eq!(stat.offset_x, -100.0); + assert!(!stat.enabled); + assert_eq!(stat.color, "stat-color-sibling"); + assert_eq!((stat.offset_y, stat.blur), (2.5, 3.5)); + assert_eq!( + transition.candidate.stat_positions["4key"][0] + .position + .active_shadow, + original.stat_positions["4key"][0].position.active_shadow + ); + let knob = &transition.candidate.knob_positions["4key"][0].position; + let idle = knob.shadow.as_ref().unwrap(); + let active = knob.active_shadow.as_ref().unwrap(); + assert!(idle.enabled && active.enabled); + assert_eq!(idle.color, "rgba(0, 0, 0, 0.28)"); + assert_eq!((idle.offset_x, idle.offset_y, idle.blur), (0.0, 4.0, 10.0)); + assert_eq!(active.color, "rgba(0, 0, 0, 0.32)"); + assert_eq!( + (active.offset_x, active.offset_y, active.blur), + (0.0, 3.0, 8.0) + ); + + let replay = prepare_editor_ops_transition(&transition.scratch, &ops).unwrap(); + assert!(replay.changed_fields.is_empty()); + assert_eq!( + replay + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::NoChange, + EditorOpResultStatusV1::TargetMissing, + ] + ); + + let mut stat_master_store = store.clone(); + stat_master_store.stat_positions.get_mut("4key").unwrap()[0] + .position + .shadow = None; + let stat_active_before = stat_master_store.stat_positions["4key"][0] + .position + .active_shadow + .clone(); + let stat_master = prepare_editor_ops_transition( + &stat_master_store, + &[patch_property_op( + EditorElementTypeV1::Stat, + &stat_id, + EditorElementPropertyPatchV1::ShadowEnabled(false), + )], + ) + .unwrap(); + assert!( + !stat_master.candidate.stat_positions["4key"][0] + .position + .shadow + .as_ref() + .unwrap() + .enabled + ); + assert_eq!( + stat_master.candidate.stat_positions["4key"][0] + .position + .active_shadow, + stat_active_before + ); + + for (element_type, active, configure) in [ + (EditorElementTypeV1::Key, false, "idle-image"), + (EditorElementTypeV1::Key, true, "active-image"), + (EditorElementTypeV1::Knob, false, "idle-transparent"), + (EditorElementTypeV1::Knob, true, "active-transparent"), + (EditorElementTypeV1::Knob, false, "idle-border"), + (EditorElementTypeV1::Knob, true, "active-border"), + ] { + let mut seeded_store = store_with_every_reorder_type(); + let (id, position) = match element_type { + EditorElementTypeV1::Key => { + let position = &mut seeded_store.key_positions.get_mut("4key").unwrap()[0]; + (position.id.clone(), position) + } + EditorElementTypeV1::Knob => { + let position = + &mut seeded_store.knob_positions.get_mut("4key").unwrap()[0].position; + (position.id.clone(), position) + } + _ => unreachable!(), + }; + position.shadow = None; + position.active_shadow = None; + position.inactive_image = None; + position.active_image = None; + position.idle_transparent = false; + position.active_transparent = false; + position.border_width = None; + match configure { + "idle-image" => position.inactive_image = Some("idle.png".to_string()), + "active-image" => position.active_image = Some("active.png".to_string()), + "idle-transparent" => position.idle_transparent = true, + "active-transparent" => position.active_transparent = true, + "idle-border" | "active-border" => position.border_width = Some(1.0), + _ => unreachable!(), + } + let patch = if active { + EditorElementPropertyPatchV1::ActiveShadow(shadow_leaf_blur(8.0)) + } else { + EditorElementPropertyPatchV1::Shadow(shadow_leaf_blur(10.0)) + }; + let op = patch_property_op(element_type, id, patch); + let seeded = + prepare_editor_ops_transition(&seeded_store, std::slice::from_ref(&op)).unwrap(); + assert_eq!( + seeded.op_results[0].status, + EditorOpResultStatusV1::Applied, + "{configure}" + ); + let position = match element_type { + EditorElementTypeV1::Key => &seeded.candidate.key_positions["4key"][0], + EditorElementTypeV1::Knob => &seeded.candidate.knob_positions["4key"][0].position, + _ => unreachable!(), + }; + let shadow = if active { + position.active_shadow.as_ref().unwrap() + } else { + position.shadow.as_ref().unwrap() + }; + assert!(!shadow.enabled, "{configure}"); + let replay = prepare_editor_ops_transition(&seeded.scratch, &[op]).unwrap(); + assert_eq!( + replay.op_results[0].status, + EditorOpResultStatusV1::NoChange, + "{configure}" + ); + } + + for (patch, code) in [ + (shadow_leaf_color(""), "INVALID_ELEMENT_SHADOW"), + (shadow_leaf_offset_x(100.1), "INVALID_ELEMENT_SHADOW"), + (shadow_leaf_offset_y(f64::NAN), "INVALID_ELEMENT_SHADOW"), + (shadow_leaf_blur(-0.1), "INVALID_ELEMENT_SHADOW"), + ] { + let error = prepare_editor_ops_transition( + &store, + &[ + ops[0].clone(), + patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + EditorElementPropertyPatchV1::Shadow(patch), + ), + ], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some(code)); + assert_eq!(store, original); + } + + for (element_type, patch) in [ + ( + EditorElementTypeV1::Graph, + EditorElementPropertyPatchV1::Shadow(shadow_leaf_blur(4.0)), + ), + ( + EditorElementTypeV1::Graph, + EditorElementPropertyPatchV1::ShadowEnabled(false), + ), + ( + EditorElementTypeV1::Stat, + EditorElementPropertyPatchV1::ActiveShadow(shadow_leaf_offset_y(1.0)), + ), + ] { + let error = prepare_editor_ops_transition( + &store, + &[ + ops[0].clone(), + patch_property_op(element_type, uuid::Uuid::new_v4().to_string(), patch), + ], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); + assert_eq!(store, original); + } +} + +#[test] +fn set_key_slot_follows_position_id_and_preserves_the_pair() { + let mut store = base_store(); + store.keys.get_mut("4key").unwrap().swap(0, 1); + store.key_positions.get_mut("4key").unwrap().swap(0, 1); + let id = store.key_positions["4key"][1].id.clone(); + let op = EditorOpV1::SetKeySlot { + id, + slot: EditorFrozenKeySlotV1::Multi(crate::models::EditorFrozenMultiKeySlotV1 { + keys: vec!["A".to_string(), "B".to_string()], + match_mode: crate::models::SlotMatch::All, + }), + }; + + let transition = prepare_editor_ops_transition(&store, std::slice::from_ref(&op)).unwrap(); + assert_eq!( + transition.candidate.keys["4key"][1], + crate::models::KeySlot::Multi { + keys: vec!["A".to_string(), "B".to_string()], + match_mode: crate::models::SlotMatch::All, + } + ); + assert_eq!(transition.candidate.key_positions, store.key_positions); + assert_eq!(transition.changed_fields, [EditorField::Keys]); + assert_eq!( + transition.op_results[0].status, + EditorOpResultStatusV1::Applied + ); + + let replay = prepare_editor_ops_transition(&transition.scratch, &[op]).unwrap(); + assert_eq!( + replay.op_results[0].status, + EditorOpResultStatusV1::NoChange + ); + assert!(replay.changed_fields.is_empty()); +} + +#[test] +fn property_and_slot_type_mismatches_reject_the_whole_request() { + let store = store_with_every_reorder_type(); + let before = store.clone(); + let key_id = store.key_positions["4key"][0].id.clone(); + let stat_id = store.stat_positions["4key"][0].position.id.clone(); + + let property_error = prepare_editor_ops_transition( + &store, + &[ + patch_hidden_op(EditorElementTypeV1::Key, key_id, true), + patch_layer_name_op( + EditorElementTypeV1::Graph, + stat_id.clone(), + Some("Wrong type"), + ), + ], + ) + .unwrap_err(); + assert_eq!( + validation_code(&property_error), + Some("ELEMENT_TYPE_MISMATCH") + ); + + let slot_error = prepare_editor_ops_transition( + &store, + &[EditorOpV1::SetKeySlot { + id: stat_id, + slot: EditorFrozenKeySlotV1::Single("A".to_string()), + }], + ) + .unwrap_err(); + assert_eq!(validation_code(&slot_error), Some("ELEMENT_TYPE_MISMATCH")); + assert_eq!(store, before); +} diff --git a/src-tauri/src/state/migration.rs b/src-tauri/src/state/migration.rs index a309fb816..de93e9296 100644 --- a/src-tauri/src/state/migration.rs +++ b/src-tauri/src/state/migration.rs @@ -2235,3819 +2235,4 @@ struct LegacyOverlayPosition { } #[cfg(test)] -mod tests { - use super::{ - load_store_from_path, migrate_local_fonts_to_app_data, migrate_sound_library_enabled, - normalize_state, parse_portable_asset_reference, recover_key_mapping_entries, - rehome_foreign_asset_references, repair_image_transforms, rgba_to_hex, AssetCategory, - LEGACY_OVERLAY_HEIGHT, LEGACY_OVERLAY_WIDTH, LEGACY_PANEL_DETACH_ENABLED_KEY, - }; - use crate::{ - defaults::{default_keys, default_positions}, - models::{ - AppStoreData, CustomCssHistoryEntry, CustomFont, CustomTab, FontType, GraphPosition, - GraphStatType, GraphType, ImageTransform, KeyCounterAlign, KeyCounterAlignMode, - KeyCounterColor, KeyCounterPlacement, KeyMappings, KeyPosition, KeySlot, KnobPosition, - LayerGroupDef, NoteColor, OverlayBounds, SlotMatch, SoundLibraryEntry, StatPosition, - StatType, TabCss, TabNoteSettings, POSITION_COLLECTION_FIELDS, - }, - }; - use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; - use serde::{Deserialize, Serialize}; - use unicode_normalization::UnicodeNormalization; - - fn rehome_test_directory(label: &str) -> std::path::PathBuf { - std::env::temp_dir().join(format!("dmnote-rehome-{label}-{}", uuid::Uuid::new_v4())) - } - - // Windows에선 /tmp가 절대 경로가 아니므로 픽스처는 플랫폼별 절대 경로 사용 - fn absolute_fixture_path(name: &str) -> String { - if cfg!(target_os = "windows") { - format!(r"C:\tmp\{name}") - } else { - format!("/tmp/{name}") - } - } - - fn data_with_one_position() -> AppStoreData { - AppStoreData { - key_positions: default_positions().clone(), - ..AppStoreData::default() - } - } - - #[test] - fn missing_note_gradient_multipliers_default_to_one_hundred_on_all_collections() { - let gradient = serde_json::json!({ - "angle": 90, - "stops": [ - { "color": "#112233", "pos": 0 }, - { "color": "#445566", "pos": 1 } - ] - }); - let mut value = serde_json::json!({ - "keyPositions": { "mode": [{ "noteGradient": gradient.clone() }] }, - "statPositions": { "mode": [{ "noteGlowGradient": gradient.clone() }] }, - "graphPositions": { "mode": [{ "noteGradient": gradient.clone() }] }, - "knobPositions": { "mode": [{ - "noteGradient": gradient, - "noteGlowGradient": null - }] } - }); - - // 보정 발생 시 true 반환 - needs_persist로 전파되어 디스크에도 영속 - assert!(super::default_store_note_gradient_multipliers(&mut value)); - - assert_eq!(value["keyPositions"]["mode"][0]["noteOpacity"], 100); - assert_eq!(value["statPositions"]["mode"][0]["noteGlowOpacity"], 100); - assert_eq!(value["graphPositions"]["mode"][0]["noteOpacity"], 100); - assert_eq!(value["knobPositions"]["mode"][0]["noteOpacity"], 100); - assert!(value["knobPositions"]["mode"][0] - .get("noteGlowOpacity") - .is_none()); - - // 이미 보정된 store 재로드는 무변경 - 반복 영속 방지 - assert!(!super::default_store_note_gradient_multipliers(&mut value)); - } - - fn tauri_store_fixture_base() -> serde_json::Value { - serde_json::json!({ - "hardwareAcceleration": true, - "alwaysOnTop": false, - "overlayLocked": false, - "noteEffect": true, - "selectedKeyType": "fixture-tab", - "customTabs": [{ "id": "fixture-tab", "name": "Fixture" }], - "angleMode": "d3d11", - "language": "ko", - "laboratoryEnabled": false, - "keys": { "fixture-tab": ["F13"] }, - "keyPositions": { - "fixture-tab": [{ - "dx": 13.0, - "dy": 14.0, - "width": 60.0, - "height": 60.0, - "activeImage": "", - "inactiveImage": "", - "count": 0, - "noteColor": "#FFFFFF", - "noteOpacity": 80 - }] - }, - "keyCounters": { "fixture-tab": { "F13": 17 } }, - "backgroundColor": "#131415", - "useCustomCss": false, - "customCss": { "path": null, "content": "" }, - "overlayResizeAnchor": "top-left", - "overlayBounds": null, - "overlayLastContentTopOffset": null, - "overlayBoundsAreLogical": false, - "keyCounterEnabled": true - }) - } - - fn load_literal_fixture(version: &str, fixture: &serde_json::Value) -> AppStoreData { - let path = std::env::temp_dir().join(format!( - "dmnote-tauri-{version}-store-fixture-{}.json", - uuid::Uuid::new_v4() - )); - std::fs::write(&path, serde_json::to_vec_pretty(fixture).unwrap()).unwrap(); - let loaded = load_store_from_path(&path) - .unwrap_or_else(|error| panic!("Tauri {version} fixture must load: {error:#}")); - let _ = std::fs::remove_file(path); - loaded.data - } - - fn store_with_each_native_collection() -> AppStoreData { - let mut data = normalize_state(AppStoreData { - keys: default_keys().clone(), - key_positions: default_positions().clone(), - ..AppStoreData::default() - }); - data.stat_positions.insert( - "4key".to_string(), - vec![StatPosition { - stat_type: StatType::Kps, - position: KeyPosition { - dx: 101.0, - ..KeyPosition::default() - }, - }], - ); - data.graph_positions.insert( - "4key".to_string(), - vec![GraphPosition { - stat_type: GraphStatType::Kps, - graph_type: GraphType::Line, - graph_speed: 100, - graph_color: "#123456".to_string(), - show_avg_line: true, - position: KeyPosition { - dx: 102.0, - ..KeyPosition::default() - }, - }], - ); - data.knob_positions.insert( - "4key".to_string(), - vec![KnobPosition { - axis_id: "axis".to_string(), - sensitivity: 1.0, - reverse: false, - position: KeyPosition { - dx: 103.0, - ..KeyPosition::default() - }, - }], - ); - crate::state::native_element_id::backfill_store_element_ids(&mut data); - data - } - - fn remove_all_native_ids(value: &mut serde_json::Value) { - for field in POSITION_COLLECTION_FIELDS { - let Some(modes) = value - .get_mut(field) - .and_then(serde_json::Value::as_object_mut) - else { - continue; - }; - for elements in modes - .values_mut() - .filter_map(serde_json::Value::as_array_mut) - { - for element in elements { - if let Some(element) = element.as_object_mut() { - element.remove("id"); - } - } - } - } - } - - #[test] - fn legacy_store_backfills_all_native_ids_and_reload_preserves_them() { - let path = std::env::temp_dir().join(format!( - "dmnote-native-id-backfill-{}.json", - uuid::Uuid::new_v4() - )); - let mut raw = serde_json::to_value(store_with_each_native_collection()).unwrap(); - remove_all_native_ids(&mut raw); - std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - let document = crate::models::EditorDocumentV1::from_store(&loaded.data); - crate::state::native_element_id::validate_document_element_ids(&document).unwrap(); - let first_ids = [ - loaded.data.key_positions["4key"][0].id.clone(), - loaded.data.stat_positions["4key"][0].position.id.clone(), - loaded.data.graph_positions["4key"][0].position.id.clone(), - loaded.data.knob_positions["4key"][0].position.id.clone(), - ]; - assert_eq!( - first_ids - .iter() - .collect::>() - .len(), - 4 - ); - assert!(loaded.needs_persist); - assert!(!loaded.repaired); - - std::fs::write(&path, serde_json::to_vec_pretty(&loaded.data).unwrap()).unwrap(); - let reloaded = load_store_from_path(&path).unwrap(); - let second_ids = [ - reloaded.data.key_positions["4key"][0].id.clone(), - reloaded.data.stat_positions["4key"][0].position.id.clone(), - reloaded.data.graph_positions["4key"][0].position.id.clone(), - reloaded.data.knob_positions["4key"][0].position.id.clone(), - ]; - assert_eq!(second_ids, first_ids); - assert!(!reloaded.needs_persist); - assert!(!reloaded.repaired); - let _ = std::fs::remove_file(path); - } - - #[test] - fn missing_note_gradient_multiplier_persists_on_first_load_and_converges() { - let path = std::env::temp_dir().join(format!( - "dmnote-note-gradient-multiplier-{}.json", - uuid::Uuid::new_v4() - )); - let mut raw = serde_json::to_value(store_with_each_native_collection()).unwrap(); - let position = &mut raw["keyPositions"]["4key"][0]; - position["noteGradient"] = serde_json::json!({ - "angle": 90, - "stops": [ - { "color": "#112233", "pos": 0 }, - { "color": "#445566", "pos": 1 } - ] - }); - position.as_object_mut().unwrap().remove("noteOpacity"); - std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); - - // 배율 부재 보정이 유일한 변경이어도 첫 로드는 영속 대상 - let loaded = load_store_from_path(&path).unwrap(); - assert_eq!(loaded.data.key_positions["4key"][0].note_opacity, 100); - assert!(loaded.needs_persist); - - // 저장 후 재로드는 무변경으로 수렴 - 시작마다 보정 반복 방지 - std::fs::write(&path, serde_json::to_vec_pretty(&loaded.data).unwrap()).unwrap(); - let reloaded = load_store_from_path(&path).unwrap(); - assert_eq!(reloaded.data.key_positions["4key"][0].note_opacity, 100); - assert!(!reloaded.needs_persist); - let _ = std::fs::remove_file(path); - } - - #[test] - fn serialized_undo_history_with_removed_outline_fields_loads_as_opaque_data() { - let path = std::env::temp_dir().join(format!( - "dmnote-removed-outline-history-{}.json", - uuid::Uuid::new_v4() - )); - let baseline = store_with_each_native_collection(); - let mut raw = serde_json::to_value(&baseline).unwrap(); - let saved_history = serde_json::json!({ - "past": [{ - "scope": "editor", - "before": { - "kind": "editor", - "value": { - "changedFields": ["keyPositions"], - "before": { - "keyPositions": { - "4key": [{ - "fontStrokeColor": "#112233", - "activeFontStrokeColor": "#445566", - "counter": { - "stroke": { - "idle": "#000000", - "active": "#FFFFFF" - }, - "strokeIdleGradient": { - "angle": 90, - "stops": [] - } - } - }] - } - } - } - } - }], - "future": [] - }); - raw.as_object_mut() - .unwrap() - .insert("history".to_string(), saved_history.clone()); - std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - assert!(!loaded.needs_persist); - assert!(!loaded.repaired); - assert_eq!(loaded.data.key_positions, baseline.key_positions); - assert_eq!(loaded.data.stat_positions, baseline.stat_positions); - assert_eq!(loaded.data.graph_positions, baseline.graph_positions); - assert_eq!(loaded.data.knob_positions, baseline.knob_positions); - assert_eq!(loaded.data.plugin_data.get("history"), Some(&saved_history)); - let _ = std::fs::remove_file(path); - } - - #[test] - fn invalid_and_duplicate_ids_are_repaired_without_touching_assets() { - let path = std::env::temp_dir().join(format!( - "dmnote-native-id-repair-{}.json", - uuid::Uuid::new_v4() - )); - let mut data = store_with_each_native_collection(); - data.stat_positions.get_mut("4key").unwrap()[0] - .position - .active_image = Some("/images/kept.png".to_string()); - data.stat_positions.get_mut("4key").unwrap()[0] - .position - .sound_path = Some("/sounds/kept.wav".to_string()); - let kept_key_id = data.key_positions["4key"][0].id.clone(); - let kept_knob_id = data.knob_positions["4key"][0].position.id.clone(); - let old_stat_id = data.stat_positions["4key"][0].position.id.clone(); - let old_graph_id = data.graph_positions["4key"][0].position.id.clone(); - let mut raw = serde_json::to_value(data).unwrap(); - raw["statPositions"]["4key"][0]["id"] = serde_json::json!(kept_key_id); - raw["graphPositions"]["4key"][0]["id"] = serde_json::json!(""); - std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - - assert!(loaded.needs_persist); - assert!(loaded.repaired); - assert_eq!(loaded.data.key_positions["4key"][0].id, kept_key_id); - assert_eq!( - loaded.data.knob_positions["4key"][0].position.id, - kept_knob_id - ); - assert_ne!( - loaded.data.stat_positions["4key"][0].position.id, - old_stat_id - ); - assert_ne!( - loaded.data.graph_positions["4key"][0].position.id, - old_graph_id - ); - assert_eq!( - loaded.data.stat_positions["4key"][0] - .position - .active_image - .as_deref(), - Some("/images/kept.png") - ); - assert_eq!( - loaded.data.stat_positions["4key"][0] - .position - .sound_path - .as_deref(), - Some("/sounds/kept.wav") - ); - let _ = std::fs::remove_file(path); - } - - #[test] - fn recovery_and_pair_padding_preserve_surviving_ids_before_backfill() { - let path = std::env::temp_dir().join(format!( - "dmnote-native-id-recovery-order-{}.json", - uuid::Uuid::new_v4() - )); - let data = store_with_each_native_collection(); - let surviving_key_id = data.key_positions["4key"][0].id.clone(); - let surviving_stat_id = data.stat_positions["4key"][0].position.id.clone(); - let original_position_len = data.key_positions["4key"].len(); - let mut raw = serde_json::to_value(data).unwrap(); - raw["keys"]["4key"] - .as_array_mut() - .unwrap() - .push(serde_json::json!("F24")); - raw["statPositions"]["4key"][0]["dx"] = serde_json::json!("broken"); - std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - - assert!(loaded.repaired); - assert_eq!( - loaded.data.key_positions["4key"].len(), - original_position_len + 1 - ); - assert_eq!(loaded.data.key_positions["4key"][0].id, surviving_key_id); - assert_eq!( - loaded.data.stat_positions["4key"][0].position.id, - surviving_stat_id - ); - assert!(crate::state::native_element_id::is_valid_element_id( - &loaded.data.key_positions["4key"][original_position_len].id - )); - crate::state::native_element_id::validate_document_element_ids( - &crate::models::EditorDocumentV1::from_store(&loaded.data), - ) - .unwrap(); - let _ = std::fs::remove_file(path); - } - - fn saved_plugin_instance_json(x: f64, instance_id: Option<&str>) -> serde_json::Value { - let mut instance = serde_json::json!({ - "position": { "x": x, "y": 2.0 }, - "tabId": "4key" - }); - if let Some(id) = instance_id { - instance["instanceId"] = serde_json::json!(id); - } - instance - } - - fn stored_plugin_instance_ids(data: &AppStoreData, plugin_id: &str) -> Vec { - data.plugin_data[&crate::state::plugin::plugin_instances_storage_key(plugin_id)] - .as_array() - .unwrap() - .iter() - .map(|instance| instance["instanceId"].as_str().unwrap().to_string()) - .collect() - } - - fn plugin_backfill_fixture_path(label: &str) -> std::path::PathBuf { - std::env::temp_dir().join(format!( - "dmnote-plugin-instance-backfill-{label}-{}.json", - uuid::Uuid::new_v4() - )) - } - - #[test] - fn backfill_plugin_instances_assigns_unique_ids_and_reload_preserves_them() { - let path = plugin_backfill_fixture_path("assign"); - let mut raw = serde_json::to_value(store_with_each_native_collection()).unwrap(); - let raw_object = raw.as_object_mut().unwrap(); - raw_object.insert( - "plugin_data_alpha/instances".to_string(), - serde_json::json!([ - saved_plugin_instance_json(1.0, None), - saved_plugin_instance_json(2.0, None) - ]), - ); - raw_object.insert( - "plugin_data_beta/instances".to_string(), - serde_json::json!([saved_plugin_instance_json(3.0, None)]), - ); - std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - assert!(loaded.needs_persist); - assert!(!loaded.repaired); - let alpha_ids = stored_plugin_instance_ids(&loaded.data, "alpha"); - let beta_ids = stored_plugin_instance_ids(&loaded.data, "beta"); - let all_ids = alpha_ids.iter().chain(&beta_ids).collect::>(); - assert_eq!(all_ids.len(), 3); - assert!(all_ids - .iter() - .all(|id| crate::state::native_element_id::is_valid_element_id(id))); - assert_eq!( - all_ids - .iter() - .collect::>() - .len(), - 3 - ); - - // 멱등: 영속 후 재로드에서 같은 ID 유지, 추가 변경 없음 - std::fs::write(&path, serde_json::to_vec_pretty(&loaded.data).unwrap()).unwrap(); - let reloaded = load_store_from_path(&path).unwrap(); - assert!(!reloaded.needs_persist); - assert!(!reloaded.repaired); - assert_eq!( - stored_plugin_instance_ids(&reloaded.data, "alpha"), - alpha_ids - ); - assert_eq!(stored_plugin_instance_ids(&reloaded.data, "beta"), beta_ids); - let _ = std::fs::remove_file(path); - } - - #[test] - fn backfill_plugin_instances_preserves_malformed_siblings_across_reload() { - let path = plugin_backfill_fixture_path("mixed"); - let existing_id = uuid::Uuid::new_v4().to_string(); - let malformed = serde_json::json!({ "position": "broken", "keep": true }); - let mut raw = serde_json::to_value(store_with_each_native_collection()).unwrap(); - raw.as_object_mut().unwrap().insert( - "plugin_data_alpha/instances".to_string(), - serde_json::json!([ - saved_plugin_instance_json(1.0, Some(&existing_id)), - saved_plugin_instance_json(2.0, None), - malformed.clone() - ]), - ); - std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - assert!(loaded.needs_persist); - assert!(!loaded.repaired); - let key = crate::state::plugin::plugin_instances_storage_key("alpha"); - let entries = loaded.data.plugin_data[&key].as_array().unwrap(); - assert_eq!(entries.len(), 3); - assert_eq!(entries[0]["instanceId"], existing_id); - assert!(crate::state::native_element_id::is_valid_element_id( - entries[1]["instanceId"].as_str().unwrap() - )); - assert_eq!(entries[2], malformed); - let persisted_bucket = loaded.data.plugin_data[&key].clone(); - - std::fs::write(&path, serde_json::to_vec_pretty(&loaded.data).unwrap()).unwrap(); - let reloaded = load_store_from_path(&path).unwrap(); - assert!(!reloaded.needs_persist); - assert!(!reloaded.repaired); - assert_eq!(reloaded.data.plugin_data[&key], persisted_bucket); - let _ = std::fs::remove_file(path); - } - - #[test] - fn backfill_plugin_instances_preserves_existing_valid_ids() { - let path = plugin_backfill_fixture_path("partial"); - let existing_id = uuid::Uuid::new_v4().to_string(); - let mut raw = serde_json::to_value(store_with_each_native_collection()).unwrap(); - raw.as_object_mut().unwrap().insert( - "plugin_data_alpha/instances".to_string(), - serde_json::json!([ - saved_plugin_instance_json(1.0, Some(&existing_id)), - saved_plugin_instance_json(2.0, None) - ]), - ); - std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - assert!(loaded.needs_persist); - assert!(!loaded.repaired); - let ids = stored_plugin_instance_ids(&loaded.data, "alpha"); - assert_eq!(ids[0], existing_id); - assert_ne!(ids[1], existing_id); - assert!(crate::state::native_element_id::is_valid_element_id( - &ids[1] - )); - // 값 필드와 순서 보존 - let instances = loaded.data.plugin_data - [&crate::state::plugin::plugin_instances_storage_key("alpha")] - .as_array() - .unwrap(); - assert_eq!(instances[0]["position"]["x"], 1.0); - assert_eq!(instances[1]["position"]["x"], 2.0); - let _ = std::fs::remove_file(path); - } - - #[test] - fn backfill_plugin_instances_reissues_invalid_ids_as_repair() { - for invalid_id in ["not-a-uuid".to_string(), uuid::Uuid::nil().to_string()] { - let path = plugin_backfill_fixture_path("invalid"); - let mut raw = serde_json::to_value(store_with_each_native_collection()).unwrap(); - raw.as_object_mut().unwrap().insert( - "plugin_data_alpha/instances".to_string(), - serde_json::json!([saved_plugin_instance_json(1.0, Some(&invalid_id))]), - ); - std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - assert!(loaded.needs_persist); - assert!(loaded.repaired); - let ids = stored_plugin_instance_ids(&loaded.data, "alpha"); - assert_ne!(ids[0], invalid_id); - assert!(crate::state::native_element_id::is_valid_element_id( - &ids[0] - )); - let _ = std::fs::remove_file(path); - } - } - - #[test] - fn backfill_plugin_instances_repairs_in_plugin_duplicates_only() { - let path = plugin_backfill_fixture_path("duplicate"); - let shared_id = uuid::Uuid::new_v4().to_string(); - let mut raw = serde_json::to_value(store_with_each_native_collection()).unwrap(); - let raw_object = raw.as_object_mut().unwrap(); - raw_object.insert( - "plugin_data_alpha/instances".to_string(), - serde_json::json!([ - saved_plugin_instance_json(1.0, Some(&shared_id)), - saved_plugin_instance_json(2.0, Some(&shared_id)) - ]), - ); - raw_object.insert( - "plugin_data_beta/instances".to_string(), - serde_json::json!([saved_plugin_instance_json(3.0, Some(&shared_id))]), - ); - std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - assert!(loaded.needs_persist); - assert!(loaded.repaired); - // 유일성은 커밋 검증과 같은 플러그인 키 단위 - alpha 내 중복만 수리, - // 교차 중복인 beta[0]은 합법이라 보존 - let alpha_ids = stored_plugin_instance_ids(&loaded.data, "alpha"); - let beta_ids = stored_plugin_instance_ids(&loaded.data, "beta"); - assert_eq!(alpha_ids[0], shared_id); - assert_ne!(alpha_ids[1], shared_id); - assert!(crate::state::native_element_id::is_valid_element_id( - &alpha_ids[1] - )); - assert_eq!(beta_ids, vec![shared_id]); - let _ = std::fs::remove_file(path); - } - - #[test] - fn backfill_plugin_instances_preserves_cross_plugin_duplicates_untouched() { - let path = plugin_backfill_fixture_path("cross"); - let shared_id = uuid::Uuid::new_v4().to_string(); - let mut raw = serde_json::to_value(store_with_each_native_collection()).unwrap(); - let raw_object = raw.as_object_mut().unwrap(); - raw_object.insert( - "plugin_data_alpha/instances".to_string(), - serde_json::json!([saved_plugin_instance_json(1.0, Some(&shared_id))]), - ); - raw_object.insert( - "plugin_data_beta/instances".to_string(), - serde_json::json!([saved_plugin_instance_json(2.0, Some(&shared_id))]), - ); - std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - // 교차 플러그인 중복만으로는 수리 대상이 아니다 - assert!(!loaded.needs_persist); - assert!(!loaded.repaired); - assert_eq!( - stored_plugin_instance_ids(&loaded.data, "alpha"), - vec![shared_id.clone()] - ); - assert_eq!( - stored_plugin_instance_ids(&loaded.data, "beta"), - vec![shared_id] - ); - let _ = std::fs::remove_file(path); - } - - #[test] - fn backfill_plugin_instances_skips_undecodable_values_and_processes_the_rest() { - let path = plugin_backfill_fixture_path("undecodable"); - let not_an_array = serde_json::json!({ "not": "array" }); - let unknown_field = serde_json::json!([{ - "position": { "x": 1.0, "y": 2.0 }, - "tabId": "4key", - "handler": true - }]); - let mut raw = serde_json::to_value(store_with_each_native_collection()).unwrap(); - let raw_object = raw.as_object_mut().unwrap(); - raw_object.insert( - "plugin_data_alpha/instances".to_string(), - serde_json::json!([saved_plugin_instance_json(1.0, None)]), - ); - raw_object.insert( - "plugin_data_broken/instances".to_string(), - not_an_array.clone(), - ); - raw_object.insert( - "plugin_data_weird/instances".to_string(), - unknown_field.clone(), - ); - std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - assert!(loaded.needs_persist); - assert!(!loaded.repaired); - let alpha_ids = stored_plugin_instance_ids(&loaded.data, "alpha"); - assert!(crate::state::native_element_id::is_valid_element_id( - &alpha_ids[0] - )); - // decode 불가 키는 원본 Value 그대로 보존 (런타임 read의 fail-closed에 위임) - assert_eq!( - loaded.data.plugin_data["plugin_data_broken/instances"], - not_an_array - ); - assert_eq!( - loaded.data.plugin_data["plugin_data_weird/instances"], - unknown_field - ); - let _ = std::fs::remove_file(path); - } - - #[test] - fn backfill_plugin_instances_runs_after_field_recovery() { - let path = plugin_backfill_fixture_path("recovery"); - let mut raw = serde_json::to_value(store_with_each_native_collection()).unwrap(); - raw["statPositions"]["4key"][0]["dx"] = serde_json::json!("broken"); - raw.as_object_mut().unwrap().insert( - "plugin_data_alpha/instances".to_string(), - serde_json::json!([saved_plugin_instance_json(1.0, None)]), - ); - std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - assert!(loaded.needs_persist); - assert!(loaded.repaired); - let ids = stored_plugin_instance_ids(&loaded.data, "alpha"); - assert_eq!(ids.len(), 1); - assert!(crate::state::native_element_id::is_valid_element_id( - &ids[0] - )); - let _ = std::fs::remove_file(path); - } - - #[test] - fn backfill_plugin_instances_leaves_empty_arrays_untouched() { - let path = plugin_backfill_fixture_path("empty"); - let mut raw = serde_json::to_value(store_with_each_native_collection()).unwrap(); - raw.as_object_mut().unwrap().insert( - "plugin_data_alpha/instances".to_string(), - serde_json::json!([]), - ); - std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - // 빈 배열은 사용자 storage로 보존 - assert!(!loaded.needs_persist); - assert!(!loaded.repaired); - assert_eq!( - loaded.data.plugin_data["plugin_data_alpha/instances"], - serde_json::json!([]) - ); - let _ = std::fs::remove_file(path); - } - - #[test] - fn legacy_panel_detach_setting_is_removed_without_touching_plugin_data() { - let path = std::env::temp_dir().join(format!( - "dmnote-panel-detach-setting-migration-{}.json", - uuid::Uuid::new_v4() - )); - let data = normalize_state(AppStoreData { - keys: default_keys().clone(), - key_positions: default_positions().clone(), - ..AppStoreData::default() - }); - let mut raw = serde_json::to_value(data).unwrap(); - let raw_object = raw.as_object_mut().unwrap(); - raw_object.insert( - LEGACY_PANEL_DETACH_ENABLED_KEY.to_string(), - serde_json::json!(false), - ); - raw_object.insert( - "plugin_data_fixture/settings".to_string(), - serde_json::json!({ "kept": true }), - ); - std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - assert!(loaded.needs_persist); - assert!(!loaded.repaired); - assert!(!loaded - .data - .plugin_data - .contains_key(LEGACY_PANEL_DETACH_ENABLED_KEY)); - assert_eq!( - loaded.data.plugin_data["plugin_data_fixture/settings"]["kept"], - true - ); - - std::fs::write(&path, serde_json::to_vec_pretty(&loaded.data).unwrap()).unwrap(); - let reloaded = load_store_from_path(&path).unwrap(); - assert!(!reloaded.needs_persist); - assert!(!reloaded.repaired); - assert!(!reloaded - .data - .plugin_data - .contains_key(LEGACY_PANEL_DETACH_ENABLED_KEY)); - assert_eq!( - reloaded.data.plugin_data["plugin_data_fixture/settings"]["kept"], - true - ); - let _ = std::fs::remove_file(path); - } - - #[test] - fn legacy_store_without_gradient_fields_preserves_position_bytes() { - let path = std::env::temp_dir().join(format!( - "dmnote-no-gradient-byte-round-trip-{}.json", - uuid::Uuid::new_v4() - )); - let data = normalize_state(AppStoreData { - keys: default_keys().clone(), - key_positions: default_positions().clone(), - ..AppStoreData::default() - }); - let original_position = data.key_positions["4key"][0].clone(); - let original = serde_json::to_vec_pretty(&data).unwrap(); - assert!(!String::from_utf8_lossy(&original).contains("Gradient")); - std::fs::write(&path, &original).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - let mut reloaded_position = loaded.data.key_positions["4key"][0].clone(); - reloaded_position.id.clear(); - - assert!(loaded.needs_persist); - assert!(!loaded.repaired); - assert_eq!(reloaded_position, original_position); - let _ = std::fs::remove_file(path); - } - - #[test] - fn noncanonical_gradient_store_repersist_and_reload_is_idempotent() { - let path = std::env::temp_dir().join(format!( - "dmnote-gradient-canonical-reload-{}.json", - uuid::Uuid::new_v4() - )); - let data = normalize_state(AppStoreData { - keys: default_keys().clone(), - key_positions: default_positions().clone(), - ..AppStoreData::default() - }); - let mut raw = serde_json::to_value(data).unwrap(); - let position = &mut raw["keyPositions"]["4key"][0]; - position["backgroundColor"] = serde_json::json!("#BADBAD"); - position["backgroundGradient"] = serde_json::json!({ - "type": "linear", - "angle": 450, - "stops": [ - { "color": "rgba(90, 162, 247, 1)", "pos": 1.4 }, - { "color": "rgba(139, 92, 246, 1)", "pos": -0.2 } - ] - }); - position["fontColor"] = serde_json::json!("stale-font"); - position["fontGradient"] = serde_json::json!({ - "angle": -270, - "stops": [ - { "color": "#123456", "pos": 0 }, - { "color": "#ABCDEF", "pos": 1 } - ] - }); - position["counter"]["fill"]["idle"] = serde_json::json!("#FFFFFF"); - position["counter"]["fillIdleGradient"] = serde_json::json!({ - "stops": [ - { "color": "#FFFFFF", "pos": 0 }, - { "color": "#000000", "pos": 1 } - ] - }); - std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - let position = &loaded.data.key_positions["4key"][0]; - assert!(loaded.needs_persist); - assert!(loaded.repaired); - assert_eq!( - position.background_color.as_deref(), - Some("rgba(139, 92, 246, 1)") - ); - assert_eq!(position.background_gradient.as_ref().unwrap().angle, 90.0); - assert_eq!( - position.background_gradient.as_ref().unwrap().stops[0].pos, - 0.0 - ); - assert_eq!(position.font_color.as_deref(), Some("#123456")); - assert_eq!(position.font_gradient.as_ref().unwrap().angle, 90.0); - assert_eq!(position.counter.fill.idle, "rgba(255,255,255,1)"); - - std::fs::write(&path, serde_json::to_vec_pretty(&loaded.data).unwrap()).unwrap(); - let reloaded = load_store_from_path(&path).unwrap(); - assert!(!reloaded.needs_persist); - assert!(!reloaded.repaired); - assert_eq!(reloaded.data, loaded.data); - let _ = std::fs::remove_file(path); - } - - #[test] - fn blank_font_color_normalization_requests_one_canonical_persist() { - let path = std::env::temp_dir().join(format!( - "dmnote-blank-font-color-reload-{}.json", - uuid::Uuid::new_v4() - )); - let data = normalize_state(AppStoreData { - keys: default_keys().clone(), - key_positions: default_positions().clone(), - ..AppStoreData::default() - }); - std::fs::write(&path, serde_json::to_vec_pretty(&data).unwrap()).unwrap(); - let seeded = load_store_from_path(&path).unwrap(); - std::fs::write(&path, serde_json::to_vec_pretty(&seeded.data).unwrap()).unwrap(); - let canonical = load_store_from_path(&path).unwrap(); - assert!(!canonical.needs_persist); - assert!(!canonical.repaired); - - let mut raw = serde_json::to_value(&canonical.data).unwrap(); - raw["keyPositions"]["4key"][0]["fontColor"] = serde_json::json!(" \t "); - raw["keyPositions"]["4key"][0]["activeFontColor"] = serde_json::json!(""); - std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); - - let normalized = load_store_from_path(&path).unwrap(); - assert!(normalized.needs_persist); - assert!(!normalized.repaired); - assert!(normalized.data.key_positions["4key"][0] - .font_color - .is_none()); - assert!(normalized.data.key_positions["4key"][0] - .active_font_color - .is_none()); - - std::fs::write(&path, serde_json::to_vec_pretty(&normalized.data).unwrap()).unwrap(); - let reloaded = load_store_from_path(&path).unwrap(); - assert!(!reloaded.needs_persist); - assert!(!reloaded.repaired); - assert_eq!(reloaded.data, normalized.data); - let _ = std::fs::remove_file(path); - } - - #[test] - fn blank_font_gradient_stop_repairs_once_and_converges_on_reload() { - let path = std::env::temp_dir().join(format!( - "dmnote-blank-font-gradient-stop-{}.json", - uuid::Uuid::new_v4() - )); - let data = normalize_state(AppStoreData { - keys: default_keys().clone(), - key_positions: default_positions().clone(), - ..AppStoreData::default() - }); - std::fs::write(&path, serde_json::to_vec_pretty(&data).unwrap()).unwrap(); - let seeded = load_store_from_path(&path).unwrap(); - std::fs::write(&path, serde_json::to_vec_pretty(&seeded.data).unwrap()).unwrap(); - let canonical = load_store_from_path(&path).unwrap(); - assert!(!canonical.needs_persist); - assert!(!canonical.repaired); - - // 공백 첫 stop: 대표색 동기와 공백 정규화가 서로 되돌려 복구가 반복되던 입력 - let mut raw = serde_json::to_value(&canonical.data).unwrap(); - raw["keyPositions"]["4key"][0]["fontGradient"] = serde_json::json!({ - "angle": 45, - "stops": [ - { "color": " ", "pos": 0 }, - { "color": "#445566", "pos": 1 } - ] - }); - std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); - - // 첫 로드는 그라데이션을 내리고 한 번만 복구로 기록 - let repaired = load_store_from_path(&path).unwrap(); - assert!(repaired.needs_persist); - assert!(repaired.repaired); - assert!(repaired.data.key_positions["4key"][0] - .font_gradient - .is_none()); - - // 저장 후 재로드는 무변경 - repaired 반복으로 자산 sweep이 계속 막히지 않게 - std::fs::write(&path, serde_json::to_vec_pretty(&repaired.data).unwrap()).unwrap(); - let reloaded = load_store_from_path(&path).unwrap(); - assert!(!reloaded.needs_persist); - assert!(!reloaded.repaired); - assert_eq!(reloaded.data, repaired.data); - let _ = std::fs::remove_file(path); - } - - #[test] - fn knob_note_border_gradient_and_rgba_migration_converge_in_either_order() { - let mut knob = KnobPosition { - axis_id: "axis".to_string(), - sensitivity: 1.0, - reverse: false, - position: KeyPosition::default(), - }; - knob.position.note_border_color = Some("rgba(17, 34, 51, 0.5)".to_string()); - knob.position.note_border_gradient = serde_json::from_value(serde_json::json!({ - "angle": 90, - "stops": [ - { "color": "rgba(17, 34, 51, 0.5)", "pos": 0 }, - { "color": "#ABC8", "pos": 1 } - ] - })) - .unwrap(); - let mut source = AppStoreData::default(); - source - .knob_positions - .insert("custom".to_string(), vec![knob]); - - let mut canonical_first = source.clone(); - assert_eq!( - super::canonicalize_gradient_pairs(&mut canonical_first), - (true, true) - ); - let canonical_first = normalize_state(canonical_first); - - let mut migration_first = normalize_state(source); - assert_eq!( - super::canonicalize_gradient_pairs(&mut migration_first), - (false, false) - ); - - assert_eq!(canonical_first, migration_first); - assert_eq!( - migration_first.knob_positions["custom"][0] - .position - .note_border_color - .as_deref(), - Some("#112233") - ); - } - - #[test] - fn invalid_counter_gradient_children_recover_without_losing_counter_siblings() { - let path = std::env::temp_dir().join(format!( - "dmnote-counter-gradient-child-recovery-{}.json", - uuid::Uuid::new_v4() - )); - let mut key_position = default_positions()["4key"][0].clone(); - key_position.counter.enabled = false; - key_position.counter.placement = KeyCounterPlacement::Outside; - key_position.counter.align = KeyCounterAlign::Left; - key_position.counter.align_mode = KeyCounterAlignMode::Between; - key_position.counter.fill = KeyCounterColor { - idle: "#112233".to_string(), - active: "#445566".to_string(), - }; - key_position.counter.gap = 17; - key_position.counter.font_size = 33; - key_position.counter.font_weight = 600; - key_position.counter.font_family = Some("Recovery Font".to_string()); - key_position.counter.font_italic = true; - key_position.counter.font_underline = true; - key_position.counter.font_strikethrough = true; - key_position.counter.animation.enabled = true; - key_position.counter.animation.preset_id = Some("custom-recovery".to_string()); - key_position.counter.animation.bezier = [0.1, 0.2, 0.7, 0.8]; - key_position.counter.animation.scale = 1.25; - key_position.counter.animation.duration_ms = 777; - key_position.note_color = NoteColor::Gradient { - top: "legacy-top".to_string(), - bottom: "legacy-bottom".to_string(), - }; - key_position.note_opacity_top = Some(23); - key_position.note_opacity_bottom = Some(67); - let expected_key_counter = key_position.counter.clone(); - let expected_key_note_color = key_position.note_color.clone(); - - let mut stat_position = StatPosition { - stat_type: StatType::Kps, - position: key_position.clone(), - }; - stat_position.position.counter.fill.idle = "#ABCDEF".to_string(); - stat_position.position.note_glow_color = Some(NoteColor::Gradient { - top: "legacy-glow-top".to_string(), - bottom: "legacy-glow-bottom".to_string(), - }); - stat_position.position.note_glow_opacity_top = Some(34); - stat_position.position.note_glow_opacity_bottom = Some(76); - let expected_stat_counter = stat_position.position.counter.clone(); - let expected_stat_glow_color = stat_position.position.note_glow_color.clone(); - - let mut data = normalize_state(AppStoreData { - keys: default_keys().clone(), - key_positions: default_positions().clone(), - ..AppStoreData::default() - }); - data.key_positions.get_mut("4key").unwrap()[0] = key_position; - data.stat_positions - .insert("4key".to_string(), vec![stat_position]); - let mut raw = serde_json::to_value(data).unwrap(); - raw["keyPositions"]["4key"][0]["noteBorderGradient"] = serde_json::json!({ - "angle": 90, - "stops": [{ "color": "#112233", "pos": 0 }] - }); - raw["keyPositions"]["4key"][0]["noteGradient"] = serde_json::json!({ - "angle": 90, - "stops": [{ "color": "#112233", "pos": 0 }] - }); - raw["keyPositions"]["4key"][0]["counter"]["fillIdleGradient"] = serde_json::json!({ - "angle": 90, - "stops": [{ "color": "#FFFFFF", "pos": 0 }] - }); - raw["statPositions"]["4key"][0]["counter"]["fillActiveGradient"] = serde_json::json!({ - "angle": 90, - "stops": [{ "color": "#000000", "pos": 1 }] - }); - raw["statPositions"]["4key"][0]["noteGlowGradient"] = serde_json::json!({ - "angle": 90, - "stops": [{ "color": "#AABBCC", "pos": 1 }] - }); - std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - let key_counter = &loaded.data.key_positions["4key"][0].counter; - let stat_counter = &loaded.data.stat_positions["4key"][0].position.counter; - - assert!(loaded.needs_persist); - assert!(loaded.repaired); - assert_eq!(key_counter, &expected_key_counter); - assert_eq!(stat_counter, &expected_stat_counter); - assert!(loaded.data.key_positions["4key"][0].note_gradient.is_none()); - assert_eq!( - loaded.data.key_positions["4key"][0].note_color, - expected_key_note_color - ); - assert_eq!( - loaded.data.key_positions["4key"][0].note_opacity_top, - Some(23) - ); - assert_eq!( - loaded.data.key_positions["4key"][0].note_opacity_bottom, - Some(67) - ); - assert!(loaded.data.stat_positions["4key"][0] - .position - .note_glow_gradient - .is_none()); - assert_eq!( - loaded.data.stat_positions["4key"][0] - .position - .note_glow_color, - expected_stat_glow_color - ); - assert!(loaded.data.key_positions["4key"][0] - .note_border_gradient - .is_none()); - assert!(key_counter.fill_idle_gradient.is_none()); - assert!(stat_counter.fill_active_gradient.is_none()); - let _ = std::fs::remove_file(path); - } - - #[test] - fn invalid_font_gradient_fields_recover_in_place_on_every_position_collection() { - let path = std::env::temp_dir().join(format!( - "dmnote-font-gradient-field-recovery-{}.json", - uuid::Uuid::new_v4() - )); - let mut data = normalize_state(AppStoreData { - keys: default_keys().clone(), - key_positions: default_positions().clone(), - ..AppStoreData::default() - }); - let mut template = data.key_positions["4key"][0].clone(); - template.font_color = Some("font-sibling".to_string()); - template.active_font_color = Some("active-font-sibling".to_string()); - let key_id = uuid::Uuid::new_v4().to_string(); - let key = &mut data.key_positions.get_mut("4key").unwrap()[0]; - key.id = key_id.clone(); - key.font_color = Some("font-sibling".to_string()); - - let stat_id = uuid::Uuid::new_v4().to_string(); - let graph_id = uuid::Uuid::new_v4().to_string(); - let knob_id = uuid::Uuid::new_v4().to_string(); - data.stat_positions.insert( - "4key".to_string(), - vec![StatPosition { - stat_type: StatType::Kps, - position: KeyPosition { - id: stat_id.clone(), - ..template.clone() - }, - }], - ); - data.graph_positions.insert( - "4key".to_string(), - vec![GraphPosition { - stat_type: GraphStatType::Kps, - graph_type: GraphType::Line, - graph_speed: 1000, - graph_color: "graph-sibling".to_string(), - show_avg_line: true, - position: KeyPosition { - id: graph_id.clone(), - ..template.clone() - }, - }], - ); - data.knob_positions.insert( - "4key".to_string(), - vec![KnobPosition { - axis_id: "axis-sibling".to_string(), - sensitivity: 1.0, - reverse: false, - position: KeyPosition { - id: knob_id.clone(), - ..template - }, - }], - ); - - let mut raw = serde_json::to_value(&data).unwrap(); - let damaged = serde_json::json!({ - "angle": 90, - "stops": [{ "color": "#112233", "pos": 0 }] - }); - raw["keyPositions"]["4key"][0]["fontGradient"] = damaged.clone(); - raw["statPositions"]["4key"][0]["activeFontGradient"] = damaged.clone(); - raw["graphPositions"]["4key"][0]["fontGradient"] = damaged.clone(); - raw["knobPositions"]["4key"][0]["activeFontGradient"] = damaged; - - raw["keyPositions"]["4key"][0]["fontWeight"] = serde_json::json!(400); - raw["keyPositions"]["4key"][0] - .as_object_mut() - .unwrap() - .remove("fontBold"); - raw["statPositions"]["4key"][0] - .as_object_mut() - .unwrap() - .remove("fontWeight"); - raw["statPositions"]["4key"][0] - .as_object_mut() - .unwrap() - .remove("fontBold"); - raw["graphPositions"]["4key"][0]["fontWeight"] = serde_json::json!(700); - raw["graphPositions"]["4key"][0] - .as_object_mut() - .unwrap() - .remove("fontBold"); - raw["knobPositions"]["4key"][0]["fontWeight"] = serde_json::json!(600); - raw["knobPositions"]["4key"][0] - .as_object_mut() - .unwrap() - .remove("fontBold"); - std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - assert!(loaded.needs_persist); - assert!(loaded.repaired); - assert_eq!( - loaded.data.key_positions["4key"].len(), - data.key_positions["4key"].len() - ); - assert_eq!(loaded.data.stat_positions["4key"].len(), 1); - assert_eq!(loaded.data.graph_positions["4key"].len(), 1); - assert_eq!(loaded.data.knob_positions["4key"].len(), 1); - assert_eq!(loaded.data.key_positions["4key"][0].id, key_id); - assert_eq!(loaded.data.stat_positions["4key"][0].position.id, stat_id); - assert_eq!(loaded.data.graph_positions["4key"][0].position.id, graph_id); - assert_eq!(loaded.data.knob_positions["4key"][0].position.id, knob_id); - assert!(loaded.data.key_positions["4key"][0].font_gradient.is_none()); - assert!(loaded.data.stat_positions["4key"][0] - .position - .active_font_gradient - .is_none()); - assert!(loaded.data.graph_positions["4key"][0] - .position - .font_gradient - .is_none()); - assert!(loaded.data.knob_positions["4key"][0] - .position - .active_font_gradient - .is_none()); - for position in [ - &loaded.data.key_positions["4key"][0], - &loaded.data.stat_positions["4key"][0].position, - &loaded.data.graph_positions["4key"][0].position, - &loaded.data.knob_positions["4key"][0].position, - ] { - assert_eq!(position.font_color.as_deref(), Some("font-sibling")); - } - let key = &loaded.data.key_positions["4key"][0]; - assert_eq!(key.font_weight, Some(400)); - assert_eq!(key.font_bold, None); - let stat = &loaded.data.stat_positions["4key"][0].position; - assert_eq!(stat.font_weight, None); - assert_eq!(stat.font_bold, None); - let graph = &loaded.data.graph_positions["4key"][0].position; - assert_eq!(graph.font_weight, Some(400)); - assert_eq!(graph.font_bold, Some(true)); - let knob = &loaded.data.knob_positions["4key"][0].position; - assert_eq!(knob.font_weight, Some(600)); - assert_eq!(knob.font_bold, None); - let _ = std::fs::remove_file(path); - } - - #[derive(Debug, Serialize, Deserialize)] - #[serde(rename_all = "camelCase")] - struct HistoricalTauri13Store { - #[serde(default)] - keys: std::collections::HashMap>, - } - - #[derive(Debug, Serialize, Deserialize)] - #[serde(rename_all = "camelCase")] - struct HistoricalTauri14PlusStore { - #[serde(default)] - keys: std::collections::HashMap>, - #[serde(default, flatten)] - plugin_data: std::collections::HashMap, - } - - #[test] - fn tauri_era_store_schema_transitions_preserve_editor_data() { - let v1_3 = tauri_store_fixture_base(); - - let mut v1_4 = tauri_store_fixture_base(); - v1_4.as_object_mut().unwrap().extend([ - ("developerModeEnabled".to_string(), serde_json::json!(true)), - ("tabCssOverrides".to_string(), serde_json::json!({})), - ("useCustomJs".to_string(), serde_json::json!(true)), - ( - "customJs".to_string(), - serde_json::json!({ "path": null, "content": "void 0" }), - ), - ( - "plugin:fixture".to_string(), - serde_json::json!({ "kept": true }), - ), - ]); - - let mut v1_5 = v1_4.clone(); - v1_5.as_object_mut().unwrap().extend([ - ( - "statPositions".to_string(), - serde_json::json!({ - "fixture-tab": [{ - "statType": "kps", - "dx": 80.0, - "dy": 14.0, - "width": 80.0, - "height": 40.0, - "activeImage": "", - "inactiveImage": "", - "count": 0, - "noteColor": "#FFFFFF", - "noteOpacity": 80 - }] - }), - ), - ( - "fontSettings".to_string(), - serde_json::json!({ "customFonts": [] }), - ), - ("gridSettings".to_string(), serde_json::json!({})), - ("shortcuts".to_string(), serde_json::json!({})), - ]); - - let mut v1_6 = v1_5.clone(); - v1_6["keyPositions"]["fixture-tab"][0]["groupId"] = serde_json::json!("fixture-group"); - v1_6.as_object_mut().unwrap().extend([ - ( - "graphPositions".to_string(), - serde_json::json!({ - "fixture-tab": [{ - "statType": "kpsAvg", - "graphType": "line", - "graphSpeed": 1000, - "graphColor": "#24BBB4", - "showAvgLine": true, - "dx": 170.0, - "dy": 14.0, - "width": 160.0, - "height": 80.0, - "activeImage": "", - "inactiveImage": "", - "count": 0, - "noteColor": "#FFFFFF", - "noteOpacity": 80 - }] - }), - ), - ( - "layerGroups".to_string(), - serde_json::json!({ - "fixture-tab": [{ "id": "fixture-group", "name": "Fixture group" }] - }), - ), - ("counterAnimationPresets".to_string(), serde_json::json!([])), - ("tabNoteOverrides".to_string(), serde_json::json!({})), - ("soundLibrary".to_string(), serde_json::json!({})), - ("obsModeEnabled".to_string(), serde_json::json!(false)), - ("obsPort".to_string(), serde_json::json!(34891)), - ("obsToken".to_string(), serde_json::Value::Null), - ]); - - let mut v1_6_1 = v1_6.clone(); - v1_6_1.as_object_mut().unwrap().extend([ - ( - "knobPositions".to_string(), - serde_json::json!({ - "fixture-tab": [{ - "axisId": "axis-x", - "sensitivity": 1.0, - "reverse": false, - "dx": 340.0, - "dy": 14.0, - "width": 80.0, - "height": 80.0, - "activeImage": "", - "inactiveImage": "", - "count": 0, - "noteColor": "#FFFFFF", - "noteOpacity": 80 - }] - }), - ), - ("keySoundOutputBackend".to_string(), serde_json::Value::Null), - ]); - - for (version, fixture) in [ - ("1.3.0", v1_3), - ("1.4.0", v1_4), - ("1.5.1", v1_5), - ("1.6.0", v1_6), - ("1.6.1", v1_6_1), - ] { - let loaded = load_literal_fixture(version, &fixture); - assert_eq!(loaded.editor_revision, 0, "{version}"); - assert_eq!( - loaded.keys["fixture-tab"], - vec![KeySlot::from("F13")], - "{version}" - ); - assert_eq!(loaded.key_positions["fixture-tab"][0].dx, 13.0, "{version}"); - assert_eq!(loaded.key_counters["fixture-tab"]["F13"], 17, "{version}"); - - if version >= "1.4.0" { - assert_eq!(loaded.plugin_data["plugin:fixture"]["kept"], true); - } - if version >= "1.5.1" { - assert_eq!(loaded.stat_positions["fixture-tab"].len(), 1); - } - if version >= "1.6.0" { - assert_eq!(loaded.graph_positions["fixture-tab"].len(), 1); - assert_eq!(loaded.layer_groups["fixture-tab"][0].id, "fixture-group"); - } - if version >= "1.6.1" { - assert_eq!(loaded.knob_positions["fixture-tab"].len(), 1); - } - } - } - - #[test] - fn reverse_downgrade_models_document_unknown_revision_behavior() { - let fixture = serde_json::json!({ - "keys": { "4key": ["Q"] }, - "editorRevision": 23 - }); - - let v1_3: HistoricalTauri13Store = serde_json::from_value(fixture.clone()).unwrap(); - let v1_3_resaved = serde_json::to_value(v1_3).unwrap(); - assert!(v1_3_resaved.get("editorRevision").is_none()); - - let mut v1_4_plus: HistoricalTauri14PlusStore = serde_json::from_value(fixture).unwrap(); - assert_eq!(v1_4_plus.plugin_data["editorRevision"], 23); - let preserved = serde_json::to_value(&v1_4_plus).unwrap(); - assert_eq!(preserved["editorRevision"], 23); - - v1_4_plus.plugin_data.clear(); - let cleared = serde_json::to_value(v1_4_plus).unwrap(); - assert!(cleared.get("editorRevision").is_none()); - } - - // master(구버전) recover_key_mapping_entries의 동결 사본: 문자열이 아닌 - // 항목을 제자리 빈 문자열로 대체 (구버전 복구 동작, 2026-08 기준) - fn frozen_legacy_recover_keys(entries: &serde_json::Value) -> Vec { - entries - .as_array() - .expect("keys mode must be an array") - .iter() - .map(|entry| entry.as_str().unwrap_or("").to_string()) - .collect() - } - - #[test] - fn downgrade_recovery_replaces_multi_slots_in_place_without_compaction() { - // 신버전이 직렬화한 keys 와이어 형식이 구버전 복구에서 어떻게 열화되는지 고정 - let slots = vec![ - KeySlot::from("Q"), - KeySlot::Multi { - keys: vec!["A".to_string(), "B".to_string()], - match_mode: SlotMatch::Any, - }, - KeySlot::from("C"), - KeySlot::Multi { - keys: vec!["LEFT CTRL".to_string(), "Z".to_string()], - match_mode: SlotMatch::All, - }, - KeySlot::default(), - ]; - let wire = serde_json::to_value(&slots).unwrap(); - - let recovered = frozen_legacy_recover_keys(&wire); - - // 배열 길이 보존(keyPositions 인덱스 결합 불변식), Multi만 제자리 "" 대체 - assert_eq!( - recovered, - vec![ - "Q".to_string(), - String::new(), - "C".to_string(), - String::new(), - String::new(), - ] - ); - } - - #[test] - fn reverse_downgrade_multi_key_fixture_enters_legacy_recovery_path() { - let fixture = serde_json::json!({ - "keys": { - "4key": [ - { "keys": ["A", "B"], "match": "any" }, - "C" - ] - } - }); - - assert!(serde_json::from_value::(fixture.clone()).is_err()); - assert!(serde_json::from_value::(fixture).is_err()); - } - - #[test] - fn key_mapping_recovery_normalizes_in_place_without_compacting_slots() { - let raw = serde_json::json!({ - "mode": [ - { "keys": ["A", "B"], "match": "any" }, - { "keys": ["Z"], "match": "all" }, - { "keys": ["A", 7, "A", "B+C", "C"], "match": "all" }, - { "keys": ["A", "B"] }, - null - ] - }); - - let recovered = recover_key_mapping_entries(&raw).unwrap(); - let mappings: KeyMappings = serde_json::from_value(recovered).unwrap(); - - assert_eq!(mappings["mode"].len(), 5); - assert_eq!( - mappings["mode"], - vec![ - KeySlot::Multi { - keys: vec!["A".to_string(), "B".to_string()], - match_mode: SlotMatch::Any, - }, - KeySlot::Single("Z".to_string()), - KeySlot::Multi { - keys: vec!["A".to_string(), "C".to_string()], - match_mode: SlotMatch::All, - }, - KeySlot::default(), - KeySlot::default(), - ] - ); - } - - #[test] - fn normalize_state_drops_blank_font_colors_on_every_position_collection() { - let blank_position = |id: &str| KeyPosition { - id: id.to_string(), - font_color: Some(" \t ".to_string()), - active_font_color: Some(String::new()), - ..KeyPosition::default() - }; - let data = normalize_state(AppStoreData { - key_positions: crate::models::KeyPositions::from([( - "custom".to_string(), - vec![blank_position("key-id")], - )]), - stat_positions: crate::models::StatPositions::from([( - "custom".to_string(), - vec![StatPosition { - stat_type: StatType::Kps, - position: blank_position("stat-id"), - }], - )]), - graph_positions: crate::models::GraphPositions::from([( - "custom".to_string(), - vec![GraphPosition { - stat_type: GraphStatType::Kps, - graph_type: GraphType::Line, - graph_speed: 1000, - graph_color: "graph".to_string(), - show_avg_line: false, - position: blank_position("graph-id"), - }], - )]), - knob_positions: crate::models::KnobPositions::from([( - "custom".to_string(), - vec![KnobPosition { - axis_id: "axis".to_string(), - sensitivity: 1.0, - reverse: false, - position: blank_position("knob-id"), - }], - )]), - ..AppStoreData::default() - }); - - for position in [ - &data.key_positions["custom"][0], - &data.stat_positions["custom"][0].position, - &data.graph_positions["custom"][0].position, - &data.knob_positions["custom"][0].position, - ] { - assert!(position.font_color.is_none()); - assert!(position.active_font_color.is_none()); - } - } - - #[test] - fn normalize_state_folds_replace_image_mode_to_sparse_none() { - let data = normalize_state(AppStoreData { - key_positions: crate::models::KeyPositions::from([( - "custom".to_string(), - vec![ - KeyPosition { - id: "replace".to_string(), - image_mode: Some(crate::models::ImageMode::Replace), - ..KeyPosition::default() - }, - KeyPosition { - id: "overlay".to_string(), - image_mode: Some(crate::models::ImageMode::Overlay), - ..KeyPosition::default() - }, - ], - )]), - ..AppStoreData::default() - }); - - assert_eq!(data.key_positions["custom"][0].image_mode, None); - assert_eq!( - data.key_positions["custom"][1].image_mode, - Some(crate::models::ImageMode::Overlay) - ); - } - - #[test] - fn normalize_state_keeps_single_count_and_separates_any_all_counters() { - let keys = vec![ - KeySlot::Single("A".to_string()), - KeySlot::Multi { - keys: vec!["A".to_string(), "B".to_string()], - match_mode: SlotMatch::Any, - }, - KeySlot::Multi { - keys: vec!["A".to_string(), "B".to_string()], - match_mode: SlotMatch::All, - }, - ]; - let data = normalize_state(AppStoreData { - keys: KeyMappings::from([("4key".to_string(), keys)]), - key_positions: crate::models::KeyPositions::from([( - "4key".to_string(), - vec![KeyPosition::default(); 3], - )]), - key_counters: crate::models::KeyCounters::from([( - "4key".to_string(), - std::collections::HashMap::from([ - ("A".to_string(), 9), - ("A|B".to_string(), 4), - ("stale".to_string(), 7), - ]), - )]), - ..AppStoreData::default() - }); - - assert_eq!(data.key_counters["4key"]["A"], 9); - assert_eq!(data.key_counters["4key"]["A|B"], 4); - assert_eq!(data.key_counters["4key"]["A+B"], 0); - assert!(!data.key_counters["4key"].contains_key("stale")); - } - - #[test] - fn load_repairs_unsafe_editor_revision_without_touching_editor_data() { - let path = std::env::temp_dir().join(format!( - "dmnote-unsafe-editor-revision-{}.json", - uuid::Uuid::new_v4() - )); - let mut data = normalize_state(AppStoreData { - keys: default_keys().clone(), - key_positions: default_positions().clone(), - ..AppStoreData::default() - }); - data.editor_revision = crate::state::editor::MAX_SAFE_WIRE_REVISION + 1; - data.key_positions.get_mut("4key").unwrap()[0].dx = 12_345.0; - crate::state::native_element_id::backfill_store_element_ids(&mut data); - let expected_keys = data.keys.clone(); - let expected_positions = data.key_positions.clone(); - std::fs::write(&path, serde_json::to_vec_pretty(&data).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - - assert_eq!(loaded.data.editor_revision, 0); - assert_eq!(loaded.data.keys, expected_keys); - assert_eq!(loaded.data.key_positions, expected_positions); - assert!(loaded.needs_persist); - assert!(loaded.repaired); - let _ = std::fs::remove_file(path); - } - - #[test] - fn load_preserves_max_safe_editor_revision() { - let path = std::env::temp_dir().join(format!( - "dmnote-max-safe-editor-revision-{}.json", - uuid::Uuid::new_v4() - )); - let mut data = normalize_state(AppStoreData { - keys: default_keys().clone(), - key_positions: default_positions().clone(), - ..AppStoreData::default() - }); - data.editor_revision = crate::state::editor::MAX_SAFE_WIRE_REVISION; - std::fs::write(&path, serde_json::to_vec_pretty(&data).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - - assert_eq!( - loaded.data.editor_revision, - crate::state::editor::MAX_SAFE_WIRE_REVISION - ); - assert!(!loaded.repaired); - let _ = std::fs::remove_file(path); - } - - #[test] - fn clear_dangling_group_ids_removes_ghosts_and_keeps_valid_ones() { - let mut data = AppStoreData::default(); - let mut positions = default_positions().clone(); - if let Some(list) = positions.get_mut("4key") { - list[0].group_id = Some("ghost-group".to_string()); - list[1].group_id = Some("real-group".to_string()); - } - data.key_positions = positions; - data.layer_groups.insert( - "4key".to_string(), - vec![LayerGroupDef { - id: "real-group".to_string(), - name: "Group".to_string(), - }], - ); - - assert!(super::clear_dangling_group_ids(&mut data)); - let list = &data.key_positions["4key"]; - assert_eq!(list[0].group_id, None); - assert_eq!(list[1].group_id.as_deref(), Some("real-group")); - - // 정리할 것이 없으면 변경 없음을 보고 (로드 시 불필요한 재저장 방지) - assert!(!super::clear_dangling_group_ids(&mut data)); - } - - // 부분 저장(update_*)은 positions 먼저 → layerGroups 나중 순서로 들어오므로 - // normalize_state가 중간 상태의 신규 groupId를 지워선 안 됨 - #[test] - fn normalize_state_preserves_group_ids_saved_before_group_definitions() { - let mut data = AppStoreData::default(); - let mut positions = default_positions().clone(); - if let Some(list) = positions.get_mut("4key") { - list[0].group_id = Some("group-created-just-now".to_string()); - } - data.key_positions = positions; - // layerGroups 정의는 아직 저장 전 (다음 커맨드에서 도착) - - let normalized = normalize_state(data); - assert_eq!( - normalized.key_positions["4key"][0].group_id.as_deref(), - Some("group-created-just-now") - ); - } - - #[test] - fn load_clears_dangling_group_ids_from_disk() { - let dir = - std::env::temp_dir().join(format!("dmnote-dangling-load-{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("store.json"); - - let mut data = AppStoreData::default(); - let mut positions = default_positions().clone(); - if let Some(list) = positions.get_mut("4key") { - list[0].group_id = Some("ghost-group".to_string()); - } - data.key_positions = positions; - std::fs::write(&path, serde_json::to_vec_pretty(&data).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - assert_eq!(loaded.data.key_positions["4key"][0].group_id, None); - // 정리 결과가 디스크에도 영속되도록 재저장 필요 플래그가 올라가야 함 - assert!(loaded.needs_persist); - - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn load_pads_every_key_position_length_mismatch_without_compaction() { - let path = std::env::temp_dir().join(format!( - "dmnote-key-position-length-load-{}.json", - uuid::Uuid::new_v4() - )); - let mut data = AppStoreData { - keys: default_keys().clone(), - key_positions: default_positions().clone(), - ..AppStoreData::default() - }; - data.keys.get_mut("4key").unwrap().push("F5".into()); - let preserved_position = KeyPosition { - dx: 987.0, - ..KeyPosition::default() - }; - data.key_positions - .get_mut("5key") - .unwrap() - .push(preserved_position.clone()); - data.keys - .insert("keys-only".to_string(), vec!["A".into(), "B".into()]); - data.key_positions.insert( - "positions-only".to_string(), - vec![preserved_position.clone()], - ); - crate::state::native_element_id::backfill_store_element_ids(&mut data); - let preserved_position = data.key_positions["5key"].last().unwrap().clone(); - std::fs::write(&path, serde_json::to_vec_pretty(&data).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - assert!(!loaded.repaired); - assert!(loaded.needs_persist); - assert_eq!( - loaded.data.keys["4key"].last().unwrap(), - &KeySlot::from("F5") - ); - let padded_position = loaded.data.key_positions["4key"].last().unwrap(); - assert!(crate::state::native_element_id::is_valid_element_id( - &padded_position.id - )); - let mut padded_without_id = padded_position.clone(); - padded_without_id.id.clear(); - assert_eq!(padded_without_id, KeyPosition::default()); - assert_eq!( - loaded.data.key_positions["5key"].last().unwrap(), - &preserved_position - ); - assert!(loaded.data.keys["5key"].last().unwrap().is_unassigned()); - assert_eq!(loaded.data.key_positions["keys-only"].len(), 2); - assert_eq!(loaded.data.keys["positions-only"], vec![KeySlot::default()]); - - let modes = loaded - .data - .keys - .keys() - .chain(loaded.data.key_positions.keys()) - .collect::>(); - for mode in modes { - assert_eq!( - loaded.data.keys.get(mode).map_or(0, Vec::len), - loaded.data.key_positions.get(mode).map_or(0, Vec::len), - "mode {mode}" - ); - } - - let _ = std::fs::remove_file(path); - } - - #[test] - fn normal_load_preserves_unbound_knobs_while_repairing_invalid_fonts() { - let path = std::env::temp_dir().join(format!( - "dmnote-semantic-identity-load-{}.json", - uuid::Uuid::new_v4() - )); - let mut data = AppStoreData::default(); - data.font_settings.custom_fonts = vec![ - CustomFont { - id: " ".to_string(), - font_type: FontType::Web, - name: "Invalid Font".to_string(), - display_name: "Invalid Font".to_string(), - enabled: true, - local_path: None, - css_content: Some("@font-face {}".to_string()), - weight_ranges: Vec::new(), - }, - CustomFont { - id: "local-font".to_string(), - font_type: FontType::Local, - name: "Local Font".to_string(), - display_name: "Local Font".to_string(), - enabled: true, - local_path: Some("relative/font.ttf".to_string()), - css_content: None, - weight_ranges: Vec::new(), - }, - ]; - data.knob_positions.insert( - "4key".to_string(), - vec![ - KnobPosition { - axis_id: String::new(), - sensitivity: 1.0, - reverse: false, - position: default_positions()["4key"][0].clone(), - }, - KnobPosition { - axis_id: "axis-valid".to_string(), - sensitivity: 1.0, - reverse: false, - position: default_positions()["4key"][1].clone(), - }, - ], - ); - std::fs::write(&path, serde_json::to_vec_pretty(&data).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - - assert!(loaded.repaired); - assert!(loaded.needs_persist); - assert_eq!(loaded.data.font_settings.custom_fonts.len(), 1); - let local = &loaded.data.font_settings.custom_fonts[0]; - assert_eq!(local.id, "local-font"); - assert!(!local.enabled); - assert_eq!(local.local_path, None); - assert_eq!(loaded.data.knob_positions["4key"].len(), 2); - assert!(loaded.data.knob_positions["4key"][0].axis_id.is_empty()); - assert_eq!(loaded.data.knob_positions["4key"][1].axis_id, "axis-valid"); - - let _ = std::fs::remove_file(path); - } - - #[test] - fn unbound_knob_survives_load_normalize_and_resave() { - let path = std::env::temp_dir().join(format!( - "dmnote-unbound-knob-roundtrip-{}.json", - uuid::Uuid::new_v4() - )); - let mut data = AppStoreData { - keys: default_keys().clone(), - key_positions: default_positions().clone(), - ..AppStoreData::default() - }; - data.knob_positions.insert( - "4key".to_string(), - vec![KnobPosition { - axis_id: String::new(), - sensitivity: 1.0, - reverse: false, - position: default_positions()["4key"][0].clone(), - }], - ); - std::fs::write(&path, serde_json::to_vec_pretty(&data).unwrap()).unwrap(); - - let first = load_store_from_path(&path).unwrap(); - assert!(!first.repaired); - assert!(first.data.knob_positions["4key"][0].axis_id.is_empty()); - - let normalized = normalize_state(first.data); - std::fs::write(&path, serde_json::to_vec_pretty(&normalized).unwrap()).unwrap(); - let second = load_store_from_path(&path).unwrap(); - assert!(!second.repaired); - assert_eq!(second.data.knob_positions["4key"].len(), 1); - assert!(second.data.knob_positions["4key"][0].axis_id.is_empty()); - - let _ = std::fs::remove_file(path); - } - use serde_json::{json, Value}; - - const TEST_SOUND_PATH: &str = "/tmp/test-sound.wav"; - - fn load_store_with_sound_entry(entry: Value) -> (AppStoreData, bool) { - let path = std::env::temp_dir().join(format!( - "dmnote-sound-migration-test-{}.json", - uuid::Uuid::new_v4() - )); - let mut data = normalize_state(AppStoreData::default()); - crate::state::native_element_id::backfill_store_element_ids(&mut data); - let mut value = serde_json::to_value(data).unwrap(); - value.as_object_mut().unwrap().insert( - "soundLibrary".to_string(), - json!({ TEST_SOUND_PATH: entry }), - ); - std::fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - let _ = std::fs::remove_file(path); - (loaded.data, loaded.needs_persist) - } - - #[test] - fn sound_library_enabled_false_migrates_to_hidden_true() { - let mut value = json!({ - "soundLibrary": { - TEST_SOUND_PATH: { "enabled": false } - } - }); - assert!(migrate_sound_library_enabled(&mut value)); - let entry = &value["soundLibrary"][TEST_SOUND_PATH]; - assert_eq!(entry["hidden"], true); - assert!(entry.get("enabled").is_none()); - - let (data, needs_persist) = load_store_with_sound_entry(json!({ "enabled": false })); - assert!(data.sound_library[TEST_SOUND_PATH].hidden); - assert!(needs_persist); - } - - #[test] - fn sound_library_enabled_true_migrates_to_hidden_false() { - let (data, needs_persist) = load_store_with_sound_entry(json!({ "enabled": true })); - assert!(!data.sound_library[TEST_SOUND_PATH].hidden); - assert!(needs_persist); - } - - #[test] - fn sound_library_without_enabled_is_unchanged() { - let mut value = json!({ - "soundLibrary": { - TEST_SOUND_PATH: { "source": "local" } - } - }); - let original = value.clone(); - assert!(!migrate_sound_library_enabled(&mut value)); - assert_eq!(value, original); - - let (data, needs_persist) = load_store_with_sound_entry(json!({ "source": "local" })); - assert!(!data.sound_library[TEST_SOUND_PATH].hidden); - assert!(!needs_persist); - } - - #[test] - fn sound_library_hidden_takes_precedence_over_enabled() { - let mut value = json!({ - "soundLibrary": { - TEST_SOUND_PATH: { "hidden": false, "enabled": false } - } - }); - assert!(migrate_sound_library_enabled(&mut value)); - let entry = &value["soundLibrary"][TEST_SOUND_PATH]; - assert_eq!(entry["hidden"], false); - assert!(entry.get("enabled").is_none()); - - let (data, needs_persist) = - load_store_with_sound_entry(json!({ "hidden": false, "enabled": false })); - assert!(!data.sound_library[TEST_SOUND_PATH].hidden); - assert!(needs_persist); - } - - #[test] - fn rgba_to_hex_converts_and_drops_alpha() { - assert_eq!( - rgba_to_hex("rgba(255, 0, 167, 1)").as_deref(), - Some("#FF00A7") - ); - assert_eq!( - rgba_to_hex("rgba(18, 52, 86, 0)").as_deref(), - Some("#123456") - ); - } - - #[test] - fn rgba_to_hex_ignores_non_rgba() { - assert_eq!(rgba_to_hex("#FF00A7"), None); - assert_eq!(rgba_to_hex("garbage"), None); - assert_eq!(rgba_to_hex("rgba(300, 0, 0, 1)"), None); // u8 범위 초과 - } - - #[test] - fn invalid_field_recovery_preserves_every_other_store_field() { - let path = std::env::temp_dir().join(format!( - "dmnote-field-recovery-test-{}.json", - uuid::Uuid::new_v4() - )); - let mut expected = AppStoreData { - hardware_acceleration: false, - always_on_top: false, - overlay_bounds: Some(OverlayBounds { - x: 11.0, - y: 22.0, - width: 933.0, - height: 411.0, - }), - obs_mode_enabled: true, - obs_port: 18_321, - obs_token: Some("obs-token-sentinel".to_string()), - ..AppStoreData::default() - }; - expected.layer_groups.insert( - "4key".to_string(), - vec![LayerGroupDef { - id: "layer-group-sentinel".to_string(), - name: "Layer Group".to_string(), - }], - ); - expected.plugin_data.insert( - "pluginData".to_string(), - json!({ "sentinel": "plugin-data" }), - ); - expected.plugin_data.insert( - "obsSettings".to_string(), - json!({ "sentinel": "obs-settings" }), - ); - expected.grid_settings.grid_snap_size = 9; - expected.knob_positions.insert( - "4key".to_string(), - vec![KnobPosition { - axis_id: "knob-axis-sentinel".to_string(), - sensitivity: 2.5, - reverse: true, - position: default_positions()["4key"][0].clone(), - }], - ); - expected.font_settings.custom_fonts.push(CustomFont { - id: "custom-font-sentinel".to_string(), - font_type: FontType::Web, - name: "Custom Font".to_string(), - display_name: "Custom Font".to_string(), - enabled: true, - local_path: None, - css_content: Some("@font-face { font-family: Custom; }".to_string()), - weight_ranges: Vec::new(), - }); - expected.tab_css_overrides.insert( - "custom-tab".to_string(), - TabCss { - path: Some("/tmp/custom-tab.css".to_string()), - content: ".sentinel { color: red; }".to_string(), - enabled: true, - }, - ); - expected.tab_note_overrides.insert( - "custom-tab".to_string(), - TabNoteSettings { - speed: Some(987), - ..TabNoteSettings::default() - }, - ); - expected.shortcuts.toggle_always_on_top.key = "F12".to_string(); - expected.sound_library.insert( - "/tmp/sound-sentinel.wav".to_string(), - SoundLibraryEntry { - display_name: Some("Sound Sentinel".to_string()), - ..SoundLibraryEntry::default() - }, - ); - - let mut fixture = serde_json::to_value(&expected).unwrap(); - assert!(serde_json::from_value::(fixture.clone()).is_ok()); - fixture.as_object_mut().unwrap().insert( - "alwaysOnTop".to_string(), - Value::String("invalid".to_string()), - ); - std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - let _ = std::fs::remove_file(path); - - expected.always_on_top = AppStoreData::default().always_on_top; - expected = normalize_state(expected); - assert!(loaded.repaired); - assert!(loaded.needs_persist); - let mut actual_value = serde_json::to_value(&loaded.data).unwrap(); - let mut expected_value = serde_json::to_value(&expected).unwrap(); - remove_all_native_ids(&mut actual_value); - remove_all_native_ids(&mut expected_value); - assert_eq!(actual_value, expected_value); - } - - #[test] - fn electron_1_2_store_preserves_tabs_keys_settings_and_window_position() { - let path = std::env::temp_dir().join(format!( - "dmnote-electron-1-2-store-test-{}.json", - uuid::Uuid::new_v4() - )); - let fixture = json!({ - "hardwareAcceleration": false, - "alwaysOnTop": false, - "overlayLocked": true, - "noteEffect": true, - "noteSettings": { - "borderRadius": 7, - "speed": 321, - "trackHeight": 123, - "reverse": true, - "fadePosition": "bottom", - "delayedNoteEnabled": true, - "shortNoteThresholdMs": 67, - "shortNoteMinLengthPx": 43 - }, - "selectedKeyType": "legacy-tab", - "customTabs": [{ "id": "legacy-tab", "name": "Legacy tab" }], - "angleMode": "d3d11", - "language": "en", - "laboratoryEnabled": true, - "keys": { "legacy-tab": ["A", "B"] }, - "keyPositions": { - "legacy-tab": [ - { - "dx": 777, - "dy": 88, - "width": 60, - "height": 60, - "activeImage": "", - "inactiveImage": "", - "count": 42, - "noteColor": "#ABCDEF", - "noteOpacity": 73 - }, - { - "dx": 888, - "dy": 99, - "width": 75, - "height": 65, - "activeImage": "", - "inactiveImage": "", - "count": 24, - "noteColor": "#FEDCBA", - "noteOpacity": 64 - } - ] - }, - "backgroundColor": "#123456", - "useCustomCSS": true, - "customCSS": { "path": "/tmp/legacy.css", "content": ".legacy {}" }, - "overlayWindowPosition": { "x": 17, "y": 29 } - }); - std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - let _ = std::fs::remove_file(path); - - assert!(loaded.repaired); - assert_eq!(loaded.data.selected_key_type, "legacy-tab"); - assert_eq!( - loaded.data.custom_tabs, - vec![CustomTab { - id: "legacy-tab".to_string(), - name: "Legacy tab".to_string(), - }] - ); - assert_eq!( - loaded.data.keys["legacy-tab"], - vec![KeySlot::from("A"), KeySlot::from("B")] - ); - assert_eq!(loaded.data.key_positions["legacy-tab"].len(), 2); - assert_eq!(loaded.data.key_positions["legacy-tab"][0].dx, 777.0); - assert_eq!(loaded.data.key_positions["legacy-tab"][1].dx, 888.0); - assert_eq!(loaded.data.note_settings.speed, 321); - assert_eq!(loaded.data.note_settings.track_height, 123); - assert!(loaded.data.note_settings.reverse); - assert!(loaded.data.use_custom_css); - assert_eq!( - loaded.data.custom_css.path.as_deref(), - Some("/tmp/legacy.css") - ); - assert_eq!(loaded.data.background_color, "#123456"); - assert_eq!( - loaded.data.overlay_bounds, - Some(OverlayBounds { - x: 17.0, - y: 29.0, - width: LEGACY_OVERLAY_WIDTH, - height: LEGACY_OVERLAY_HEIGHT, - }) - ); - } - - #[test] - fn tauri_1_3_store_preserves_custom_layout_without_repair_fallback() { - let path = std::env::temp_dir().join(format!( - "dmnote-tauri-1-3-store-test-{}.json", - uuid::Uuid::new_v4() - )); - let fixture = json!({ - "hardwareAcceleration": false, - "alwaysOnTop": false, - "overlayLocked": true, - "noteEffect": true, - "noteSettings": { - "borderRadius": 9, - "speed": 456, - "trackHeight": 222, - "reverse": true, - "fadePosition": "bottom", - "delayedNoteEnabled": true, - "shortNoteThresholdMs": 73, - "shortNoteMinLengthPx": 41 - }, - "selectedKeyType": "tauri-legacy-tab", - "customTabs": [{ "id": "tauri-legacy-tab", "name": "Tauri legacy" }], - "angleMode": "metal", - "language": "ko", - "laboratoryEnabled": true, - "keys": { "tauri-legacy-tab": ["Q"] }, - "keyPositions": { - "tauri-legacy-tab": [{ - "dx": 654, - "dy": 87, - "width": 91, - "height": 63, - "activeImage": "/tmp/legacy-active.png", - "inactiveImage": "/tmp/legacy-idle.png", - "count": 19, - "noteColor": "#13579B", - "noteOpacity": 76 - }] - }, - "keyCounters": { "tauri-legacy-tab": { "Q": 19 } }, - "backgroundColor": "#2468AC", - "useCustomCss": true, - "customCss": { "path": "/tmp/tauri-legacy.css", "content": ".tauri {}" }, - "overlayResizeAnchor": "top-left", - "overlayBounds": { "x": 31, "y": 47, "width": 911, "height": 333 }, - "overlayLastContentTopOffset": 12.5, - "overlayBoundsAreLogical": false, - "keyCounterEnabled": true - }); - std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - let _ = std::fs::remove_file(path); - - assert!(!loaded.repaired); - assert_eq!(loaded.data.selected_key_type, "tauri-legacy-tab"); - assert_eq!( - loaded.data.keys["tauri-legacy-tab"], - vec![KeySlot::from("Q")] - ); - assert_eq!(loaded.data.key_positions["tauri-legacy-tab"].len(), 1); - let position = &loaded.data.key_positions["tauri-legacy-tab"][0]; - assert_eq!(position.dx, 654.0); - assert_eq!(position.width, 91.0); - assert_eq!( - position.active_image.as_deref(), - Some("/tmp/legacy-active.png") - ); - assert_eq!(position.note_border_radius, Some(9.0)); - assert_eq!(loaded.data.key_counters["tauri-legacy-tab"]["Q"], 19); - assert_eq!(loaded.data.note_settings.speed, 456); - assert_eq!(loaded.data.background_color, "#2468AC"); - assert!(loaded.data.use_custom_css); - } - - #[test] - fn custom_tabs_and_keys_recover_without_compacting_parallel_arrays() { - let path = std::env::temp_dir().join(format!( - "dmnote-tab-key-entry-recovery-test-{}.json", - uuid::Uuid::new_v4() - )); - let alpha_tab = CustomTab { - id: "alpha-tab".to_string(), - name: "Alpha".to_string(), - }; - let beta_tab = CustomTab { - id: "beta-tab".to_string(), - name: "Beta".to_string(), - }; - let empty_mode_tab = CustomTab { - id: "empty-mode".to_string(), - name: "Empty mode".to_string(), - }; - let mut positions = vec![default_positions()["4key"][0].clone(); 5]; - for (index, position) in positions.iter_mut().enumerate() { - position.dx = ((index + 1) * 101) as f64; - } - - let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); - let fields = fixture.as_object_mut().unwrap(); - fields.insert( - "customTabs".to_string(), - json!([ - serde_json::to_value(&alpha_tab).unwrap(), - 42, - serde_json::to_value(&beta_tab).unwrap(), - serde_json::to_value(&empty_mode_tab).unwrap() - ]), - ); - fields.insert("selectedKeyType".to_string(), json!("alpha-tab")); - fields.insert( - "keys".to_string(), - json!({ - "4key": 42, - "alpha-tab": [42, "A", null, "C", {}], - "beta-tab": ["D"], - "empty-mode": 42, - }), - ); - fields.insert( - "keyPositions".to_string(), - json!({ - "alpha-tab": serde_json::to_value(&positions).unwrap(), - "beta-tab": [serde_json::to_value(&positions[0]).unwrap()], - "empty-mode": [], - }), - ); - std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - let _ = std::fs::remove_file(path); - - assert!(loaded.repaired); - assert!(loaded.needs_persist); - assert_eq!( - loaded.data.custom_tabs, - vec![alpha_tab, beta_tab, empty_mode_tab] - ); - assert_eq!(loaded.data.selected_key_type, "alpha-tab"); - assert_eq!( - loaded.data.keys["alpha-tab"], - vec![ - KeySlot::default(), - KeySlot::from("A"), - KeySlot::default(), - KeySlot::from("C"), - KeySlot::default(), - ] - ); - assert_eq!(loaded.data.keys["beta-tab"], vec![KeySlot::from("D")]); - assert!(loaded.data.keys["empty-mode"].is_empty()); - assert_eq!(loaded.data.keys["4key"], default_keys()["4key"]); - assert_eq!( - loaded.data.key_positions["alpha-tab"] - .iter() - .map(|position| position.dx) - .collect::>(), - vec![101.0, 202.0, 303.0, 404.0, 505.0] - ); - } - - #[test] - fn custom_css_history_normalizes_at_the_store_load_boundary() { - let path = std::env::temp_dir().join(format!( - "dmnote-css-history-normalize-test-{}.json", - uuid::Uuid::new_v4() - )); - let duplicate = absolute_fixture_path("history-duplicate.css"); - let mut history = vec![ - CustomCssHistoryEntry { - path: "relative.css".to_string(), - loaded_at: 999, - last_used_at: 999, - }, - CustomCssHistoryEntry { - path: duplicate.clone(), - loaded_at: 1, - last_used_at: 1, - }, - CustomCssHistoryEntry { - path: duplicate.clone(), - loaded_at: 100, - last_used_at: 100, - }, - ]; - for index in 0..12 { - history.push(CustomCssHistoryEntry { - path: absolute_fixture_path(&format!("history-{index}.css")), - loaded_at: index, - last_used_at: index, - }); - } - let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); - fixture["customCssHistory"] = serde_json::to_value(history).unwrap(); - std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - let _ = std::fs::remove_file(path); - - assert!(loaded.needs_persist); - assert_eq!(loaded.data.custom_css_history.len(), 10); - assert!(loaded - .data - .custom_css_history - .windows(2) - .all(|pair| pair[0].last_used_at >= pair[1].last_used_at)); - assert_eq!( - loaded - .data - .custom_css_history - .iter() - .filter(|entry| entry.path == duplicate) - .count(), - 1 - ); - assert!(loaded - .data - .custom_css_history - .iter() - .all(|entry| std::path::Path::new(&entry.path).is_absolute())); - } - - #[test] - fn custom_css_history_recovers_valid_entries_individually() { - let path = std::env::temp_dir().join(format!( - "dmnote-css-history-recovery-test-{}.json", - uuid::Uuid::new_v4() - )); - let first = CustomCssHistoryEntry { - path: absolute_fixture_path("history-first.css"), - loaded_at: 10, - last_used_at: 10, - }; - let second = CustomCssHistoryEntry { - path: absolute_fixture_path("history-second.css"), - loaded_at: 20, - last_used_at: 20, - }; - let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); - fixture["customCssHistory"] = json!([ - serde_json::to_value(&first).unwrap(), - { "path": 42, "lastUsedAt": "invalid" }, - serde_json::to_value(&second).unwrap() - ]); - std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - let _ = std::fs::remove_file(path); - - assert!(loaded.repaired); - assert!(loaded.needs_persist); - assert_eq!(loaded.data.custom_css_history, vec![second, first]); - } - - #[test] - fn custom_css_history_migrates_loaded_at_without_reseeding_active_path() { - let path = std::env::temp_dir().join(format!( - "dmnote-css-history-seed-test-{}.json", - uuid::Uuid::new_v4() - )); - let existing_path = absolute_fixture_path("history-existing.css"); - let active_path = absolute_fixture_path("history-active.css"); - let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); - fixture["customCss"] = json!({ - "path": active_path, - "content": "body {}" - }); - fixture["customCssHistory"] = json!([{ - "path": existing_path, - "lastUsedAt": 42 - }]); - std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - let _ = std::fs::remove_file(path); - - assert!(loaded.needs_persist); - assert_eq!(loaded.data.custom_css_history.len(), 1); - let existing = loaded - .data - .custom_css_history - .iter() - .find(|entry| entry.path == existing_path) - .unwrap(); - assert_eq!(existing.loaded_at, 42); - assert!(!loaded - .data - .custom_css_history - .iter() - .any(|entry| entry.path == active_path)); - } - - #[test] - fn custom_css_history_keeps_latest_legacy_duplicate_before_timestamp_migration() { - let path = std::env::temp_dir().join(format!( - "dmnote-css-history-legacy-duplicate-test-{}.json", - uuid::Uuid::new_v4() - )); - let duplicate_path = absolute_fixture_path("history-duplicate.css"); - let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); - fixture["customCssHistory"] = json!([ - { - "path": duplicate_path, - "lastUsedAt": 1 - }, - { - "path": duplicate_path, - "lastUsedAt": 100 - } - ]); - std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - let _ = std::fs::remove_file(path); - - assert_eq!(loaded.data.custom_css_history.len(), 1); - assert_eq!(loaded.data.custom_css_history[0].loaded_at, 100); - assert_eq!(loaded.data.custom_css_history[0].last_used_at, 100); - } - - #[test] - fn custom_css_history_seeds_active_path_only_when_legacy_field_is_missing() { - let path = std::env::temp_dir().join(format!( - "dmnote-css-history-legacy-seed-test-{}.json", - uuid::Uuid::new_v4() - )); - let active_path = absolute_fixture_path("history-active.css"); - let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); - fixture.as_object_mut().unwrap().remove("customCssHistory"); - fixture["customCss"] = json!({ - "path": active_path, - "content": "body {}" - }); - std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - let _ = std::fs::remove_file(path); - - assert!(loaded.needs_persist); - assert_eq!(loaded.data.custom_css_history.len(), 1); - assert_eq!(loaded.data.custom_css_history[0].path, active_path); - } - - #[test] - fn explicit_empty_custom_css_history_survives_repeated_loads() { - let path = std::env::temp_dir().join(format!( - "dmnote-css-history-empty-test-{}.json", - uuid::Uuid::new_v4() - )); - let active_path = absolute_fixture_path("history-active.css"); - let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); - fixture["customCss"] = json!({ - "path": active_path, - "content": "body {}" - }); - fixture["customCssHistory"] = json!([]); - std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); - - let first = load_store_from_path(&path).unwrap(); - let second = load_store_from_path(&path).unwrap(); - let _ = std::fs::remove_file(path); - - assert!(first.data.custom_css_history.is_empty()); - assert!(second.data.custom_css_history.is_empty()); - } - - #[cfg(unix)] - #[test] - fn legacy_symlink_active_css_is_seeded_with_its_canonical_path() { - use std::os::unix::fs::symlink; - - let root = std::env::temp_dir().join(format!( - "dmnote-css-history-symlink-test-{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&root).unwrap(); - let target = root.join("target.css"); - let alias = root.join("alias.css"); - let store_path = root.join("store.json"); - std::fs::write(&target, "body {}").unwrap(); - symlink(&target, &alias).unwrap(); - - let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); - fixture.as_object_mut().unwrap().remove("customCssHistory"); - fixture["customCss"] = json!({ - "path": alias.to_string_lossy(), - "content": "body {}" - }); - std::fs::write(&store_path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); - - let loaded = load_store_from_path(&store_path).unwrap(); - let canonical = std::fs::canonicalize(&target) - .unwrap() - .to_string_lossy() - .to_string(); - let _ = std::fs::remove_dir_all(root); - - assert_eq!( - loaded.data.custom_css.path.as_deref(), - Some(canonical.as_str()) - ); - assert_eq!(loaded.data.custom_css_history.len(), 1); - assert_eq!(loaded.data.custom_css_history[0].path, canonical); - } - - #[test] - fn custom_tab_whole_mode_damage_recovers_parallel_shape_only() { - let path = std::env::temp_dir().join(format!( - "dmnote-tab-mode-recovery-test-{}.json", - uuid::Uuid::new_v4() - )); - let mut first_position = default_positions()["4key"][0].clone(); - first_position.dx = 111.0; - let mut second_position = default_positions()["4key"][1].clone(); - second_position.dx = 222.0; - - let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); - let fields = fixture.as_object_mut().unwrap(); - fields.insert( - "customTabs".to_string(), - json!([ - { "id": "keys-damaged", "name": "Keys damaged" }, - { "id": "positions-damaged", "name": "Positions damaged" }, - { "id": "valid-mismatch", "name": "Valid mismatch" }, - { "id": "missing-both", "name": "Missing both" } - ]), - ); - fields.insert("selectedKeyType".to_string(), json!("positions-damaged")); - fields.insert( - "keys".to_string(), - json!({ - "keys-damaged": 42, - "positions-damaged": ["A", "B", "C"], - "valid-mismatch": ["Q"] - }), - ); - fields.insert( - "keyPositions".to_string(), - json!({ - "keys-damaged": [ - serde_json::to_value(&first_position).unwrap(), - serde_json::to_value(&second_position).unwrap() - ], - "positions-damaged": 42, - "valid-mismatch": [ - serde_json::to_value(&first_position).unwrap(), - serde_json::to_value(&second_position).unwrap() - ] - }), - ); - std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - let _ = std::fs::remove_file(path); - - assert!(loaded.repaired); - assert!(loaded.needs_persist); - assert_eq!(loaded.data.selected_key_type, "positions-damaged"); - assert_eq!( - loaded.data.keys["keys-damaged"], - vec![KeySlot::default(), KeySlot::default()] - ); - assert_eq!( - loaded.data.key_positions["keys-damaged"] - .iter() - .map(|position| position.dx) - .collect::>(), - vec![111.0, 222.0] - ); - assert_eq!( - loaded.data.keys["positions-damaged"], - vec![KeySlot::from("A"), KeySlot::from("B"), KeySlot::from("C")] - ); - assert!(loaded.data.key_positions["positions-damaged"] - .iter() - .all(|position| { - let mut position = position.clone(); - position.id.clear(); - position == KeyPosition::default() - })); - assert_eq!( - loaded.data.keys["valid-mismatch"], - vec![KeySlot::from("Q"), KeySlot::default()] - ); - assert_eq!(loaded.data.key_positions["valid-mismatch"].len(), 2); - assert!(loaded.data.keys["missing-both"].is_empty()); - assert!(loaded.data.key_positions["missing-both"].is_empty()); - } - - #[test] - fn custom_tab_missing_modes_are_repaired_on_an_otherwise_valid_store() { - let path = std::env::temp_dir().join(format!( - "dmnote-tab-missing-modes-test-{}.json", - uuid::Uuid::new_v4() - )); - let mut fixture = AppStoreData::default(); - fixture.custom_tabs.push(CustomTab { - id: "missing-modes".to_string(), - name: "Missing modes".to_string(), - }); - fixture.selected_key_type = "missing-modes".to_string(); - std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - let _ = std::fs::remove_file(path); - - assert!(loaded.repaired); - assert!(loaded.needs_persist); - assert_eq!(loaded.data.selected_key_type, "missing-modes"); - assert!(loaded.data.keys["missing-modes"].is_empty()); - assert!(loaded.data.key_positions["missing-modes"].is_empty()); - } - - #[test] - fn normal_load_rejects_selected_mode_without_a_matching_tab() { - let path = std::env::temp_dir().join(format!( - "dmnote-ghost-selection-test-{}.json", - uuid::Uuid::new_v4() - )); - let mut fixture = AppStoreData { - selected_key_type: "ghost-tab".to_string(), - ..AppStoreData::default() - }; - fixture - .keys - .insert("ghost-tab".to_string(), vec!["G".into()]); - std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - let _ = std::fs::remove_file(path); - - assert!(!loaded.repaired); - assert!(loaded.needs_persist); - assert_eq!(loaded.data.selected_key_type, "4key"); - assert_eq!(loaded.data.keys["ghost-tab"], vec![KeySlot::from("G")]); - } - - #[test] - fn asset_entries_recover_only_from_valid_identity_fields() { - let path = std::env::temp_dir().join(format!( - "dmnote-asset-identity-recovery-test-{}.json", - uuid::Uuid::new_v4() - )); - let font_path = std::env::temp_dir().join(format!( - "dmnote-recovered-font-{}.ttf", - uuid::Uuid::new_v4() - )); - let sound_path = std::env::temp_dir().join(format!( - "dmnote-recovered-sound-{}.wav", - uuid::Uuid::new_v4() - )); - let valid_font = CustomFont { - id: "recoverable-font".to_string(), - font_type: FontType::Local, - name: "Recoverable Font".to_string(), - display_name: "Recoverable Font".to_string(), - enabled: true, - local_path: Some(font_path.to_string_lossy().to_string()), - css_content: None, - weight_ranges: Vec::new(), - }; - let mut recoverable_font = serde_json::to_value(&valid_font).unwrap(); - recoverable_font - .as_object_mut() - .unwrap() - .insert("enabled".to_string(), json!("invalid")); - let mut invalid_identity_font = recoverable_font.clone(); - invalid_identity_font - .as_object_mut() - .unwrap() - .insert("id".to_string(), json!("")); - - let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); - let fields = fixture.as_object_mut().unwrap(); - fields.insert( - "fontSettings".to_string(), - json!({ "customFonts": [recoverable_font, invalid_identity_font] }), - ); - fields.insert( - "soundLibrary".to_string(), - json!({ - sound_path.to_string_lossy().to_string(): 42, - "relative.wav": 42 - }), - ); - std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - let _ = std::fs::remove_file(path); - - assert!(loaded.repaired); - assert_eq!(loaded.data.font_settings.custom_fonts.len(), 1); - let recovered_font = &loaded.data.font_settings.custom_fonts[0]; - assert_eq!(recovered_font.id, "recoverable-font"); - assert!(!recovered_font.enabled); - assert_eq!(recovered_font.local_path, valid_font.local_path); - let sound_key = sound_path.to_string_lossy().to_string(); - assert_eq!( - loaded.data.sound_library[&sound_key], - SoundLibraryEntry::default() - ); - assert!(!loaded.data.sound_library.contains_key("relative.wav")); - } - - #[test] - fn invalid_font_weight_ranges_are_reset_without_removing_the_font() { - let path = std::env::temp_dir().join(format!( - "dmnote-font-weight-range-recovery-test-{}.json", - uuid::Uuid::new_v4() - )); - let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); - fixture.as_object_mut().unwrap().insert( - "fontSettings".to_string(), - json!({ - "customFonts": [{ - "id": "recoverable-web-font", - "type": "web", - "name": "Recoverable Web Font", - "displayName": "Recoverable Web Font", - "enabled": true, - "cssContent": "@font-face { font-family: 'Recoverable Web Font'; src: url(font.woff2); }", - "weightRanges": [{ "min": 900, "max": 100 }] - }] - }), - ); - std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - let _ = std::fs::remove_file(path); - - assert!(loaded.repaired); - assert_eq!(loaded.data.font_settings.custom_fonts.len(), 1); - assert_eq!( - loaded.data.font_settings.custom_fonts[0].id, - "recoverable-web-font" - ); - assert!(loaded.data.font_settings.custom_fonts[0] - .weight_ranges - .is_empty()); - } - - #[test] - fn position_widgets_recover_layout_only_with_valid_identity() { - fn corrupt_height(mut value: Value) -> Value { - value - .as_object_mut() - .unwrap() - .insert("height".to_string(), json!("invalid")); - value - } - - let path = std::env::temp_dir().join(format!( - "dmnote-widget-layout-recovery-test-{}.json", - uuid::Uuid::new_v4() - )); - let mut stat_position = default_positions()["4key"][0].clone(); - stat_position.sound_path = Some("/tmp/recovered-stat.wav".to_string()); - let mut graph_position = default_positions()["4key"][1].clone(); - graph_position.sound_path = Some("/tmp/recovered-graph.wav".to_string()); - let mut knob_position = default_positions()["4key"][2].clone(); - knob_position.sound_path = Some("/tmp/recovered-knob.wav".to_string()); - - let recoverable_stat = corrupt_height( - serde_json::to_value(StatPosition { - stat_type: StatType::Kps, - position: stat_position.clone(), - }) - .unwrap(), - ); - let mut invalid_stat = recoverable_stat.clone(); - invalid_stat - .as_object_mut() - .unwrap() - .insert("statType".to_string(), json!("invalid")); - - let recoverable_graph = corrupt_height( - serde_json::to_value(GraphPosition { - stat_type: GraphStatType::Kps, - graph_type: GraphType::Line, - graph_speed: 1, - graph_color: "#FFFFFF".to_string(), - show_avg_line: true, - position: graph_position.clone(), - }) - .unwrap(), - ); - let mut invalid_graph = recoverable_graph.clone(); - invalid_graph - .as_object_mut() - .unwrap() - .insert("graphType".to_string(), json!("invalid")); - let mut invalid_graph_setting = recoverable_graph.clone(); - invalid_graph_setting - .as_object_mut() - .unwrap() - .insert("graphSpeed".to_string(), json!("invalid")); - - let recoverable_knob = corrupt_height( - serde_json::to_value(KnobPosition { - axis_id: "axis-recoverable".to_string(), - sensitivity: 1.0, - reverse: false, - position: knob_position.clone(), - }) - .unwrap(), - ); - let mut unbound_knob = recoverable_knob.clone(); - unbound_knob - .as_object_mut() - .unwrap() - .insert("axisId".to_string(), json!("")); - let mut invalid_knob_setting = recoverable_knob.clone(); - invalid_knob_setting - .as_object_mut() - .unwrap() - .insert("sensitivity".to_string(), json!("invalid")); - - let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); - let fields = fixture.as_object_mut().unwrap(); - fields.insert( - "statPositions".to_string(), - json!({ "recovery-mode": [recoverable_stat, invalid_stat] }), - ); - fields.insert( - "graphPositions".to_string(), - json!({ - "recovery-mode": [recoverable_graph, invalid_graph, invalid_graph_setting] - }), - ); - fields.insert( - "knobPositions".to_string(), - json!({ - "recovery-mode": [recoverable_knob, unbound_knob, invalid_knob_setting] - }), - ); - std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - let _ = std::fs::remove_file(path); - - assert_eq!(loaded.data.stat_positions["recovery-mode"].len(), 1); - assert_eq!(loaded.data.graph_positions["recovery-mode"].len(), 1); - assert_eq!(loaded.data.knob_positions["recovery-mode"].len(), 2); - let stat = &loaded.data.stat_positions["recovery-mode"][0]; - let graph = &loaded.data.graph_positions["recovery-mode"][0]; - let knob = &loaded.data.knob_positions["recovery-mode"][0]; - let unbound_knob = &loaded.data.knob_positions["recovery-mode"][1]; - assert_eq!(stat.stat_type, StatType::Kps); - assert_eq!(graph.graph_type, GraphType::Line); - assert_eq!(knob.axis_id, "axis-recoverable"); - assert!(unbound_knob.axis_id.is_empty()); - assert_eq!(stat.position.height, KeyPosition::default().height); - assert_eq!(graph.position.height, KeyPosition::default().height); - assert_eq!(knob.position.height, KeyPosition::default().height); - assert_eq!(stat.position.sound_path, stat_position.sound_path); - assert_eq!(graph.position.sound_path, graph_position.sound_path); - assert_eq!(knob.position.sound_path, knob_position.sound_path); - assert_eq!(unbound_knob.position.sound_path, knob_position.sound_path); - } - - #[test] - fn repaired_selection_falls_back_without_deleting_orphaned_mode_data() { - let path = std::env::temp_dir().join(format!( - "dmnote-selected-tab-recovery-test-{}.json", - uuid::Uuid::new_v4() - )); - let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); - let fields = fixture.as_object_mut().unwrap(); - fields.insert( - "customTabs".to_string(), - json!([{ "id": "ghost-tab", "name": 42 }]), - ); - fields.insert("selectedKeyType".to_string(), json!("ghost-tab")); - fields.insert("keys".to_string(), json!({ "ghost-tab": ["G"] })); - std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - let _ = std::fs::remove_file(path); - - assert!(loaded.repaired); - assert!(loaded.data.custom_tabs.is_empty()); - assert_eq!(loaded.data.selected_key_type, "4key"); - assert_eq!(loaded.data.keys["ghost-tab"], vec![KeySlot::from("G")]); - } - - #[test] - fn nested_user_settings_recover_valid_siblings_only() { - let path = std::env::temp_dir().join(format!( - "dmnote-nested-settings-recovery-test-{}.json", - uuid::Uuid::new_v4() - )); - let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); - let fields = fixture.as_object_mut().unwrap(); - - let note_settings = fields["noteSettings"].as_object_mut().unwrap(); - note_settings.insert("speed".to_string(), json!(987)); - note_settings.insert("trackHeight".to_string(), json!("invalid")); - - fields.insert( - "customTabs".to_string(), - json!([{ "id": "alpha-tab", "name": "Alpha" }]), - ); - fields.insert("keys".to_string(), json!({ "alpha-tab": ["A", "B", "C"] })); - fields.insert( - "layerGroups".to_string(), - json!({ - "alpha-tab": [ - { "id": "group-a", "name": "Group A" }, - 42, - { "id": "group-c", "name": "Group C" } - ], - "invalid-mode": 42 - }), - ); - fields.insert( - "keyCounters".to_string(), - json!({ - "alpha-tab": { "A": 7, "B": "invalid", "C": 9 }, - "invalid-mode": 42 - }), - ); - fields.insert( - "customCss".to_string(), - json!({ "path": "/tmp/sentinel.css", "content": 42 }), - ); - fields.insert( - "counterAnimationPresets".to_string(), - json!([ - { - "id": "animation-a", - "name": "Animation A", - "source": "user", - "bezier": [0.25, 0.46, 0.45, 0.94], - "scale": 1.1, - "durationMs": 300 - }, - 42, - { - "id": "animation-c", - "name": "Animation C", - "source": "user", - "bezier": [0.1, 0.2, 0.3, 0.4], - "scale": 1.2, - "durationMs": 400 - } - ]), - ); - fields.insert( - "tabCssOverrides".to_string(), - json!({ - "alpha-tab": { "path": null, "content": ".alpha {}", "enabled": true }, - "repaired-tab": { "path": "/tmp/repaired.css", "content": ".repaired {}", "enabled": 42 }, - "invalid-tab": 42 - }), - ); - fields.insert( - "tabNoteOverrides".to_string(), - json!({ - "alpha-tab": { "speed": 654 }, - "repaired-tab": { "speed": 777, "reverse": "invalid" }, - "invalid-tab": 42 - }), - ); - fields.insert( - "customJs".to_string(), - json!({ - "path": "/tmp/sentinel.js", - "content": "globalThis.sentinel = true;", - "plugins": [ - { "id": "plugin-a", "name": "Plugin A", "path": null, "content": "a", "enabled": true }, - 42, - { "id": "plugin-c", "name": "Plugin C", "path": null, "content": "c", "enabled": false } - ] - }), - ); - - let grid_settings = fields["gridSettings"].as_object_mut().unwrap(); - grid_settings.insert("alignmentGuides".to_string(), json!(false)); - grid_settings.insert("gridSnapSize".to_string(), json!("invalid")); - grid_settings.insert("overlayPadding".to_string(), json!(17)); - - let shortcuts = fields["shortcuts"].as_object_mut().unwrap(); - shortcuts.insert("toggleOverlay".to_string(), json!(42)); - shortcuts.insert( - "toggleAlwaysOnTop".to_string(), - json!({ "key": "F12", "ctrl": true, "shift": false, "alt": false, "meta": false }), - ); - - let repaired_key = absolute_fixture_path("repaired.wav"); - let path_only_key = absolute_fixture_path("path-only.wav"); - let mut sound_library = serde_json::Map::new(); - sound_library.insert( - repaired_key.clone(), - json!({ - "source": 42, - "displayName": "Recovered sound", - "trimStartRatio": 0.2 - }), - ); - sound_library.insert(path_only_key.clone(), json!(42)); - fields.insert( - "soundLibrary".to_string(), - serde_json::Value::Object(sound_library), - ); - - std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); - let loaded = load_store_from_path(&path).unwrap(); - let _ = std::fs::remove_file(path); - - assert!(loaded.repaired); - assert_eq!(loaded.data.note_settings.speed, 987); - assert_eq!( - loaded.data.note_settings.track_height, - AppStoreData::default().note_settings.track_height - ); - assert_eq!( - loaded.data.layer_groups["alpha-tab"] - .iter() - .map(|group| group.id.as_str()) - .collect::>(), - vec!["group-a", "group-c"] - ); - assert!(!loaded.data.layer_groups.contains_key("invalid-mode")); - assert_eq!(loaded.data.key_counters["alpha-tab"]["A"], 7); - assert_eq!(loaded.data.key_counters["alpha-tab"]["B"], 0); - assert_eq!(loaded.data.key_counters["alpha-tab"]["C"], 9); - assert!(!loaded.data.key_counters.contains_key("invalid-mode")); - assert_eq!( - loaded.data.custom_css.path.as_deref(), - Some("/tmp/sentinel.css") - ); - assert!(loaded.data.custom_css.content.is_empty()); - assert_eq!( - loaded - .data - .counter_animation_presets - .iter() - .map(|preset| preset.id.as_str()) - .collect::>(), - vec!["animation-a", "animation-c"] - ); - assert_eq!( - loaded.data.tab_css_overrides["alpha-tab"].content, - ".alpha {}" - ); - assert_eq!( - loaded.data.tab_css_overrides["repaired-tab"].content, - ".repaired {}" - ); - assert!(loaded.data.tab_css_overrides["repaired-tab"].enabled); - assert!(!loaded.data.tab_css_overrides.contains_key("invalid-tab")); - assert_eq!(loaded.data.tab_note_overrides["alpha-tab"].speed, Some(654)); - assert_eq!( - loaded.data.tab_note_overrides["repaired-tab"].speed, - Some(777) - ); - assert_eq!(loaded.data.tab_note_overrides["repaired-tab"].reverse, None); - assert!(!loaded.data.tab_note_overrides.contains_key("invalid-tab")); - assert_eq!( - loaded.data.custom_js.path.as_deref(), - Some("/tmp/sentinel.js") - ); - assert_eq!( - loaded - .data - .custom_js - .plugins - .iter() - .map(|plugin| plugin.id.as_str()) - .collect::>(), - vec!["plugin-a", "plugin-c"] - ); - assert!(!loaded.data.grid_settings.alignment_guides); - assert_eq!( - loaded.data.grid_settings.grid_snap_size, - AppStoreData::default().grid_settings.grid_snap_size - ); - assert_eq!(loaded.data.grid_settings.overlay_padding, 17); - assert_eq!(loaded.data.shortcuts.toggle_always_on_top.key, "F12"); - assert_eq!( - loaded.data.shortcuts.toggle_overlay, - AppStoreData::default().shortcuts.toggle_overlay - ); - assert_eq!( - loaded.data.sound_library[repaired_key.as_str()] - .display_name - .as_deref(), - Some("Recovered sound") - ); - assert_eq!( - loaded.data.sound_library[repaired_key.as_str()].trim_start_ratio, - Some(0.2) - ); - assert_eq!( - loaded.data.sound_library[repaired_key.as_str()].source, - crate::models::SoundSource::Local - ); - assert_eq!( - loaded.data.sound_library[path_only_key.as_str()], - SoundLibraryEntry::default() - ); - } - - #[test] - fn asset_position_collections_recover_valid_entries_only() { - let path = std::env::temp_dir().join(format!( - "dmnote-position-entry-recovery-test-{}.json", - uuid::Uuid::new_v4() - )); - let position = default_positions()["4key"][0].clone(); - let mut third_position = default_positions()["4key"][1].clone(); - third_position.dx += 17.0; - let mut partial_position = default_positions()["4key"][0].clone(); - partial_position.dx = 246.0; - partial_position.width = 88.0; - partial_position.active_image = Some("/tmp/recovered-image.png".to_string()); - partial_position.sound_path = Some("/tmp/recovered-sound.wav".to_string()); - let mut partial_position_value = serde_json::to_value(&partial_position).unwrap(); - partial_position_value - .as_object_mut() - .unwrap() - .insert("height".to_string(), json!("invalid")); - let stat = StatPosition { - stat_type: StatType::Kps, - position: position.clone(), - }; - let graph = GraphPosition { - stat_type: GraphStatType::Kps, - graph_type: GraphType::Line, - graph_speed: 1, - graph_color: "#FFFFFF".to_string(), - show_avg_line: true, - position: position.clone(), - }; - let knob = KnobPosition { - axis_id: "axis-sentinel".to_string(), - sensitivity: 1.0, - reverse: false, - position: position.clone(), - }; - let font = CustomFont { - id: "font-sentinel".to_string(), - font_type: FontType::Local, - name: "Font Sentinel".to_string(), - display_name: "Font Sentinel".to_string(), - enabled: true, - local_path: Some(absolute_fixture_path("font-sentinel.ttf")), - css_content: None, - weight_ranges: Vec::new(), - }; - - let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); - let fields = fixture.as_object_mut().unwrap(); - fields.insert( - "keyPositions".to_string(), - json!({ - "partial-mode": [ - serde_json::to_value(&position).unwrap(), - partial_position_value, - serde_json::to_value(&third_position).unwrap(), - 42 - ], - "invalid-mode": 42, - }), - ); - fields.insert( - "statPositions".to_string(), - json!({ - "partial-mode": [serde_json::to_value(&stat).unwrap(), 42], - "invalid-mode": 42, - }), - ); - fields.insert( - "graphPositions".to_string(), - json!({ - "partial-mode": [serde_json::to_value(&graph).unwrap(), 42], - "invalid-mode": 42, - }), - ); - fields.insert( - "knobPositions".to_string(), - json!({ - "partial-mode": [serde_json::to_value(&knob).unwrap(), 42], - "invalid-mode": 42, - }), - ); - fields.insert( - "fontSettings".to_string(), - json!({ - "customFonts": [serde_json::to_value(&font).unwrap(), 42], - }), - ); - std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - let _ = std::fs::remove_file(path); - - assert!(loaded.repaired); - assert!(loaded.needs_persist); - let recovered_key_positions = &loaded.data.key_positions["partial-mode"]; - assert_eq!(recovered_key_positions.len(), 4); - let mut recovered_first = recovered_key_positions[0].clone(); - recovered_first.id.clear(); - assert_eq!(recovered_first, position); - assert_eq!(recovered_key_positions[1].dx, partial_position.dx); - assert_eq!(recovered_key_positions[1].width, partial_position.width); - assert_eq!( - recovered_key_positions[1].height, - KeyPosition::default().height - ); - assert_eq!( - recovered_key_positions[1].active_image, - partial_position.active_image - ); - assert_eq!( - recovered_key_positions[1].sound_path, - partial_position.sound_path - ); - let mut recovered_third = recovered_key_positions[2].clone(); - recovered_third.id.clear(); - assert_eq!(recovered_third, third_position); - let mut recovered_default = recovered_key_positions[3].clone(); - recovered_default.id.clear(); - assert_eq!(recovered_default, KeyPosition::default()); - assert_eq!(recovered_key_positions[3].width, 60.0); - let mut recovered_stat = loaded.data.stat_positions["partial-mode"][0].clone(); - recovered_stat.position.id.clear(); - assert_eq!(recovered_stat, stat); - let mut recovered_graph = loaded.data.graph_positions["partial-mode"][0].clone(); - recovered_graph.position.id.clear(); - assert_eq!(recovered_graph, graph); - let mut recovered_knob = loaded.data.knob_positions["partial-mode"][0].clone(); - recovered_knob.position.id.clear(); - assert_eq!(recovered_knob, knob); - assert!(!loaded.data.key_positions.contains_key("invalid-mode")); - assert!(!loaded.data.stat_positions.contains_key("invalid-mode")); - assert!(!loaded.data.graph_positions.contains_key("invalid-mode")); - assert!(!loaded.data.knob_positions.contains_key("invalid-mode")); - assert_eq!(loaded.data.font_settings.custom_fonts, vec![font]); - } - - #[test] - fn image_settings_recover_in_place_without_removing_positions() { - let path = std::env::temp_dir().join(format!( - "dmnote-image-transform-recovery-test-{}.json", - uuid::Uuid::new_v4() - )); - let positions = vec![ - default_positions()["4key"][0].clone(), - default_positions()["4key"][1].clone(), - ]; - let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); - fixture["keyPositions"]["recovery-mode"] = serde_json::to_value(&positions).unwrap(); - fixture["keyPositions"]["recovery-mode"][0]["imageMode"] = json!("damaged"); - fixture["keyPositions"]["recovery-mode"][0]["idleImageTransform"] = json!({ - "offsetX": 25.0, - "offsetY": -30.0, - "rotation": 45.0, - "scale": 100.0 - }); - std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); - - let loaded = load_store_from_path(&path).unwrap(); - let _ = std::fs::remove_file(path); - - assert!(loaded.repaired); - assert!(loaded.needs_persist); - assert_eq!(loaded.data.key_positions["recovery-mode"].len(), 2); - let recovered = &loaded.data.key_positions["recovery-mode"][0]; - assert_eq!(recovered.image_mode, None); - assert_eq!( - recovered.idle_image_transform, - Some(ImageTransform { - offset_x: 25.0, - offset_y: -30.0, - rotation: 45.0, - scale: 1.0, - }) - ); - - let mut data = loaded.data; - let transform = data.key_positions.get_mut("recovery-mode").unwrap()[0] - .active_image_transform - .insert(ImageTransform { - offset_x: 10.0, - offset_y: 20.0, - rotation: 30.0, - scale: f64::NAN, - }); - assert!(transform.scale.is_nan()); - let original_len = data.key_positions["recovery-mode"].len(); - assert!(repair_image_transforms(&mut data)); - assert_eq!(data.key_positions["recovery-mode"].len(), original_len); - assert_eq!( - data.key_positions["recovery-mode"][0].active_image_transform, - Some(ImageTransform { - offset_x: 10.0, - offset_y: 20.0, - rotation: 30.0, - scale: 1.0, - }) - ); - } - - #[test] - fn non_utf8_store_recovers_instead_of_failing_init() { - // 잘못된 UTF-8 바이트(0xFF)는 과거 read_to_string에서 IO 에러로 초기화 실패 + .bak 미생성 - let path = std::env::temp_dir().join(format!( - "dmnote-non-utf8-recovery-test-{}.json", - uuid::Uuid::new_v4() - )); - std::fs::write(&path, [b'{', 0xFF, b'}']).unwrap(); - - let loaded = load_store_from_path(&path).expect("비 UTF-8 store는 Err 대신 복구되어야 함"); - let _ = std::fs::remove_file(&path); - - // 복구 분기 합류 — repaired/needs_persist로 store.rs가 .bak 백업 후 재저장 - assert!(loaded.repaired); - assert!(loaded.needs_persist); - - // 문법만 깨진 UTF-8 JSON과 동일한 기본값 복구 경로로 수렴 - let broken_path = std::env::temp_dir().join(format!( - "dmnote-broken-json-baseline-{}.json", - uuid::Uuid::new_v4() - )); - std::fs::write(&broken_path, b"{ not json").unwrap(); - let baseline = load_store_from_path(&broken_path).unwrap(); - let _ = std::fs::remove_file(&broken_path); - let mut loaded_value = serde_json::to_value(&loaded.data).unwrap(); - let mut baseline_value = serde_json::to_value(&baseline.data).unwrap(); - remove_all_native_ids(&mut loaded_value); - remove_all_native_ids(&mut baseline_value); - assert_eq!(loaded_value, baseline_value); - } - - #[test] - fn missing_local_font_is_restored_from_embedded_data_uri() { - let app_data_dir = std::env::temp_dir().join(format!( - "dmnote-font-migration-test-{}", - uuid::Uuid::new_v4() - )); - let missing_path = app_data_dir.join("missing.woff2"); - let font_bytes = b"embedded-font"; - let css_content = format!( - "@font-face {{ src: url(data:font/woff2;base64,{}) format('woff2'); }}", - BASE64_STANDARD.encode(font_bytes) - ); - let mut data = AppStoreData::default(); - data.font_settings.custom_fonts.push(CustomFont { - id: "local-font".to_string(), - font_type: FontType::Local, - name: "Local Font".to_string(), - display_name: "Local Font".to_string(), - enabled: true, - local_path: Some(missing_path.to_string_lossy().to_string()), - css_content: Some(css_content), - weight_ranges: Vec::new(), - }); - - assert!(migrate_local_fonts_to_app_data(&app_data_dir, &mut data)); - - let font = &data.font_settings.custom_fonts[0]; - let restored_path = std::path::PathBuf::from(font.local_path.as_ref().unwrap()); - assert!(font.enabled); - assert!(font.css_content.is_none()); - assert_eq!( - restored_path.parent(), - Some(app_data_dir.join("fonts").as_path()) - ); - assert_eq!( - restored_path.extension().and_then(|ext| ext.to_str()), - Some("woff2") - ); - assert_eq!(std::fs::read(&restored_path).unwrap(), font_bytes); - - let _ = std::fs::remove_dir_all(app_data_dir); - } - - #[test] - fn portable_asset_reference_parser_accepts_cross_platform_absolute_forms() { - let cases = [ - r"C:\Users\me\AppData\Roaming\com.dmnote.desktop\sounds\key.wav", - r"\\?\C:\Users\me\AppData\Roaming\com.dmnote.desktop\sounds\key.wav", - r"\\server\share\com.dmnote.desktop\sounds\key.wav", - r"\\?\UNC\server\share\com.dmnote.desktop\sounds\key.wav", - "/Users/me/Library/Application Support/com.dmnote.desktop/sounds/key.wav", - "file:///C:/Users/me/AppData/Roaming/com.dmnote.desktop/sounds/key.wav", - ]; - for raw in cases { - assert_eq!( - parse_portable_asset_reference(raw), - Some((AssetCategory::Sounds, "key.wav".to_string())), - "failed to parse {raw}" - ); - } - } - - #[test] - fn cross_platform_asset_forms_rehome_to_existing_local_files() { - let dir = rehome_test_directory("absolute-forms"); - let sounds_dir = dir.join("sounds"); - std::fs::create_dir_all(&sounds_dir).unwrap(); - let forms = [ - r"C:\Users\me\AppData\Roaming\com.dmnote.desktop\sounds\normal.wav", - r"\\?\C:\Users\me\AppData\Roaming\com.dmnote.desktop\sounds\verbatim.wav", - r"\\server\share\com.dmnote.desktop\sounds\unc.wav", - "file:///C:/Users/me/AppData/Roaming/com.dmnote.desktop/sounds/url.wav", - ]; - for name in ["normal.wav", "verbatim.wav", "unc.wav", "url.wav"] { - std::fs::write(sounds_dir.join(name), b"sound").unwrap(); - } - - for (raw, name) in - forms - .into_iter() - .zip(["normal.wav", "verbatim.wav", "unc.wav", "url.wav"]) - { - let mut data = data_with_one_position(); - data.key_positions.get_mut("4key").unwrap()[0].sound_path = Some(raw.to_string()); - assert!(rehome_foreign_asset_references(&dir, &mut data)); - assert_eq!( - data.key_positions["4key"][0].sound_path.as_deref(), - Some(sounds_dir.join(name).to_string_lossy().as_ref()) - ); - } - - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn missing_external_and_traversal_asset_references_remain_unchanged() { - let dir = rehome_test_directory("rejected"); - std::fs::create_dir_all(dir.join("sounds")).unwrap(); - std::fs::write(dir.join("sounds").join("evil.wav"), b"sound").unwrap(); - for raw in [ - r"C:\Users\me\com.dmnote.desktop\sounds\missing.wav", - r"D:\music\evil.wav", - r"C:\Users\me\com.dmnote.desktop\sounds\..\evil.wav", - r"C:\Users\me\com.dmnote.desktop\sounds\..", - ] { - let mut data = data_with_one_position(); - data.key_positions.get_mut("4key").unwrap()[0].sound_path = Some(raw.to_string()); - assert!(!rehome_foreign_asset_references(&dir, &mut data)); - assert_eq!( - data.key_positions["4key"][0].sound_path.as_deref(), - Some(raw) - ); - } - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn normalized_unicode_file_name_rehomes_only_on_unique_match() { - let dir = rehome_test_directory("unicode"); - let images_dir = dir.join("images"); - std::fs::create_dir_all(&images_dir).unwrap(); - let nfc_name = "키음.wav"; - let nfd_name = nfc_name.nfd().collect::(); - let actual_path = images_dir.join(&nfd_name); - std::fs::write(&actual_path, b"image").unwrap(); - let mut data = data_with_one_position(); - data.key_positions.get_mut("4key").unwrap()[0].active_image = - Some(format!(r"C:\Users\me\com.dmnote.desktop\images\{nfc_name}")); - - assert!(rehome_foreign_asset_references(&dir, &mut data)); - let rehomed = - std::path::PathBuf::from(data.key_positions["4key"][0].active_image.as_ref().unwrap()); - assert!(rehomed.is_file()); - assert_eq!( - rehomed - .file_name() - .unwrap() - .to_string_lossy() - .nfc() - .collect::(), - nfc_name - ); - assert!(!rehome_foreign_asset_references(&dir, &mut data)); - - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn foreign_font_reference_stays_enabled_and_heals_after_files_arrive() { - let dir = rehome_test_directory("font-heal"); - std::fs::create_dir_all(dir.join("fonts")).unwrap(); - let foreign = r"C:\Users\me\AppData\Roaming\com.dmnote.desktop\fonts\portable.ttf"; - let mut data = AppStoreData::default(); - data.font_settings.custom_fonts.push(CustomFont { - id: "font".to_string(), - font_type: FontType::Local, - name: "Font".to_string(), - display_name: "Font".to_string(), - enabled: true, - local_path: Some(foreign.to_string()), - css_content: None, - weight_ranges: Vec::new(), - }); - - // store만 복사된 상태 — 로드 체인 순서(재귀화 → 폰트 마이그레이션) 재현 - assert!(!rehome_foreign_asset_references(&dir, &mut data)); - migrate_local_fonts_to_app_data(&dir, &mut data); - assert!(data.font_settings.custom_fonts[0].enabled); - assert_eq!( - data.font_settings.custom_fonts[0].local_path.as_deref(), - Some(foreign) - ); - - // 파일이 뒤늦게 복사되면 다음 로드에서 치유 - let local = dir.join("fonts").join("portable.ttf"); - std::fs::write(&local, b"font").unwrap(); - assert!(rehome_foreign_asset_references(&dir, &mut data)); - migrate_local_fonts_to_app_data(&dir, &mut data); - assert!(data.font_settings.custom_fonts[0].enabled); - assert_eq!( - data.font_settings.custom_fonts[0].local_path.as_deref(), - Some(local.to_string_lossy().as_ref()) - ); - - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn dangling_local_font_reference_is_still_disabled() { - // 현재 appData 루트가 마커명을 포함하는 실제 구조 재현 - let root = rehome_test_directory("font-dangling"); - let dir = root.join("com.dmnote.desktop"); - std::fs::create_dir_all(dir.join("fonts")).unwrap(); - let missing_local = dir.join("fonts").join("gone.ttf"); - let mut data = AppStoreData::default(); - data.font_settings.custom_fonts.push(CustomFont { - id: "font".to_string(), - font_type: FontType::Local, - name: "Font".to_string(), - display_name: "Font".to_string(), - enabled: true, - local_path: Some(missing_local.to_string_lossy().into_owned()), - css_content: None, - weight_ranges: Vec::new(), - }); - - // 마커가 있어도 현재 루트 하위의 단순 누락 참조는 기존대로 비활성화 - assert!(!rehome_foreign_asset_references(&dir, &mut data)); - assert!(migrate_local_fonts_to_app_data(&dir, &mut data)); - assert!(!data.font_settings.custom_fonts[0].enabled); - - let _ = std::fs::remove_dir_all(root); - } - - #[cfg(target_os = "windows")] - #[test] - fn windows_identity_variants_of_current_app_data_are_not_foreign() { - let root = rehome_test_directory("win-identity").join("com.dmnote.desktop"); - std::fs::create_dir_all(root.join("fonts")).unwrap(); - let missing = root.join("fonts").join("gone.ttf"); - let plain = missing.to_string_lossy().into_owned(); - - // 대소문자·verbatim·file URL 표기 차이는 전부 "현재 appData"로 인식되어야 함 - let lowercase = plain.to_ascii_lowercase(); - let verbatim = format!(r"\\?\{plain}"); - let url = url::Url::from_file_path(&missing).unwrap().to_string(); - for raw in [lowercase, verbatim, url] { - assert!( - !super::is_foreign_portable_asset_reference(&root, &raw), - "현재 appData 표기 변형이 외래로 오판됨: {raw}" - ); - } - - // 다른 사용자 경로는 외래 판정 유지 - let other = r"C:\Users\dmnote-other\AppData\Roaming\com.dmnote.desktop\fonts\gone.ttf"; - assert!(super::is_foreign_portable_asset_reference(&root, other)); - - let _ = std::fs::remove_dir_all(root.parent().unwrap()); - } - - #[test] - fn corrupt_entries_with_foreign_managed_paths_survive_recovery() { - // 사운드: 외래 관리 경로 키 + 손상 값 → 삭제 대신 기본 메타데이터로 재건 - let foreign_sound = r"C:\Users\me\AppData\Roaming\com.dmnote.desktop\sounds\key.wav"; - let posix_sound = "/Users/me/Library/Application Support/com.dmnote.desktop/sounds/k2.wav"; - let mut entries = serde_json::Map::new(); - entries.insert(foreign_sound.to_string(), json!(42)); - entries.insert(posix_sound.to_string(), json!(42)); - entries.insert("not-a-path".to_string(), json!(42)); - - let recovered = - super::recover_sound_library_entries(&serde_json::Value::Object(entries)).unwrap(); - let recovered = recovered.as_object().unwrap(); - assert!(recovered.contains_key(foreign_sound)); - assert!(recovered.contains_key(posix_sound)); - assert!(!recovered.contains_key("not-a-path")); - - // 폰트: 외래 관리 경로 + enabled 손상 → 항목 삭제 대신 비활성 복구 - let mut font = serde_json::to_value(CustomFont { - id: "font".to_string(), - font_type: FontType::Local, - name: "Font".to_string(), - display_name: "Font".to_string(), - enabled: true, - local_path: Some( - r"C:\Users\me\AppData\Roaming\com.dmnote.desktop\fonts\p.ttf".to_string(), - ), - css_content: None, - weight_ranges: Vec::new(), - }) - .unwrap(); - font.as_object_mut() - .unwrap() - .insert("enabled".to_string(), json!(42)); - - let candidate = super::recover_local_font_enabled(&font).unwrap(); - assert_eq!( - candidate.as_object().unwrap().get("enabled"), - Some(&serde_json::Value::Bool(false)) - ); - } - - #[test] - fn sound_library_rekey_preserves_metadata_and_conflicts() { - let dir = rehome_test_directory("sound-library"); - let local_path = dir.join("sounds").join("library.wav"); - std::fs::create_dir_all(local_path.parent().unwrap()).unwrap(); - std::fs::write(&local_path, b"sound").unwrap(); - let foreign = r"C:\Users\me\com.dmnote.desktop\sounds\library.wav".to_string(); - let mut metadata = SoundLibraryEntry { - display_name: Some("보존 이름".to_string()), - trim_start_ratio: Some(0.25), - ..SoundLibraryEntry::default() - }; - let local_key = local_path.to_string_lossy().into_owned(); - - let mut data = AppStoreData::default(); - data.sound_library.insert(foreign.clone(), metadata.clone()); - assert!(rehome_foreign_asset_references(&dir, &mut data)); - assert_eq!(data.sound_library[&local_key], metadata); - assert!(!data.sound_library.contains_key(&foreign)); - - data.sound_library.insert(foreign.clone(), metadata.clone()); - assert!(rehome_foreign_asset_references(&dir, &mut data)); - assert_eq!(data.sound_library.len(), 1); - - metadata.display_name = Some("다른 이름".to_string()); - data.sound_library.insert(foreign.clone(), metadata.clone()); - assert!(!rehome_foreign_asset_references(&dir, &mut data)); - assert_eq!(data.sound_library[&foreign], metadata); - assert_ne!(data.sound_library[&local_key], metadata); - - let alternate_foreign = r"\\server\share\com.dmnote.desktop\sounds\library.wav".to_string(); - let mut candidates_only = AppStoreData::default(); - candidates_only - .sound_library - .insert(foreign.clone(), SoundLibraryEntry::default()); - candidates_only - .sound_library - .insert(alternate_foreign.clone(), metadata.clone()); - assert!(!rehome_foreign_asset_references(&dir, &mut candidates_only)); - assert!(candidates_only.sound_library.contains_key(&foreign)); - assert!(candidates_only - .sound_library - .contains_key(&alternate_foreign)); - assert!(!candidates_only.sound_library.contains_key(&local_key)); - - let _ = std::fs::remove_dir_all(dir); - } -} +mod tests; diff --git a/src-tauri/src/state/migration/tests.rs b/src-tauri/src/state/migration/tests.rs new file mode 100644 index 000000000..7d301748f --- /dev/null +++ b/src-tauri/src/state/migration/tests.rs @@ -0,0 +1,3811 @@ + +use super::{ + load_store_from_path, migrate_local_fonts_to_app_data, migrate_sound_library_enabled, + normalize_state, parse_portable_asset_reference, recover_key_mapping_entries, + rehome_foreign_asset_references, repair_image_transforms, rgba_to_hex, AssetCategory, + LEGACY_OVERLAY_HEIGHT, LEGACY_OVERLAY_WIDTH, LEGACY_PANEL_DETACH_ENABLED_KEY, +}; +use crate::{ + defaults::{default_keys, default_positions}, + models::{ + AppStoreData, CustomCssHistoryEntry, CustomFont, CustomTab, FontType, GraphPosition, + GraphStatType, GraphType, ImageTransform, KeyCounterAlign, KeyCounterAlignMode, + KeyCounterColor, KeyCounterPlacement, KeyMappings, KeyPosition, KeySlot, KnobPosition, + LayerGroupDef, NoteColor, OverlayBounds, SlotMatch, SoundLibraryEntry, StatPosition, + StatType, TabCss, TabNoteSettings, POSITION_COLLECTION_FIELDS, + }, +}; +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; +use serde::{Deserialize, Serialize}; +use unicode_normalization::UnicodeNormalization; + +fn rehome_test_directory(label: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("dmnote-rehome-{label}-{}", uuid::Uuid::new_v4())) +} + +// Windows에선 /tmp가 절대 경로가 아니므로 픽스처는 플랫폼별 절대 경로 사용 +fn absolute_fixture_path(name: &str) -> String { + if cfg!(target_os = "windows") { + format!(r"C:\tmp\{name}") + } else { + format!("/tmp/{name}") + } +} + +fn data_with_one_position() -> AppStoreData { + AppStoreData { + key_positions: default_positions().clone(), + ..AppStoreData::default() + } +} + +#[test] +fn missing_note_gradient_multipliers_default_to_one_hundred_on_all_collections() { + let gradient = serde_json::json!({ + "angle": 90, + "stops": [ + { "color": "#112233", "pos": 0 }, + { "color": "#445566", "pos": 1 } + ] + }); + let mut value = serde_json::json!({ + "keyPositions": { "mode": [{ "noteGradient": gradient.clone() }] }, + "statPositions": { "mode": [{ "noteGlowGradient": gradient.clone() }] }, + "graphPositions": { "mode": [{ "noteGradient": gradient.clone() }] }, + "knobPositions": { "mode": [{ + "noteGradient": gradient, + "noteGlowGradient": null + }] } + }); + + // 보정 발생 시 true 반환 - needs_persist로 전파되어 디스크에도 영속 + assert!(super::default_store_note_gradient_multipliers(&mut value)); + + assert_eq!(value["keyPositions"]["mode"][0]["noteOpacity"], 100); + assert_eq!(value["statPositions"]["mode"][0]["noteGlowOpacity"], 100); + assert_eq!(value["graphPositions"]["mode"][0]["noteOpacity"], 100); + assert_eq!(value["knobPositions"]["mode"][0]["noteOpacity"], 100); + assert!(value["knobPositions"]["mode"][0] + .get("noteGlowOpacity") + .is_none()); + + // 이미 보정된 store 재로드는 무변경 - 반복 영속 방지 + assert!(!super::default_store_note_gradient_multipliers(&mut value)); +} + +fn tauri_store_fixture_base() -> serde_json::Value { + serde_json::json!({ + "hardwareAcceleration": true, + "alwaysOnTop": false, + "overlayLocked": false, + "noteEffect": true, + "selectedKeyType": "fixture-tab", + "customTabs": [{ "id": "fixture-tab", "name": "Fixture" }], + "angleMode": "d3d11", + "language": "ko", + "laboratoryEnabled": false, + "keys": { "fixture-tab": ["F13"] }, + "keyPositions": { + "fixture-tab": [{ + "dx": 13.0, + "dy": 14.0, + "width": 60.0, + "height": 60.0, + "activeImage": "", + "inactiveImage": "", + "count": 0, + "noteColor": "#FFFFFF", + "noteOpacity": 80 + }] + }, + "keyCounters": { "fixture-tab": { "F13": 17 } }, + "backgroundColor": "#131415", + "useCustomCss": false, + "customCss": { "path": null, "content": "" }, + "overlayResizeAnchor": "top-left", + "overlayBounds": null, + "overlayLastContentTopOffset": null, + "overlayBoundsAreLogical": false, + "keyCounterEnabled": true + }) +} + +fn load_literal_fixture(version: &str, fixture: &serde_json::Value) -> AppStoreData { + let path = std::env::temp_dir().join(format!( + "dmnote-tauri-{version}-store-fixture-{}.json", + uuid::Uuid::new_v4() + )); + std::fs::write(&path, serde_json::to_vec_pretty(fixture).unwrap()).unwrap(); + let loaded = load_store_from_path(&path) + .unwrap_or_else(|error| panic!("Tauri {version} fixture must load: {error:#}")); + let _ = std::fs::remove_file(path); + loaded.data +} + +fn store_with_each_native_collection() -> AppStoreData { + let mut data = normalize_state(AppStoreData { + keys: default_keys().clone(), + key_positions: default_positions().clone(), + ..AppStoreData::default() + }); + data.stat_positions.insert( + "4key".to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: KeyPosition { + dx: 101.0, + ..KeyPosition::default() + }, + }], + ); + data.graph_positions.insert( + "4key".to_string(), + vec![GraphPosition { + stat_type: GraphStatType::Kps, + graph_type: GraphType::Line, + graph_speed: 100, + graph_color: "#123456".to_string(), + show_avg_line: true, + position: KeyPosition { + dx: 102.0, + ..KeyPosition::default() + }, + }], + ); + data.knob_positions.insert( + "4key".to_string(), + vec![KnobPosition { + axis_id: "axis".to_string(), + sensitivity: 1.0, + reverse: false, + position: KeyPosition { + dx: 103.0, + ..KeyPosition::default() + }, + }], + ); + crate::state::native_element_id::backfill_store_element_ids(&mut data); + data +} + +fn remove_all_native_ids(value: &mut serde_json::Value) { + for field in POSITION_COLLECTION_FIELDS { + let Some(modes) = value + .get_mut(field) + .and_then(serde_json::Value::as_object_mut) + else { + continue; + }; + for elements in modes + .values_mut() + .filter_map(serde_json::Value::as_array_mut) + { + for element in elements { + if let Some(element) = element.as_object_mut() { + element.remove("id"); + } + } + } + } +} + +#[test] +fn legacy_store_backfills_all_native_ids_and_reload_preserves_them() { + let path = std::env::temp_dir().join(format!( + "dmnote-native-id-backfill-{}.json", + uuid::Uuid::new_v4() + )); + let mut raw = serde_json::to_value(store_with_each_native_collection()).unwrap(); + remove_all_native_ids(&mut raw); + std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + let document = crate::models::EditorDocumentV1::from_store(&loaded.data); + crate::state::native_element_id::validate_document_element_ids(&document).unwrap(); + let first_ids = [ + loaded.data.key_positions["4key"][0].id.clone(), + loaded.data.stat_positions["4key"][0].position.id.clone(), + loaded.data.graph_positions["4key"][0].position.id.clone(), + loaded.data.knob_positions["4key"][0].position.id.clone(), + ]; + assert_eq!( + first_ids + .iter() + .collect::>() + .len(), + 4 + ); + assert!(loaded.needs_persist); + assert!(!loaded.repaired); + + std::fs::write(&path, serde_json::to_vec_pretty(&loaded.data).unwrap()).unwrap(); + let reloaded = load_store_from_path(&path).unwrap(); + let second_ids = [ + reloaded.data.key_positions["4key"][0].id.clone(), + reloaded.data.stat_positions["4key"][0].position.id.clone(), + reloaded.data.graph_positions["4key"][0].position.id.clone(), + reloaded.data.knob_positions["4key"][0].position.id.clone(), + ]; + assert_eq!(second_ids, first_ids); + assert!(!reloaded.needs_persist); + assert!(!reloaded.repaired); + let _ = std::fs::remove_file(path); +} + +#[test] +fn missing_note_gradient_multiplier_persists_on_first_load_and_converges() { + let path = std::env::temp_dir().join(format!( + "dmnote-note-gradient-multiplier-{}.json", + uuid::Uuid::new_v4() + )); + let mut raw = serde_json::to_value(store_with_each_native_collection()).unwrap(); + let position = &mut raw["keyPositions"]["4key"][0]; + position["noteGradient"] = serde_json::json!({ + "angle": 90, + "stops": [ + { "color": "#112233", "pos": 0 }, + { "color": "#445566", "pos": 1 } + ] + }); + position.as_object_mut().unwrap().remove("noteOpacity"); + std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); + + // 배율 부재 보정이 유일한 변경이어도 첫 로드는 영속 대상 + let loaded = load_store_from_path(&path).unwrap(); + assert_eq!(loaded.data.key_positions["4key"][0].note_opacity, 100); + assert!(loaded.needs_persist); + + // 저장 후 재로드는 무변경으로 수렴 - 시작마다 보정 반복 방지 + std::fs::write(&path, serde_json::to_vec_pretty(&loaded.data).unwrap()).unwrap(); + let reloaded = load_store_from_path(&path).unwrap(); + assert_eq!(reloaded.data.key_positions["4key"][0].note_opacity, 100); + assert!(!reloaded.needs_persist); + let _ = std::fs::remove_file(path); +} + +#[test] +fn serialized_undo_history_with_removed_outline_fields_loads_as_opaque_data() { + let path = std::env::temp_dir().join(format!( + "dmnote-removed-outline-history-{}.json", + uuid::Uuid::new_v4() + )); + let baseline = store_with_each_native_collection(); + let mut raw = serde_json::to_value(&baseline).unwrap(); + let saved_history = serde_json::json!({ + "past": [{ + "scope": "editor", + "before": { + "kind": "editor", + "value": { + "changedFields": ["keyPositions"], + "before": { + "keyPositions": { + "4key": [{ + "fontStrokeColor": "#112233", + "activeFontStrokeColor": "#445566", + "counter": { + "stroke": { + "idle": "#000000", + "active": "#FFFFFF" + }, + "strokeIdleGradient": { + "angle": 90, + "stops": [] + } + } + }] + } + } + } + } + }], + "future": [] + }); + raw.as_object_mut() + .unwrap() + .insert("history".to_string(), saved_history.clone()); + std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + assert!(!loaded.needs_persist); + assert!(!loaded.repaired); + assert_eq!(loaded.data.key_positions, baseline.key_positions); + assert_eq!(loaded.data.stat_positions, baseline.stat_positions); + assert_eq!(loaded.data.graph_positions, baseline.graph_positions); + assert_eq!(loaded.data.knob_positions, baseline.knob_positions); + assert_eq!(loaded.data.plugin_data.get("history"), Some(&saved_history)); + let _ = std::fs::remove_file(path); +} + +#[test] +fn invalid_and_duplicate_ids_are_repaired_without_touching_assets() { + let path = std::env::temp_dir().join(format!( + "dmnote-native-id-repair-{}.json", + uuid::Uuid::new_v4() + )); + let mut data = store_with_each_native_collection(); + data.stat_positions.get_mut("4key").unwrap()[0] + .position + .active_image = Some("/images/kept.png".to_string()); + data.stat_positions.get_mut("4key").unwrap()[0] + .position + .sound_path = Some("/sounds/kept.wav".to_string()); + let kept_key_id = data.key_positions["4key"][0].id.clone(); + let kept_knob_id = data.knob_positions["4key"][0].position.id.clone(); + let old_stat_id = data.stat_positions["4key"][0].position.id.clone(); + let old_graph_id = data.graph_positions["4key"][0].position.id.clone(); + let mut raw = serde_json::to_value(data).unwrap(); + raw["statPositions"]["4key"][0]["id"] = serde_json::json!(kept_key_id); + raw["graphPositions"]["4key"][0]["id"] = serde_json::json!(""); + std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + + assert!(loaded.needs_persist); + assert!(loaded.repaired); + assert_eq!(loaded.data.key_positions["4key"][0].id, kept_key_id); + assert_eq!( + loaded.data.knob_positions["4key"][0].position.id, + kept_knob_id + ); + assert_ne!( + loaded.data.stat_positions["4key"][0].position.id, + old_stat_id + ); + assert_ne!( + loaded.data.graph_positions["4key"][0].position.id, + old_graph_id + ); + assert_eq!( + loaded.data.stat_positions["4key"][0] + .position + .active_image + .as_deref(), + Some("/images/kept.png") + ); + assert_eq!( + loaded.data.stat_positions["4key"][0] + .position + .sound_path + .as_deref(), + Some("/sounds/kept.wav") + ); + let _ = std::fs::remove_file(path); +} + +#[test] +fn recovery_and_pair_padding_preserve_surviving_ids_before_backfill() { + let path = std::env::temp_dir().join(format!( + "dmnote-native-id-recovery-order-{}.json", + uuid::Uuid::new_v4() + )); + let data = store_with_each_native_collection(); + let surviving_key_id = data.key_positions["4key"][0].id.clone(); + let surviving_stat_id = data.stat_positions["4key"][0].position.id.clone(); + let original_position_len = data.key_positions["4key"].len(); + let mut raw = serde_json::to_value(data).unwrap(); + raw["keys"]["4key"] + .as_array_mut() + .unwrap() + .push(serde_json::json!("F24")); + raw["statPositions"]["4key"][0]["dx"] = serde_json::json!("broken"); + std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + + assert!(loaded.repaired); + assert_eq!( + loaded.data.key_positions["4key"].len(), + original_position_len + 1 + ); + assert_eq!(loaded.data.key_positions["4key"][0].id, surviving_key_id); + assert_eq!( + loaded.data.stat_positions["4key"][0].position.id, + surviving_stat_id + ); + assert!(crate::state::native_element_id::is_valid_element_id( + &loaded.data.key_positions["4key"][original_position_len].id + )); + crate::state::native_element_id::validate_document_element_ids( + &crate::models::EditorDocumentV1::from_store(&loaded.data), + ) + .unwrap(); + let _ = std::fs::remove_file(path); +} + +fn saved_plugin_instance_json(x: f64, instance_id: Option<&str>) -> serde_json::Value { + let mut instance = serde_json::json!({ + "position": { "x": x, "y": 2.0 }, + "tabId": "4key" + }); + if let Some(id) = instance_id { + instance["instanceId"] = serde_json::json!(id); + } + instance +} + +fn stored_plugin_instance_ids(data: &AppStoreData, plugin_id: &str) -> Vec { + data.plugin_data[&crate::state::plugin::plugin_instances_storage_key(plugin_id)] + .as_array() + .unwrap() + .iter() + .map(|instance| instance["instanceId"].as_str().unwrap().to_string()) + .collect() +} + +fn plugin_backfill_fixture_path(label: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!( + "dmnote-plugin-instance-backfill-{label}-{}.json", + uuid::Uuid::new_v4() + )) +} + +#[test] +fn backfill_plugin_instances_assigns_unique_ids_and_reload_preserves_them() { + let path = plugin_backfill_fixture_path("assign"); + let mut raw = serde_json::to_value(store_with_each_native_collection()).unwrap(); + let raw_object = raw.as_object_mut().unwrap(); + raw_object.insert( + "plugin_data_alpha/instances".to_string(), + serde_json::json!([ + saved_plugin_instance_json(1.0, None), + saved_plugin_instance_json(2.0, None) + ]), + ); + raw_object.insert( + "plugin_data_beta/instances".to_string(), + serde_json::json!([saved_plugin_instance_json(3.0, None)]), + ); + std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + assert!(loaded.needs_persist); + assert!(!loaded.repaired); + let alpha_ids = stored_plugin_instance_ids(&loaded.data, "alpha"); + let beta_ids = stored_plugin_instance_ids(&loaded.data, "beta"); + let all_ids = alpha_ids.iter().chain(&beta_ids).collect::>(); + assert_eq!(all_ids.len(), 3); + assert!(all_ids + .iter() + .all(|id| crate::state::native_element_id::is_valid_element_id(id))); + assert_eq!( + all_ids + .iter() + .collect::>() + .len(), + 3 + ); + + // 멱등: 영속 후 재로드에서 같은 ID 유지, 추가 변경 없음 + std::fs::write(&path, serde_json::to_vec_pretty(&loaded.data).unwrap()).unwrap(); + let reloaded = load_store_from_path(&path).unwrap(); + assert!(!reloaded.needs_persist); + assert!(!reloaded.repaired); + assert_eq!( + stored_plugin_instance_ids(&reloaded.data, "alpha"), + alpha_ids + ); + assert_eq!(stored_plugin_instance_ids(&reloaded.data, "beta"), beta_ids); + let _ = std::fs::remove_file(path); +} + +#[test] +fn backfill_plugin_instances_preserves_malformed_siblings_across_reload() { + let path = plugin_backfill_fixture_path("mixed"); + let existing_id = uuid::Uuid::new_v4().to_string(); + let malformed = serde_json::json!({ "position": "broken", "keep": true }); + let mut raw = serde_json::to_value(store_with_each_native_collection()).unwrap(); + raw.as_object_mut().unwrap().insert( + "plugin_data_alpha/instances".to_string(), + serde_json::json!([ + saved_plugin_instance_json(1.0, Some(&existing_id)), + saved_plugin_instance_json(2.0, None), + malformed.clone() + ]), + ); + std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + assert!(loaded.needs_persist); + assert!(!loaded.repaired); + let key = crate::state::plugin::plugin_instances_storage_key("alpha"); + let entries = loaded.data.plugin_data[&key].as_array().unwrap(); + assert_eq!(entries.len(), 3); + assert_eq!(entries[0]["instanceId"], existing_id); + assert!(crate::state::native_element_id::is_valid_element_id( + entries[1]["instanceId"].as_str().unwrap() + )); + assert_eq!(entries[2], malformed); + let persisted_bucket = loaded.data.plugin_data[&key].clone(); + + std::fs::write(&path, serde_json::to_vec_pretty(&loaded.data).unwrap()).unwrap(); + let reloaded = load_store_from_path(&path).unwrap(); + assert!(!reloaded.needs_persist); + assert!(!reloaded.repaired); + assert_eq!(reloaded.data.plugin_data[&key], persisted_bucket); + let _ = std::fs::remove_file(path); +} + +#[test] +fn backfill_plugin_instances_preserves_existing_valid_ids() { + let path = plugin_backfill_fixture_path("partial"); + let existing_id = uuid::Uuid::new_v4().to_string(); + let mut raw = serde_json::to_value(store_with_each_native_collection()).unwrap(); + raw.as_object_mut().unwrap().insert( + "plugin_data_alpha/instances".to_string(), + serde_json::json!([ + saved_plugin_instance_json(1.0, Some(&existing_id)), + saved_plugin_instance_json(2.0, None) + ]), + ); + std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + assert!(loaded.needs_persist); + assert!(!loaded.repaired); + let ids = stored_plugin_instance_ids(&loaded.data, "alpha"); + assert_eq!(ids[0], existing_id); + assert_ne!(ids[1], existing_id); + assert!(crate::state::native_element_id::is_valid_element_id( + &ids[1] + )); + // 값 필드와 순서 보존 + let instances = loaded.data.plugin_data + [&crate::state::plugin::plugin_instances_storage_key("alpha")] + .as_array() + .unwrap(); + assert_eq!(instances[0]["position"]["x"], 1.0); + assert_eq!(instances[1]["position"]["x"], 2.0); + let _ = std::fs::remove_file(path); +} + +#[test] +fn backfill_plugin_instances_reissues_invalid_ids_as_repair() { + for invalid_id in ["not-a-uuid".to_string(), uuid::Uuid::nil().to_string()] { + let path = plugin_backfill_fixture_path("invalid"); + let mut raw = serde_json::to_value(store_with_each_native_collection()).unwrap(); + raw.as_object_mut().unwrap().insert( + "plugin_data_alpha/instances".to_string(), + serde_json::json!([saved_plugin_instance_json(1.0, Some(&invalid_id))]), + ); + std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + assert!(loaded.needs_persist); + assert!(loaded.repaired); + let ids = stored_plugin_instance_ids(&loaded.data, "alpha"); + assert_ne!(ids[0], invalid_id); + assert!(crate::state::native_element_id::is_valid_element_id( + &ids[0] + )); + let _ = std::fs::remove_file(path); + } +} + +#[test] +fn backfill_plugin_instances_repairs_in_plugin_duplicates_only() { + let path = plugin_backfill_fixture_path("duplicate"); + let shared_id = uuid::Uuid::new_v4().to_string(); + let mut raw = serde_json::to_value(store_with_each_native_collection()).unwrap(); + let raw_object = raw.as_object_mut().unwrap(); + raw_object.insert( + "plugin_data_alpha/instances".to_string(), + serde_json::json!([ + saved_plugin_instance_json(1.0, Some(&shared_id)), + saved_plugin_instance_json(2.0, Some(&shared_id)) + ]), + ); + raw_object.insert( + "plugin_data_beta/instances".to_string(), + serde_json::json!([saved_plugin_instance_json(3.0, Some(&shared_id))]), + ); + std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + assert!(loaded.needs_persist); + assert!(loaded.repaired); + // 유일성은 커밋 검증과 같은 플러그인 키 단위 - alpha 내 중복만 수리, + // 교차 중복인 beta[0]은 합법이라 보존 + let alpha_ids = stored_plugin_instance_ids(&loaded.data, "alpha"); + let beta_ids = stored_plugin_instance_ids(&loaded.data, "beta"); + assert_eq!(alpha_ids[0], shared_id); + assert_ne!(alpha_ids[1], shared_id); + assert!(crate::state::native_element_id::is_valid_element_id( + &alpha_ids[1] + )); + assert_eq!(beta_ids, vec![shared_id]); + let _ = std::fs::remove_file(path); +} + +#[test] +fn backfill_plugin_instances_preserves_cross_plugin_duplicates_untouched() { + let path = plugin_backfill_fixture_path("cross"); + let shared_id = uuid::Uuid::new_v4().to_string(); + let mut raw = serde_json::to_value(store_with_each_native_collection()).unwrap(); + let raw_object = raw.as_object_mut().unwrap(); + raw_object.insert( + "plugin_data_alpha/instances".to_string(), + serde_json::json!([saved_plugin_instance_json(1.0, Some(&shared_id))]), + ); + raw_object.insert( + "plugin_data_beta/instances".to_string(), + serde_json::json!([saved_plugin_instance_json(2.0, Some(&shared_id))]), + ); + std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + // 교차 플러그인 중복만으로는 수리 대상이 아니다 + assert!(!loaded.needs_persist); + assert!(!loaded.repaired); + assert_eq!( + stored_plugin_instance_ids(&loaded.data, "alpha"), + vec![shared_id.clone()] + ); + assert_eq!( + stored_plugin_instance_ids(&loaded.data, "beta"), + vec![shared_id] + ); + let _ = std::fs::remove_file(path); +} + +#[test] +fn backfill_plugin_instances_skips_undecodable_values_and_processes_the_rest() { + let path = plugin_backfill_fixture_path("undecodable"); + let not_an_array = serde_json::json!({ "not": "array" }); + let unknown_field = serde_json::json!([{ + "position": { "x": 1.0, "y": 2.0 }, + "tabId": "4key", + "handler": true + }]); + let mut raw = serde_json::to_value(store_with_each_native_collection()).unwrap(); + let raw_object = raw.as_object_mut().unwrap(); + raw_object.insert( + "plugin_data_alpha/instances".to_string(), + serde_json::json!([saved_plugin_instance_json(1.0, None)]), + ); + raw_object.insert( + "plugin_data_broken/instances".to_string(), + not_an_array.clone(), + ); + raw_object.insert( + "plugin_data_weird/instances".to_string(), + unknown_field.clone(), + ); + std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + assert!(loaded.needs_persist); + assert!(!loaded.repaired); + let alpha_ids = stored_plugin_instance_ids(&loaded.data, "alpha"); + assert!(crate::state::native_element_id::is_valid_element_id( + &alpha_ids[0] + )); + // decode 불가 키는 원본 Value 그대로 보존 (런타임 read의 fail-closed에 위임) + assert_eq!( + loaded.data.plugin_data["plugin_data_broken/instances"], + not_an_array + ); + assert_eq!( + loaded.data.plugin_data["plugin_data_weird/instances"], + unknown_field + ); + let _ = std::fs::remove_file(path); +} + +#[test] +fn backfill_plugin_instances_runs_after_field_recovery() { + let path = plugin_backfill_fixture_path("recovery"); + let mut raw = serde_json::to_value(store_with_each_native_collection()).unwrap(); + raw["statPositions"]["4key"][0]["dx"] = serde_json::json!("broken"); + raw.as_object_mut().unwrap().insert( + "plugin_data_alpha/instances".to_string(), + serde_json::json!([saved_plugin_instance_json(1.0, None)]), + ); + std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + assert!(loaded.needs_persist); + assert!(loaded.repaired); + let ids = stored_plugin_instance_ids(&loaded.data, "alpha"); + assert_eq!(ids.len(), 1); + assert!(crate::state::native_element_id::is_valid_element_id( + &ids[0] + )); + let _ = std::fs::remove_file(path); +} + +#[test] +fn backfill_plugin_instances_leaves_empty_arrays_untouched() { + let path = plugin_backfill_fixture_path("empty"); + let mut raw = serde_json::to_value(store_with_each_native_collection()).unwrap(); + raw.as_object_mut().unwrap().insert( + "plugin_data_alpha/instances".to_string(), + serde_json::json!([]), + ); + std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + // 빈 배열은 사용자 storage로 보존 + assert!(!loaded.needs_persist); + assert!(!loaded.repaired); + assert_eq!( + loaded.data.plugin_data["plugin_data_alpha/instances"], + serde_json::json!([]) + ); + let _ = std::fs::remove_file(path); +} + +#[test] +fn legacy_panel_detach_setting_is_removed_without_touching_plugin_data() { + let path = std::env::temp_dir().join(format!( + "dmnote-panel-detach-setting-migration-{}.json", + uuid::Uuid::new_v4() + )); + let data = normalize_state(AppStoreData { + keys: default_keys().clone(), + key_positions: default_positions().clone(), + ..AppStoreData::default() + }); + let mut raw = serde_json::to_value(data).unwrap(); + let raw_object = raw.as_object_mut().unwrap(); + raw_object.insert( + LEGACY_PANEL_DETACH_ENABLED_KEY.to_string(), + serde_json::json!(false), + ); + raw_object.insert( + "plugin_data_fixture/settings".to_string(), + serde_json::json!({ "kept": true }), + ); + std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + assert!(loaded.needs_persist); + assert!(!loaded.repaired); + assert!(!loaded + .data + .plugin_data + .contains_key(LEGACY_PANEL_DETACH_ENABLED_KEY)); + assert_eq!( + loaded.data.plugin_data["plugin_data_fixture/settings"]["kept"], + true + ); + + std::fs::write(&path, serde_json::to_vec_pretty(&loaded.data).unwrap()).unwrap(); + let reloaded = load_store_from_path(&path).unwrap(); + assert!(!reloaded.needs_persist); + assert!(!reloaded.repaired); + assert!(!reloaded + .data + .plugin_data + .contains_key(LEGACY_PANEL_DETACH_ENABLED_KEY)); + assert_eq!( + reloaded.data.plugin_data["plugin_data_fixture/settings"]["kept"], + true + ); + let _ = std::fs::remove_file(path); +} + +#[test] +fn legacy_store_without_gradient_fields_preserves_position_bytes() { + let path = std::env::temp_dir().join(format!( + "dmnote-no-gradient-byte-round-trip-{}.json", + uuid::Uuid::new_v4() + )); + let data = normalize_state(AppStoreData { + keys: default_keys().clone(), + key_positions: default_positions().clone(), + ..AppStoreData::default() + }); + let original_position = data.key_positions["4key"][0].clone(); + let original = serde_json::to_vec_pretty(&data).unwrap(); + assert!(!String::from_utf8_lossy(&original).contains("Gradient")); + std::fs::write(&path, &original).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + let mut reloaded_position = loaded.data.key_positions["4key"][0].clone(); + reloaded_position.id.clear(); + + assert!(loaded.needs_persist); + assert!(!loaded.repaired); + assert_eq!(reloaded_position, original_position); + let _ = std::fs::remove_file(path); +} + +#[test] +fn noncanonical_gradient_store_repersist_and_reload_is_idempotent() { + let path = std::env::temp_dir().join(format!( + "dmnote-gradient-canonical-reload-{}.json", + uuid::Uuid::new_v4() + )); + let data = normalize_state(AppStoreData { + keys: default_keys().clone(), + key_positions: default_positions().clone(), + ..AppStoreData::default() + }); + let mut raw = serde_json::to_value(data).unwrap(); + let position = &mut raw["keyPositions"]["4key"][0]; + position["backgroundColor"] = serde_json::json!("#BADBAD"); + position["backgroundGradient"] = serde_json::json!({ + "type": "linear", + "angle": 450, + "stops": [ + { "color": "rgba(90, 162, 247, 1)", "pos": 1.4 }, + { "color": "rgba(139, 92, 246, 1)", "pos": -0.2 } + ] + }); + position["fontColor"] = serde_json::json!("stale-font"); + position["fontGradient"] = serde_json::json!({ + "angle": -270, + "stops": [ + { "color": "#123456", "pos": 0 }, + { "color": "#ABCDEF", "pos": 1 } + ] + }); + position["counter"]["fill"]["idle"] = serde_json::json!("#FFFFFF"); + position["counter"]["fillIdleGradient"] = serde_json::json!({ + "stops": [ + { "color": "#FFFFFF", "pos": 0 }, + { "color": "#000000", "pos": 1 } + ] + }); + std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + let position = &loaded.data.key_positions["4key"][0]; + assert!(loaded.needs_persist); + assert!(loaded.repaired); + assert_eq!( + position.background_color.as_deref(), + Some("rgba(139, 92, 246, 1)") + ); + assert_eq!(position.background_gradient.as_ref().unwrap().angle, 90.0); + assert_eq!( + position.background_gradient.as_ref().unwrap().stops[0].pos, + 0.0 + ); + assert_eq!(position.font_color.as_deref(), Some("#123456")); + assert_eq!(position.font_gradient.as_ref().unwrap().angle, 90.0); + assert_eq!(position.counter.fill.idle, "rgba(255,255,255,1)"); + + std::fs::write(&path, serde_json::to_vec_pretty(&loaded.data).unwrap()).unwrap(); + let reloaded = load_store_from_path(&path).unwrap(); + assert!(!reloaded.needs_persist); + assert!(!reloaded.repaired); + assert_eq!(reloaded.data, loaded.data); + let _ = std::fs::remove_file(path); +} + +#[test] +fn blank_font_color_normalization_requests_one_canonical_persist() { + let path = std::env::temp_dir().join(format!( + "dmnote-blank-font-color-reload-{}.json", + uuid::Uuid::new_v4() + )); + let data = normalize_state(AppStoreData { + keys: default_keys().clone(), + key_positions: default_positions().clone(), + ..AppStoreData::default() + }); + std::fs::write(&path, serde_json::to_vec_pretty(&data).unwrap()).unwrap(); + let seeded = load_store_from_path(&path).unwrap(); + std::fs::write(&path, serde_json::to_vec_pretty(&seeded.data).unwrap()).unwrap(); + let canonical = load_store_from_path(&path).unwrap(); + assert!(!canonical.needs_persist); + assert!(!canonical.repaired); + + let mut raw = serde_json::to_value(&canonical.data).unwrap(); + raw["keyPositions"]["4key"][0]["fontColor"] = serde_json::json!(" \t "); + raw["keyPositions"]["4key"][0]["activeFontColor"] = serde_json::json!(""); + std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); + + let normalized = load_store_from_path(&path).unwrap(); + assert!(normalized.needs_persist); + assert!(!normalized.repaired); + assert!(normalized.data.key_positions["4key"][0] + .font_color + .is_none()); + assert!(normalized.data.key_positions["4key"][0] + .active_font_color + .is_none()); + + std::fs::write(&path, serde_json::to_vec_pretty(&normalized.data).unwrap()).unwrap(); + let reloaded = load_store_from_path(&path).unwrap(); + assert!(!reloaded.needs_persist); + assert!(!reloaded.repaired); + assert_eq!(reloaded.data, normalized.data); + let _ = std::fs::remove_file(path); +} + +#[test] +fn blank_font_gradient_stop_repairs_once_and_converges_on_reload() { + let path = std::env::temp_dir().join(format!( + "dmnote-blank-font-gradient-stop-{}.json", + uuid::Uuid::new_v4() + )); + let data = normalize_state(AppStoreData { + keys: default_keys().clone(), + key_positions: default_positions().clone(), + ..AppStoreData::default() + }); + std::fs::write(&path, serde_json::to_vec_pretty(&data).unwrap()).unwrap(); + let seeded = load_store_from_path(&path).unwrap(); + std::fs::write(&path, serde_json::to_vec_pretty(&seeded.data).unwrap()).unwrap(); + let canonical = load_store_from_path(&path).unwrap(); + assert!(!canonical.needs_persist); + assert!(!canonical.repaired); + + // 공백 첫 stop: 대표색 동기와 공백 정규화가 서로 되돌려 복구가 반복되던 입력 + let mut raw = serde_json::to_value(&canonical.data).unwrap(); + raw["keyPositions"]["4key"][0]["fontGradient"] = serde_json::json!({ + "angle": 45, + "stops": [ + { "color": " ", "pos": 0 }, + { "color": "#445566", "pos": 1 } + ] + }); + std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); + + // 첫 로드는 그라데이션을 내리고 한 번만 복구로 기록 + let repaired = load_store_from_path(&path).unwrap(); + assert!(repaired.needs_persist); + assert!(repaired.repaired); + assert!(repaired.data.key_positions["4key"][0] + .font_gradient + .is_none()); + + // 저장 후 재로드는 무변경 - repaired 반복으로 자산 sweep이 계속 막히지 않게 + std::fs::write(&path, serde_json::to_vec_pretty(&repaired.data).unwrap()).unwrap(); + let reloaded = load_store_from_path(&path).unwrap(); + assert!(!reloaded.needs_persist); + assert!(!reloaded.repaired); + assert_eq!(reloaded.data, repaired.data); + let _ = std::fs::remove_file(path); +} + +#[test] +fn knob_note_border_gradient_and_rgba_migration_converge_in_either_order() { + let mut knob = KnobPosition { + axis_id: "axis".to_string(), + sensitivity: 1.0, + reverse: false, + position: KeyPosition::default(), + }; + knob.position.note_border_color = Some("rgba(17, 34, 51, 0.5)".to_string()); + knob.position.note_border_gradient = serde_json::from_value(serde_json::json!({ + "angle": 90, + "stops": [ + { "color": "rgba(17, 34, 51, 0.5)", "pos": 0 }, + { "color": "#ABC8", "pos": 1 } + ] + })) + .unwrap(); + let mut source = AppStoreData::default(); + source + .knob_positions + .insert("custom".to_string(), vec![knob]); + + let mut canonical_first = source.clone(); + assert_eq!( + super::canonicalize_gradient_pairs(&mut canonical_first), + (true, true) + ); + let canonical_first = normalize_state(canonical_first); + + let mut migration_first = normalize_state(source); + assert_eq!( + super::canonicalize_gradient_pairs(&mut migration_first), + (false, false) + ); + + assert_eq!(canonical_first, migration_first); + assert_eq!( + migration_first.knob_positions["custom"][0] + .position + .note_border_color + .as_deref(), + Some("#112233") + ); +} + +#[test] +fn invalid_counter_gradient_children_recover_without_losing_counter_siblings() { + let path = std::env::temp_dir().join(format!( + "dmnote-counter-gradient-child-recovery-{}.json", + uuid::Uuid::new_v4() + )); + let mut key_position = default_positions()["4key"][0].clone(); + key_position.counter.enabled = false; + key_position.counter.placement = KeyCounterPlacement::Outside; + key_position.counter.align = KeyCounterAlign::Left; + key_position.counter.align_mode = KeyCounterAlignMode::Between; + key_position.counter.fill = KeyCounterColor { + idle: "#112233".to_string(), + active: "#445566".to_string(), + }; + key_position.counter.gap = 17; + key_position.counter.font_size = 33; + key_position.counter.font_weight = 600; + key_position.counter.font_family = Some("Recovery Font".to_string()); + key_position.counter.font_italic = true; + key_position.counter.font_underline = true; + key_position.counter.font_strikethrough = true; + key_position.counter.animation.enabled = true; + key_position.counter.animation.preset_id = Some("custom-recovery".to_string()); + key_position.counter.animation.bezier = [0.1, 0.2, 0.7, 0.8]; + key_position.counter.animation.scale = 1.25; + key_position.counter.animation.duration_ms = 777; + key_position.note_color = NoteColor::Gradient { + top: "legacy-top".to_string(), + bottom: "legacy-bottom".to_string(), + }; + key_position.note_opacity_top = Some(23); + key_position.note_opacity_bottom = Some(67); + let expected_key_counter = key_position.counter.clone(); + let expected_key_note_color = key_position.note_color.clone(); + + let mut stat_position = StatPosition { + stat_type: StatType::Kps, + position: key_position.clone(), + }; + stat_position.position.counter.fill.idle = "#ABCDEF".to_string(); + stat_position.position.note_glow_color = Some(NoteColor::Gradient { + top: "legacy-glow-top".to_string(), + bottom: "legacy-glow-bottom".to_string(), + }); + stat_position.position.note_glow_opacity_top = Some(34); + stat_position.position.note_glow_opacity_bottom = Some(76); + let expected_stat_counter = stat_position.position.counter.clone(); + let expected_stat_glow_color = stat_position.position.note_glow_color.clone(); + + let mut data = normalize_state(AppStoreData { + keys: default_keys().clone(), + key_positions: default_positions().clone(), + ..AppStoreData::default() + }); + data.key_positions.get_mut("4key").unwrap()[0] = key_position; + data.stat_positions + .insert("4key".to_string(), vec![stat_position]); + let mut raw = serde_json::to_value(data).unwrap(); + raw["keyPositions"]["4key"][0]["noteBorderGradient"] = serde_json::json!({ + "angle": 90, + "stops": [{ "color": "#112233", "pos": 0 }] + }); + raw["keyPositions"]["4key"][0]["noteGradient"] = serde_json::json!({ + "angle": 90, + "stops": [{ "color": "#112233", "pos": 0 }] + }); + raw["keyPositions"]["4key"][0]["counter"]["fillIdleGradient"] = serde_json::json!({ + "angle": 90, + "stops": [{ "color": "#FFFFFF", "pos": 0 }] + }); + raw["statPositions"]["4key"][0]["counter"]["fillActiveGradient"] = serde_json::json!({ + "angle": 90, + "stops": [{ "color": "#000000", "pos": 1 }] + }); + raw["statPositions"]["4key"][0]["noteGlowGradient"] = serde_json::json!({ + "angle": 90, + "stops": [{ "color": "#AABBCC", "pos": 1 }] + }); + std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + let key_counter = &loaded.data.key_positions["4key"][0].counter; + let stat_counter = &loaded.data.stat_positions["4key"][0].position.counter; + + assert!(loaded.needs_persist); + assert!(loaded.repaired); + assert_eq!(key_counter, &expected_key_counter); + assert_eq!(stat_counter, &expected_stat_counter); + assert!(loaded.data.key_positions["4key"][0].note_gradient.is_none()); + assert_eq!( + loaded.data.key_positions["4key"][0].note_color, + expected_key_note_color + ); + assert_eq!( + loaded.data.key_positions["4key"][0].note_opacity_top, + Some(23) + ); + assert_eq!( + loaded.data.key_positions["4key"][0].note_opacity_bottom, + Some(67) + ); + assert!(loaded.data.stat_positions["4key"][0] + .position + .note_glow_gradient + .is_none()); + assert_eq!( + loaded.data.stat_positions["4key"][0] + .position + .note_glow_color, + expected_stat_glow_color + ); + assert!(loaded.data.key_positions["4key"][0] + .note_border_gradient + .is_none()); + assert!(key_counter.fill_idle_gradient.is_none()); + assert!(stat_counter.fill_active_gradient.is_none()); + let _ = std::fs::remove_file(path); +} + +#[test] +fn invalid_font_gradient_fields_recover_in_place_on_every_position_collection() { + let path = std::env::temp_dir().join(format!( + "dmnote-font-gradient-field-recovery-{}.json", + uuid::Uuid::new_v4() + )); + let mut data = normalize_state(AppStoreData { + keys: default_keys().clone(), + key_positions: default_positions().clone(), + ..AppStoreData::default() + }); + let mut template = data.key_positions["4key"][0].clone(); + template.font_color = Some("font-sibling".to_string()); + template.active_font_color = Some("active-font-sibling".to_string()); + let key_id = uuid::Uuid::new_v4().to_string(); + let key = &mut data.key_positions.get_mut("4key").unwrap()[0]; + key.id = key_id.clone(); + key.font_color = Some("font-sibling".to_string()); + + let stat_id = uuid::Uuid::new_v4().to_string(); + let graph_id = uuid::Uuid::new_v4().to_string(); + let knob_id = uuid::Uuid::new_v4().to_string(); + data.stat_positions.insert( + "4key".to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: KeyPosition { + id: stat_id.clone(), + ..template.clone() + }, + }], + ); + data.graph_positions.insert( + "4key".to_string(), + vec![GraphPosition { + stat_type: GraphStatType::Kps, + graph_type: GraphType::Line, + graph_speed: 1000, + graph_color: "graph-sibling".to_string(), + show_avg_line: true, + position: KeyPosition { + id: graph_id.clone(), + ..template.clone() + }, + }], + ); + data.knob_positions.insert( + "4key".to_string(), + vec![KnobPosition { + axis_id: "axis-sibling".to_string(), + sensitivity: 1.0, + reverse: false, + position: KeyPosition { + id: knob_id.clone(), + ..template + }, + }], + ); + + let mut raw = serde_json::to_value(&data).unwrap(); + let damaged = serde_json::json!({ + "angle": 90, + "stops": [{ "color": "#112233", "pos": 0 }] + }); + raw["keyPositions"]["4key"][0]["fontGradient"] = damaged.clone(); + raw["statPositions"]["4key"][0]["activeFontGradient"] = damaged.clone(); + raw["graphPositions"]["4key"][0]["fontGradient"] = damaged.clone(); + raw["knobPositions"]["4key"][0]["activeFontGradient"] = damaged; + + raw["keyPositions"]["4key"][0]["fontWeight"] = serde_json::json!(400); + raw["keyPositions"]["4key"][0] + .as_object_mut() + .unwrap() + .remove("fontBold"); + raw["statPositions"]["4key"][0] + .as_object_mut() + .unwrap() + .remove("fontWeight"); + raw["statPositions"]["4key"][0] + .as_object_mut() + .unwrap() + .remove("fontBold"); + raw["graphPositions"]["4key"][0]["fontWeight"] = serde_json::json!(700); + raw["graphPositions"]["4key"][0] + .as_object_mut() + .unwrap() + .remove("fontBold"); + raw["knobPositions"]["4key"][0]["fontWeight"] = serde_json::json!(600); + raw["knobPositions"]["4key"][0] + .as_object_mut() + .unwrap() + .remove("fontBold"); + std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + assert!(loaded.needs_persist); + assert!(loaded.repaired); + assert_eq!( + loaded.data.key_positions["4key"].len(), + data.key_positions["4key"].len() + ); + assert_eq!(loaded.data.stat_positions["4key"].len(), 1); + assert_eq!(loaded.data.graph_positions["4key"].len(), 1); + assert_eq!(loaded.data.knob_positions["4key"].len(), 1); + assert_eq!(loaded.data.key_positions["4key"][0].id, key_id); + assert_eq!(loaded.data.stat_positions["4key"][0].position.id, stat_id); + assert_eq!(loaded.data.graph_positions["4key"][0].position.id, graph_id); + assert_eq!(loaded.data.knob_positions["4key"][0].position.id, knob_id); + assert!(loaded.data.key_positions["4key"][0].font_gradient.is_none()); + assert!(loaded.data.stat_positions["4key"][0] + .position + .active_font_gradient + .is_none()); + assert!(loaded.data.graph_positions["4key"][0] + .position + .font_gradient + .is_none()); + assert!(loaded.data.knob_positions["4key"][0] + .position + .active_font_gradient + .is_none()); + for position in [ + &loaded.data.key_positions["4key"][0], + &loaded.data.stat_positions["4key"][0].position, + &loaded.data.graph_positions["4key"][0].position, + &loaded.data.knob_positions["4key"][0].position, + ] { + assert_eq!(position.font_color.as_deref(), Some("font-sibling")); + } + let key = &loaded.data.key_positions["4key"][0]; + assert_eq!(key.font_weight, Some(400)); + assert_eq!(key.font_bold, None); + let stat = &loaded.data.stat_positions["4key"][0].position; + assert_eq!(stat.font_weight, None); + assert_eq!(stat.font_bold, None); + let graph = &loaded.data.graph_positions["4key"][0].position; + assert_eq!(graph.font_weight, Some(400)); + assert_eq!(graph.font_bold, Some(true)); + let knob = &loaded.data.knob_positions["4key"][0].position; + assert_eq!(knob.font_weight, Some(600)); + assert_eq!(knob.font_bold, None); + let _ = std::fs::remove_file(path); +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct HistoricalTauri13Store { + #[serde(default)] + keys: std::collections::HashMap>, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct HistoricalTauri14PlusStore { + #[serde(default)] + keys: std::collections::HashMap>, + #[serde(default, flatten)] + plugin_data: std::collections::HashMap, +} + +#[test] +fn tauri_era_store_schema_transitions_preserve_editor_data() { + let v1_3 = tauri_store_fixture_base(); + + let mut v1_4 = tauri_store_fixture_base(); + v1_4.as_object_mut().unwrap().extend([ + ("developerModeEnabled".to_string(), serde_json::json!(true)), + ("tabCssOverrides".to_string(), serde_json::json!({})), + ("useCustomJs".to_string(), serde_json::json!(true)), + ( + "customJs".to_string(), + serde_json::json!({ "path": null, "content": "void 0" }), + ), + ( + "plugin:fixture".to_string(), + serde_json::json!({ "kept": true }), + ), + ]); + + let mut v1_5 = v1_4.clone(); + v1_5.as_object_mut().unwrap().extend([ + ( + "statPositions".to_string(), + serde_json::json!({ + "fixture-tab": [{ + "statType": "kps", + "dx": 80.0, + "dy": 14.0, + "width": 80.0, + "height": 40.0, + "activeImage": "", + "inactiveImage": "", + "count": 0, + "noteColor": "#FFFFFF", + "noteOpacity": 80 + }] + }), + ), + ( + "fontSettings".to_string(), + serde_json::json!({ "customFonts": [] }), + ), + ("gridSettings".to_string(), serde_json::json!({})), + ("shortcuts".to_string(), serde_json::json!({})), + ]); + + let mut v1_6 = v1_5.clone(); + v1_6["keyPositions"]["fixture-tab"][0]["groupId"] = serde_json::json!("fixture-group"); + v1_6.as_object_mut().unwrap().extend([ + ( + "graphPositions".to_string(), + serde_json::json!({ + "fixture-tab": [{ + "statType": "kpsAvg", + "graphType": "line", + "graphSpeed": 1000, + "graphColor": "#24BBB4", + "showAvgLine": true, + "dx": 170.0, + "dy": 14.0, + "width": 160.0, + "height": 80.0, + "activeImage": "", + "inactiveImage": "", + "count": 0, + "noteColor": "#FFFFFF", + "noteOpacity": 80 + }] + }), + ), + ( + "layerGroups".to_string(), + serde_json::json!({ + "fixture-tab": [{ "id": "fixture-group", "name": "Fixture group" }] + }), + ), + ("counterAnimationPresets".to_string(), serde_json::json!([])), + ("tabNoteOverrides".to_string(), serde_json::json!({})), + ("soundLibrary".to_string(), serde_json::json!({})), + ("obsModeEnabled".to_string(), serde_json::json!(false)), + ("obsPort".to_string(), serde_json::json!(34891)), + ("obsToken".to_string(), serde_json::Value::Null), + ]); + + let mut v1_6_1 = v1_6.clone(); + v1_6_1.as_object_mut().unwrap().extend([ + ( + "knobPositions".to_string(), + serde_json::json!({ + "fixture-tab": [{ + "axisId": "axis-x", + "sensitivity": 1.0, + "reverse": false, + "dx": 340.0, + "dy": 14.0, + "width": 80.0, + "height": 80.0, + "activeImage": "", + "inactiveImage": "", + "count": 0, + "noteColor": "#FFFFFF", + "noteOpacity": 80 + }] + }), + ), + ("keySoundOutputBackend".to_string(), serde_json::Value::Null), + ]); + + for (version, fixture) in [ + ("1.3.0", v1_3), + ("1.4.0", v1_4), + ("1.5.1", v1_5), + ("1.6.0", v1_6), + ("1.6.1", v1_6_1), + ] { + let loaded = load_literal_fixture(version, &fixture); + assert_eq!(loaded.editor_revision, 0, "{version}"); + assert_eq!( + loaded.keys["fixture-tab"], + vec![KeySlot::from("F13")], + "{version}" + ); + assert_eq!(loaded.key_positions["fixture-tab"][0].dx, 13.0, "{version}"); + assert_eq!(loaded.key_counters["fixture-tab"]["F13"], 17, "{version}"); + + if version >= "1.4.0" { + assert_eq!(loaded.plugin_data["plugin:fixture"]["kept"], true); + } + if version >= "1.5.1" { + assert_eq!(loaded.stat_positions["fixture-tab"].len(), 1); + } + if version >= "1.6.0" { + assert_eq!(loaded.graph_positions["fixture-tab"].len(), 1); + assert_eq!(loaded.layer_groups["fixture-tab"][0].id, "fixture-group"); + } + if version >= "1.6.1" { + assert_eq!(loaded.knob_positions["fixture-tab"].len(), 1); + } + } +} + +#[test] +fn reverse_downgrade_models_document_unknown_revision_behavior() { + let fixture = serde_json::json!({ + "keys": { "4key": ["Q"] }, + "editorRevision": 23 + }); + + let v1_3: HistoricalTauri13Store = serde_json::from_value(fixture.clone()).unwrap(); + let v1_3_resaved = serde_json::to_value(v1_3).unwrap(); + assert!(v1_3_resaved.get("editorRevision").is_none()); + + let mut v1_4_plus: HistoricalTauri14PlusStore = serde_json::from_value(fixture).unwrap(); + assert_eq!(v1_4_plus.plugin_data["editorRevision"], 23); + let preserved = serde_json::to_value(&v1_4_plus).unwrap(); + assert_eq!(preserved["editorRevision"], 23); + + v1_4_plus.plugin_data.clear(); + let cleared = serde_json::to_value(v1_4_plus).unwrap(); + assert!(cleared.get("editorRevision").is_none()); +} + +// master(구버전) recover_key_mapping_entries의 동결 사본: 문자열이 아닌 +// 항목을 제자리 빈 문자열로 대체 (구버전 복구 동작, 2026-08 기준) +fn frozen_legacy_recover_keys(entries: &serde_json::Value) -> Vec { + entries + .as_array() + .expect("keys mode must be an array") + .iter() + .map(|entry| entry.as_str().unwrap_or("").to_string()) + .collect() +} + +#[test] +fn downgrade_recovery_replaces_multi_slots_in_place_without_compaction() { + // 신버전이 직렬화한 keys 와이어 형식이 구버전 복구에서 어떻게 열화되는지 고정 + let slots = vec![ + KeySlot::from("Q"), + KeySlot::Multi { + keys: vec!["A".to_string(), "B".to_string()], + match_mode: SlotMatch::Any, + }, + KeySlot::from("C"), + KeySlot::Multi { + keys: vec!["LEFT CTRL".to_string(), "Z".to_string()], + match_mode: SlotMatch::All, + }, + KeySlot::default(), + ]; + let wire = serde_json::to_value(&slots).unwrap(); + + let recovered = frozen_legacy_recover_keys(&wire); + + // 배열 길이 보존(keyPositions 인덱스 결합 불변식), Multi만 제자리 "" 대체 + assert_eq!( + recovered, + vec![ + "Q".to_string(), + String::new(), + "C".to_string(), + String::new(), + String::new(), + ] + ); +} + +#[test] +fn reverse_downgrade_multi_key_fixture_enters_legacy_recovery_path() { + let fixture = serde_json::json!({ + "keys": { + "4key": [ + { "keys": ["A", "B"], "match": "any" }, + "C" + ] + } + }); + + assert!(serde_json::from_value::(fixture.clone()).is_err()); + assert!(serde_json::from_value::(fixture).is_err()); +} + +#[test] +fn key_mapping_recovery_normalizes_in_place_without_compacting_slots() { + let raw = serde_json::json!({ + "mode": [ + { "keys": ["A", "B"], "match": "any" }, + { "keys": ["Z"], "match": "all" }, + { "keys": ["A", 7, "A", "B+C", "C"], "match": "all" }, + { "keys": ["A", "B"] }, + null + ] + }); + + let recovered = recover_key_mapping_entries(&raw).unwrap(); + let mappings: KeyMappings = serde_json::from_value(recovered).unwrap(); + + assert_eq!(mappings["mode"].len(), 5); + assert_eq!( + mappings["mode"], + vec![ + KeySlot::Multi { + keys: vec!["A".to_string(), "B".to_string()], + match_mode: SlotMatch::Any, + }, + KeySlot::Single("Z".to_string()), + KeySlot::Multi { + keys: vec!["A".to_string(), "C".to_string()], + match_mode: SlotMatch::All, + }, + KeySlot::default(), + KeySlot::default(), + ] + ); +} + +#[test] +fn normalize_state_drops_blank_font_colors_on_every_position_collection() { + let blank_position = |id: &str| KeyPosition { + id: id.to_string(), + font_color: Some(" \t ".to_string()), + active_font_color: Some(String::new()), + ..KeyPosition::default() + }; + let data = normalize_state(AppStoreData { + key_positions: crate::models::KeyPositions::from([( + "custom".to_string(), + vec![blank_position("key-id")], + )]), + stat_positions: crate::models::StatPositions::from([( + "custom".to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: blank_position("stat-id"), + }], + )]), + graph_positions: crate::models::GraphPositions::from([( + "custom".to_string(), + vec![GraphPosition { + stat_type: GraphStatType::Kps, + graph_type: GraphType::Line, + graph_speed: 1000, + graph_color: "graph".to_string(), + show_avg_line: false, + position: blank_position("graph-id"), + }], + )]), + knob_positions: crate::models::KnobPositions::from([( + "custom".to_string(), + vec![KnobPosition { + axis_id: "axis".to_string(), + sensitivity: 1.0, + reverse: false, + position: blank_position("knob-id"), + }], + )]), + ..AppStoreData::default() + }); + + for position in [ + &data.key_positions["custom"][0], + &data.stat_positions["custom"][0].position, + &data.graph_positions["custom"][0].position, + &data.knob_positions["custom"][0].position, + ] { + assert!(position.font_color.is_none()); + assert!(position.active_font_color.is_none()); + } +} + +#[test] +fn normalize_state_folds_replace_image_mode_to_sparse_none() { + let data = normalize_state(AppStoreData { + key_positions: crate::models::KeyPositions::from([( + "custom".to_string(), + vec![ + KeyPosition { + id: "replace".to_string(), + image_mode: Some(crate::models::ImageMode::Replace), + ..KeyPosition::default() + }, + KeyPosition { + id: "overlay".to_string(), + image_mode: Some(crate::models::ImageMode::Overlay), + ..KeyPosition::default() + }, + ], + )]), + ..AppStoreData::default() + }); + + assert_eq!(data.key_positions["custom"][0].image_mode, None); + assert_eq!( + data.key_positions["custom"][1].image_mode, + Some(crate::models::ImageMode::Overlay) + ); +} + +#[test] +fn normalize_state_keeps_single_count_and_separates_any_all_counters() { + let keys = vec![ + KeySlot::Single("A".to_string()), + KeySlot::Multi { + keys: vec!["A".to_string(), "B".to_string()], + match_mode: SlotMatch::Any, + }, + KeySlot::Multi { + keys: vec!["A".to_string(), "B".to_string()], + match_mode: SlotMatch::All, + }, + ]; + let data = normalize_state(AppStoreData { + keys: KeyMappings::from([("4key".to_string(), keys)]), + key_positions: crate::models::KeyPositions::from([( + "4key".to_string(), + vec![KeyPosition::default(); 3], + )]), + key_counters: crate::models::KeyCounters::from([( + "4key".to_string(), + std::collections::HashMap::from([ + ("A".to_string(), 9), + ("A|B".to_string(), 4), + ("stale".to_string(), 7), + ]), + )]), + ..AppStoreData::default() + }); + + assert_eq!(data.key_counters["4key"]["A"], 9); + assert_eq!(data.key_counters["4key"]["A|B"], 4); + assert_eq!(data.key_counters["4key"]["A+B"], 0); + assert!(!data.key_counters["4key"].contains_key("stale")); +} + +#[test] +fn load_repairs_unsafe_editor_revision_without_touching_editor_data() { + let path = std::env::temp_dir().join(format!( + "dmnote-unsafe-editor-revision-{}.json", + uuid::Uuid::new_v4() + )); + let mut data = normalize_state(AppStoreData { + keys: default_keys().clone(), + key_positions: default_positions().clone(), + ..AppStoreData::default() + }); + data.editor_revision = crate::state::editor::MAX_SAFE_WIRE_REVISION + 1; + data.key_positions.get_mut("4key").unwrap()[0].dx = 12_345.0; + crate::state::native_element_id::backfill_store_element_ids(&mut data); + let expected_keys = data.keys.clone(); + let expected_positions = data.key_positions.clone(); + std::fs::write(&path, serde_json::to_vec_pretty(&data).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + + assert_eq!(loaded.data.editor_revision, 0); + assert_eq!(loaded.data.keys, expected_keys); + assert_eq!(loaded.data.key_positions, expected_positions); + assert!(loaded.needs_persist); + assert!(loaded.repaired); + let _ = std::fs::remove_file(path); +} + +#[test] +fn load_preserves_max_safe_editor_revision() { + let path = std::env::temp_dir().join(format!( + "dmnote-max-safe-editor-revision-{}.json", + uuid::Uuid::new_v4() + )); + let mut data = normalize_state(AppStoreData { + keys: default_keys().clone(), + key_positions: default_positions().clone(), + ..AppStoreData::default() + }); + data.editor_revision = crate::state::editor::MAX_SAFE_WIRE_REVISION; + std::fs::write(&path, serde_json::to_vec_pretty(&data).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + + assert_eq!( + loaded.data.editor_revision, + crate::state::editor::MAX_SAFE_WIRE_REVISION + ); + assert!(!loaded.repaired); + let _ = std::fs::remove_file(path); +} + +#[test] +fn clear_dangling_group_ids_removes_ghosts_and_keeps_valid_ones() { + let mut data = AppStoreData::default(); + let mut positions = default_positions().clone(); + if let Some(list) = positions.get_mut("4key") { + list[0].group_id = Some("ghost-group".to_string()); + list[1].group_id = Some("real-group".to_string()); + } + data.key_positions = positions; + data.layer_groups.insert( + "4key".to_string(), + vec![LayerGroupDef { + id: "real-group".to_string(), + name: "Group".to_string(), + }], + ); + + assert!(super::clear_dangling_group_ids(&mut data)); + let list = &data.key_positions["4key"]; + assert_eq!(list[0].group_id, None); + assert_eq!(list[1].group_id.as_deref(), Some("real-group")); + + // 정리할 것이 없으면 변경 없음을 보고 (로드 시 불필요한 재저장 방지) + assert!(!super::clear_dangling_group_ids(&mut data)); +} + +// 부분 저장(update_*)은 positions 먼저 → layerGroups 나중 순서로 들어오므로 +// normalize_state가 중간 상태의 신규 groupId를 지워선 안 됨 +#[test] +fn normalize_state_preserves_group_ids_saved_before_group_definitions() { + let mut data = AppStoreData::default(); + let mut positions = default_positions().clone(); + if let Some(list) = positions.get_mut("4key") { + list[0].group_id = Some("group-created-just-now".to_string()); + } + data.key_positions = positions; + // layerGroups 정의는 아직 저장 전 (다음 커맨드에서 도착) + + let normalized = normalize_state(data); + assert_eq!( + normalized.key_positions["4key"][0].group_id.as_deref(), + Some("group-created-just-now") + ); +} + +#[test] +fn load_clears_dangling_group_ids_from_disk() { + let dir = std::env::temp_dir().join(format!("dmnote-dangling-load-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("store.json"); + + let mut data = AppStoreData::default(); + let mut positions = default_positions().clone(); + if let Some(list) = positions.get_mut("4key") { + list[0].group_id = Some("ghost-group".to_string()); + } + data.key_positions = positions; + std::fs::write(&path, serde_json::to_vec_pretty(&data).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + assert_eq!(loaded.data.key_positions["4key"][0].group_id, None); + // 정리 결과가 디스크에도 영속되도록 재저장 필요 플래그가 올라가야 함 + assert!(loaded.needs_persist); + + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn load_pads_every_key_position_length_mismatch_without_compaction() { + let path = std::env::temp_dir().join(format!( + "dmnote-key-position-length-load-{}.json", + uuid::Uuid::new_v4() + )); + let mut data = AppStoreData { + keys: default_keys().clone(), + key_positions: default_positions().clone(), + ..AppStoreData::default() + }; + data.keys.get_mut("4key").unwrap().push("F5".into()); + let preserved_position = KeyPosition { + dx: 987.0, + ..KeyPosition::default() + }; + data.key_positions + .get_mut("5key") + .unwrap() + .push(preserved_position.clone()); + data.keys + .insert("keys-only".to_string(), vec!["A".into(), "B".into()]); + data.key_positions.insert( + "positions-only".to_string(), + vec![preserved_position.clone()], + ); + crate::state::native_element_id::backfill_store_element_ids(&mut data); + let preserved_position = data.key_positions["5key"].last().unwrap().clone(); + std::fs::write(&path, serde_json::to_vec_pretty(&data).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + assert!(!loaded.repaired); + assert!(loaded.needs_persist); + assert_eq!( + loaded.data.keys["4key"].last().unwrap(), + &KeySlot::from("F5") + ); + let padded_position = loaded.data.key_positions["4key"].last().unwrap(); + assert!(crate::state::native_element_id::is_valid_element_id( + &padded_position.id + )); + let mut padded_without_id = padded_position.clone(); + padded_without_id.id.clear(); + assert_eq!(padded_without_id, KeyPosition::default()); + assert_eq!( + loaded.data.key_positions["5key"].last().unwrap(), + &preserved_position + ); + assert!(loaded.data.keys["5key"].last().unwrap().is_unassigned()); + assert_eq!(loaded.data.key_positions["keys-only"].len(), 2); + assert_eq!(loaded.data.keys["positions-only"], vec![KeySlot::default()]); + + let modes = loaded + .data + .keys + .keys() + .chain(loaded.data.key_positions.keys()) + .collect::>(); + for mode in modes { + assert_eq!( + loaded.data.keys.get(mode).map_or(0, Vec::len), + loaded.data.key_positions.get(mode).map_or(0, Vec::len), + "mode {mode}" + ); + } + + let _ = std::fs::remove_file(path); +} + +#[test] +fn normal_load_preserves_unbound_knobs_while_repairing_invalid_fonts() { + let path = std::env::temp_dir().join(format!( + "dmnote-semantic-identity-load-{}.json", + uuid::Uuid::new_v4() + )); + let mut data = AppStoreData::default(); + data.font_settings.custom_fonts = vec![ + CustomFont { + id: " ".to_string(), + font_type: FontType::Web, + name: "Invalid Font".to_string(), + display_name: "Invalid Font".to_string(), + enabled: true, + local_path: None, + css_content: Some("@font-face {}".to_string()), + weight_ranges: Vec::new(), + }, + CustomFont { + id: "local-font".to_string(), + font_type: FontType::Local, + name: "Local Font".to_string(), + display_name: "Local Font".to_string(), + enabled: true, + local_path: Some("relative/font.ttf".to_string()), + css_content: None, + weight_ranges: Vec::new(), + }, + ]; + data.knob_positions.insert( + "4key".to_string(), + vec![ + KnobPosition { + axis_id: String::new(), + sensitivity: 1.0, + reverse: false, + position: default_positions()["4key"][0].clone(), + }, + KnobPosition { + axis_id: "axis-valid".to_string(), + sensitivity: 1.0, + reverse: false, + position: default_positions()["4key"][1].clone(), + }, + ], + ); + std::fs::write(&path, serde_json::to_vec_pretty(&data).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + + assert!(loaded.repaired); + assert!(loaded.needs_persist); + assert_eq!(loaded.data.font_settings.custom_fonts.len(), 1); + let local = &loaded.data.font_settings.custom_fonts[0]; + assert_eq!(local.id, "local-font"); + assert!(!local.enabled); + assert_eq!(local.local_path, None); + assert_eq!(loaded.data.knob_positions["4key"].len(), 2); + assert!(loaded.data.knob_positions["4key"][0].axis_id.is_empty()); + assert_eq!(loaded.data.knob_positions["4key"][1].axis_id, "axis-valid"); + + let _ = std::fs::remove_file(path); +} + +#[test] +fn unbound_knob_survives_load_normalize_and_resave() { + let path = std::env::temp_dir().join(format!( + "dmnote-unbound-knob-roundtrip-{}.json", + uuid::Uuid::new_v4() + )); + let mut data = AppStoreData { + keys: default_keys().clone(), + key_positions: default_positions().clone(), + ..AppStoreData::default() + }; + data.knob_positions.insert( + "4key".to_string(), + vec![KnobPosition { + axis_id: String::new(), + sensitivity: 1.0, + reverse: false, + position: default_positions()["4key"][0].clone(), + }], + ); + std::fs::write(&path, serde_json::to_vec_pretty(&data).unwrap()).unwrap(); + + let first = load_store_from_path(&path).unwrap(); + assert!(!first.repaired); + assert!(first.data.knob_positions["4key"][0].axis_id.is_empty()); + + let normalized = normalize_state(first.data); + std::fs::write(&path, serde_json::to_vec_pretty(&normalized).unwrap()).unwrap(); + let second = load_store_from_path(&path).unwrap(); + assert!(!second.repaired); + assert_eq!(second.data.knob_positions["4key"].len(), 1); + assert!(second.data.knob_positions["4key"][0].axis_id.is_empty()); + + let _ = std::fs::remove_file(path); +} +use serde_json::{json, Value}; + +const TEST_SOUND_PATH: &str = "/tmp/test-sound.wav"; + +fn load_store_with_sound_entry(entry: Value) -> (AppStoreData, bool) { + let path = std::env::temp_dir().join(format!( + "dmnote-sound-migration-test-{}.json", + uuid::Uuid::new_v4() + )); + let mut data = normalize_state(AppStoreData::default()); + crate::state::native_element_id::backfill_store_element_ids(&mut data); + let mut value = serde_json::to_value(data).unwrap(); + value.as_object_mut().unwrap().insert( + "soundLibrary".to_string(), + json!({ TEST_SOUND_PATH: entry }), + ); + std::fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + let _ = std::fs::remove_file(path); + (loaded.data, loaded.needs_persist) +} + +#[test] +fn sound_library_enabled_false_migrates_to_hidden_true() { + let mut value = json!({ + "soundLibrary": { + TEST_SOUND_PATH: { "enabled": false } + } + }); + assert!(migrate_sound_library_enabled(&mut value)); + let entry = &value["soundLibrary"][TEST_SOUND_PATH]; + assert_eq!(entry["hidden"], true); + assert!(entry.get("enabled").is_none()); + + let (data, needs_persist) = load_store_with_sound_entry(json!({ "enabled": false })); + assert!(data.sound_library[TEST_SOUND_PATH].hidden); + assert!(needs_persist); +} + +#[test] +fn sound_library_enabled_true_migrates_to_hidden_false() { + let (data, needs_persist) = load_store_with_sound_entry(json!({ "enabled": true })); + assert!(!data.sound_library[TEST_SOUND_PATH].hidden); + assert!(needs_persist); +} + +#[test] +fn sound_library_without_enabled_is_unchanged() { + let mut value = json!({ + "soundLibrary": { + TEST_SOUND_PATH: { "source": "local" } + } + }); + let original = value.clone(); + assert!(!migrate_sound_library_enabled(&mut value)); + assert_eq!(value, original); + + let (data, needs_persist) = load_store_with_sound_entry(json!({ "source": "local" })); + assert!(!data.sound_library[TEST_SOUND_PATH].hidden); + assert!(!needs_persist); +} + +#[test] +fn sound_library_hidden_takes_precedence_over_enabled() { + let mut value = json!({ + "soundLibrary": { + TEST_SOUND_PATH: { "hidden": false, "enabled": false } + } + }); + assert!(migrate_sound_library_enabled(&mut value)); + let entry = &value["soundLibrary"][TEST_SOUND_PATH]; + assert_eq!(entry["hidden"], false); + assert!(entry.get("enabled").is_none()); + + let (data, needs_persist) = + load_store_with_sound_entry(json!({ "hidden": false, "enabled": false })); + assert!(!data.sound_library[TEST_SOUND_PATH].hidden); + assert!(needs_persist); +} + +#[test] +fn rgba_to_hex_converts_and_drops_alpha() { + assert_eq!( + rgba_to_hex("rgba(255, 0, 167, 1)").as_deref(), + Some("#FF00A7") + ); + assert_eq!( + rgba_to_hex("rgba(18, 52, 86, 0)").as_deref(), + Some("#123456") + ); +} + +#[test] +fn rgba_to_hex_ignores_non_rgba() { + assert_eq!(rgba_to_hex("#FF00A7"), None); + assert_eq!(rgba_to_hex("garbage"), None); + assert_eq!(rgba_to_hex("rgba(300, 0, 0, 1)"), None); // u8 범위 초과 +} + +#[test] +fn invalid_field_recovery_preserves_every_other_store_field() { + let path = std::env::temp_dir().join(format!( + "dmnote-field-recovery-test-{}.json", + uuid::Uuid::new_v4() + )); + let mut expected = AppStoreData { + hardware_acceleration: false, + always_on_top: false, + overlay_bounds: Some(OverlayBounds { + x: 11.0, + y: 22.0, + width: 933.0, + height: 411.0, + }), + obs_mode_enabled: true, + obs_port: 18_321, + obs_token: Some("obs-token-sentinel".to_string()), + ..AppStoreData::default() + }; + expected.layer_groups.insert( + "4key".to_string(), + vec![LayerGroupDef { + id: "layer-group-sentinel".to_string(), + name: "Layer Group".to_string(), + }], + ); + expected.plugin_data.insert( + "pluginData".to_string(), + json!({ "sentinel": "plugin-data" }), + ); + expected.plugin_data.insert( + "obsSettings".to_string(), + json!({ "sentinel": "obs-settings" }), + ); + expected.grid_settings.grid_snap_size = 9; + expected.knob_positions.insert( + "4key".to_string(), + vec![KnobPosition { + axis_id: "knob-axis-sentinel".to_string(), + sensitivity: 2.5, + reverse: true, + position: default_positions()["4key"][0].clone(), + }], + ); + expected.font_settings.custom_fonts.push(CustomFont { + id: "custom-font-sentinel".to_string(), + font_type: FontType::Web, + name: "Custom Font".to_string(), + display_name: "Custom Font".to_string(), + enabled: true, + local_path: None, + css_content: Some("@font-face { font-family: Custom; }".to_string()), + weight_ranges: Vec::new(), + }); + expected.tab_css_overrides.insert( + "custom-tab".to_string(), + TabCss { + path: Some("/tmp/custom-tab.css".to_string()), + content: ".sentinel { color: red; }".to_string(), + enabled: true, + }, + ); + expected.tab_note_overrides.insert( + "custom-tab".to_string(), + TabNoteSettings { + speed: Some(987), + ..TabNoteSettings::default() + }, + ); + expected.shortcuts.toggle_always_on_top.key = "F12".to_string(); + expected.sound_library.insert( + "/tmp/sound-sentinel.wav".to_string(), + SoundLibraryEntry { + display_name: Some("Sound Sentinel".to_string()), + ..SoundLibraryEntry::default() + }, + ); + + let mut fixture = serde_json::to_value(&expected).unwrap(); + assert!(serde_json::from_value::(fixture.clone()).is_ok()); + fixture.as_object_mut().unwrap().insert( + "alwaysOnTop".to_string(), + Value::String("invalid".to_string()), + ); + std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + let _ = std::fs::remove_file(path); + + expected.always_on_top = AppStoreData::default().always_on_top; + expected = normalize_state(expected); + assert!(loaded.repaired); + assert!(loaded.needs_persist); + let mut actual_value = serde_json::to_value(&loaded.data).unwrap(); + let mut expected_value = serde_json::to_value(&expected).unwrap(); + remove_all_native_ids(&mut actual_value); + remove_all_native_ids(&mut expected_value); + assert_eq!(actual_value, expected_value); +} + +#[test] +fn electron_1_2_store_preserves_tabs_keys_settings_and_window_position() { + let path = std::env::temp_dir().join(format!( + "dmnote-electron-1-2-store-test-{}.json", + uuid::Uuid::new_v4() + )); + let fixture = json!({ + "hardwareAcceleration": false, + "alwaysOnTop": false, + "overlayLocked": true, + "noteEffect": true, + "noteSettings": { + "borderRadius": 7, + "speed": 321, + "trackHeight": 123, + "reverse": true, + "fadePosition": "bottom", + "delayedNoteEnabled": true, + "shortNoteThresholdMs": 67, + "shortNoteMinLengthPx": 43 + }, + "selectedKeyType": "legacy-tab", + "customTabs": [{ "id": "legacy-tab", "name": "Legacy tab" }], + "angleMode": "d3d11", + "language": "en", + "laboratoryEnabled": true, + "keys": { "legacy-tab": ["A", "B"] }, + "keyPositions": { + "legacy-tab": [ + { + "dx": 777, + "dy": 88, + "width": 60, + "height": 60, + "activeImage": "", + "inactiveImage": "", + "count": 42, + "noteColor": "#ABCDEF", + "noteOpacity": 73 + }, + { + "dx": 888, + "dy": 99, + "width": 75, + "height": 65, + "activeImage": "", + "inactiveImage": "", + "count": 24, + "noteColor": "#FEDCBA", + "noteOpacity": 64 + } + ] + }, + "backgroundColor": "#123456", + "useCustomCSS": true, + "customCSS": { "path": "/tmp/legacy.css", "content": ".legacy {}" }, + "overlayWindowPosition": { "x": 17, "y": 29 } + }); + std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + let _ = std::fs::remove_file(path); + + assert!(loaded.repaired); + assert_eq!(loaded.data.selected_key_type, "legacy-tab"); + assert_eq!( + loaded.data.custom_tabs, + vec![CustomTab { + id: "legacy-tab".to_string(), + name: "Legacy tab".to_string(), + }] + ); + assert_eq!( + loaded.data.keys["legacy-tab"], + vec![KeySlot::from("A"), KeySlot::from("B")] + ); + assert_eq!(loaded.data.key_positions["legacy-tab"].len(), 2); + assert_eq!(loaded.data.key_positions["legacy-tab"][0].dx, 777.0); + assert_eq!(loaded.data.key_positions["legacy-tab"][1].dx, 888.0); + assert_eq!(loaded.data.note_settings.speed, 321); + assert_eq!(loaded.data.note_settings.track_height, 123); + assert!(loaded.data.note_settings.reverse); + assert!(loaded.data.use_custom_css); + assert_eq!( + loaded.data.custom_css.path.as_deref(), + Some("/tmp/legacy.css") + ); + assert_eq!(loaded.data.background_color, "#123456"); + assert_eq!( + loaded.data.overlay_bounds, + Some(OverlayBounds { + x: 17.0, + y: 29.0, + width: LEGACY_OVERLAY_WIDTH, + height: LEGACY_OVERLAY_HEIGHT, + }) + ); +} + +#[test] +fn tauri_1_3_store_preserves_custom_layout_without_repair_fallback() { + let path = std::env::temp_dir().join(format!( + "dmnote-tauri-1-3-store-test-{}.json", + uuid::Uuid::new_v4() + )); + let fixture = json!({ + "hardwareAcceleration": false, + "alwaysOnTop": false, + "overlayLocked": true, + "noteEffect": true, + "noteSettings": { + "borderRadius": 9, + "speed": 456, + "trackHeight": 222, + "reverse": true, + "fadePosition": "bottom", + "delayedNoteEnabled": true, + "shortNoteThresholdMs": 73, + "shortNoteMinLengthPx": 41 + }, + "selectedKeyType": "tauri-legacy-tab", + "customTabs": [{ "id": "tauri-legacy-tab", "name": "Tauri legacy" }], + "angleMode": "metal", + "language": "ko", + "laboratoryEnabled": true, + "keys": { "tauri-legacy-tab": ["Q"] }, + "keyPositions": { + "tauri-legacy-tab": [{ + "dx": 654, + "dy": 87, + "width": 91, + "height": 63, + "activeImage": "/tmp/legacy-active.png", + "inactiveImage": "/tmp/legacy-idle.png", + "count": 19, + "noteColor": "#13579B", + "noteOpacity": 76 + }] + }, + "keyCounters": { "tauri-legacy-tab": { "Q": 19 } }, + "backgroundColor": "#2468AC", + "useCustomCss": true, + "customCss": { "path": "/tmp/tauri-legacy.css", "content": ".tauri {}" }, + "overlayResizeAnchor": "top-left", + "overlayBounds": { "x": 31, "y": 47, "width": 911, "height": 333 }, + "overlayLastContentTopOffset": 12.5, + "overlayBoundsAreLogical": false, + "keyCounterEnabled": true + }); + std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + let _ = std::fs::remove_file(path); + + assert!(!loaded.repaired); + assert_eq!(loaded.data.selected_key_type, "tauri-legacy-tab"); + assert_eq!( + loaded.data.keys["tauri-legacy-tab"], + vec![KeySlot::from("Q")] + ); + assert_eq!(loaded.data.key_positions["tauri-legacy-tab"].len(), 1); + let position = &loaded.data.key_positions["tauri-legacy-tab"][0]; + assert_eq!(position.dx, 654.0); + assert_eq!(position.width, 91.0); + assert_eq!( + position.active_image.as_deref(), + Some("/tmp/legacy-active.png") + ); + assert_eq!(position.note_border_radius, Some(9.0)); + assert_eq!(loaded.data.key_counters["tauri-legacy-tab"]["Q"], 19); + assert_eq!(loaded.data.note_settings.speed, 456); + assert_eq!(loaded.data.background_color, "#2468AC"); + assert!(loaded.data.use_custom_css); +} + +#[test] +fn custom_tabs_and_keys_recover_without_compacting_parallel_arrays() { + let path = std::env::temp_dir().join(format!( + "dmnote-tab-key-entry-recovery-test-{}.json", + uuid::Uuid::new_v4() + )); + let alpha_tab = CustomTab { + id: "alpha-tab".to_string(), + name: "Alpha".to_string(), + }; + let beta_tab = CustomTab { + id: "beta-tab".to_string(), + name: "Beta".to_string(), + }; + let empty_mode_tab = CustomTab { + id: "empty-mode".to_string(), + name: "Empty mode".to_string(), + }; + let mut positions = vec![default_positions()["4key"][0].clone(); 5]; + for (index, position) in positions.iter_mut().enumerate() { + position.dx = ((index + 1) * 101) as f64; + } + + let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); + let fields = fixture.as_object_mut().unwrap(); + fields.insert( + "customTabs".to_string(), + json!([ + serde_json::to_value(&alpha_tab).unwrap(), + 42, + serde_json::to_value(&beta_tab).unwrap(), + serde_json::to_value(&empty_mode_tab).unwrap() + ]), + ); + fields.insert("selectedKeyType".to_string(), json!("alpha-tab")); + fields.insert( + "keys".to_string(), + json!({ + "4key": 42, + "alpha-tab": [42, "A", null, "C", {}], + "beta-tab": ["D"], + "empty-mode": 42, + }), + ); + fields.insert( + "keyPositions".to_string(), + json!({ + "alpha-tab": serde_json::to_value(&positions).unwrap(), + "beta-tab": [serde_json::to_value(&positions[0]).unwrap()], + "empty-mode": [], + }), + ); + std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + let _ = std::fs::remove_file(path); + + assert!(loaded.repaired); + assert!(loaded.needs_persist); + assert_eq!( + loaded.data.custom_tabs, + vec![alpha_tab, beta_tab, empty_mode_tab] + ); + assert_eq!(loaded.data.selected_key_type, "alpha-tab"); + assert_eq!( + loaded.data.keys["alpha-tab"], + vec![ + KeySlot::default(), + KeySlot::from("A"), + KeySlot::default(), + KeySlot::from("C"), + KeySlot::default(), + ] + ); + assert_eq!(loaded.data.keys["beta-tab"], vec![KeySlot::from("D")]); + assert!(loaded.data.keys["empty-mode"].is_empty()); + assert_eq!(loaded.data.keys["4key"], default_keys()["4key"]); + assert_eq!( + loaded.data.key_positions["alpha-tab"] + .iter() + .map(|position| position.dx) + .collect::>(), + vec![101.0, 202.0, 303.0, 404.0, 505.0] + ); +} + +#[test] +fn custom_css_history_normalizes_at_the_store_load_boundary() { + let path = std::env::temp_dir().join(format!( + "dmnote-css-history-normalize-test-{}.json", + uuid::Uuid::new_v4() + )); + let duplicate = absolute_fixture_path("history-duplicate.css"); + let mut history = vec![ + CustomCssHistoryEntry { + path: "relative.css".to_string(), + loaded_at: 999, + last_used_at: 999, + }, + CustomCssHistoryEntry { + path: duplicate.clone(), + loaded_at: 1, + last_used_at: 1, + }, + CustomCssHistoryEntry { + path: duplicate.clone(), + loaded_at: 100, + last_used_at: 100, + }, + ]; + for index in 0..12 { + history.push(CustomCssHistoryEntry { + path: absolute_fixture_path(&format!("history-{index}.css")), + loaded_at: index, + last_used_at: index, + }); + } + let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); + fixture["customCssHistory"] = serde_json::to_value(history).unwrap(); + std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + let _ = std::fs::remove_file(path); + + assert!(loaded.needs_persist); + assert_eq!(loaded.data.custom_css_history.len(), 10); + assert!(loaded + .data + .custom_css_history + .windows(2) + .all(|pair| pair[0].last_used_at >= pair[1].last_used_at)); + assert_eq!( + loaded + .data + .custom_css_history + .iter() + .filter(|entry| entry.path == duplicate) + .count(), + 1 + ); + assert!(loaded + .data + .custom_css_history + .iter() + .all(|entry| std::path::Path::new(&entry.path).is_absolute())); +} + +#[test] +fn custom_css_history_recovers_valid_entries_individually() { + let path = std::env::temp_dir().join(format!( + "dmnote-css-history-recovery-test-{}.json", + uuid::Uuid::new_v4() + )); + let first = CustomCssHistoryEntry { + path: absolute_fixture_path("history-first.css"), + loaded_at: 10, + last_used_at: 10, + }; + let second = CustomCssHistoryEntry { + path: absolute_fixture_path("history-second.css"), + loaded_at: 20, + last_used_at: 20, + }; + let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); + fixture["customCssHistory"] = json!([ + serde_json::to_value(&first).unwrap(), + { "path": 42, "lastUsedAt": "invalid" }, + serde_json::to_value(&second).unwrap() + ]); + std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + let _ = std::fs::remove_file(path); + + assert!(loaded.repaired); + assert!(loaded.needs_persist); + assert_eq!(loaded.data.custom_css_history, vec![second, first]); +} + +#[test] +fn custom_css_history_migrates_loaded_at_without_reseeding_active_path() { + let path = std::env::temp_dir().join(format!( + "dmnote-css-history-seed-test-{}.json", + uuid::Uuid::new_v4() + )); + let existing_path = absolute_fixture_path("history-existing.css"); + let active_path = absolute_fixture_path("history-active.css"); + let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); + fixture["customCss"] = json!({ + "path": active_path, + "content": "body {}" + }); + fixture["customCssHistory"] = json!([{ + "path": existing_path, + "lastUsedAt": 42 + }]); + std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + let _ = std::fs::remove_file(path); + + assert!(loaded.needs_persist); + assert_eq!(loaded.data.custom_css_history.len(), 1); + let existing = loaded + .data + .custom_css_history + .iter() + .find(|entry| entry.path == existing_path) + .unwrap(); + assert_eq!(existing.loaded_at, 42); + assert!(!loaded + .data + .custom_css_history + .iter() + .any(|entry| entry.path == active_path)); +} + +#[test] +fn custom_css_history_keeps_latest_legacy_duplicate_before_timestamp_migration() { + let path = std::env::temp_dir().join(format!( + "dmnote-css-history-legacy-duplicate-test-{}.json", + uuid::Uuid::new_v4() + )); + let duplicate_path = absolute_fixture_path("history-duplicate.css"); + let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); + fixture["customCssHistory"] = json!([ + { + "path": duplicate_path, + "lastUsedAt": 1 + }, + { + "path": duplicate_path, + "lastUsedAt": 100 + } + ]); + std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + let _ = std::fs::remove_file(path); + + assert_eq!(loaded.data.custom_css_history.len(), 1); + assert_eq!(loaded.data.custom_css_history[0].loaded_at, 100); + assert_eq!(loaded.data.custom_css_history[0].last_used_at, 100); +} + +#[test] +fn custom_css_history_seeds_active_path_only_when_legacy_field_is_missing() { + let path = std::env::temp_dir().join(format!( + "dmnote-css-history-legacy-seed-test-{}.json", + uuid::Uuid::new_v4() + )); + let active_path = absolute_fixture_path("history-active.css"); + let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); + fixture.as_object_mut().unwrap().remove("customCssHistory"); + fixture["customCss"] = json!({ + "path": active_path, + "content": "body {}" + }); + std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + let _ = std::fs::remove_file(path); + + assert!(loaded.needs_persist); + assert_eq!(loaded.data.custom_css_history.len(), 1); + assert_eq!(loaded.data.custom_css_history[0].path, active_path); +} + +#[test] +fn explicit_empty_custom_css_history_survives_repeated_loads() { + let path = std::env::temp_dir().join(format!( + "dmnote-css-history-empty-test-{}.json", + uuid::Uuid::new_v4() + )); + let active_path = absolute_fixture_path("history-active.css"); + let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); + fixture["customCss"] = json!({ + "path": active_path, + "content": "body {}" + }); + fixture["customCssHistory"] = json!([]); + std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); + + let first = load_store_from_path(&path).unwrap(); + let second = load_store_from_path(&path).unwrap(); + let _ = std::fs::remove_file(path); + + assert!(first.data.custom_css_history.is_empty()); + assert!(second.data.custom_css_history.is_empty()); +} + +#[cfg(unix)] +#[test] +fn legacy_symlink_active_css_is_seeded_with_its_canonical_path() { + use std::os::unix::fs::symlink; + + let root = std::env::temp_dir().join(format!( + "dmnote-css-history-symlink-test-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&root).unwrap(); + let target = root.join("target.css"); + let alias = root.join("alias.css"); + let store_path = root.join("store.json"); + std::fs::write(&target, "body {}").unwrap(); + symlink(&target, &alias).unwrap(); + + let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); + fixture.as_object_mut().unwrap().remove("customCssHistory"); + fixture["customCss"] = json!({ + "path": alias.to_string_lossy(), + "content": "body {}" + }); + std::fs::write(&store_path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); + + let loaded = load_store_from_path(&store_path).unwrap(); + let canonical = std::fs::canonicalize(&target) + .unwrap() + .to_string_lossy() + .to_string(); + let _ = std::fs::remove_dir_all(root); + + assert_eq!( + loaded.data.custom_css.path.as_deref(), + Some(canonical.as_str()) + ); + assert_eq!(loaded.data.custom_css_history.len(), 1); + assert_eq!(loaded.data.custom_css_history[0].path, canonical); +} + +#[test] +fn custom_tab_whole_mode_damage_recovers_parallel_shape_only() { + let path = std::env::temp_dir().join(format!( + "dmnote-tab-mode-recovery-test-{}.json", + uuid::Uuid::new_v4() + )); + let mut first_position = default_positions()["4key"][0].clone(); + first_position.dx = 111.0; + let mut second_position = default_positions()["4key"][1].clone(); + second_position.dx = 222.0; + + let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); + let fields = fixture.as_object_mut().unwrap(); + fields.insert( + "customTabs".to_string(), + json!([ + { "id": "keys-damaged", "name": "Keys damaged" }, + { "id": "positions-damaged", "name": "Positions damaged" }, + { "id": "valid-mismatch", "name": "Valid mismatch" }, + { "id": "missing-both", "name": "Missing both" } + ]), + ); + fields.insert("selectedKeyType".to_string(), json!("positions-damaged")); + fields.insert( + "keys".to_string(), + json!({ + "keys-damaged": 42, + "positions-damaged": ["A", "B", "C"], + "valid-mismatch": ["Q"] + }), + ); + fields.insert( + "keyPositions".to_string(), + json!({ + "keys-damaged": [ + serde_json::to_value(&first_position).unwrap(), + serde_json::to_value(&second_position).unwrap() + ], + "positions-damaged": 42, + "valid-mismatch": [ + serde_json::to_value(&first_position).unwrap(), + serde_json::to_value(&second_position).unwrap() + ] + }), + ); + std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + let _ = std::fs::remove_file(path); + + assert!(loaded.repaired); + assert!(loaded.needs_persist); + assert_eq!(loaded.data.selected_key_type, "positions-damaged"); + assert_eq!( + loaded.data.keys["keys-damaged"], + vec![KeySlot::default(), KeySlot::default()] + ); + assert_eq!( + loaded.data.key_positions["keys-damaged"] + .iter() + .map(|position| position.dx) + .collect::>(), + vec![111.0, 222.0] + ); + assert_eq!( + loaded.data.keys["positions-damaged"], + vec![KeySlot::from("A"), KeySlot::from("B"), KeySlot::from("C")] + ); + assert!(loaded.data.key_positions["positions-damaged"] + .iter() + .all(|position| { + let mut position = position.clone(); + position.id.clear(); + position == KeyPosition::default() + })); + assert_eq!( + loaded.data.keys["valid-mismatch"], + vec![KeySlot::from("Q"), KeySlot::default()] + ); + assert_eq!(loaded.data.key_positions["valid-mismatch"].len(), 2); + assert!(loaded.data.keys["missing-both"].is_empty()); + assert!(loaded.data.key_positions["missing-both"].is_empty()); +} + +#[test] +fn custom_tab_missing_modes_are_repaired_on_an_otherwise_valid_store() { + let path = std::env::temp_dir().join(format!( + "dmnote-tab-missing-modes-test-{}.json", + uuid::Uuid::new_v4() + )); + let mut fixture = AppStoreData::default(); + fixture.custom_tabs.push(CustomTab { + id: "missing-modes".to_string(), + name: "Missing modes".to_string(), + }); + fixture.selected_key_type = "missing-modes".to_string(); + std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + let _ = std::fs::remove_file(path); + + assert!(loaded.repaired); + assert!(loaded.needs_persist); + assert_eq!(loaded.data.selected_key_type, "missing-modes"); + assert!(loaded.data.keys["missing-modes"].is_empty()); + assert!(loaded.data.key_positions["missing-modes"].is_empty()); +} + +#[test] +fn normal_load_rejects_selected_mode_without_a_matching_tab() { + let path = std::env::temp_dir().join(format!( + "dmnote-ghost-selection-test-{}.json", + uuid::Uuid::new_v4() + )); + let mut fixture = AppStoreData { + selected_key_type: "ghost-tab".to_string(), + ..AppStoreData::default() + }; + fixture + .keys + .insert("ghost-tab".to_string(), vec!["G".into()]); + std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + let _ = std::fs::remove_file(path); + + assert!(!loaded.repaired); + assert!(loaded.needs_persist); + assert_eq!(loaded.data.selected_key_type, "4key"); + assert_eq!(loaded.data.keys["ghost-tab"], vec![KeySlot::from("G")]); +} + +#[test] +fn asset_entries_recover_only_from_valid_identity_fields() { + let path = std::env::temp_dir().join(format!( + "dmnote-asset-identity-recovery-test-{}.json", + uuid::Uuid::new_v4() + )); + let font_path = std::env::temp_dir().join(format!( + "dmnote-recovered-font-{}.ttf", + uuid::Uuid::new_v4() + )); + let sound_path = std::env::temp_dir().join(format!( + "dmnote-recovered-sound-{}.wav", + uuid::Uuid::new_v4() + )); + let valid_font = CustomFont { + id: "recoverable-font".to_string(), + font_type: FontType::Local, + name: "Recoverable Font".to_string(), + display_name: "Recoverable Font".to_string(), + enabled: true, + local_path: Some(font_path.to_string_lossy().to_string()), + css_content: None, + weight_ranges: Vec::new(), + }; + let mut recoverable_font = serde_json::to_value(&valid_font).unwrap(); + recoverable_font + .as_object_mut() + .unwrap() + .insert("enabled".to_string(), json!("invalid")); + let mut invalid_identity_font = recoverable_font.clone(); + invalid_identity_font + .as_object_mut() + .unwrap() + .insert("id".to_string(), json!("")); + + let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); + let fields = fixture.as_object_mut().unwrap(); + fields.insert( + "fontSettings".to_string(), + json!({ "customFonts": [recoverable_font, invalid_identity_font] }), + ); + fields.insert( + "soundLibrary".to_string(), + json!({ + sound_path.to_string_lossy().to_string(): 42, + "relative.wav": 42 + }), + ); + std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + let _ = std::fs::remove_file(path); + + assert!(loaded.repaired); + assert_eq!(loaded.data.font_settings.custom_fonts.len(), 1); + let recovered_font = &loaded.data.font_settings.custom_fonts[0]; + assert_eq!(recovered_font.id, "recoverable-font"); + assert!(!recovered_font.enabled); + assert_eq!(recovered_font.local_path, valid_font.local_path); + let sound_key = sound_path.to_string_lossy().to_string(); + assert_eq!( + loaded.data.sound_library[&sound_key], + SoundLibraryEntry::default() + ); + assert!(!loaded.data.sound_library.contains_key("relative.wav")); +} + +#[test] +fn invalid_font_weight_ranges_are_reset_without_removing_the_font() { + let path = std::env::temp_dir().join(format!( + "dmnote-font-weight-range-recovery-test-{}.json", + uuid::Uuid::new_v4() + )); + let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); + fixture.as_object_mut().unwrap().insert( + "fontSettings".to_string(), + json!({ + "customFonts": [{ + "id": "recoverable-web-font", + "type": "web", + "name": "Recoverable Web Font", + "displayName": "Recoverable Web Font", + "enabled": true, + "cssContent": "@font-face { font-family: 'Recoverable Web Font'; src: url(font.woff2); }", + "weightRanges": [{ "min": 900, "max": 100 }] + }] + }), + ); + std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + let _ = std::fs::remove_file(path); + + assert!(loaded.repaired); + assert_eq!(loaded.data.font_settings.custom_fonts.len(), 1); + assert_eq!( + loaded.data.font_settings.custom_fonts[0].id, + "recoverable-web-font" + ); + assert!(loaded.data.font_settings.custom_fonts[0] + .weight_ranges + .is_empty()); +} + +#[test] +fn position_widgets_recover_layout_only_with_valid_identity() { + fn corrupt_height(mut value: Value) -> Value { + value + .as_object_mut() + .unwrap() + .insert("height".to_string(), json!("invalid")); + value + } + + let path = std::env::temp_dir().join(format!( + "dmnote-widget-layout-recovery-test-{}.json", + uuid::Uuid::new_v4() + )); + let mut stat_position = default_positions()["4key"][0].clone(); + stat_position.sound_path = Some("/tmp/recovered-stat.wav".to_string()); + let mut graph_position = default_positions()["4key"][1].clone(); + graph_position.sound_path = Some("/tmp/recovered-graph.wav".to_string()); + let mut knob_position = default_positions()["4key"][2].clone(); + knob_position.sound_path = Some("/tmp/recovered-knob.wav".to_string()); + + let recoverable_stat = corrupt_height( + serde_json::to_value(StatPosition { + stat_type: StatType::Kps, + position: stat_position.clone(), + }) + .unwrap(), + ); + let mut invalid_stat = recoverable_stat.clone(); + invalid_stat + .as_object_mut() + .unwrap() + .insert("statType".to_string(), json!("invalid")); + + let recoverable_graph = corrupt_height( + serde_json::to_value(GraphPosition { + stat_type: GraphStatType::Kps, + graph_type: GraphType::Line, + graph_speed: 1, + graph_color: "#FFFFFF".to_string(), + show_avg_line: true, + position: graph_position.clone(), + }) + .unwrap(), + ); + let mut invalid_graph = recoverable_graph.clone(); + invalid_graph + .as_object_mut() + .unwrap() + .insert("graphType".to_string(), json!("invalid")); + let mut invalid_graph_setting = recoverable_graph.clone(); + invalid_graph_setting + .as_object_mut() + .unwrap() + .insert("graphSpeed".to_string(), json!("invalid")); + + let recoverable_knob = corrupt_height( + serde_json::to_value(KnobPosition { + axis_id: "axis-recoverable".to_string(), + sensitivity: 1.0, + reverse: false, + position: knob_position.clone(), + }) + .unwrap(), + ); + let mut unbound_knob = recoverable_knob.clone(); + unbound_knob + .as_object_mut() + .unwrap() + .insert("axisId".to_string(), json!("")); + let mut invalid_knob_setting = recoverable_knob.clone(); + invalid_knob_setting + .as_object_mut() + .unwrap() + .insert("sensitivity".to_string(), json!("invalid")); + + let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); + let fields = fixture.as_object_mut().unwrap(); + fields.insert( + "statPositions".to_string(), + json!({ "recovery-mode": [recoverable_stat, invalid_stat] }), + ); + fields.insert( + "graphPositions".to_string(), + json!({ + "recovery-mode": [recoverable_graph, invalid_graph, invalid_graph_setting] + }), + ); + fields.insert( + "knobPositions".to_string(), + json!({ + "recovery-mode": [recoverable_knob, unbound_knob, invalid_knob_setting] + }), + ); + std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + let _ = std::fs::remove_file(path); + + assert_eq!(loaded.data.stat_positions["recovery-mode"].len(), 1); + assert_eq!(loaded.data.graph_positions["recovery-mode"].len(), 1); + assert_eq!(loaded.data.knob_positions["recovery-mode"].len(), 2); + let stat = &loaded.data.stat_positions["recovery-mode"][0]; + let graph = &loaded.data.graph_positions["recovery-mode"][0]; + let knob = &loaded.data.knob_positions["recovery-mode"][0]; + let unbound_knob = &loaded.data.knob_positions["recovery-mode"][1]; + assert_eq!(stat.stat_type, StatType::Kps); + assert_eq!(graph.graph_type, GraphType::Line); + assert_eq!(knob.axis_id, "axis-recoverable"); + assert!(unbound_knob.axis_id.is_empty()); + assert_eq!(stat.position.height, KeyPosition::default().height); + assert_eq!(graph.position.height, KeyPosition::default().height); + assert_eq!(knob.position.height, KeyPosition::default().height); + assert_eq!(stat.position.sound_path, stat_position.sound_path); + assert_eq!(graph.position.sound_path, graph_position.sound_path); + assert_eq!(knob.position.sound_path, knob_position.sound_path); + assert_eq!(unbound_knob.position.sound_path, knob_position.sound_path); +} + +#[test] +fn repaired_selection_falls_back_without_deleting_orphaned_mode_data() { + let path = std::env::temp_dir().join(format!( + "dmnote-selected-tab-recovery-test-{}.json", + uuid::Uuid::new_v4() + )); + let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); + let fields = fixture.as_object_mut().unwrap(); + fields.insert( + "customTabs".to_string(), + json!([{ "id": "ghost-tab", "name": 42 }]), + ); + fields.insert("selectedKeyType".to_string(), json!("ghost-tab")); + fields.insert("keys".to_string(), json!({ "ghost-tab": ["G"] })); + std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + let _ = std::fs::remove_file(path); + + assert!(loaded.repaired); + assert!(loaded.data.custom_tabs.is_empty()); + assert_eq!(loaded.data.selected_key_type, "4key"); + assert_eq!(loaded.data.keys["ghost-tab"], vec![KeySlot::from("G")]); +} + +#[test] +fn nested_user_settings_recover_valid_siblings_only() { + let path = std::env::temp_dir().join(format!( + "dmnote-nested-settings-recovery-test-{}.json", + uuid::Uuid::new_v4() + )); + let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); + let fields = fixture.as_object_mut().unwrap(); + + let note_settings = fields["noteSettings"].as_object_mut().unwrap(); + note_settings.insert("speed".to_string(), json!(987)); + note_settings.insert("trackHeight".to_string(), json!("invalid")); + + fields.insert( + "customTabs".to_string(), + json!([{ "id": "alpha-tab", "name": "Alpha" }]), + ); + fields.insert("keys".to_string(), json!({ "alpha-tab": ["A", "B", "C"] })); + fields.insert( + "layerGroups".to_string(), + json!({ + "alpha-tab": [ + { "id": "group-a", "name": "Group A" }, + 42, + { "id": "group-c", "name": "Group C" } + ], + "invalid-mode": 42 + }), + ); + fields.insert( + "keyCounters".to_string(), + json!({ + "alpha-tab": { "A": 7, "B": "invalid", "C": 9 }, + "invalid-mode": 42 + }), + ); + fields.insert( + "customCss".to_string(), + json!({ "path": "/tmp/sentinel.css", "content": 42 }), + ); + fields.insert( + "counterAnimationPresets".to_string(), + json!([ + { + "id": "animation-a", + "name": "Animation A", + "source": "user", + "bezier": [0.25, 0.46, 0.45, 0.94], + "scale": 1.1, + "durationMs": 300 + }, + 42, + { + "id": "animation-c", + "name": "Animation C", + "source": "user", + "bezier": [0.1, 0.2, 0.3, 0.4], + "scale": 1.2, + "durationMs": 400 + } + ]), + ); + fields.insert( + "tabCssOverrides".to_string(), + json!({ + "alpha-tab": { "path": null, "content": ".alpha {}", "enabled": true }, + "repaired-tab": { "path": "/tmp/repaired.css", "content": ".repaired {}", "enabled": 42 }, + "invalid-tab": 42 + }), + ); + fields.insert( + "tabNoteOverrides".to_string(), + json!({ + "alpha-tab": { "speed": 654 }, + "repaired-tab": { "speed": 777, "reverse": "invalid" }, + "invalid-tab": 42 + }), + ); + fields.insert( + "customJs".to_string(), + json!({ + "path": "/tmp/sentinel.js", + "content": "globalThis.sentinel = true;", + "plugins": [ + { "id": "plugin-a", "name": "Plugin A", "path": null, "content": "a", "enabled": true }, + 42, + { "id": "plugin-c", "name": "Plugin C", "path": null, "content": "c", "enabled": false } + ] + }), + ); + + let grid_settings = fields["gridSettings"].as_object_mut().unwrap(); + grid_settings.insert("alignmentGuides".to_string(), json!(false)); + grid_settings.insert("gridSnapSize".to_string(), json!("invalid")); + grid_settings.insert("overlayPadding".to_string(), json!(17)); + + let shortcuts = fields["shortcuts"].as_object_mut().unwrap(); + shortcuts.insert("toggleOverlay".to_string(), json!(42)); + shortcuts.insert( + "toggleAlwaysOnTop".to_string(), + json!({ "key": "F12", "ctrl": true, "shift": false, "alt": false, "meta": false }), + ); + + let repaired_key = absolute_fixture_path("repaired.wav"); + let path_only_key = absolute_fixture_path("path-only.wav"); + let mut sound_library = serde_json::Map::new(); + sound_library.insert( + repaired_key.clone(), + json!({ + "source": 42, + "displayName": "Recovered sound", + "trimStartRatio": 0.2 + }), + ); + sound_library.insert(path_only_key.clone(), json!(42)); + fields.insert( + "soundLibrary".to_string(), + serde_json::Value::Object(sound_library), + ); + + std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); + let loaded = load_store_from_path(&path).unwrap(); + let _ = std::fs::remove_file(path); + + assert!(loaded.repaired); + assert_eq!(loaded.data.note_settings.speed, 987); + assert_eq!( + loaded.data.note_settings.track_height, + AppStoreData::default().note_settings.track_height + ); + assert_eq!( + loaded.data.layer_groups["alpha-tab"] + .iter() + .map(|group| group.id.as_str()) + .collect::>(), + vec!["group-a", "group-c"] + ); + assert!(!loaded.data.layer_groups.contains_key("invalid-mode")); + assert_eq!(loaded.data.key_counters["alpha-tab"]["A"], 7); + assert_eq!(loaded.data.key_counters["alpha-tab"]["B"], 0); + assert_eq!(loaded.data.key_counters["alpha-tab"]["C"], 9); + assert!(!loaded.data.key_counters.contains_key("invalid-mode")); + assert_eq!( + loaded.data.custom_css.path.as_deref(), + Some("/tmp/sentinel.css") + ); + assert!(loaded.data.custom_css.content.is_empty()); + assert_eq!( + loaded + .data + .counter_animation_presets + .iter() + .map(|preset| preset.id.as_str()) + .collect::>(), + vec!["animation-a", "animation-c"] + ); + assert_eq!( + loaded.data.tab_css_overrides["alpha-tab"].content, + ".alpha {}" + ); + assert_eq!( + loaded.data.tab_css_overrides["repaired-tab"].content, + ".repaired {}" + ); + assert!(loaded.data.tab_css_overrides["repaired-tab"].enabled); + assert!(!loaded.data.tab_css_overrides.contains_key("invalid-tab")); + assert_eq!(loaded.data.tab_note_overrides["alpha-tab"].speed, Some(654)); + assert_eq!( + loaded.data.tab_note_overrides["repaired-tab"].speed, + Some(777) + ); + assert_eq!(loaded.data.tab_note_overrides["repaired-tab"].reverse, None); + assert!(!loaded.data.tab_note_overrides.contains_key("invalid-tab")); + assert_eq!( + loaded.data.custom_js.path.as_deref(), + Some("/tmp/sentinel.js") + ); + assert_eq!( + loaded + .data + .custom_js + .plugins + .iter() + .map(|plugin| plugin.id.as_str()) + .collect::>(), + vec!["plugin-a", "plugin-c"] + ); + assert!(!loaded.data.grid_settings.alignment_guides); + assert_eq!( + loaded.data.grid_settings.grid_snap_size, + AppStoreData::default().grid_settings.grid_snap_size + ); + assert_eq!(loaded.data.grid_settings.overlay_padding, 17); + assert_eq!(loaded.data.shortcuts.toggle_always_on_top.key, "F12"); + assert_eq!( + loaded.data.shortcuts.toggle_overlay, + AppStoreData::default().shortcuts.toggle_overlay + ); + assert_eq!( + loaded.data.sound_library[repaired_key.as_str()] + .display_name + .as_deref(), + Some("Recovered sound") + ); + assert_eq!( + loaded.data.sound_library[repaired_key.as_str()].trim_start_ratio, + Some(0.2) + ); + assert_eq!( + loaded.data.sound_library[repaired_key.as_str()].source, + crate::models::SoundSource::Local + ); + assert_eq!( + loaded.data.sound_library[path_only_key.as_str()], + SoundLibraryEntry::default() + ); +} + +#[test] +fn asset_position_collections_recover_valid_entries_only() { + let path = std::env::temp_dir().join(format!( + "dmnote-position-entry-recovery-test-{}.json", + uuid::Uuid::new_v4() + )); + let position = default_positions()["4key"][0].clone(); + let mut third_position = default_positions()["4key"][1].clone(); + third_position.dx += 17.0; + let mut partial_position = default_positions()["4key"][0].clone(); + partial_position.dx = 246.0; + partial_position.width = 88.0; + partial_position.active_image = Some("/tmp/recovered-image.png".to_string()); + partial_position.sound_path = Some("/tmp/recovered-sound.wav".to_string()); + let mut partial_position_value = serde_json::to_value(&partial_position).unwrap(); + partial_position_value + .as_object_mut() + .unwrap() + .insert("height".to_string(), json!("invalid")); + let stat = StatPosition { + stat_type: StatType::Kps, + position: position.clone(), + }; + let graph = GraphPosition { + stat_type: GraphStatType::Kps, + graph_type: GraphType::Line, + graph_speed: 1, + graph_color: "#FFFFFF".to_string(), + show_avg_line: true, + position: position.clone(), + }; + let knob = KnobPosition { + axis_id: "axis-sentinel".to_string(), + sensitivity: 1.0, + reverse: false, + position: position.clone(), + }; + let font = CustomFont { + id: "font-sentinel".to_string(), + font_type: FontType::Local, + name: "Font Sentinel".to_string(), + display_name: "Font Sentinel".to_string(), + enabled: true, + local_path: Some(absolute_fixture_path("font-sentinel.ttf")), + css_content: None, + weight_ranges: Vec::new(), + }; + + let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); + let fields = fixture.as_object_mut().unwrap(); + fields.insert( + "keyPositions".to_string(), + json!({ + "partial-mode": [ + serde_json::to_value(&position).unwrap(), + partial_position_value, + serde_json::to_value(&third_position).unwrap(), + 42 + ], + "invalid-mode": 42, + }), + ); + fields.insert( + "statPositions".to_string(), + json!({ + "partial-mode": [serde_json::to_value(&stat).unwrap(), 42], + "invalid-mode": 42, + }), + ); + fields.insert( + "graphPositions".to_string(), + json!({ + "partial-mode": [serde_json::to_value(&graph).unwrap(), 42], + "invalid-mode": 42, + }), + ); + fields.insert( + "knobPositions".to_string(), + json!({ + "partial-mode": [serde_json::to_value(&knob).unwrap(), 42], + "invalid-mode": 42, + }), + ); + fields.insert( + "fontSettings".to_string(), + json!({ + "customFonts": [serde_json::to_value(&font).unwrap(), 42], + }), + ); + std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + let _ = std::fs::remove_file(path); + + assert!(loaded.repaired); + assert!(loaded.needs_persist); + let recovered_key_positions = &loaded.data.key_positions["partial-mode"]; + assert_eq!(recovered_key_positions.len(), 4); + let mut recovered_first = recovered_key_positions[0].clone(); + recovered_first.id.clear(); + assert_eq!(recovered_first, position); + assert_eq!(recovered_key_positions[1].dx, partial_position.dx); + assert_eq!(recovered_key_positions[1].width, partial_position.width); + assert_eq!( + recovered_key_positions[1].height, + KeyPosition::default().height + ); + assert_eq!( + recovered_key_positions[1].active_image, + partial_position.active_image + ); + assert_eq!( + recovered_key_positions[1].sound_path, + partial_position.sound_path + ); + let mut recovered_third = recovered_key_positions[2].clone(); + recovered_third.id.clear(); + assert_eq!(recovered_third, third_position); + let mut recovered_default = recovered_key_positions[3].clone(); + recovered_default.id.clear(); + assert_eq!(recovered_default, KeyPosition::default()); + assert_eq!(recovered_key_positions[3].width, 60.0); + let mut recovered_stat = loaded.data.stat_positions["partial-mode"][0].clone(); + recovered_stat.position.id.clear(); + assert_eq!(recovered_stat, stat); + let mut recovered_graph = loaded.data.graph_positions["partial-mode"][0].clone(); + recovered_graph.position.id.clear(); + assert_eq!(recovered_graph, graph); + let mut recovered_knob = loaded.data.knob_positions["partial-mode"][0].clone(); + recovered_knob.position.id.clear(); + assert_eq!(recovered_knob, knob); + assert!(!loaded.data.key_positions.contains_key("invalid-mode")); + assert!(!loaded.data.stat_positions.contains_key("invalid-mode")); + assert!(!loaded.data.graph_positions.contains_key("invalid-mode")); + assert!(!loaded.data.knob_positions.contains_key("invalid-mode")); + assert_eq!(loaded.data.font_settings.custom_fonts, vec![font]); +} + +#[test] +fn image_settings_recover_in_place_without_removing_positions() { + let path = std::env::temp_dir().join(format!( + "dmnote-image-transform-recovery-test-{}.json", + uuid::Uuid::new_v4() + )); + let positions = vec![ + default_positions()["4key"][0].clone(), + default_positions()["4key"][1].clone(), + ]; + let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); + fixture["keyPositions"]["recovery-mode"] = serde_json::to_value(&positions).unwrap(); + fixture["keyPositions"]["recovery-mode"][0]["imageMode"] = json!("damaged"); + fixture["keyPositions"]["recovery-mode"][0]["idleImageTransform"] = json!({ + "offsetX": 25.0, + "offsetY": -30.0, + "rotation": 45.0, + "scale": 100.0 + }); + std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + let _ = std::fs::remove_file(path); + + assert!(loaded.repaired); + assert!(loaded.needs_persist); + assert_eq!(loaded.data.key_positions["recovery-mode"].len(), 2); + let recovered = &loaded.data.key_positions["recovery-mode"][0]; + assert_eq!(recovered.image_mode, None); + assert_eq!( + recovered.idle_image_transform, + Some(ImageTransform { + offset_x: 25.0, + offset_y: -30.0, + rotation: 45.0, + scale: 1.0, + }) + ); + + let mut data = loaded.data; + let transform = data.key_positions.get_mut("recovery-mode").unwrap()[0] + .active_image_transform + .insert(ImageTransform { + offset_x: 10.0, + offset_y: 20.0, + rotation: 30.0, + scale: f64::NAN, + }); + assert!(transform.scale.is_nan()); + let original_len = data.key_positions["recovery-mode"].len(); + assert!(repair_image_transforms(&mut data)); + assert_eq!(data.key_positions["recovery-mode"].len(), original_len); + assert_eq!( + data.key_positions["recovery-mode"][0].active_image_transform, + Some(ImageTransform { + offset_x: 10.0, + offset_y: 20.0, + rotation: 30.0, + scale: 1.0, + }) + ); +} + +#[test] +fn non_utf8_store_recovers_instead_of_failing_init() { + // 잘못된 UTF-8 바이트(0xFF)는 과거 read_to_string에서 IO 에러로 초기화 실패 + .bak 미생성 + let path = std::env::temp_dir().join(format!( + "dmnote-non-utf8-recovery-test-{}.json", + uuid::Uuid::new_v4() + )); + std::fs::write(&path, [b'{', 0xFF, b'}']).unwrap(); + + let loaded = load_store_from_path(&path).expect("비 UTF-8 store는 Err 대신 복구되어야 함"); + let _ = std::fs::remove_file(&path); + + // 복구 분기 합류 — repaired/needs_persist로 store.rs가 .bak 백업 후 재저장 + assert!(loaded.repaired); + assert!(loaded.needs_persist); + + // 문법만 깨진 UTF-8 JSON과 동일한 기본값 복구 경로로 수렴 + let broken_path = std::env::temp_dir().join(format!( + "dmnote-broken-json-baseline-{}.json", + uuid::Uuid::new_v4() + )); + std::fs::write(&broken_path, b"{ not json").unwrap(); + let baseline = load_store_from_path(&broken_path).unwrap(); + let _ = std::fs::remove_file(&broken_path); + let mut loaded_value = serde_json::to_value(&loaded.data).unwrap(); + let mut baseline_value = serde_json::to_value(&baseline.data).unwrap(); + remove_all_native_ids(&mut loaded_value); + remove_all_native_ids(&mut baseline_value); + assert_eq!(loaded_value, baseline_value); +} + +#[test] +fn missing_local_font_is_restored_from_embedded_data_uri() { + let app_data_dir = std::env::temp_dir().join(format!( + "dmnote-font-migration-test-{}", + uuid::Uuid::new_v4() + )); + let missing_path = app_data_dir.join("missing.woff2"); + let font_bytes = b"embedded-font"; + let css_content = format!( + "@font-face {{ src: url(data:font/woff2;base64,{}) format('woff2'); }}", + BASE64_STANDARD.encode(font_bytes) + ); + let mut data = AppStoreData::default(); + data.font_settings.custom_fonts.push(CustomFont { + id: "local-font".to_string(), + font_type: FontType::Local, + name: "Local Font".to_string(), + display_name: "Local Font".to_string(), + enabled: true, + local_path: Some(missing_path.to_string_lossy().to_string()), + css_content: Some(css_content), + weight_ranges: Vec::new(), + }); + + assert!(migrate_local_fonts_to_app_data(&app_data_dir, &mut data)); + + let font = &data.font_settings.custom_fonts[0]; + let restored_path = std::path::PathBuf::from(font.local_path.as_ref().unwrap()); + assert!(font.enabled); + assert!(font.css_content.is_none()); + assert_eq!( + restored_path.parent(), + Some(app_data_dir.join("fonts").as_path()) + ); + assert_eq!( + restored_path.extension().and_then(|ext| ext.to_str()), + Some("woff2") + ); + assert_eq!(std::fs::read(&restored_path).unwrap(), font_bytes); + + let _ = std::fs::remove_dir_all(app_data_dir); +} + +#[test] +fn portable_asset_reference_parser_accepts_cross_platform_absolute_forms() { + let cases = [ + r"C:\Users\me\AppData\Roaming\com.dmnote.desktop\sounds\key.wav", + r"\\?\C:\Users\me\AppData\Roaming\com.dmnote.desktop\sounds\key.wav", + r"\\server\share\com.dmnote.desktop\sounds\key.wav", + r"\\?\UNC\server\share\com.dmnote.desktop\sounds\key.wav", + "/Users/me/Library/Application Support/com.dmnote.desktop/sounds/key.wav", + "file:///C:/Users/me/AppData/Roaming/com.dmnote.desktop/sounds/key.wav", + ]; + for raw in cases { + assert_eq!( + parse_portable_asset_reference(raw), + Some((AssetCategory::Sounds, "key.wav".to_string())), + "failed to parse {raw}" + ); + } +} + +#[test] +fn cross_platform_asset_forms_rehome_to_existing_local_files() { + let dir = rehome_test_directory("absolute-forms"); + let sounds_dir = dir.join("sounds"); + std::fs::create_dir_all(&sounds_dir).unwrap(); + let forms = [ + r"C:\Users\me\AppData\Roaming\com.dmnote.desktop\sounds\normal.wav", + r"\\?\C:\Users\me\AppData\Roaming\com.dmnote.desktop\sounds\verbatim.wav", + r"\\server\share\com.dmnote.desktop\sounds\unc.wav", + "file:///C:/Users/me/AppData/Roaming/com.dmnote.desktop/sounds/url.wav", + ]; + for name in ["normal.wav", "verbatim.wav", "unc.wav", "url.wav"] { + std::fs::write(sounds_dir.join(name), b"sound").unwrap(); + } + + for (raw, name) in forms + .into_iter() + .zip(["normal.wav", "verbatim.wav", "unc.wav", "url.wav"]) + { + let mut data = data_with_one_position(); + data.key_positions.get_mut("4key").unwrap()[0].sound_path = Some(raw.to_string()); + assert!(rehome_foreign_asset_references(&dir, &mut data)); + assert_eq!( + data.key_positions["4key"][0].sound_path.as_deref(), + Some(sounds_dir.join(name).to_string_lossy().as_ref()) + ); + } + + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn missing_external_and_traversal_asset_references_remain_unchanged() { + let dir = rehome_test_directory("rejected"); + std::fs::create_dir_all(dir.join("sounds")).unwrap(); + std::fs::write(dir.join("sounds").join("evil.wav"), b"sound").unwrap(); + for raw in [ + r"C:\Users\me\com.dmnote.desktop\sounds\missing.wav", + r"D:\music\evil.wav", + r"C:\Users\me\com.dmnote.desktop\sounds\..\evil.wav", + r"C:\Users\me\com.dmnote.desktop\sounds\..", + ] { + let mut data = data_with_one_position(); + data.key_positions.get_mut("4key").unwrap()[0].sound_path = Some(raw.to_string()); + assert!(!rehome_foreign_asset_references(&dir, &mut data)); + assert_eq!( + data.key_positions["4key"][0].sound_path.as_deref(), + Some(raw) + ); + } + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn normalized_unicode_file_name_rehomes_only_on_unique_match() { + let dir = rehome_test_directory("unicode"); + let images_dir = dir.join("images"); + std::fs::create_dir_all(&images_dir).unwrap(); + let nfc_name = "키음.wav"; + let nfd_name = nfc_name.nfd().collect::(); + let actual_path = images_dir.join(&nfd_name); + std::fs::write(&actual_path, b"image").unwrap(); + let mut data = data_with_one_position(); + data.key_positions.get_mut("4key").unwrap()[0].active_image = + Some(format!(r"C:\Users\me\com.dmnote.desktop\images\{nfc_name}")); + + assert!(rehome_foreign_asset_references(&dir, &mut data)); + let rehomed = + std::path::PathBuf::from(data.key_positions["4key"][0].active_image.as_ref().unwrap()); + assert!(rehomed.is_file()); + assert_eq!( + rehomed + .file_name() + .unwrap() + .to_string_lossy() + .nfc() + .collect::(), + nfc_name + ); + assert!(!rehome_foreign_asset_references(&dir, &mut data)); + + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn foreign_font_reference_stays_enabled_and_heals_after_files_arrive() { + let dir = rehome_test_directory("font-heal"); + std::fs::create_dir_all(dir.join("fonts")).unwrap(); + let foreign = r"C:\Users\me\AppData\Roaming\com.dmnote.desktop\fonts\portable.ttf"; + let mut data = AppStoreData::default(); + data.font_settings.custom_fonts.push(CustomFont { + id: "font".to_string(), + font_type: FontType::Local, + name: "Font".to_string(), + display_name: "Font".to_string(), + enabled: true, + local_path: Some(foreign.to_string()), + css_content: None, + weight_ranges: Vec::new(), + }); + + // store만 복사된 상태 — 로드 체인 순서(재귀화 → 폰트 마이그레이션) 재현 + assert!(!rehome_foreign_asset_references(&dir, &mut data)); + migrate_local_fonts_to_app_data(&dir, &mut data); + assert!(data.font_settings.custom_fonts[0].enabled); + assert_eq!( + data.font_settings.custom_fonts[0].local_path.as_deref(), + Some(foreign) + ); + + // 파일이 뒤늦게 복사되면 다음 로드에서 치유 + let local = dir.join("fonts").join("portable.ttf"); + std::fs::write(&local, b"font").unwrap(); + assert!(rehome_foreign_asset_references(&dir, &mut data)); + migrate_local_fonts_to_app_data(&dir, &mut data); + assert!(data.font_settings.custom_fonts[0].enabled); + assert_eq!( + data.font_settings.custom_fonts[0].local_path.as_deref(), + Some(local.to_string_lossy().as_ref()) + ); + + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn dangling_local_font_reference_is_still_disabled() { + // 현재 appData 루트가 마커명을 포함하는 실제 구조 재현 + let root = rehome_test_directory("font-dangling"); + let dir = root.join("com.dmnote.desktop"); + std::fs::create_dir_all(dir.join("fonts")).unwrap(); + let missing_local = dir.join("fonts").join("gone.ttf"); + let mut data = AppStoreData::default(); + data.font_settings.custom_fonts.push(CustomFont { + id: "font".to_string(), + font_type: FontType::Local, + name: "Font".to_string(), + display_name: "Font".to_string(), + enabled: true, + local_path: Some(missing_local.to_string_lossy().into_owned()), + css_content: None, + weight_ranges: Vec::new(), + }); + + // 마커가 있어도 현재 루트 하위의 단순 누락 참조는 기존대로 비활성화 + assert!(!rehome_foreign_asset_references(&dir, &mut data)); + assert!(migrate_local_fonts_to_app_data(&dir, &mut data)); + assert!(!data.font_settings.custom_fonts[0].enabled); + + let _ = std::fs::remove_dir_all(root); +} + +#[cfg(target_os = "windows")] +#[test] +fn windows_identity_variants_of_current_app_data_are_not_foreign() { + let root = rehome_test_directory("win-identity").join("com.dmnote.desktop"); + std::fs::create_dir_all(root.join("fonts")).unwrap(); + let missing = root.join("fonts").join("gone.ttf"); + let plain = missing.to_string_lossy().into_owned(); + + // 대소문자·verbatim·file URL 표기 차이는 전부 "현재 appData"로 인식되어야 함 + let lowercase = plain.to_ascii_lowercase(); + let verbatim = format!(r"\\?\{plain}"); + let url = url::Url::from_file_path(&missing).unwrap().to_string(); + for raw in [lowercase, verbatim, url] { + assert!( + !super::is_foreign_portable_asset_reference(&root, &raw), + "현재 appData 표기 변형이 외래로 오판됨: {raw}" + ); + } + + // 다른 사용자 경로는 외래 판정 유지 + let other = r"C:\Users\dmnote-other\AppData\Roaming\com.dmnote.desktop\fonts\gone.ttf"; + assert!(super::is_foreign_portable_asset_reference(&root, other)); + + let _ = std::fs::remove_dir_all(root.parent().unwrap()); +} + +#[test] +fn corrupt_entries_with_foreign_managed_paths_survive_recovery() { + // 사운드: 외래 관리 경로 키 + 손상 값 → 삭제 대신 기본 메타데이터로 재건 + let foreign_sound = r"C:\Users\me\AppData\Roaming\com.dmnote.desktop\sounds\key.wav"; + let posix_sound = "/Users/me/Library/Application Support/com.dmnote.desktop/sounds/k2.wav"; + let mut entries = serde_json::Map::new(); + entries.insert(foreign_sound.to_string(), json!(42)); + entries.insert(posix_sound.to_string(), json!(42)); + entries.insert("not-a-path".to_string(), json!(42)); + + let recovered = + super::recover_sound_library_entries(&serde_json::Value::Object(entries)).unwrap(); + let recovered = recovered.as_object().unwrap(); + assert!(recovered.contains_key(foreign_sound)); + assert!(recovered.contains_key(posix_sound)); + assert!(!recovered.contains_key("not-a-path")); + + // 폰트: 외래 관리 경로 + enabled 손상 → 항목 삭제 대신 비활성 복구 + let mut font = serde_json::to_value(CustomFont { + id: "font".to_string(), + font_type: FontType::Local, + name: "Font".to_string(), + display_name: "Font".to_string(), + enabled: true, + local_path: Some(r"C:\Users\me\AppData\Roaming\com.dmnote.desktop\fonts\p.ttf".to_string()), + css_content: None, + weight_ranges: Vec::new(), + }) + .unwrap(); + font.as_object_mut() + .unwrap() + .insert("enabled".to_string(), json!(42)); + + let candidate = super::recover_local_font_enabled(&font).unwrap(); + assert_eq!( + candidate.as_object().unwrap().get("enabled"), + Some(&serde_json::Value::Bool(false)) + ); +} + +#[test] +fn sound_library_rekey_preserves_metadata_and_conflicts() { + let dir = rehome_test_directory("sound-library"); + let local_path = dir.join("sounds").join("library.wav"); + std::fs::create_dir_all(local_path.parent().unwrap()).unwrap(); + std::fs::write(&local_path, b"sound").unwrap(); + let foreign = r"C:\Users\me\com.dmnote.desktop\sounds\library.wav".to_string(); + let mut metadata = SoundLibraryEntry { + display_name: Some("보존 이름".to_string()), + trim_start_ratio: Some(0.25), + ..SoundLibraryEntry::default() + }; + let local_key = local_path.to_string_lossy().into_owned(); + + let mut data = AppStoreData::default(); + data.sound_library.insert(foreign.clone(), metadata.clone()); + assert!(rehome_foreign_asset_references(&dir, &mut data)); + assert_eq!(data.sound_library[&local_key], metadata); + assert!(!data.sound_library.contains_key(&foreign)); + + data.sound_library.insert(foreign.clone(), metadata.clone()); + assert!(rehome_foreign_asset_references(&dir, &mut data)); + assert_eq!(data.sound_library.len(), 1); + + metadata.display_name = Some("다른 이름".to_string()); + data.sound_library.insert(foreign.clone(), metadata.clone()); + assert!(!rehome_foreign_asset_references(&dir, &mut data)); + assert_eq!(data.sound_library[&foreign], metadata); + assert_ne!(data.sound_library[&local_key], metadata); + + let alternate_foreign = r"\\server\share\com.dmnote.desktop\sounds\library.wav".to_string(); + let mut candidates_only = AppStoreData::default(); + candidates_only + .sound_library + .insert(foreign.clone(), SoundLibraryEntry::default()); + candidates_only + .sound_library + .insert(alternate_foreign.clone(), metadata.clone()); + assert!(!rehome_foreign_asset_references(&dir, &mut candidates_only)); + assert!(candidates_only.sound_library.contains_key(&foreign)); + assert!(candidates_only + .sound_library + .contains_key(&alternate_foreign)); + assert!(!candidates_only.sound_library.contains_key(&local_key)); + + let _ = std::fs::remove_dir_all(dir); +} From 4da0e8dcc678988e2712d1fcd8b7a29827d82fab Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 06:14:29 +0900 Subject: [PATCH 004/245] =?UTF-8?q?test:=20ASIO=20=EC=B6=9C=EB=A0=A5=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=20=EA=B2=80=EC=A6=9D=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/audio/engine.rs | 224 +++++++++------------------ src-tauri/src/audio/engine/asio.rs | 233 +++++++++++++++++++++++++++++ 2 files changed, 307 insertions(+), 150 deletions(-) create mode 100644 src-tauri/src/audio/engine/asio.rs diff --git a/src-tauri/src/audio/engine.rs b/src-tauri/src/audio/engine.rs index 6d01129c3..64724da10 100644 --- a/src-tauri/src/audio/engine.rs +++ b/src-tauri/src/audio/engine.rs @@ -18,8 +18,6 @@ use std::{error::Error, fmt}; use anyhow::{Context, Result}; #[cfg(debug_assertions)] use log::debug; -#[cfg(all(windows, feature = "asio-backend"))] -use log::info; use log::warn; use parking_lot::RwLock; use rodio::{cpal, DeviceSinkBuilder, MixerDeviceSink, Source}; @@ -37,6 +35,13 @@ use symphonia::{ default::{get_codecs, get_probe}, }; +mod asio; + +use asio::{ + backend_available as asio_backend_available, list_drivers as list_asio_drivers, + open_audio_sink as open_asio_audio_sink, +}; + #[cfg(debug_assertions)] const LATENCY_SUMMARY_INTERVAL: u64 = 50; @@ -213,8 +218,8 @@ impl KeySoundOutputBackend { driver_name, buffer_size, } => Self::Asio { - driver_name: driver_name.trim().to_string(), - buffer_size: buffer_size.filter(|size| *size > 0), + driver_name: asio::normalize_driver_name(&driver_name), + buffer_size: asio::normalize_buffer_size(buffer_size), }, } } @@ -752,10 +757,6 @@ impl Error for AudioSinkOpenError { } } -fn asio_backend_available() -> bool { - cfg!(all(windows, feature = "asio-backend")) -} - fn stream_error_callback( label: &'static str, ) -> ( @@ -1043,147 +1044,6 @@ fn open_system_device_audio_sink(id: &str, stored_name: &str) -> AudioSinkResult }) } -#[cfg(all(windows, feature = "asio-backend"))] -fn open_asio_audio_sink( - driver_name: &str, - buffer_size: Option, -) -> AudioSinkResult { - use cpal::traits::{DeviceTrait, HostTrait}; - - let driver_name = driver_name.trim(); - if driver_name.is_empty() { - return Err(AudioSinkOpenError::AsioDeviceNotFound); - } - - let host = cpal::host_from_id(cpal::HostId::Asio) - .map_err(|_| AudioSinkOpenError::AsioDeviceNotFound)?; - let devices = host - .output_devices() - .map_err(|_| AudioSinkOpenError::AsioDeviceNotFound)?; - - for device in devices { - let name = match device.description() { - Ok(description) => description.name().trim().to_string(), - Err(err) => { - warn!("[KeySound] failed to read ASIO device name: {err}"); - continue; - } - }; - if name == driver_name { - return open_device_audio_sink(device, buffer_size); - } - } - - Err(AudioSinkOpenError::AsioDeviceNotFound) -} - -#[cfg(not(all(windows, feature = "asio-backend")))] -fn open_asio_audio_sink( - _driver_name: &str, - _buffer_size: Option, -) -> AudioSinkResult { - Err(AudioSinkOpenError::AsioUnavailableBuild) -} - -/// ASIO 기본 버퍼 크기(프레임). 미지정 시 이 값으로 고정 오픈 -#[cfg(all(windows, feature = "asio-backend"))] -const DEFAULT_ASIO_BUFFER_FRAMES: u32 = 64; - -#[cfg(all(windows, feature = "asio-backend"))] -fn open_device_audio_sink( - device: cpal::Device, - buffer_size: Option, -) -> AudioSinkResult { - // 미지정(None)이면 기본 64로 오픈 - let frames = buffer_size - .filter(|frames| *frames > 0) - .unwrap_or(DEFAULT_ASIO_BUFFER_FRAMES); - - // 고정 버퍼 지정 시 그 값 그대로 오픈 - try_open_asio_sink(device, frames) -} - -#[cfg(all(windows, feature = "asio-backend"))] -fn try_open_asio_sink(device: cpal::Device, buffer_size: u32) -> AudioSinkResult { - use cpal::traits::DeviceTrait; - - // 일부 드라이버(Realtek ASIO 등)는 클럭 미확립 상태에서 sample rate 0을 보고함. - // rodio from_device 내부의 NonZero unwrap 패닉(release는 abort) 방지를 위한 사전 검증 - let default_config = device - .default_output_config() - .map_err(|err| AudioSinkOpenError::OpenFailed(anyhow::Error::new(err)))?; - if default_config.sample_rate() == 0 || default_config.channels() == 0 { - return Err(AudioSinkOpenError::OpenFailed(anyhow::anyhow!( - "ASIO 드라이버가 유효한 샘플레이트/채널 구성을 보고하지 않았습니다" - ))); - } - - let (error, callback) = stream_error_callback("ASIO stream"); - - let builder = DeviceSinkBuilder::from_device(device) - .map_err(|err| AudioSinkOpenError::OpenFailed(anyhow::Error::new(err)))? - .with_error_callback(callback); - - // 샘플레이트는 드라이버 현재값(default_output_config)을 그대로 사용 → ASIOSetSampleRate 회피. - // 버퍼는 명시 고정만 사용 - let sink = builder - .with_buffer_size(cpal::BufferSize::Fixed(buffer_size)) - .open_stream() - .map_err(|err| AudioSinkOpenError::OpenFailed(anyhow::Error::new(err)))?; - - let config = sink.config(); - info!( - "[KeySound] ASIO 스트림 오픈: 요청 버퍼={}, 적용 sample_rate={}Hz, buffer={:?}", - buffer_size, - config.sample_rate().get(), - config.buffer_size() - ); - - Ok(StreamHandler { sink, error }) -} - -#[cfg(all(windows, feature = "asio-backend"))] -fn list_asio_drivers() -> Vec { - use cpal::traits::{DeviceTrait, HostTrait}; - - let Ok(host) = cpal::host_from_id(cpal::HostId::Asio) else { - return Vec::new(); - }; - let Ok(devices) = host.output_devices() else { - return Vec::new(); - }; - - let mut names: Vec = devices - .filter_map(|device| { - let name = device - .description() - .ok() - .map(|description| description.name().trim().to_string()) - .filter(|name| !name.is_empty())?; - // 불량 드라이버 제외, 샘플레이트/채널 0 보고 - match device.default_output_config() { - Ok(config) if config.sample_rate() > 0 && config.channels() > 0 => Some(name), - Ok(_) => { - warn!("[KeySound] ASIO 드라이버 '{name}' 목록 제외: 유효하지 않은 샘플레이트/채널 보고"); - None - } - Err(err) => { - warn!("[KeySound] ASIO 드라이버 '{name}' 목록 제외: 기본 구성 조회 실패 ({err})"); - None - } - } - }) - .collect(); - names.sort(); - names.dedup(); - names -} - -#[cfg(not(all(windows, feature = "asio-backend")))] -fn list_asio_drivers() -> Vec { - Vec::new() -} - fn list_system_output_devices() -> Vec { use cpal::traits::{DeviceTrait, HostTrait}; @@ -1277,7 +1137,8 @@ mod output_backend_tests { }; use super::{ - open_initial_output_backend, open_system_device_audio_sink, startup_fallback_forgets, + asio_output_error_code, asio_output_error_message, open_initial_output_backend, + open_system_device_audio_sink, output_fallback_error, startup_fallback_forgets, switch_output_backend_with_notification, AudioSinkOpenError, KeySoundEngine, KeySoundOutputBackend, ERROR_CODE_ASIO_DEVICE_NOT_FOUND, ERROR_CODE_ASIO_OPEN_FAILED, ERROR_CODE_ASIO_UNAVAILABLE_BUILD, ERROR_CODE_DEFAULT_OPEN_FAILED, @@ -1299,6 +1160,69 @@ mod output_backend_tests { ); } + #[test] + fn asio_backend_normalizes_driver_name_and_zero_buffer() { + assert_eq!( + KeySoundOutputBackend::Asio { + driver_name: " Focusrite USB ASIO ".to_string(), + buffer_size: Some(0), + } + .normalized(), + KeySoundOutputBackend::Asio { + driver_name: "Focusrite USB ASIO".to_string(), + buffer_size: None, + } + ); + } + + #[test] + fn asio_error_contract_distinguishes_build_device_and_open_failures() { + let cases = [ + ( + AudioSinkOpenError::AsioUnavailableBuild, + ERROR_CODE_ASIO_UNAVAILABLE_BUILD, + "ASIO 미지원 빌드", + ), + ( + AudioSinkOpenError::AsioDeviceNotFound, + ERROR_CODE_ASIO_DEVICE_NOT_FOUND, + "ASIO 장치를 찾을 수 없습니다", + ), + ( + AudioSinkOpenError::DeviceNotFound, + ERROR_CODE_DEVICE_NOT_FOUND, + "출력 장치를 찾을 수 없습니다", + ), + ]; + + for (error, expected_code, expected_message) in cases { + assert_eq!(asio_output_error_code(&error), expected_code); + assert_eq!(asio_output_error_message(&error), expected_message); + } + + let open_error = AudioSinkOpenError::OpenFailed(anyhow::anyhow!("driver busy")); + assert_eq!( + asio_output_error_code(&open_error), + ERROR_CODE_ASIO_OPEN_FAILED + ); + assert_eq!( + asio_output_error_message(&open_error), + "ASIO 장치를 열 수 없어 기본 출력으로 재생합니다" + ); + + let backend = KeySoundOutputBackend::Asio { + driver_name: "Focusrite USB ASIO".to_string(), + buffer_size: Some(64), + }; + assert_eq!( + output_fallback_error(&backend, &open_error), + ( + "ASIO 장치를 열 수 없어 기본 출력으로 재생합니다".to_string(), + ERROR_CODE_ASIO_OPEN_FAILED.to_string(), + ) + ); + } + #[test] fn invalid_device_id_is_reported_as_not_found() { let result = open_system_device_audio_sink("invalid-device-id", "Speakers"); diff --git a/src-tauri/src/audio/engine/asio.rs b/src-tauri/src/audio/engine/asio.rs new file mode 100644 index 000000000..478bb17ab --- /dev/null +++ b/src-tauri/src/audio/engine/asio.rs @@ -0,0 +1,233 @@ +/// ASIO 기본 버퍼 크기(프레임). 미지정 시 이 값으로 고정 오픈 +#[cfg(any(test, all(windows, feature = "asio-backend")))] +const DEFAULT_BUFFER_FRAMES: u32 = 64; + +use super::{AudioSinkOpenError, AudioSinkResult, StreamHandler}; + +pub(super) const fn backend_available() -> bool { + cfg!(all(windows, feature = "asio-backend")) +} + +pub(super) fn normalize_driver_name(driver_name: &str) -> String { + driver_name.trim().to_string() +} + +pub(super) fn normalize_buffer_size(buffer_size: Option) -> Option { + buffer_size.filter(|frames| *frames > 0) +} + +#[cfg(any(test, all(windows, feature = "asio-backend")))] +fn effective_buffer_frames(buffer_size: Option) -> u32 { + normalize_buffer_size(buffer_size).unwrap_or(DEFAULT_BUFFER_FRAMES) +} + +#[cfg(any(test, all(windows, feature = "asio-backend")))] +fn is_valid_output_config(sample_rate: u32, channels: u16) -> bool { + sample_rate > 0 && channels > 0 +} + +#[cfg(any(test, all(windows, feature = "asio-backend")))] +fn normalize_driver_names(names: impl IntoIterator) -> Vec { + let mut names = names + .into_iter() + .map(|name| normalize_driver_name(&name)) + .filter(|name| !name.is_empty()) + .collect::>(); + names.sort(); + names.dedup(); + names +} + +#[cfg(all(windows, feature = "asio-backend"))] +pub(super) fn open_audio_sink( + driver_name: &str, + buffer_size: Option, +) -> AudioSinkResult { + use cpal::traits::{DeviceTrait, HostTrait}; + + let driver_name = normalize_driver_name(driver_name); + if driver_name.is_empty() { + return Err(AudioSinkOpenError::AsioDeviceNotFound); + } + + let host = cpal::host_from_id(cpal::HostId::Asio) + .map_err(|_| AudioSinkOpenError::AsioDeviceNotFound)?; + let devices = host + .output_devices() + .map_err(|_| AudioSinkOpenError::AsioDeviceNotFound)?; + + for device in devices { + let name = match device.description() { + Ok(description) => normalize_driver_name(description.name()), + Err(err) => { + log::warn!("[KeySound] failed to read ASIO device name: {err}"); + continue; + } + }; + if name == driver_name { + return open_device_audio_sink(device, buffer_size); + } + } + + Err(AudioSinkOpenError::AsioDeviceNotFound) +} + +#[cfg(not(all(windows, feature = "asio-backend")))] +pub(super) fn open_audio_sink( + _driver_name: &str, + _buffer_size: Option, +) -> AudioSinkResult { + Err(AudioSinkOpenError::AsioUnavailableBuild) +} + +#[cfg(all(windows, feature = "asio-backend"))] +fn open_device_audio_sink( + device: cpal::Device, + buffer_size: Option, +) -> AudioSinkResult { + try_open_asio_sink(device, effective_buffer_frames(buffer_size)) +} + +#[cfg(all(windows, feature = "asio-backend"))] +fn try_open_asio_sink(device: cpal::Device, buffer_size: u32) -> AudioSinkResult { + use cpal::traits::DeviceTrait; + use rodio::DeviceSinkBuilder; + + // 일부 드라이버(Realtek ASIO 등)는 클럭 미확립 상태에서 sample rate 0을 보고함. + // rodio from_device 내부의 NonZero unwrap 패닉(release는 abort) 방지를 위한 사전 검증 + let default_config = device + .default_output_config() + .map_err(|err| AudioSinkOpenError::OpenFailed(anyhow::Error::new(err)))?; + if !is_valid_output_config(default_config.sample_rate(), default_config.channels()) { + return Err(AudioSinkOpenError::OpenFailed(anyhow::anyhow!( + "ASIO 드라이버가 유효한 샘플레이트/채널 구성을 보고하지 않았습니다" + ))); + } + + let (error, callback) = super::stream_error_callback("ASIO stream"); + let sink = DeviceSinkBuilder::from_device(device) + .map_err(|err| AudioSinkOpenError::OpenFailed(anyhow::Error::new(err)))? + .with_error_callback(callback) + // 샘플레이트는 드라이버 현재값 유지, 버퍼만 명시 고정 + .with_buffer_size(cpal::BufferSize::Fixed(buffer_size)) + .open_stream() + .map_err(|err| AudioSinkOpenError::OpenFailed(anyhow::Error::new(err)))?; + + let config = sink.config(); + log::info!( + "[KeySound] ASIO 스트림 오픈: 요청 버퍼={}, 적용 sample_rate={}Hz, buffer={:?}", + buffer_size, + config.sample_rate().get(), + config.buffer_size() + ); + + Ok(StreamHandler { sink, error }) +} + +#[cfg(all(windows, feature = "asio-backend"))] +pub(super) fn list_drivers() -> Vec { + use cpal::traits::{DeviceTrait, HostTrait}; + + let Ok(host) = cpal::host_from_id(cpal::HostId::Asio) else { + return Vec::new(); + }; + let Ok(devices) = host.output_devices() else { + return Vec::new(); + }; + + normalize_driver_names(devices.filter_map(|device| { + let name = device + .description() + .ok() + .map(|description| normalize_driver_name(description.name())) + .filter(|name| !name.is_empty())?; + match device.default_output_config() { + Ok(config) if is_valid_output_config(config.sample_rate(), config.channels()) => { + Some(name) + } + Ok(_) => { + log::warn!("[KeySound] ASIO 드라이버 '{name}' 목록 제외: 유효하지 않은 샘플레이트/채널 보고"); + None + } + Err(err) => { + log::warn!("[KeySound] ASIO 드라이버 '{name}' 목록 제외: 기본 구성 조회 실패 ({err})"); + None + } + } + })) +} + +#[cfg(not(all(windows, feature = "asio-backend")))] +pub(super) fn list_drivers() -> Vec { + Vec::new() +} + +#[cfg(test)] +mod tests { + use super::{ + backend_available, effective_buffer_frames, is_valid_output_config, normalize_buffer_size, + normalize_driver_name, normalize_driver_names, DEFAULT_BUFFER_FRAMES, + }; + #[cfg(not(all(windows, feature = "asio-backend")))] + use super::{open_audio_sink, AudioSinkOpenError}; + + #[test] + fn driver_name_normalization_trims_only_outer_whitespace() { + assert_eq!( + normalize_driver_name(" Focusrite USB ASIO "), + "Focusrite USB ASIO" + ); + assert_eq!(normalize_driver_name(" "), ""); + } + + #[test] + fn buffer_normalization_rejects_zero_and_preserves_explicit_frames() { + assert_eq!(normalize_buffer_size(None), None); + assert_eq!(normalize_buffer_size(Some(0)), None); + assert_eq!(normalize_buffer_size(Some(128)), Some(128)); + } + + #[test] + fn effective_buffer_uses_default_only_for_missing_or_zero_values() { + assert_eq!(effective_buffer_frames(None), DEFAULT_BUFFER_FRAMES); + assert_eq!(effective_buffer_frames(Some(0)), DEFAULT_BUFFER_FRAMES); + assert_eq!(effective_buffer_frames(Some(256)), 256); + } + + #[test] + fn output_config_requires_nonzero_sample_rate_and_channels() { + assert!(is_valid_output_config(48_000, 2)); + assert!(!is_valid_output_config(0, 2)); + assert!(!is_valid_output_config(48_000, 0)); + } + + #[test] + fn driver_catalog_is_trimmed_sorted_deduplicated_and_nonempty() { + assert_eq!( + normalize_driver_names([ + " Zebra ASIO ".to_string(), + "Alpha ASIO".to_string(), + "".to_string(), + "Alpha ASIO ".to_string(), + ]), + vec!["Alpha ASIO".to_string(), "Zebra ASIO".to_string()] + ); + } + + #[test] + fn backend_capability_matches_compile_configuration() { + assert_eq!( + backend_available(), + cfg!(all(windows, feature = "asio-backend")) + ); + } + + #[cfg(not(all(windows, feature = "asio-backend")))] + #[test] + fn unsupported_build_returns_the_typed_unavailable_error() { + assert!(matches!( + open_audio_sink("Focusrite USB ASIO", Some(64)), + Err(AudioSinkOpenError::AsioUnavailableBuild) + )); + } +} From 8b41337cb6bd722261bd7128eb79218040376d53 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 06:18:14 +0900 Subject: [PATCH 005/245] =?UTF-8?q?refactor:=20=ED=82=A4=EC=9D=8C=20?= =?UTF-8?q?=EC=B6=9C=EB=A0=A5=20=EC=84=A4=EC=A0=95=20=EA=B4=80=EC=8B=AC?= =?UTF-8?q?=EC=82=AC=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/components/main/Settings.tsx | 261 +----------------- .../SettingsPanel/KeySoundOutputSettings.tsx | 120 ++++++++ .../SettingsPanel/keySoundOutputModel.test.ts | 97 +++++++ .../main/SettingsPanel/keySoundOutputModel.ts | 87 ++++++ .../hooks/app/useKeySoundOutput.test.tsx | 126 +++++++++ src/renderer/hooks/app/useKeySoundOutput.ts | 97 +++++++ 6 files changed, 532 insertions(+), 256 deletions(-) create mode 100644 src/renderer/components/main/SettingsPanel/KeySoundOutputSettings.tsx create mode 100644 src/renderer/components/main/SettingsPanel/keySoundOutputModel.test.ts create mode 100644 src/renderer/components/main/SettingsPanel/keySoundOutputModel.ts create mode 100644 src/renderer/hooks/app/useKeySoundOutput.test.tsx create mode 100644 src/renderer/hooks/app/useKeySoundOutput.ts diff --git a/src/renderer/components/main/Settings.tsx b/src/renderer/components/main/Settings.tsx index cd6fbc12f..8c4f81cf0 100644 --- a/src/renderer/components/main/Settings.tsx +++ b/src/renderer/components/main/Settings.tsx @@ -24,6 +24,7 @@ import type { SettingsPanelKey } from '@components/main/SettingsPanel/SettingsSi import ShortcutsPanelContent from '@components/main/SettingsPanel/ShortcutsPanelContent'; import PluginsPanelContent from '@components/main/SettingsPanel/PluginsPanelContent'; import CssPanelContent from '@components/main/SettingsPanel/CssPanelContent'; +import KeySoundOutputSettings from '@components/main/SettingsPanel/KeySoundOutputSettings'; import { FILL_DISABLED_CLASS, FILL_INTERACTIVE_CLASS, @@ -60,34 +61,10 @@ import { pluginApi } from '@api/modules/pluginApi'; import { keysApi } from '@api/modules/keysApi'; import { appApi, windowApi } from '@api/modules/appApi'; import { obsApi } from '@api/modules/obsApi'; -import { keySoundOutputApi } from '@api/modules/resourceApi'; -import type { - KeySoundOutputBackend, - KeySoundOutputDevices, - KeySoundOutputState, -} from '@api/modules/resourceApi'; import type { ObsStatus } from '@src/types/obs'; import { DEFAULT_OBS_PORT } from '@src/types/obs'; import { assertCanonicalEditorDocument } from '@src/types/editor'; -// ASIO 버퍼 크기 선택지(프레임). 게임 설정값과 맞춰야 ASIO 공존 가능. -const ASIO_BUFFER_SIZES = [64, 128, 256, 512, 1024] as const; -// 기본 버퍼 크기 (게임 기본값과 동일한 최저값) -const DEFAULT_ASIO_BUFFER = 64; - -// 설정 패널은 열 때마다 재마운트되므로, 마지막 출력 상태를 모듈에 캐시해 -// 재진입 시 '기본 장치 → 선택 장치' 드롭다운 깜빡임을 방지한다. -let cachedKeySoundOutput: KeySoundOutputState | null = null; -// null이면 목록 미로딩 -let cachedOutputDevices: KeySoundOutputDevices | null = null; - -const KEY_SOUND_DEVICE_PREFIX = 'device:'; -const KEY_SOUND_ASIO_PREFIX = 'asio:'; - -// 드롭다운 라벨용 축약 -const truncateDeviceName = (name: string) => - name.length > 16 ? `${name.slice(0, 16)}…` : name; - interface SettingsProps { showAlert: (msg: string, confirmText?: string) => void; showConfirm: ( @@ -192,130 +169,12 @@ const Settings = ({ }); const obsTogglingRef = useRef(false); - // 키음 출력 백엔드 (기본 장치 / 시스템 장치 / ASIO) — 캐시로 초기화해 재진입 깜빡임 방지 - const [keySoundOutput, setKeySoundOutputRaw] = - useState(cachedKeySoundOutput); - // 목록 로딩 완료(null 아님) 전에는 드롭다운을 잠그지 않음 (첫 마운트 비활성 깜빡임 방지) - const [outputDevices, setOutputDevices] = - useState(cachedOutputDevices); - const pendingKeySoundOutputRef = useRef(null); - const applyingKeySoundOutputRef = useRef(false); - - const setKeySoundOutput = (state: KeySoundOutputState) => { - cachedKeySoundOutput = state; - setKeySoundOutputRaw(state); - }; - useEffect(() => { if (!applyingResizeAnchorRef.current) { confirmedResizeAnchorRef.current = overlayResizeAnchor; } }, [overlayResizeAnchor]); - useEffect(() => { - let cancelled = false; - (async () => { - try { - const [devices, state] = await Promise.all([ - keySoundOutputApi.listDevices(), - keySoundOutputApi.getState(), - ]); - if (cancelled) return; - cachedOutputDevices = devices; - setOutputDevices(devices); - if ( - !applyingKeySoundOutputRef.current && - !pendingKeySoundOutputRef.current - ) { - setKeySoundOutput(state); - } - } catch (error) { - console.error('Failed to load key sound output state', error); - } - })(); - return () => { - cancelled = true; - }; - }, []); - - const enqueueKeySoundOutput = (backend: KeySoundOutputBackend) => { - pendingKeySoundOutputRef.current = backend; - setKeySoundOutputRaw((current) => { - if (!current) return current; - const optimistic = { - ...current, - requested: backend, - error: null, - errorCode: null, - }; - cachedKeySoundOutput = optimistic; - return optimistic; - }); - if (applyingKeySoundOutputRef.current) return; - - applyingKeySoundOutputRef.current = true; - void (async () => { - while (pendingKeySoundOutputRef.current) { - const requested = pendingKeySoundOutputRef.current; - pendingKeySoundOutputRef.current = null; - try { - const result = await keySoundOutputApi.setBackend(requested); - if (!pendingKeySoundOutputRef.current) setKeySoundOutput(result); - } catch (error) { - console.error('Failed to set key sound output backend', error); - if (!pendingKeySoundOutputRef.current) { - try { - const authoritative = await keySoundOutputApi.getState(); - if (!pendingKeySoundOutputRef.current) { - setKeySoundOutput(authoritative); - } - } catch (syncError) { - console.error('Failed to resync key sound output', syncError); - } - } - } - } - applyingKeySoundOutputRef.current = false; - })(); - }; - - const handleKeySoundOutputChange = (val: string) => { - if (val.startsWith(KEY_SOUND_ASIO_PREFIX)) { - enqueueKeySoundOutput({ - kind: 'asio', - driverName: val.slice(KEY_SOUND_ASIO_PREFIX.length), - // ASIO 선택 시 기본 버퍼 64 (게임과 동일하게 맞춰야 공존 가능) - bufferSize: DEFAULT_ASIO_BUFFER, - }); - return; - } - if (val.startsWith(KEY_SOUND_DEVICE_PREFIX)) { - const id = val.slice(KEY_SOUND_DEVICE_PREFIX.length); - const requested = keySoundOutput?.requested; - // 목록에 없는 장치는 저장된(분리된) 선택 항목뿐 - const name = - outputDevices?.system.find((item) => item.id === id)?.name ?? - (requested?.kind === 'device' && requested.id === id - ? requested.name - : null); - if (name === null) return; - enqueueKeySoundOutput({ kind: 'device', id, name }); - return; - } - enqueueKeySoundOutput({ kind: 'defaultDevice' }); - }; - - // ASIO 버퍼 크기 변경 (게임과 동일 버퍼로 맞춰야 ASIO 공존 가능) - const handleAsioBufferChange = (val: string) => { - const requested = keySoundOutput?.requested; - if (requested?.kind !== 'asio') return; - enqueueKeySoundOutput({ - kind: 'asio', - driverName: requested.driverName, - bufferSize: Number(val), - }); - }; - // Lenis smooth scroll 적용 (전역 설정 사용) const { scrollContainerRef } = useLenis(); @@ -989,50 +848,6 @@ const Settings = ({ }); }; - const requestedBackend = keySoundOutput?.requested; - const requestedAsioDriver = - requestedBackend?.kind === 'asio' ? requestedBackend.driverName : null; - const asioDrivers = outputDevices?.asio ?? []; - const visibleAsioDrivers = - requestedAsioDriver && !asioDrivers.includes(requestedAsioDriver) - ? [...asioDrivers, requestedAsioDriver] - : asioDrivers; - // 저장된 장치가 현재 목록에 없어도(분리됨) 선택 상태가 보이도록 병합 - const requestedDevice = - requestedBackend?.kind === 'device' ? requestedBackend : null; - const systemDevices = outputDevices?.system ?? []; - const visibleSystemDevices = - requestedDevice && !systemDevices.some((d) => d.id === requestedDevice.id) - ? [ - ...systemDevices, - { id: requestedDevice.id, name: requestedDevice.name }, - ] - : systemDevices; - // 같은 이름 장치는 순번으로 구분, 순번은 축약 밖에 붙여 항상 보이게 - const systemDeviceLabels = new Map(); - const nameCounts = new Map(); - for (const device of visibleSystemDevices) { - const seen = (nameCounts.get(device.name) ?? 0) + 1; - nameCounts.set(device.name, seen); - const base = truncateDeviceName(device.name); - systemDeviceLabels.set(device.id, seen > 1 ? `${base} (${seen})` : base); - } - const keySoundOutputValue = - requestedBackend?.kind === 'asio' - ? `${KEY_SOUND_ASIO_PREFIX}${requestedBackend.driverName}` - : requestedBackend?.kind === 'device' - ? `${KEY_SOUND_DEVICE_PREFIX}${requestedBackend.id}` - : 'defaultDevice'; - const requestedAsioBuffer = - keySoundOutput?.requested.kind === 'asio' - ? keySoundOutput.requested.bufferSize || DEFAULT_ASIO_BUFFER - : DEFAULT_ASIO_BUFFER; - const visibleAsioBuffers = ASIO_BUFFER_SIZES.some( - (size) => size === requestedAsioBuffer, - ) - ? ASIO_BUFFER_SIZES - : [...ASIO_BUFFER_SIZES, requestedAsioBuffer].sort((a, b) => a - b); - return (
{/* 키음 출력 설정 */} - - - {t('settings.keySoundOutput') || '키 사운드 출력'} -

- } - onMouseEnter={() => hoverPreview('keySoundOutput')} - onMouseLeave={() => hoverPreview(null)} - > - ({ - value: `${KEY_SOUND_DEVICE_PREFIX}${device.id}`, - label: - systemDeviceLabels.get(device.id) ?? - truncateDeviceName(device.name), - })), - ...visibleAsioDrivers.map((name) => ({ - value: `${KEY_SOUND_ASIO_PREFIX}${name}`, - label: `ASIO: ${truncateDeviceName(name)}`, - })), - ]} - value={keySoundOutputValue} - onChange={handleKeySoundOutputChange} - placeholder={ - t('settings.keySoundOutputDefault') || '기본 재생 장치' - } - align="right" - widthClass="max-w-[160px]" - disabled={ - outputDevices !== null && - visibleSystemDevices.length + visibleAsioDrivers.length === - 0 - } - /> -
- - {t('settings.keySoundOutputBuffer') || 'ASIO 버퍼 크기'} -

- } - > - ({ - value: String(size), - label: String(size), - }))} - value={String(requestedAsioBuffer)} - onChange={handleAsioBufferChange} - placeholder={String(DEFAULT_ASIO_BUFFER)} - align="right" - disabled={keySoundOutput?.requested.kind !== 'asio'} - /> -
-
+ hoverPreview('keySoundOutput')} + onMouseLeave={() => hoverPreview(null)} + /> {/* 기타 설정 */} diff --git a/src/renderer/components/main/SettingsPanel/KeySoundOutputSettings.tsx b/src/renderer/components/main/SettingsPanel/KeySoundOutputSettings.tsx new file mode 100644 index 000000000..1ba46a8ca --- /dev/null +++ b/src/renderer/components/main/SettingsPanel/KeySoundOutputSettings.tsx @@ -0,0 +1,120 @@ +import Dropdown from '@components/main/common/Dropdown'; +import { SettingCard, SettingRow } from '@components/main/common/SettingRow'; +import { useTranslation } from '@contexts/useTranslation'; +import { useKeySoundOutput } from '@hooks/app/useKeySoundOutput'; +import { + createKeySoundOutputViewModel, + DEFAULT_ASIO_BUFFER, + KEY_SOUND_ASIO_PREFIX, + KEY_SOUND_DEVICE_PREFIX, + truncateKeySoundDeviceName, +} from './keySoundOutputModel'; + +interface KeySoundOutputSettingsProps { + onMouseEnter: () => void; + onMouseLeave: () => void; +} + +const KeySoundOutputSettings = ({ + onMouseEnter, + onMouseLeave, +}: KeySoundOutputSettingsProps) => { + const { t } = useTranslation(); + const { state, devices, enqueue } = useKeySoundOutput(); + const viewModel = createKeySoundOutputViewModel(state, devices); + + const handleOutputChange = (value: string) => { + if (value.startsWith(KEY_SOUND_ASIO_PREFIX)) { + enqueue({ + kind: 'asio', + driverName: value.slice(KEY_SOUND_ASIO_PREFIX.length), + bufferSize: DEFAULT_ASIO_BUFFER, + }); + return; + } + if (value.startsWith(KEY_SOUND_DEVICE_PREFIX)) { + const id = value.slice(KEY_SOUND_DEVICE_PREFIX.length); + const device = viewModel.visibleSystemDevices.find( + (candidate) => candidate.id === id, + ); + if (!device) return; + enqueue({ kind: 'device', id, name: device.name }); + return; + } + enqueue({ kind: 'defaultDevice' }); + }; + + const handleBufferChange = (value: string) => { + const requested = state?.requested; + if (requested?.kind !== 'asio') return; + enqueue({ + kind: 'asio', + driverName: requested.driverName, + bufferSize: Number(value), + }); + }; + + return ( + + + {t('settings.keySoundOutput') || '키 사운드 출력'} +

+ } + onMouseEnter={onMouseEnter} + onMouseLeave={onMouseLeave} + > + ({ + value: `${KEY_SOUND_DEVICE_PREFIX}${device.id}`, + label: + viewModel.systemDeviceLabels.get(device.id) ?? + truncateKeySoundDeviceName(device.name), + })), + ...viewModel.visibleAsioDrivers.map((name) => ({ + value: `${KEY_SOUND_ASIO_PREFIX}${name}`, + label: `ASIO: ${truncateKeySoundDeviceName(name)}`, + })), + ]} + value={viewModel.selectedValue} + onChange={handleOutputChange} + placeholder={t('settings.keySoundOutputDefault') || '기본 재생 장치'} + align="right" + widthClass="max-w-[160px]" + disabled={viewModel.selectionDisabled} + /> +
+ + {t('settings.keySoundOutputBuffer') || 'ASIO 버퍼 크기'} +

+ } + > + ({ + value: String(size), + label: String(size), + }))} + value={String(viewModel.requestedAsioBuffer)} + onChange={handleBufferChange} + placeholder={String(DEFAULT_ASIO_BUFFER)} + align="right" + disabled={!viewModel.asioSelected} + /> +
+
+ ); +}; + +export default KeySoundOutputSettings; diff --git a/src/renderer/components/main/SettingsPanel/keySoundOutputModel.test.ts b/src/renderer/components/main/SettingsPanel/keySoundOutputModel.test.ts new file mode 100644 index 000000000..dbfd3c87c --- /dev/null +++ b/src/renderer/components/main/SettingsPanel/keySoundOutputModel.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import type { + KeySoundOutputDevices, + KeySoundOutputState, +} from '@api/modules/resourceApi'; +import { + createKeySoundOutputViewModel, + DEFAULT_ASIO_BUFFER, + truncateKeySoundDeviceName, +} from './keySoundOutputModel'; + +const devices: KeySoundOutputDevices = { + defaultDevice: true, + system: [ + { id: 'first', name: 'Repeated output device' }, + { id: 'second', name: 'Repeated output device' }, + ], + asio: ['Focusrite USB ASIO'], +}; + +const state = ( + requested: KeySoundOutputState['requested'], +): KeySoundOutputState => ({ + requested, + effective: requested, + error: null, + errorCode: null, + asioAvailable: true, +}); + +describe('createKeySoundOutputViewModel', () => { + it('현재 목록에서 사라진 시스템 장치를 선택 항목으로 유지한다', () => { + const model = createKeySoundOutputViewModel( + state({ kind: 'device', id: 'missing', name: 'Detached DAC' }), + devices, + ); + + expect(model.visibleSystemDevices.at(-1)).toEqual({ + id: 'missing', + name: 'Detached DAC', + }); + expect(model.selectedValue).toBe('device:missing'); + }); + + it('현재 목록에서 사라진 ASIO 드라이버와 비표준 버퍼를 유지한다', () => { + const model = createKeySoundOutputViewModel( + state({ kind: 'asio', driverName: 'Legacy ASIO', bufferSize: 96 }), + devices, + ); + + expect(model.visibleAsioDrivers).toEqual([ + 'Focusrite USB ASIO', + 'Legacy ASIO', + ]); + expect(model.visibleAsioBuffers).toContain(96); + expect(model.requestedAsioBuffer).toBe(96); + expect(model.asioSelected).toBe(true); + }); + + it('동일 이름 장치를 순번으로 구분하고 긴 이름을 축약한다', () => { + const model = createKeySoundOutputViewModel( + state({ kind: 'defaultDevice' }), + devices, + ); + + expect(model.systemDeviceLabels.get('first')).toBe( + truncateKeySoundDeviceName('Repeated output device'), + ); + expect(model.systemDeviceLabels.get('second')).toBe( + `${truncateKeySoundDeviceName('Repeated output device')} (2)`, + ); + }); + + it('장치 로딩 완료 후 선택지가 없을 때만 선택을 잠근다', () => { + const requested = state({ kind: 'defaultDevice' }); + expect( + createKeySoundOutputViewModel(requested, null).selectionDisabled, + ).toBe(false); + expect( + createKeySoundOutputViewModel(requested, { + defaultDevice: true, + system: [], + asio: [], + }).selectionDisabled, + ).toBe(true); + }); + + it('ASIO 버퍼 미지정과 0을 기본 버퍼로 표시한다', () => { + for (const bufferSize of [undefined, null, 0]) { + const model = createKeySoundOutputViewModel( + state({ kind: 'asio', driverName: 'ASIO', bufferSize }), + devices, + ); + expect(model.requestedAsioBuffer).toBe(DEFAULT_ASIO_BUFFER); + } + }); +}); diff --git a/src/renderer/components/main/SettingsPanel/keySoundOutputModel.ts b/src/renderer/components/main/SettingsPanel/keySoundOutputModel.ts new file mode 100644 index 000000000..8559101a3 --- /dev/null +++ b/src/renderer/components/main/SettingsPanel/keySoundOutputModel.ts @@ -0,0 +1,87 @@ +import type { + KeySoundOutputDevices, + KeySoundOutputState, +} from '@api/modules/resourceApi'; + +export const ASIO_BUFFER_SIZES = [64, 128, 256, 512, 1024] as const; +export const DEFAULT_ASIO_BUFFER = 64; +export const KEY_SOUND_DEVICE_PREFIX = 'device:'; +export const KEY_SOUND_ASIO_PREFIX = 'asio:'; + +export const truncateKeySoundDeviceName = (name: string): string => + name.length > 16 ? `${name.slice(0, 16)}…` : name; + +export interface KeySoundOutputViewModel { + visibleSystemDevices: KeySoundOutputDevices['system']; + visibleAsioDrivers: string[]; + systemDeviceLabels: Map; + selectedValue: string; + requestedAsioBuffer: number; + visibleAsioBuffers: readonly number[]; + asioSelected: boolean; + selectionDisabled: boolean; +} + +export const createKeySoundOutputViewModel = ( + state: KeySoundOutputState | null, + devices: KeySoundOutputDevices | null, +): KeySoundOutputViewModel => { + const requestedBackend = state?.requested; + const requestedAsioDriver = + requestedBackend?.kind === 'asio' ? requestedBackend.driverName : null; + const asioDrivers = devices?.asio ?? []; + const visibleAsioDrivers = + requestedAsioDriver && !asioDrivers.includes(requestedAsioDriver) + ? [...asioDrivers, requestedAsioDriver] + : asioDrivers; + + const requestedDevice = + requestedBackend?.kind === 'device' ? requestedBackend : null; + const systemDevices = devices?.system ?? []; + const visibleSystemDevices = + requestedDevice && + !systemDevices.some((device) => device.id === requestedDevice.id) + ? [ + ...systemDevices, + { id: requestedDevice.id, name: requestedDevice.name }, + ] + : systemDevices; + + const systemDeviceLabels = new Map(); + const nameCounts = new Map(); + for (const device of visibleSystemDevices) { + const seen = (nameCounts.get(device.name) ?? 0) + 1; + nameCounts.set(device.name, seen); + const base = truncateKeySoundDeviceName(device.name); + systemDeviceLabels.set(device.id, seen > 1 ? `${base} (${seen})` : base); + } + + const selectedValue = + requestedBackend?.kind === 'asio' + ? `${KEY_SOUND_ASIO_PREFIX}${requestedBackend.driverName}` + : requestedBackend?.kind === 'device' + ? `${KEY_SOUND_DEVICE_PREFIX}${requestedBackend.id}` + : 'defaultDevice'; + const asioSelected = requestedBackend?.kind === 'asio'; + const requestedAsioBuffer = asioSelected + ? requestedBackend.bufferSize || DEFAULT_ASIO_BUFFER + : DEFAULT_ASIO_BUFFER; + const visibleAsioBuffers = ASIO_BUFFER_SIZES.some( + (size) => size === requestedAsioBuffer, + ) + ? ASIO_BUFFER_SIZES + : [...ASIO_BUFFER_SIZES, requestedAsioBuffer].sort((a, b) => a - b); + + return { + visibleSystemDevices, + visibleAsioDrivers, + systemDeviceLabels, + selectedValue, + requestedAsioBuffer, + visibleAsioBuffers, + asioSelected, + selectionDisabled: + devices !== null && + visibleSystemDevices.length + visibleAsioDrivers.length === 0, + }; +}; diff --git a/src/renderer/hooks/app/useKeySoundOutput.test.tsx b/src/renderer/hooks/app/useKeySoundOutput.test.tsx new file mode 100644 index 000000000..cd31dfc66 --- /dev/null +++ b/src/renderer/hooks/app/useKeySoundOutput.test.tsx @@ -0,0 +1,126 @@ +// @vitest-environment jsdom +import React, { act, useEffect } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { + KeySoundOutputBackend, + KeySoundOutputState, +} from '@api/modules/resourceApi'; +import { + useKeySoundOutput, + type KeySoundOutputController, +} from './useKeySoundOutput'; + +const apiMocks = vi.hoisted(() => ({ + listDevices: vi.fn(), + getState: vi.fn(), + setBackend: vi.fn(), +})); + +vi.mock('@api/modules/resourceApi', () => ({ + keySoundOutputApi: apiMocks, +})); + +const defaultBackend: KeySoundOutputBackend = { kind: 'defaultDevice' }; +const outputState = ( + requested: KeySoundOutputBackend, +): KeySoundOutputState => ({ + requested, + effective: requested, + error: null, + errorCode: null, + asioAvailable: true, +}); + +let latestController: KeySoundOutputController; + +const Harness = () => { + const controller = useKeySoundOutput(); + useEffect(() => { + latestController = controller; + }, [controller]); + return null; +}; + +describe('useKeySoundOutput', () => { + let host: HTMLDivElement; + let root: Root; + + beforeEach(async () => { + apiMocks.listDevices.mockReset().mockResolvedValue({ + defaultDevice: true, + system: [], + asio: [], + }); + apiMocks.getState + .mockReset() + .mockResolvedValue(outputState(defaultBackend)); + apiMocks.setBackend.mockReset(); + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + await act(async () => root.render()); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + vi.restoreAllMocks(); + }); + + it('적용 중 들어온 선택을 마지막 요청까지 순서대로 반영한다', async () => { + const firstBackend: KeySoundOutputBackend = { + kind: 'asio', + driverName: 'First ASIO', + bufferSize: 64, + }; + const latestBackend: KeySoundOutputBackend = { + kind: 'asio', + driverName: 'Latest ASIO', + bufferSize: 128, + }; + let resolveFirst!: (state: KeySoundOutputState) => void; + apiMocks.setBackend + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ) + .mockResolvedValueOnce(outputState(latestBackend)); + + act(() => latestController.enqueue(firstBackend)); + act(() => latestController.enqueue(latestBackend)); + expect(latestController.state?.requested).toEqual(latestBackend); + + await act(async () => resolveFirst(outputState(firstBackend))); + + expect(apiMocks.setBackend).toHaveBeenNthCalledWith(1, firstBackend); + expect(apiMocks.setBackend).toHaveBeenNthCalledWith(2, latestBackend); + expect(latestController.state).toEqual(outputState(latestBackend)); + }); + + it('적용 실패 시 백엔드 권위 상태를 다시 읽는다', async () => { + const failedBackend: KeySoundOutputBackend = { + kind: 'device', + id: 'missing', + name: 'Missing DAC', + }; + const authoritative = outputState(defaultBackend); + apiMocks.setBackend.mockRejectedValueOnce(new Error('open failed')); + apiMocks.getState.mockResolvedValueOnce(authoritative); + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + await act(async () => latestController.enqueue(failedBackend)); + + expect(apiMocks.getState).toHaveBeenCalledTimes(2); + expect(latestController.state).toEqual(authoritative); + expect(consoleError).toHaveBeenCalledWith( + 'Failed to set key sound output backend', + expect.any(Error), + ); + }); +}); diff --git a/src/renderer/hooks/app/useKeySoundOutput.ts b/src/renderer/hooks/app/useKeySoundOutput.ts new file mode 100644 index 000000000..f74fad5b7 --- /dev/null +++ b/src/renderer/hooks/app/useKeySoundOutput.ts @@ -0,0 +1,97 @@ +import { useEffect, useRef, useState } from 'react'; +import { keySoundOutputApi } from '@api/modules/resourceApi'; +import type { + KeySoundOutputBackend, + KeySoundOutputDevices, + KeySoundOutputState, +} from '@api/modules/resourceApi'; + +// 설정 패널 재진입 시 장치 선택 드롭다운 깜빡임 방지용 마지막 상태 +let cachedKeySoundOutput: KeySoundOutputState | null = null; +let cachedOutputDevices: KeySoundOutputDevices | null = null; + +export interface KeySoundOutputController { + state: KeySoundOutputState | null; + devices: KeySoundOutputDevices | null; + enqueue: (backend: KeySoundOutputBackend) => void; +} + +export const useKeySoundOutput = (): KeySoundOutputController => { + const [state, setStateRaw] = useState( + cachedKeySoundOutput, + ); + const [devices, setDevices] = useState( + cachedOutputDevices, + ); + const pendingRef = useRef(null); + const applyingRef = useRef(false); + + const setState = (next: KeySoundOutputState) => { + cachedKeySoundOutput = next; + setStateRaw(next); + }; + + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const [nextDevices, nextState] = await Promise.all([ + keySoundOutputApi.listDevices(), + keySoundOutputApi.getState(), + ]); + if (cancelled) return; + cachedOutputDevices = nextDevices; + setDevices(nextDevices); + if (!applyingRef.current && !pendingRef.current) { + setState(nextState); + } + } catch (error) { + console.error('Failed to load key sound output state', error); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + const enqueue = (backend: KeySoundOutputBackend) => { + pendingRef.current = backend; + setStateRaw((current) => { + if (!current) return current; + const optimistic = { + ...current, + requested: backend, + error: null, + errorCode: null, + }; + cachedKeySoundOutput = optimistic; + return optimistic; + }); + if (applyingRef.current) return; + + applyingRef.current = true; + void (async () => { + while (pendingRef.current) { + const requested = pendingRef.current; + pendingRef.current = null; + try { + const result = await keySoundOutputApi.setBackend(requested); + if (!pendingRef.current) setState(result); + } catch (error) { + console.error('Failed to set key sound output backend', error); + if (!pendingRef.current) { + try { + const authoritative = await keySoundOutputApi.getState(); + if (!pendingRef.current) setState(authoritative); + } catch (syncError) { + console.error('Failed to resync key sound output', syncError); + } + } + } + } + applyingRef.current = false; + })(); + }; + + return { state, devices, enqueue }; +}; From 8ce5dda8238f6c1dc7506fe615ad930cb0b25164 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 06:18:45 +0900 Subject: [PATCH 006/245] =?UTF-8?q?chore:=20=ED=94=8C=EB=9E=AB=ED=8F=BC=20?= =?UTF-8?q?import=20=EA=B2=BD=EA=B3=A0=EC=99=80=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=ED=8F=AC=EB=A7=B7=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/app.rs | 5 +++-- src-tauri/src/models/tests.rs | 1 - src-tauri/src/state/app_state/tests.rs | 1 - src-tauri/src/state/editor/tests.rs | 1 - src-tauri/src/state/editor_ops/tests.rs | 1 - src-tauri/src/state/migration/tests.rs | 1 - 6 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/app.rs b/src-tauri/src/app.rs index 4b86f633f..c50aa0d94 100644 --- a/src-tauri/src/app.rs +++ b/src-tauri/src/app.rs @@ -1,6 +1,7 @@ +#[cfg(target_os = "windows")] +use crate::models; use crate::{ - commands, compute_compensating_zoom, keyboard, models, services, - should_apply_compensating_zoom, state, + commands, compute_compensating_zoom, keyboard, services, should_apply_compensating_zoom, state, }; use anyhow::Result; use log::LevelFilter; diff --git a/src-tauri/src/models/tests.rs b/src-tauri/src/models/tests.rs index 8f76a6738..cc6dccf04 100644 --- a/src-tauri/src/models/tests.rs +++ b/src-tauri/src/models/tests.rs @@ -1,4 +1,3 @@ - use super::{ compact_canonical_rgba, note_border_representative_hex, scrub_removed_text_outline_fields, FadePosition, GradientSpec, GraphPosition, GraphStatType, GraphType, KeyCounterAlign, diff --git a/src-tauri/src/state/app_state/tests.rs b/src-tauri/src/state/app_state/tests.rs index bbb6c471b..bab3ccfa9 100644 --- a/src-tauri/src/state/app_state/tests.rs +++ b/src-tauri/src/state/app_state/tests.rs @@ -1,4 +1,3 @@ - use std::{ collections::{HashMap, HashSet}, sync::{ diff --git a/src-tauri/src/state/editor/tests.rs b/src-tauri/src/state/editor/tests.rs index e14c2f0a0..6763795dd 100644 --- a/src-tauri/src/state/editor/tests.rs +++ b/src-tauri/src/state/editor/tests.rs @@ -1,4 +1,3 @@ - use std::collections::HashMap; use crate::models::{ diff --git a/src-tauri/src/state/editor_ops/tests.rs b/src-tauri/src/state/editor_ops/tests.rs index e0e6397e3..f1245248c 100644 --- a/src-tauri/src/state/editor_ops/tests.rs +++ b/src-tauri/src/state/editor_ops/tests.rs @@ -1,4 +1,3 @@ - use crate::{ defaults::default_keys, models::{ diff --git a/src-tauri/src/state/migration/tests.rs b/src-tauri/src/state/migration/tests.rs index 7d301748f..7a393e30e 100644 --- a/src-tauri/src/state/migration/tests.rs +++ b/src-tauri/src/state/migration/tests.rs @@ -1,4 +1,3 @@ - use super::{ load_store_from_path, migrate_local_fonts_to_app_data, migrate_sound_library_enabled, normalize_state, parse_portable_asset_reference, recover_key_mapping_entries, From 877be7606a5ea235de40579ddf7c0f3ffd2cf118 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 06:24:29 +0900 Subject: [PATCH 007/245] =?UTF-8?q?refactor:=20=EB=B0=B0=EC=B9=98=20?= =?UTF-8?q?=EC=86=8D=EC=84=B1=20=ED=8C=A8=EB=84=90=20UI=20=EC=B1=85?= =?UTF-8?q?=EC=9E=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../batch/BatchPanelHeader.tsx | 110 ++ .../batch/BatchSelectionPanel.tsx | 1136 +---------------- .../batch/BatchSpecializedPanels.tsx | 817 ++++++++++++ .../PropertiesPanel/batch/batchPanelShared.ts | 226 ++++ 4 files changed, 1176 insertions(+), 1113 deletions(-) create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/batch/BatchPanelHeader.tsx create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSpecializedPanels.tsx create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/batch/batchPanelShared.ts diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchPanelHeader.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchPanelHeader.tsx new file mode 100644 index 000000000..f951ae2ce --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchPanelHeader.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import { PANEL_HEADER_CLASS } from '../panelChrome'; + +const RenameIcon = () => ( + +); + +interface BatchPanelHeaderProps { + totalCount: number; + selectedGroupInfo: { id: string; name: string; memberCount: number } | null; + isRenaming: boolean; + renameInputRef: React.RefObject; + renameValue: string; + setRenameValue: (value: string) => void; + renameCancelledRef: React.MutableRefObject; + handleRenameCommit: (value: string) => void; + handleRenameCancel: () => void; + handleRenameStart: () => void; + t: (key: string) => string | undefined; +} + +const BatchPanelHeader = ({ + totalCount, + selectedGroupInfo, + isRenaming, + renameInputRef, + renameValue, + setRenameValue, + renameCancelledRef, + handleRenameCommit, + handleRenameCancel, + handleRenameStart, + t, +}: BatchPanelHeaderProps) => ( +
+
+ {selectedGroupInfo ? ( + isRenaming ? ( + setRenameValue(event.target.value)} + onBlur={() => { + if (!renameCancelledRef.current) handleRenameCommit(renameValue); + renameCancelledRef.current = false; + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + (event.target as HTMLInputElement).blur(); + } else if (event.key === 'Escape') { + event.preventDefault(); + handleRenameCancel(); + } + }} + /> + ) : ( +
+ + {selectedGroupInfo.name} + + +
+ ) + ) : ( + + {t('propertiesPanel.multiSelection') || '다중 선택'} + + )} + {!selectedGroupInfo && ( + ({totalCount}) + )} +
+
+); + +export default BatchPanelHeader; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx index 2eb3e5dc7..02e6afc7d 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx @@ -5,18 +5,15 @@ import type { GraphItemPosition, GraphItemType, } from '@src/types/key/graphItems'; -import type { KnobItemPosition } from '@src/types/key/knobs'; -import { paintPropertyFields } from '@src/types/color'; import type { SelectedElement } from '@stores/grid/useGridSelectionStore'; import { useEditStatePreviewPublisher } from '@stores/grid/useEditStatePreviewStore'; -import { PANEL_ROOT_CLASS, PANEL_HEADER_CLASS } from '../panelChrome'; +import { PANEL_ROOT_CLASS } from '../panelChrome'; import { normalizeCounterSettings, createDefaultCounterSettings, } from '@src/types/key/keys'; import { PropertyRow, - PropertySection, NumberInput, ColorInput, Tabs, @@ -26,31 +23,21 @@ import { TABS, TabType, } from '../index'; -import BatchGeometrySection from './BatchGeometrySection'; import Checkbox from '@components/main/common/Checkbox'; import Dropdown from '@components/main/common/Dropdown'; import ColorPicker from '@components/main/Modal/content/pickers/ColorPicker'; import PopupExit from '@components/main/Modal/PopupExit'; import ImagePicker from '@components/main/Modal/content/pickers/ImagePicker'; import EditSessionBoundary from '../EditSessionBoundary'; -import type { ElementIdSelection } from '@hooks/pickers/useBatchElementBinding'; import { - patchActiveImageByTargets, - patchActiveTransparentByTargets, patchCounterAnimationEnabledByTargets, patchCounterEnabledByTargets, patchCounterLayoutByTargets, patchCounterTypographyByTargets, - patchPaintByTargets, - patchShadowByTargets, patchNotePaintByIds, patchCounterFillByTargets, - patchStylePropertyByTargets, - patchInactiveImageByTargets, - patchIdleTransparentByTargets, patchSoundEnabledByIds, patchSoundVolumeByIds, - patchSoundPathByIds, } from '@src/renderer/editor/runtime/elementOps'; import { reportElementOpError } from '@src/renderer/editor/runtime/elementIntent'; import { editGestureController } from '@src/renderer/editor/runtime/editGestureController'; @@ -66,19 +53,11 @@ import { resolveElementById } from '@src/renderer/editor/model/elementIdMap'; import { isNativeElementId } from '@src/renderer/editor/model/elementId'; import { captureEditorDocument } from '@src/renderer/editor/runtime/editorStateCoordinator'; import type { - EditorPaintPropertyPatchV1, - EditorPreviewStylePropertyPatchV1, - EditorStylePropertyPreviewPatchV1, - EditorShadowPropertyPatchV1, EditorNotePaintPropertyPatchV1, EditorCounterFillPropertyPatchV1, } from '@src/types/editor'; import { projectNotePaintPatch } from '@src/types/key/notePaint'; -import { - previewBatchGraphColor, - previewBatchPaint, - previewBatchStyleProperty, -} from '../previewPatchForwarders'; +import { previewBatchGraphColor } from '../previewPatchForwarders'; import { hexWithAlphaPercent, parseAlphaPercent, @@ -86,326 +65,22 @@ import { } from '@utils/color/colorUtils'; import type { BatchElementPropertyUpdate } from '../types'; import { useBatchNotePaint, type BatchNoteSurface } from './useBatchNotePaint'; - -const NATIVE_IMAGE_TYPES = ['key', 'stat', 'graph', 'knob'] as const; - -const createStylePropertyHandlers = ( - targets: readonly { - elementType: 'key' | 'stat' | 'graph' | 'knob'; - id: string; - }[], - selectedKeyType: string, - options: { settleGesture?: boolean } = { settleGesture: true }, -) => { - const stableTargets = - targets.length > 0 && - targets.every(({ id }) => id.length > 0 && isNativeElementId(id)) && - new Set(targets.map(({ id }) => id)).size === targets.length - ? targets - : null; - if (!stableTargets) { - return { - previewStyleProperty: undefined, - commitStyleProperty: undefined, - }; - } - return { - previewStyleProperty: (patch: EditorStylePropertyPreviewPatchV1) => - previewBatchStyleProperty(stableTargets, selectedKeyType, patch), - commitStyleProperty: (patch: EditorPreviewStylePropertyPatchV1) => { - const gestureId = options.settleGesture - ? editGestureController.activeGestureId() ?? undefined - : undefined; - const persisted = patchStylePropertyByTargets(stableTargets, patch, { - gestureId, - }); - if (options.settleGesture) { - editGestureController.settleCommit(persisted); - } - void persisted.catch(reportElementOpError); - }, - }; -}; - -// 표면별 허용 타깃 - font는 라벨 렌더러가 있는 키·스탯(active는 키만) -const paintRelevantTargets = < - T extends { elementType: 'key' | 'stat' | 'graph' | 'knob' }, ->( - targets: readonly T[], - patch: EditorPaintPropertyPatchV1, -): readonly T[] => { - const { active, surface } = paintPropertyFields(patch.property); - if (surface === 'font') { - return targets.filter(({ elementType }) => - active - ? elementType === 'key' - : elementType === 'key' || elementType === 'stat', - ); - } - return active - ? targets.filter( - ({ elementType }) => elementType === 'key' || elementType === 'knob', - ) - : targets; -}; - -const createPaintHandlers = ( - targets: readonly { - elementType: 'key' | 'stat' | 'graph' | 'knob'; - id: string; - }[], - selectedKeyType: string, -) => { - const stableTargets = (patch: EditorPaintPropertyPatchV1) => { - const relevant = paintRelevantTargets(targets, patch); - const stable = - relevant.length > 0 && - relevant.every(({ id }) => id.length > 0 && isNativeElementId(id)) && - new Set(relevant.map(({ id }) => id)).size === relevant.length; - return stable ? relevant : null; - }; - return { - previewPaint: (patch: EditorPaintPropertyPatchV1) => { - const stable = stableTargets(patch); - if (!stable) return; - previewBatchPaint(stable, selectedKeyType, patch); - }, - commitPaint: (patch: EditorPaintPropertyPatchV1) => { - const stable = stableTargets(patch); - if (!stable) return; - const gestureId = editGestureController.activeGestureId() ?? undefined; - const persisted = patchPaintByTargets(stable, patch, { gestureId }); - editGestureController.settleCommit(persisted); - void persisted.catch(reportElementOpError); - }, - }; -}; - -const createShadowCommitHandler = - ( - targets: readonly { - elementType: 'key' | 'stat' | 'knob'; - id: string; - }[], - ) => - (patch: EditorShadowPropertyPatchV1) => { - const relevant = - patch.property === 'activeShadow' - ? targets.filter(({ elementType }) => elementType !== 'stat') - : targets; - const stable = - relevant.length > 0 && - relevant.every(({ id }) => id.length > 0 && isNativeElementId(id)) && - new Set(relevant.map(({ id }) => id)).size === relevant.length; - if (!stable) { - return; - } - const gestureId = editGestureController.activeGestureId() ?? undefined; - const persisted = patchShadowByTargets(relevant, patch, { gestureId }); - editGestureController.settleCommit(persisted); - void persisted.catch(reportElementOpError); - }; - -const commitBoundInactiveImage = ( - selection: ElementIdSelection, - inactiveImage: string, -) => { - const targets = NATIVE_IMAGE_TYPES.flatMap((elementType) => - (selection[elementType] ?? []).map((id) => ({ elementType, id })), - ); - if (targets.length === 0) return; - const persisted = patchInactiveImageByTargets(targets, inactiveImage); - void persisted.catch(reportElementOpError); -}; - -const commitBoundActiveImage = ( - selection: ElementIdSelection, - activeImage: string, -) => { - const targets = (['key', 'knob'] as const).flatMap((elementType) => - (selection[elementType] ?? []).map((id) => ({ elementType, id })), - ); - if (targets.length === 0) return; - const persisted = patchActiveImageByTargets(targets, activeImage); - void persisted.catch(reportElementOpError); -}; - -const commitBoundIdleTransparent = ( - selection: ElementIdSelection, - idleTransparent: boolean, -) => { - const targets = NATIVE_IMAGE_TYPES.flatMap((elementType) => - (selection[elementType] ?? []).map((id) => ({ elementType, id })), - ); - if (targets.length === 0) return; - const persisted = patchIdleTransparentByTargets(targets, idleTransparent); - void persisted.catch(reportElementOpError); -}; - -const commitBoundActiveTransparent = ( - selection: ElementIdSelection, - activeTransparent: boolean, -) => { - const targets = (['key', 'knob'] as const).flatMap((elementType) => - (selection[elementType] ?? []).map((id) => ({ elementType, id })), - ); - if (targets.length === 0) return; - const persisted = patchActiveTransparentByTargets(targets, activeTransparent); - void persisted.catch(reportElementOpError); -}; - -const commitBoundSoundPath = ( - selection: ElementIdSelection, - soundPath: string, -) => { - const ids = selection.key ?? []; - if (ids.length === 0) return; - const persisted = patchSoundPathByIds(ids, soundPath); - void persisted.catch(reportElementOpError); -}; - -// 24 그리드를 12px로 렌더 - 스트로크 2.4가 화면상 1.2 -const RenameIcon: React.FC = () => ( - -); - -// ============================================================================ -// Shared batch panel header (group rename + summed count) -// ============================================================================ - -interface BatchPanelHeaderProps { - // native+plugin 합산 표시 개수 - totalCount: number; - selectedGroupInfo: { id: string; name: string; memberCount: number } | null; - isRenaming: boolean; - renameInputRef: React.RefObject; - renameValue: string; - setRenameValue: (value: string) => void; - renameCancelledRef: React.MutableRefObject; - handleRenameCommit: (value: string) => void; - handleRenameCancel: () => void; - handleRenameStart: () => void; - t: (key: string) => string | undefined; -} - -const BatchPanelHeader: React.FC = ({ - totalCount, - selectedGroupInfo, - isRenaming, - renameInputRef, - renameValue, - setRenameValue, - renameCancelledRef, - handleRenameCommit, - handleRenameCancel, - handleRenameStart, - t, -}) => ( -
-
- {selectedGroupInfo ? ( - isRenaming ? ( - setRenameValue(e.target.value)} - onBlur={() => { - if (!renameCancelledRef.current) { - handleRenameCommit(renameValue); - } - renameCancelledRef.current = false; - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - (e.target as HTMLInputElement).blur(); - } else if (e.key === 'Escape') { - e.preventDefault(); - handleRenameCancel(); - } - }} - /> - ) : ( -
- - {selectedGroupInfo.name} - - -
- ) - ) : ( - - {t('propertiesPanel.multiSelection') || '다중 선택'} - - )} - {!selectedGroupInfo && ( - ({totalCount}) - )} -
-
-); - -// ============================================================================ -// Mixed key-like + graph batch selection panel -// ============================================================================ - -type BatchPickerTarget = - | 'noteColor' - | 'glowColor' - | 'borderColor' - | 'fill' - | null; - -type MixedValueResult = { isMixed: boolean; value: T }; -type MixedValueGetter

= ( - getter: (pos: P) => T | undefined, - defaultValue: T, -) => MixedValueResult; - -interface KeyData { - index: number; - position: KeyPosition | undefined; - keyCode: string | null; - keyInfo: { globalKey: string; displayName: string } | null; -} - -interface BatchLocalColors { - fillIdle: string; - fillActive: string; -} +import BatchPanelHeader from './BatchPanelHeader'; +import { + commitBoundActiveImage, + commitBoundActiveTransparent, + commitBoundIdleTransparent, + commitBoundInactiveImage, + commitBoundSoundPath, + createPaintHandlers, + createShadowCommitHandler, + createStylePropertyHandlers, + type BatchLocalColors, + type BatchPickerTarget, + type KeyData, + type MixedValueGetter, + type MixedValueResult, +} from './batchPanelShared'; interface BatchKeyLikePanelProps { setPanelElement: (el: HTMLDivElement | null) => void; @@ -1595,773 +1270,8 @@ export const BatchKeyLikePanel: React.FC = ({ ); }; -// ============================================================================ -// Graph-only batch selection panel -// ============================================================================ - -interface BatchGraphOnlyPanelProps { - setPanelElement: (el: HTMLDivElement | null) => void; - // native+plugin 합산 개수 - 헤더 표시·분배 게이트 (미전달 시 native 개수) - totalCount?: number; - selectedGraphElements: SelectedElement[]; - selectedGroupInfo: { id: string; name: string; memberCount: number } | null; - isRenaming: boolean; - renameInputRef: React.RefObject; - renameValue: string; - setRenameValue: (value: string) => void; - renameCancelledRef: React.MutableRefObject; - handleRenameCommit: (value: string) => void; - handleRenameCancel: () => void; - handleRenameStart: () => void; - handleBatchAlign: ( - direction: 'left' | 'centerH' | 'right' | 'top' | 'centerV' | 'bottom', - ) => void; - handleBatchDistribute: (direction: 'horizontal' | 'vertical') => void; - handleBatchSpacing: ( - spacing: number, - options?: { gestureId?: string; deferSave?: boolean }, - ) => void; - handleBatchSpacingPreview: (spacing: number) => void; - handleBatchSpacingCommit: ( - spacing: number, - options?: { gestureId?: string; deferSave?: boolean }, - ) => void; - getBatchSpacingValue: () => MixedValueResult; - handleBatchResize: (dimension: 'width' | 'height', value: number) => void; - handleBatchResizePreview: ( - dimension: 'width' | 'height', - value: number, - ) => void; - onElementPropertyCommit?: ( - updates: BatchElementPropertyUpdate, - options?: { gestureId?: string }, - ) => void; - handleGraphBatchSharedSetting: (updates: Partial) => void; - getMixedValueGraphs: MixedValueGetter; - getMixedValueGraphsAsKey: MixedValueGetter; - getSelectedGraphsData: () => KeyData[]; - batchScrollRefFor: (tab: TabType) => (node: HTMLDivElement | null) => void; - batchImageButtonRef: React.RefObject; - showBatchImagePicker: boolean; - setShowBatchImagePicker: (value: boolean) => void; - panelElement: HTMLDivElement | null; - useCustomCSS: boolean; - selectedKeyType: string; - t: (key: string) => string | undefined; -} - -export const BatchGraphOnlyPanel: React.FC = ({ - setPanelElement, - totalCount, - selectedGraphElements, - selectedGroupInfo, - isRenaming, - renameInputRef, - renameValue, - setRenameValue, - renameCancelledRef, - handleRenameCommit, - handleRenameCancel, - handleRenameStart, - handleBatchAlign, - handleBatchDistribute, - handleBatchSpacing, - handleBatchSpacingPreview, - handleBatchSpacingCommit, - getBatchSpacingValue, - handleBatchResize, - handleBatchResizePreview, - onElementPropertyCommit, - handleGraphBatchSharedSetting, - getMixedValueGraphs, - getMixedValueGraphsAsKey, - getSelectedGraphsData, - batchScrollRefFor, - batchImageButtonRef, - showBatchImagePicker, - setShowBatchImagePicker, - panelElement, - useCustomCSS, - selectedKeyType, - t, -}) => { - // 이미지 피커 open 시점의 그래프 선택을 ID로 고정 - const graphImageBinding = useBatchElementBinding(showBatchImagePicker, () => - captureBatchElementBinding({ graph: selectedGraphElements }), - ); - const graphTransparencyBinding = captureBatchElementBinding({ - graph: selectedGraphElements, - }); - const { previewStyleProperty, commitStyleProperty } = - createStylePropertyHandlers( - selectedGraphElements.map(({ id }) => ({ - elementType: 'graph', - id, - })), - selectedKeyType, - ); - const { previewPaint, commitPaint } = createPaintHandlers( - selectedGraphElements.map(({ id }) => ({ - elementType: 'graph', - id, - })), - selectedKeyType, - ); - - const graphShapeOptions = [ - { label: t('propertiesPanel.graphShapeLine') || 'Line', value: 'line' }, - { label: t('propertiesPanel.graphShapeBar') || 'Bar', value: 'bar' }, - ]; - const graphTypeState = getMixedValueGraphs( - (pos) => pos.graphType || 'line', - 'line' as string, - ); - const showAvgLineState = getMixedValueGraphs( - (pos) => pos.showAvgLine ?? true, - true, - ); - const graphSpeedState = getMixedValueGraphs( - (pos) => Math.round(pos.graphSpeed || 1000), - 1000, - ); - const graphColorState = getMixedValueGraphs( - (pos) => pos.graphColor || '#86EFAC', - '#86EFAC', - ); - // 피커 칸은 hex와 알파를 따로 판단한다. 그래프 색은 rgba 문자열일 수 있다 - const graphColorMixed = { - hex: getMixedValueGraphs( - (pos) => toRgbHexColor(pos.graphColor || '#86EFAC'), - '', - ).isMixed, - alpha: getMixedValueGraphs( - (pos) => parseAlphaPercent(pos.graphColor || '#86EFAC'), - 100, - ).isMixed, - }; - const graphAnimationState = getMixedValueGraphs( - (pos) => pos.graphAnimationEnabled ?? true, - true, - ); - const hasLineGraph = getSelectedGraphsData().some( - (data) => - ((data.position as GraphItemPosition | undefined)?.graphType || - 'line') === 'line', - ); - const batchGraphSpacing = getBatchSpacingValue(); - - return ( -

-
- -
- -
-
- - - - {graphTypeState.isMixed ? ( - - Mixed - - ) : null} - - handleGraphBatchSharedSetting({ - graphType: value as GraphItemType, - }) - } - /> - - - {hasLineGraph && ( -
-

- {t('propertiesPanel.graphShowAverageLine') || - 'Show Average Line'} -

- - handleGraphBatchSharedSetting({ - showAvgLine: !showAvgLineState.value, - }) - } - /> -
- )} - - - {graphSpeedState.isMixed ? ( - - Mixed - - ) : null} - { - const clamped = Math.max(500, Math.min(5000, value)); - const snapped = Math.round(clamped / 100) * 100; - handleGraphBatchSharedSetting({ - graphSpeed: snapped, - }); - }} - min={500} - max={5000} - suffix="ms" - isMixed={graphSpeedState.isMixed} - /> - - - - {graphColorState.isMixed ? ( - - Mixed - - ) : null} - {}} - onPreview={(value) => - previewBatchGraphColor( - selectedGraphElements.map(({ id }) => id), - selectedKeyType, - value, - ) - } - onChangeComplete={(value) => - handleGraphBatchSharedSetting({ graphColor: value }) - } - onCancel={() => editGestureController.cancel()} - colorId={`graph-batch-color-${selectedKeyType}`} - panelElement={panelElement} - /> - - -
-

- {t('propertiesPanel.graphAnimation') || 'Graph Animation'} -

-
- {graphAnimationState.isMixed ? ( - - Mixed - - ) : null} - - handleGraphBatchSharedSetting({ - graphAnimationEnabled: !graphAnimationState.value, - }) - } - /> -
-
- - } - getMixedValue={getMixedValueGraphsAsKey} - getSelectedKeysData={getSelectedGraphsData} - handleBatchAlign={handleBatchAlign} - handleBatchDistribute={handleBatchDistribute} - handleBatchSpacing={handleBatchSpacing} - handleBatchSpacingPreview={handleBatchSpacingPreview} - handleBatchSpacingCommit={handleBatchSpacingCommit} - batchSpacing={batchGraphSpacing} - handleBatchResize={handleBatchResize} - handleBatchResizePreview={handleBatchResizePreview} - onElementPropertyCommit={onElementPropertyCommit} - showBatchImagePicker={showBatchImagePicker} - onToggleBatchImagePicker={() => - setShowBatchImagePicker(!showBatchImagePicker) - } - batchImageButtonRef={batchImageButtonRef} - panelElement={panelElement} - useCustomCSS={useCustomCSS} - t={t} - /> -
-
-
- - - {showBatchImagePicker && batchImageButtonRef.current ? ( - pos.inactiveImage, '').isMixed - ? '' - : getMixedValueGraphs((pos) => pos.inactiveImage, '').value - } - activeImage={ - getMixedValueGraphs((pos) => pos.activeImage, '').isMixed - ? '' - : getMixedValueGraphs((pos) => pos.activeImage, '').value - } - idleTransparent={ - getMixedValueGraphs((pos) => pos.idleTransparent, false).value - } - activeTransparent={ - getMixedValueGraphs((pos) => pos.activeTransparent, false).value - } - completionBinding={graphImageBinding.binding} - onIdleImageChange={(imageUrl: string) => { - commitBoundInactiveImage(graphImageBinding.selection, imageUrl); - }} - onIdleTransparentChange={(value: boolean) => { - commitBoundIdleTransparent( - graphTransparencyBinding.selection, - value, - ); - }} - onIdleImageReset={() => { - commitBoundInactiveImage(graphImageBinding.selection, ''); - }} - onClose={() => setShowBatchImagePicker(false)} - /> - ) : null} - -
- ); -}; - -// ============================================================================ -// Knob-only batch selection panel -// ============================================================================ - -interface BatchKnobOnlyPanelProps { - setPanelElement: (el: HTMLDivElement | null) => void; - // native+plugin 합산 개수 - 헤더 표시·분배 게이트 (미전달 시 native 개수) - totalCount?: number; - selectedKnobElements: SelectedElement[]; - selectedGroupInfo: { id: string; name: string; memberCount: number } | null; - isRenaming: boolean; - renameInputRef: React.RefObject; - renameValue: string; - setRenameValue: (value: string) => void; - renameCancelledRef: React.MutableRefObject; - handleRenameCommit: (value: string) => void; - handleRenameCancel: () => void; - handleRenameStart: () => void; - handleBatchAlign: ( - direction: 'left' | 'centerH' | 'right' | 'top' | 'centerV' | 'bottom', - ) => void; - handleBatchDistribute: (direction: 'horizontal' | 'vertical') => void; - handleBatchSpacing: ( - spacing: number, - options?: { gestureId?: string; deferSave?: boolean }, - ) => void; - handleBatchSpacingPreview: (spacing: number) => void; - handleBatchSpacingCommit: ( - spacing: number, - options?: { gestureId?: string; deferSave?: boolean }, - ) => void; - getBatchSpacingValue: () => MixedValueResult; - handleBatchResize: (dimension: 'width' | 'height', value: number) => void; - handleBatchResizePreview: ( - dimension: 'width' | 'height', - value: number, - ) => void; - onElementPropertyCommit?: ( - updates: BatchElementPropertyUpdate, - options?: { gestureId?: string }, - ) => void; - handleKnobBatchSharedSetting: (updates: Partial) => void; - getMixedValueKnobs: MixedValueGetter; - getMixedValueKnobsAsKey: MixedValueGetter; - getSelectedKnobsData: () => KeyData[]; - batchScrollRefFor: (tab: TabType) => (node: HTMLDivElement | null) => void; - batchImageButtonRef: React.RefObject; - showBatchImagePicker: boolean; - setShowBatchImagePicker: (value: boolean) => void; - panelElement: HTMLDivElement | null; - useCustomCSS: boolean; - selectedKeyType: string; - t: (key: string) => string | undefined; -} - -export const BatchKnobOnlyPanel: React.FC = ({ - setPanelElement, - totalCount, - selectedKnobElements, - selectedGroupInfo, - isRenaming, - renameInputRef, - renameValue, - setRenameValue, - renameCancelledRef, - handleRenameCommit, - handleRenameCancel, - handleRenameStart, - handleBatchAlign, - handleBatchDistribute, - handleBatchSpacing, - handleBatchSpacingPreview, - handleBatchSpacingCommit, - getBatchSpacingValue, - handleBatchResize, - handleBatchResizePreview, - onElementPropertyCommit, - handleKnobBatchSharedSetting, - getMixedValueKnobs, - getMixedValueKnobsAsKey, - getSelectedKnobsData, - batchScrollRefFor, - batchImageButtonRef, - showBatchImagePicker, - setShowBatchImagePicker, - panelElement, - useCustomCSS, - selectedKeyType, - t, -}) => { - // 이미지 피커 open 시점의 노브 선택을 ID로 고정 - const knobImageBinding = useBatchElementBinding(showBatchImagePicker, () => - captureBatchElementBinding({ knob: selectedKnobElements }), - ); - const knobTransparencyBinding = captureBatchElementBinding({ - knob: selectedKnobElements, - }); - const { previewStyleProperty, commitStyleProperty } = - createStylePropertyHandlers( - selectedKnobElements.map(({ id }) => ({ - elementType: 'knob', - id, - })), - selectedKeyType, - ); - const { previewPaint, commitPaint } = createPaintHandlers( - selectedKnobElements.map(({ id }) => ({ - elementType: 'knob', - id, - })), - selectedKeyType, - ); - const commitShadow = createShadowCommitHandler( - selectedKnobElements.map(({ id }) => ({ - elementType: 'knob', - id, - })), - ); - - const sensitivityState = getMixedValueKnobs( - (pos) => Number(pos.sensitivity ?? 1), - 1, - ); - const reverseState = getMixedValueKnobs((pos) => pos.reverse ?? false, false); - const batchKnobSpacing = getBatchSpacingValue(); - - return ( -
-
- -
- -
-
- - - - {sensitivityState.isMixed ? ( - - Mixed - - ) : null} - - handleKnobBatchSharedSetting({ - sensitivity: Math.max(0, value), - }) - } - suffix="×" - min={0} - max={100} - allowDecimal - decimalScale={2} - isMixed={sensitivityState.isMixed} - /> - - -
-

- {t('propertiesPanel.knobReverse') || '방향 반전'} -

-
- {reverseState.isMixed ? ( - - Mixed - - ) : null} - - handleKnobBatchSharedSetting({ - reverse: !reverseState.value, - }) - } - /> -
-
- - } - getMixedValue={getMixedValueKnobsAsKey} - getSelectedKeysData={getSelectedKnobsData} - handleBatchAlign={handleBatchAlign} - handleBatchDistribute={handleBatchDistribute} - handleBatchSpacing={handleBatchSpacing} - handleBatchSpacingPreview={handleBatchSpacingPreview} - handleBatchSpacingCommit={handleBatchSpacingCommit} - batchSpacing={batchKnobSpacing} - handleBatchResize={handleBatchResize} - handleBatchResizePreview={handleBatchResizePreview} - onElementPropertyCommit={onElementPropertyCommit} - showBatchImagePicker={showBatchImagePicker} - onToggleBatchImagePicker={() => - setShowBatchImagePicker(!showBatchImagePicker) - } - batchImageButtonRef={batchImageButtonRef} - panelElement={panelElement} - useCustomCSS={useCustomCSS} - t={t} - /> -
-
-
- - - {showBatchImagePicker && batchImageButtonRef.current ? ( - pos.inactiveImage, '').isMixed - ? '' - : getMixedValueKnobs((pos) => pos.inactiveImage, '').value - } - activeImage={ - getMixedValueKnobs((pos) => pos.activeImage, '').isMixed - ? '' - : getMixedValueKnobs((pos) => pos.activeImage, '').value - } - idleTransparent={ - getMixedValueKnobs((pos) => pos.idleTransparent, false).value - } - activeTransparent={ - getMixedValueKnobs((pos) => pos.activeTransparent, false).value - } - completionBinding={knobImageBinding.binding} - onIdleImageChange={(imageUrl: string) => { - commitBoundInactiveImage(knobImageBinding.selection, imageUrl); - }} - onActiveImageChange={(imageUrl: string) => { - commitBoundActiveImage(knobImageBinding.selection, imageUrl); - }} - onIdleTransparentChange={(value: boolean) => { - commitBoundIdleTransparent( - knobTransparencyBinding.selection, - value, - ); - }} - onActiveTransparentChange={(value: boolean) => { - commitBoundActiveTransparent( - knobTransparencyBinding.selection, - value, - ); - }} - onIdleImageReset={() => { - commitBoundInactiveImage(knobImageBinding.selection, ''); - }} - onActiveImageReset={() => { - commitBoundActiveImage(knobImageBinding.selection, ''); - }} - onClose={() => setShowBatchImagePicker(false)} - /> - ) : null} - -
- ); -}; - -// ============================================================================ -// Plugin-only batch selection panel (lightweight geometry) -// ============================================================================ - -interface BatchPluginOnlyPanelProps { - setPanelElement: (el: HTMLDivElement | null) => void; - // 플러그인 단독 다중 선택 개수 - totalCount: number; - selectedGroupInfo: { id: string; name: string; memberCount: number } | null; - isRenaming: boolean; - renameInputRef: React.RefObject; - renameValue: string; - setRenameValue: (value: string) => void; - renameCancelledRef: React.MutableRefObject; - handleRenameCommit: (value: string) => void; - handleRenameCancel: () => void; - handleRenameStart: () => void; - handleBatchAlign: ( - direction: 'left' | 'centerH' | 'right' | 'top' | 'centerV' | 'bottom', - ) => void; - handleBatchDistribute: (direction: 'horizontal' | 'vertical') => void; - handleBatchSpacing: ( - spacing: number, - options?: { gestureId?: string; deferSave?: boolean }, - ) => void; - handleBatchSpacingCommit: ( - spacing: number, - options?: { gestureId?: string; deferSave?: boolean }, - ) => void; - getBatchSpacingValue: () => MixedValueResult; - batchScrollRefFor: (tab: TabType) => (node: HTMLDivElement | null) => void; - t: (key: string) => string | undefined; -} - -// 플러그인 크기는 content-driven이라 resize 없이 정렬·분배·간격만 노출. -// 스타일 필드는 플러그인 스키마 소유라 배치 편집 대상이 아니다 -export const BatchPluginOnlyPanel: React.FC = ({ - setPanelElement, - totalCount, - selectedGroupInfo, - isRenaming, - renameInputRef, - renameValue, - setRenameValue, - renameCancelledRef, - handleRenameCommit, - handleRenameCancel, - handleRenameStart, - handleBatchAlign, - handleBatchDistribute, - handleBatchSpacing, - handleBatchSpacingCommit, - getBatchSpacingValue, - batchScrollRefFor, - t, -}) => { - const batchPluginSpacing = getBatchSpacingValue(); - - return ( -
-
- -
- -
-
- - - - - -
-
-
- ); -}; +export { + BatchGraphOnlyPanel, + BatchKnobOnlyPanel, + BatchPluginOnlyPanel, +} from './BatchSpecializedPanels'; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSpecializedPanels.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSpecializedPanels.tsx new file mode 100644 index 000000000..cbb86e210 --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSpecializedPanels.tsx @@ -0,0 +1,817 @@ +/* eslint-disable react-hooks/refs */ +import React from 'react'; +import type { KeyPosition } from '@src/types/key/keys'; +import type { + GraphItemPosition, + GraphItemType, +} from '@src/types/key/graphItems'; +import type { KnobItemPosition } from '@src/types/key/knobs'; +import type { SelectedElement } from '@stores/grid/useGridSelectionStore'; +import { PANEL_ROOT_CLASS } from '../panelChrome'; +import { + PropertyRow, + PropertySection, + NumberInput, + ColorInput, + BatchStyleTabContent, + TABS, + type TabType, +} from '../index'; +import BatchGeometrySection from './BatchGeometrySection'; +import Checkbox from '@components/main/common/Checkbox'; +import Dropdown from '@components/main/common/Dropdown'; +import PopupExit from '@components/main/Modal/PopupExit'; +import ImagePicker from '@components/main/Modal/content/pickers/ImagePicker'; +import EditSessionBoundary from '../EditSessionBoundary'; +import { + captureBatchElementBinding, + useBatchElementBinding, +} from '@hooks/pickers/useBatchElementBinding'; +import { editGestureController } from '@src/renderer/editor/runtime/editGestureController'; +import { previewBatchGraphColor } from '../previewPatchForwarders'; +import { parseAlphaPercent, toRgbHexColor } from '@utils/color/colorUtils'; +import type { BatchElementPropertyUpdate } from '../types'; +import BatchPanelHeader from './BatchPanelHeader'; +import { + commitBoundActiveImage, + commitBoundActiveTransparent, + commitBoundIdleTransparent, + commitBoundInactiveImage, + createPaintHandlers, + createShadowCommitHandler, + createStylePropertyHandlers, + type KeyData, + type MixedValueGetter, + type MixedValueResult, +} from './batchPanelShared'; + +// ============================================================================ +// Graph-only batch selection panel +// ============================================================================ + +interface BatchGraphOnlyPanelProps { + setPanelElement: (el: HTMLDivElement | null) => void; + // native+plugin 합산 개수 - 헤더 표시·분배 게이트 (미전달 시 native 개수) + totalCount?: number; + selectedGraphElements: SelectedElement[]; + selectedGroupInfo: { id: string; name: string; memberCount: number } | null; + isRenaming: boolean; + renameInputRef: React.RefObject; + renameValue: string; + setRenameValue: (value: string) => void; + renameCancelledRef: React.MutableRefObject; + handleRenameCommit: (value: string) => void; + handleRenameCancel: () => void; + handleRenameStart: () => void; + handleBatchAlign: ( + direction: 'left' | 'centerH' | 'right' | 'top' | 'centerV' | 'bottom', + ) => void; + handleBatchDistribute: (direction: 'horizontal' | 'vertical') => void; + handleBatchSpacing: ( + spacing: number, + options?: { gestureId?: string; deferSave?: boolean }, + ) => void; + handleBatchSpacingPreview: (spacing: number) => void; + handleBatchSpacingCommit: ( + spacing: number, + options?: { gestureId?: string; deferSave?: boolean }, + ) => void; + getBatchSpacingValue: () => MixedValueResult; + handleBatchResize: (dimension: 'width' | 'height', value: number) => void; + handleBatchResizePreview: ( + dimension: 'width' | 'height', + value: number, + ) => void; + onElementPropertyCommit?: ( + updates: BatchElementPropertyUpdate, + options?: { gestureId?: string }, + ) => void; + handleGraphBatchSharedSetting: (updates: Partial) => void; + getMixedValueGraphs: MixedValueGetter; + getMixedValueGraphsAsKey: MixedValueGetter; + getSelectedGraphsData: () => KeyData[]; + batchScrollRefFor: (tab: TabType) => (node: HTMLDivElement | null) => void; + batchImageButtonRef: React.RefObject; + showBatchImagePicker: boolean; + setShowBatchImagePicker: (value: boolean) => void; + panelElement: HTMLDivElement | null; + useCustomCSS: boolean; + selectedKeyType: string; + t: (key: string) => string | undefined; +} + +export const BatchGraphOnlyPanel: React.FC = ({ + setPanelElement, + totalCount, + selectedGraphElements, + selectedGroupInfo, + isRenaming, + renameInputRef, + renameValue, + setRenameValue, + renameCancelledRef, + handleRenameCommit, + handleRenameCancel, + handleRenameStart, + handleBatchAlign, + handleBatchDistribute, + handleBatchSpacing, + handleBatchSpacingPreview, + handleBatchSpacingCommit, + getBatchSpacingValue, + handleBatchResize, + handleBatchResizePreview, + onElementPropertyCommit, + handleGraphBatchSharedSetting, + getMixedValueGraphs, + getMixedValueGraphsAsKey, + getSelectedGraphsData, + batchScrollRefFor, + batchImageButtonRef, + showBatchImagePicker, + setShowBatchImagePicker, + panelElement, + useCustomCSS, + selectedKeyType, + t, +}) => { + // 이미지 피커 open 시점의 그래프 선택을 ID로 고정 + const graphImageBinding = useBatchElementBinding(showBatchImagePicker, () => + captureBatchElementBinding({ graph: selectedGraphElements }), + ); + const graphTransparencyBinding = captureBatchElementBinding({ + graph: selectedGraphElements, + }); + const { previewStyleProperty, commitStyleProperty } = + createStylePropertyHandlers( + selectedGraphElements.map(({ id }) => ({ + elementType: 'graph', + id, + })), + selectedKeyType, + ); + const { previewPaint, commitPaint } = createPaintHandlers( + selectedGraphElements.map(({ id }) => ({ + elementType: 'graph', + id, + })), + selectedKeyType, + ); + + const graphShapeOptions = [ + { label: t('propertiesPanel.graphShapeLine') || 'Line', value: 'line' }, + { label: t('propertiesPanel.graphShapeBar') || 'Bar', value: 'bar' }, + ]; + const graphTypeState = getMixedValueGraphs( + (pos) => pos.graphType || 'line', + 'line' as string, + ); + const showAvgLineState = getMixedValueGraphs( + (pos) => pos.showAvgLine ?? true, + true, + ); + const graphSpeedState = getMixedValueGraphs( + (pos) => Math.round(pos.graphSpeed || 1000), + 1000, + ); + const graphColorState = getMixedValueGraphs( + (pos) => pos.graphColor || '#86EFAC', + '#86EFAC', + ); + // 피커 칸은 hex와 알파를 따로 판단한다. 그래프 색은 rgba 문자열일 수 있다 + const graphColorMixed = { + hex: getMixedValueGraphs( + (pos) => toRgbHexColor(pos.graphColor || '#86EFAC'), + '', + ).isMixed, + alpha: getMixedValueGraphs( + (pos) => parseAlphaPercent(pos.graphColor || '#86EFAC'), + 100, + ).isMixed, + }; + const graphAnimationState = getMixedValueGraphs( + (pos) => pos.graphAnimationEnabled ?? true, + true, + ); + const hasLineGraph = getSelectedGraphsData().some( + (data) => + ((data.position as GraphItemPosition | undefined)?.graphType || + 'line') === 'line', + ); + const batchGraphSpacing = getBatchSpacingValue(); + + return ( +
+
+ +
+ +
+
+ + + + {graphTypeState.isMixed ? ( + + Mixed + + ) : null} + + handleGraphBatchSharedSetting({ + graphType: value as GraphItemType, + }) + } + /> + + + {hasLineGraph && ( +
+

+ {t('propertiesPanel.graphShowAverageLine') || + 'Show Average Line'} +

+ + handleGraphBatchSharedSetting({ + showAvgLine: !showAvgLineState.value, + }) + } + /> +
+ )} + + + {graphSpeedState.isMixed ? ( + + Mixed + + ) : null} + { + const clamped = Math.max(500, Math.min(5000, value)); + const snapped = Math.round(clamped / 100) * 100; + handleGraphBatchSharedSetting({ + graphSpeed: snapped, + }); + }} + min={500} + max={5000} + suffix="ms" + isMixed={graphSpeedState.isMixed} + /> + + + + {graphColorState.isMixed ? ( + + Mixed + + ) : null} + {}} + onPreview={(value) => + previewBatchGraphColor( + selectedGraphElements.map(({ id }) => id), + selectedKeyType, + value, + ) + } + onChangeComplete={(value) => + handleGraphBatchSharedSetting({ graphColor: value }) + } + onCancel={() => editGestureController.cancel()} + colorId={`graph-batch-color-${selectedKeyType}`} + panelElement={panelElement} + /> + + +
+

+ {t('propertiesPanel.graphAnimation') || 'Graph Animation'} +

+
+ {graphAnimationState.isMixed ? ( + + Mixed + + ) : null} + + handleGraphBatchSharedSetting({ + graphAnimationEnabled: !graphAnimationState.value, + }) + } + /> +
+
+ + } + getMixedValue={getMixedValueGraphsAsKey} + getSelectedKeysData={getSelectedGraphsData} + handleBatchAlign={handleBatchAlign} + handleBatchDistribute={handleBatchDistribute} + handleBatchSpacing={handleBatchSpacing} + handleBatchSpacingPreview={handleBatchSpacingPreview} + handleBatchSpacingCommit={handleBatchSpacingCommit} + batchSpacing={batchGraphSpacing} + handleBatchResize={handleBatchResize} + handleBatchResizePreview={handleBatchResizePreview} + onElementPropertyCommit={onElementPropertyCommit} + showBatchImagePicker={showBatchImagePicker} + onToggleBatchImagePicker={() => + setShowBatchImagePicker(!showBatchImagePicker) + } + batchImageButtonRef={batchImageButtonRef} + panelElement={panelElement} + useCustomCSS={useCustomCSS} + t={t} + /> +
+
+
+ + + {showBatchImagePicker && batchImageButtonRef.current ? ( + pos.inactiveImage, '').isMixed + ? '' + : getMixedValueGraphs((pos) => pos.inactiveImage, '').value + } + activeImage={ + getMixedValueGraphs((pos) => pos.activeImage, '').isMixed + ? '' + : getMixedValueGraphs((pos) => pos.activeImage, '').value + } + idleTransparent={ + getMixedValueGraphs((pos) => pos.idleTransparent, false).value + } + activeTransparent={ + getMixedValueGraphs((pos) => pos.activeTransparent, false).value + } + completionBinding={graphImageBinding.binding} + onIdleImageChange={(imageUrl: string) => { + commitBoundInactiveImage(graphImageBinding.selection, imageUrl); + }} + onIdleTransparentChange={(value: boolean) => { + commitBoundIdleTransparent( + graphTransparencyBinding.selection, + value, + ); + }} + onIdleImageReset={() => { + commitBoundInactiveImage(graphImageBinding.selection, ''); + }} + onClose={() => setShowBatchImagePicker(false)} + /> + ) : null} + +
+ ); +}; + +// ============================================================================ +// Knob-only batch selection panel +// ============================================================================ + +interface BatchKnobOnlyPanelProps { + setPanelElement: (el: HTMLDivElement | null) => void; + // native+plugin 합산 개수 - 헤더 표시·분배 게이트 (미전달 시 native 개수) + totalCount?: number; + selectedKnobElements: SelectedElement[]; + selectedGroupInfo: { id: string; name: string; memberCount: number } | null; + isRenaming: boolean; + renameInputRef: React.RefObject; + renameValue: string; + setRenameValue: (value: string) => void; + renameCancelledRef: React.MutableRefObject; + handleRenameCommit: (value: string) => void; + handleRenameCancel: () => void; + handleRenameStart: () => void; + handleBatchAlign: ( + direction: 'left' | 'centerH' | 'right' | 'top' | 'centerV' | 'bottom', + ) => void; + handleBatchDistribute: (direction: 'horizontal' | 'vertical') => void; + handleBatchSpacing: ( + spacing: number, + options?: { gestureId?: string; deferSave?: boolean }, + ) => void; + handleBatchSpacingPreview: (spacing: number) => void; + handleBatchSpacingCommit: ( + spacing: number, + options?: { gestureId?: string; deferSave?: boolean }, + ) => void; + getBatchSpacingValue: () => MixedValueResult; + handleBatchResize: (dimension: 'width' | 'height', value: number) => void; + handleBatchResizePreview: ( + dimension: 'width' | 'height', + value: number, + ) => void; + onElementPropertyCommit?: ( + updates: BatchElementPropertyUpdate, + options?: { gestureId?: string }, + ) => void; + handleKnobBatchSharedSetting: (updates: Partial) => void; + getMixedValueKnobs: MixedValueGetter; + getMixedValueKnobsAsKey: MixedValueGetter; + getSelectedKnobsData: () => KeyData[]; + batchScrollRefFor: (tab: TabType) => (node: HTMLDivElement | null) => void; + batchImageButtonRef: React.RefObject; + showBatchImagePicker: boolean; + setShowBatchImagePicker: (value: boolean) => void; + panelElement: HTMLDivElement | null; + useCustomCSS: boolean; + selectedKeyType: string; + t: (key: string) => string | undefined; +} + +export const BatchKnobOnlyPanel: React.FC = ({ + setPanelElement, + totalCount, + selectedKnobElements, + selectedGroupInfo, + isRenaming, + renameInputRef, + renameValue, + setRenameValue, + renameCancelledRef, + handleRenameCommit, + handleRenameCancel, + handleRenameStart, + handleBatchAlign, + handleBatchDistribute, + handleBatchSpacing, + handleBatchSpacingPreview, + handleBatchSpacingCommit, + getBatchSpacingValue, + handleBatchResize, + handleBatchResizePreview, + onElementPropertyCommit, + handleKnobBatchSharedSetting, + getMixedValueKnobs, + getMixedValueKnobsAsKey, + getSelectedKnobsData, + batchScrollRefFor, + batchImageButtonRef, + showBatchImagePicker, + setShowBatchImagePicker, + panelElement, + useCustomCSS, + selectedKeyType, + t, +}) => { + // 이미지 피커 open 시점의 노브 선택을 ID로 고정 + const knobImageBinding = useBatchElementBinding(showBatchImagePicker, () => + captureBatchElementBinding({ knob: selectedKnobElements }), + ); + const knobTransparencyBinding = captureBatchElementBinding({ + knob: selectedKnobElements, + }); + const { previewStyleProperty, commitStyleProperty } = + createStylePropertyHandlers( + selectedKnobElements.map(({ id }) => ({ + elementType: 'knob', + id, + })), + selectedKeyType, + ); + const { previewPaint, commitPaint } = createPaintHandlers( + selectedKnobElements.map(({ id }) => ({ + elementType: 'knob', + id, + })), + selectedKeyType, + ); + const commitShadow = createShadowCommitHandler( + selectedKnobElements.map(({ id }) => ({ + elementType: 'knob', + id, + })), + ); + + const sensitivityState = getMixedValueKnobs( + (pos) => Number(pos.sensitivity ?? 1), + 1, + ); + const reverseState = getMixedValueKnobs((pos) => pos.reverse ?? false, false); + const batchKnobSpacing = getBatchSpacingValue(); + + return ( +
+
+ +
+ +
+
+ + + + {sensitivityState.isMixed ? ( + + Mixed + + ) : null} + + handleKnobBatchSharedSetting({ + sensitivity: Math.max(0, value), + }) + } + suffix="×" + min={0} + max={100} + allowDecimal + decimalScale={2} + isMixed={sensitivityState.isMixed} + /> + + +
+

+ {t('propertiesPanel.knobReverse') || '방향 반전'} +

+
+ {reverseState.isMixed ? ( + + Mixed + + ) : null} + + handleKnobBatchSharedSetting({ + reverse: !reverseState.value, + }) + } + /> +
+
+ + } + getMixedValue={getMixedValueKnobsAsKey} + getSelectedKeysData={getSelectedKnobsData} + handleBatchAlign={handleBatchAlign} + handleBatchDistribute={handleBatchDistribute} + handleBatchSpacing={handleBatchSpacing} + handleBatchSpacingPreview={handleBatchSpacingPreview} + handleBatchSpacingCommit={handleBatchSpacingCommit} + batchSpacing={batchKnobSpacing} + handleBatchResize={handleBatchResize} + handleBatchResizePreview={handleBatchResizePreview} + onElementPropertyCommit={onElementPropertyCommit} + showBatchImagePicker={showBatchImagePicker} + onToggleBatchImagePicker={() => + setShowBatchImagePicker(!showBatchImagePicker) + } + batchImageButtonRef={batchImageButtonRef} + panelElement={panelElement} + useCustomCSS={useCustomCSS} + t={t} + /> +
+
+
+ + + {showBatchImagePicker && batchImageButtonRef.current ? ( + pos.inactiveImage, '').isMixed + ? '' + : getMixedValueKnobs((pos) => pos.inactiveImage, '').value + } + activeImage={ + getMixedValueKnobs((pos) => pos.activeImage, '').isMixed + ? '' + : getMixedValueKnobs((pos) => pos.activeImage, '').value + } + idleTransparent={ + getMixedValueKnobs((pos) => pos.idleTransparent, false).value + } + activeTransparent={ + getMixedValueKnobs((pos) => pos.activeTransparent, false).value + } + completionBinding={knobImageBinding.binding} + onIdleImageChange={(imageUrl: string) => { + commitBoundInactiveImage(knobImageBinding.selection, imageUrl); + }} + onActiveImageChange={(imageUrl: string) => { + commitBoundActiveImage(knobImageBinding.selection, imageUrl); + }} + onIdleTransparentChange={(value: boolean) => { + commitBoundIdleTransparent( + knobTransparencyBinding.selection, + value, + ); + }} + onActiveTransparentChange={(value: boolean) => { + commitBoundActiveTransparent( + knobTransparencyBinding.selection, + value, + ); + }} + onIdleImageReset={() => { + commitBoundInactiveImage(knobImageBinding.selection, ''); + }} + onActiveImageReset={() => { + commitBoundActiveImage(knobImageBinding.selection, ''); + }} + onClose={() => setShowBatchImagePicker(false)} + /> + ) : null} + +
+ ); +}; + +// ============================================================================ +// Plugin-only batch selection panel (lightweight geometry) +// ============================================================================ + +interface BatchPluginOnlyPanelProps { + setPanelElement: (el: HTMLDivElement | null) => void; + // 플러그인 단독 다중 선택 개수 + totalCount: number; + selectedGroupInfo: { id: string; name: string; memberCount: number } | null; + isRenaming: boolean; + renameInputRef: React.RefObject; + renameValue: string; + setRenameValue: (value: string) => void; + renameCancelledRef: React.MutableRefObject; + handleRenameCommit: (value: string) => void; + handleRenameCancel: () => void; + handleRenameStart: () => void; + handleBatchAlign: ( + direction: 'left' | 'centerH' | 'right' | 'top' | 'centerV' | 'bottom', + ) => void; + handleBatchDistribute: (direction: 'horizontal' | 'vertical') => void; + handleBatchSpacing: ( + spacing: number, + options?: { gestureId?: string; deferSave?: boolean }, + ) => void; + handleBatchSpacingCommit: ( + spacing: number, + options?: { gestureId?: string; deferSave?: boolean }, + ) => void; + getBatchSpacingValue: () => MixedValueResult; + batchScrollRefFor: (tab: TabType) => (node: HTMLDivElement | null) => void; + t: (key: string) => string | undefined; +} + +// 플러그인 크기는 content-driven이라 resize 없이 정렬·분배·간격만 노출. +// 스타일 필드는 플러그인 스키마 소유라 배치 편집 대상이 아니다 +export const BatchPluginOnlyPanel: React.FC = ({ + setPanelElement, + totalCount, + selectedGroupInfo, + isRenaming, + renameInputRef, + renameValue, + setRenameValue, + renameCancelledRef, + handleRenameCommit, + handleRenameCancel, + handleRenameStart, + handleBatchAlign, + handleBatchDistribute, + handleBatchSpacing, + handleBatchSpacingCommit, + getBatchSpacingValue, + batchScrollRefFor, + t, +}) => { + const batchPluginSpacing = getBatchSpacingValue(); + + return ( +
+
+ +
+ +
+
+ + + + + +
+
+
+ ); +}; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/batchPanelShared.ts b/src/renderer/components/main/Grid/PropertiesPanel/batch/batchPanelShared.ts new file mode 100644 index 000000000..7cdc72e94 --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/batchPanelShared.ts @@ -0,0 +1,226 @@ +import { paintPropertyFields } from '@src/types/color'; +import type { KeyPosition } from '@src/types/key/keys'; +import type { ElementIdSelection } from '@hooks/pickers/useBatchElementBinding'; +import { + patchActiveImageByTargets, + patchActiveTransparentByTargets, + patchInactiveImageByTargets, + patchIdleTransparentByTargets, + patchPaintByTargets, + patchShadowByTargets, + patchSoundPathByIds, + patchStylePropertyByTargets, +} from '@src/renderer/editor/runtime/elementOps'; +import { reportElementOpError } from '@src/renderer/editor/runtime/elementIntent'; +import { editGestureController } from '@src/renderer/editor/runtime/editGestureController'; +import { isNativeElementId } from '@src/renderer/editor/model/elementId'; +import type { + EditorPaintPropertyPatchV1, + EditorPreviewStylePropertyPatchV1, + EditorShadowPropertyPatchV1, + EditorStylePropertyPreviewPatchV1, +} from '@src/types/editor'; +import { + previewBatchPaint, + previewBatchStyleProperty, +} from '../previewPatchForwarders'; + +const NATIVE_IMAGE_TYPES = ['key', 'stat', 'graph', 'knob'] as const; + +export const createStylePropertyHandlers = ( + targets: readonly { + elementType: 'key' | 'stat' | 'graph' | 'knob'; + id: string; + }[], + selectedKeyType: string, + options: { settleGesture?: boolean } = { settleGesture: true }, +) => { + const stableTargets = + targets.length > 0 && + targets.every(({ id }) => id.length > 0 && isNativeElementId(id)) && + new Set(targets.map(({ id }) => id)).size === targets.length + ? targets + : null; + if (!stableTargets) { + return { + previewStyleProperty: undefined, + commitStyleProperty: undefined, + }; + } + return { + previewStyleProperty: (patch: EditorStylePropertyPreviewPatchV1) => + previewBatchStyleProperty(stableTargets, selectedKeyType, patch), + commitStyleProperty: (patch: EditorPreviewStylePropertyPatchV1) => { + const gestureId = options.settleGesture + ? editGestureController.activeGestureId() ?? undefined + : undefined; + const persisted = patchStylePropertyByTargets(stableTargets, patch, { + gestureId, + }); + if (options.settleGesture) { + editGestureController.settleCommit(persisted); + } + void persisted.catch(reportElementOpError); + }, + }; +}; + +// 표면별 허용 타깃 - font는 라벨 렌더러가 있는 키·스탯(active는 키만) +const paintRelevantTargets = < + T extends { elementType: 'key' | 'stat' | 'graph' | 'knob' }, +>( + targets: readonly T[], + patch: EditorPaintPropertyPatchV1, +): readonly T[] => { + const { active, surface } = paintPropertyFields(patch.property); + if (surface === 'font') { + return targets.filter(({ elementType }) => + active + ? elementType === 'key' + : elementType === 'key' || elementType === 'stat', + ); + } + return active + ? targets.filter( + ({ elementType }) => elementType === 'key' || elementType === 'knob', + ) + : targets; +}; + +export const createPaintHandlers = ( + targets: readonly { + elementType: 'key' | 'stat' | 'graph' | 'knob'; + id: string; + }[], + selectedKeyType: string, +) => { + const stableTargets = (patch: EditorPaintPropertyPatchV1) => { + const relevant = paintRelevantTargets(targets, patch); + const stable = + relevant.length > 0 && + relevant.every(({ id }) => id.length > 0 && isNativeElementId(id)) && + new Set(relevant.map(({ id }) => id)).size === relevant.length; + return stable ? relevant : null; + }; + return { + previewPaint: (patch: EditorPaintPropertyPatchV1) => { + const stable = stableTargets(patch); + if (!stable) return; + previewBatchPaint(stable, selectedKeyType, patch); + }, + commitPaint: (patch: EditorPaintPropertyPatchV1) => { + const stable = stableTargets(patch); + if (!stable) return; + const gestureId = editGestureController.activeGestureId() ?? undefined; + const persisted = patchPaintByTargets(stable, patch, { gestureId }); + editGestureController.settleCommit(persisted); + void persisted.catch(reportElementOpError); + }, + }; +}; + +export const createShadowCommitHandler = + ( + targets: readonly { + elementType: 'key' | 'stat' | 'knob'; + id: string; + }[], + ) => + (patch: EditorShadowPropertyPatchV1) => { + const relevant = + patch.property === 'activeShadow' + ? targets.filter(({ elementType }) => elementType !== 'stat') + : targets; + const stable = + relevant.length > 0 && + relevant.every(({ id }) => id.length > 0 && isNativeElementId(id)) && + new Set(relevant.map(({ id }) => id)).size === relevant.length; + if (!stable) return; + const gestureId = editGestureController.activeGestureId() ?? undefined; + const persisted = patchShadowByTargets(relevant, patch, { gestureId }); + editGestureController.settleCommit(persisted); + void persisted.catch(reportElementOpError); + }; + +export const commitBoundInactiveImage = ( + selection: ElementIdSelection, + inactiveImage: string, +) => { + const targets = NATIVE_IMAGE_TYPES.flatMap((elementType) => + (selection[elementType] ?? []).map((id) => ({ elementType, id })), + ); + if (targets.length === 0) return; + const persisted = patchInactiveImageByTargets(targets, inactiveImage); + void persisted.catch(reportElementOpError); +}; + +export const commitBoundActiveImage = ( + selection: ElementIdSelection, + activeImage: string, +) => { + const targets = (['key', 'knob'] as const).flatMap((elementType) => + (selection[elementType] ?? []).map((id) => ({ elementType, id })), + ); + if (targets.length === 0) return; + const persisted = patchActiveImageByTargets(targets, activeImage); + void persisted.catch(reportElementOpError); +}; + +export const commitBoundIdleTransparent = ( + selection: ElementIdSelection, + idleTransparent: boolean, +) => { + const targets = NATIVE_IMAGE_TYPES.flatMap((elementType) => + (selection[elementType] ?? []).map((id) => ({ elementType, id })), + ); + if (targets.length === 0) return; + const persisted = patchIdleTransparentByTargets(targets, idleTransparent); + void persisted.catch(reportElementOpError); +}; + +export const commitBoundActiveTransparent = ( + selection: ElementIdSelection, + activeTransparent: boolean, +) => { + const targets = (['key', 'knob'] as const).flatMap((elementType) => + (selection[elementType] ?? []).map((id) => ({ elementType, id })), + ); + if (targets.length === 0) return; + const persisted = patchActiveTransparentByTargets(targets, activeTransparent); + void persisted.catch(reportElementOpError); +}; + +export const commitBoundSoundPath = ( + selection: ElementIdSelection, + soundPath: string, +) => { + const ids = selection.key ?? []; + if (ids.length === 0) return; + const persisted = patchSoundPathByIds(ids, soundPath); + void persisted.catch(reportElementOpError); +}; + +export type BatchPickerTarget = + | 'noteColor' + | 'glowColor' + | 'borderColor' + | 'fill' + | null; + +export type MixedValueResult = { isMixed: boolean; value: T }; +export type MixedValueGetter

= ( + getter: (position: P) => T | undefined, + defaultValue: T, +) => MixedValueResult; + +export interface KeyData { + index: number; + position: KeyPosition | undefined; + keyCode: string | null; + keyInfo: { globalKey: string; displayName: string } | null; +} + +export interface BatchLocalColors { + fillIdle: string; + fillActive: string; +} From 3e987d76723e6968c1f1471eaf4e5d725b70c4e0 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 06:27:13 +0900 Subject: [PATCH 008/245] =?UTF-8?q?refactor:=20=EB=8B=A8=EC=9D=BC=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=ED=8C=A8=EB=84=90=20=EC=9C=A0=ED=98=95?= =?UTF-8?q?=EB=B3=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PropertiesPanel/single/RenameIcon.tsx | 26 + .../single/SingleGraphPanel.tsx | 535 +++++++ .../single/SingleKnobPanel.tsx | 872 ++++++++++ .../single/SingleSelectionPanel.tsx | 1397 +---------------- .../single/singlePanelModel.ts | 15 + 5 files changed, 1453 insertions(+), 1392 deletions(-) create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/single/RenameIcon.tsx create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/single/SingleGraphPanel.tsx create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/single/SingleKnobPanel.tsx create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/single/singlePanelModel.ts diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/RenameIcon.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/RenameIcon.tsx new file mode 100644 index 000000000..43caaef6b --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/RenameIcon.tsx @@ -0,0 +1,26 @@ +const RenameIcon = () => ( + +); + +export default RenameIcon; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleGraphPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleGraphPanel.tsx new file mode 100644 index 000000000..2104d211d --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleGraphPanel.tsx @@ -0,0 +1,535 @@ +/* eslint-disable react-hooks/refs */ +import React from 'react'; +import type { ImageFit } from '@src/types/key/keys'; +import type { StatItemType } from '@src/types/key/statItems'; +import type { + GraphItemPosition, + GraphItemType, +} from '@src/types/key/graphItems'; +import { editGestureController } from '@src/renderer/editor/runtime/editGestureController'; +import { isNativeElementId } from '@src/renderer/editor/model/elementId'; +import { paintDescriptor } from '@src/types/color'; +import { + DEFAULT_ELEMENT_BG, + DEFAULT_ELEMENT_RADIUS, +} from '@utils/core/elementDefaults'; +import { resolveElementBorder } from '@utils/core/elementBorder'; +import { PANEL_ROOT_CLASS, PANEL_HEADER_CLASS } from '../panelChrome'; +import { + PropertyRow, + NumberInput, + TextInput, + ColorInput, + PropertySection, + TABS, + type TabType, +} from '../index'; +import Checkbox from '@components/main/common/Checkbox'; +import Dropdown from '@components/main/common/Dropdown'; +import PopupExit from '@components/main/Modal/PopupExit'; +import ImagePicker from '@components/main/Modal/content/pickers/ImagePicker'; +import { AXIS_FIELD_WIDTH } from '@utils/cardRecipes'; +import EditSessionBoundary from '../EditSessionBoundary'; +import type { GeometryField } from '@src/renderer/editor/runtime/elementOps'; +import type { + EditorElementPropertyPatchV1, + EditorPaintPropertyPatchV1, + EditorPreviewStylePropertyPatchV1, +} from '@src/types/editor'; +import RenameIcon from './RenameIcon'; +import { getStatTypeLabel } from './singlePanelModel'; + +// ============================================================================ +// Single Graph Selection Panel +// ============================================================================ + +interface SingleGraphPanelProps { + setPanelElement: (el: HTMLDivElement | null) => void; + // 혼합 선택 시 패널 본문 상단에 표시할 플러그인 개수 안내 + singleGraphPosition: GraphItemPosition; + selectedKeyType: string; + isRenaming: boolean; + renameInputRef: React.RefObject; + renameValue: string; + setRenameValue: (value: string) => void; + renameCancelledRef: React.MutableRefObject; + handleRenameCommit: (value: string) => void; + handleRenameCancel: () => void; + handleRenameStart: () => void; + onElementPropertyCommit?: ( + patch: EditorElementPropertyPatchV1, + options?: { gestureId?: string }, + ) => void; + onGraphColorPreview?: (color: string) => void; + onInactiveImageCommit?: (inactiveImage: string) => void; + onIdleTransparentCommit?: (idleTransparent: boolean) => void; + onIdleImageFitCommit?: (idleImageFit: ImageFit) => void; + onStylePropertyCommit?: (patch: EditorPreviewStylePropertyPatchV1) => void; + onPaintPreview?: (patch: EditorPaintPropertyPatchV1) => void; + onPaintCommit?: (patch: EditorPaintPropertyPatchV1) => void; + handleGeometryCommit?: (field: GeometryField, value: number) => void; + handleGeometryPreview?: (field: GeometryField, value: number) => void; + singleScrollRefFor: (tab: TabType) => (node: HTMLDivElement | null) => void; + showGraphImagePicker: boolean; + setShowGraphImagePicker: (value: boolean) => void; + graphImageButtonRef: React.RefObject; + graphClassNameDraft: string; + setGraphClassNameDraft: (value: string) => void; + panelElement: HTMLDivElement | null; + useCustomCSS: boolean; + t: (key: string) => string | undefined; +} + +export const SingleGraphPanel: React.FC = ({ + setPanelElement, + singleGraphPosition, + selectedKeyType, + isRenaming, + renameInputRef, + renameValue, + setRenameValue, + renameCancelledRef, + handleRenameCommit, + handleRenameCancel, + handleRenameStart, + onElementPropertyCommit, + onGraphColorPreview, + onInactiveImageCommit, + onIdleTransparentCommit, + onIdleImageFitCommit, + onStylePropertyCommit, + onPaintPreview, + onPaintCommit, + handleGeometryCommit, + handleGeometryPreview, + singleScrollRefFor, + showGraphImagePicker, + setShowGraphImagePicker, + graphImageButtonRef, + graphClassNameDraft, + setGraphClassNameDraft, + panelElement, + useCustomCSS, + t, +}) => { + const graphShapeOptions = [ + { label: t('propertiesPanel.graphShapeLine') || 'Line', value: 'line' }, + { label: t('propertiesPanel.graphShapeBar') || 'Bar', value: 'bar' }, + ]; + + const resolvedGraphStatType = + (singleGraphPosition.statType as StatItemType) || 'kps'; + const graphDefaultTitle = `${getStatTypeLabel(resolvedGraphStatType)} Graph`; + const graphTitle = singleGraphPosition.layerName || graphDefaultTitle; + // 미지정 테두리는 앱 기본 립을 그대로 보여 준다 (렌더와 같은 해석기) + const graphBorderDisplay = resolveElementBorder(singleGraphPosition, false); + + return ( +

+
+ {isRenaming ? ( + setRenameValue(e.target.value)} + onBlur={() => { + if (!renameCancelledRef.current) { + handleRenameCommit(renameValue); + } + renameCancelledRef.current = false; + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + (e.target as HTMLInputElement).blur(); + } else if (e.key === 'Escape') { + e.preventDefault(); + handleRenameCancel(); + } + }} + /> + ) : ( +
+ + {graphTitle} + + +
+ )} +
+
+
+ + + + { + handleGeometryCommit?.('dx', value); + }} + onPreview={(value) => handleGeometryPreview?.('dx', value)} + onCancel={() => editGestureController.cancel()} + prefix="X" + width={AXIS_FIELD_WIDTH} + min={-9999} + max={9999} + allowDecimal + decimalScale={1} + /> + { + handleGeometryCommit?.('dy', value); + }} + onPreview={(value) => handleGeometryPreview?.('dy', value)} + onCancel={() => editGestureController.cancel()} + prefix="Y" + width={AXIS_FIELD_WIDTH} + min={-9999} + max={9999} + allowDecimal + decimalScale={1} + /> + + + + { + handleGeometryCommit?.('width', Math.max(20, value)); + }} + onPreview={(value) => + handleGeometryPreview?.('width', Math.max(20, value)) + } + onCancel={() => editGestureController.cancel()} + prefix="W" + width={AXIS_FIELD_WIDTH} + min={20} + max={9999} + /> + { + handleGeometryCommit?.('height', Math.max(20, value)); + }} + onPreview={(value) => + handleGeometryPreview?.('height', Math.max(20, value)) + } + onCancel={() => editGestureController.cancel()} + prefix="H" + width={AXIS_FIELD_WIDTH} + min={20} + max={9999} + /> + + + + + + + onElementPropertyCommit?.({ + property: 'graphType', + value: value as GraphItemType, + }) + } + /> + + + {(singleGraphPosition.graphType || 'line') === 'line' && ( +
+

+ {t('propertiesPanel.graphShowAverageLine') || + 'Show Average Line'} +

+ + onElementPropertyCommit?.({ + property: 'showAvgLine', + value: !(singleGraphPosition.showAvgLine ?? true), + }) + } + /> +
+ )} + + + { + const clamped = Math.max(500, Math.min(5000, value)); + const snapped = Math.round(clamped / 100) * 100; + onElementPropertyCommit?.({ + property: 'graphSpeed', + value: snapped, + }); + }} + min={500} + max={5000} + suffix="ms" + /> + + + + {}} + onPreview={(value) => onGraphColorPreview?.(value)} + onChangeComplete={(value) => + onElementPropertyCommit?.({ + property: 'graphColor', + value: value, + }) + } + onCancel={() => editGestureController.cancel()} + colorId={`graph-color-${selectedKeyType}-${singleGraphPosition.id}`} + panelElement={panelElement} + /> + + +
+

+ {t('propertiesPanel.graphAnimation') || 'Graph Animation'} +

+ + onElementPropertyCommit?.({ + property: 'graphAnimationEnabled', + value: !( + singleGraphPosition.graphAnimationEnabled ?? true + ), + }) + } + /> +
+
+ + + + {}} + onChangeComplete={() => {}} + gradientValue={singleGraphPosition.backgroundGradient ?? null} + canvasAnchor={ + singleGraphPosition.id && + isNativeElementId(singleGraphPosition.id) + ? { kind: 'graph', id: singleGraphPosition.id } + : undefined + } + onModePreview={(_state, modeValue) => + onPaintPreview?.({ + property: 'backgroundPaint', + value: paintDescriptor(modeValue), + }) + } + onModeCommit={(_state, modeValue) => + onPaintCommit?.({ + property: 'backgroundPaint', + value: paintDescriptor(modeValue), + }) + } + onCancel={() => editGestureController.cancel()} + colorId={`graph-bg-color-${selectedKeyType}-${singleGraphPosition.id}`} + panelElement={panelElement} + /> + + + + {}} + onChangeComplete={() => {}} + gradientValue={graphBorderDisplay.gradient} + canvasAnchor={ + singleGraphPosition.id && + isNativeElementId(singleGraphPosition.id) + ? { kind: 'graph', id: singleGraphPosition.id } + : undefined + } + onModePreview={(_state, modeValue) => + onPaintPreview?.({ + property: 'borderPaint', + value: paintDescriptor(modeValue), + }) + } + onModeCommit={(_state, modeValue) => + onPaintCommit?.({ + property: 'borderPaint', + value: paintDescriptor(modeValue), + }) + } + onCancel={() => editGestureController.cancel()} + colorId={`graph-border-color-${selectedKeyType}-${singleGraphPosition.id}`} + gradientSurface="border" + panelElement={panelElement} + /> + + + + + onStylePropertyCommit?.({ + property: 'borderWidth', + value: Math.max(0, Math.min(20, value)), + }) + } + min={0} + max={20} + suffix="px" + /> + + + + + onStylePropertyCommit?.({ + property: 'borderRadius', + value: Math.max(0, Math.min(100, value)), + }) + } + min={0} + max={100} + suffix="px" + /> + + + + + + + + {useCustomCSS && ( + +
+

+ {t('propertiesPanel.useInlineStyles') || + '인라인 스타일 우선'} +

+ + onElementPropertyCommit?.({ + property: 'useInlineStyles', + value: !(singleGraphPosition.useInlineStyles ?? false), + }) + } + /> +
+ + + + onStylePropertyCommit?.({ + property: 'className', + value: value, + }) + } + placeholder="className" + width="90px" + /> + +
+ )} +
+
+
+ + + {showGraphImagePicker && graphImageButtonRef.current ? ( + + onInactiveImageCommit?.(imageUrl) + } + onIdleTransparentChange={(value: boolean) => + onIdleTransparentCommit?.(value) + } + onIdleImageFitChange={(fit: string) => + onIdleImageFitCommit?.(fit as ImageFit) + } + onIdleImageReset={() => onInactiveImageCommit?.('')} + onClose={() => setShowGraphImagePicker(false)} + /> + ) : null} + +
+ ); +}; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleKnobPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleKnobPanel.tsx new file mode 100644 index 000000000..f14ee8059 --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleKnobPanel.tsx @@ -0,0 +1,872 @@ +/* eslint-disable react-hooks/refs */ +import React, { useEffect, useRef, useState } from 'react'; +import { patchKnobAxisIdById } from '@src/renderer/editor/runtime/elementOps'; +import { reportElementOpError } from '@src/renderer/editor/runtime/elementIntent'; +import { editGestureController } from '@src/renderer/editor/runtime/editGestureController'; +import { isNativeElementId } from '@src/renderer/editor/model/elementId'; +import type { EditStateAnchor } from '@stores/grid/useEditStatePreviewStore'; +import type { ImageFit } from '@src/types/key/keys'; +import type { KnobItemPosition } from '@src/types/key/knobs'; +import { + paintDescriptor, + gradientToCss, + type ColorModeValue, + type GradientSpec, +} from '@src/types/color'; +import { useGradientColorState } from '@hooks/pickers/useGradientColorState'; +import { axisEventBus } from '@utils/core/axisEventBus'; +import { + DEFAULT_ELEMENT_BG, + DEFAULT_ELEMENT_BORDER, + DEFAULT_ELEMENT_BORDER_WIDTH, + DEFAULT_ELEMENT_ACTIVE_BORDER, + DEFAULT_ELEMENT_ACTIVE_BG, + DEFAULT_ELEMENT_SHADOW_SPEC, + DEFAULT_ELEMENT_ACTIVE_SHADOW_SPEC, +} from '@utils/core/elementDefaults'; +import { resolveElementBorder } from '@utils/core/elementBorder'; +import { + elementShadowLeafFromPartial, + resolveElementShadowForPosition, +} from '@src/types/key/shadows'; +import { PANEL_ROOT_CLASS, PANEL_HEADER_CLASS } from '../panelChrome'; +import { + PropertyRow, + NumberInput, + TextInput, + PropertySection, + TABS, + type TabType, +} from '../index'; +import Checkbox from '@components/main/common/Checkbox'; +import ColorPicker from '@components/main/Modal/content/pickers/ColorPicker'; +import PopupExit from '@components/main/Modal/PopupExit'; +import ImagePicker from '@components/main/Modal/content/pickers/ImagePicker'; +import { ColorSwatchButton } from '@components/main/Modal/content/pickers/ColorSwatch'; +import ShadowControls from '../ShadowControls'; +import { AXIS_FIELD_WIDTH } from '@utils/cardRecipes'; +import EditSessionBoundary from '../EditSessionBoundary'; +import type { GeometryField } from '@src/renderer/editor/runtime/elementOps'; +import type { + EditorPaintPropertyPatchV1, + EditorShadowPropertyPatchV1, + EditorPreviewStylePropertyPatchV1, + EditorStylePropertyPreviewPatchV1, + EditorElementPropertyPatchV1, +} from '@src/types/editor'; +import RenameIcon from './RenameIcon'; + +// ============================================================================ +// Single Knob Selection Panel +// ============================================================================ + +interface SingleKnobPanelProps { + setPanelElement: (el: HTMLDivElement | null) => void; + // 혼합 선택 시 패널 본문 상단에 표시할 플러그인 개수 안내 + singleKnobPosition: KnobItemPosition; + selectedKeyType: string; + isRenaming: boolean; + renameInputRef: React.RefObject; + renameValue: string; + setRenameValue: (value: string) => void; + renameCancelledRef: React.MutableRefObject; + handleRenameCommit: (value: string) => void; + handleRenameCancel: () => void; + handleRenameStart: () => void; + onElementPropertyCommit?: ( + patch: EditorElementPropertyPatchV1, + options?: { gestureId?: string }, + ) => void; + onInactiveImageCommit?: (inactiveImage: string) => void; + onActiveImageCommit?: (activeImage: string) => void; + onIdleTransparentCommit?: (idleTransparent: boolean) => void; + onActiveTransparentCommit?: (activeTransparent: boolean) => void; + onIdleImageFitCommit?: (idleImageFit: ImageFit) => void; + onActiveImageFitCommit?: (activeImageFit: ImageFit) => void; + onStylePropertyPreview?: (patch: EditorStylePropertyPreviewPatchV1) => void; + onStylePropertyCommit?: (patch: EditorPreviewStylePropertyPatchV1) => void; + onPaintPreview?: (patch: EditorPaintPropertyPatchV1) => void; + onPaintCommit?: (patch: EditorPaintPropertyPatchV1) => void; + onShadowCommit?: (patch: EditorShadowPropertyPatchV1) => void; + handleGeometryCommit?: (field: GeometryField, value: number) => void; + handleGeometryPreview?: (field: GeometryField, value: number) => void; + singleScrollRefFor: (tab: TabType) => (node: HTMLDivElement | null) => void; + panelElement: HTMLDivElement | null; + useCustomCSS: boolean; + t: (key: string) => string; +} + +export const SingleKnobPanel: React.FC = ({ + setPanelElement, + singleKnobPosition, + selectedKeyType, + isRenaming, + renameInputRef, + renameValue, + setRenameValue, + renameCancelledRef, + handleRenameCommit, + handleRenameCancel, + handleRenameStart, + onElementPropertyCommit, + onInactiveImageCommit, + onActiveImageCommit, + onIdleTransparentCommit, + onActiveTransparentCommit, + onIdleImageFitCommit, + onActiveImageFitCommit, + onStylePropertyCommit, + onPaintPreview, + onPaintCommit, + onStylePropertyPreview, + onShadowCommit, + handleGeometryCommit, + handleGeometryPreview, + singleScrollRefFor, + panelElement, + useCustomCSS, + t, +}) => { + const panelRef = useRef(null); + const imageButtonRef = useRef(null); + const [showImagePicker, setShowImagePicker] = useState(false); + const [axisCaptureTarget, setAxisCaptureTarget] = useState( + null, + ); + // 민감도는 오버레이 프리뷰 대상이 아니라 확정 전까지 패널 안에서만 값을 들고 있는다 + // 노브 id로 묶어야 대상이 바뀐 첫 렌더에 이전 노브의 미확정 값이 새 칸에 비치지 않는다 + const [sensitivityDraft, setSensitivityDraft] = useState<{ + id: string; + value: number; + } | null>(null); + const sensitivityDraftValue = + sensitivityDraft?.id === singleKnobPosition.id + ? sensitivityDraft.value + : null; + // draft는 커밋이 착지할 때 지운다 - 먼저 비우면 착지 전 한 프레임 옛 값이 비친다 + useEffect(() => { + setSensitivityDraft(null); + }, [singleKnobPosition.id, singleKnobPosition.sensitivity]); + const [classNameDraft, setClassNameDraft] = useState( + singleKnobPosition.className || '', + ); + // 캡처는 시작 ID를 동결해 그 노브에 완료를 적용한다. 표시도 소유권 기준이어야 + // 한다 - truthiness로 그리면 대상이 바뀐 뒤 현재 노브 행에 "감지 중"이 뜨고, + // 사용자가 노브를 돌리면 화면에 없는 옛 노브에 축이 바인딩된다 + const capturingThisKnob = axisCaptureTarget === singleKnobPosition.id; + + useEffect(() => { + setClassNameDraft(singleKnobPosition.className || ''); + }, [selectedKeyType, singleKnobPosition.className, singleKnobPosition.id]); + + // 회전 감지 바인딩: 노브를 돌리면 가장 많이 움직인 축을 자동 바인딩 + useEffect(() => { + if (!axisCaptureTarget) return; + axisEventBus.initialize(); + const counts = new Map(); + let bound = false; + const unsub = axisEventBus.subscribe(({ axisId }) => { + if (bound) return; + const c = (counts.get(axisId) ?? 0) + 1; + counts.set(axisId, c); + if (c >= 3) { + bound = true; + const persisted = patchKnobAxisIdById(axisCaptureTarget, axisId); + void persisted.catch(reportElementOpError); + setAxisCaptureTarget(null); + } + }); + const timer = window.setTimeout(() => setAxisCaptureTarget(null), 6000); + return () => { + unsub(); + window.clearTimeout(timer); + }; + }, [axisCaptureTarget]); + + const setRef = (node: HTMLDivElement | null) => { + panelRef.current = node; + setPanelElement(node); + }; + + const knobTitle = singleKnobPosition.layerName || 'Knob'; + const axisLabel = singleKnobPosition.axisId + ? singleKnobPosition.axisId.replace(/^HIDA:/, '') + : t('propertiesPanel.knobAxisUnset') || '미지정'; + + // 대기/입력 색상 (키 패널과 동일한 기본값/전환 로직) + const DEFAULT_KNOB_BACKGROUND_COLOR = DEFAULT_ELEMENT_BG; + const DEFAULT_KNOB_BORDER_COLOR = DEFAULT_ELEMENT_BORDER; + const DEFAULT_KNOB_ACTIVE_BACKGROUND_COLOR = DEFAULT_ELEMENT_ACTIVE_BG; + const DEFAULT_KNOB_ACTIVE_BORDER_COLOR = DEFAULT_ELEMENT_ACTIVE_BORDER; + + type KnobColorTarget = 'backgroundColor' | 'borderColor'; + type KnobColorProperty = + | KnobColorTarget + | 'activeBackgroundColor' + | 'activeBorderColor'; + + const [pickerFor, setPickerFor] = useState(null); + const [colorState, setColorState] = useState<'idle' | 'active'>('idle'); + const bgColorBtnRef = useRef(null); + const borderColorBtnRef = useRef(null); + + const [localColors, setLocalColors] = useState< + Record + >({ + backgroundColor: + singleKnobPosition.backgroundColor || DEFAULT_KNOB_BACKGROUND_COLOR, + activeBackgroundColor: + singleKnobPosition.activeBackgroundColor || + singleKnobPosition.backgroundColor || + DEFAULT_KNOB_ACTIVE_BACKGROUND_COLOR, + borderColor: singleKnobPosition.borderColor || DEFAULT_KNOB_BORDER_COLOR, + activeBorderColor: + singleKnobPosition.activeBorderColor || + singleKnobPosition.borderColor || + DEFAULT_KNOB_ACTIVE_BORDER_COLOR, + }); + + // 피커는 대상 변경 시 닫는다. 축 캡처는 시작 ID를 별도로 동결하므로 + // 재정렬이나 모드 전환 뒤에도 시작 대상을 유지한다 + useEffect(() => { + setPickerFor(null); + setShowImagePicker(false); + }, [selectedKeyType, singleKnobPosition.id]); + + // 피커가 닫혀있을 때만 외부 prop과 동기화 + useEffect(() => { + if (!pickerFor) { + setLocalColors({ + backgroundColor: + singleKnobPosition.backgroundColor || DEFAULT_KNOB_BACKGROUND_COLOR, + activeBackgroundColor: + singleKnobPosition.activeBackgroundColor || + singleKnobPosition.backgroundColor || + DEFAULT_KNOB_ACTIVE_BACKGROUND_COLOR, + borderColor: + singleKnobPosition.borderColor || DEFAULT_KNOB_BORDER_COLOR, + activeBorderColor: + singleKnobPosition.activeBorderColor || + singleKnobPosition.borderColor || + DEFAULT_KNOB_ACTIVE_BORDER_COLOR, + }); + } + }, [ + pickerFor, + singleKnobPosition.backgroundColor, + singleKnobPosition.activeBackgroundColor, + singleKnobPosition.borderColor, + singleKnobPosition.activeBorderColor, + DEFAULT_KNOB_BACKGROUND_COLOR, + DEFAULT_KNOB_ACTIVE_BACKGROUND_COLOR, + DEFAULT_KNOB_BORDER_COLOR, + DEFAULT_KNOB_ACTIVE_BORDER_COLOR, + ]); + + const resolveColorProperty = (target: KnobColorTarget): KnobColorProperty => + colorState === 'active' + ? target === 'backgroundColor' + ? 'activeBackgroundColor' + : 'activeBorderColor' + : target; + + const activeColorPropertyFor = ( + target: KnobColorTarget, + ): 'activeBackgroundColor' | 'activeBorderColor' => + target === 'backgroundColor' + ? 'activeBackgroundColor' + : 'activeBorderColor'; + + const isNonEmptyString = (value: unknown): value is string => + typeof value === 'string' && value.trim().length > 0; + + const colorValueFor = (target: KnobColorTarget): string => + localColors[resolveColorProperty(target)]; + + const handleColorChange = (target: KnobColorTarget, color: string) => { + const prop = resolveColorProperty(target); + setLocalColors((prev) => ({ ...prev, [prop]: color })); + }; + + // ── 그라데이션 배선 (키 패널과 동일 패턴) — 단색 커밋도 이 경로로 통합 ── + + // 테두리 미지정이면 렌더와 같은 기본 립을 편집 대상으로 보여 준다 + const defaultKnobBorderGradientFor = ( + active: boolean, + ): GradientSpec | null => { + const resolved = resolveElementBorder(singleKnobPosition, active); + return resolved.isDefault ? resolved.gradient : null; + }; + + const storedGradientOf = (prop: KnobColorProperty): GradientSpec | null => { + switch (prop) { + case 'backgroundColor': + return singleKnobPosition.backgroundGradient ?? null; + case 'activeBackgroundColor': + return singleKnobPosition.activeBackgroundGradient ?? null; + case 'borderColor': + return ( + singleKnobPosition.borderGradient ?? + defaultKnobBorderGradientFor(false) + ); + case 'activeBorderColor': + return ( + singleKnobPosition.activeBorderGradient ?? + defaultKnobBorderGradientFor(true) + ); + default: + return null; + } + }; + + const gradientSpecFor = (target: KnobColorTarget): GradientSpec | null => { + const idleGradient = storedGradientOf(target); + if (colorState !== 'active') return idleGradient; + const activeProp = activeColorPropertyFor(target); + const activeGradient = storedGradientOf(activeProp); + const activeHasValue = + isNonEmptyString(singleKnobPosition[activeProp]) || + activeGradient != null; + return activeHasValue ? activeGradient : idleGradient; + }; + + const handleGradientCommit = (value: ColorModeValue) => { + if (!pickerFor) return; + const prop = resolveColorProperty(pickerFor); + const descriptor = paintDescriptor(value); + const paintField = + colorState === 'active' + ? pickerFor === 'backgroundColor' + ? 'activeBackgroundPaint' + : 'activeBorderPaint' + : pickerFor === 'backgroundColor' + ? 'backgroundPaint' + : 'borderPaint'; + setLocalColors((prev) => ({ ...prev, [prop]: descriptor.color })); + onPaintCommit?.({ property: paintField, value: descriptor } as never); + }; + + const knobGradientState = useGradientColorState({ + pair: pickerFor + ? { + color: colorValueFor(pickerFor), + gradient: gradientSpecFor(pickerFor), + } + : {}, + fallbackColor: '#ffffff', + contextKey: `knob:${selectedKeyType}:${singleKnobPosition.id}:${ + pickerFor ?? 'none' + }:${colorState}`, + canvasAnchor: pickerFor + ? singleKnobPosition.id && isNativeElementId(singleKnobPosition.id) + ? { kind: 'knob', id: singleKnobPosition.id } + : undefined + : undefined, + canvasSurface: pickerFor === 'borderColor' ? 'border' : 'background', + canvasState: colorState, + onPreview: (value) => { + if (!pickerFor) return; + const prop = resolveColorProperty(pickerFor); + const descriptor = paintDescriptor(value); + setLocalColors((prev) => ({ ...prev, [prop]: descriptor.color })); + const paintField = + colorState === 'active' + ? pickerFor === 'backgroundColor' + ? 'activeBackgroundPaint' + : 'activeBorderPaint' + : pickerFor === 'backgroundColor' + ? 'backgroundPaint' + : 'borderPaint'; + onPaintPreview?.({ property: paintField, value: descriptor } as never); + }, + onCancel: () => editGestureController.cancel(), + onCommit: handleGradientCommit, + }); + + const handlePickerToggle = (target: KnobColorTarget) => { + setPickerFor((prev) => (prev === target ? null : target)); + // 새로 열 때는 항상 대기 탭에서 시작 + if (pickerFor !== target) setColorState('idle'); + }; + + // 그림자·이미지 피커의 캔버스 상태 프리뷰 대상 (색 피커는 세션 훅이 발행) + const knobPreviewAnchor: EditStateAnchor | null = + singleKnobPosition.id && isNativeElementId(singleKnobPosition.id) + ? { kind: 'knob', id: singleKnobPosition.id } + : null; + + // 라운딩 기본값: 미지정 시 원형(짧은 변의 절반) + const effectiveBorderRadius = + singleKnobPosition.borderRadius ?? + Math.round( + Math.min( + singleKnobPosition.width || 60, + singleKnobPosition.height || 60, + ) / 2, + ); + const knobIdleShadow = resolveElementShadowForPosition({ + position: singleKnobPosition, + elementType: 'knob', + active: false, + defaultShadow: DEFAULT_ELEMENT_SHADOW_SPEC, + defaultActiveShadow: DEFAULT_ELEMENT_ACTIVE_SHADOW_SPEC, + }); + const knobActiveShadow = resolveElementShadowForPosition({ + position: singleKnobPosition, + elementType: 'knob', + active: true, + defaultShadow: DEFAULT_ELEMENT_SHADOW_SPEC, + defaultActiveShadow: DEFAULT_ELEMENT_ACTIVE_SHADOW_SPEC, + }); + + return ( +
+
+ {isRenaming ? ( + setRenameValue(e.target.value)} + onBlur={() => { + if (!renameCancelledRef.current) { + handleRenameCommit(renameValue); + } + renameCancelledRef.current = false; + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + (e.target as HTMLInputElement).blur(); + } else if (e.key === 'Escape') { + e.preventDefault(); + handleRenameCancel(); + } + }} + /> + ) : ( +
+ + {knobTitle} + + +
+ )} +
+ +
+
+ + {/* 노브 매핑 (키 매핑과 동일한 라벨/버튼 구조) */} + + + + + + + + + { + handleGeometryCommit?.('dx', value); + }} + onPreview={(value) => handleGeometryPreview?.('dx', value)} + onCancel={() => editGestureController.cancel()} + prefix="X" + width={AXIS_FIELD_WIDTH} + min={-9999} + max={9999} + allowDecimal + decimalScale={1} + /> + { + handleGeometryCommit?.('dy', value); + }} + onPreview={(value) => handleGeometryPreview?.('dy', value)} + onCancel={() => editGestureController.cancel()} + prefix="Y" + width={AXIS_FIELD_WIDTH} + min={-9999} + max={9999} + allowDecimal + decimalScale={1} + /> + + + + { + handleGeometryCommit?.('width', Math.max(20, value)); + }} + onPreview={(value) => + handleGeometryPreview?.('width', Math.max(20, value)) + } + onCancel={() => editGestureController.cancel()} + prefix="W" + width={AXIS_FIELD_WIDTH} + min={20} + max={9999} + /> + { + handleGeometryCommit?.('height', Math.max(20, value)); + }} + onPreview={(value) => + handleGeometryPreview?.('height', Math.max(20, value)) + } + onCancel={() => editGestureController.cancel()} + prefix="H" + width={AXIS_FIELD_WIDTH} + min={20} + max={9999} + /> + + + + + {/* 회전 배율: 1 = 물리 1회전당 화면 1회전 (축 해상도 무관) */} + + { + const next = Math.max(0, value); + setSensitivityDraft({ + id: singleKnobPosition.id, + value: next, + }); + onElementPropertyCommit?.({ + property: 'sensitivity', + value: next, + }); + }} + onPreview={(value) => + setSensitivityDraft({ + id: singleKnobPosition.id, + value: Math.max(0, value), + }) + } + onCancel={() => setSensitivityDraft(null)} + suffix="×" + min={0} + max={100} + allowDecimal + decimalScale={2} + /> + + +
+

+ {t('propertiesPanel.knobReverse') || '방향 반전'} +

+ + onElementPropertyCommit?.({ + property: 'reverse', + value: !(singleKnobPosition.reverse ?? false), + }) + } + /> +
+
+ + + {/* 배경색 (대기/입력 상태 전환은 피커 내부 토글) */} + + handlePickerToggle('backgroundColor')} + open={pickerFor === 'backgroundColor'} + className="w-[23px] h-[23px] rounded-md cursor-pointer transition-shadow flex-shrink-0" + surfaceClassName="rounded-md" + color={colorValueFor('backgroundColor')} + image={(() => { + const spec = gradientSpecFor('backgroundColor'); + return spec ? gradientToCss(spec) : undefined; + })()} + /> + + + {/* 테두리 색상 */} + + handlePickerToggle('borderColor')} + open={pickerFor === 'borderColor'} + className="w-[23px] h-[23px] rounded-md cursor-pointer transition-shadow flex-shrink-0" + surfaceClassName="rounded-md" + color={colorValueFor('borderColor')} + image={(() => { + const spec = gradientSpecFor('borderColor'); + return spec ? gradientToCss(spec) : undefined; + })()} + /> + + + {/* 테두리 두께 */} + + + onStylePropertyCommit?.({ + property: 'borderWidth', + value: value, + }) + } + suffix="px" + min={0} + max={20} + /> + + + {/* 모서리 반경 (미지정 시 원형) */} + + + onStylePropertyCommit?.({ + property: 'borderRadius', + value: value, + }) + } + suffix="px" + min={0} + max={999} + /> + + + + + + + {useCustomCSS && ( + <> +
+

+ {t('propertiesPanel.useInlineStyles') || + '인라인 스타일 우선'} +

+ + onElementPropertyCommit?.({ + property: 'useInlineStyles', + value: !(singleKnobPosition.useInlineStyles ?? false), + }) + } + /> +
+ + + + onStylePropertyCommit?.({ + property: 'className', + value: value, + }) + } + placeholder="className" + width="90px" + /> + + + )} +
+ + { + const leaf = elementShadowLeafFromPartial(patch); + if (!leaf) return; + onShadowCommit?.( + state === 'active' + ? { property: 'activeShadow', value: leaf } + : { property: 'shadow', value: leaf }, + ); + }} + onPreview={(state, leaf) => + onStylePropertyPreview?.({ + property: state === 'active' ? 'activeShadow' : 'shadow', + value: leaf, + }) + } + onPreviewCancel={() => editGestureController.cancel()} + onEnabledChange={(enabled) => { + onShadowCommit?.({ property: 'shadowEnabled', value: enabled }); + }} + panelElement={panelElement} + t={t} + /> +
+
+
+ + + {showImagePicker && imageButtonRef.current ? ( + + onInactiveImageCommit?.(imageUrl) + } + onActiveImageChange={(imageUrl: string) => + onActiveImageCommit?.(imageUrl) + } + onIdleTransparentChange={(value: boolean) => + onIdleTransparentCommit?.(value) + } + onActiveTransparentChange={(value: boolean) => + onActiveTransparentCommit?.(value) + } + onIdleImageFitChange={(fit: string) => + onIdleImageFitCommit?.(fit as ImageFit) + } + onActiveImageFitChange={(fit: string) => + onActiveImageFitCommit?.(fit as ImageFit) + } + onIdleImageReset={() => onInactiveImageCommit?.('')} + onActiveImageReset={() => onActiveImageCommit?.('')} + onClose={() => setShowImagePicker(false)} + /> + ) : null} + + + {/* 대기/입력 색상 ColorPicker (키 패널과 동일한 stateMode 토글) */} + + {pickerFor ? ( + + knobGradientState.handlePickerColorChange(c, false) + } + onColorChangeComplete={(c: string) => + knobGradientState.handlePickerColorChange(c, true) + } + onInputCancel={(_target, restoredColor) => { + knobGradientState.cancelPreview(); + if (pickerFor && typeof restoredColor === 'string') { + handleColorChange(pickerFor, restoredColor); + } + editGestureController.cancel(); + }} + onClose={() => setPickerFor(null)} + solidOnly={true} + stateMode={colorState} + onStateModeChange={setColorState} + interactiveRefs={[bgColorBtnRef, borderColorBtnRef]} + headerSlot={knobGradientState.headerSlot} + footerSlot={knobGradientState.footerSlot} + gradientSpec={knobGradientState.paletteGradientSpec} + onGradientSpecSelect={knobGradientState.handleGradientSpecSelect} + /> + ) : null} + +
+ ); +}; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx index a889aa2ae..eb5ceffad 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx @@ -1,10 +1,5 @@ -/* eslint-disable react-hooks/refs */ -import React, { useEffect, useRef, useState } from 'react'; -import { patchKnobAxisIdById } from '@src/renderer/editor/runtime/elementOps'; -import { reportElementOpError } from '@src/renderer/editor/runtime/elementIntent'; -import { editGestureController } from '@src/renderer/editor/runtime/editGestureController'; +import React, { useEffect } from 'react'; import { isNativeElementId } from '@src/renderer/editor/model/elementId'; -import type { EditStateAnchor } from '@stores/grid/useEditStatePreviewStore'; import { flushPluginInstancesEditSession } from '@plugins/runtime/displayElement/instancesCommitQueue'; import type { ImageFit, KeyPosition, KeySlot } from '@src/types/key/keys'; import { @@ -13,40 +8,12 @@ import { type StatItemPosition, type StatItemType, } from '@src/types/key/statItems'; -import type { - GraphItemPosition, - GraphItemType, -} from '@src/types/key/graphItems'; -import type { KnobItemPosition } from '@src/types/key/knobs'; import type { PluginGeometryField } from '@hooks/Grid/usePluginGeometryGesture'; import type { CounterTabContentProps, NoteTabContentProps, StyleTabContentProps, } from '../types'; -import { - paintDescriptor, - gradientToCss, - type ColorModeValue, - type GradientSpec, -} from '@src/types/color'; -import { useGradientColorState } from '@hooks/pickers/useGradientColorState'; -import { axisEventBus } from '@utils/core/axisEventBus'; -import { - DEFAULT_ELEMENT_BG, - DEFAULT_ELEMENT_BORDER, - DEFAULT_ELEMENT_BORDER_WIDTH, - DEFAULT_ELEMENT_ACTIVE_BORDER, - DEFAULT_ELEMENT_ACTIVE_BG, - DEFAULT_ELEMENT_RADIUS, - DEFAULT_ELEMENT_SHADOW_SPEC, - DEFAULT_ELEMENT_ACTIVE_SHADOW_SPEC, -} from '@utils/core/elementDefaults'; -import { resolveElementBorder } from '@utils/core/elementBorder'; -import { - elementShadowLeafFromPartial, - resolveElementShadowForPosition, -} from '@src/types/key/shadows'; import type { PluginSettingSchema, PluginMessages, @@ -62,8 +29,6 @@ import { import { PropertyRow, NumberInput, - TextInput, - ColorInput, PropertySection, Tabs, StyleTabContent, @@ -72,13 +37,7 @@ import { TABS, TabType, } from '../index'; -import Checkbox from '@components/main/common/Checkbox'; import Dropdown from '@components/main/common/Dropdown'; -import ColorPicker from '@components/main/Modal/content/pickers/ColorPicker'; -import PopupExit from '@components/main/Modal/PopupExit'; -import ImagePicker from '@components/main/Modal/content/pickers/ImagePicker'; -import { ColorSwatchButton } from '@components/main/Modal/content/pickers/ColorSwatch'; -import ShadowControls from '../ShadowControls'; import { AXIS_FIELD_WIDTH } from '@utils/cardRecipes'; import EditSessionBoundary from '../EditSessionBoundary'; import type { GeometryField } from '@src/renderer/editor/runtime/elementOps'; @@ -87,48 +46,9 @@ import type { EditorShadowPropertyPatchV1, EditorPreviewStylePropertyPatchV1, EditorStylePropertyPreviewPatchV1, - EditorElementPropertyPatchV1, } from '@src/types/editor'; - -const getStatTypeLabel = (statType?: StatItemType | null): string => { - switch (statType) { - case 'kpsAvg': - return 'AVG'; - case 'kpsMax': - return 'MAX'; - case 'total': - return 'Total'; - case 'kps': - default: - return 'KPS'; - } -}; - -// 24 그리드를 12px로 렌더 - 스트로크 2.4가 화면상 1.2 -const RenameIcon: React.FC = () => ( - -); +import RenameIcon from './RenameIcon'; +import { getStatTypeLabel } from './singlePanelModel'; // ============================================================================ // Plugin Selection Panel @@ -368,1315 +288,8 @@ export const PluginSelectionPanel: React.FC = ({ ); }; -// ============================================================================ -// Single Graph Selection Panel -// ============================================================================ - -interface SingleGraphPanelProps { - setPanelElement: (el: HTMLDivElement | null) => void; - // 혼합 선택 시 패널 본문 상단에 표시할 플러그인 개수 안내 - singleGraphPosition: GraphItemPosition; - selectedKeyType: string; - isRenaming: boolean; - renameInputRef: React.RefObject; - renameValue: string; - setRenameValue: (value: string) => void; - renameCancelledRef: React.MutableRefObject; - handleRenameCommit: (value: string) => void; - handleRenameCancel: () => void; - handleRenameStart: () => void; - onElementPropertyCommit?: ( - patch: EditorElementPropertyPatchV1, - options?: { gestureId?: string }, - ) => void; - onGraphColorPreview?: (color: string) => void; - onInactiveImageCommit?: (inactiveImage: string) => void; - onIdleTransparentCommit?: (idleTransparent: boolean) => void; - onIdleImageFitCommit?: (idleImageFit: ImageFit) => void; - onStylePropertyCommit?: (patch: EditorPreviewStylePropertyPatchV1) => void; - onPaintPreview?: (patch: EditorPaintPropertyPatchV1) => void; - onPaintCommit?: (patch: EditorPaintPropertyPatchV1) => void; - handleGeometryCommit?: (field: GeometryField, value: number) => void; - handleGeometryPreview?: (field: GeometryField, value: number) => void; - singleScrollRefFor: (tab: TabType) => (node: HTMLDivElement | null) => void; - showGraphImagePicker: boolean; - setShowGraphImagePicker: (value: boolean) => void; - graphImageButtonRef: React.RefObject; - graphClassNameDraft: string; - setGraphClassNameDraft: (value: string) => void; - panelElement: HTMLDivElement | null; - useCustomCSS: boolean; - t: (key: string) => string | undefined; -} - -export const SingleGraphPanel: React.FC = ({ - setPanelElement, - singleGraphPosition, - selectedKeyType, - isRenaming, - renameInputRef, - renameValue, - setRenameValue, - renameCancelledRef, - handleRenameCommit, - handleRenameCancel, - handleRenameStart, - onElementPropertyCommit, - onGraphColorPreview, - onInactiveImageCommit, - onIdleTransparentCommit, - onIdleImageFitCommit, - onStylePropertyCommit, - onPaintPreview, - onPaintCommit, - handleGeometryCommit, - handleGeometryPreview, - singleScrollRefFor, - showGraphImagePicker, - setShowGraphImagePicker, - graphImageButtonRef, - graphClassNameDraft, - setGraphClassNameDraft, - panelElement, - useCustomCSS, - t, -}) => { - const graphShapeOptions = [ - { label: t('propertiesPanel.graphShapeLine') || 'Line', value: 'line' }, - { label: t('propertiesPanel.graphShapeBar') || 'Bar', value: 'bar' }, - ]; - - const resolvedGraphStatType = - (singleGraphPosition.statType as StatItemType) || 'kps'; - const graphDefaultTitle = `${getStatTypeLabel(resolvedGraphStatType)} Graph`; - const graphTitle = singleGraphPosition.layerName || graphDefaultTitle; - // 미지정 테두리는 앱 기본 립을 그대로 보여 준다 (렌더와 같은 해석기) - const graphBorderDisplay = resolveElementBorder(singleGraphPosition, false); - - return ( -
-
- {isRenaming ? ( - setRenameValue(e.target.value)} - onBlur={() => { - if (!renameCancelledRef.current) { - handleRenameCommit(renameValue); - } - renameCancelledRef.current = false; - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - (e.target as HTMLInputElement).blur(); - } else if (e.key === 'Escape') { - e.preventDefault(); - handleRenameCancel(); - } - }} - /> - ) : ( -
- - {graphTitle} - - -
- )} -
-
-
- - - - { - handleGeometryCommit?.('dx', value); - }} - onPreview={(value) => handleGeometryPreview?.('dx', value)} - onCancel={() => editGestureController.cancel()} - prefix="X" - width={AXIS_FIELD_WIDTH} - min={-9999} - max={9999} - allowDecimal - decimalScale={1} - /> - { - handleGeometryCommit?.('dy', value); - }} - onPreview={(value) => handleGeometryPreview?.('dy', value)} - onCancel={() => editGestureController.cancel()} - prefix="Y" - width={AXIS_FIELD_WIDTH} - min={-9999} - max={9999} - allowDecimal - decimalScale={1} - /> - - - - { - handleGeometryCommit?.('width', Math.max(20, value)); - }} - onPreview={(value) => - handleGeometryPreview?.('width', Math.max(20, value)) - } - onCancel={() => editGestureController.cancel()} - prefix="W" - width={AXIS_FIELD_WIDTH} - min={20} - max={9999} - /> - { - handleGeometryCommit?.('height', Math.max(20, value)); - }} - onPreview={(value) => - handleGeometryPreview?.('height', Math.max(20, value)) - } - onCancel={() => editGestureController.cancel()} - prefix="H" - width={AXIS_FIELD_WIDTH} - min={20} - max={9999} - /> - - - - - - - onElementPropertyCommit?.({ - property: 'graphType', - value: value as GraphItemType, - }) - } - /> - - - {(singleGraphPosition.graphType || 'line') === 'line' && ( -
-

- {t('propertiesPanel.graphShowAverageLine') || - 'Show Average Line'} -

- - onElementPropertyCommit?.({ - property: 'showAvgLine', - value: !(singleGraphPosition.showAvgLine ?? true), - }) - } - /> -
- )} - - - { - const clamped = Math.max(500, Math.min(5000, value)); - const snapped = Math.round(clamped / 100) * 100; - onElementPropertyCommit?.({ - property: 'graphSpeed', - value: snapped, - }); - }} - min={500} - max={5000} - suffix="ms" - /> - - - - {}} - onPreview={(value) => onGraphColorPreview?.(value)} - onChangeComplete={(value) => - onElementPropertyCommit?.({ - property: 'graphColor', - value: value, - }) - } - onCancel={() => editGestureController.cancel()} - colorId={`graph-color-${selectedKeyType}-${singleGraphPosition.id}`} - panelElement={panelElement} - /> - - -
-

- {t('propertiesPanel.graphAnimation') || 'Graph Animation'} -

- - onElementPropertyCommit?.({ - property: 'graphAnimationEnabled', - value: !( - singleGraphPosition.graphAnimationEnabled ?? true - ), - }) - } - /> -
-
- - - - {}} - onChangeComplete={() => {}} - gradientValue={singleGraphPosition.backgroundGradient ?? null} - canvasAnchor={ - singleGraphPosition.id && - isNativeElementId(singleGraphPosition.id) - ? { kind: 'graph', id: singleGraphPosition.id } - : undefined - } - onModePreview={(_state, modeValue) => - onPaintPreview?.({ - property: 'backgroundPaint', - value: paintDescriptor(modeValue), - }) - } - onModeCommit={(_state, modeValue) => - onPaintCommit?.({ - property: 'backgroundPaint', - value: paintDescriptor(modeValue), - }) - } - onCancel={() => editGestureController.cancel()} - colorId={`graph-bg-color-${selectedKeyType}-${singleGraphPosition.id}`} - panelElement={panelElement} - /> - - - - {}} - onChangeComplete={() => {}} - gradientValue={graphBorderDisplay.gradient} - canvasAnchor={ - singleGraphPosition.id && - isNativeElementId(singleGraphPosition.id) - ? { kind: 'graph', id: singleGraphPosition.id } - : undefined - } - onModePreview={(_state, modeValue) => - onPaintPreview?.({ - property: 'borderPaint', - value: paintDescriptor(modeValue), - }) - } - onModeCommit={(_state, modeValue) => - onPaintCommit?.({ - property: 'borderPaint', - value: paintDescriptor(modeValue), - }) - } - onCancel={() => editGestureController.cancel()} - colorId={`graph-border-color-${selectedKeyType}-${singleGraphPosition.id}`} - gradientSurface="border" - panelElement={panelElement} - /> - - - - - onStylePropertyCommit?.({ - property: 'borderWidth', - value: Math.max(0, Math.min(20, value)), - }) - } - min={0} - max={20} - suffix="px" - /> - - - - - onStylePropertyCommit?.({ - property: 'borderRadius', - value: Math.max(0, Math.min(100, value)), - }) - } - min={0} - max={100} - suffix="px" - /> - - - - - - - - {useCustomCSS && ( - -
-

- {t('propertiesPanel.useInlineStyles') || - '인라인 스타일 우선'} -

- - onElementPropertyCommit?.({ - property: 'useInlineStyles', - value: !(singleGraphPosition.useInlineStyles ?? false), - }) - } - /> -
- - - - onStylePropertyCommit?.({ - property: 'className', - value: value, - }) - } - placeholder="className" - width="90px" - /> - -
- )} -
-
-
- - - {showGraphImagePicker && graphImageButtonRef.current ? ( - - onInactiveImageCommit?.(imageUrl) - } - onIdleTransparentChange={(value: boolean) => - onIdleTransparentCommit?.(value) - } - onIdleImageFitChange={(fit: string) => - onIdleImageFitCommit?.(fit as ImageFit) - } - onIdleImageReset={() => onInactiveImageCommit?.('')} - onClose={() => setShowGraphImagePicker(false)} - /> - ) : null} - -
- ); -}; - -// ============================================================================ -// Single Knob Selection Panel -// ============================================================================ - -interface SingleKnobPanelProps { - setPanelElement: (el: HTMLDivElement | null) => void; - // 혼합 선택 시 패널 본문 상단에 표시할 플러그인 개수 안내 - singleKnobPosition: KnobItemPosition; - selectedKeyType: string; - isRenaming: boolean; - renameInputRef: React.RefObject; - renameValue: string; - setRenameValue: (value: string) => void; - renameCancelledRef: React.MutableRefObject; - handleRenameCommit: (value: string) => void; - handleRenameCancel: () => void; - handleRenameStart: () => void; - onElementPropertyCommit?: ( - patch: EditorElementPropertyPatchV1, - options?: { gestureId?: string }, - ) => void; - onInactiveImageCommit?: (inactiveImage: string) => void; - onActiveImageCommit?: (activeImage: string) => void; - onIdleTransparentCommit?: (idleTransparent: boolean) => void; - onActiveTransparentCommit?: (activeTransparent: boolean) => void; - onIdleImageFitCommit?: (idleImageFit: ImageFit) => void; - onActiveImageFitCommit?: (activeImageFit: ImageFit) => void; - onStylePropertyPreview?: (patch: EditorStylePropertyPreviewPatchV1) => void; - onStylePropertyCommit?: (patch: EditorPreviewStylePropertyPatchV1) => void; - onPaintPreview?: (patch: EditorPaintPropertyPatchV1) => void; - onPaintCommit?: (patch: EditorPaintPropertyPatchV1) => void; - onShadowCommit?: (patch: EditorShadowPropertyPatchV1) => void; - handleGeometryCommit?: (field: GeometryField, value: number) => void; - handleGeometryPreview?: (field: GeometryField, value: number) => void; - singleScrollRefFor: (tab: TabType) => (node: HTMLDivElement | null) => void; - panelElement: HTMLDivElement | null; - useCustomCSS: boolean; - t: (key: string) => string; -} - -export const SingleKnobPanel: React.FC = ({ - setPanelElement, - singleKnobPosition, - selectedKeyType, - isRenaming, - renameInputRef, - renameValue, - setRenameValue, - renameCancelledRef, - handleRenameCommit, - handleRenameCancel, - handleRenameStart, - onElementPropertyCommit, - onInactiveImageCommit, - onActiveImageCommit, - onIdleTransparentCommit, - onActiveTransparentCommit, - onIdleImageFitCommit, - onActiveImageFitCommit, - onStylePropertyCommit, - onPaintPreview, - onPaintCommit, - onStylePropertyPreview, - onShadowCommit, - handleGeometryCommit, - handleGeometryPreview, - singleScrollRefFor, - panelElement, - useCustomCSS, - t, -}) => { - const panelRef = useRef(null); - const imageButtonRef = useRef(null); - const [showImagePicker, setShowImagePicker] = useState(false); - const [axisCaptureTarget, setAxisCaptureTarget] = useState( - null, - ); - // 민감도는 오버레이 프리뷰 대상이 아니라 확정 전까지 패널 안에서만 값을 들고 있는다 - // 노브 id로 묶어야 대상이 바뀐 첫 렌더에 이전 노브의 미확정 값이 새 칸에 비치지 않는다 - const [sensitivityDraft, setSensitivityDraft] = useState<{ - id: string; - value: number; - } | null>(null); - const sensitivityDraftValue = - sensitivityDraft?.id === singleKnobPosition.id - ? sensitivityDraft.value - : null; - // draft는 커밋이 착지할 때 지운다 - 먼저 비우면 착지 전 한 프레임 옛 값이 비친다 - useEffect(() => { - setSensitivityDraft(null); - }, [singleKnobPosition.id, singleKnobPosition.sensitivity]); - const [classNameDraft, setClassNameDraft] = useState( - singleKnobPosition.className || '', - ); - // 캡처는 시작 ID를 동결해 그 노브에 완료를 적용한다. 표시도 소유권 기준이어야 - // 한다 - truthiness로 그리면 대상이 바뀐 뒤 현재 노브 행에 "감지 중"이 뜨고, - // 사용자가 노브를 돌리면 화면에 없는 옛 노브에 축이 바인딩된다 - const capturingThisKnob = axisCaptureTarget === singleKnobPosition.id; - - useEffect(() => { - setClassNameDraft(singleKnobPosition.className || ''); - }, [selectedKeyType, singleKnobPosition.className, singleKnobPosition.id]); - - // 회전 감지 바인딩: 노브를 돌리면 가장 많이 움직인 축을 자동 바인딩 - useEffect(() => { - if (!axisCaptureTarget) return; - axisEventBus.initialize(); - const counts = new Map(); - let bound = false; - const unsub = axisEventBus.subscribe(({ axisId }) => { - if (bound) return; - const c = (counts.get(axisId) ?? 0) + 1; - counts.set(axisId, c); - if (c >= 3) { - bound = true; - const persisted = patchKnobAxisIdById(axisCaptureTarget, axisId); - void persisted.catch(reportElementOpError); - setAxisCaptureTarget(null); - } - }); - const timer = window.setTimeout(() => setAxisCaptureTarget(null), 6000); - return () => { - unsub(); - window.clearTimeout(timer); - }; - }, [axisCaptureTarget]); - - const setRef = (node: HTMLDivElement | null) => { - panelRef.current = node; - setPanelElement(node); - }; - - const knobTitle = singleKnobPosition.layerName || 'Knob'; - const axisLabel = singleKnobPosition.axisId - ? singleKnobPosition.axisId.replace(/^HIDA:/, '') - : t('propertiesPanel.knobAxisUnset') || '미지정'; - - // 대기/입력 색상 (키 패널과 동일한 기본값/전환 로직) - const DEFAULT_KNOB_BACKGROUND_COLOR = DEFAULT_ELEMENT_BG; - const DEFAULT_KNOB_BORDER_COLOR = DEFAULT_ELEMENT_BORDER; - const DEFAULT_KNOB_ACTIVE_BACKGROUND_COLOR = DEFAULT_ELEMENT_ACTIVE_BG; - const DEFAULT_KNOB_ACTIVE_BORDER_COLOR = DEFAULT_ELEMENT_ACTIVE_BORDER; - - type KnobColorTarget = 'backgroundColor' | 'borderColor'; - type KnobColorProperty = - | KnobColorTarget - | 'activeBackgroundColor' - | 'activeBorderColor'; - - const [pickerFor, setPickerFor] = useState(null); - const [colorState, setColorState] = useState<'idle' | 'active'>('idle'); - const bgColorBtnRef = useRef(null); - const borderColorBtnRef = useRef(null); - - const [localColors, setLocalColors] = useState< - Record - >({ - backgroundColor: - singleKnobPosition.backgroundColor || DEFAULT_KNOB_BACKGROUND_COLOR, - activeBackgroundColor: - singleKnobPosition.activeBackgroundColor || - singleKnobPosition.backgroundColor || - DEFAULT_KNOB_ACTIVE_BACKGROUND_COLOR, - borderColor: singleKnobPosition.borderColor || DEFAULT_KNOB_BORDER_COLOR, - activeBorderColor: - singleKnobPosition.activeBorderColor || - singleKnobPosition.borderColor || - DEFAULT_KNOB_ACTIVE_BORDER_COLOR, - }); - - // 피커는 대상 변경 시 닫는다. 축 캡처는 시작 ID를 별도로 동결하므로 - // 재정렬이나 모드 전환 뒤에도 시작 대상을 유지한다 - useEffect(() => { - setPickerFor(null); - setShowImagePicker(false); - }, [selectedKeyType, singleKnobPosition.id]); - - // 피커가 닫혀있을 때만 외부 prop과 동기화 - useEffect(() => { - if (!pickerFor) { - setLocalColors({ - backgroundColor: - singleKnobPosition.backgroundColor || DEFAULT_KNOB_BACKGROUND_COLOR, - activeBackgroundColor: - singleKnobPosition.activeBackgroundColor || - singleKnobPosition.backgroundColor || - DEFAULT_KNOB_ACTIVE_BACKGROUND_COLOR, - borderColor: - singleKnobPosition.borderColor || DEFAULT_KNOB_BORDER_COLOR, - activeBorderColor: - singleKnobPosition.activeBorderColor || - singleKnobPosition.borderColor || - DEFAULT_KNOB_ACTIVE_BORDER_COLOR, - }); - } - }, [ - pickerFor, - singleKnobPosition.backgroundColor, - singleKnobPosition.activeBackgroundColor, - singleKnobPosition.borderColor, - singleKnobPosition.activeBorderColor, - DEFAULT_KNOB_BACKGROUND_COLOR, - DEFAULT_KNOB_ACTIVE_BACKGROUND_COLOR, - DEFAULT_KNOB_BORDER_COLOR, - DEFAULT_KNOB_ACTIVE_BORDER_COLOR, - ]); - - const resolveColorProperty = (target: KnobColorTarget): KnobColorProperty => - colorState === 'active' - ? target === 'backgroundColor' - ? 'activeBackgroundColor' - : 'activeBorderColor' - : target; - - const activeColorPropertyFor = ( - target: KnobColorTarget, - ): 'activeBackgroundColor' | 'activeBorderColor' => - target === 'backgroundColor' - ? 'activeBackgroundColor' - : 'activeBorderColor'; - - const isNonEmptyString = (value: unknown): value is string => - typeof value === 'string' && value.trim().length > 0; - - const colorValueFor = (target: KnobColorTarget): string => - localColors[resolveColorProperty(target)]; - - const handleColorChange = (target: KnobColorTarget, color: string) => { - const prop = resolveColorProperty(target); - setLocalColors((prev) => ({ ...prev, [prop]: color })); - }; - - // ── 그라데이션 배선 (키 패널과 동일 패턴) — 단색 커밋도 이 경로로 통합 ── - - // 테두리 미지정이면 렌더와 같은 기본 립을 편집 대상으로 보여 준다 - const defaultKnobBorderGradientFor = ( - active: boolean, - ): GradientSpec | null => { - const resolved = resolveElementBorder(singleKnobPosition, active); - return resolved.isDefault ? resolved.gradient : null; - }; - - const storedGradientOf = (prop: KnobColorProperty): GradientSpec | null => { - switch (prop) { - case 'backgroundColor': - return singleKnobPosition.backgroundGradient ?? null; - case 'activeBackgroundColor': - return singleKnobPosition.activeBackgroundGradient ?? null; - case 'borderColor': - return ( - singleKnobPosition.borderGradient ?? - defaultKnobBorderGradientFor(false) - ); - case 'activeBorderColor': - return ( - singleKnobPosition.activeBorderGradient ?? - defaultKnobBorderGradientFor(true) - ); - default: - return null; - } - }; - - const gradientSpecFor = (target: KnobColorTarget): GradientSpec | null => { - const idleGradient = storedGradientOf(target); - if (colorState !== 'active') return idleGradient; - const activeProp = activeColorPropertyFor(target); - const activeGradient = storedGradientOf(activeProp); - const activeHasValue = - isNonEmptyString(singleKnobPosition[activeProp]) || - activeGradient != null; - return activeHasValue ? activeGradient : idleGradient; - }; - - const handleGradientCommit = (value: ColorModeValue) => { - if (!pickerFor) return; - const prop = resolveColorProperty(pickerFor); - const descriptor = paintDescriptor(value); - const paintField = - colorState === 'active' - ? pickerFor === 'backgroundColor' - ? 'activeBackgroundPaint' - : 'activeBorderPaint' - : pickerFor === 'backgroundColor' - ? 'backgroundPaint' - : 'borderPaint'; - setLocalColors((prev) => ({ ...prev, [prop]: descriptor.color })); - onPaintCommit?.({ property: paintField, value: descriptor } as never); - }; - - const knobGradientState = useGradientColorState({ - pair: pickerFor - ? { - color: colorValueFor(pickerFor), - gradient: gradientSpecFor(pickerFor), - } - : {}, - fallbackColor: '#ffffff', - contextKey: `knob:${selectedKeyType}:${singleKnobPosition.id}:${ - pickerFor ?? 'none' - }:${colorState}`, - canvasAnchor: pickerFor - ? singleKnobPosition.id && isNativeElementId(singleKnobPosition.id) - ? { kind: 'knob', id: singleKnobPosition.id } - : undefined - : undefined, - canvasSurface: pickerFor === 'borderColor' ? 'border' : 'background', - canvasState: colorState, - onPreview: (value) => { - if (!pickerFor) return; - const prop = resolveColorProperty(pickerFor); - const descriptor = paintDescriptor(value); - setLocalColors((prev) => ({ ...prev, [prop]: descriptor.color })); - const paintField = - colorState === 'active' - ? pickerFor === 'backgroundColor' - ? 'activeBackgroundPaint' - : 'activeBorderPaint' - : pickerFor === 'backgroundColor' - ? 'backgroundPaint' - : 'borderPaint'; - onPaintPreview?.({ property: paintField, value: descriptor } as never); - }, - onCancel: () => editGestureController.cancel(), - onCommit: handleGradientCommit, - }); - - const handlePickerToggle = (target: KnobColorTarget) => { - setPickerFor((prev) => (prev === target ? null : target)); - // 새로 열 때는 항상 대기 탭에서 시작 - if (pickerFor !== target) setColorState('idle'); - }; - - // 그림자·이미지 피커의 캔버스 상태 프리뷰 대상 (색 피커는 세션 훅이 발행) - const knobPreviewAnchor: EditStateAnchor | null = - singleKnobPosition.id && isNativeElementId(singleKnobPosition.id) - ? { kind: 'knob', id: singleKnobPosition.id } - : null; - - // 라운딩 기본값: 미지정 시 원형(짧은 변의 절반) - const effectiveBorderRadius = - singleKnobPosition.borderRadius ?? - Math.round( - Math.min( - singleKnobPosition.width || 60, - singleKnobPosition.height || 60, - ) / 2, - ); - const knobIdleShadow = resolveElementShadowForPosition({ - position: singleKnobPosition, - elementType: 'knob', - active: false, - defaultShadow: DEFAULT_ELEMENT_SHADOW_SPEC, - defaultActiveShadow: DEFAULT_ELEMENT_ACTIVE_SHADOW_SPEC, - }); - const knobActiveShadow = resolveElementShadowForPosition({ - position: singleKnobPosition, - elementType: 'knob', - active: true, - defaultShadow: DEFAULT_ELEMENT_SHADOW_SPEC, - defaultActiveShadow: DEFAULT_ELEMENT_ACTIVE_SHADOW_SPEC, - }); - - return ( -
-
- {isRenaming ? ( - setRenameValue(e.target.value)} - onBlur={() => { - if (!renameCancelledRef.current) { - handleRenameCommit(renameValue); - } - renameCancelledRef.current = false; - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - (e.target as HTMLInputElement).blur(); - } else if (e.key === 'Escape') { - e.preventDefault(); - handleRenameCancel(); - } - }} - /> - ) : ( -
- - {knobTitle} - - -
- )} -
- -
-
- - {/* 노브 매핑 (키 매핑과 동일한 라벨/버튼 구조) */} - - - - - - - - - { - handleGeometryCommit?.('dx', value); - }} - onPreview={(value) => handleGeometryPreview?.('dx', value)} - onCancel={() => editGestureController.cancel()} - prefix="X" - width={AXIS_FIELD_WIDTH} - min={-9999} - max={9999} - allowDecimal - decimalScale={1} - /> - { - handleGeometryCommit?.('dy', value); - }} - onPreview={(value) => handleGeometryPreview?.('dy', value)} - onCancel={() => editGestureController.cancel()} - prefix="Y" - width={AXIS_FIELD_WIDTH} - min={-9999} - max={9999} - allowDecimal - decimalScale={1} - /> - - - - { - handleGeometryCommit?.('width', Math.max(20, value)); - }} - onPreview={(value) => - handleGeometryPreview?.('width', Math.max(20, value)) - } - onCancel={() => editGestureController.cancel()} - prefix="W" - width={AXIS_FIELD_WIDTH} - min={20} - max={9999} - /> - { - handleGeometryCommit?.('height', Math.max(20, value)); - }} - onPreview={(value) => - handleGeometryPreview?.('height', Math.max(20, value)) - } - onCancel={() => editGestureController.cancel()} - prefix="H" - width={AXIS_FIELD_WIDTH} - min={20} - max={9999} - /> - - - - - {/* 회전 배율: 1 = 물리 1회전당 화면 1회전 (축 해상도 무관) */} - - { - const next = Math.max(0, value); - setSensitivityDraft({ - id: singleKnobPosition.id, - value: next, - }); - onElementPropertyCommit?.({ - property: 'sensitivity', - value: next, - }); - }} - onPreview={(value) => - setSensitivityDraft({ - id: singleKnobPosition.id, - value: Math.max(0, value), - }) - } - onCancel={() => setSensitivityDraft(null)} - suffix="×" - min={0} - max={100} - allowDecimal - decimalScale={2} - /> - - -
-

- {t('propertiesPanel.knobReverse') || '방향 반전'} -

- - onElementPropertyCommit?.({ - property: 'reverse', - value: !(singleKnobPosition.reverse ?? false), - }) - } - /> -
-
- - - {/* 배경색 (대기/입력 상태 전환은 피커 내부 토글) */} - - handlePickerToggle('backgroundColor')} - open={pickerFor === 'backgroundColor'} - className="w-[23px] h-[23px] rounded-md cursor-pointer transition-shadow flex-shrink-0" - surfaceClassName="rounded-md" - color={colorValueFor('backgroundColor')} - image={(() => { - const spec = gradientSpecFor('backgroundColor'); - return spec ? gradientToCss(spec) : undefined; - })()} - /> - - - {/* 테두리 색상 */} - - handlePickerToggle('borderColor')} - open={pickerFor === 'borderColor'} - className="w-[23px] h-[23px] rounded-md cursor-pointer transition-shadow flex-shrink-0" - surfaceClassName="rounded-md" - color={colorValueFor('borderColor')} - image={(() => { - const spec = gradientSpecFor('borderColor'); - return spec ? gradientToCss(spec) : undefined; - })()} - /> - - - {/* 테두리 두께 */} - - - onStylePropertyCommit?.({ - property: 'borderWidth', - value: value, - }) - } - suffix="px" - min={0} - max={20} - /> - - - {/* 모서리 반경 (미지정 시 원형) */} - - - onStylePropertyCommit?.({ - property: 'borderRadius', - value: value, - }) - } - suffix="px" - min={0} - max={999} - /> - - - - - - - {useCustomCSS && ( - <> -
-

- {t('propertiesPanel.useInlineStyles') || - '인라인 스타일 우선'} -

- - onElementPropertyCommit?.({ - property: 'useInlineStyles', - value: !(singleKnobPosition.useInlineStyles ?? false), - }) - } - /> -
- - - - onStylePropertyCommit?.({ - property: 'className', - value: value, - }) - } - placeholder="className" - width="90px" - /> - - - )} -
- - { - const leaf = elementShadowLeafFromPartial(patch); - if (!leaf) return; - onShadowCommit?.( - state === 'active' - ? { property: 'activeShadow', value: leaf } - : { property: 'shadow', value: leaf }, - ); - }} - onPreview={(state, leaf) => - onStylePropertyPreview?.({ - property: state === 'active' ? 'activeShadow' : 'shadow', - value: leaf, - }) - } - onPreviewCancel={() => editGestureController.cancel()} - onEnabledChange={(enabled) => { - onShadowCommit?.({ property: 'shadowEnabled', value: enabled }); - }} - panelElement={panelElement} - t={t} - /> -
-
-
- - - {showImagePicker && imageButtonRef.current ? ( - - onInactiveImageCommit?.(imageUrl) - } - onActiveImageChange={(imageUrl: string) => - onActiveImageCommit?.(imageUrl) - } - onIdleTransparentChange={(value: boolean) => - onIdleTransparentCommit?.(value) - } - onActiveTransparentChange={(value: boolean) => - onActiveTransparentCommit?.(value) - } - onIdleImageFitChange={(fit: string) => - onIdleImageFitCommit?.(fit as ImageFit) - } - onActiveImageFitChange={(fit: string) => - onActiveImageFitCommit?.(fit as ImageFit) - } - onIdleImageReset={() => onInactiveImageCommit?.('')} - onActiveImageReset={() => onActiveImageCommit?.('')} - onClose={() => setShowImagePicker(false)} - /> - ) : null} - - - {/* 대기/입력 색상 ColorPicker (키 패널과 동일한 stateMode 토글) */} - - {pickerFor ? ( - - knobGradientState.handlePickerColorChange(c, false) - } - onColorChangeComplete={(c: string) => - knobGradientState.handlePickerColorChange(c, true) - } - onInputCancel={(_target, restoredColor) => { - knobGradientState.cancelPreview(); - if (pickerFor && typeof restoredColor === 'string') { - handleColorChange(pickerFor, restoredColor); - } - editGestureController.cancel(); - }} - onClose={() => setPickerFor(null)} - solidOnly={true} - stateMode={colorState} - onStateModeChange={setColorState} - interactiveRefs={[bgColorBtnRef, borderColorBtnRef]} - headerSlot={knobGradientState.headerSlot} - footerSlot={knobGradientState.footerSlot} - gradientSpec={knobGradientState.paletteGradientSpec} - onGradientSpecSelect={knobGradientState.handleGradientSpecSelect} - /> - ) : null} - -
- ); -}; +export { SingleGraphPanel } from './SingleGraphPanel'; +export { SingleKnobPanel } from './SingleKnobPanel'; // ============================================================================ // Single Key/Stat Selection Panel diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/singlePanelModel.ts b/src/renderer/components/main/Grid/PropertiesPanel/single/singlePanelModel.ts new file mode 100644 index 000000000..0ce069140 --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/singlePanelModel.ts @@ -0,0 +1,15 @@ +import type { StatItemType } from '@src/types/key/statItems'; + +export const getStatTypeLabel = (statType?: StatItemType | null): string => { + switch (statType) { + case 'kpsAvg': + return 'AVG'; + case 'kpsMax': + return 'MAX'; + case 'total': + return 'Total'; + case 'kps': + default: + return 'KPS'; + } +}; From 6f7c1dab4f47af8d87da3c6db2725736ba47de82 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 06:30:37 +0900 Subject: [PATCH 009/245] =?UTF-8?q?refactor:=20=EC=8A=A4=ED=86=A0=EC=96=B4?= =?UTF-8?q?=20=EC=82=AC=EC=9A=B4=EB=93=9C=20=EC=9E=90=EC=82=B0=20=EB=B3=B5?= =?UTF-8?q?=EA=B5=AC=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/state/store.rs | 1047 +------------------- src-tauri/src/state/store/sound_assets.rs | 1052 +++++++++++++++++++++ 2 files changed, 1066 insertions(+), 1033 deletions(-) create mode 100644 src-tauri/src/state/store/sound_assets.rs diff --git a/src-tauri/src/state/store.rs b/src-tauri/src/state/store.rs index 0107d044a..40d70ae36 100644 --- a/src-tauri/src/state/store.rs +++ b/src-tauri/src/state/store.rs @@ -1,12 +1,12 @@ use std::{ collections::{HashMap, HashSet, VecDeque}, fs, - path::{Component, Path, PathBuf}, + path::{Path, PathBuf}, sync::{ atomic::{AtomicU64, Ordering}, Arc, }, - time::{Duration, Instant, SystemTime, UNIX_EPOCH}, + time::Instant, }; use crate::errors::{EditorCommitError, EditorCommitErrorCode}; @@ -20,7 +20,7 @@ use crate::models::{ SettingsDiff, SettingsPatchInput, SettingsState, EDITOR_SCHEMA_VERSION, }; use anyhow::{anyhow, Context, Result}; -use parking_lot::{Mutex, RwLock, RwLockWriteGuard}; +use parking_lot::{RwLock, RwLockWriteGuard}; use serde_json::Value; use tauri::path::PathResolver; use tauri::Runtime; @@ -45,7 +45,6 @@ use super::history::{ HISTORY_ENTRY_TOO_LARGE, HISTORY_INVALID_OPPOSITE_ENTRY, HISTORY_IN_PROGRESS, HISTORY_SCOPE_MISMATCH, HISTORY_TARGET_ALREADY_APPLIED, INVALID_HISTORY_OPERATION_ID, }; -use super::local_asset_path::path_identity_key; use super::migration::{ find_legacy_store_file, load_store_from_path, migrate_key_images_to_app_data, migrate_local_fonts_to_app_data, normalize_state, rehome_foreign_asset_references, @@ -62,6 +61,7 @@ use super::plugin::{ mod asset_references; mod persistence; +mod sound_assets; use asset_references::{ collect_local_font_paths, collect_local_image_paths, collect_local_sound_paths, @@ -72,11 +72,18 @@ use asset_references::{ collect_local_image_path_keys, collect_local_sound_path_keys, iter_all_positions, }; use persistence::StoreWriter; +pub(crate) use sound_assets::{ + move_staged_sound_deletions_to_trash, restore_staged_sound_deletions, + stage_sound_files_for_deletion, StagedSoundDeletionFile, PROCESSED_WAV_TRANSACTION_LOCK, +}; +#[cfg(test)] +use sound_assets::{ + purge_expired_trash_sessions_at, recover_interrupted_processed_wav_replacements_with, + recover_interrupted_sound_deletions_with, sweep_unreferenced_asset_files, system_time_millis, + TrashSession, TRASH_RETENTION, +}; -const TRASH_RETENTION: Duration = Duration::from_secs(30 * 24 * 60 * 60); -const SOUND_DELETE_BACKUP_MARKER: &str = ".delete-backup-"; const KEY_COUNTER_BASELINE_REQUIRED: &str = "KEY_COUNTER_BASELINE_REQUIRED"; -pub(crate) static PROCESSED_WAV_TRANSACTION_LOCK: Mutex<()> = Mutex::new(()); fn key_counter_baseline_required() -> EditorCommitError { EditorCommitError::validation( @@ -85,26 +92,6 @@ fn key_counter_baseline_required() -> EditorCommitError { ) } -#[derive(Debug)] -pub(crate) struct StagedSoundDeletionFile { - source_path: PathBuf, - backup_path: PathBuf, -} - -struct SoundRecoveryOutcome { - protected_keys: HashSet, - complete: bool, -} - -impl SoundRecoveryOutcome { - fn complete() -> Self { - Self { - protected_keys: HashSet::new(), - complete: true, - } - } -} - pub struct AppStore { path: PathBuf, state: RwLock, @@ -2943,205 +2930,6 @@ impl AppStore { _admission: admission, }) } - - /// 앱 종료 시점에 한 번 호출하는 자원 정리. - /// 현재 store에서 참조하지 않는 자산은 30일 보관되는 trash 세션으로 격리 - #[cfg(test)] - pub fn cleanup_orphan_assets_now(&self) -> Result<()> { - let _transaction_guard = PROCESSED_WAV_TRANSACTION_LOCK.lock(); - self.cleanup_orphan_assets_while_locked() - } - - fn cleanup_orphan_assets_while_locked(&self) -> Result<()> { - let app_data_dir = self - .path - .parent() - .context("failed to resolve app data directory from store path")?; - - let fonts_dir = app_data_dir.join("fonts"); - let images_dir = app_data_dir.join("images"); - let sounds_dir = app_data_dir.join("sounds"); - let trash_dir = app_data_dir.join("trash"); - - self.recover_pending_processed_wav_replacement(&sounds_dir)?; - let processed_recovery = recover_interrupted_processed_wav_replacements(&sounds_dir)?; - let deletion_recovery = - recover_interrupted_sound_deletions(&self.state.read().data, &sounds_dir, &trash_dir)?; - let sound_recovery_complete = processed_recovery.complete && deletion_recovery.complete; - let mut recovered_sound_keys = processed_recovery.protected_keys; - recovered_sound_keys.extend(deletion_recovery.protected_keys); - purge_expired_trash_sessions(&trash_dir)?; - - if self.skip_asset_sweep { - log::warn!( - "[Assets] Skipping orphan asset sweep for a recovered or legacy-migrated store session" - ); - return Ok(()); - } - - let snapshot = self.state.read().data.clone(); - let referenced_fonts = collect_local_font_paths(app_data_dir, &snapshot); - let referenced_images = collect_local_image_paths(app_data_dir, &snapshot); - let mut referenced_sounds = collect_local_sound_paths(app_data_dir, &snapshot); - referenced_sounds.keys.extend(recovered_sound_keys); - referenced_sounds.complete &= sound_recovery_complete; - let mut trash_session = TrashSession::new(trash_dir, SystemTime::now())?; - - if referenced_fonts.complete { - sweep_unreferenced_asset_files( - "Fonts", - &fonts_dir, - &referenced_fonts.keys, - &mut trash_session, - )?; - } else { - log::warn!("[Fonts] Skipping asset sweep because a local path could not be resolved"); - } - if referenced_images.complete { - sweep_unreferenced_asset_files( - "Images", - &images_dir, - &referenced_images.keys, - &mut trash_session, - )?; - } else { - log::warn!("[Images] Skipping asset sweep because a local path could not be resolved"); - } - if referenced_sounds.complete { - sweep_unreferenced_asset_files( - "Sounds", - &sounds_dir, - &referenced_sounds.keys, - &mut trash_session, - )?; - } else { - log::warn!("[Sounds] Skipping asset sweep because a local path could not be resolved"); - } - Ok(()) - } - - pub(crate) fn flush_cleanup_and_shutdown(&self) -> Result<()> { - let _transaction_guard = PROCESSED_WAV_TRANSACTION_LOCK.lock(); - - let flush_result = self.flush(); - let cleanup_result = if flush_result.is_ok() { - self.cleanup_orphan_assets_while_locked() - } else { - Ok(()) - }; - let shutdown_result = self.flush_and_shutdown(); - - match (flush_result, cleanup_result, shutdown_result) { - (Ok(()), Ok(()), Ok(())) => Ok(()), - (Err(flush), _, Err(shutdown)) => Err(anyhow!( - "failed to flush store before cleanup: {flush:#}; failed to shut down store writer: {shutdown:#}" - )), - (Err(flush), _, Ok(())) => { - Err(anyhow!("failed to flush store before asset cleanup: {flush:#}")) - } - (Ok(()), Err(cleanup), Err(shutdown)) => Err(anyhow!( - "failed to clean up assets: {cleanup:#}; failed to shut down store writer: {shutdown:#}" - )), - (Ok(()), Err(cleanup), Ok(())) => Err(cleanup), - (Ok(()), Ok(()), Err(shutdown)) => Err(shutdown), - } - } - - pub fn recover_interrupted_processed_wav_replacements_now(&self) -> Result<()> { - let _transaction_guard = PROCESSED_WAV_TRANSACTION_LOCK.lock(); - self.recover_interrupted_processed_wav_replacements_while_locked() - } - - /// `PROCESSED_WAV_TRANSACTION_LOCK`을 이미 보유한 편집 명령 전용 - pub(crate) fn recover_interrupted_processed_wav_replacements_while_locked(&self) -> Result<()> { - self.recover_interrupted_sound_assets_while_locked() - .map(|_| ()) - } - - /// `PROCESSED_WAV_TRANSACTION_LOCK`을 이미 보유한 목록 명령 전용 - pub(crate) fn prepare_sound_listing_while_locked(&self) -> Result { - self.recover_interrupted_sound_assets_while_locked() - } - - fn recover_interrupted_sound_assets_while_locked(&self) -> Result { - let app_data_dir = self - .path - .parent() - .context("failed to resolve app data directory from store path")?; - let sounds_dir = app_data_dir.join("sounds"); - let trash_dir = app_data_dir.join("trash"); - self.recover_pending_processed_wav_replacement(&sounds_dir)?; - let processed_recovery = recover_interrupted_processed_wav_replacements(&sounds_dir)?; - let deletion_recovery = - recover_interrupted_sound_deletions(&self.state.read().data, &sounds_dir, &trash_dir)?; - Ok(processed_recovery.complete && deletion_recovery.complete) - } - - fn recover_pending_processed_wav_replacement(&self, sounds_dir: &Path) -> Result<()> { - self.recover_pending_processed_wav_replacement_with(sounds_dir, Path::try_exists) - } - - fn recover_pending_processed_wav_replacement_with( - &self, - sounds_dir: &Path, - mut try_exists: TryExists, - ) -> Result<()> - where - TryExists: FnMut(&Path) -> std::io::Result, - { - let Some(pending) = self - .state - .read() - .data - .pending_processed_wav_replacement - .clone() - else { - return Ok(()); - }; - - let target_path = validate_pending_processed_wav_target(sounds_dir, &pending.sound_path)?; - let backup_path = processed_wav_backup_path(&target_path)?; - let target_exists = try_exists(&target_path).with_context(|| { - format!( - "failed to check pending WAV target at {}", - target_path.display() - ) - })?; - let backup_exists = try_exists(&backup_path).with_context(|| { - format!( - "failed to check pending WAV backup at {}", - backup_path.display() - ) - })?; - let files_missing = !target_exists && !backup_exists; - - if files_missing { - log::warn!( - "[Sounds] Clearing unrecoverable WAV transaction because target '{}' and backup '{}' are both missing", - target_path.display(), - backup_path.display() - ); - } else { - rollback_pending_processed_wav( - &target_path, - pending.had_original, - target_exists, - backup_exists, - )?; - } - self.update(|data| { - if data.pending_processed_wav_replacement.as_ref() == Some(&pending) { - data.pending_processed_wav_replacement = None; - } - })?; - if !files_missing { - log::info!( - "[Sounds] Recovered interrupted WAV transaction for '{}'", - target_path.display() - ); - } - Ok(()) - } } impl Drop for AppStore { @@ -3554,813 +3342,6 @@ fn insert_gesture_mutation_ack( }); } -fn validate_pending_processed_wav_target(sounds_dir: &Path, value: &str) -> Result { - let target = PathBuf::from(value); - if !target.is_absolute() - || target - .components() - .any(|component| matches!(component, Component::CurDir | Component::ParentDir)) - { - return Err(anyhow!("invalid pending WAV path")); - } - - let canonical_sounds_dir = fs::canonicalize(sounds_dir).with_context(|| { - format!( - "failed to resolve sound directory during WAV recovery at {}", - sounds_dir.display() - ) - })?; - let boundary_path = (if target.exists() { - fs::canonicalize(&target).map_err(anyhow::Error::from) - } else { - target - .parent() - .context("pending WAV path has no parent") - .and_then(|parent| fs::canonicalize(parent).map_err(anyhow::Error::from)) - }) - .with_context(|| { - format!( - "failed to resolve pending WAV boundary at {}", - target.display() - ) - })?; - - if !boundary_path.starts_with(&canonical_sounds_dir) { - return Err(anyhow!("pending WAV path escapes the sound directory")); - } - Ok(target) -} - -fn processed_wav_backup_path(path: &Path) -> Result { - let mut file_name = path - .file_name() - .context("pending WAV path has no file name")? - .to_os_string(); - file_name.push(".bak"); - Ok(path.with_file_name(file_name)) -} - -fn interrupted_wav_path(path: &Path) -> PathBuf { - let mut file_name = path.file_name().unwrap_or_default().to_os_string(); - file_name.push(format!(".interrupted-{}", uuid::Uuid::new_v4())); - path.with_file_name(file_name) -} - -fn rollback_pending_processed_wav( - target_path: &Path, - had_original: bool, - target_exists: bool, - backup_exists: bool, -) -> Result<()> { - let backup_path = processed_wav_backup_path(target_path)?; - if !had_original { - if target_exists { - let interrupted_path = interrupted_wav_path(target_path); - fs::rename(target_path, &interrupted_path).with_context(|| { - format!( - "failed to quarantine uncommitted WAV '{}' at '{}'", - target_path.display(), - interrupted_path.display() - ) - })?; - } - return Ok(()); - } - - if !backup_exists { - if target_exists { - return Ok(()); - } - return Err(anyhow!( - "both the original WAV and its backup are missing during recovery" - )); - } - - if !target_exists { - fs::rename(&backup_path, target_path).with_context(|| { - format!( - "failed to restore WAV backup '{}' to '{}'", - backup_path.display(), - target_path.display() - ) - })?; - return Ok(()); - } - - let interrupted_path = interrupted_wav_path(target_path); - fs::rename(target_path, &interrupted_path).with_context(|| { - format!( - "failed to move uncommitted WAV '{}' to '{}'", - target_path.display(), - interrupted_path.display() - ) - })?; - if let Err(error) = fs::rename(&backup_path, target_path) { - return match fs::rename(&interrupted_path, target_path) { - Ok(()) => Err(error).with_context(|| { - format!( - "failed to restore WAV backup '{}' to '{}'", - backup_path.display(), - target_path.display() - ) - }), - Err(recovery_error) => Err(anyhow!( - "failed to restore WAV backup: {error}; failed to put uncommitted WAV back: {recovery_error}" - )), - }; - } - Ok(()) -} - -fn recover_interrupted_processed_wav_replacements( - sounds_dir: &Path, -) -> Result { - recover_interrupted_processed_wav_replacements_with(sounds_dir, |from, to| fs::rename(from, to)) -} - -fn recover_interrupted_processed_wav_replacements_with( - sounds_dir: &Path, - mut rename: Rename, -) -> Result -where - Rename: FnMut(&Path, &Path) -> std::io::Result<()>, -{ - let mut outcome = SoundRecoveryOutcome::complete(); - if !sounds_dir.exists() { - return Ok(outcome); - } - - let read_dir = fs::read_dir(sounds_dir).with_context(|| { - format!( - "failed to read sound directory for recovery at {}", - sounds_dir.display() - ) - })?; - - for entry in read_dir { - let entry = match entry { - Ok(entry) => entry, - Err(err) => { - outcome.complete = false; - log::warn!( - "[Sounds] Failed to read an entry from '{}' during recovery: {err}", - sounds_dir.display() - ); - continue; - } - }; - let backup_path = entry.path(); - let is_file = match entry.file_type() { - Ok(file_type) => file_type.is_file(), - Err(err) => { - outcome.complete = false; - log::warn!( - "[Sounds] Failed to inspect '{}' during recovery: {err}", - backup_path.display() - ); - continue; - } - }; - if !is_file || !backup_path.starts_with(sounds_dir) { - continue; - } - - let Some(file_name) = backup_path.file_name().and_then(|name| name.to_str()) else { - continue; - }; - let Some(target_name) = file_name.strip_suffix(".bak") else { - continue; - }; - let target_path = backup_path.with_file_name(target_name); - if !target_path - .extension() - .and_then(|extension| extension.to_str()) - .is_some_and(|extension| extension.eq_ignore_ascii_case("wav")) - { - continue; - } - - match target_path.try_exists() { - Ok(true) => continue, - Ok(false) => {} - Err(err) => { - outcome.complete = false; - outcome - .protected_keys - .insert(path_identity_key(&backup_path)); - log::warn!( - "[Sounds] Failed to inspect processed WAV target '{}': {err}", - target_path.display() - ); - continue; - } - } - - if let Err(err) = rename(&backup_path, &target_path) { - outcome.complete = false; - outcome - .protected_keys - .insert(path_identity_key(&backup_path)); - log::warn!( - "[Sounds] Failed to recover processed WAV '{}' from '{}': {err}", - target_path.display(), - backup_path.display() - ); - continue; - } - log::info!( - "[Sounds] Recovered processed WAV '{}'", - target_path.display() - ); - } - - Ok(outcome) -} - -struct TrashSession { - trash_dir: PathBuf, - timestamp_millis: u128, - next_suffix: u64, - session_dir: Option, -} - -impl TrashSession { - fn new(trash_dir: PathBuf, now: SystemTime) -> Result { - Ok(Self { - trash_dir, - timestamp_millis: system_time_millis(now)?, - next_suffix: 0, - session_dir: None, - }) - } - - fn destination_path(&mut self, source_dir: &Path, source_path: &Path) -> Result { - let category = source_dir.file_name().with_context(|| { - format!( - "failed to resolve asset category from {}", - source_dir.display() - ) - })?; - let file_name = source_path.file_name().with_context(|| { - format!( - "failed to resolve asset file name from {}", - source_path.display() - ) - })?; - - loop { - let session_dir = self.ensure_session_dir()?; - let category_dir = session_dir.join(category); - fs::create_dir_all(&category_dir).with_context(|| { - format!( - "failed to create trash category directory at {}", - category_dir.display() - ) - })?; - let destination = category_dir.join(file_name); - if !destination.exists() { - return Ok(destination); - } - - self.session_dir = None; - } - } - - fn ensure_session_dir(&mut self) -> Result { - if let Some(path) = self.session_dir.as_ref() { - return Ok(path.clone()); - } - - fs::create_dir_all(&self.trash_dir).with_context(|| { - format!( - "failed to create asset trash directory at {}", - self.trash_dir.display() - ) - })?; - - loop { - let suffix = self.next_suffix; - self.next_suffix = self - .next_suffix - .checked_add(1) - .context("asset trash session suffix overflow")?; - let name = if suffix == 0 { - self.timestamp_millis.to_string() - } else { - format!("{}-{suffix}", self.timestamp_millis) - }; - let candidate = self.trash_dir.join(name); - match fs::create_dir(&candidate) { - Ok(()) => { - self.session_dir = Some(candidate.clone()); - return Ok(candidate); - } - Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue, - Err(err) => { - return Err(err).with_context(|| { - format!( - "failed to create asset trash session at {}", - candidate.display() - ) - }); - } - } - } - } -} - -pub(crate) fn stage_sound_files_for_deletion( - source_paths: &[PathBuf], -) -> Result> { - let mut staged = Vec::new(); - let mut seen = HashSet::new(); - - for source_path in source_paths { - let key = path_identity_key(source_path); - if !seen.insert(key) { - continue; - } - match source_path.try_exists() { - Ok(false) => continue, - Ok(true) if !source_path.is_file() => { - let error = anyhow!( - "sound deletion target is not a file at {}", - source_path.display() - ); - return rollback_staged_after_error(staged, error); - } - Ok(true) => {} - Err(error) => { - return rollback_staged_after_error( - staged, - anyhow!( - "failed to inspect sound deletion target at {}: {error}", - source_path.display() - ), - ); - } - } - - let backup_path = match sound_delete_backup_path(source_path) { - Ok(path) => path, - Err(error) => return rollback_staged_after_error(staged, error), - }; - if let Err(error) = fs::rename(source_path, &backup_path) { - return rollback_staged_after_error( - staged, - anyhow!( - "failed to stage sound deletion '{}' -> '{}': {error}", - source_path.display(), - backup_path.display() - ), - ); - } - staged.push(StagedSoundDeletionFile { - source_path: source_path.clone(), - backup_path, - }); - } - - Ok(staged) -} - -pub(crate) fn restore_staged_sound_deletions(staged: &[StagedSoundDeletionFile]) -> Result<()> { - let mut failures = Vec::new(); - for file in staged.iter().rev() { - match file.backup_path.try_exists() { - Ok(false) => continue, - Ok(true) => {} - Err(error) => { - failures.push(format!( - "failed to inspect staged backup '{}': {error}", - file.backup_path.display() - )); - continue; - } - } - match file.source_path.try_exists() { - Ok(true) => { - failures.push(format!( - "source path already exists at {}", - file.source_path.display() - )); - continue; - } - Ok(false) => {} - Err(error) => { - failures.push(format!( - "failed to inspect source path '{}': {error}", - file.source_path.display() - )); - continue; - } - } - if let Err(error) = fs::rename(&file.backup_path, &file.source_path) { - failures.push(format!( - "'{}' -> '{}': {error}", - file.backup_path.display(), - file.source_path.display() - )); - } - } - - if failures.is_empty() { - Ok(()) - } else { - Err(anyhow!( - "failed to restore staged sound deletion: {}", - failures.join("; ") - )) - } -} - -pub(crate) fn move_staged_sound_deletions_to_trash( - staged: &[StagedSoundDeletionFile], - trash_dir: &Path, -) -> Result<()> { - let mut trash_session = TrashSession::new(trash_dir.to_path_buf(), SystemTime::now())?; - let mut failures = Vec::new(); - - for file in staged { - match file.backup_path.try_exists() { - Ok(false) => continue, - Ok(true) => {} - Err(error) => { - failures.push(format!( - "failed to inspect staged backup '{}': {error}", - file.backup_path.display() - )); - continue; - } - } - let Some(source_dir) = file.source_path.parent() else { - failures.push(format!( - "missing source directory for {}", - file.source_path.display() - )); - continue; - }; - let destination = match trash_session.destination_path(source_dir, &file.source_path) { - Ok(destination) => destination, - Err(error) => { - failures.push(format!("{}: {error:#}", file.backup_path.display())); - continue; - } - }; - if let Err(error) = fs::rename(&file.backup_path, &destination) { - failures.push(format!( - "'{}' -> '{}': {error}", - file.backup_path.display(), - destination.display() - )); - } - } - - if failures.is_empty() { - Ok(()) - } else { - Err(anyhow!( - "failed to move staged sound deletion to trash: {}", - failures.join("; ") - )) - } -} - -fn rollback_staged_after_error( - staged: Vec, - primary: anyhow::Error, -) -> Result { - match restore_staged_sound_deletions(&staged) { - Ok(()) => Err(primary), - Err(rollback) => Err(anyhow!("{primary:#}; {rollback:#}")), - } -} - -fn sound_delete_backup_path(source_path: &Path) -> Result { - let file_name = source_path.file_name().with_context(|| { - format!( - "failed to resolve sound deletion file name from {}", - source_path.display() - ) - })?; - let mut backup_name = file_name.to_os_string(); - backup_name.push(SOUND_DELETE_BACKUP_MARKER); - backup_name.push(uuid::Uuid::new_v4().to_string()); - Ok(source_path.with_file_name(backup_name)) -} - -fn sound_delete_source_path(backup_path: &Path) -> Option { - let file_name = backup_path.file_name()?.to_str()?; - let (source_name, suffix) = file_name.rsplit_once(SOUND_DELETE_BACKUP_MARKER)?; - uuid::Uuid::parse_str(suffix).ok()?; - (!source_name.is_empty()).then(|| backup_path.with_file_name(source_name)) -} - -fn recover_interrupted_sound_deletions( - data: &AppStoreData, - sounds_dir: &Path, - trash_dir: &Path, -) -> Result { - recover_interrupted_sound_deletions_with(data, sounds_dir, trash_dir, |from, to| { - fs::rename(from, to) - }) -} - -fn recover_interrupted_sound_deletions_with( - data: &AppStoreData, - sounds_dir: &Path, - trash_dir: &Path, - mut rename: Rename, -) -> Result -where - Rename: FnMut(&Path, &Path) -> std::io::Result<()>, -{ - let mut backups = Vec::new(); - collect_sound_delete_backups(sounds_dir, &mut backups)?; - let mut outcome = SoundRecoveryOutcome::complete(); - if backups.is_empty() { - return Ok(outcome); - } - - let referenced_keys = collect_sound_deletion_reference_keys(data, sounds_dir); - let mut trash_session = TrashSession::new(trash_dir.to_path_buf(), SystemTime::now())?; - - for backup_path in backups { - let Some(source_path) = sound_delete_source_path(&backup_path) else { - continue; - }; - let referenced = referenced_keys.contains(&path_identity_key(&source_path)); - let source_exists = match source_path.try_exists() { - Ok(exists) => exists, - Err(error) => { - outcome.complete = false; - outcome - .protected_keys - .insert(path_identity_key(&backup_path)); - log::warn!( - "[Sounds] Failed to inspect interrupted deletion source '{}': {error}", - source_path.display() - ); - continue; - } - }; - if referenced && !source_exists { - if let Err(error) = rename(&backup_path, &source_path) { - outcome.complete = false; - outcome - .protected_keys - .insert(path_identity_key(&backup_path)); - log::warn!( - "[Sounds] Failed to restore interrupted deletion '{}' -> '{}': {error}", - backup_path.display(), - source_path.display() - ); - } else { - log::info!( - "[Sounds] Restored interrupted deletion '{}'", - source_path.display() - ); - } - continue; - } - - let Some(source_dir) = source_path.parent() else { - continue; - }; - let destination = match trash_session.destination_path(source_dir, &source_path) { - Ok(destination) => destination, - Err(error) => { - log::warn!( - "[Sounds] Failed to prepare interrupted deletion trash for '{}': {error:#}", - backup_path.display() - ); - continue; - } - }; - if let Err(error) = rename(&backup_path, &destination) { - log::warn!( - "[Sounds] Failed to finish interrupted deletion '{}' -> '{}': {error}", - backup_path.display(), - destination.display() - ); - } - } - - Ok(outcome) -} - -fn collect_sound_delete_backups(directory: &Path, backups: &mut Vec) -> Result<()> { - if !directory.exists() { - return Ok(()); - } - for entry in fs::read_dir(directory) - .with_context(|| format!("failed to scan sound directory at {}", directory.display()))? - { - let entry = entry?; - let file_type = entry.file_type()?; - if file_type.is_dir() { - collect_sound_delete_backups(&entry.path(), backups)?; - } else if file_type.is_file() && sound_delete_source_path(&entry.path()).is_some() { - backups.push(entry.path()); - } - } - Ok(()) -} - -fn collect_sound_deletion_reference_keys( - data: &AppStoreData, - sounds_dir: &Path, -) -> HashSet { - // sounds_dir는 항상 /sounds, 분류 루트는 부모로 유도 - let app_data_dir = sounds_dir.parent().unwrap_or(sounds_dir); - let mut keys = collect_local_sound_paths(app_data_dir, data).keys; - for entry in data.sound_library.values() { - let Some(original_path) = entry.original_path.as_deref() else { - continue; - }; - let relative = Path::new(original_path); - if relative.is_absolute() - || relative - .components() - .any(|component| matches!(component, Component::CurDir | Component::ParentDir)) - { - continue; - } - keys.insert(path_identity_key(&sounds_dir.join(relative))); - } - keys -} - -fn system_time_millis(time: SystemTime) -> Result { - time.duration_since(UNIX_EPOCH) - .context("system time is before the Unix epoch") - .map(|duration| duration.as_millis()) -} - -fn purge_expired_trash_sessions(trash_dir: &Path) -> Result<()> { - purge_expired_trash_sessions_at(trash_dir, SystemTime::now()) -} - -fn purge_expired_trash_sessions_at(trash_dir: &Path, now: SystemTime) -> Result<()> { - if !trash_dir.exists() { - return Ok(()); - } - - let now_millis = system_time_millis(now)?; - let retention_millis = TRASH_RETENTION.as_millis(); - let read_dir = fs::read_dir(trash_dir).with_context(|| { - format!( - "failed to read asset trash directory at {}", - trash_dir.display() - ) - })?; - - for entry in read_dir { - let entry = match entry { - Ok(entry) => entry, - Err(err) => { - log::warn!( - "[Assets] Failed to read an entry from trash '{}': {err}", - trash_dir.display() - ); - continue; - } - }; - let file_type = match entry.file_type() { - Ok(file_type) => file_type, - Err(err) => { - log::warn!( - "[Assets] Failed to inspect trash entry '{}': {err}", - entry.path().display() - ); - continue; - } - }; - if !file_type.is_dir() { - continue; - } - - let Some(timestamp_millis) = entry - .file_name() - .to_str() - .and_then(parse_trash_session_timestamp) - else { - continue; - }; - if now_millis.saturating_sub(timestamp_millis) <= retention_millis { - continue; - } - - let path = entry.path(); - if let Err(err) = fs::remove_dir_all(&path) { - log::warn!( - "[Assets] Failed to purge expired trash session '{}': {err}", - path.display() - ); - } else { - log::info!("[Assets] Purged expired trash session '{}'", path.display()); - } - } - - Ok(()) -} - -fn parse_trash_session_timestamp(name: &str) -> Option { - let mut parts = name.split('-'); - let timestamp = parts.next()?.parse().ok()?; - match (parts.next(), parts.next()) { - (None, None) => {} - (Some(suffix), None) if suffix.parse::().is_ok() => {} - _ => return None, - } - Some(timestamp) -} - -fn is_temporary_asset_file(path: &Path) -> bool { - path.extension() - .and_then(|extension| extension.to_str()) - .is_some_and(|extension| extension.eq_ignore_ascii_case("tmp")) -} - -fn sweep_unreferenced_asset_files( - log_prefix: &str, - target_dir: &Path, - referenced_path_keys: &HashSet, - trash_session: &mut TrashSession, -) -> Result<()> { - if !target_dir.exists() { - return Ok(()); - } - - let read_dir = fs::read_dir(target_dir) - .with_context(|| format!("failed to read asset directory at {}", target_dir.display()))?; - - for entry in read_dir { - let entry = match entry { - Ok(entry) => entry, - Err(err) => { - log::warn!( - "[{log_prefix}] Failed to read an entry from '{}': {err}", - target_dir.display() - ); - continue; - } - }; - let path = entry.path(); - if !path.is_file() { - continue; - } - if !path.starts_with(target_dir) { - continue; - } - let key = path_identity_key(&path); - if referenced_path_keys.contains(&key) { - continue; - } - - if is_temporary_asset_file(&path) { - if let Err(err) = fs::remove_file(&path) { - log::warn!( - "[{log_prefix}] Failed to remove temporary asset '{}': {err}", - path.display() - ); - } else { - log::info!( - "[{log_prefix}] Removed temporary asset '{}'", - path.display() - ); - } - continue; - } - - let destination = match trash_session.destination_path(target_dir, &path) { - Ok(destination) => destination, - Err(err) => { - log::warn!( - "[{log_prefix}] Failed to prepare trash destination for '{}': {err:#}", - path.display() - ); - continue; - } - }; - if let Err(err) = fs::rename(&path, &destination) { - log::warn!( - "[{log_prefix}] Failed to move stale asset '{}' to '{}': {err}", - path.display(), - destination.display() - ); - } else { - log::info!( - "[{log_prefix}] Moved stale asset '{}' to trash at '{}'", - path.display(), - destination.display() - ); - } - } - - Ok(()) -} - #[cfg(test)] mod gradient_real_data_simulation; diff --git a/src-tauri/src/state/store/sound_assets.rs b/src-tauri/src/state/store/sound_assets.rs new file mode 100644 index 000000000..d0f1ccb6c --- /dev/null +++ b/src-tauri/src/state/store/sound_assets.rs @@ -0,0 +1,1052 @@ +use std::{ + collections::HashSet, + fs, + path::{Component, Path, PathBuf}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use anyhow::{anyhow, Context, Result}; +use parking_lot::Mutex; + +use crate::models::AppStoreData; + +use super::super::local_asset_path::path_identity_key; +use super::{ + collect_local_font_paths, collect_local_image_paths, collect_local_sound_paths, AppStore, +}; + +pub(super) const TRASH_RETENTION: Duration = Duration::from_secs(30 * 24 * 60 * 60); +const SOUND_DELETE_BACKUP_MARKER: &str = ".delete-backup-"; +pub(crate) static PROCESSED_WAV_TRANSACTION_LOCK: Mutex<()> = Mutex::new(()); + +#[derive(Debug)] +pub(crate) struct StagedSoundDeletionFile { + pub(super) source_path: PathBuf, + pub(super) backup_path: PathBuf, +} + +pub(super) struct SoundRecoveryOutcome { + pub(super) protected_keys: HashSet, + pub(super) complete: bool, +} + +impl SoundRecoveryOutcome { + fn complete() -> Self { + Self { + protected_keys: HashSet::new(), + complete: true, + } + } +} + +impl AppStore { + /// 앱 종료 시점에 한 번 호출하는 자원 정리. + /// 현재 store에서 참조하지 않는 자산은 30일 보관되는 trash 세션으로 격리 + #[cfg(test)] + pub fn cleanup_orphan_assets_now(&self) -> Result<()> { + let _transaction_guard = PROCESSED_WAV_TRANSACTION_LOCK.lock(); + self.cleanup_orphan_assets_while_locked() + } + + fn cleanup_orphan_assets_while_locked(&self) -> Result<()> { + let app_data_dir = self + .path + .parent() + .context("failed to resolve app data directory from store path")?; + + let fonts_dir = app_data_dir.join("fonts"); + let images_dir = app_data_dir.join("images"); + let sounds_dir = app_data_dir.join("sounds"); + let trash_dir = app_data_dir.join("trash"); + + self.recover_pending_processed_wav_replacement(&sounds_dir)?; + let processed_recovery = recover_interrupted_processed_wav_replacements(&sounds_dir)?; + let deletion_recovery = + recover_interrupted_sound_deletions(&self.state.read().data, &sounds_dir, &trash_dir)?; + let sound_recovery_complete = processed_recovery.complete && deletion_recovery.complete; + let mut recovered_sound_keys = processed_recovery.protected_keys; + recovered_sound_keys.extend(deletion_recovery.protected_keys); + purge_expired_trash_sessions(&trash_dir)?; + + if self.skip_asset_sweep { + log::warn!( + "[Assets] Skipping orphan asset sweep for a recovered or legacy-migrated store session" + ); + return Ok(()); + } + + let snapshot = self.state.read().data.clone(); + let referenced_fonts = collect_local_font_paths(app_data_dir, &snapshot); + let referenced_images = collect_local_image_paths(app_data_dir, &snapshot); + let mut referenced_sounds = collect_local_sound_paths(app_data_dir, &snapshot); + referenced_sounds.keys.extend(recovered_sound_keys); + referenced_sounds.complete &= sound_recovery_complete; + let mut trash_session = TrashSession::new(trash_dir, SystemTime::now())?; + + if referenced_fonts.complete { + sweep_unreferenced_asset_files( + "Fonts", + &fonts_dir, + &referenced_fonts.keys, + &mut trash_session, + )?; + } else { + log::warn!("[Fonts] Skipping asset sweep because a local path could not be resolved"); + } + if referenced_images.complete { + sweep_unreferenced_asset_files( + "Images", + &images_dir, + &referenced_images.keys, + &mut trash_session, + )?; + } else { + log::warn!("[Images] Skipping asset sweep because a local path could not be resolved"); + } + if referenced_sounds.complete { + sweep_unreferenced_asset_files( + "Sounds", + &sounds_dir, + &referenced_sounds.keys, + &mut trash_session, + )?; + } else { + log::warn!("[Sounds] Skipping asset sweep because a local path could not be resolved"); + } + Ok(()) + } + + pub(crate) fn flush_cleanup_and_shutdown(&self) -> Result<()> { + let _transaction_guard = PROCESSED_WAV_TRANSACTION_LOCK.lock(); + + let flush_result = self.flush(); + let cleanup_result = if flush_result.is_ok() { + self.cleanup_orphan_assets_while_locked() + } else { + Ok(()) + }; + let shutdown_result = self.flush_and_shutdown(); + + match (flush_result, cleanup_result, shutdown_result) { + (Ok(()), Ok(()), Ok(())) => Ok(()), + (Err(flush), _, Err(shutdown)) => Err(anyhow!( + "failed to flush store before cleanup: {flush:#}; failed to shut down store writer: {shutdown:#}" + )), + (Err(flush), _, Ok(())) => { + Err(anyhow!("failed to flush store before asset cleanup: {flush:#}")) + } + (Ok(()), Err(cleanup), Err(shutdown)) => Err(anyhow!( + "failed to clean up assets: {cleanup:#}; failed to shut down store writer: {shutdown:#}" + )), + (Ok(()), Err(cleanup), Ok(())) => Err(cleanup), + (Ok(()), Ok(()), Err(shutdown)) => Err(shutdown), + } + } + + pub fn recover_interrupted_processed_wav_replacements_now(&self) -> Result<()> { + let _transaction_guard = PROCESSED_WAV_TRANSACTION_LOCK.lock(); + self.recover_interrupted_processed_wav_replacements_while_locked() + } + + /// `PROCESSED_WAV_TRANSACTION_LOCK`을 이미 보유한 편집 명령 전용 + pub(crate) fn recover_interrupted_processed_wav_replacements_while_locked(&self) -> Result<()> { + self.recover_interrupted_sound_assets_while_locked() + .map(|_| ()) + } + + /// `PROCESSED_WAV_TRANSACTION_LOCK`을 이미 보유한 목록 명령 전용 + pub(crate) fn prepare_sound_listing_while_locked(&self) -> Result { + self.recover_interrupted_sound_assets_while_locked() + } + + fn recover_interrupted_sound_assets_while_locked(&self) -> Result { + let app_data_dir = self + .path + .parent() + .context("failed to resolve app data directory from store path")?; + let sounds_dir = app_data_dir.join("sounds"); + let trash_dir = app_data_dir.join("trash"); + self.recover_pending_processed_wav_replacement(&sounds_dir)?; + let processed_recovery = recover_interrupted_processed_wav_replacements(&sounds_dir)?; + let deletion_recovery = + recover_interrupted_sound_deletions(&self.state.read().data, &sounds_dir, &trash_dir)?; + Ok(processed_recovery.complete && deletion_recovery.complete) + } + + fn recover_pending_processed_wav_replacement(&self, sounds_dir: &Path) -> Result<()> { + self.recover_pending_processed_wav_replacement_with(sounds_dir, Path::try_exists) + } + + pub(super) fn recover_pending_processed_wav_replacement_with( + &self, + sounds_dir: &Path, + mut try_exists: TryExists, + ) -> Result<()> + where + TryExists: FnMut(&Path) -> std::io::Result, + { + let Some(pending) = self + .state + .read() + .data + .pending_processed_wav_replacement + .clone() + else { + return Ok(()); + }; + + let target_path = validate_pending_processed_wav_target(sounds_dir, &pending.sound_path)?; + let backup_path = processed_wav_backup_path(&target_path)?; + let target_exists = try_exists(&target_path).with_context(|| { + format!( + "failed to check pending WAV target at {}", + target_path.display() + ) + })?; + let backup_exists = try_exists(&backup_path).with_context(|| { + format!( + "failed to check pending WAV backup at {}", + backup_path.display() + ) + })?; + let files_missing = !target_exists && !backup_exists; + + if files_missing { + log::warn!( + "[Sounds] Clearing unrecoverable WAV transaction because target '{}' and backup '{}' are both missing", + target_path.display(), + backup_path.display() + ); + } else { + rollback_pending_processed_wav( + &target_path, + pending.had_original, + target_exists, + backup_exists, + )?; + } + self.update(|data| { + if data.pending_processed_wav_replacement.as_ref() == Some(&pending) { + data.pending_processed_wav_replacement = None; + } + })?; + if !files_missing { + log::info!( + "[Sounds] Recovered interrupted WAV transaction for '{}'", + target_path.display() + ); + } + Ok(()) + } +} + +fn validate_pending_processed_wav_target(sounds_dir: &Path, value: &str) -> Result { + let target = PathBuf::from(value); + if !target.is_absolute() + || target + .components() + .any(|component| matches!(component, Component::CurDir | Component::ParentDir)) + { + return Err(anyhow!("invalid pending WAV path")); + } + + let canonical_sounds_dir = fs::canonicalize(sounds_dir).with_context(|| { + format!( + "failed to resolve sound directory during WAV recovery at {}", + sounds_dir.display() + ) + })?; + let boundary_path = (if target.exists() { + fs::canonicalize(&target).map_err(anyhow::Error::from) + } else { + target + .parent() + .context("pending WAV path has no parent") + .and_then(|parent| fs::canonicalize(parent).map_err(anyhow::Error::from)) + }) + .with_context(|| { + format!( + "failed to resolve pending WAV boundary at {}", + target.display() + ) + })?; + + if !boundary_path.starts_with(&canonical_sounds_dir) { + return Err(anyhow!("pending WAV path escapes the sound directory")); + } + Ok(target) +} + +fn processed_wav_backup_path(path: &Path) -> Result { + let mut file_name = path + .file_name() + .context("pending WAV path has no file name")? + .to_os_string(); + file_name.push(".bak"); + Ok(path.with_file_name(file_name)) +} + +fn interrupted_wav_path(path: &Path) -> PathBuf { + let mut file_name = path.file_name().unwrap_or_default().to_os_string(); + file_name.push(format!(".interrupted-{}", uuid::Uuid::new_v4())); + path.with_file_name(file_name) +} + +fn rollback_pending_processed_wav( + target_path: &Path, + had_original: bool, + target_exists: bool, + backup_exists: bool, +) -> Result<()> { + let backup_path = processed_wav_backup_path(target_path)?; + if !had_original { + if target_exists { + let interrupted_path = interrupted_wav_path(target_path); + fs::rename(target_path, &interrupted_path).with_context(|| { + format!( + "failed to quarantine uncommitted WAV '{}' at '{}'", + target_path.display(), + interrupted_path.display() + ) + })?; + } + return Ok(()); + } + + if !backup_exists { + if target_exists { + return Ok(()); + } + return Err(anyhow!( + "both the original WAV and its backup are missing during recovery" + )); + } + + if !target_exists { + fs::rename(&backup_path, target_path).with_context(|| { + format!( + "failed to restore WAV backup '{}' to '{}'", + backup_path.display(), + target_path.display() + ) + })?; + return Ok(()); + } + + let interrupted_path = interrupted_wav_path(target_path); + fs::rename(target_path, &interrupted_path).with_context(|| { + format!( + "failed to move uncommitted WAV '{}' to '{}'", + target_path.display(), + interrupted_path.display() + ) + })?; + if let Err(error) = fs::rename(&backup_path, target_path) { + return match fs::rename(&interrupted_path, target_path) { + Ok(()) => Err(error).with_context(|| { + format!( + "failed to restore WAV backup '{}' to '{}'", + backup_path.display(), + target_path.display() + ) + }), + Err(recovery_error) => Err(anyhow!( + "failed to restore WAV backup: {error}; failed to put uncommitted WAV back: {recovery_error}" + )), + }; + } + Ok(()) +} + +fn recover_interrupted_processed_wav_replacements( + sounds_dir: &Path, +) -> Result { + recover_interrupted_processed_wav_replacements_with(sounds_dir, |from, to| fs::rename(from, to)) +} + +pub(super) fn recover_interrupted_processed_wav_replacements_with( + sounds_dir: &Path, + mut rename: Rename, +) -> Result +where + Rename: FnMut(&Path, &Path) -> std::io::Result<()>, +{ + let mut outcome = SoundRecoveryOutcome::complete(); + if !sounds_dir.exists() { + return Ok(outcome); + } + + let read_dir = fs::read_dir(sounds_dir).with_context(|| { + format!( + "failed to read sound directory for recovery at {}", + sounds_dir.display() + ) + })?; + + for entry in read_dir { + let entry = match entry { + Ok(entry) => entry, + Err(err) => { + outcome.complete = false; + log::warn!( + "[Sounds] Failed to read an entry from '{}' during recovery: {err}", + sounds_dir.display() + ); + continue; + } + }; + let backup_path = entry.path(); + let is_file = match entry.file_type() { + Ok(file_type) => file_type.is_file(), + Err(err) => { + outcome.complete = false; + log::warn!( + "[Sounds] Failed to inspect '{}' during recovery: {err}", + backup_path.display() + ); + continue; + } + }; + if !is_file || !backup_path.starts_with(sounds_dir) { + continue; + } + + let Some(file_name) = backup_path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + let Some(target_name) = file_name.strip_suffix(".bak") else { + continue; + }; + let target_path = backup_path.with_file_name(target_name); + if !target_path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("wav")) + { + continue; + } + + match target_path.try_exists() { + Ok(true) => continue, + Ok(false) => {} + Err(err) => { + outcome.complete = false; + outcome + .protected_keys + .insert(path_identity_key(&backup_path)); + log::warn!( + "[Sounds] Failed to inspect processed WAV target '{}': {err}", + target_path.display() + ); + continue; + } + } + + if let Err(err) = rename(&backup_path, &target_path) { + outcome.complete = false; + outcome + .protected_keys + .insert(path_identity_key(&backup_path)); + log::warn!( + "[Sounds] Failed to recover processed WAV '{}' from '{}': {err}", + target_path.display(), + backup_path.display() + ); + continue; + } + log::info!( + "[Sounds] Recovered processed WAV '{}'", + target_path.display() + ); + } + + Ok(outcome) +} + +pub(super) struct TrashSession { + trash_dir: PathBuf, + timestamp_millis: u128, + next_suffix: u64, + session_dir: Option, +} + +impl TrashSession { + pub(super) fn new(trash_dir: PathBuf, now: SystemTime) -> Result { + Ok(Self { + trash_dir, + timestamp_millis: system_time_millis(now)?, + next_suffix: 0, + session_dir: None, + }) + } + + pub(super) fn destination_path( + &mut self, + source_dir: &Path, + source_path: &Path, + ) -> Result { + let category = source_dir.file_name().with_context(|| { + format!( + "failed to resolve asset category from {}", + source_dir.display() + ) + })?; + let file_name = source_path.file_name().with_context(|| { + format!( + "failed to resolve asset file name from {}", + source_path.display() + ) + })?; + + loop { + let session_dir = self.ensure_session_dir()?; + let category_dir = session_dir.join(category); + fs::create_dir_all(&category_dir).with_context(|| { + format!( + "failed to create trash category directory at {}", + category_dir.display() + ) + })?; + let destination = category_dir.join(file_name); + if !destination.exists() { + return Ok(destination); + } + + self.session_dir = None; + } + } + + fn ensure_session_dir(&mut self) -> Result { + if let Some(path) = self.session_dir.as_ref() { + return Ok(path.clone()); + } + + fs::create_dir_all(&self.trash_dir).with_context(|| { + format!( + "failed to create asset trash directory at {}", + self.trash_dir.display() + ) + })?; + + loop { + let suffix = self.next_suffix; + self.next_suffix = self + .next_suffix + .checked_add(1) + .context("asset trash session suffix overflow")?; + let name = if suffix == 0 { + self.timestamp_millis.to_string() + } else { + format!("{}-{suffix}", self.timestamp_millis) + }; + let candidate = self.trash_dir.join(name); + match fs::create_dir(&candidate) { + Ok(()) => { + self.session_dir = Some(candidate.clone()); + return Ok(candidate); + } + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(err) => { + return Err(err).with_context(|| { + format!( + "failed to create asset trash session at {}", + candidate.display() + ) + }); + } + } + } + } +} + +pub(crate) fn stage_sound_files_for_deletion( + source_paths: &[PathBuf], +) -> Result> { + let mut staged = Vec::new(); + let mut seen = HashSet::new(); + + for source_path in source_paths { + let key = path_identity_key(source_path); + if !seen.insert(key) { + continue; + } + match source_path.try_exists() { + Ok(false) => continue, + Ok(true) if !source_path.is_file() => { + let error = anyhow!( + "sound deletion target is not a file at {}", + source_path.display() + ); + return rollback_staged_after_error(staged, error); + } + Ok(true) => {} + Err(error) => { + return rollback_staged_after_error( + staged, + anyhow!( + "failed to inspect sound deletion target at {}: {error}", + source_path.display() + ), + ); + } + } + + let backup_path = match sound_delete_backup_path(source_path) { + Ok(path) => path, + Err(error) => return rollback_staged_after_error(staged, error), + }; + if let Err(error) = fs::rename(source_path, &backup_path) { + return rollback_staged_after_error( + staged, + anyhow!( + "failed to stage sound deletion '{}' -> '{}': {error}", + source_path.display(), + backup_path.display() + ), + ); + } + staged.push(StagedSoundDeletionFile { + source_path: source_path.clone(), + backup_path, + }); + } + + Ok(staged) +} + +pub(crate) fn restore_staged_sound_deletions(staged: &[StagedSoundDeletionFile]) -> Result<()> { + let mut failures = Vec::new(); + for file in staged.iter().rev() { + match file.backup_path.try_exists() { + Ok(false) => continue, + Ok(true) => {} + Err(error) => { + failures.push(format!( + "failed to inspect staged backup '{}': {error}", + file.backup_path.display() + )); + continue; + } + } + match file.source_path.try_exists() { + Ok(true) => { + failures.push(format!( + "source path already exists at {}", + file.source_path.display() + )); + continue; + } + Ok(false) => {} + Err(error) => { + failures.push(format!( + "failed to inspect source path '{}': {error}", + file.source_path.display() + )); + continue; + } + } + if let Err(error) = fs::rename(&file.backup_path, &file.source_path) { + failures.push(format!( + "'{}' -> '{}': {error}", + file.backup_path.display(), + file.source_path.display() + )); + } + } + + if failures.is_empty() { + Ok(()) + } else { + Err(anyhow!( + "failed to restore staged sound deletion: {}", + failures.join("; ") + )) + } +} + +pub(crate) fn move_staged_sound_deletions_to_trash( + staged: &[StagedSoundDeletionFile], + trash_dir: &Path, +) -> Result<()> { + let mut trash_session = TrashSession::new(trash_dir.to_path_buf(), SystemTime::now())?; + let mut failures = Vec::new(); + + for file in staged { + match file.backup_path.try_exists() { + Ok(false) => continue, + Ok(true) => {} + Err(error) => { + failures.push(format!( + "failed to inspect staged backup '{}': {error}", + file.backup_path.display() + )); + continue; + } + } + let Some(source_dir) = file.source_path.parent() else { + failures.push(format!( + "missing source directory for {}", + file.source_path.display() + )); + continue; + }; + let destination = match trash_session.destination_path(source_dir, &file.source_path) { + Ok(destination) => destination, + Err(error) => { + failures.push(format!("{}: {error:#}", file.backup_path.display())); + continue; + } + }; + if let Err(error) = fs::rename(&file.backup_path, &destination) { + failures.push(format!( + "'{}' -> '{}': {error}", + file.backup_path.display(), + destination.display() + )); + } + } + + if failures.is_empty() { + Ok(()) + } else { + Err(anyhow!( + "failed to move staged sound deletion to trash: {}", + failures.join("; ") + )) + } +} + +fn rollback_staged_after_error( + staged: Vec, + primary: anyhow::Error, +) -> Result { + match restore_staged_sound_deletions(&staged) { + Ok(()) => Err(primary), + Err(rollback) => Err(anyhow!("{primary:#}; {rollback:#}")), + } +} + +fn sound_delete_backup_path(source_path: &Path) -> Result { + let file_name = source_path.file_name().with_context(|| { + format!( + "failed to resolve sound deletion file name from {}", + source_path.display() + ) + })?; + let mut backup_name = file_name.to_os_string(); + backup_name.push(SOUND_DELETE_BACKUP_MARKER); + backup_name.push(uuid::Uuid::new_v4().to_string()); + Ok(source_path.with_file_name(backup_name)) +} + +fn sound_delete_source_path(backup_path: &Path) -> Option { + let file_name = backup_path.file_name()?.to_str()?; + let (source_name, suffix) = file_name.rsplit_once(SOUND_DELETE_BACKUP_MARKER)?; + uuid::Uuid::parse_str(suffix).ok()?; + (!source_name.is_empty()).then(|| backup_path.with_file_name(source_name)) +} + +fn recover_interrupted_sound_deletions( + data: &AppStoreData, + sounds_dir: &Path, + trash_dir: &Path, +) -> Result { + recover_interrupted_sound_deletions_with(data, sounds_dir, trash_dir, |from, to| { + fs::rename(from, to) + }) +} + +pub(super) fn recover_interrupted_sound_deletions_with( + data: &AppStoreData, + sounds_dir: &Path, + trash_dir: &Path, + mut rename: Rename, +) -> Result +where + Rename: FnMut(&Path, &Path) -> std::io::Result<()>, +{ + let mut backups = Vec::new(); + collect_sound_delete_backups(sounds_dir, &mut backups)?; + let mut outcome = SoundRecoveryOutcome::complete(); + if backups.is_empty() { + return Ok(outcome); + } + + let referenced_keys = collect_sound_deletion_reference_keys(data, sounds_dir); + let mut trash_session = TrashSession::new(trash_dir.to_path_buf(), SystemTime::now())?; + + for backup_path in backups { + let Some(source_path) = sound_delete_source_path(&backup_path) else { + continue; + }; + let referenced = referenced_keys.contains(&path_identity_key(&source_path)); + let source_exists = match source_path.try_exists() { + Ok(exists) => exists, + Err(error) => { + outcome.complete = false; + outcome + .protected_keys + .insert(path_identity_key(&backup_path)); + log::warn!( + "[Sounds] Failed to inspect interrupted deletion source '{}': {error}", + source_path.display() + ); + continue; + } + }; + if referenced && !source_exists { + if let Err(error) = rename(&backup_path, &source_path) { + outcome.complete = false; + outcome + .protected_keys + .insert(path_identity_key(&backup_path)); + log::warn!( + "[Sounds] Failed to restore interrupted deletion '{}' -> '{}': {error}", + backup_path.display(), + source_path.display() + ); + } else { + log::info!( + "[Sounds] Restored interrupted deletion '{}'", + source_path.display() + ); + } + continue; + } + + let Some(source_dir) = source_path.parent() else { + continue; + }; + let destination = match trash_session.destination_path(source_dir, &source_path) { + Ok(destination) => destination, + Err(error) => { + log::warn!( + "[Sounds] Failed to prepare interrupted deletion trash for '{}': {error:#}", + backup_path.display() + ); + continue; + } + }; + if let Err(error) = rename(&backup_path, &destination) { + log::warn!( + "[Sounds] Failed to finish interrupted deletion '{}' -> '{}': {error}", + backup_path.display(), + destination.display() + ); + } + } + + Ok(outcome) +} + +fn collect_sound_delete_backups(directory: &Path, backups: &mut Vec) -> Result<()> { + if !directory.exists() { + return Ok(()); + } + for entry in fs::read_dir(directory) + .with_context(|| format!("failed to scan sound directory at {}", directory.display()))? + { + let entry = entry?; + let file_type = entry.file_type()?; + if file_type.is_dir() { + collect_sound_delete_backups(&entry.path(), backups)?; + } else if file_type.is_file() && sound_delete_source_path(&entry.path()).is_some() { + backups.push(entry.path()); + } + } + Ok(()) +} + +fn collect_sound_deletion_reference_keys( + data: &AppStoreData, + sounds_dir: &Path, +) -> HashSet { + // sounds_dir는 항상 /sounds, 분류 루트는 부모로 유도 + let app_data_dir = sounds_dir.parent().unwrap_or(sounds_dir); + let mut keys = collect_local_sound_paths(app_data_dir, data).keys; + for entry in data.sound_library.values() { + let Some(original_path) = entry.original_path.as_deref() else { + continue; + }; + let relative = Path::new(original_path); + if relative.is_absolute() + || relative + .components() + .any(|component| matches!(component, Component::CurDir | Component::ParentDir)) + { + continue; + } + keys.insert(path_identity_key(&sounds_dir.join(relative))); + } + keys +} + +pub(super) fn system_time_millis(time: SystemTime) -> Result { + time.duration_since(UNIX_EPOCH) + .context("system time is before the Unix epoch") + .map(|duration| duration.as_millis()) +} + +fn purge_expired_trash_sessions(trash_dir: &Path) -> Result<()> { + purge_expired_trash_sessions_at(trash_dir, SystemTime::now()) +} + +pub(super) fn purge_expired_trash_sessions_at(trash_dir: &Path, now: SystemTime) -> Result<()> { + if !trash_dir.exists() { + return Ok(()); + } + + let now_millis = system_time_millis(now)?; + let retention_millis = TRASH_RETENTION.as_millis(); + let read_dir = fs::read_dir(trash_dir).with_context(|| { + format!( + "failed to read asset trash directory at {}", + trash_dir.display() + ) + })?; + + for entry in read_dir { + let entry = match entry { + Ok(entry) => entry, + Err(err) => { + log::warn!( + "[Assets] Failed to read an entry from trash '{}': {err}", + trash_dir.display() + ); + continue; + } + }; + let file_type = match entry.file_type() { + Ok(file_type) => file_type, + Err(err) => { + log::warn!( + "[Assets] Failed to inspect trash entry '{}': {err}", + entry.path().display() + ); + continue; + } + }; + if !file_type.is_dir() { + continue; + } + + let Some(timestamp_millis) = entry + .file_name() + .to_str() + .and_then(parse_trash_session_timestamp) + else { + continue; + }; + if now_millis.saturating_sub(timestamp_millis) <= retention_millis { + continue; + } + + let path = entry.path(); + if let Err(err) = fs::remove_dir_all(&path) { + log::warn!( + "[Assets] Failed to purge expired trash session '{}': {err}", + path.display() + ); + } else { + log::info!("[Assets] Purged expired trash session '{}'", path.display()); + } + } + + Ok(()) +} + +fn parse_trash_session_timestamp(name: &str) -> Option { + let mut parts = name.split('-'); + let timestamp = parts.next()?.parse().ok()?; + match (parts.next(), parts.next()) { + (None, None) => {} + (Some(suffix), None) if suffix.parse::().is_ok() => {} + _ => return None, + } + Some(timestamp) +} + +fn is_temporary_asset_file(path: &Path) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("tmp")) +} + +pub(super) fn sweep_unreferenced_asset_files( + log_prefix: &str, + target_dir: &Path, + referenced_path_keys: &HashSet, + trash_session: &mut TrashSession, +) -> Result<()> { + if !target_dir.exists() { + return Ok(()); + } + + let read_dir = fs::read_dir(target_dir) + .with_context(|| format!("failed to read asset directory at {}", target_dir.display()))?; + + for entry in read_dir { + let entry = match entry { + Ok(entry) => entry, + Err(err) => { + log::warn!( + "[{log_prefix}] Failed to read an entry from '{}': {err}", + target_dir.display() + ); + continue; + } + }; + let path = entry.path(); + if !path.is_file() { + continue; + } + if !path.starts_with(target_dir) { + continue; + } + let key = path_identity_key(&path); + if referenced_path_keys.contains(&key) { + continue; + } + + if is_temporary_asset_file(&path) { + if let Err(err) = fs::remove_file(&path) { + log::warn!( + "[{log_prefix}] Failed to remove temporary asset '{}': {err}", + path.display() + ); + } else { + log::info!( + "[{log_prefix}] Removed temporary asset '{}'", + path.display() + ); + } + continue; + } + + let destination = match trash_session.destination_path(target_dir, &path) { + Ok(destination) => destination, + Err(err) => { + log::warn!( + "[{log_prefix}] Failed to prepare trash destination for '{}': {err:#}", + path.display() + ); + continue; + } + }; + if let Err(err) = fs::rename(&path, &destination) { + log::warn!( + "[{log_prefix}] Failed to move stale asset '{}' to '{}': {err}", + path.display(), + destination.display() + ); + } else { + log::info!( + "[{log_prefix}] Moved stale asset '{}' to trash at '{}'", + path.display(), + destination.display() + ); + } + } + + Ok(()) +} From 96b3c4e646b457a63c57ee3ef0d6abc14f677cf2 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 06:31:24 +0900 Subject: [PATCH 010/245] =?UTF-8?q?docs:=20=EB=8C=80=ED=98=95=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EB=A6=AC=ED=8C=A9=ED=84=B0=EB=A7=81=20=EA=B3=84?= =?UTF-8?q?=ED=9A=8D=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/code-quality-refactoring-plan.md | 134 ++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 docs/code-quality-refactoring-plan.md diff --git a/docs/code-quality-refactoring-plan.md b/docs/code-quality-refactoring-plan.md new file mode 100644 index 000000000..6473d9097 --- /dev/null +++ b/docs/code-quality-refactoring-plan.md @@ -0,0 +1,134 @@ +# 코드 품질 리팩터링 계획 + +## 목표와 원칙 + +대형 파일을 단순히 줄 수만 나누지 않고, 변경 이유와 검증 경계가 같은 책임끼리 모듈화한다. + +- UI 컴포넌트, 비동기 상태 훅, 순수 변환 모델을 분리한다. +- 기존 공개 import 경로와 wire/API 계약은 가능한 한 유지한다. +- 파일 이동 커밋과 동작 변경 커밋을 섞지 않는다. +- 각 단계에서 관련 테스트를 먼저 실행하고, 단계 종료 시 전체 게이트를 실행한다. +- 성능 수치는 실제 benchmark 실행 결과만 기록한다. + +## 이번 브랜치에서 완료한 범위 + +기준 브랜치: `main`의 `af1bc19c` + +| 영역 | 이전 | 현재 | 분리 결과 | +| --- | ---: | ---: | --- | +| `PropertiesPanel.tsx` | 3407줄 | 1907줄 | 선택 모델, navigation 훅, 단일 선택 commit handler, 플러그인 설정 폼 분리 | +| `BatchSelectionPanel.tsx` | 2367줄 | 1277줄 | 공통 handler/header와 그래프·노브·플러그인 전용 패널 분리 | +| `SingleSelectionPanel.tsx` | 2061줄 | 674줄 | 그래프·노브 패널과 공통 표시 모델 분리 | +| `Settings.tsx` | 1423줄 | 1172줄 | 키음 출력 UI, 비동기 적용 큐 훅, 순수 view model 분리 | +| `store.rs` | 22978줄 | 3349줄 | 테스트, writer 영속화, 자산 참조, 사운드 복구·trash·sweep 분리 | +| `editor_ops.rs` | 10154줄 | 2940줄 | 인라인 테스트 모듈 분리 | +| `migration.rs` | 6053줄 | 2238줄 | 인라인 테스트 모듈 분리 | +| `editor.rs` | 5777줄 | 2396줄 | 인라인 테스트 모듈 분리 | +| `app_state.rs` | 9314줄 | 6600줄 | 인라인 테스트 모듈 분리 | +| `models/mod.rs` | 4169줄 | 3033줄 | 인라인 테스트 모듈 분리 | + +ASIO는 장치 I/O 경계와 순수 정책을 `audio/engine/asio.rs`로 분리했다. 버퍼 정규화, 드라이버 목록 정규화, 유효 출력 구성, 빌드 가용성, 오류 코드와 폴백 계약을 하드웨어 없이 검증한다. + +## 잔여 대형 프론트엔드 우선순위 + +아래 줄 수는 이번 브랜치에서 `wc -l`로 측정한 값이다. + +### 1. 에디터 런타임 + +- `editor/runtime/editorCoordinator.ts` — 3001줄 + - mutation queue와 retry/conflict 처리 + - canonical snapshot 동기화와 event publication + - gesture/operation 결과 projection + - facade에는 public coordinator API와 조립만 유지 +- `editor/runtime/elementOps.ts` — 2611줄 + - geometry, style, paint/shadow, media, batch operation별 모듈 + - 기존 `elementOps` import 경로는 barrel export로 유지 + +검증: coordinator/element operation 단위 테스트, conflict·retry·gesture focused suite, 전체 Vitest. + +### 2. Grid 상호작용 + +- `Grid/core/Grid.tsx` — 2303줄 + - scene/layer 조립, 포인터·키보드 interaction, overlay UI 분리 + - 선택과 resize/drag hook은 facade에서 주입 +- `hooks/Grid/useGridSelection.ts` — 1552줄 + - 순수 selection reducer/model과 DOM event adapter 분리 + - marquee, modifier, group/plugin selection을 각각 테스트 +- `components/shared/PluginElement.tsx` — 1647줄 + - runtime props 해석, 측정/geometry, pointer/context menu bridge 분리 + +검증: Grid interaction focused suite, detached panel contract, plugin element isolation, 전체 Vitest. + +### 3. 공통 입력과 편집기 + +- `ColorPicker.tsx` — 1513줄: 색상 상태 모델, gradient editor, palette/history UI 분리 +- `NumberInput.tsx` — 1440줄: 수식 parser, scrub/keyboard session, 표시 컴포넌트 분리 +- `CounterAnimationEditorModal.tsx` — 1417줄: draft reducer, media preview, form section 분리 +- `SoundTrimModal.tsx` — 1275줄: waveform/selection model, decode/export 작업, UI 분리 + +입력 컴포넌트는 Escape 취소, preview/commit 경계, child window 동작을 회귀 테스트로 고정한 뒤 이동한다. + +## 잔여 대형 백엔드 우선순위 + +- `state/app_state.rs` — 6600줄 + - bootstrap, window/event publication, keyboard runtime, shutdown coordinator 분리 + - test-only emitter/harness도 별도 test support 모듈로 이동 +- `state/store.rs` — 3349줄 + - core transaction facade만 남기고 editor/history transaction과 plugin storage transaction 분리 + - writer와 asset 모듈이 store lock을 우회하지 못하도록 현재 commit 경계를 유지 +- `commands/preset/load.rs` — 3104줄 + - 파일 decode/validation, migration, store transaction, event projection 분리 +- `commands/keys/sound.rs` — 2189줄 + - scan/library, WAV processing, delete transaction command 분리 + +store 자산 분리는 다음 불변식을 계속 지킨다. + +- orphan 정리는 직접 삭제 대신 `trash/<세션>/` 30일 격리 +- store 복구 세션은 sweep 생략 +- 파일 자산 추가 시 참조 수집과 손상/크래시 복구 테스트 동시 추가 +- `keys[mode][i]`와 `keyPositions[mode][i]`의 인덱스 결합 유지 + +## 단계별 검증 게이트 + +### 프론트엔드 단계 + +```bash +npx tsc --noEmit +npm run lint +npm run format +npm test -- --reporter=dot +npm run build +``` + +### Rust 단계 + +```bash +cd src-tauri +cargo fmt --check +cargo check +cargo clippy --all-targets -- -D warnings +cargo test +``` + +Tauri command를 추가하거나 삭제한 단계에서는 production build 후 `permissions/dmnote-allow-all.json` 갱신 여부도 확인한다. + +## ASIO 검증 행렬 + +자동 테스트는 하드웨어와 무관한 정책을 모든 개발 플랫폼에서 실행한다. 실제 Windows/ASIO 경계는 다음 순서로 별도 확인한다. + +1. Windows에서 `cargo test --features asio-backend audio::engine` 실행 +2. `npm run tauri:build`로 ASIO feature production build 확인 +3. 장치 목록 정렬·중복 제거와 저장된 분리 장치 표시 확인 +4. 기본값, 0, 비표준 buffer frame 처리 확인 +5. 장치 부재와 장치 open 실패의 서로 다른 오류 코드·기동 폴백 확인 +6. 실제 드라이버에서 선택, 재시작 복원, 다른 ASIO 앱 점유 중 폴백을 수동 smoke test + +macOS의 Windows cross-check는 MSVC C 런타임 헤더가 없는 환경에서 `ring` C 빌드 전에 중단되므로 Windows 네이티브 검증을 대체하지 않는다. + +## 완료 조건 + +- 단계별 commit이 한 책임만 설명한다. +- 기존 공개 API와 저장 데이터 형식이 유지된다. +- focused suite와 전체 게이트가 모두 통과한다. +- 새 경고가 없고 Rust는 Clippy `-D warnings`를 통과한다. +- 남은 대형 파일은 이 문서의 다음 책임 경계로 이어지며, 줄 수만 줄이기 위한 임의 분할은 하지 않는다. From 378c68aad0afcfc7f80192a41c010f79c3a71abf Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 07:01:34 +0900 Subject: [PATCH 011/245] =?UTF-8?q?refactor:=20=EC=97=90=EB=94=94=ED=84=B0?= =?UTF-8?q?=20=EC=9D=98=EB=AF=B8=20=EC=97=B0=EC=82=B0=20=ED=88=AC=EC=98=81?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../editor/runtime/editorCoordinator.ts | 853 +---------------- .../editor/runtime/semanticOpsProjection.ts | 862 ++++++++++++++++++ 2 files changed, 866 insertions(+), 849 deletions(-) create mode 100644 src/renderer/editor/runtime/semanticOpsProjection.ts diff --git a/src/renderer/editor/runtime/editorCoordinator.ts b/src/renderer/editor/runtime/editorCoordinator.ts index 7d013ba89..1ae8a8054 100644 --- a/src/renderer/editor/runtime/editorCoordinator.ts +++ b/src/renderer/editor/runtime/editorCoordinator.ts @@ -1,33 +1,9 @@ import { stableStringify } from '@utils/core/stableStringify'; import { - normalizeLayerGroupsForMode, - projectLayerGroupRename, - projectStableElementGroups, -} from '@utils/layerGroupUtils'; -import { currentPluginGroupMembers } from './pluginGroupMembers'; -import { - inheritedPaintMaterialization, - paintPropertyFields, -} from '@src/types/color'; -import { projectElementShadowPatch } from '@src/types/key/shadows'; -import { - applyImageTransformLeaf, - type ImageTransform, -} from '@src/types/key/imageLayer'; -import { - isNotePaintPropertyPatchV1, - mirrorBodyPaintToGlow, - projectNotePaintPatch, -} from '@src/types/key/notePaint'; -import { - isCounterFillPropertyPatchV1, - projectCounterFillPatch, -} from '@src/types/key/counterFill'; -import { - DEFAULT_ELEMENT_ACTIVE_SHADOW_SPEC, - DEFAULT_ELEMENT_SHADOW_SPEC, -} from '@utils/core/elementDefaults'; -import { isEditorShadowPropertyPatchV1 } from '@src/types/editor'; + SEMANTIC_POSITION_FIELDS, + applySemanticOps, + fieldsForSemanticOp, +} from './semanticOpsProjection'; import { EDITOR_COMMIT_SCHEMA_VERSION, @@ -65,10 +41,6 @@ import type { EditorPatchV1, EditorLegacyPatchV1, } from '@src/types/editor'; -import { - implicitCounterFontBold, - implicitElementFontBold, -} from '@utils/core/fontWeights'; export type EditorApplyReason = | 'initial' @@ -255,823 +227,6 @@ const patchForFields = ( return patch; }; -const SEMANTIC_POSITION_FIELDS: Record = { - key: 'keyPositions', - stat: 'statPositions', - graph: 'graphPositions', - knob: 'knobPositions', -}; - -const fieldsForSemanticOp = (op: EditorOpV1): EditorField[] => { - if (op.kind === 'insertFrozenElements') { - const fields = new Set(); - op.elements.forEach((element) => { - fields.add(SEMANTIC_POSITION_FIELDS[element.elementType]); - if (element.elementType === 'key') fields.add('keys'); - }); - op.zUpdates.forEach((update) => - fields.add(SEMANTIC_POSITION_FIELDS[update.elementType]), - ); - if (op.groups.length > 0) fields.add('layerGroups'); - return [...fields]; - } - if (op.kind === 'reorderElements') { - const fields = new Set(); - op.zUpdates.forEach((update) => - fields.add(SEMANTIC_POSITION_FIELDS[update.elementType]), - ); - op.groupUpdates.forEach((update) => - fields.add(SEMANTIC_POSITION_FIELDS[update.elementType]), - ); - if (op.completeModeOrder) fields.add('layerGroups'); - return [...fields]; - } - if (op.kind === 'setElementGroups') { - return [ - ...new Set([ - ...op.targets.map( - (target) => SEMANTIC_POSITION_FIELDS[target.elementType], - ), - 'layerGroups' as const, - ]), - ]; - } - if (op.kind === 'renameLayerGroup') return ['layerGroups']; - if (op.kind === 'patchElement') { - return [SEMANTIC_POSITION_FIELDS[op.elementType]]; - } - if (op.kind === 'setKeySlot') return ['keys']; - const positionField = SEMANTIC_POSITION_FIELDS[op.elementType]; - if (op.kind === 'setBounds') return [positionField]; - return op.elementType === 'key' ? ['keys', 'keyPositions'] : [positionField]; -}; - -// 그룹 normalize의 플러그인 멤버 소스는 plugin store가 등록한 제공자 - -// 백엔드가 store 인스턴스를 쓰는 것의 미러. 낙관 replay의 근사이며 -// 드리프트는 커밋 이벤트의 canonical patch가 정정한다 - -const applySemanticOps = ( - base: CanonicalEditorDocumentV1, - ops: readonly EditorOpV1[], - results?: readonly EditorOpResultV1[], -): CanonicalEditorDocumentV1 => { - const next = clone(base); - ops.forEach((op, opIndex) => { - const result = results?.[opIndex]; - if (result?.status === 'targetMissing') return; - if (op.kind === 'insertFrozenElements') { - if (result?.status === 'noChange') return; - if (op.groups.length > 0) { - const existingGroupIds = new Set( - (next.layerGroups[op.mode] ?? []).map((group) => group.id), - ); - next.layerGroups = { - ...next.layerGroups, - [op.mode]: [ - ...(next.layerGroups[op.mode] ?? []), - ...op.groups - .filter((group) => !existingGroupIds.has(group.id)) - .map((group) => ({ ...group })), - ], - }; - } - for (const element of op.elements) { - let existing: - | { elementType: EditorElementTypeV1; mode: string; index: number } - | undefined; - for (const [elementType, field] of Object.entries( - SEMANTIC_POSITION_FIELDS, - ) as Array<[EditorElementTypeV1, EditorField]>) { - for (const [mode, positions] of Object.entries(next[field])) { - const index = positions.findIndex( - (position) => position.id === element.position.id, - ); - if (index >= 0) { - existing = { elementType, mode, index }; - break; - } - } - if (existing) break; - } - if (existing) { - if ( - existing.elementType !== element.elementType || - existing.mode !== op.mode - ) { - continue; - } - const field = SEMANTIC_POSITION_FIELDS[element.elementType]; - const record = next[field] as Record< - string, - Array> - >; - next[field] = { - ...record, - [op.mode]: (record[op.mode] ?? []).map((position, index) => - index === existing!.index - ? (clone(element.position) as Record) - : position, - ), - } as never; - if (element.elementType === 'key') { - next.keys = { - ...next.keys, - [op.mode]: (next.keys[op.mode] ?? []).map((slot, index) => - index === existing!.index ? clone(element.slot) : slot, - ), - }; - } - continue; - } - if (element.elementType === 'key') { - next.keys = { - ...next.keys, - [op.mode]: [...(next.keys[op.mode] ?? []), clone(element.slot)], - }; - next.keyPositions = { - ...next.keyPositions, - [op.mode]: [ - ...(next.keyPositions[op.mode] ?? []), - clone(element.position), - ], - }; - } else { - const field = SEMANTIC_POSITION_FIELDS[element.elementType]; - const record = next[field] as Record< - string, - Array> - >; - next[field] = { - ...record, - [op.mode]: [ - ...(record[op.mode] ?? []), - clone(element.position) as Record, - ], - } as never; - } - } - for (const update of op.zUpdates) { - const field = SEMANTIC_POSITION_FIELDS[update.elementType]; - const record = next[field] as Record< - string, - Array & { id: string }> - >; - const positions = record[op.mode] ?? []; - const index = positions.findIndex( - (position) => position.id === update.id, - ); - if (index < 0) continue; - next[field] = { - ...record, - [op.mode]: positions.map((position, positionIndex) => - positionIndex === index - ? (position.zIndex ?? 0) === update.zIndex - ? position - : { ...position, zIndex: update.zIndex } - : position, - ), - } as never; - } - return; - } - if (op.kind === 'reorderElements') { - if (result?.status === 'noChange') return; - const zById = new Map( - op.zUpdates.map((update) => [update.id, update] as const), - ); - const groupById = new Map( - op.groupUpdates.map((update) => [update.id, update] as const), - ); - const touchedTypes = new Set([ - ...op.zUpdates.map((update) => update.elementType), - ...op.groupUpdates.map((update) => update.elementType), - ]); - for (const elementType of touchedTypes) { - const field = SEMANTIC_POSITION_FIELDS[elementType]; - const record = next[field] as Record< - string, - Array & { id: string }> - >; - next[field] = { - ...record, - [op.mode]: (record[op.mode] ?? []).map((position) => { - const id = position.id; - const zUpdate = zById.get(id); - const groupUpdate = groupById.get(id); - if ( - zUpdate?.elementType !== elementType && - groupUpdate?.elementType !== elementType - ) { - return position; - } - const updated = { ...position }; - if (zUpdate?.elementType === elementType) { - updated.zIndex = zUpdate.zIndex; - } - if (groupUpdate?.elementType === elementType) { - if (groupUpdate.groupId === null) delete updated.groupId; - else updated.groupId = groupUpdate.groupId; - } - return updated; - }), - } as never; - } - if (op.completeModeOrder) { - const normalized = normalizeLayerGroupsForMode({ - mode: op.mode, - keyPositions: next.keyPositions, - statPositions: next.statPositions, - graphPositions: next.graphPositions, - knobPositions: next.knobPositions, - layerGroups: next.layerGroups, - pluginElements: currentPluginGroupMembers(), - }); - next.keyPositions = normalized.keyPositions; - next.statPositions = normalized.statPositions; - next.graphPositions = normalized.graphPositions; - next.knobPositions = normalized.knobPositions; - next.layerGroups = normalized.layerGroups; - } - return; - } - if (op.kind === 'setElementGroups') { - if (result?.status === 'noChange') return; - const projected = projectStableElementGroups({ - mode: op.mode, - targets: op.targets, - targetGroup: op.targetGroup, - keyPositions: next.keyPositions, - statPositions: next.statPositions, - graphPositions: next.graphPositions, - knobPositions: next.knobPositions, - layerGroups: next.layerGroups, - pluginElements: currentPluginGroupMembers(), - }); - if (!projected) return; - next.keyPositions = projected.keyPositions; - next.statPositions = projected.statPositions; - next.graphPositions = projected.graphPositions; - next.knobPositions = projected.knobPositions; - next.layerGroups = projected.layerGroups; - return; - } - if (op.kind === 'renameLayerGroup') { - if (result?.status === 'noChange') return; - const layerGroups = projectLayerGroupRename({ - mode: op.mode, - groupId: op.groupId, - name: op.name, - layerGroups: next.layerGroups, - }); - if (layerGroups) next.layerGroups = layerGroups; - return; - } - if (op.kind === 'patchElement') { - if (result?.status === 'noChange') return; - const field = SEMANTIC_POSITION_FIELDS[op.elementType]; - const record = next[field] as Record< - string, - Array & { id: string }> - >; - for (const [mode, positions] of Object.entries(record)) { - const index = positions.findIndex((position) => position.id === op.id); - if (index < 0) continue; - next[field] = { - ...record, - [mode]: positions.map((position, positionIndex) => { - if (positionIndex !== index) return position; - if (op.patch.property === 'layerName') { - const updated = { ...position }; - if (op.patch.value === null) delete updated.layerName; - else updated.layerName = op.patch.value; - return updated; - } - if (op.patch.property === 'graphType') { - return { ...position, graphType: op.patch.value }; - } - if (op.patch.property === 'graphColor') { - return { ...position, graphColor: op.patch.value }; - } - if (op.patch.property === 'showAvgLine') { - return { ...position, showAvgLine: op.patch.value }; - } - if (op.patch.property === 'graphAnimationEnabled') { - return { - ...position, - graphAnimationEnabled: op.patch.value, - }; - } - if (op.patch.property === 'graphSpeed') { - return { ...position, graphSpeed: op.patch.value }; - } - if (op.patch.property === 'reverse') { - return { ...position, reverse: op.patch.value }; - } - if (op.patch.property === 'sensitivity') { - return { ...position, sensitivity: op.patch.value }; - } - if (op.patch.property === 'axisId') { - return { ...position, axisId: op.patch.value }; - } - if (op.patch.property === 'soundEnabled') { - return { ...position, soundEnabled: op.patch.value }; - } - if (op.patch.property === 'soundVolume') { - return { ...position, soundVolume: op.patch.value }; - } - if (op.patch.property === 'soundPath') { - return { ...position, soundPath: op.patch.value }; - } - if (op.patch.property === 'inactiveImage') { - return { ...position, inactiveImage: op.patch.value }; - } - if (op.patch.property === 'activeImage') { - return { ...position, activeImage: op.patch.value }; - } - if (op.patch.property === 'idleTransparent') { - return { - ...position, - idleTransparent: op.patch.value, - }; - } - if (op.patch.property === 'activeTransparent') { - return { - ...position, - activeTransparent: op.patch.value, - }; - } - if (op.patch.property === 'idleImageFit') { - return { ...position, idleImageFit: op.patch.value }; - } - if (op.patch.property === 'activeImageFit') { - return { ...position, activeImageFit: op.patch.value }; - } - if (op.patch.property === 'imageMode') { - // replace는 기본값이라 sparse 저장 - 백엔드와 동일 - const { imageMode: _imageMode, ...rest } = position; - return op.patch.value === 'replace' - ? rest - : { ...position, imageMode: op.patch.value }; - } - if ( - op.patch.property === 'idleImageTransform' || - op.patch.property === 'activeImageTransform' - ) { - const field = op.patch.property; - if (op.patch.value === null) { - const { [field]: _dropped, ...rest } = position; - return rest; - } - return { - ...position, - [field]: applyImageTransformLeaf( - position[field] as ImageTransform | undefined, - op.patch.value, - ), - }; - } - if (op.patch.property === 'counterEnabled') { - const counter = position.counter as - | Record - | undefined; - return { - ...position, - counter: { ...counter, enabled: op.patch.value }, - }; - } - if (op.patch.property === 'counterAnimationEnabled') { - const counter = position.counter as - | Record - | undefined; - const animation = (counter?.animation ?? {}) as Record< - string, - unknown - >; - return { - ...position, - counter: { - ...counter, - animation: { - ...animation, - enabled: op.patch.value, - }, - }, - }; - } - if (op.patch.property === 'counterPlacement') { - const counter = position.counter as - | Record - | undefined; - return { - ...position, - counter: { ...counter, placement: op.patch.value }, - }; - } - if (op.patch.property === 'counterAlign') { - const counter = position.counter as - | Record - | undefined; - return { - ...position, - counter: { ...counter, align: op.patch.value }, - }; - } - if (op.patch.property === 'counterAlignMode') { - const counter = position.counter as - | Record - | undefined; - return { - ...position, - counter: { ...counter, alignMode: op.patch.value }, - }; - } - if (op.patch.property === 'counterGap') { - const counter = position.counter as - | Record - | undefined; - return { - ...position, - counter: { ...counter, gap: op.patch.value }, - }; - } - if (op.patch.property === 'counterFontSize') { - const counter = position.counter as - | Record - | undefined; - return { - ...position, - counter: { ...counter, fontSize: op.patch.value }, - }; - } - if (op.patch.property === 'counterFontWeight') { - const counter = position.counter as - | Record - | undefined; - // 백엔드와 같은 암묵 Bold 고정 (fontWeights.implicitCounterFontBold) - return { - ...position, - counter: { - ...counter, - fontWeight: op.patch.value, - ...(typeof counter?.fontBold !== 'boolean' - ? { fontBold: implicitCounterFontBold(counter?.fontWeight) } - : {}), - }, - }; - } - if (op.patch.property === 'counterFontBold') { - const counter = position.counter as - | Record - | undefined; - return { - ...position, - counter: { ...counter, fontBold: op.patch.value }, - }; - } - if (op.patch.property === 'counterFontItalic') { - const counter = position.counter as - | Record - | undefined; - return { - ...position, - counter: { ...counter, fontItalic: op.patch.value }, - }; - } - if (op.patch.property === 'counterFontUnderline') { - const counter = position.counter as - | Record - | undefined; - return { - ...position, - counter: { - ...counter, - fontUnderline: op.patch.value, - }, - }; - } - if (op.patch.property === 'counterFontStrikethrough') { - const counter = position.counter as - | Record - | undefined; - return { - ...position, - counter: { - ...counter, - fontStrikethrough: op.patch.value, - }, - }; - } - if (op.patch.property === 'counterFontFamily') { - const counter = position.counter as - | Record - | undefined; - return { - ...position, - counter: { - ...counter, - fontFamily: op.patch.value, - }, - }; - } - if (op.patch.property === 'counterAnimationPreset') { - const counter = position.counter as - | Record - | undefined; - const animation = (counter?.animation ?? {}) as Record< - string, - unknown - >; - const intent = op.patch.value; - return { - ...position, - counter: { - ...counter, - animation: { - ...animation, - ...('applyPresetId' in intent - ? { presetId: intent.presetId } - : {}), - ...('bezier' in intent - ? { bezier: [...intent.bezier] } - : {}), - ...('scale' in intent ? { scale: intent.scale } : {}), - ...('durationMs' in intent - ? { durationMs: intent.durationMs } - : {}), - }, - }, - }; - } - if (op.patch.property === 'useInlineStyles') { - return { - ...position, - useInlineStyles: op.patch.value, - }; - } - if (op.patch.property === 'fontWeight') { - // 백엔드와 같은 암묵 Bold 고정 (fontWeights.implicitElementFontBold) - return { - ...position, - fontWeight: op.patch.value, - ...(position.fontBold == null - ? { fontBold: implicitElementFontBold(position.fontWeight) } - : {}), - }; - } - if (op.patch.property === 'fontBold') { - return { ...position, fontBold: op.patch.value }; - } - if (op.patch.property === 'fontItalic') { - return { ...position, fontItalic: op.patch.value }; - } - if (op.patch.property === 'fontUnderline') { - return { ...position, fontUnderline: op.patch.value }; - } - if (op.patch.property === 'fontStrikethrough') { - return { - ...position, - fontStrikethrough: op.patch.value, - }; - } - if (op.patch.property === 'fontFamily') { - return { ...position, fontFamily: op.patch.value }; - } - if ( - op.patch.property === 'backgroundPaint' || - op.patch.property === 'activeBackgroundPaint' || - op.patch.property === 'borderPaint' || - op.patch.property === 'activeBorderPaint' || - op.patch.property === 'fontPaint' || - op.patch.property === 'activeFontPaint' - ) { - const field = op.patch.property; - const paint = op.patch.value; - const { - active, - surface, - colorField, - gradientField, - activeColorField, - activeGradientField, - } = paintPropertyFields(field); - // 물질화 대상 - active 쌍을 가진 요소 (font는 키만) - const materializes = - surface === 'font' - ? op.elementType === 'key' - : op.elementType === 'key' || op.elementType === 'knob'; - const preservation: Record = {}; - if (!active && materializes) { - const inherited = inheritedPaintMaterialization( - { - color: - typeof position[colorField] === 'string' - ? (position[colorField] as string) - : undefined, - gradient: position[gradientField] as never, - }, - { - color: - typeof position[activeColorField] === 'string' - ? (position[activeColorField] as string) - : undefined, - gradient: position[activeGradientField] as never, - }, - ); - if (inherited) { - if (inherited.color != null) { - preservation[activeColorField] = inherited.color; - } - if (inherited.gradient) { - preservation[activeGradientField] = inherited.gradient; - } - } - } - return { - ...position, - ...preservation, - [colorField]: paint.color, - [gradientField]: paint.gradient ?? undefined, - }; - } - if (isEditorShadowPropertyPatchV1(op.patch)) { - return { - ...position, - ...projectElementShadowPatch({ - position: position as never, - elementType: op.elementType as 'key' | 'stat' | 'knob', - patch: op.patch, - defaultShadow: DEFAULT_ELEMENT_SHADOW_SPEC, - defaultActiveShadow: DEFAULT_ELEMENT_ACTIVE_SHADOW_SPEC, - }), - }; - } - if (isCounterFillPropertyPatchV1(op.patch)) { - return { - ...position, - ...projectCounterFillPatch(position as never, op.patch), - }; - } - if (isNotePaintPropertyPatchV1(op.patch)) { - // position 전달 - {opacity} 단독의 sibling shadow 재계산 (§9-5) - return { - ...position, - ...projectNotePaintPatch(op.patch, position as never), - }; - } - if (op.patch.property === 'displayText') { - return { ...position, displayText: op.patch.value }; - } - if (op.patch.property === 'className') { - return { ...position, className: op.patch.value }; - } - if (op.patch.property === 'borderWidth') { - return { ...position, borderWidth: op.patch.value }; - } - if (op.patch.property === 'borderRadius') { - return { ...position, borderRadius: op.patch.value }; - } - if (op.patch.property === 'fontSize') { - return { ...position, fontSize: op.patch.value }; - } - if (op.patch.property === 'noteGlowSize') { - return { ...position, noteGlowSize: op.patch.value }; - } - if (op.patch.property === 'noteOffsetX') { - // null 보존 - 백엔드가 이 필드를 null로 직렬화하므로 undefined로 - // 바꾸면 의미가 같은데도 getChangedEditorFields가 변경으로 잡는다 - return { ...position, noteOffsetX: op.patch.value }; - } - if (op.patch.property === 'noteOffsetY') { - // null 보존 - 백엔드가 이 필드를 null로 직렬화하므로 undefined로 - // 바꾸면 의미가 같은데도 getChangedEditorFields가 변경으로 잡는다 - return { ...position, noteOffsetY: op.patch.value }; - } - if (op.patch.property === 'noteWidth') { - // null 보존 - 백엔드가 이 필드를 null로 직렬화하므로 undefined로 - // 바꾸면 의미가 같은데도 getChangedEditorFields가 변경으로 잡는다 - return { ...position, noteWidth: op.patch.value }; - } - if (op.patch.property === 'noteBorderWidth') { - return { - ...position, - noteBorderWidth: op.patch.value, - }; - } - if (op.patch.property === 'noteBorderRadius') { - return { - ...position, - noteBorderRadius: op.patch.value, - }; - } - if (op.patch.property === 'noteEffectEnabled') { - return { - ...position, - noteEffectEnabled: op.patch.value, - }; - } - if (op.patch.property === 'noteAutoYCorrection') { - return { - ...position, - noteAutoYCorrection: op.patch.value, - }; - } - if (op.patch.property === 'noteGlowEnabled') { - return { - ...position, - noteGlowEnabled: op.patch.value, - }; - } - if (op.patch.property === 'noteGlowSyncPaint') { - // 켜는 순간 본체 페인트를 글로우로 복사 (Rust 적용기와 동일) - const next = { ...position, noteGlowSyncPaint: op.patch.value }; - return op.patch.value - ? { ...next, ...mirrorBodyPaintToGlow(next as never) } - : next; - } - if (op.patch.property === 'noteAlignment') { - return { ...position, noteAlignment: op.patch.value }; - } - if (op.patch.property === 'noteBorderSide') { - return { ...position, noteBorderSide: op.patch.value }; - } - if (op.patch.property === 'statType') { - return { ...position, statType: op.patch.value }; - } - if (op.patch.property === 'hidden') { - return { ...position, hidden: op.patch.value }; - } - // 속성 arm 누락을 컴파일 시점에 잡는다. Rust 적용부는 exhaustive - // match라 누락이 컴파일 오류지만 이 체인은 폴백으로 흘렀다 - const unhandled: never = op.patch; - void unhandled; - return position; - }), - } as never; - break; - } - return; - } - if (op.kind === 'setKeySlot') { - if (result?.status === 'noChange') return; - for (const [mode, positions] of Object.entries(next.keyPositions)) { - const index = positions.findIndex((position) => position.id === op.id); - if (index < 0) continue; - next.keys = { - ...next.keys, - [mode]: (next.keys[mode] ?? []).map((slot, slotIndex) => - slotIndex === index ? clone(op.slot) : slot, - ), - }; - break; - } - return; - } - const field = SEMANTIC_POSITION_FIELDS[op.elementType]; - const positionsByMode = next[field] as Record< - string, - Array> - >; - for (const [mode, positions] of Object.entries(positionsByMode)) { - const index = positions.findIndex((position) => position.id === op.id); - if (index < 0) continue; - if (op.kind === 'setBounds') { - const bounds = result?.bounds ?? op.bounds; - positionsByMode[mode] = positions.map((position, positionIndex) => - positionIndex === index ? { ...position, ...bounds } : position, - ); - break; - } - positionsByMode[mode] = positions.filter( - (_, positionIndex) => positionIndex !== index, - ); - if (op.elementType === 'key') { - next.keys = { - ...next.keys, - [mode]: (next.keys[mode] ?? []).filter( - (_, slotIndex) => slotIndex !== index, - ), - }; - } - const normalized = normalizeLayerGroupsForMode({ - mode, - keyPositions: next.keyPositions, - statPositions: next.statPositions, - graphPositions: next.graphPositions, - knobPositions: next.knobPositions, - layerGroups: next.layerGroups, - pluginElements: currentPluginGroupMembers(), - }); - next.keyPositions = normalized.keyPositions; - next.statPositions = normalized.statPositions; - next.graphPositions = normalized.graphPositions; - next.knobPositions = normalized.knobPositions; - next.layerGroups = normalized.layerGroups; - break; - } - }); - return next; -}; - // 위치 필드에서 id로 요소 항목 탐색 const findPositionEntryById = ( document: CanonicalEditorDocumentV1, diff --git a/src/renderer/editor/runtime/semanticOpsProjection.ts b/src/renderer/editor/runtime/semanticOpsProjection.ts new file mode 100644 index 000000000..b1bf89953 --- /dev/null +++ b/src/renderer/editor/runtime/semanticOpsProjection.ts @@ -0,0 +1,862 @@ +import { + normalizeLayerGroupsForMode, + projectLayerGroupRename, + projectStableElementGroups, +} from '@utils/layerGroupUtils'; +import { currentPluginGroupMembers } from './pluginGroupMembers'; +import { + inheritedPaintMaterialization, + paintPropertyFields, +} from '@src/types/color'; +import { projectElementShadowPatch } from '@src/types/key/shadows'; +import { + applyImageTransformLeaf, + type ImageTransform, +} from '@src/types/key/imageLayer'; +import { + isNotePaintPropertyPatchV1, + mirrorBodyPaintToGlow, + projectNotePaintPatch, +} from '@src/types/key/notePaint'; +import { + isCounterFillPropertyPatchV1, + projectCounterFillPatch, +} from '@src/types/key/counterFill'; +import { + DEFAULT_ELEMENT_ACTIVE_SHADOW_SPEC, + DEFAULT_ELEMENT_SHADOW_SPEC, +} from '@utils/core/elementDefaults'; +import { isEditorShadowPropertyPatchV1 } from '@src/types/editor'; +import type { + CanonicalEditorDocumentV1, + EditorElementTypeV1, + EditorField, + EditorOpResultV1, + EditorOpV1, +} from '@src/types/editor'; +import { + implicitCounterFontBold, + implicitElementFontBold, +} from '@utils/core/fontWeights'; + +const clone = (value: T): T => structuredClone(value); + +export const SEMANTIC_POSITION_FIELDS: Record< + EditorElementTypeV1, + EditorField +> = { + key: 'keyPositions', + stat: 'statPositions', + graph: 'graphPositions', + knob: 'knobPositions', +}; + +export const fieldsForSemanticOp = (op: EditorOpV1): EditorField[] => { + if (op.kind === 'insertFrozenElements') { + const fields = new Set(); + op.elements.forEach((element) => { + fields.add(SEMANTIC_POSITION_FIELDS[element.elementType]); + if (element.elementType === 'key') fields.add('keys'); + }); + op.zUpdates.forEach((update) => + fields.add(SEMANTIC_POSITION_FIELDS[update.elementType]), + ); + if (op.groups.length > 0) fields.add('layerGroups'); + return [...fields]; + } + if (op.kind === 'reorderElements') { + const fields = new Set(); + op.zUpdates.forEach((update) => + fields.add(SEMANTIC_POSITION_FIELDS[update.elementType]), + ); + op.groupUpdates.forEach((update) => + fields.add(SEMANTIC_POSITION_FIELDS[update.elementType]), + ); + if (op.completeModeOrder) fields.add('layerGroups'); + return [...fields]; + } + if (op.kind === 'setElementGroups') { + return [ + ...new Set([ + ...op.targets.map( + (target) => SEMANTIC_POSITION_FIELDS[target.elementType], + ), + 'layerGroups' as const, + ]), + ]; + } + if (op.kind === 'renameLayerGroup') return ['layerGroups']; + if (op.kind === 'patchElement') { + return [SEMANTIC_POSITION_FIELDS[op.elementType]]; + } + if (op.kind === 'setKeySlot') return ['keys']; + const positionField = SEMANTIC_POSITION_FIELDS[op.elementType]; + if (op.kind === 'setBounds') return [positionField]; + return op.elementType === 'key' ? ['keys', 'keyPositions'] : [positionField]; +}; + +// 그룹 normalize의 플러그인 멤버 소스는 plugin store가 등록한 제공자 - +// 백엔드가 store 인스턴스를 쓰는 것의 미러. 낙관 replay의 근사이며 +// 드리프트는 커밋 이벤트의 canonical patch가 정정한다 + +export const applySemanticOps = ( + base: CanonicalEditorDocumentV1, + ops: readonly EditorOpV1[], + results?: readonly EditorOpResultV1[], +): CanonicalEditorDocumentV1 => { + const next = clone(base); + ops.forEach((op, opIndex) => { + const result = results?.[opIndex]; + if (result?.status === 'targetMissing') return; + if (op.kind === 'insertFrozenElements') { + if (result?.status === 'noChange') return; + if (op.groups.length > 0) { + const existingGroupIds = new Set( + (next.layerGroups[op.mode] ?? []).map((group) => group.id), + ); + next.layerGroups = { + ...next.layerGroups, + [op.mode]: [ + ...(next.layerGroups[op.mode] ?? []), + ...op.groups + .filter((group) => !existingGroupIds.has(group.id)) + .map((group) => ({ ...group })), + ], + }; + } + for (const element of op.elements) { + let existing: + | { elementType: EditorElementTypeV1; mode: string; index: number } + | undefined; + for (const [elementType, field] of Object.entries( + SEMANTIC_POSITION_FIELDS, + ) as Array<[EditorElementTypeV1, EditorField]>) { + for (const [mode, positions] of Object.entries(next[field])) { + const index = positions.findIndex( + (position) => position.id === element.position.id, + ); + if (index >= 0) { + existing = { elementType, mode, index }; + break; + } + } + if (existing) break; + } + if (existing) { + if ( + existing.elementType !== element.elementType || + existing.mode !== op.mode + ) { + continue; + } + const field = SEMANTIC_POSITION_FIELDS[element.elementType]; + const record = next[field] as Record< + string, + Array> + >; + next[field] = { + ...record, + [op.mode]: (record[op.mode] ?? []).map((position, index) => + index === existing!.index + ? (clone(element.position) as Record) + : position, + ), + } as never; + if (element.elementType === 'key') { + next.keys = { + ...next.keys, + [op.mode]: (next.keys[op.mode] ?? []).map((slot, index) => + index === existing!.index ? clone(element.slot) : slot, + ), + }; + } + continue; + } + if (element.elementType === 'key') { + next.keys = { + ...next.keys, + [op.mode]: [...(next.keys[op.mode] ?? []), clone(element.slot)], + }; + next.keyPositions = { + ...next.keyPositions, + [op.mode]: [ + ...(next.keyPositions[op.mode] ?? []), + clone(element.position), + ], + }; + } else { + const field = SEMANTIC_POSITION_FIELDS[element.elementType]; + const record = next[field] as Record< + string, + Array> + >; + next[field] = { + ...record, + [op.mode]: [ + ...(record[op.mode] ?? []), + clone(element.position) as Record, + ], + } as never; + } + } + for (const update of op.zUpdates) { + const field = SEMANTIC_POSITION_FIELDS[update.elementType]; + const record = next[field] as Record< + string, + Array & { id: string }> + >; + const positions = record[op.mode] ?? []; + const index = positions.findIndex( + (position) => position.id === update.id, + ); + if (index < 0) continue; + next[field] = { + ...record, + [op.mode]: positions.map((position, positionIndex) => + positionIndex === index + ? (position.zIndex ?? 0) === update.zIndex + ? position + : { ...position, zIndex: update.zIndex } + : position, + ), + } as never; + } + return; + } + if (op.kind === 'reorderElements') { + if (result?.status === 'noChange') return; + const zById = new Map( + op.zUpdates.map((update) => [update.id, update] as const), + ); + const groupById = new Map( + op.groupUpdates.map((update) => [update.id, update] as const), + ); + const touchedTypes = new Set([ + ...op.zUpdates.map((update) => update.elementType), + ...op.groupUpdates.map((update) => update.elementType), + ]); + for (const elementType of touchedTypes) { + const field = SEMANTIC_POSITION_FIELDS[elementType]; + const record = next[field] as Record< + string, + Array & { id: string }> + >; + next[field] = { + ...record, + [op.mode]: (record[op.mode] ?? []).map((position) => { + const id = position.id; + const zUpdate = zById.get(id); + const groupUpdate = groupById.get(id); + if ( + zUpdate?.elementType !== elementType && + groupUpdate?.elementType !== elementType + ) { + return position; + } + const updated = { ...position }; + if (zUpdate?.elementType === elementType) { + updated.zIndex = zUpdate.zIndex; + } + if (groupUpdate?.elementType === elementType) { + if (groupUpdate.groupId === null) delete updated.groupId; + else updated.groupId = groupUpdate.groupId; + } + return updated; + }), + } as never; + } + if (op.completeModeOrder) { + const normalized = normalizeLayerGroupsForMode({ + mode: op.mode, + keyPositions: next.keyPositions, + statPositions: next.statPositions, + graphPositions: next.graphPositions, + knobPositions: next.knobPositions, + layerGroups: next.layerGroups, + pluginElements: currentPluginGroupMembers(), + }); + next.keyPositions = normalized.keyPositions; + next.statPositions = normalized.statPositions; + next.graphPositions = normalized.graphPositions; + next.knobPositions = normalized.knobPositions; + next.layerGroups = normalized.layerGroups; + } + return; + } + if (op.kind === 'setElementGroups') { + if (result?.status === 'noChange') return; + const projected = projectStableElementGroups({ + mode: op.mode, + targets: op.targets, + targetGroup: op.targetGroup, + keyPositions: next.keyPositions, + statPositions: next.statPositions, + graphPositions: next.graphPositions, + knobPositions: next.knobPositions, + layerGroups: next.layerGroups, + pluginElements: currentPluginGroupMembers(), + }); + if (!projected) return; + next.keyPositions = projected.keyPositions; + next.statPositions = projected.statPositions; + next.graphPositions = projected.graphPositions; + next.knobPositions = projected.knobPositions; + next.layerGroups = projected.layerGroups; + return; + } + if (op.kind === 'renameLayerGroup') { + if (result?.status === 'noChange') return; + const layerGroups = projectLayerGroupRename({ + mode: op.mode, + groupId: op.groupId, + name: op.name, + layerGroups: next.layerGroups, + }); + if (layerGroups) next.layerGroups = layerGroups; + return; + } + if (op.kind === 'patchElement') { + if (result?.status === 'noChange') return; + const field = SEMANTIC_POSITION_FIELDS[op.elementType]; + const record = next[field] as Record< + string, + Array & { id: string }> + >; + for (const [mode, positions] of Object.entries(record)) { + const index = positions.findIndex((position) => position.id === op.id); + if (index < 0) continue; + next[field] = { + ...record, + [mode]: positions.map((position, positionIndex) => { + if (positionIndex !== index) return position; + if (op.patch.property === 'layerName') { + const updated = { ...position }; + if (op.patch.value === null) delete updated.layerName; + else updated.layerName = op.patch.value; + return updated; + } + if (op.patch.property === 'graphType') { + return { ...position, graphType: op.patch.value }; + } + if (op.patch.property === 'graphColor') { + return { ...position, graphColor: op.patch.value }; + } + if (op.patch.property === 'showAvgLine') { + return { ...position, showAvgLine: op.patch.value }; + } + if (op.patch.property === 'graphAnimationEnabled') { + return { + ...position, + graphAnimationEnabled: op.patch.value, + }; + } + if (op.patch.property === 'graphSpeed') { + return { ...position, graphSpeed: op.patch.value }; + } + if (op.patch.property === 'reverse') { + return { ...position, reverse: op.patch.value }; + } + if (op.patch.property === 'sensitivity') { + return { ...position, sensitivity: op.patch.value }; + } + if (op.patch.property === 'axisId') { + return { ...position, axisId: op.patch.value }; + } + if (op.patch.property === 'soundEnabled') { + return { ...position, soundEnabled: op.patch.value }; + } + if (op.patch.property === 'soundVolume') { + return { ...position, soundVolume: op.patch.value }; + } + if (op.patch.property === 'soundPath') { + return { ...position, soundPath: op.patch.value }; + } + if (op.patch.property === 'inactiveImage') { + return { ...position, inactiveImage: op.patch.value }; + } + if (op.patch.property === 'activeImage') { + return { ...position, activeImage: op.patch.value }; + } + if (op.patch.property === 'idleTransparent') { + return { + ...position, + idleTransparent: op.patch.value, + }; + } + if (op.patch.property === 'activeTransparent') { + return { + ...position, + activeTransparent: op.patch.value, + }; + } + if (op.patch.property === 'idleImageFit') { + return { ...position, idleImageFit: op.patch.value }; + } + if (op.patch.property === 'activeImageFit') { + return { ...position, activeImageFit: op.patch.value }; + } + if (op.patch.property === 'imageMode') { + // replace는 기본값이라 sparse 저장 - 백엔드와 동일 + const { imageMode: _imageMode, ...rest } = position; + return op.patch.value === 'replace' + ? rest + : { ...position, imageMode: op.patch.value }; + } + if ( + op.patch.property === 'idleImageTransform' || + op.patch.property === 'activeImageTransform' + ) { + const field = op.patch.property; + if (op.patch.value === null) { + const { [field]: _dropped, ...rest } = position; + return rest; + } + return { + ...position, + [field]: applyImageTransformLeaf( + position[field] as ImageTransform | undefined, + op.patch.value, + ), + }; + } + if (op.patch.property === 'counterEnabled') { + const counter = position.counter as + | Record + | undefined; + return { + ...position, + counter: { ...counter, enabled: op.patch.value }, + }; + } + if (op.patch.property === 'counterAnimationEnabled') { + const counter = position.counter as + | Record + | undefined; + const animation = (counter?.animation ?? {}) as Record< + string, + unknown + >; + return { + ...position, + counter: { + ...counter, + animation: { + ...animation, + enabled: op.patch.value, + }, + }, + }; + } + if (op.patch.property === 'counterPlacement') { + const counter = position.counter as + | Record + | undefined; + return { + ...position, + counter: { ...counter, placement: op.patch.value }, + }; + } + if (op.patch.property === 'counterAlign') { + const counter = position.counter as + | Record + | undefined; + return { + ...position, + counter: { ...counter, align: op.patch.value }, + }; + } + if (op.patch.property === 'counterAlignMode') { + const counter = position.counter as + | Record + | undefined; + return { + ...position, + counter: { ...counter, alignMode: op.patch.value }, + }; + } + if (op.patch.property === 'counterGap') { + const counter = position.counter as + | Record + | undefined; + return { + ...position, + counter: { ...counter, gap: op.patch.value }, + }; + } + if (op.patch.property === 'counterFontSize') { + const counter = position.counter as + | Record + | undefined; + return { + ...position, + counter: { ...counter, fontSize: op.patch.value }, + }; + } + if (op.patch.property === 'counterFontWeight') { + const counter = position.counter as + | Record + | undefined; + // 백엔드와 같은 암묵 Bold 고정 (fontWeights.implicitCounterFontBold) + return { + ...position, + counter: { + ...counter, + fontWeight: op.patch.value, + ...(typeof counter?.fontBold !== 'boolean' + ? { fontBold: implicitCounterFontBold(counter?.fontWeight) } + : {}), + }, + }; + } + if (op.patch.property === 'counterFontBold') { + const counter = position.counter as + | Record + | undefined; + return { + ...position, + counter: { ...counter, fontBold: op.patch.value }, + }; + } + if (op.patch.property === 'counterFontItalic') { + const counter = position.counter as + | Record + | undefined; + return { + ...position, + counter: { ...counter, fontItalic: op.patch.value }, + }; + } + if (op.patch.property === 'counterFontUnderline') { + const counter = position.counter as + | Record + | undefined; + return { + ...position, + counter: { + ...counter, + fontUnderline: op.patch.value, + }, + }; + } + if (op.patch.property === 'counterFontStrikethrough') { + const counter = position.counter as + | Record + | undefined; + return { + ...position, + counter: { + ...counter, + fontStrikethrough: op.patch.value, + }, + }; + } + if (op.patch.property === 'counterFontFamily') { + const counter = position.counter as + | Record + | undefined; + return { + ...position, + counter: { + ...counter, + fontFamily: op.patch.value, + }, + }; + } + if (op.patch.property === 'counterAnimationPreset') { + const counter = position.counter as + | Record + | undefined; + const animation = (counter?.animation ?? {}) as Record< + string, + unknown + >; + const intent = op.patch.value; + return { + ...position, + counter: { + ...counter, + animation: { + ...animation, + ...('applyPresetId' in intent + ? { presetId: intent.presetId } + : {}), + ...('bezier' in intent + ? { bezier: [...intent.bezier] } + : {}), + ...('scale' in intent ? { scale: intent.scale } : {}), + ...('durationMs' in intent + ? { durationMs: intent.durationMs } + : {}), + }, + }, + }; + } + if (op.patch.property === 'useInlineStyles') { + return { + ...position, + useInlineStyles: op.patch.value, + }; + } + if (op.patch.property === 'fontWeight') { + // 백엔드와 같은 암묵 Bold 고정 (fontWeights.implicitElementFontBold) + return { + ...position, + fontWeight: op.patch.value, + ...(position.fontBold == null + ? { fontBold: implicitElementFontBold(position.fontWeight) } + : {}), + }; + } + if (op.patch.property === 'fontBold') { + return { ...position, fontBold: op.patch.value }; + } + if (op.patch.property === 'fontItalic') { + return { ...position, fontItalic: op.patch.value }; + } + if (op.patch.property === 'fontUnderline') { + return { ...position, fontUnderline: op.patch.value }; + } + if (op.patch.property === 'fontStrikethrough') { + return { + ...position, + fontStrikethrough: op.patch.value, + }; + } + if (op.patch.property === 'fontFamily') { + return { ...position, fontFamily: op.patch.value }; + } + if ( + op.patch.property === 'backgroundPaint' || + op.patch.property === 'activeBackgroundPaint' || + op.patch.property === 'borderPaint' || + op.patch.property === 'activeBorderPaint' || + op.patch.property === 'fontPaint' || + op.patch.property === 'activeFontPaint' + ) { + const field = op.patch.property; + const paint = op.patch.value; + const { + active, + surface, + colorField, + gradientField, + activeColorField, + activeGradientField, + } = paintPropertyFields(field); + // 물질화 대상 - active 쌍을 가진 요소 (font는 키만) + const materializes = + surface === 'font' + ? op.elementType === 'key' + : op.elementType === 'key' || op.elementType === 'knob'; + const preservation: Record = {}; + if (!active && materializes) { + const inherited = inheritedPaintMaterialization( + { + color: + typeof position[colorField] === 'string' + ? (position[colorField] as string) + : undefined, + gradient: position[gradientField] as never, + }, + { + color: + typeof position[activeColorField] === 'string' + ? (position[activeColorField] as string) + : undefined, + gradient: position[activeGradientField] as never, + }, + ); + if (inherited) { + if (inherited.color != null) { + preservation[activeColorField] = inherited.color; + } + if (inherited.gradient) { + preservation[activeGradientField] = inherited.gradient; + } + } + } + return { + ...position, + ...preservation, + [colorField]: paint.color, + [gradientField]: paint.gradient ?? undefined, + }; + } + if (isEditorShadowPropertyPatchV1(op.patch)) { + return { + ...position, + ...projectElementShadowPatch({ + position: position as never, + elementType: op.elementType as 'key' | 'stat' | 'knob', + patch: op.patch, + defaultShadow: DEFAULT_ELEMENT_SHADOW_SPEC, + defaultActiveShadow: DEFAULT_ELEMENT_ACTIVE_SHADOW_SPEC, + }), + }; + } + if (isCounterFillPropertyPatchV1(op.patch)) { + return { + ...position, + ...projectCounterFillPatch(position as never, op.patch), + }; + } + if (isNotePaintPropertyPatchV1(op.patch)) { + // position 전달 - {opacity} 단독의 sibling shadow 재계산 (§9-5) + return { + ...position, + ...projectNotePaintPatch(op.patch, position as never), + }; + } + if (op.patch.property === 'displayText') { + return { ...position, displayText: op.patch.value }; + } + if (op.patch.property === 'className') { + return { ...position, className: op.patch.value }; + } + if (op.patch.property === 'borderWidth') { + return { ...position, borderWidth: op.patch.value }; + } + if (op.patch.property === 'borderRadius') { + return { ...position, borderRadius: op.patch.value }; + } + if (op.patch.property === 'fontSize') { + return { ...position, fontSize: op.patch.value }; + } + if (op.patch.property === 'noteGlowSize') { + return { ...position, noteGlowSize: op.patch.value }; + } + if (op.patch.property === 'noteOffsetX') { + // null 보존 - 백엔드가 이 필드를 null로 직렬화하므로 undefined로 + // 바꾸면 의미가 같은데도 getChangedEditorFields가 변경으로 잡는다 + return { ...position, noteOffsetX: op.patch.value }; + } + if (op.patch.property === 'noteOffsetY') { + // null 보존 - 백엔드가 이 필드를 null로 직렬화하므로 undefined로 + // 바꾸면 의미가 같은데도 getChangedEditorFields가 변경으로 잡는다 + return { ...position, noteOffsetY: op.patch.value }; + } + if (op.patch.property === 'noteWidth') { + // null 보존 - 백엔드가 이 필드를 null로 직렬화하므로 undefined로 + // 바꾸면 의미가 같은데도 getChangedEditorFields가 변경으로 잡는다 + return { ...position, noteWidth: op.patch.value }; + } + if (op.patch.property === 'noteBorderWidth') { + return { + ...position, + noteBorderWidth: op.patch.value, + }; + } + if (op.patch.property === 'noteBorderRadius') { + return { + ...position, + noteBorderRadius: op.patch.value, + }; + } + if (op.patch.property === 'noteEffectEnabled') { + return { + ...position, + noteEffectEnabled: op.patch.value, + }; + } + if (op.patch.property === 'noteAutoYCorrection') { + return { + ...position, + noteAutoYCorrection: op.patch.value, + }; + } + if (op.patch.property === 'noteGlowEnabled') { + return { + ...position, + noteGlowEnabled: op.patch.value, + }; + } + if (op.patch.property === 'noteGlowSyncPaint') { + // 켜는 순간 본체 페인트를 글로우로 복사 (Rust 적용기와 동일) + const next = { ...position, noteGlowSyncPaint: op.patch.value }; + return op.patch.value + ? { ...next, ...mirrorBodyPaintToGlow(next as never) } + : next; + } + if (op.patch.property === 'noteAlignment') { + return { ...position, noteAlignment: op.patch.value }; + } + if (op.patch.property === 'noteBorderSide') { + return { ...position, noteBorderSide: op.patch.value }; + } + if (op.patch.property === 'statType') { + return { ...position, statType: op.patch.value }; + } + if (op.patch.property === 'hidden') { + return { ...position, hidden: op.patch.value }; + } + // 속성 arm 누락을 컴파일 시점에 잡는다. Rust 적용부는 exhaustive + // match라 누락이 컴파일 오류지만 이 체인은 폴백으로 흘렀다 + const unhandled: never = op.patch; + void unhandled; + return position; + }), + } as never; + break; + } + return; + } + if (op.kind === 'setKeySlot') { + if (result?.status === 'noChange') return; + for (const [mode, positions] of Object.entries(next.keyPositions)) { + const index = positions.findIndex((position) => position.id === op.id); + if (index < 0) continue; + next.keys = { + ...next.keys, + [mode]: (next.keys[mode] ?? []).map((slot, slotIndex) => + slotIndex === index ? clone(op.slot) : slot, + ), + }; + break; + } + return; + } + const field = SEMANTIC_POSITION_FIELDS[op.elementType]; + const positionsByMode = next[field] as Record< + string, + Array> + >; + for (const [mode, positions] of Object.entries(positionsByMode)) { + const index = positions.findIndex((position) => position.id === op.id); + if (index < 0) continue; + if (op.kind === 'setBounds') { + const bounds = result?.bounds ?? op.bounds; + positionsByMode[mode] = positions.map((position, positionIndex) => + positionIndex === index ? { ...position, ...bounds } : position, + ); + break; + } + positionsByMode[mode] = positions.filter( + (_, positionIndex) => positionIndex !== index, + ); + if (op.elementType === 'key') { + next.keys = { + ...next.keys, + [mode]: (next.keys[mode] ?? []).filter( + (_, slotIndex) => slotIndex !== index, + ), + }; + } + const normalized = normalizeLayerGroupsForMode({ + mode, + keyPositions: next.keyPositions, + statPositions: next.statPositions, + graphPositions: next.graphPositions, + knobPositions: next.knobPositions, + layerGroups: next.layerGroups, + pluginElements: currentPluginGroupMembers(), + }); + next.keyPositions = normalized.keyPositions; + next.statPositions = normalized.statPositions; + next.graphPositions = normalized.graphPositions; + next.knobPositions = normalized.knobPositions; + next.layerGroups = normalized.layerGroups; + break; + } + }); + return next; +}; From 771348364fb3a372f758129f58bf84dfa40a6051 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 07:04:30 +0900 Subject: [PATCH 012/245] =?UTF-8?q?refactor:=20=EC=97=90=EB=94=94=ED=84=B0?= =?UTF-8?q?=20=EC=9A=94=EC=86=8C=20=EC=97=B0=EC=82=B0=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../editor/runtime/elementDocumentModel.ts | 41 + .../editor/runtime/elementGeometryOps.ts | 334 +++ .../editor/runtime/elementGroupOps.ts | 325 +++ src/renderer/editor/runtime/elementOps.ts | 2076 +---------------- .../editor/runtime/elementPropertyCore.ts | 190 ++ .../editor/runtime/elementStyleOps.ts | 1231 ++++++++++ 6 files changed, 2139 insertions(+), 2058 deletions(-) create mode 100644 src/renderer/editor/runtime/elementDocumentModel.ts create mode 100644 src/renderer/editor/runtime/elementGeometryOps.ts create mode 100644 src/renderer/editor/runtime/elementGroupOps.ts create mode 100644 src/renderer/editor/runtime/elementPropertyCore.ts create mode 100644 src/renderer/editor/runtime/elementStyleOps.ts diff --git a/src/renderer/editor/runtime/elementDocumentModel.ts b/src/renderer/editor/runtime/elementDocumentModel.ts new file mode 100644 index 000000000..a78300dc8 --- /dev/null +++ b/src/renderer/editor/runtime/elementDocumentModel.ts @@ -0,0 +1,41 @@ +import type { NativeElementType } from '../model/elementIdMap'; + +export type LooseRecord = Record< + string, + Array<{ id: string } & Record> +>; + +export const findInRecord = ( + record: LooseRecord, + id: string, +): { mode: string; index: number } | null => { + for (const [mode, list] of Object.entries(record)) { + const index = list.findIndex((position) => position.id === id); + if (index >= 0) return { mode, index }; + } + return null; +}; + +export const removeAt = ( + record: LooseRecord, + mode: string, + index: number, +): LooseRecord => ({ + ...record, + [mode]: (record[mode] ?? []).filter((_, i) => i !== index), +}); + +// 삭제: 키는 keys와 keyPositions의 인덱스 결합을 함께 제거, 아이템은 해당 +// 컬렉션만. 반환 false = 실행 시점에 대상 없음(이미 삭제). +// 오류는 전파된다 - 편입 전 실패는 receipt가 로컬 삭제를 되돌린다. +// 선택 보정은 정책상 eager와 함께 즉시 수행하고 실패해도 복구하지 않는다 + +export const FIELD_BY_TYPE: Record< + NativeElementType, + 'keyPositions' | 'statPositions' | 'graphPositions' | 'knobPositions' +> = { + key: 'keyPositions', + stat: 'statPositions', + graph: 'graphPositions', + knob: 'knobPositions', +}; diff --git a/src/renderer/editor/runtime/elementGeometryOps.ts b/src/renderer/editor/runtime/elementGeometryOps.ts new file mode 100644 index 000000000..60765b8ca --- /dev/null +++ b/src/renderer/editor/runtime/elementGeometryOps.ts @@ -0,0 +1,334 @@ +import { isNativeElementId } from '../model/elementId'; +import type { NativeElementType } from '../model/elementIdMap'; +import { + applyPropertyIntentsEagerly, + type PropertyIntents, +} from './elementIntent'; +import { + commitGeneratedSemanticOps, + commitSemanticOps, +} from './editorSemanticOps'; +import { captureEditorDocument } from './editorStateCoordinator'; +import { + computeBatchGeometryPlan, + type BatchGeometryOperation, +} from './batchGeometryPlan'; +import type { + CanonicalEditorDocumentV1, + EditorBoundsV1, + EditorOpV1, +} from '@src/types/editor'; +import { + FIELD_BY_TYPE, + findInRecord, + type LooseRecord, +} from './elementDocumentModel'; + +// 다중 선택 정산: 대상 id들의 현재 canonical 기하(dx·dy)를 의도로 캡처해 +// 슬롯 안에서 id 재해석으로 적용한다. 4컬렉션 full-record 캡처는 배타 +// mutation(카운터 프리셋 삭제 등)의 IPC 창과 겹치면 직렬화 때문에 그 직후에 +// 확정적으로 착지해 무관 필드 재작성을 되돌린다 - 기하만 실어 그 결합을 끊는다 +export type GeometryField = 'dx' | 'dy' | 'width' | 'height'; +type GeometryPatch = Partial>; + +export interface BatchGeometryTarget { + type: NativeElementType; + id: string; +} + +export interface BatchGeometryDescriptor { + mode: string; + targets: readonly BatchGeometryTarget[]; + operation: BatchGeometryOperation; +} + +const readBatchGeometryElements = ( + base: CanonicalEditorDocumentV1, + descriptor: BatchGeometryDescriptor, +) => { + const seen = new Set(); + const elements: Array<{ + key: string; + type: NativeElementType; + id: string; + x: number; + y: number; + width: number; + height: number; + }> = []; + for (const target of descriptor.targets) { + if (!isNativeElementId(target.id) || seen.has(target.id)) return null; + seen.add(target.id); + const record = base[FIELD_BY_TYPE[target.type]] as unknown as LooseRecord; + const locator = findInRecord(record, target.id); + if (!locator || locator.mode !== descriptor.mode) return null; + const position = record[locator.mode]?.[locator.index]; + if ( + !position || + typeof position.dx !== 'number' || + typeof position.dy !== 'number' || + typeof position.width !== 'number' || + typeof position.height !== 'number' + ) { + return null; + } + elements.push({ + key: target.id, + type: target.type, + id: target.id, + x: position.dx, + y: position.dy, + width: position.width, + height: position.height, + }); + } + return elements; +}; + +const planBatchGeometry = ( + base: CanonicalEditorDocumentV1, + descriptor: BatchGeometryDescriptor, +) => { + const elements = readBatchGeometryElements(base, descriptor); + if (!elements) return null; + const plan = computeBatchGeometryPlan(elements, descriptor.operation); + if (!plan) return null; + const targetById = new Map( + elements.map((element) => [element.id, element] as const), + ); + return { + ...plan, + ops: plan.bounds.flatMap(({ key, bounds }) => { + const target = targetById.get(key); + return target + ? [ + { + kind: 'setBounds' as const, + elementType: target.type, + id: target.id, + bounds, + }, + ] + : []; + }), + }; +}; + +export const commitBatchGeometryByIds = ( + descriptor: BatchGeometryDescriptor, + options: { gestureId?: string; preflight?: () => void } = {}, +): Promise => { + const frozenDescriptor = structuredClone(descriptor); + const initialPlan = planBatchGeometry( + captureEditorDocument(), + frozenDescriptor, + ); + if (!initialPlan || initialPlan.updates.length === 0) { + return Promise.resolve(false); + } + const intents = new Map< + NativeElementType, + Map> + >(); + const targetById = new Map( + frozenDescriptor.targets.map((target) => [target.id, target] as const), + ); + for (const { key, patch } of initialPlan.updates) { + const target = targetById.get(key); + if (!target) continue; + const byId = intents.get(target.type) ?? new Map(); + byId.set(target.id, patch as Record); + intents.set(target.type, byId); + } + const receipt = + intents.size > 0 ? applyPropertyIntentsEagerly(intents) : null; + let enrolled = false; + return commitGeneratedSemanticOps( + (base) => planBatchGeometry(base, frozenDescriptor)?.ops ?? null, + { + ...(options.gestureId ? { gestureId: options.gestureId } : {}), + ...(options.preflight ? { preflight: options.preflight } : {}), + onEnrolled: () => { + enrolled = true; + }, + }, + ) + .then((outcome) => { + if (!outcome) { + if (!enrolled) receipt?.rollback(); + return false; + } + return outcome.opResults.every( + ({ status }) => status !== 'targetMissing', + ); + }) + .catch((error) => { + if (!enrolled) receipt?.rollback(); + throw error; + }); +}; + +export const commitElementGeometryById = ( + type: NativeElementType, + id: string, + patch: GeometryPatch, + options: { gestureId?: string; preflight?: () => void } = {}, +): Promise => { + const entries = Object.entries(patch) as Array<[GeometryField, number]>; + if ( + !isNativeElementId(id) || + entries.length === 0 || + entries.some( + ([field, value]) => + !['dx', 'dy', 'width', 'height'].includes(field) || + !Number.isFinite(value) || + ((field === 'width' || field === 'height') && value <= 0), + ) + ) { + return Promise.resolve(false); + } + + const frozenPatch = Object.fromEntries(entries) as GeometryPatch; + const intents: PropertyIntents = new Map([ + [type, new Map([[id, frozenPatch as Record]])], + ]); + const receipt = applyPropertyIntentsEagerly(intents); + let enrolled = false; + + return commitGeneratedSemanticOps( + (base) => { + const record = base[FIELD_BY_TYPE[type]] as unknown as LooseRecord; + const locator = findInRecord(record, id); + if (!locator) return null; + const position = record[locator.mode]?.[locator.index]; + if (!position) return null; + const bounds: EditorBoundsV1 = { + dx: position.dx as number, + dy: position.dy as number, + width: position.width as number, + height: position.height as number, + ...frozenPatch, + }; + return [{ kind: 'setBounds', elementType: type, id, bounds }]; + }, + { + ...(options.gestureId ? { gestureId: options.gestureId } : {}), + ...(options.preflight ? { preflight: options.preflight } : {}), + onEnrolled: () => { + enrolled = true; + }, + }, + ) + .then((outcome) => { + if (!outcome) { + if (!enrolled) receipt?.rollback(); + return false; + } + return outcome.opResults[0]?.status !== 'targetMissing'; + }) + .catch((error) => { + if (!enrolled) receipt?.rollback(); + throw error; + }); +}; + +// 리사이즈 완료 전용: 시작 시 동결한 대상 id들에 최종 bounds를 하나의 +// intent로 - eager와 wire, receipt를 같은 의도가 소유한다. 대상 소실은 +// targetLost로 eager 복원, 오류는 전파 +export const commitElementBoundsById = ( + intents: PropertyIntents, + gestureId?: string, +): Promise => { + const ops: EditorOpV1[] = []; + const boundsKeys = new Set(['dx', 'dy', 'width', 'height']); + const seenIds = new Set(); + for (const [elementType, byId] of intents) { + for (const [id, patch] of byId) { + if ( + !isNativeElementId(id) || + seenIds.has(id) || + Object.keys(patch).some((key) => !boundsKeys.has(key)) || + typeof patch.dx !== 'number' || + typeof patch.dy !== 'number' || + typeof patch.width !== 'number' || + typeof patch.height !== 'number' || + !Number.isFinite(patch.dx) || + !Number.isFinite(patch.dy) || + !Number.isFinite(patch.width) || + !Number.isFinite(patch.height) || + patch.width <= 0 || + patch.height <= 0 + ) { + return Promise.resolve(false); + } + seenIds.add(id); + ops.push({ + kind: 'setBounds', + elementType, + id, + bounds: { + dx: patch.dx, + dy: patch.dy, + width: patch.width, + height: patch.height, + }, + }); + } + } + if (ops.length === 0) return Promise.resolve(false); + + const receipt = applyPropertyIntentsEagerly(intents); + let enrolled = false; + return commitSemanticOps(ops, { + ...(gestureId ? { gestureId } : {}), + onEnrolled: () => { + enrolled = true; + }, + }) + .then(({ opResults }) => + opResults.some(({ status }) => status !== 'targetMissing'), + ) + .catch((error) => { + if (!enrolled) receipt?.rollback(); + throw error; + }); +}; + +export const commitSingleElementBoundsById = ( + type: NativeElementType, + id: string, + bounds: EditorBoundsV1, + gestureId?: string, +): Promise => { + if ( + !isNativeElementId(id) || + !Number.isFinite(bounds.dx) || + !Number.isFinite(bounds.dy) || + !Number.isFinite(bounds.width) || + !Number.isFinite(bounds.height) || + bounds.width <= 0 || + bounds.height <= 0 + ) { + return Promise.resolve(false); + } + const intents: PropertyIntents = new Map([ + [type, new Map([[id, { ...bounds }]])], + ]); + const receipt = applyPropertyIntentsEagerly(intents); + let enrolled = false; + + return commitSemanticOps( + [{ kind: 'setBounds', elementType: type, id, bounds }], + { + ...(gestureId ? { gestureId } : {}), + onEnrolled: () => { + enrolled = true; + }, + }, + ) + .then(({ opResults }) => opResults[0]?.status !== 'targetMissing') + .catch((error) => { + if (!enrolled) receipt?.rollback(); + throw error; + }); +}; diff --git a/src/renderer/editor/runtime/elementGroupOps.ts b/src/renderer/editor/runtime/elementGroupOps.ts new file mode 100644 index 000000000..85890914d --- /dev/null +++ b/src/renderer/editor/runtime/elementGroupOps.ts @@ -0,0 +1,325 @@ +import { useGraphItemStore } from '@stores/data/useGraphItemStore'; +import { useKeyStore } from '@stores/data/useKeyStore'; +import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { useStatItemStore } from '@stores/data/useStatItemStore'; +import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; +import { isNativeElementId } from '../model/elementId'; +import type { NativeElementType } from '../model/elementIdMap'; +import { currentPluginGroupMembers } from './pluginGroupMembers'; +import { + projectLayerGroupRename, + projectStableElementGroups, +} from '@utils/layerGroupUtils'; +import { + applyPropertyIntentsEagerly, + createPropertyReceipt, +} from './elementIntent'; +import { + commitGeneratedSemanticOps, + commitSemanticOps, +} from './editorSemanticOps'; +import { + captureEditorDocument, + editorCoordinator, +} from './editorStateCoordinator'; +import type { + CanonicalEditorDocumentV1, + EditorElementGroupTargetV1, + EditorElementPropertyPatchV1, + EditorTargetLayerGroupV1, +} from '@src/types/editor'; +import { patchElementPropertyById } from './elementPropertyCore'; +import { + FIELD_BY_TYPE, + findInRecord, + type LooseRecord, +} from './elementDocumentModel'; + +interface ElementPropertyPatchTarget { + type: NativeElementType; + id: string; + patch: EditorElementPropertyPatchV1; +} + +export const setLayerGroupHidden = ( + mode: string, + groupId: string, + hidden: boolean, + options: { preflight?: () => void } = {}, +): Promise => { + if (!mode || !groupId) return Promise.resolve(false); + const eagerIntents = new Map< + NativeElementType, + Map> + >(); + const initial = captureEditorDocument(); + const initialTargets: Array<{ type: NativeElementType; id: string }> = []; + for (const type of Object.keys(FIELD_BY_TYPE) as NativeElementType[]) { + for (const position of initial[FIELD_BY_TYPE[type]][mode] ?? []) { + if ( + position.groupId !== groupId || + typeof position.id !== 'string' || + position.id.length === 0 || + !isNativeElementId(position.id) + ) { + continue; + } + const byId = eagerIntents.get(type) ?? new Map(); + byId.set(position.id, { hidden }); + eagerIntents.set(type, byId); + initialTargets.push({ type, id: position.id }); + } + } + applyPropertyIntentsEagerly(eagerIntents); + const reconcileEager = ( + base: CanonicalEditorDocumentV1, + currentMemberIds?: ReadonlySet, + ) => { + createPropertyReceipt( + initialTargets.flatMap(({ type, id }) => { + if (currentMemberIds?.has(id)) return []; + const located = findInRecord( + base[FIELD_BY_TYPE[type]] as unknown as LooseRecord, + id, + ); + if (!located) return []; + return [ + { + type, + id, + field: 'hidden', + before: + base[FIELD_BY_TYPE[type]][located.mode]?.[located.index]?.hidden, + expected: hidden, + }, + ]; + }), + )?.rollback(); + }; + let reconciled = false; + let enrolled = false; + return commitGeneratedSemanticOps( + (latest) => { + const targets: ElementPropertyPatchTarget[] = []; + let unsupported = false; + for (const type of Object.keys(FIELD_BY_TYPE) as NativeElementType[]) { + for (const position of latest[FIELD_BY_TYPE[type]][mode] ?? []) { + if (position.groupId !== groupId) continue; + if ( + typeof position.id !== 'string' || + position.id.length === 0 || + !isNativeElementId(position.id) + ) { + unsupported = true; + continue; + } + targets.push({ + type, + id: position.id, + patch: { property: 'hidden', value: hidden }, + }); + } + } + if (unsupported || targets.length === 0) { + if (!reconciled) { + reconcileEager(latest); + reconciled = true; + } + return null; + } + if (!reconciled) { + reconcileEager(latest, new Set(targets.map(({ id }) => id))); + reconciled = true; + } + return targets.map(({ type, id, patch }) => ({ + kind: 'patchElement' as const, + elementType: type, + id, + patch, + })); + }, + { + ...(options.preflight ? { preflight: options.preflight } : {}), + onEnrolled: () => { + enrolled = true; + }, + }, + ) + .then((outcome) => { + if (!outcome && !enrolled && !reconciled) { + reconcileEager(editorCoordinator.getState().lastAck ?? initial); + } + return ( + outcome?.opResults.some(({ status }) => status !== 'targetMissing') ?? + false + ); + }) + .catch((error) => { + if (!enrolled && !reconciled) { + reconcileEager(editorCoordinator.getState().lastAck ?? initial); + } + throw error; + }); +}; + +const validStructuralText = (value: string, maxBytes: number): boolean => + value.length > 0 && new TextEncoder().encode(value).length <= maxBytes; + +const validElementGroupTargets = ( + targets: readonly EditorElementGroupTargetV1[], +): boolean => + targets.length > 0 && + targets.length <= 4096 && + targets.every( + ({ elementType, id }) => + ['key', 'stat', 'graph', 'knob'].includes(elementType) && + isNativeElementId(id), + ) && + new Set(targets.map(({ id }) => id)).size === targets.length; + +const validTargetLayerGroup = ( + targetGroup: EditorTargetLayerGroupV1 | null, +): boolean => + targetGroup === null || + (validStructuralText(targetGroup.id, 256) && + (targetGroup.kind === 'existing' || + (targetGroup.kind === 'create' && + validStructuralText(targetGroup.name, 1024)))); + +export const setElementGroupsByTargets = ( + mode: string, + targets: readonly EditorElementGroupTargetV1[], + targetGroup: EditorTargetLayerGroupV1 | null, + options: { preflight?: () => void } = {}, +): Promise => { + if ( + !validStructuralText(mode, 128) || + !validElementGroupTargets(targets) || + !validTargetLayerGroup(targetGroup) + ) { + return Promise.resolve(false); + } + const before = { + keyPositions: useKeyStore.getState().canonicalPositions, + statPositions: useStatItemStore.getState().positions, + graphPositions: useGraphItemStore.getState().positions, + knobPositions: useKnobItemStore.getState().positions, + layerGroups: useLayerGroupStore.getState().layerGroups, + }; + const projected = projectStableElementGroups({ + mode, + targets, + targetGroup, + ...before, + pluginElements: currentPluginGroupMembers(), + }); + if (!projected) return Promise.resolve(false); + if (projected.changed) { + useKeyStore.getState().setPositions(projected.keyPositions); + useStatItemStore.getState().setPositions(projected.statPositions); + useGraphItemStore.getState().setPositions(projected.graphPositions); + useKnobItemStore.getState().setPositions(projected.knobPositions); + useLayerGroupStore.getState().setLayerGroups(projected.layerGroups); + } + let enrolled = false; + return commitSemanticOps( + [ + { + kind: 'setElementGroups', + mode, + targets: targets.map((target) => ({ ...target })), + targetGroup: targetGroup ? { ...targetGroup } : null, + }, + ], + { + preflight: options.preflight, + onEnrolled: () => { + enrolled = true; + }, + }, + ) + .then((outcome) => outcome.opResults[0]?.status !== 'targetMissing') + .catch((error) => { + if (!enrolled && projected.changed) { + if ( + useKeyStore.getState().canonicalPositions === projected.keyPositions + ) { + useKeyStore.getState().setPositions(before.keyPositions); + } + if (useStatItemStore.getState().positions === projected.statPositions) { + useStatItemStore.getState().setPositions(before.statPositions); + } + if ( + useGraphItemStore.getState().positions === projected.graphPositions + ) { + useGraphItemStore.getState().setPositions(before.graphPositions); + } + if (useKnobItemStore.getState().positions === projected.knobPositions) { + useKnobItemStore.getState().setPositions(before.knobPositions); + } + if ( + useLayerGroupStore.getState().layerGroups === projected.layerGroups + ) { + useLayerGroupStore.getState().setLayerGroups(before.layerGroups); + } + } + throw error; + }); +}; + +export const renameLayerGroupById = ( + mode: string, + groupId: string, + name: string, + options: { preflight?: () => void } = {}, +): Promise => { + if ( + !validStructuralText(mode, 128) || + !validStructuralText(groupId, 256) || + !validStructuralText(name, 1024) + ) { + return Promise.resolve(false); + } + const before = useLayerGroupStore.getState().layerGroups; + const projected = projectLayerGroupRename({ + mode, + groupId, + name, + layerGroups: before, + }); + if (!projected) return Promise.resolve(false); + useLayerGroupStore.getState().setLayerGroups(projected); + let enrolled = false; + return commitSemanticOps( + [{ kind: 'renameLayerGroup', mode, groupId, name }], + { + preflight: options.preflight, + onEnrolled: () => { + enrolled = true; + }, + }, + ) + .then((outcome) => outcome.opResults[0]?.status !== 'targetMissing') + .catch((error) => { + if ( + !enrolled && + useLayerGroupStore.getState().layerGroups === projected + ) { + useLayerGroupStore.getState().setLayerGroups(before); + } + throw error; + }); +}; + +export const patchElementLayerNameById = ( + type: NativeElementType, + id: string, + layerName: string | null, + options: { preflight?: () => void } = {}, +): Promise => + patchElementPropertyById( + type, + id, + { property: 'layerName', value: layerName }, + options, + ); diff --git a/src/renderer/editor/runtime/elementOps.ts b/src/renderer/editor/runtime/elementOps.ts index f396897d5..901562395 100644 --- a/src/renderer/editor/runtime/elementOps.ts +++ b/src/renderer/editor/runtime/elementOps.ts @@ -4,8 +4,10 @@ import { useKnobItemStore } from '@stores/data/useKnobItemStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; - -import { resolveElementById } from '../model/elementIdMap'; +import { + resolveElementById, + type NativeElementType, +} from '../model/elementIdMap'; import { isNativeElementId } from '../model/elementId'; import { currentPluginGroupMembers } from './pluginGroupMembers'; import { @@ -15,122 +17,32 @@ import { import { newElementId } from '../model/elementId'; import { cloneSlot } from '@utils/keySlot'; import { stableStringify } from '@utils/core/stableStringify'; -import { - normalizeLayerGroupsForMode, - projectLayerGroupRename, - projectStableElementGroups, -} from '@utils/layerGroupUtils'; -import { - applyPropertyIntentsEagerly, - createPropertyReceipt, - type ElementIntentReceipt, - type PropertyIntents, -} from './elementIntent'; -import { - commitGeneratedSemanticOps, - commitSemanticOps, -} from './editorSemanticOps'; +import { normalizeLayerGroupsForMode } from '@utils/layerGroupUtils'; +import type { ElementIntentReceipt } from './elementIntent'; +import { commitSemanticOps } from './editorSemanticOps'; import { captureEditorDocument, editorCoordinator, } from './editorStateCoordinator'; -import { - computeBatchGeometryPlan, - type BatchGeometryOperation, -} from './batchGeometryPlan'; - import type { CanonicalEditorDocumentV1, - EditorBoundsV1, EditorFrozenElementV1, - EditorElementPropertyPatchV1, - EditorCounterBooleanPropertyPatchV1, - EditorCounterLayoutPropertyPatchV1, - EditorCounterTypographyPropertyPatchV1, - EditorCounterFillPropertyPatchV1, - EditorCounterAnimationPresetIntentV1, - EditorFontFamilyPropertyPatchV1, - EditorFontStylePropertyPatchV1, - EditorPreviewStylePropertyPatchV1, - EditorPaintPropertyPatchV1, - EditorShadowPropertyPatchV1, - EditorNotePaintPropertyPatchV1, - EditorGraphRuntimePropertyPatchV1, - EditorKnobRuntimePropertyPatchV1, - EditorNotePropertyPatchV1, - EditorElementGroupTargetV1, - EditorTargetLayerGroupV1, - EditorOpV1, -} from '@src/types/editor'; -import { - isEditorPaintPropertyPatchV1, - isEditorShadowPropertyPatchV1, } from '@src/types/editor'; - -import type { NativeElementType } from '../model/elementIdMap'; import type { KeyPosition, KeySlot } from '@src/types/key/keys'; import type { StatItemPosition } from '@src/types/key/statItems'; import type { GraphItemPosition } from '@src/types/key/graphItems'; import type { KnobItemPosition } from '@src/types/key/knobs'; -import { paintPropertyFields, projectPaintDescriptor } from '@src/types/color'; -import { projectElementShadowPatch } from '@src/types/key/shadows'; -import { - applyImageTransformLeaf, - type ImageMode, - type ImageTransform, - type ImageTransformLeafPatch, -} from '@src/types/key/imageLayer'; -import { - isNotePaintPropertyPatchV1, - projectNotePaintPatch, -} from '@src/types/key/notePaint'; import { - isCounterFillPropertyPatchV1, - projectCounterFillPatch, -} from '@src/types/key/counterFill'; -import { - DEFAULT_ELEMENT_ACTIVE_SHADOW_SPEC, - DEFAULT_ELEMENT_SHADOW_SPEC, -} from '@utils/core/elementDefaults'; -import { - implicitCounterFontBold, - implicitElementFontBold, -} from '@utils/core/fontWeights'; + findInRecord, + removeAt, + type LooseRecord, +} from './elementDocumentModel'; // 메뉴·확인 모달처럼 대상 확정과 실행 사이가 긴 파괴적 액션의 semantic op. // 대상은 {type, id}로 받고, eager 반영과 wire 생성 각각이 실행 시점의 // 문서에서 id를 다시 찾아 적용한다. 못 찾으면(삭제·모드 소실) 조용히 // 중단한다 - index를 들고 있다가 다른 요소를 지우는 창을 없애는 것이 목적 -type LooseRecord = Record< - string, - Array<{ id: string } & Record> ->; - -const findInRecord = ( - record: LooseRecord, - id: string, -): { mode: string; index: number } | null => { - for (const [mode, list] of Object.entries(record)) { - const index = list.findIndex((position) => position.id === id); - if (index >= 0) return { mode, index }; - } - return null; -}; - -const removeAt = ( - record: LooseRecord, - mode: string, - index: number, -): LooseRecord => ({ - ...record, - [mode]: (record[mode] ?? []).filter((_, i) => i !== index), -}); - -// 삭제: 키는 keys와 keyPositions의 인덱스 결합을 함께 제거, 아이템은 해당 -// 컬렉션만. 반환 false = 실행 시점에 대상 없음(이미 삭제). -// 오류는 전파된다 - 편입 전 실패는 receipt가 로컬 삭제를 되돌린다. -// 선택 보정은 정책상 eager와 함께 즉시 수행하고 실패해도 복구하지 않는다 export const deleteElementById = ( type: NativeElementType, id: string, @@ -591,16 +503,6 @@ export const placeDuplicatedKnob = ( }, }); -const FIELD_BY_TYPE: Record< - NativeElementType, - 'keyPositions' | 'statPositions' | 'graphPositions' | 'knobPositions' -> = { - key: 'keyPositions', - stat: 'statPositions', - graph: 'graphPositions', - knob: 'knobPositions', -}; - // 키 슬롯 재바인딩: keys만 바꾸되 대상은 paired 위치의 안정 id로 재결합한다. // index 기반 keys 단독 커밋은 same-shape 재정렬과 겹치면 다른 위치 id와 // 잘못 결합된다 @@ -660,1952 +562,10 @@ export const rebindKeySlotById = ( }); }; -const POSITION_FIELD_BY_TYPE: Record = { - key: 'keyPositions', - stat: 'statPositions', - graph: 'graphPositions', - knob: 'knobPositions', -}; - -// 현재 문서의 요소 레코드 (없으면 null) -const findCurrentPosition = ( - document: Record | null | undefined, - elementType: NativeElementType, - id: string, -): (Record & { id: string }) | null => { - const record = document?.[POSITION_FIELD_BY_TYPE[elementType]]; - if (!record || typeof record !== 'object') return null; - return ( - Object.values(record as Record) - .flat() - .find( - (position): position is Record & { id: string } => - typeof position === 'object' && - position !== null && - (position as { id?: unknown }).id === id, - ) ?? null - ); -}; - -// 단건 property의 즉시 반영 투영. 도킹·분리, 단건·다건 네 경로가 같은 걸 쓴다 -const elementPropertyIntents = ( - targets: readonly { elementType: NativeElementType; id: string }[], - patch: EditorElementPropertyPatchV1, -): PropertyIntents => { - const intents = new Map< - NativeElementType, - Map> - >(); - // 굵기 변경은 백엔드와 같은 암묵 Bold 고정을, 이미지 변환 leaf는 현재 변환에 - // 적용한 결과를 투영한다 (둘 다 현재 위치가 필요) - const isImageTransformPatch = - patch.property === 'idleImageTransform' || - patch.property === 'activeImageTransform'; - const document = - patch.property === 'fontWeight' || isImageTransformPatch - ? (captureEditorDocument() as unknown as Record | null) - : null; - for (const { elementType, id } of targets) { - // nullable leaf의 null은 위치 조각에서 undefined로, 나머지는 1:1 투영 - const byId = intents.get(elementType) ?? new Map(); - const intent: Record = { - [patch.property]: patch.value ?? undefined, - }; - if (patch.property === 'fontWeight') { - const current = findCurrentPosition(document, elementType, id); - if (current && current.fontBold == null) { - intent.fontBold = implicitElementFontBold(current.fontWeight); - } - } else if (isImageTransformPatch && patch.value !== null) { - const current = findCurrentPosition(document, elementType, id); - intent[patch.property] = applyImageTransformLeaf( - current?.[patch.property] as ImageTransform | undefined, - patch.value, - ); - } - byId.set(id, intent); - intents.set(elementType, byId); - } - return intents; -}; - -// 빈 목록·비 native id·중복 id는 커밋 대상이 아니다 -const hasCommittableTargets = (targets: readonly { id: string }[]): boolean => - targets.length > 0 && - targets.every(({ id }) => isNativeElementId(id)) && - new Set(targets.map(({ id }) => id)).size === targets.length; - -const applyElementPropertyEagerly = ( - type: NativeElementType, - id: string, - patch: EditorElementPropertyPatchV1, -): ElementIntentReceipt | null => - applyPropertyIntentsEagerly( - elementPropertyIntents([{ elementType: type, id }], patch), - ); - -export const patchElementPropertyById = ( - type: NativeElementType, - id: string, - patch: EditorElementPropertyPatchV1, - options: { gestureId?: string; preflight?: () => void } = {}, -): Promise => { - if (!isNativeElementId(id)) return Promise.resolve(false); - const receipt = applyElementPropertyEagerly(type, id, patch); - let enrolled = false; - return commitSemanticOps( - [{ kind: 'patchElement', elementType: type, id, patch }], - { - ...(options.gestureId ? { gestureId: options.gestureId } : {}), - preflight: options.preflight, - onEnrolled: () => { - enrolled = true; - }, - }, - ) - .then((outcome) => outcome.opResults[0]?.status !== 'targetMissing') - .catch((error) => { - if (!enrolled) receipt?.rollback(); - throw error; - }); -}; - -interface ElementPropertyPatchTarget { - type: NativeElementType; - id: string; - patch: EditorElementPropertyPatchV1; -} - -interface ElementPropertyTarget { - elementType: NativeElementType; - id: string; -} - -interface PropertyCommitOptions { - gestureId?: string; - preflight?: () => void; -} - -// 공용 다건 경로: 빈 목록·비 native id·중복 id를 거르고 같은 property patch를 -// 전 대상에 eager+wire로 커밋한다. 속성별 기계적 래퍼는 전부 여기로 위임하고 -// value 타입은 판별 유니온 patch가 컴파일 타임에 강제한다 -const patchElementPropertyByTargets = ( - targets: readonly ElementPropertyTarget[], - patch: EditorElementPropertyPatchV1, - options: PropertyCommitOptions = {}, -): Promise => { - if (!hasCommittableTargets(targets)) return Promise.resolve(false); - const receipt = applyPropertyIntentsEagerly( - elementPropertyIntents(targets, patch), - ); - let enrolled = false; - return commitSemanticOps( - targets.map(({ elementType, id }) => ({ - kind: 'patchElement' as const, - elementType, - id, - patch, - })), - { - ...(options.gestureId ? { gestureId: options.gestureId } : {}), - preflight: options.preflight, - onEnrolled: () => { - enrolled = true; - }, - }, - ) - .then((outcome) => - outcome.opResults.some((result) => result.status !== 'targetMissing'), - ) - .catch((error) => { - if (!enrolled) receipt?.rollback(); - throw error; - }); -}; - -const idTargets = ( - elementType: NativeElementType, - ids: readonly string[], -): ElementPropertyTarget[] => ids.map((id) => ({ elementType, id })); - -export const patchElementHiddenById = ( - type: NativeElementType, - id: string, - hidden: boolean, - options: { preflight?: () => void } = {}, -): Promise => - patchElementPropertyById( - type, - id, - { property: 'hidden', value: hidden }, - options, - ); - -export const setLayerGroupHidden = ( - mode: string, - groupId: string, - hidden: boolean, - options: { preflight?: () => void } = {}, -): Promise => { - if (!mode || !groupId) return Promise.resolve(false); - const eagerIntents = new Map< - NativeElementType, - Map> - >(); - const initial = captureEditorDocument(); - const initialTargets: Array<{ type: NativeElementType; id: string }> = []; - for (const type of Object.keys(FIELD_BY_TYPE) as NativeElementType[]) { - for (const position of initial[FIELD_BY_TYPE[type]][mode] ?? []) { - if ( - position.groupId !== groupId || - typeof position.id !== 'string' || - position.id.length === 0 || - !isNativeElementId(position.id) - ) { - continue; - } - const byId = eagerIntents.get(type) ?? new Map(); - byId.set(position.id, { hidden }); - eagerIntents.set(type, byId); - initialTargets.push({ type, id: position.id }); - } - } - applyPropertyIntentsEagerly(eagerIntents); - const reconcileEager = ( - base: CanonicalEditorDocumentV1, - currentMemberIds?: ReadonlySet, - ) => { - createPropertyReceipt( - initialTargets.flatMap(({ type, id }) => { - if (currentMemberIds?.has(id)) return []; - const located = findInRecord( - base[FIELD_BY_TYPE[type]] as unknown as LooseRecord, - id, - ); - if (!located) return []; - return [ - { - type, - id, - field: 'hidden', - before: - base[FIELD_BY_TYPE[type]][located.mode]?.[located.index]?.hidden, - expected: hidden, - }, - ]; - }), - )?.rollback(); - }; - let reconciled = false; - let enrolled = false; - return commitGeneratedSemanticOps( - (latest) => { - const targets: ElementPropertyPatchTarget[] = []; - let unsupported = false; - for (const type of Object.keys(FIELD_BY_TYPE) as NativeElementType[]) { - for (const position of latest[FIELD_BY_TYPE[type]][mode] ?? []) { - if (position.groupId !== groupId) continue; - if ( - typeof position.id !== 'string' || - position.id.length === 0 || - !isNativeElementId(position.id) - ) { - unsupported = true; - continue; - } - targets.push({ - type, - id: position.id, - patch: { property: 'hidden', value: hidden }, - }); - } - } - if (unsupported || targets.length === 0) { - if (!reconciled) { - reconcileEager(latest); - reconciled = true; - } - return null; - } - if (!reconciled) { - reconcileEager(latest, new Set(targets.map(({ id }) => id))); - reconciled = true; - } - return targets.map(({ type, id, patch }) => ({ - kind: 'patchElement' as const, - elementType: type, - id, - patch, - })); - }, - { - ...(options.preflight ? { preflight: options.preflight } : {}), - onEnrolled: () => { - enrolled = true; - }, - }, - ) - .then((outcome) => { - if (!outcome && !enrolled && !reconciled) { - reconcileEager(editorCoordinator.getState().lastAck ?? initial); - } - return ( - outcome?.opResults.some(({ status }) => status !== 'targetMissing') ?? - false - ); - }) - .catch((error) => { - if (!enrolled && !reconciled) { - reconcileEager(editorCoordinator.getState().lastAck ?? initial); - } - throw error; - }); -}; - -const validStructuralText = (value: string, maxBytes: number): boolean => - value.length > 0 && new TextEncoder().encode(value).length <= maxBytes; - -const validElementGroupTargets = ( - targets: readonly EditorElementGroupTargetV1[], -): boolean => - targets.length > 0 && - targets.length <= 4096 && - targets.every( - ({ elementType, id }) => - ['key', 'stat', 'graph', 'knob'].includes(elementType) && - isNativeElementId(id), - ) && - new Set(targets.map(({ id }) => id)).size === targets.length; - -const validTargetLayerGroup = ( - targetGroup: EditorTargetLayerGroupV1 | null, -): boolean => - targetGroup === null || - (validStructuralText(targetGroup.id, 256) && - (targetGroup.kind === 'existing' || - (targetGroup.kind === 'create' && - validStructuralText(targetGroup.name, 1024)))); - -export const setElementGroupsByTargets = ( - mode: string, - targets: readonly EditorElementGroupTargetV1[], - targetGroup: EditorTargetLayerGroupV1 | null, - options: { preflight?: () => void } = {}, -): Promise => { - if ( - !validStructuralText(mode, 128) || - !validElementGroupTargets(targets) || - !validTargetLayerGroup(targetGroup) - ) { - return Promise.resolve(false); - } - const before = { - keyPositions: useKeyStore.getState().canonicalPositions, - statPositions: useStatItemStore.getState().positions, - graphPositions: useGraphItemStore.getState().positions, - knobPositions: useKnobItemStore.getState().positions, - layerGroups: useLayerGroupStore.getState().layerGroups, - }; - const projected = projectStableElementGroups({ - mode, - targets, - targetGroup, - ...before, - pluginElements: currentPluginGroupMembers(), - }); - if (!projected) return Promise.resolve(false); - if (projected.changed) { - useKeyStore.getState().setPositions(projected.keyPositions); - useStatItemStore.getState().setPositions(projected.statPositions); - useGraphItemStore.getState().setPositions(projected.graphPositions); - useKnobItemStore.getState().setPositions(projected.knobPositions); - useLayerGroupStore.getState().setLayerGroups(projected.layerGroups); - } - let enrolled = false; - return commitSemanticOps( - [ - { - kind: 'setElementGroups', - mode, - targets: targets.map((target) => ({ ...target })), - targetGroup: targetGroup ? { ...targetGroup } : null, - }, - ], - { - preflight: options.preflight, - onEnrolled: () => { - enrolled = true; - }, - }, - ) - .then((outcome) => outcome.opResults[0]?.status !== 'targetMissing') - .catch((error) => { - if (!enrolled && projected.changed) { - if ( - useKeyStore.getState().canonicalPositions === projected.keyPositions - ) { - useKeyStore.getState().setPositions(before.keyPositions); - } - if (useStatItemStore.getState().positions === projected.statPositions) { - useStatItemStore.getState().setPositions(before.statPositions); - } - if ( - useGraphItemStore.getState().positions === projected.graphPositions - ) { - useGraphItemStore.getState().setPositions(before.graphPositions); - } - if (useKnobItemStore.getState().positions === projected.knobPositions) { - useKnobItemStore.getState().setPositions(before.knobPositions); - } - if ( - useLayerGroupStore.getState().layerGroups === projected.layerGroups - ) { - useLayerGroupStore.getState().setLayerGroups(before.layerGroups); - } - } - throw error; - }); -}; - -export const renameLayerGroupById = ( - mode: string, - groupId: string, - name: string, - options: { preflight?: () => void } = {}, -): Promise => { - if ( - !validStructuralText(mode, 128) || - !validStructuralText(groupId, 256) || - !validStructuralText(name, 1024) - ) { - return Promise.resolve(false); - } - const before = useLayerGroupStore.getState().layerGroups; - const projected = projectLayerGroupRename({ - mode, - groupId, - name, - layerGroups: before, - }); - if (!projected) return Promise.resolve(false); - useLayerGroupStore.getState().setLayerGroups(projected); - let enrolled = false; - return commitSemanticOps( - [{ kind: 'renameLayerGroup', mode, groupId, name }], - { - preflight: options.preflight, - onEnrolled: () => { - enrolled = true; - }, - }, - ) - .then((outcome) => outcome.opResults[0]?.status !== 'targetMissing') - .catch((error) => { - if ( - !enrolled && - useLayerGroupStore.getState().layerGroups === projected - ) { - useLayerGroupStore.getState().setLayerGroups(before); - } - throw error; - }); -}; - -export const patchElementLayerNameById = ( - type: NativeElementType, - id: string, - layerName: string | null, - options: { preflight?: () => void } = {}, -): Promise => - patchElementPropertyById( - type, - id, - { property: 'layerName', value: layerName }, - options, - ); - -export const patchGraphTypesByIds = ( - ids: readonly string[], - graphType: 'line' | 'bar', - options: { preflight?: () => void } = {}, -): Promise => - patchElementPropertyByTargets( - idTargets('graph', ids), - { property: 'graphType', value: graphType }, - options, - ); - -export const patchGraphColorsByIds = ( - ids: readonly string[], - graphColor: string, - options: PropertyCommitOptions = {}, -): Promise => - patchElementPropertyByTargets( - idTargets('graph', ids), - { property: 'graphColor', value: graphColor }, - options, - ); - -export const patchGraphPropertiesByIds = ( - ids: readonly string[], - patch: EditorGraphRuntimePropertyPatchV1, - options: { preflight?: () => void } = {}, -): Promise => - patchElementPropertyByTargets(idTargets('graph', ids), patch, options); - -export const patchKnobAxisIdById = ( - id: string, - axisId: string, - options: { preflight?: () => void } = {}, -): Promise => - patchElementPropertyById( - 'knob', - id, - { property: 'axisId', value: axisId }, - options, - ); - -export const patchSoundPathById = ( - id: string, - soundPath: string, - options: { preflight?: () => void } = {}, -): Promise => - patchElementPropertyById( - 'key', - id, - { property: 'soundPath', value: soundPath }, - options, - ); - -export const patchSoundEnabledById = ( - id: string, - soundEnabled: boolean, - options: { preflight?: () => void } = {}, -): Promise => - patchElementPropertyById( - 'key', - id, - { property: 'soundEnabled', value: soundEnabled }, - options, - ); - -export const patchSoundEnabledByIds = ( - ids: readonly string[], - soundEnabled: boolean, - options: { preflight?: () => void } = {}, -): Promise => - patchElementPropertyByTargets( - idTargets('key', ids), - { property: 'soundEnabled', value: soundEnabled }, - options, - ); - -const validSoundVolume = (soundVolume: number): boolean => - Number.isFinite(soundVolume) && soundVolume >= 0 && soundVolume <= 200; - -export const patchSoundVolumeById = ( - id: string, - soundVolume: number, - options: { gestureId?: string; preflight?: () => void } = {}, -): Promise => - validSoundVolume(soundVolume) - ? patchElementPropertyById( - 'key', - id, - { property: 'soundVolume', value: soundVolume }, - options, - ) - : Promise.resolve(false); - -export const patchSoundVolumeByIds = ( - ids: readonly string[], - soundVolume: number, - options: { gestureId?: string; preflight?: () => void } = {}, -): Promise => - validSoundVolume(soundVolume) - ? patchElementPropertyByTargets( - idTargets('key', ids), - { property: 'soundVolume', value: soundVolume }, - options, - ) - : Promise.resolve(false); - -export const patchSoundPathByIds = ( - ids: readonly string[], - soundPath: string, - options: { preflight?: () => void } = {}, -): Promise => - patchElementPropertyByTargets( - idTargets('key', ids), - { property: 'soundPath', value: soundPath }, - options, - ); - -type CounterAnimationTarget = { - elementType: 'key' | 'stat'; - id: string; -}; - -const counterBooleanPropertyIntents = ( - targets: readonly CounterAnimationTarget[], - patch: EditorCounterBooleanPropertyPatchV1, -): PropertyIntents => { - const document = captureEditorDocument(); - const propertyIntents = new Map< - NativeElementType, - Map> - >(); - for (const { elementType, id } of targets) { - const field = elementType === 'key' ? 'keyPositions' : 'statPositions'; - const record = document[field] as Record< - string, - Array & { id: string }> - >; - const current = Object.values(record) - .flat() - .find((position) => position.id === id); - if ( - !current || - current.counter === null || - typeof current.counter !== 'object' || - Array.isArray(current.counter) - ) { - continue; - } - const counter = current.counter as Record; - const nextCounter = - patch.property === 'counterEnabled' - ? { ...counter, enabled: patch.value } - : counter.animation !== null && - typeof counter.animation === 'object' && - !Array.isArray(counter.animation) - ? { - ...counter, - animation: { - ...(counter.animation as Record), - enabled: patch.value, - }, - } - : null; - if (!nextCounter) continue; - const byId = propertyIntents.get(elementType) ?? new Map(); - byId.set(id, { counter: nextCounter }); - propertyIntents.set(elementType, byId); - } - return propertyIntents; -}; - -const patchCounterBooleanByTargets = ( - targets: readonly CounterAnimationTarget[], - patch: EditorCounterBooleanPropertyPatchV1, - options: { preflight?: () => void } = {}, -): Promise => { - if ( - targets.length === 0 || - targets.some(({ id }) => id.length === 0 || !isNativeElementId(id)) || - new Set(targets.map(({ id }) => id)).size !== targets.length - ) { - return Promise.resolve(false); - } - const receipt = applyPropertyIntentsEagerly( - counterBooleanPropertyIntents(targets, patch), - ); - let enrolled = false; - return commitSemanticOps( - targets.map(({ elementType, id }) => ({ - kind: 'patchElement' as const, - elementType, - id, - patch, - })), - { - preflight: options.preflight, - onEnrolled: () => { - enrolled = true; - }, - }, - ) - .then((outcome) => - outcome.opResults.some((result) => result.status !== 'targetMissing'), - ) - .catch((error) => { - if (!enrolled) receipt?.rollback(); - throw error; - }); -}; - -const counterLayoutPropertyIntents = ( - targets: readonly CounterAnimationTarget[], - patch: EditorCounterLayoutPropertyPatchV1, -): PropertyIntents => { - const document = captureEditorDocument(); - const propertyIntents = new Map< - NativeElementType, - Map> - >(); - for (const { elementType, id } of targets) { - const field = elementType === 'key' ? 'keyPositions' : 'statPositions'; - const record = document[field] as Record< - string, - Array & { id: string }> - >; - const current = Object.values(record) - .flat() - .find((position) => position.id === id); - if ( - !current || - current.counter === null || - typeof current.counter !== 'object' || - Array.isArray(current.counter) - ) { - continue; - } - const counter = current.counter as Record; - const nextCounter = - patch.property === 'counterPlacement' - ? { ...counter, placement: patch.value } - : patch.property === 'counterAlign' - ? { ...counter, align: patch.value } - : patch.property === 'counterAlignMode' - ? { ...counter, alignMode: patch.value } - : { ...counter, gap: patch.value }; - const byId = propertyIntents.get(elementType) ?? new Map(); - byId.set(id, { counter: nextCounter }); - propertyIntents.set(elementType, byId); - } - return propertyIntents; -}; - -export const patchCounterLayoutByTargets = ( - targets: readonly CounterAnimationTarget[], - patch: EditorCounterLayoutPropertyPatchV1, - options: { preflight?: () => void } = {}, -): Promise => { - if ( - targets.length === 0 || - targets.some(({ id }) => id.length === 0 || !isNativeElementId(id)) || - new Set(targets.map(({ id }) => id)).size !== targets.length - ) { - return Promise.resolve(false); - } - const receipt = applyPropertyIntentsEagerly( - counterLayoutPropertyIntents(targets, patch), - ); - let enrolled = false; - return commitSemanticOps( - targets.map(({ elementType, id }) => ({ - kind: 'patchElement' as const, - elementType, - id, - patch, - })), - { - preflight: options.preflight, - onEnrolled: () => { - enrolled = true; - }, - }, - ) - .then((outcome) => - outcome.opResults.some((result) => result.status !== 'targetMissing'), - ) - .catch((error) => { - if (!enrolled) receipt?.rollback(); - throw error; - }); -}; - -export const patchCounterLayoutById = ( - elementType: 'key' | 'stat', - id: string, - patch: EditorCounterLayoutPropertyPatchV1, - options: { preflight?: () => void } = {}, -): Promise => - patchCounterLayoutByTargets([{ elementType, id }], patch, options); - -const counterTypographyPropertyIntents = ( - targets: readonly CounterAnimationTarget[], - patch: EditorCounterTypographyPropertyPatchV1, -): PropertyIntents => { - const document = captureEditorDocument(); - const propertyIntents = new Map< - NativeElementType, - Map> - >(); - for (const { elementType, id } of targets) { - const field = elementType === 'key' ? 'keyPositions' : 'statPositions'; - const record = document[field] as Record< - string, - Array & { id: string }> - >; - const current = Object.values(record) - .flat() - .find((position) => position.id === id); - if ( - !current || - current.counter === null || - typeof current.counter !== 'object' || - Array.isArray(current.counter) - ) { - continue; - } - const counter = current.counter as Record; - const nextCounter = - patch.property === 'counterFontSize' - ? { ...counter, fontSize: patch.value } - : patch.property === 'counterFontWeight' - ? { - ...counter, - fontWeight: patch.value, - ...(typeof counter.fontBold !== 'boolean' - ? { fontBold: implicitCounterFontBold(counter.fontWeight) } - : {}), - } - : patch.property === 'counterFontBold' - ? { ...counter, fontBold: patch.value } - : patch.property === 'counterFontItalic' - ? { ...counter, fontItalic: patch.value } - : patch.property === 'counterFontUnderline' - ? { ...counter, fontUnderline: patch.value } - : patch.property === 'counterFontStrikethrough' - ? { ...counter, fontStrikethrough: patch.value } - : { ...counter, fontFamily: patch.value }; - const byId = propertyIntents.get(elementType) ?? new Map(); - byId.set(id, { counter: nextCounter }); - propertyIntents.set(elementType, byId); - } - return propertyIntents; -}; - -const isCounterTypographyPatch = ( - patch: EditorCounterTypographyPropertyPatchV1, -): boolean => { - if (patch.property === 'counterFontSize') { - return ( - Number.isSafeInteger(patch.value) && patch.value >= 8 && patch.value <= 72 - ); - } - if (patch.property === 'counterFontWeight') { - return ( - Number.isSafeInteger(patch.value) && - patch.value >= 100 && - patch.value <= 900 - ); - } - if (patch.property === 'counterFontBold') { - return typeof patch.value === 'boolean'; - } - if (patch.property === 'counterFontItalic') { - return typeof patch.value === 'boolean'; - } - if (patch.property === 'counterFontUnderline') { - return typeof patch.value === 'boolean'; - } - if (patch.property === 'counterFontStrikethrough') { - return typeof patch.value === 'boolean'; - } - return ( - patch.property === 'counterFontFamily' && typeof patch.value === 'string' - ); -}; - -export const patchCounterTypographyByTargets = ( - targets: readonly CounterAnimationTarget[], - patch: EditorCounterTypographyPropertyPatchV1, - options: PropertyCommitOptions = {}, -): Promise => { - if ( - !isCounterTypographyPatch(patch) || - targets.length === 0 || - targets.some(({ id }) => id.length === 0 || !isNativeElementId(id)) || - new Set(targets.map(({ id }) => id)).size !== targets.length - ) { - return Promise.resolve(false); - } - const receipt = applyPropertyIntentsEagerly( - counterTypographyPropertyIntents(targets, patch), - ); - let enrolled = false; - return commitSemanticOps( - targets.map(({ elementType, id }) => ({ - kind: 'patchElement' as const, - elementType, - id, - patch, - })), - { - ...(options.gestureId ? { gestureId: options.gestureId } : {}), - preflight: options.preflight, - onEnrolled: () => { - enrolled = true; - }, - }, - ) - .then((outcome) => - outcome.opResults.some((result) => result.status !== 'targetMissing'), - ) - .catch((error) => { - if (!enrolled) receipt?.rollback(); - throw error; - }); -}; - -export const patchCounterTypographyById = ( - elementType: 'key' | 'stat', - id: string, - patch: EditorCounterTypographyPropertyPatchV1, - options: PropertyCommitOptions = {}, -): Promise => - patchCounterTypographyByTargets([{ elementType, id }], patch, options); - -export const patchCounterEnabledByTargets = ( - targets: readonly CounterAnimationTarget[], - enabled: boolean, - options: { preflight?: () => void } = {}, -): Promise => - patchCounterBooleanByTargets( - targets, - { property: 'counterEnabled', value: enabled }, - options, - ); - -export const patchCounterEnabledById = ( - elementType: 'key' | 'stat', - id: string, - enabled: boolean, - options: { preflight?: () => void } = {}, -): Promise => - patchCounterEnabledByTargets([{ elementType, id }], enabled, options); - -export const patchCounterAnimationEnabledByTargets = ( - targets: readonly CounterAnimationTarget[], - enabled: boolean, - options: { preflight?: () => void } = {}, -): Promise => - patchCounterBooleanByTargets( - targets, - { property: 'counterAnimationEnabled', value: enabled }, - options, - ); - -export const patchCounterAnimationEnabledById = ( - elementType: 'key' | 'stat', - id: string, - enabled: boolean, - options: { preflight?: () => void } = {}, -): Promise => - patchCounterAnimationEnabledByTargets( - [{ elementType, id }], - enabled, - options, - ); - -const counterAnimationPropertyIntents = ( - targets: readonly CounterAnimationTarget[], - intent: EditorCounterAnimationPresetIntentV1, -): PropertyIntents => { - const document = captureEditorDocument(); - const propertyIntents = new Map< - NativeElementType, - Map> - >(); - for (const { elementType, id } of targets) { - const field = elementType === 'key' ? 'keyPositions' : 'statPositions'; - const record = document[field] as Record< - string, - Array & { id: string }> - >; - const current = Object.values(record) - .flat() - .find((position) => position.id === id); - if (!current) continue; - if ( - current.counter === null || - typeof current.counter !== 'object' || - Array.isArray(current.counter) - ) { - continue; - } - const counter = current.counter as Record; - if ( - counter.animation === null || - typeof counter.animation !== 'object' || - Array.isArray(counter.animation) - ) { - continue; - } - const animation = counter.animation as Record; - const byId = propertyIntents.get(elementType) ?? new Map(); - byId.set(id, { - counter: { - ...counter, - animation: { - ...animation, - ...('applyPresetId' in intent ? { presetId: intent.presetId } : {}), - ...('bezier' in intent ? { bezier: [...intent.bezier] } : {}), - ...('scale' in intent ? { scale: intent.scale } : {}), - ...('durationMs' in intent ? { durationMs: intent.durationMs } : {}), - }, - }, - }); - propertyIntents.set(elementType, byId); - } - return propertyIntents; -}; - -export const patchCounterAnimationPresetByTargets = ( - targets: readonly CounterAnimationTarget[], - intent: EditorCounterAnimationPresetIntentV1, - options: { preflight?: () => void } = {}, -): Promise => { - if ( - targets.length === 0 || - targets.some(({ id }) => id.length === 0 || !isNativeElementId(id)) || - new Set(targets.map(({ id }) => id)).size !== targets.length - ) { - return Promise.resolve(false); - } - const receipt = applyPropertyIntentsEagerly( - counterAnimationPropertyIntents(targets, intent), - ); - let enrolled = false; - return commitSemanticOps( - targets.map(({ elementType, id }) => ({ - kind: 'patchElement' as const, - elementType, - id, - patch: { - property: 'counterAnimationPreset', - value: structuredClone(intent), - }, - })), - { - preflight: options.preflight, - onEnrolled: () => { - enrolled = true; - }, - }, - ) - .then((outcome) => - outcome.opResults.some((result) => result.status !== 'targetMissing'), - ) - .catch((error) => { - if (!enrolled) receipt?.rollback(); - throw error; - }); -}; - -export const patchCounterAnimationPresetById = ( - elementType: 'key' | 'stat', - id: string, - intent: EditorCounterAnimationPresetIntentV1, - options: { preflight?: () => void } = {}, -): Promise => - patchCounterAnimationPresetByTargets([{ elementType, id }], intent, options); - -export const patchInactiveImageById = ( - type: NativeElementType, - id: string, - inactiveImage: string, - options: { preflight?: () => void } = {}, -): Promise => - patchElementPropertyById( - type, - id, - { property: 'inactiveImage', value: inactiveImage }, - options, - ); - -export const patchInactiveImageByTargets = ( - targets: readonly { elementType: NativeElementType; id: string }[], - inactiveImage: string, - options: { preflight?: () => void } = {}, -): Promise => - patchElementPropertyByTargets( - targets, - { property: 'inactiveImage', value: inactiveImage }, - options, - ); - -export const patchActiveImageById = ( - type: 'key' | 'knob', - id: string, - activeImage: string, - options: { preflight?: () => void } = {}, -): Promise => - patchElementPropertyById( - type, - id, - { property: 'activeImage', value: activeImage }, - options, - ); - -export const patchActiveImageByTargets = ( - targets: readonly { elementType: 'key' | 'knob'; id: string }[], - activeImage: string, - options: { preflight?: () => void } = {}, -): Promise => - patchElementPropertyByTargets( - targets, - { property: 'activeImage', value: activeImage }, - options, - ); - -export const patchIdleTransparentById = ( - type: NativeElementType, - id: string, - idleTransparent: boolean, - options: { preflight?: () => void } = {}, -): Promise => - patchElementPropertyById( - type, - id, - { property: 'idleTransparent', value: idleTransparent }, - options, - ); - -export const patchIdleTransparentByTargets = ( - targets: readonly { elementType: NativeElementType; id: string }[], - idleTransparent: boolean, - options: { preflight?: () => void } = {}, -): Promise => - patchElementPropertyByTargets( - targets, - { property: 'idleTransparent', value: idleTransparent }, - options, - ); - -export const patchActiveTransparentById = ( - type: 'key' | 'knob', - id: string, - activeTransparent: boolean, - options: { preflight?: () => void } = {}, -): Promise => - patchElementPropertyById( - type, - id, - { property: 'activeTransparent', value: activeTransparent }, - options, - ); - -export const patchActiveTransparentByTargets = ( - targets: readonly { elementType: 'key' | 'knob'; id: string }[], - activeTransparent: boolean, - options: { preflight?: () => void } = {}, -): Promise => - patchElementPropertyByTargets( - targets, - { property: 'activeTransparent', value: activeTransparent }, - options, - ); - -type ImageFit = 'cover' | 'contain' | 'fill' | 'none'; - -export const patchIdleImageFitById = ( - type: NativeElementType, - id: string, - idleImageFit: ImageFit, - options: { preflight?: () => void } = {}, -): Promise => - patchElementPropertyById( - type, - id, - { property: 'idleImageFit', value: idleImageFit }, - options, - ); - -export const patchActiveImageFitById = ( - type: 'key' | 'knob', - id: string, - activeImageFit: ImageFit, - options: { preflight?: () => void } = {}, -): Promise => - patchElementPropertyById( - type, - id, - { property: 'activeImageFit', value: activeImageFit }, - options, - ); - -export const patchImageModeById = ( - id: string, - imageMode: ImageMode, - options: { preflight?: () => void } = {}, -): Promise => - patchElementPropertyById( - 'key', - id, - { property: 'imageMode', value: imageMode }, - options, - ); - -// null이면 해당 상태의 변환을 identity로 되돌린다 -export const patchImageTransformById = ( - id: string, - state: 'idle' | 'active', - patch: ImageTransformLeafPatch | null, - options: { gestureId?: string; preflight?: () => void } = {}, -): Promise => - patchElementPropertyById( - 'key', - id, - state === 'idle' - ? { property: 'idleImageTransform', value: patch } - : { property: 'activeImageTransform', value: patch }, - options, - ); - -export const patchKnobPropertiesByIds = ( - ids: readonly string[], - patch: EditorKnobRuntimePropertyPatchV1, - options: { preflight?: () => void } = {}, -): Promise => - patchElementPropertyByTargets(idTargets('knob', ids), patch, options); - -export const patchUseInlineStylesByTargets = ( - targets: readonly { elementType: NativeElementType; id: string }[], - useInlineStyles: boolean, - options: { preflight?: () => void } = {}, -): Promise => - patchElementPropertyByTargets( - targets, - { property: 'useInlineStyles', value: useInlineStyles }, - options, - ); - -export const patchFontStyleByTargets = ( - targets: readonly { elementType: NativeElementType; id: string }[], - patch: EditorFontStylePropertyPatchV1, - options: PropertyCommitOptions = {}, -): Promise => patchElementPropertyByTargets(targets, patch, options); - -export const patchFontFamilyByTargets = ( - targets: readonly { elementType: NativeElementType; id: string }[], - patch: EditorFontFamilyPropertyPatchV1, - options: PropertyCommitOptions = {}, -): Promise => patchElementPropertyByTargets(targets, patch, options); - -type PaintTarget = { elementType: NativeElementType; id: string }; - -const paintPropertyIntents = ( - targets: readonly PaintTarget[], - patch: EditorPaintPropertyPatchV1, -): PropertyIntents => { - const document = captureEditorDocument(); - const fieldName = patch.property; - const descriptor = patch.value; - const intents = new Map< - NativeElementType, - Map> - >(); - for (const { elementType, id } of targets) { - const collection = - elementType === 'key' - ? document.keyPositions - : elementType === 'stat' - ? document.statPositions - : elementType === 'graph' - ? document.graphPositions - : document.knobPositions; - const current = Object.values(collection) - .flat() - .find((position) => position.id === id) as - | (Record & { id: string }) - | undefined; - if (!current) continue; - const next = projectPaintDescriptor( - current, - elementType, - fieldName, - descriptor, - ); - const byId = - intents.get(elementType) ?? new Map>(); - byId.set(id, next); - intents.set(elementType, byId); - } - return intents; -}; - -export const patchPaintByTargets = ( - targets: readonly PaintTarget[], - patch: EditorPaintPropertyPatchV1, - options: PropertyCommitOptions = {}, -): Promise => { - const { active, surface } = paintPropertyFields(patch.property); - // 표면별 허용 타깃 - font는 라벨 렌더러가 있는 키·스탯(active는 키만) - const rejectsTarget = (elementType: NativeElementType): boolean => - surface === 'font' - ? active - ? elementType !== 'key' - : elementType !== 'key' && elementType !== 'stat' - : active && elementType !== 'key' && elementType !== 'knob'; - if ( - !isEditorPaintPropertyPatchV1(patch) || - targets.length === 0 || - targets.some( - ({ elementType, id }) => - id.length === 0 || !isNativeElementId(id) || rejectsTarget(elementType), - ) || - new Set(targets.map(({ id }) => id)).size !== targets.length - ) { - return Promise.resolve(false); - } - const receipt = applyPropertyIntentsEagerly( - paintPropertyIntents(targets, patch), - ); - let enrolled = false; - return commitSemanticOps( - targets.map(({ elementType, id }) => ({ - kind: 'patchElement' as const, - elementType, - id, - patch: structuredClone(patch), - })), - { - ...(options.gestureId ? { gestureId: options.gestureId } : {}), - preflight: options.preflight, - onEnrolled: () => { - enrolled = true; - }, - }, - ) - .then((outcome) => - outcome.opResults.some((result) => result.status !== 'targetMissing'), - ) - .catch((error) => { - if (!enrolled) receipt?.rollback(); - throw error; - }); -}; - -export const patchPaintById = ( - elementType: NativeElementType, - id: string, - patch: EditorPaintPropertyPatchV1, - options: PropertyCommitOptions = {}, -): Promise => - patchPaintByTargets([{ elementType, id }], patch, options); - -type CounterFillTarget = { elementType: 'key' | 'stat'; id: string }; - -const counterFillPropertyIntents = ( - targets: readonly CounterFillTarget[], - patch: EditorCounterFillPropertyPatchV1, -): PropertyIntents => { - const document = captureEditorDocument(); - const intents = new Map< - NativeElementType, - Map> - >(); - for (const { elementType, id } of targets) { - const collection = - elementType === 'key' ? document.keyPositions : document.statPositions; - const current = Object.values(collection) - .flat() - .find((position) => position.id === id) as KeyPosition | undefined; - if (!current) continue; - const byId = - intents.get(elementType) ?? new Map>(); - byId.set(id, projectCounterFillPatch(current, patch)); - intents.set(elementType, byId); - } - return intents; -}; - -export const patchCounterFillByTargets = ( - targets: readonly CounterFillTarget[], - patch: EditorCounterFillPropertyPatchV1, - options: { preflight?: () => void } = {}, -): Promise => { - const active = patch.property === 'counterFillActive'; - if ( - !isCounterFillPropertyPatchV1(patch) || - targets.length === 0 || - targets.some( - ({ elementType, id }) => - !id || - !isNativeElementId(id) || - (elementType !== 'key' && elementType !== 'stat') || - (active && elementType !== 'key'), - ) || - new Set(targets.map(({ id }) => id)).size !== targets.length - ) { - return Promise.resolve(false); - } - const receipt = applyPropertyIntentsEagerly( - counterFillPropertyIntents(targets, patch), - ); - let enrolled = false; - return commitSemanticOps( - targets.map(({ elementType, id }) => ({ - kind: 'patchElement' as const, - elementType, - id, - patch: structuredClone(patch), - })), - { - preflight: options.preflight, - onEnrolled: () => { - enrolled = true; - }, - }, - ) - .then((outcome) => - outcome.opResults.some((result) => result.status !== 'targetMissing'), - ) - .catch((error) => { - if (!enrolled) receipt?.rollback(); - throw error; - }); -}; - -export const patchCounterFillById = ( - elementType: 'key' | 'stat', - id: string, - patch: EditorCounterFillPropertyPatchV1, - options: { preflight?: () => void } = {}, -) => patchCounterFillByTargets([{ elementType, id }], patch, options); - -type ShadowTarget = { - elementType: NativeElementType; - id: string; -}; - -const shadowPropertyIntents = ( - targets: readonly ShadowTarget[], - patch: EditorShadowPropertyPatchV1, -): PropertyIntents => { - const document = captureEditorDocument(); - const intents = new Map< - NativeElementType, - Map> - >(); - for (const { elementType, id } of targets) { - if (elementType === 'graph') continue; - const collection = - elementType === 'key' - ? document.keyPositions - : elementType === 'stat' - ? document.statPositions - : document.knobPositions; - const current = Object.values(collection) - .flat() - .find((position) => position.id === id) as - | (Record & { id: string }) - | undefined; - if (!current) continue; - const next = projectElementShadowPatch({ - position: current as never, - elementType, - patch, - defaultShadow: DEFAULT_ELEMENT_SHADOW_SPEC, - defaultActiveShadow: DEFAULT_ELEMENT_ACTIVE_SHADOW_SPEC, - }); - const byId = - intents.get(elementType) ?? new Map>(); - byId.set(id, next); - intents.set(elementType, byId); - } - return intents; -}; - -export const patchShadowByTargets = ( - targets: readonly ShadowTarget[], - patch: EditorShadowPropertyPatchV1, - options: { gestureId?: string; preflight?: () => void } = {}, -): Promise => { - if ( - !isEditorShadowPropertyPatchV1(patch) || - targets.length === 0 || - targets.some( - ({ elementType, id }) => - id.length === 0 || - !isNativeElementId(id) || - elementType === 'graph' || - (patch.property === 'activeShadow' && elementType === 'stat'), - ) || - new Set(targets.map(({ id }) => id)).size !== targets.length - ) { - return Promise.resolve(false); - } - const receipt = applyPropertyIntentsEagerly( - shadowPropertyIntents(targets, patch), - ); - let enrolled = false; - return commitSemanticOps( - targets.map(({ elementType, id }) => ({ - kind: 'patchElement' as const, - elementType, - id, - patch: structuredClone(patch), - })), - { - ...(options.gestureId ? { gestureId: options.gestureId } : {}), - preflight: options.preflight, - onEnrolled: () => { - enrolled = true; - }, - }, - ) - .then((outcome) => - outcome.opResults.some((result) => result.status !== 'targetMissing'), - ) - .catch((error) => { - if (!enrolled) receipt?.rollback(); - throw error; - }); -}; - -export const patchShadowById = ( - elementType: 'key' | 'stat' | 'knob', - id: string, - patch: EditorShadowPropertyPatchV1, - options: { gestureId?: string; preflight?: () => void } = {}, -): Promise => - patchShadowByTargets([{ elementType, id }], patch, options); - -const notePaintPropertyIntents = ( - ids: readonly string[], - patch: EditorNotePaintPropertyPatchV1, -): PropertyIntents => { - const document = captureEditorDocument(); - const byId = new Map>(); - for (const id of ids) { - const current = Object.values(document.keyPositions) - .flat() - .find((position) => position.id === id); - if (!current) continue; - // position 전달 - {opacity} 단독의 sibling shadow 재계산이 백엔드와 일치 (§9-5) - byId.set( - id, - projectNotePaintPatch(patch, current as unknown as KeyPosition) as Record< - string, - unknown - >, - ); - } - return new Map([['key', byId]]); -}; - -export const patchNotePaintByIds = ( - ids: readonly string[], - patch: EditorNotePaintPropertyPatchV1, - options: { gestureId?: string; preflight?: () => void } = {}, -): Promise => { - if ( - !isNotePaintPropertyPatchV1(patch) || - ids.length === 0 || - ids.some((id) => id.length === 0 || !isNativeElementId(id)) || - new Set(ids).size !== ids.length - ) { - return Promise.resolve(false); - } - const receipt = applyPropertyIntentsEagerly( - notePaintPropertyIntents(ids, patch), - ); - let enrolled = false; - return commitSemanticOps( - ids.map((id) => ({ - kind: 'patchElement' as const, - elementType: 'key' as const, - id, - patch: structuredClone(patch), - })), - { - ...(options.gestureId ? { gestureId: options.gestureId } : {}), - preflight: options.preflight, - onEnrolled: () => { - enrolled = true; - }, - }, - ) - .then((outcome) => - outcome.opResults.some((result) => result.status !== 'targetMissing'), - ) - .catch((error) => { - if (!enrolled) receipt?.rollback(); - throw error; - }); -}; - -export const patchNotePaintById = ( - id: string, - patch: EditorNotePaintPropertyPatchV1, - options: { gestureId?: string; preflight?: () => void } = {}, -): Promise => patchNotePaintByIds([id], patch, options); - -// note* 계열 수치 한계 검증, 대상에 key 외 타입이 있으면 즉시 무효 -const invalidNoteStylePatch = ( - patch: EditorPreviewStylePropertyPatchV1, - hasNonKeyTarget: boolean, -): boolean => - (patch.property === 'noteGlowSize' && - (hasNonKeyTarget || - !Number.isFinite(patch.value) || - patch.value < 0 || - patch.value > 50)) || - (patch.property === 'noteOffsetX' && - (hasNonKeyTarget || - (patch.value !== null && - (!Number.isFinite(patch.value) || - patch.value < -500 || - patch.value > 500)))) || - (patch.property === 'noteOffsetY' && - (hasNonKeyTarget || - (patch.value !== null && - (!Number.isFinite(patch.value) || - patch.value < -500 || - patch.value > 500)))) || - (patch.property === 'noteWidth' && - (hasNonKeyTarget || - (patch.value !== null && - (!Number.isFinite(patch.value) || patch.value <= 0)))) || - (patch.property === 'noteBorderWidth' && - (hasNonKeyTarget || - !Number.isFinite(patch.value) || - patch.value < 0 || - patch.value > 20)) || - (patch.property === 'noteBorderRadius' && - (hasNonKeyTarget || - !Number.isFinite(patch.value) || - patch.value < 0 || - patch.value > 100)); - -export const patchStylePropertyById = ( - type: NativeElementType, - id: string, - patch: EditorPreviewStylePropertyPatchV1, - options: { gestureId?: string; preflight?: () => void } = {}, -): Promise => - invalidNoteStylePatch(patch, type !== 'key') - ? Promise.resolve(false) - : patchElementPropertyById(type, id, patch, options); - -export const patchStylePropertyByTargets = ( - targets: readonly { elementType: NativeElementType; id: string }[], - patch: EditorPreviewStylePropertyPatchV1, - options: { gestureId?: string; preflight?: () => void } = {}, -): Promise => - invalidNoteStylePatch( - patch, - targets.some((target) => target.elementType !== 'key'), - ) - ? Promise.resolve(false) - : patchElementPropertyByTargets(targets, patch, options); - -export const patchNotePropertiesByIds = ( - ids: readonly string[], - patch: EditorNotePropertyPatchV1, - options: { preflight?: () => void } = {}, -): Promise => - patchElementPropertyByTargets(idTargets('key', ids), patch, options); - -// 다중 선택 정산: 대상 id들의 현재 canonical 기하(dx·dy)를 의도로 캡처해 -// 슬롯 안에서 id 재해석으로 적용한다. 4컬렉션 full-record 캡처는 배타 -// mutation(카운터 프리셋 삭제 등)의 IPC 창과 겹치면 직렬화 때문에 그 직후에 -// 확정적으로 착지해 무관 필드 재작성을 되돌린다 - 기하만 실어 그 결합을 끊는다 -export type GeometryField = 'dx' | 'dy' | 'width' | 'height'; -type GeometryPatch = Partial>; - -export interface BatchGeometryTarget { - type: NativeElementType; - id: string; -} - -export interface BatchGeometryDescriptor { - mode: string; - targets: readonly BatchGeometryTarget[]; - operation: BatchGeometryOperation; -} - -const readBatchGeometryElements = ( - base: CanonicalEditorDocumentV1, - descriptor: BatchGeometryDescriptor, -) => { - const seen = new Set(); - const elements: Array<{ - key: string; - type: NativeElementType; - id: string; - x: number; - y: number; - width: number; - height: number; - }> = []; - for (const target of descriptor.targets) { - if (!isNativeElementId(target.id) || seen.has(target.id)) return null; - seen.add(target.id); - const record = base[FIELD_BY_TYPE[target.type]] as unknown as LooseRecord; - const locator = findInRecord(record, target.id); - if (!locator || locator.mode !== descriptor.mode) return null; - const position = record[locator.mode]?.[locator.index]; - if ( - !position || - typeof position.dx !== 'number' || - typeof position.dy !== 'number' || - typeof position.width !== 'number' || - typeof position.height !== 'number' - ) { - return null; - } - elements.push({ - key: target.id, - type: target.type, - id: target.id, - x: position.dx, - y: position.dy, - width: position.width, - height: position.height, - }); - } - return elements; -}; - -const planBatchGeometry = ( - base: CanonicalEditorDocumentV1, - descriptor: BatchGeometryDescriptor, -) => { - const elements = readBatchGeometryElements(base, descriptor); - if (!elements) return null; - const plan = computeBatchGeometryPlan(elements, descriptor.operation); - if (!plan) return null; - const targetById = new Map( - elements.map((element) => [element.id, element] as const), - ); - return { - ...plan, - ops: plan.bounds.flatMap(({ key, bounds }) => { - const target = targetById.get(key); - return target - ? [ - { - kind: 'setBounds' as const, - elementType: target.type, - id: target.id, - bounds, - }, - ] - : []; - }), - }; -}; - -export const commitBatchGeometryByIds = ( - descriptor: BatchGeometryDescriptor, - options: { gestureId?: string; preflight?: () => void } = {}, -): Promise => { - const frozenDescriptor = structuredClone(descriptor); - const initialPlan = planBatchGeometry( - captureEditorDocument(), - frozenDescriptor, - ); - if (!initialPlan || initialPlan.updates.length === 0) { - return Promise.resolve(false); - } - const intents = new Map< - NativeElementType, - Map> - >(); - const targetById = new Map( - frozenDescriptor.targets.map((target) => [target.id, target] as const), - ); - for (const { key, patch } of initialPlan.updates) { - const target = targetById.get(key); - if (!target) continue; - const byId = intents.get(target.type) ?? new Map(); - byId.set(target.id, patch as Record); - intents.set(target.type, byId); - } - const receipt = - intents.size > 0 ? applyPropertyIntentsEagerly(intents) : null; - let enrolled = false; - return commitGeneratedSemanticOps( - (base) => planBatchGeometry(base, frozenDescriptor)?.ops ?? null, - { - ...(options.gestureId ? { gestureId: options.gestureId } : {}), - ...(options.preflight ? { preflight: options.preflight } : {}), - onEnrolled: () => { - enrolled = true; - }, - }, - ) - .then((outcome) => { - if (!outcome) { - if (!enrolled) receipt?.rollback(); - return false; - } - return outcome.opResults.every( - ({ status }) => status !== 'targetMissing', - ); - }) - .catch((error) => { - if (!enrolled) receipt?.rollback(); - throw error; - }); -}; - -export const commitElementGeometryById = ( - type: NativeElementType, - id: string, - patch: GeometryPatch, - options: { gestureId?: string; preflight?: () => void } = {}, -): Promise => { - const entries = Object.entries(patch) as Array<[GeometryField, number]>; - if ( - !isNativeElementId(id) || - entries.length === 0 || - entries.some( - ([field, value]) => - !['dx', 'dy', 'width', 'height'].includes(field) || - !Number.isFinite(value) || - ((field === 'width' || field === 'height') && value <= 0), - ) - ) { - return Promise.resolve(false); - } - - const frozenPatch = Object.fromEntries(entries) as GeometryPatch; - const intents: PropertyIntents = new Map([ - [type, new Map([[id, frozenPatch as Record]])], - ]); - const receipt = applyPropertyIntentsEagerly(intents); - let enrolled = false; - - return commitGeneratedSemanticOps( - (base) => { - const record = base[FIELD_BY_TYPE[type]] as unknown as LooseRecord; - const locator = findInRecord(record, id); - if (!locator) return null; - const position = record[locator.mode]?.[locator.index]; - if (!position) return null; - const bounds: EditorBoundsV1 = { - dx: position.dx as number, - dy: position.dy as number, - width: position.width as number, - height: position.height as number, - ...frozenPatch, - }; - return [{ kind: 'setBounds', elementType: type, id, bounds }]; - }, - { - ...(options.gestureId ? { gestureId: options.gestureId } : {}), - ...(options.preflight ? { preflight: options.preflight } : {}), - onEnrolled: () => { - enrolled = true; - }, - }, - ) - .then((outcome) => { - if (!outcome) { - if (!enrolled) receipt?.rollback(); - return false; - } - return outcome.opResults[0]?.status !== 'targetMissing'; - }) - .catch((error) => { - if (!enrolled) receipt?.rollback(); - throw error; - }); -}; - -// 리사이즈 완료 전용: 시작 시 동결한 대상 id들에 최종 bounds를 하나의 -// intent로 - eager와 wire, receipt를 같은 의도가 소유한다. 대상 소실은 -// targetLost로 eager 복원, 오류는 전파 -export const commitElementBoundsById = ( - intents: PropertyIntents, - gestureId?: string, -): Promise => { - const ops: EditorOpV1[] = []; - const boundsKeys = new Set(['dx', 'dy', 'width', 'height']); - const seenIds = new Set(); - for (const [elementType, byId] of intents) { - for (const [id, patch] of byId) { - if ( - !isNativeElementId(id) || - seenIds.has(id) || - Object.keys(patch).some((key) => !boundsKeys.has(key)) || - typeof patch.dx !== 'number' || - typeof patch.dy !== 'number' || - typeof patch.width !== 'number' || - typeof patch.height !== 'number' || - !Number.isFinite(patch.dx) || - !Number.isFinite(patch.dy) || - !Number.isFinite(patch.width) || - !Number.isFinite(patch.height) || - patch.width <= 0 || - patch.height <= 0 - ) { - return Promise.resolve(false); - } - seenIds.add(id); - ops.push({ - kind: 'setBounds', - elementType, - id, - bounds: { - dx: patch.dx, - dy: patch.dy, - width: patch.width, - height: patch.height, - }, - }); - } - } - if (ops.length === 0) return Promise.resolve(false); - - const receipt = applyPropertyIntentsEagerly(intents); - let enrolled = false; - return commitSemanticOps(ops, { - ...(gestureId ? { gestureId } : {}), - onEnrolled: () => { - enrolled = true; - }, - }) - .then(({ opResults }) => - opResults.some(({ status }) => status !== 'targetMissing'), - ) - .catch((error) => { - if (!enrolled) receipt?.rollback(); - throw error; - }); -}; - -export const commitSingleElementBoundsById = ( - type: NativeElementType, - id: string, - bounds: EditorBoundsV1, - gestureId?: string, -): Promise => { - if ( - !isNativeElementId(id) || - !Number.isFinite(bounds.dx) || - !Number.isFinite(bounds.dy) || - !Number.isFinite(bounds.width) || - !Number.isFinite(bounds.height) || - bounds.width <= 0 || - bounds.height <= 0 - ) { - return Promise.resolve(false); - } - const intents: PropertyIntents = new Map([ - [type, new Map([[id, { ...bounds }]])], - ]); - const receipt = applyPropertyIntentsEagerly(intents); - let enrolled = false; - - return commitSemanticOps( - [{ kind: 'setBounds', elementType: type, id, bounds }], - { - ...(gestureId ? { gestureId } : {}), - onEnrolled: () => { - enrolled = true; - }, - }, - ) - .then(({ opResults }) => opResults[0]?.status !== 'targetMissing') - .catch((error) => { - if (!enrolled) receipt?.rollback(); - throw error; - }); -}; +export { + patchElementHiddenById, + patchElementPropertyById, +} from './elementPropertyCore'; +export * from './elementGroupOps'; +export * from './elementStyleOps'; +export * from './elementGeometryOps'; diff --git a/src/renderer/editor/runtime/elementPropertyCore.ts b/src/renderer/editor/runtime/elementPropertyCore.ts new file mode 100644 index 000000000..725f244d5 --- /dev/null +++ b/src/renderer/editor/runtime/elementPropertyCore.ts @@ -0,0 +1,190 @@ +import { isNativeElementId } from '../model/elementId'; +import type { NativeElementType } from '../model/elementIdMap'; +import { + applyPropertyIntentsEagerly, + type ElementIntentReceipt, + type PropertyIntents, +} from './elementIntent'; +import { commitSemanticOps } from './editorSemanticOps'; +import { captureEditorDocument } from './editorStateCoordinator'; +import type { EditorElementPropertyPatchV1 } from '@src/types/editor'; +import { + applyImageTransformLeaf, + type ImageTransform, +} from '@src/types/key/imageLayer'; +import { implicitElementFontBold } from '@utils/core/fontWeights'; + +const POSITION_FIELD_BY_TYPE: Record = { + key: 'keyPositions', + stat: 'statPositions', + graph: 'graphPositions', + knob: 'knobPositions', +}; + +// 현재 문서의 요소 레코드 (없으면 null) +const findCurrentPosition = ( + document: Record | null | undefined, + elementType: NativeElementType, + id: string, +): (Record & { id: string }) | null => { + const record = document?.[POSITION_FIELD_BY_TYPE[elementType]]; + if (!record || typeof record !== 'object') return null; + return ( + Object.values(record as Record) + .flat() + .find( + (position): position is Record & { id: string } => + typeof position === 'object' && + position !== null && + (position as { id?: unknown }).id === id, + ) ?? null + ); +}; + +// 단건 property의 즉시 반영 투영. 도킹·분리, 단건·다건 네 경로가 같은 걸 쓴다 +const elementPropertyIntents = ( + targets: readonly { elementType: NativeElementType; id: string }[], + patch: EditorElementPropertyPatchV1, +): PropertyIntents => { + const intents = new Map< + NativeElementType, + Map> + >(); + // 굵기 변경은 백엔드와 같은 암묵 Bold 고정을, 이미지 변환 leaf는 현재 변환에 + // 적용한 결과를 투영한다 (둘 다 현재 위치가 필요) + const isImageTransformPatch = + patch.property === 'idleImageTransform' || + patch.property === 'activeImageTransform'; + const document = + patch.property === 'fontWeight' || isImageTransformPatch + ? (captureEditorDocument() as unknown as Record | null) + : null; + for (const { elementType, id } of targets) { + // nullable leaf의 null은 위치 조각에서 undefined로, 나머지는 1:1 투영 + const byId = intents.get(elementType) ?? new Map(); + const intent: Record = { + [patch.property]: patch.value ?? undefined, + }; + if (patch.property === 'fontWeight') { + const current = findCurrentPosition(document, elementType, id); + if (current && current.fontBold == null) { + intent.fontBold = implicitElementFontBold(current.fontWeight); + } + } else if (isImageTransformPatch && patch.value !== null) { + const current = findCurrentPosition(document, elementType, id); + intent[patch.property] = applyImageTransformLeaf( + current?.[patch.property] as ImageTransform | undefined, + patch.value, + ); + } + byId.set(id, intent); + intents.set(elementType, byId); + } + return intents; +}; + +// 빈 목록·비 native id·중복 id는 커밋 대상이 아니다 +const hasCommittableTargets = (targets: readonly { id: string }[]): boolean => + targets.length > 0 && + targets.every(({ id }) => isNativeElementId(id)) && + new Set(targets.map(({ id }) => id)).size === targets.length; + +const applyElementPropertyEagerly = ( + type: NativeElementType, + id: string, + patch: EditorElementPropertyPatchV1, +): ElementIntentReceipt | null => + applyPropertyIntentsEagerly( + elementPropertyIntents([{ elementType: type, id }], patch), + ); + +export const patchElementPropertyById = ( + type: NativeElementType, + id: string, + patch: EditorElementPropertyPatchV1, + options: { gestureId?: string; preflight?: () => void } = {}, +): Promise => { + if (!isNativeElementId(id)) return Promise.resolve(false); + const receipt = applyElementPropertyEagerly(type, id, patch); + let enrolled = false; + return commitSemanticOps( + [{ kind: 'patchElement', elementType: type, id, patch }], + { + ...(options.gestureId ? { gestureId: options.gestureId } : {}), + preflight: options.preflight, + onEnrolled: () => { + enrolled = true; + }, + }, + ) + .then((outcome) => outcome.opResults[0]?.status !== 'targetMissing') + .catch((error) => { + if (!enrolled) receipt?.rollback(); + throw error; + }); +}; + +interface ElementPropertyTarget { + elementType: NativeElementType; + id: string; +} + +export interface PropertyCommitOptions { + gestureId?: string; + preflight?: () => void; +} + +// 공용 다건 경로: 빈 목록·비 native id·중복 id를 거르고 같은 property patch를 +// 전 대상에 eager+wire로 커밋한다. 속성별 기계적 래퍼는 전부 여기로 위임하고 +// value 타입은 판별 유니온 patch가 컴파일 타임에 강제한다 +export const patchElementPropertyByTargets = ( + targets: readonly ElementPropertyTarget[], + patch: EditorElementPropertyPatchV1, + options: PropertyCommitOptions = {}, +): Promise => { + if (!hasCommittableTargets(targets)) return Promise.resolve(false); + const receipt = applyPropertyIntentsEagerly( + elementPropertyIntents(targets, patch), + ); + let enrolled = false; + return commitSemanticOps( + targets.map(({ elementType, id }) => ({ + kind: 'patchElement' as const, + elementType, + id, + patch, + })), + { + ...(options.gestureId ? { gestureId: options.gestureId } : {}), + preflight: options.preflight, + onEnrolled: () => { + enrolled = true; + }, + }, + ) + .then((outcome) => + outcome.opResults.some((result) => result.status !== 'targetMissing'), + ) + .catch((error) => { + if (!enrolled) receipt?.rollback(); + throw error; + }); +}; + +export const idTargets = ( + elementType: NativeElementType, + ids: readonly string[], +): ElementPropertyTarget[] => ids.map((id) => ({ elementType, id })); + +export const patchElementHiddenById = ( + type: NativeElementType, + id: string, + hidden: boolean, + options: { preflight?: () => void } = {}, +): Promise => + patchElementPropertyById( + type, + id, + { property: 'hidden', value: hidden }, + options, + ); diff --git a/src/renderer/editor/runtime/elementStyleOps.ts b/src/renderer/editor/runtime/elementStyleOps.ts new file mode 100644 index 000000000..700dd4f4e --- /dev/null +++ b/src/renderer/editor/runtime/elementStyleOps.ts @@ -0,0 +1,1231 @@ +import { isNativeElementId } from '../model/elementId'; +import type { NativeElementType } from '../model/elementIdMap'; +import { + applyPropertyIntentsEagerly, + type PropertyIntents, +} from './elementIntent'; +import { commitSemanticOps } from './editorSemanticOps'; +import { captureEditorDocument } from './editorStateCoordinator'; +import type { + EditorCounterAnimationPresetIntentV1, + EditorCounterBooleanPropertyPatchV1, + EditorCounterFillPropertyPatchV1, + EditorCounterLayoutPropertyPatchV1, + EditorCounterTypographyPropertyPatchV1, + EditorFontFamilyPropertyPatchV1, + EditorFontStylePropertyPatchV1, + EditorGraphRuntimePropertyPatchV1, + EditorKnobRuntimePropertyPatchV1, + EditorNotePaintPropertyPatchV1, + EditorNotePropertyPatchV1, + EditorPaintPropertyPatchV1, + EditorPreviewStylePropertyPatchV1, + EditorShadowPropertyPatchV1, +} from '@src/types/editor'; +import { + isEditorPaintPropertyPatchV1, + isEditorShadowPropertyPatchV1, +} from '@src/types/editor'; +import type { KeyPosition } from '@src/types/key/keys'; +import { paintPropertyFields, projectPaintDescriptor } from '@src/types/color'; +import { projectElementShadowPatch } from '@src/types/key/shadows'; +import type { + ImageMode, + ImageTransformLeafPatch, +} from '@src/types/key/imageLayer'; +import { + isNotePaintPropertyPatchV1, + projectNotePaintPatch, +} from '@src/types/key/notePaint'; +import { + isCounterFillPropertyPatchV1, + projectCounterFillPatch, +} from '@src/types/key/counterFill'; +import { + DEFAULT_ELEMENT_ACTIVE_SHADOW_SPEC, + DEFAULT_ELEMENT_SHADOW_SPEC, +} from '@utils/core/elementDefaults'; +import { implicitCounterFontBold } from '@utils/core/fontWeights'; +import { + idTargets, + patchElementPropertyById, + patchElementPropertyByTargets, + type PropertyCommitOptions, +} from './elementPropertyCore'; + +export const patchGraphTypesByIds = ( + ids: readonly string[], + graphType: 'line' | 'bar', + options: { preflight?: () => void } = {}, +): Promise => + patchElementPropertyByTargets( + idTargets('graph', ids), + { property: 'graphType', value: graphType }, + options, + ); + +export const patchGraphColorsByIds = ( + ids: readonly string[], + graphColor: string, + options: PropertyCommitOptions = {}, +): Promise => + patchElementPropertyByTargets( + idTargets('graph', ids), + { property: 'graphColor', value: graphColor }, + options, + ); + +export const patchGraphPropertiesByIds = ( + ids: readonly string[], + patch: EditorGraphRuntimePropertyPatchV1, + options: { preflight?: () => void } = {}, +): Promise => + patchElementPropertyByTargets(idTargets('graph', ids), patch, options); + +export const patchKnobAxisIdById = ( + id: string, + axisId: string, + options: { preflight?: () => void } = {}, +): Promise => + patchElementPropertyById( + 'knob', + id, + { property: 'axisId', value: axisId }, + options, + ); + +export const patchSoundPathById = ( + id: string, + soundPath: string, + options: { preflight?: () => void } = {}, +): Promise => + patchElementPropertyById( + 'key', + id, + { property: 'soundPath', value: soundPath }, + options, + ); + +export const patchSoundEnabledById = ( + id: string, + soundEnabled: boolean, + options: { preflight?: () => void } = {}, +): Promise => + patchElementPropertyById( + 'key', + id, + { property: 'soundEnabled', value: soundEnabled }, + options, + ); + +export const patchSoundEnabledByIds = ( + ids: readonly string[], + soundEnabled: boolean, + options: { preflight?: () => void } = {}, +): Promise => + patchElementPropertyByTargets( + idTargets('key', ids), + { property: 'soundEnabled', value: soundEnabled }, + options, + ); + +const validSoundVolume = (soundVolume: number): boolean => + Number.isFinite(soundVolume) && soundVolume >= 0 && soundVolume <= 200; + +export const patchSoundVolumeById = ( + id: string, + soundVolume: number, + options: { gestureId?: string; preflight?: () => void } = {}, +): Promise => + validSoundVolume(soundVolume) + ? patchElementPropertyById( + 'key', + id, + { property: 'soundVolume', value: soundVolume }, + options, + ) + : Promise.resolve(false); + +export const patchSoundVolumeByIds = ( + ids: readonly string[], + soundVolume: number, + options: { gestureId?: string; preflight?: () => void } = {}, +): Promise => + validSoundVolume(soundVolume) + ? patchElementPropertyByTargets( + idTargets('key', ids), + { property: 'soundVolume', value: soundVolume }, + options, + ) + : Promise.resolve(false); + +export const patchSoundPathByIds = ( + ids: readonly string[], + soundPath: string, + options: { preflight?: () => void } = {}, +): Promise => + patchElementPropertyByTargets( + idTargets('key', ids), + { property: 'soundPath', value: soundPath }, + options, + ); + +type CounterAnimationTarget = { + elementType: 'key' | 'stat'; + id: string; +}; + +const counterBooleanPropertyIntents = ( + targets: readonly CounterAnimationTarget[], + patch: EditorCounterBooleanPropertyPatchV1, +): PropertyIntents => { + const document = captureEditorDocument(); + const propertyIntents = new Map< + NativeElementType, + Map> + >(); + for (const { elementType, id } of targets) { + const field = elementType === 'key' ? 'keyPositions' : 'statPositions'; + const record = document[field] as Record< + string, + Array & { id: string }> + >; + const current = Object.values(record) + .flat() + .find((position) => position.id === id); + if ( + !current || + current.counter === null || + typeof current.counter !== 'object' || + Array.isArray(current.counter) + ) { + continue; + } + const counter = current.counter as Record; + const nextCounter = + patch.property === 'counterEnabled' + ? { ...counter, enabled: patch.value } + : counter.animation !== null && + typeof counter.animation === 'object' && + !Array.isArray(counter.animation) + ? { + ...counter, + animation: { + ...(counter.animation as Record), + enabled: patch.value, + }, + } + : null; + if (!nextCounter) continue; + const byId = propertyIntents.get(elementType) ?? new Map(); + byId.set(id, { counter: nextCounter }); + propertyIntents.set(elementType, byId); + } + return propertyIntents; +}; + +const patchCounterBooleanByTargets = ( + targets: readonly CounterAnimationTarget[], + patch: EditorCounterBooleanPropertyPatchV1, + options: { preflight?: () => void } = {}, +): Promise => { + if ( + targets.length === 0 || + targets.some(({ id }) => id.length === 0 || !isNativeElementId(id)) || + new Set(targets.map(({ id }) => id)).size !== targets.length + ) { + return Promise.resolve(false); + } + const receipt = applyPropertyIntentsEagerly( + counterBooleanPropertyIntents(targets, patch), + ); + let enrolled = false; + return commitSemanticOps( + targets.map(({ elementType, id }) => ({ + kind: 'patchElement' as const, + elementType, + id, + patch, + })), + { + preflight: options.preflight, + onEnrolled: () => { + enrolled = true; + }, + }, + ) + .then((outcome) => + outcome.opResults.some((result) => result.status !== 'targetMissing'), + ) + .catch((error) => { + if (!enrolled) receipt?.rollback(); + throw error; + }); +}; + +const counterLayoutPropertyIntents = ( + targets: readonly CounterAnimationTarget[], + patch: EditorCounterLayoutPropertyPatchV1, +): PropertyIntents => { + const document = captureEditorDocument(); + const propertyIntents = new Map< + NativeElementType, + Map> + >(); + for (const { elementType, id } of targets) { + const field = elementType === 'key' ? 'keyPositions' : 'statPositions'; + const record = document[field] as Record< + string, + Array & { id: string }> + >; + const current = Object.values(record) + .flat() + .find((position) => position.id === id); + if ( + !current || + current.counter === null || + typeof current.counter !== 'object' || + Array.isArray(current.counter) + ) { + continue; + } + const counter = current.counter as Record; + const nextCounter = + patch.property === 'counterPlacement' + ? { ...counter, placement: patch.value } + : patch.property === 'counterAlign' + ? { ...counter, align: patch.value } + : patch.property === 'counterAlignMode' + ? { ...counter, alignMode: patch.value } + : { ...counter, gap: patch.value }; + const byId = propertyIntents.get(elementType) ?? new Map(); + byId.set(id, { counter: nextCounter }); + propertyIntents.set(elementType, byId); + } + return propertyIntents; +}; + +export const patchCounterLayoutByTargets = ( + targets: readonly CounterAnimationTarget[], + patch: EditorCounterLayoutPropertyPatchV1, + options: { preflight?: () => void } = {}, +): Promise => { + if ( + targets.length === 0 || + targets.some(({ id }) => id.length === 0 || !isNativeElementId(id)) || + new Set(targets.map(({ id }) => id)).size !== targets.length + ) { + return Promise.resolve(false); + } + const receipt = applyPropertyIntentsEagerly( + counterLayoutPropertyIntents(targets, patch), + ); + let enrolled = false; + return commitSemanticOps( + targets.map(({ elementType, id }) => ({ + kind: 'patchElement' as const, + elementType, + id, + patch, + })), + { + preflight: options.preflight, + onEnrolled: () => { + enrolled = true; + }, + }, + ) + .then((outcome) => + outcome.opResults.some((result) => result.status !== 'targetMissing'), + ) + .catch((error) => { + if (!enrolled) receipt?.rollback(); + throw error; + }); +}; + +export const patchCounterLayoutById = ( + elementType: 'key' | 'stat', + id: string, + patch: EditorCounterLayoutPropertyPatchV1, + options: { preflight?: () => void } = {}, +): Promise => + patchCounterLayoutByTargets([{ elementType, id }], patch, options); + +const counterTypographyPropertyIntents = ( + targets: readonly CounterAnimationTarget[], + patch: EditorCounterTypographyPropertyPatchV1, +): PropertyIntents => { + const document = captureEditorDocument(); + const propertyIntents = new Map< + NativeElementType, + Map> + >(); + for (const { elementType, id } of targets) { + const field = elementType === 'key' ? 'keyPositions' : 'statPositions'; + const record = document[field] as Record< + string, + Array & { id: string }> + >; + const current = Object.values(record) + .flat() + .find((position) => position.id === id); + if ( + !current || + current.counter === null || + typeof current.counter !== 'object' || + Array.isArray(current.counter) + ) { + continue; + } + const counter = current.counter as Record; + const nextCounter = + patch.property === 'counterFontSize' + ? { ...counter, fontSize: patch.value } + : patch.property === 'counterFontWeight' + ? { + ...counter, + fontWeight: patch.value, + ...(typeof counter.fontBold !== 'boolean' + ? { fontBold: implicitCounterFontBold(counter.fontWeight) } + : {}), + } + : patch.property === 'counterFontBold' + ? { ...counter, fontBold: patch.value } + : patch.property === 'counterFontItalic' + ? { ...counter, fontItalic: patch.value } + : patch.property === 'counterFontUnderline' + ? { ...counter, fontUnderline: patch.value } + : patch.property === 'counterFontStrikethrough' + ? { ...counter, fontStrikethrough: patch.value } + : { ...counter, fontFamily: patch.value }; + const byId = propertyIntents.get(elementType) ?? new Map(); + byId.set(id, { counter: nextCounter }); + propertyIntents.set(elementType, byId); + } + return propertyIntents; +}; + +const isCounterTypographyPatch = ( + patch: EditorCounterTypographyPropertyPatchV1, +): boolean => { + if (patch.property === 'counterFontSize') { + return ( + Number.isSafeInteger(patch.value) && patch.value >= 8 && patch.value <= 72 + ); + } + if (patch.property === 'counterFontWeight') { + return ( + Number.isSafeInteger(patch.value) && + patch.value >= 100 && + patch.value <= 900 + ); + } + if (patch.property === 'counterFontBold') { + return typeof patch.value === 'boolean'; + } + if (patch.property === 'counterFontItalic') { + return typeof patch.value === 'boolean'; + } + if (patch.property === 'counterFontUnderline') { + return typeof patch.value === 'boolean'; + } + if (patch.property === 'counterFontStrikethrough') { + return typeof patch.value === 'boolean'; + } + return ( + patch.property === 'counterFontFamily' && typeof patch.value === 'string' + ); +}; + +export const patchCounterTypographyByTargets = ( + targets: readonly CounterAnimationTarget[], + patch: EditorCounterTypographyPropertyPatchV1, + options: PropertyCommitOptions = {}, +): Promise => { + if ( + !isCounterTypographyPatch(patch) || + targets.length === 0 || + targets.some(({ id }) => id.length === 0 || !isNativeElementId(id)) || + new Set(targets.map(({ id }) => id)).size !== targets.length + ) { + return Promise.resolve(false); + } + const receipt = applyPropertyIntentsEagerly( + counterTypographyPropertyIntents(targets, patch), + ); + let enrolled = false; + return commitSemanticOps( + targets.map(({ elementType, id }) => ({ + kind: 'patchElement' as const, + elementType, + id, + patch, + })), + { + ...(options.gestureId ? { gestureId: options.gestureId } : {}), + preflight: options.preflight, + onEnrolled: () => { + enrolled = true; + }, + }, + ) + .then((outcome) => + outcome.opResults.some((result) => result.status !== 'targetMissing'), + ) + .catch((error) => { + if (!enrolled) receipt?.rollback(); + throw error; + }); +}; + +export const patchCounterTypographyById = ( + elementType: 'key' | 'stat', + id: string, + patch: EditorCounterTypographyPropertyPatchV1, + options: PropertyCommitOptions = {}, +): Promise => + patchCounterTypographyByTargets([{ elementType, id }], patch, options); + +export const patchCounterEnabledByTargets = ( + targets: readonly CounterAnimationTarget[], + enabled: boolean, + options: { preflight?: () => void } = {}, +): Promise => + patchCounterBooleanByTargets( + targets, + { property: 'counterEnabled', value: enabled }, + options, + ); + +export const patchCounterEnabledById = ( + elementType: 'key' | 'stat', + id: string, + enabled: boolean, + options: { preflight?: () => void } = {}, +): Promise => + patchCounterEnabledByTargets([{ elementType, id }], enabled, options); + +export const patchCounterAnimationEnabledByTargets = ( + targets: readonly CounterAnimationTarget[], + enabled: boolean, + options: { preflight?: () => void } = {}, +): Promise => + patchCounterBooleanByTargets( + targets, + { property: 'counterAnimationEnabled', value: enabled }, + options, + ); + +export const patchCounterAnimationEnabledById = ( + elementType: 'key' | 'stat', + id: string, + enabled: boolean, + options: { preflight?: () => void } = {}, +): Promise => + patchCounterAnimationEnabledByTargets( + [{ elementType, id }], + enabled, + options, + ); + +const counterAnimationPropertyIntents = ( + targets: readonly CounterAnimationTarget[], + intent: EditorCounterAnimationPresetIntentV1, +): PropertyIntents => { + const document = captureEditorDocument(); + const propertyIntents = new Map< + NativeElementType, + Map> + >(); + for (const { elementType, id } of targets) { + const field = elementType === 'key' ? 'keyPositions' : 'statPositions'; + const record = document[field] as Record< + string, + Array & { id: string }> + >; + const current = Object.values(record) + .flat() + .find((position) => position.id === id); + if (!current) continue; + if ( + current.counter === null || + typeof current.counter !== 'object' || + Array.isArray(current.counter) + ) { + continue; + } + const counter = current.counter as Record; + if ( + counter.animation === null || + typeof counter.animation !== 'object' || + Array.isArray(counter.animation) + ) { + continue; + } + const animation = counter.animation as Record; + const byId = propertyIntents.get(elementType) ?? new Map(); + byId.set(id, { + counter: { + ...counter, + animation: { + ...animation, + ...('applyPresetId' in intent ? { presetId: intent.presetId } : {}), + ...('bezier' in intent ? { bezier: [...intent.bezier] } : {}), + ...('scale' in intent ? { scale: intent.scale } : {}), + ...('durationMs' in intent ? { durationMs: intent.durationMs } : {}), + }, + }, + }); + propertyIntents.set(elementType, byId); + } + return propertyIntents; +}; + +export const patchCounterAnimationPresetByTargets = ( + targets: readonly CounterAnimationTarget[], + intent: EditorCounterAnimationPresetIntentV1, + options: { preflight?: () => void } = {}, +): Promise => { + if ( + targets.length === 0 || + targets.some(({ id }) => id.length === 0 || !isNativeElementId(id)) || + new Set(targets.map(({ id }) => id)).size !== targets.length + ) { + return Promise.resolve(false); + } + const receipt = applyPropertyIntentsEagerly( + counterAnimationPropertyIntents(targets, intent), + ); + let enrolled = false; + return commitSemanticOps( + targets.map(({ elementType, id }) => ({ + kind: 'patchElement' as const, + elementType, + id, + patch: { + property: 'counterAnimationPreset', + value: structuredClone(intent), + }, + })), + { + preflight: options.preflight, + onEnrolled: () => { + enrolled = true; + }, + }, + ) + .then((outcome) => + outcome.opResults.some((result) => result.status !== 'targetMissing'), + ) + .catch((error) => { + if (!enrolled) receipt?.rollback(); + throw error; + }); +}; + +export const patchCounterAnimationPresetById = ( + elementType: 'key' | 'stat', + id: string, + intent: EditorCounterAnimationPresetIntentV1, + options: { preflight?: () => void } = {}, +): Promise => + patchCounterAnimationPresetByTargets([{ elementType, id }], intent, options); + +export const patchInactiveImageById = ( + type: NativeElementType, + id: string, + inactiveImage: string, + options: { preflight?: () => void } = {}, +): Promise => + patchElementPropertyById( + type, + id, + { property: 'inactiveImage', value: inactiveImage }, + options, + ); + +export const patchInactiveImageByTargets = ( + targets: readonly { elementType: NativeElementType; id: string }[], + inactiveImage: string, + options: { preflight?: () => void } = {}, +): Promise => + patchElementPropertyByTargets( + targets, + { property: 'inactiveImage', value: inactiveImage }, + options, + ); + +export const patchActiveImageById = ( + type: 'key' | 'knob', + id: string, + activeImage: string, + options: { preflight?: () => void } = {}, +): Promise => + patchElementPropertyById( + type, + id, + { property: 'activeImage', value: activeImage }, + options, + ); + +export const patchActiveImageByTargets = ( + targets: readonly { elementType: 'key' | 'knob'; id: string }[], + activeImage: string, + options: { preflight?: () => void } = {}, +): Promise => + patchElementPropertyByTargets( + targets, + { property: 'activeImage', value: activeImage }, + options, + ); + +export const patchIdleTransparentById = ( + type: NativeElementType, + id: string, + idleTransparent: boolean, + options: { preflight?: () => void } = {}, +): Promise => + patchElementPropertyById( + type, + id, + { property: 'idleTransparent', value: idleTransparent }, + options, + ); + +export const patchIdleTransparentByTargets = ( + targets: readonly { elementType: NativeElementType; id: string }[], + idleTransparent: boolean, + options: { preflight?: () => void } = {}, +): Promise => + patchElementPropertyByTargets( + targets, + { property: 'idleTransparent', value: idleTransparent }, + options, + ); + +export const patchActiveTransparentById = ( + type: 'key' | 'knob', + id: string, + activeTransparent: boolean, + options: { preflight?: () => void } = {}, +): Promise => + patchElementPropertyById( + type, + id, + { property: 'activeTransparent', value: activeTransparent }, + options, + ); + +export const patchActiveTransparentByTargets = ( + targets: readonly { elementType: 'key' | 'knob'; id: string }[], + activeTransparent: boolean, + options: { preflight?: () => void } = {}, +): Promise => + patchElementPropertyByTargets( + targets, + { property: 'activeTransparent', value: activeTransparent }, + options, + ); + +type ImageFit = 'cover' | 'contain' | 'fill' | 'none'; + +export const patchIdleImageFitById = ( + type: NativeElementType, + id: string, + idleImageFit: ImageFit, + options: { preflight?: () => void } = {}, +): Promise => + patchElementPropertyById( + type, + id, + { property: 'idleImageFit', value: idleImageFit }, + options, + ); + +export const patchActiveImageFitById = ( + type: 'key' | 'knob', + id: string, + activeImageFit: ImageFit, + options: { preflight?: () => void } = {}, +): Promise => + patchElementPropertyById( + type, + id, + { property: 'activeImageFit', value: activeImageFit }, + options, + ); + +export const patchImageModeById = ( + id: string, + imageMode: ImageMode, + options: { preflight?: () => void } = {}, +): Promise => + patchElementPropertyById( + 'key', + id, + { property: 'imageMode', value: imageMode }, + options, + ); + +// null이면 해당 상태의 변환을 identity로 되돌린다 +export const patchImageTransformById = ( + id: string, + state: 'idle' | 'active', + patch: ImageTransformLeafPatch | null, + options: { gestureId?: string; preflight?: () => void } = {}, +): Promise => + patchElementPropertyById( + 'key', + id, + state === 'idle' + ? { property: 'idleImageTransform', value: patch } + : { property: 'activeImageTransform', value: patch }, + options, + ); + +export const patchKnobPropertiesByIds = ( + ids: readonly string[], + patch: EditorKnobRuntimePropertyPatchV1, + options: { preflight?: () => void } = {}, +): Promise => + patchElementPropertyByTargets(idTargets('knob', ids), patch, options); + +export const patchUseInlineStylesByTargets = ( + targets: readonly { elementType: NativeElementType; id: string }[], + useInlineStyles: boolean, + options: { preflight?: () => void } = {}, +): Promise => + patchElementPropertyByTargets( + targets, + { property: 'useInlineStyles', value: useInlineStyles }, + options, + ); + +export const patchFontStyleByTargets = ( + targets: readonly { elementType: NativeElementType; id: string }[], + patch: EditorFontStylePropertyPatchV1, + options: PropertyCommitOptions = {}, +): Promise => patchElementPropertyByTargets(targets, patch, options); + +export const patchFontFamilyByTargets = ( + targets: readonly { elementType: NativeElementType; id: string }[], + patch: EditorFontFamilyPropertyPatchV1, + options: PropertyCommitOptions = {}, +): Promise => patchElementPropertyByTargets(targets, patch, options); + +type PaintTarget = { elementType: NativeElementType; id: string }; + +const paintPropertyIntents = ( + targets: readonly PaintTarget[], + patch: EditorPaintPropertyPatchV1, +): PropertyIntents => { + const document = captureEditorDocument(); + const fieldName = patch.property; + const descriptor = patch.value; + const intents = new Map< + NativeElementType, + Map> + >(); + for (const { elementType, id } of targets) { + const collection = + elementType === 'key' + ? document.keyPositions + : elementType === 'stat' + ? document.statPositions + : elementType === 'graph' + ? document.graphPositions + : document.knobPositions; + const current = Object.values(collection) + .flat() + .find((position) => position.id === id) as + | (Record & { id: string }) + | undefined; + if (!current) continue; + const next = projectPaintDescriptor( + current, + elementType, + fieldName, + descriptor, + ); + const byId = + intents.get(elementType) ?? new Map>(); + byId.set(id, next); + intents.set(elementType, byId); + } + return intents; +}; + +export const patchPaintByTargets = ( + targets: readonly PaintTarget[], + patch: EditorPaintPropertyPatchV1, + options: PropertyCommitOptions = {}, +): Promise => { + const { active, surface } = paintPropertyFields(patch.property); + // 표면별 허용 타깃 - font는 라벨 렌더러가 있는 키·스탯(active는 키만) + const rejectsTarget = (elementType: NativeElementType): boolean => + surface === 'font' + ? active + ? elementType !== 'key' + : elementType !== 'key' && elementType !== 'stat' + : active && elementType !== 'key' && elementType !== 'knob'; + if ( + !isEditorPaintPropertyPatchV1(patch) || + targets.length === 0 || + targets.some( + ({ elementType, id }) => + id.length === 0 || !isNativeElementId(id) || rejectsTarget(elementType), + ) || + new Set(targets.map(({ id }) => id)).size !== targets.length + ) { + return Promise.resolve(false); + } + const receipt = applyPropertyIntentsEagerly( + paintPropertyIntents(targets, patch), + ); + let enrolled = false; + return commitSemanticOps( + targets.map(({ elementType, id }) => ({ + kind: 'patchElement' as const, + elementType, + id, + patch: structuredClone(patch), + })), + { + ...(options.gestureId ? { gestureId: options.gestureId } : {}), + preflight: options.preflight, + onEnrolled: () => { + enrolled = true; + }, + }, + ) + .then((outcome) => + outcome.opResults.some((result) => result.status !== 'targetMissing'), + ) + .catch((error) => { + if (!enrolled) receipt?.rollback(); + throw error; + }); +}; + +export const patchPaintById = ( + elementType: NativeElementType, + id: string, + patch: EditorPaintPropertyPatchV1, + options: PropertyCommitOptions = {}, +): Promise => + patchPaintByTargets([{ elementType, id }], patch, options); + +type CounterFillTarget = { elementType: 'key' | 'stat'; id: string }; + +const counterFillPropertyIntents = ( + targets: readonly CounterFillTarget[], + patch: EditorCounterFillPropertyPatchV1, +): PropertyIntents => { + const document = captureEditorDocument(); + const intents = new Map< + NativeElementType, + Map> + >(); + for (const { elementType, id } of targets) { + const collection = + elementType === 'key' ? document.keyPositions : document.statPositions; + const current = Object.values(collection) + .flat() + .find((position) => position.id === id) as KeyPosition | undefined; + if (!current) continue; + const byId = + intents.get(elementType) ?? new Map>(); + byId.set(id, projectCounterFillPatch(current, patch)); + intents.set(elementType, byId); + } + return intents; +}; + +export const patchCounterFillByTargets = ( + targets: readonly CounterFillTarget[], + patch: EditorCounterFillPropertyPatchV1, + options: { preflight?: () => void } = {}, +): Promise => { + const active = patch.property === 'counterFillActive'; + if ( + !isCounterFillPropertyPatchV1(patch) || + targets.length === 0 || + targets.some( + ({ elementType, id }) => + !id || + !isNativeElementId(id) || + (elementType !== 'key' && elementType !== 'stat') || + (active && elementType !== 'key'), + ) || + new Set(targets.map(({ id }) => id)).size !== targets.length + ) { + return Promise.resolve(false); + } + const receipt = applyPropertyIntentsEagerly( + counterFillPropertyIntents(targets, patch), + ); + let enrolled = false; + return commitSemanticOps( + targets.map(({ elementType, id }) => ({ + kind: 'patchElement' as const, + elementType, + id, + patch: structuredClone(patch), + })), + { + preflight: options.preflight, + onEnrolled: () => { + enrolled = true; + }, + }, + ) + .then((outcome) => + outcome.opResults.some((result) => result.status !== 'targetMissing'), + ) + .catch((error) => { + if (!enrolled) receipt?.rollback(); + throw error; + }); +}; + +export const patchCounterFillById = ( + elementType: 'key' | 'stat', + id: string, + patch: EditorCounterFillPropertyPatchV1, + options: { preflight?: () => void } = {}, +) => patchCounterFillByTargets([{ elementType, id }], patch, options); + +type ShadowTarget = { + elementType: NativeElementType; + id: string; +}; + +const shadowPropertyIntents = ( + targets: readonly ShadowTarget[], + patch: EditorShadowPropertyPatchV1, +): PropertyIntents => { + const document = captureEditorDocument(); + const intents = new Map< + NativeElementType, + Map> + >(); + for (const { elementType, id } of targets) { + if (elementType === 'graph') continue; + const collection = + elementType === 'key' + ? document.keyPositions + : elementType === 'stat' + ? document.statPositions + : document.knobPositions; + const current = Object.values(collection) + .flat() + .find((position) => position.id === id) as + | (Record & { id: string }) + | undefined; + if (!current) continue; + const next = projectElementShadowPatch({ + position: current as never, + elementType, + patch, + defaultShadow: DEFAULT_ELEMENT_SHADOW_SPEC, + defaultActiveShadow: DEFAULT_ELEMENT_ACTIVE_SHADOW_SPEC, + }); + const byId = + intents.get(elementType) ?? new Map>(); + byId.set(id, next); + intents.set(elementType, byId); + } + return intents; +}; + +export const patchShadowByTargets = ( + targets: readonly ShadowTarget[], + patch: EditorShadowPropertyPatchV1, + options: { gestureId?: string; preflight?: () => void } = {}, +): Promise => { + if ( + !isEditorShadowPropertyPatchV1(patch) || + targets.length === 0 || + targets.some( + ({ elementType, id }) => + id.length === 0 || + !isNativeElementId(id) || + elementType === 'graph' || + (patch.property === 'activeShadow' && elementType === 'stat'), + ) || + new Set(targets.map(({ id }) => id)).size !== targets.length + ) { + return Promise.resolve(false); + } + const receipt = applyPropertyIntentsEagerly( + shadowPropertyIntents(targets, patch), + ); + let enrolled = false; + return commitSemanticOps( + targets.map(({ elementType, id }) => ({ + kind: 'patchElement' as const, + elementType, + id, + patch: structuredClone(patch), + })), + { + ...(options.gestureId ? { gestureId: options.gestureId } : {}), + preflight: options.preflight, + onEnrolled: () => { + enrolled = true; + }, + }, + ) + .then((outcome) => + outcome.opResults.some((result) => result.status !== 'targetMissing'), + ) + .catch((error) => { + if (!enrolled) receipt?.rollback(); + throw error; + }); +}; + +export const patchShadowById = ( + elementType: 'key' | 'stat' | 'knob', + id: string, + patch: EditorShadowPropertyPatchV1, + options: { gestureId?: string; preflight?: () => void } = {}, +): Promise => + patchShadowByTargets([{ elementType, id }], patch, options); + +const notePaintPropertyIntents = ( + ids: readonly string[], + patch: EditorNotePaintPropertyPatchV1, +): PropertyIntents => { + const document = captureEditorDocument(); + const byId = new Map>(); + for (const id of ids) { + const current = Object.values(document.keyPositions) + .flat() + .find((position) => position.id === id); + if (!current) continue; + // position 전달 - {opacity} 단독의 sibling shadow 재계산이 백엔드와 일치 (§9-5) + byId.set( + id, + projectNotePaintPatch(patch, current as unknown as KeyPosition) as Record< + string, + unknown + >, + ); + } + return new Map([['key', byId]]); +}; + +export const patchNotePaintByIds = ( + ids: readonly string[], + patch: EditorNotePaintPropertyPatchV1, + options: { gestureId?: string; preflight?: () => void } = {}, +): Promise => { + if ( + !isNotePaintPropertyPatchV1(patch) || + ids.length === 0 || + ids.some((id) => id.length === 0 || !isNativeElementId(id)) || + new Set(ids).size !== ids.length + ) { + return Promise.resolve(false); + } + const receipt = applyPropertyIntentsEagerly( + notePaintPropertyIntents(ids, patch), + ); + let enrolled = false; + return commitSemanticOps( + ids.map((id) => ({ + kind: 'patchElement' as const, + elementType: 'key' as const, + id, + patch: structuredClone(patch), + })), + { + ...(options.gestureId ? { gestureId: options.gestureId } : {}), + preflight: options.preflight, + onEnrolled: () => { + enrolled = true; + }, + }, + ) + .then((outcome) => + outcome.opResults.some((result) => result.status !== 'targetMissing'), + ) + .catch((error) => { + if (!enrolled) receipt?.rollback(); + throw error; + }); +}; + +export const patchNotePaintById = ( + id: string, + patch: EditorNotePaintPropertyPatchV1, + options: { gestureId?: string; preflight?: () => void } = {}, +): Promise => patchNotePaintByIds([id], patch, options); + +// note* 계열 수치 한계 검증, 대상에 key 외 타입이 있으면 즉시 무효 +const invalidNoteStylePatch = ( + patch: EditorPreviewStylePropertyPatchV1, + hasNonKeyTarget: boolean, +): boolean => + (patch.property === 'noteGlowSize' && + (hasNonKeyTarget || + !Number.isFinite(patch.value) || + patch.value < 0 || + patch.value > 50)) || + (patch.property === 'noteOffsetX' && + (hasNonKeyTarget || + (patch.value !== null && + (!Number.isFinite(patch.value) || + patch.value < -500 || + patch.value > 500)))) || + (patch.property === 'noteOffsetY' && + (hasNonKeyTarget || + (patch.value !== null && + (!Number.isFinite(patch.value) || + patch.value < -500 || + patch.value > 500)))) || + (patch.property === 'noteWidth' && + (hasNonKeyTarget || + (patch.value !== null && + (!Number.isFinite(patch.value) || patch.value <= 0)))) || + (patch.property === 'noteBorderWidth' && + (hasNonKeyTarget || + !Number.isFinite(patch.value) || + patch.value < 0 || + patch.value > 20)) || + (patch.property === 'noteBorderRadius' && + (hasNonKeyTarget || + !Number.isFinite(patch.value) || + patch.value < 0 || + patch.value > 100)); + +export const patchStylePropertyById = ( + type: NativeElementType, + id: string, + patch: EditorPreviewStylePropertyPatchV1, + options: { gestureId?: string; preflight?: () => void } = {}, +): Promise => + invalidNoteStylePatch(patch, type !== 'key') + ? Promise.resolve(false) + : patchElementPropertyById(type, id, patch, options); + +export const patchStylePropertyByTargets = ( + targets: readonly { elementType: NativeElementType; id: string }[], + patch: EditorPreviewStylePropertyPatchV1, + options: { gestureId?: string; preflight?: () => void } = {}, +): Promise => + invalidNoteStylePatch( + patch, + targets.some((target) => target.elementType !== 'key'), + ) + ? Promise.resolve(false) + : patchElementPropertyByTargets(targets, patch, options); + +export const patchNotePropertiesByIds = ( + ids: readonly string[], + patch: EditorNotePropertyPatchV1, + options: { preflight?: () => void } = {}, +): Promise => + patchElementPropertyByTargets(idTargets('key', ids), patch, options); From 50bee95d48d846aa9ddcfd67bc4041c3353d03c4 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 07:13:19 +0900 Subject: [PATCH 013/245] =?UTF-8?q?refactor:=20=ED=94=84=EB=A1=9C=ED=8D=BC?= =?UTF-8?q?=ED=8B=B0=20=ED=8C=A8=EB=84=90=20=ED=8E=B8=EC=A7=91=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=ED=9B=85=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/main/Grid/PropertiesPanel.tsx | 232 +++--------------- .../usePluginSettingsPanelController.ts | 79 ++++++ .../usePropertiesPanelRename.ts | 184 ++++++++++++++ 3 files changed, 291 insertions(+), 204 deletions(-) create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/usePluginSettingsPanelController.ts create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/usePropertiesPanelRename.ts diff --git a/src/renderer/components/main/Grid/PropertiesPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel.tsx index 50c9dd72b..8b4d538bb 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel.tsx @@ -4,7 +4,6 @@ import { usePanelHost } from '@contexts/PanelHostContext'; import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; import { useSettingsStore } from '@stores/useSettingsStore'; import { usePropertiesPanelStore } from '@stores/grid/usePropertiesPanelStore'; -import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; import { getDefaultSettings, omitLayoutSettingValues, @@ -25,8 +24,6 @@ import { usePluginGeometryGesture } from '@hooks/Grid/usePluginGeometryGesture'; import { editGestureController } from '@src/renderer/editor/runtime/editGestureController'; import { commitBatchGeometryByIds, - patchElementLayerNameById, - renameLayerGroupById, patchFontFamilyByTargets, patchFontStyleByTargets, patchGraphColorsByIds, @@ -81,10 +78,11 @@ import { getGraphRuntimePropertyPatch, getKnobRuntimePropertyPatch, getNotePropertyPatch, - getStatTypeLabel, getUseInlineStylesPatch, shouldNormalizePropertyTabToStyle, } from './PropertiesPanel/propertyPanelAdapters'; +import { usePropertiesPanelRename } from './PropertiesPanel/usePropertiesPanelRename'; +import { usePluginSettingsPanelController } from './PropertiesPanel/usePluginSettingsPanelController'; // ============================================================================ // 메인 컴포넌트 Props @@ -152,12 +150,15 @@ const PropertiesPanel: React.FC = ({ selectedGroupInfo, } = usePropertiesPanelSelection(); const { useCustomCSS } = useSettingsStore(); - const pluginSettingsPanel = usePropertiesPanelStore( - (state) => state.pluginSettingsPanel, - ); - const closePluginSettingsPanel = usePropertiesPanelStore( - (state) => state.closePluginSettingsPanel, - ); + const { + pluginSettingsPanel, + pluginPanelSettings, + isPluginSettingsSaving, + cancelRef: handlePluginSettingsPanelCancelImpl, + handleChange: handlePluginSettingsPanelChange, + handleConfirm: handlePluginSettingsPanelConfirm, + handleCancel: handlePluginSettingsPanelCancel, + } = usePluginSettingsPanelController(); const isPanelVisibleStore = usePropertiesPanelStore( (state) => state.isCanvasPanelOpen, ); @@ -177,20 +178,6 @@ const PropertiesPanel: React.FC = ({ Partial & { dx?: number; dy?: number } >({}); const pluginVisibilityErrorsRef = useRef(new Set()); - const [pluginPanelSettings, setPluginPanelSettings] = useState< - Record - >({}); - const [isPluginSettingsSaving, setIsPluginSettingsSaving] = useState(false); - const pluginSettingsSavingRef = useRef(false); - - // 레이어 이름 변경 상태 - const [isRenaming, setIsRenaming] = useState(false); - const [renameValue, setRenameValue] = useState(''); - const renameInputRef = useRef(null); - const renameCancelledRef = useRef(false); - const renameRequestSignal = usePropertiesPanelStore( - (state) => state.renameRequestSignal, - ); // 이미지 픽커 상태 const [showImagePicker, setShowImagePicker] = useState(false); @@ -221,6 +208,7 @@ const PropertiesPanel: React.FC = ({ // panelMode를 ref로도 유지 (useEffect에서 최신 값 참조용) const panelModeRef = useRef(panelMode); + // eslint-disable-next-line react-hooks/refs -- 이벤트 정산의 최신 패널 모드 유지 panelModeRef.current = panelMode; // 이전 선택 상태 추적 (선택 해제 감지용) @@ -253,7 +241,6 @@ const PropertiesPanel: React.FC = ({ (state) => state.setPropertyPanelActiveTab, ); - const handlePluginSettingsPanelCancelImpl = useRef<() => void>(() => {}); const panelScopeKey = [ pluginSettingsPanel ? 'plugin-settings' : 'grid', selectedKeyElements.length, @@ -287,139 +274,27 @@ const PropertiesPanel: React.FC = ({ setActiveTab(TABS.STYLE); } }, [activeTab, selectedElements, setActiveTab]); - - // 레이어 이름 변경: 현재 선택된 요소의 layerName 가져오기 - const getCurrentLayerName = (): string => { - if (selectedGroupInfo) return selectedGroupInfo.name || ''; - if (singleKeyPosition) return singleKeyPosition.layerName || ''; - if (singleStatPosition) return singleStatPosition.layerName || ''; - if (singleGraphPosition) return singleGraphPosition.layerName || ''; - if (singleKnobPosition) return singleKnobPosition.layerName || ''; - return ''; - }; - - // 레이어 이름 변경: 현재 선택된 요소의 기본 표시 이름 가져오기 - const getCurrentDefaultTitle = (): string => { - if (selectedGroupInfo) return selectedGroupInfo.name; - if (singleKeyPosition) { - return singleKeyInfo?.displayName || singleKeyCode || 'Key'; - } - if (singleStatPosition) { - return getStatTypeLabel(singleStatPosition.statType ?? null); - } - if (singleGraphPosition) { - return `${getStatTypeLabel(singleGraphPosition.statType ?? null)} Graph`; - } - if (singleKnobPosition) return 'Knob'; - return ''; - }; - - const handleGroupRenameCommit = async (groupId: string, value: string) => { - const trimmed = value.trim(); - if (trimmed === '') return; - - const currentGroups = useLayerGroupStore.getState().layerGroups; - const currentModeGroups = currentGroups[selectedKeyType] || []; - const currentGroup = currentModeGroups.find( - (group) => group.id === groupId, - ); - if (!currentGroup || currentGroup.name === trimmed) return; - - try { - await renameLayerGroupById(selectedKeyType, groupId, trimmed); - } catch (error) { - console.error('Failed to rename group', error); - } - }; - - // 레이어 이름 변경 시작 - const handleRenameStartImpl = useRef<() => void>(() => {}); - handleRenameStartImpl.current = () => { - const current = getCurrentLayerName(); - setRenameValue(current || getCurrentDefaultTitle()); - setIsRenaming(true); - requestAnimationFrame(() => { - renameInputRef.current?.focus(); - renameInputRef.current?.select(); - }); - }; - const handleRenameStart = () => { - handleRenameStartImpl.current(); - }; - - // 레이어 이름 변경 커밋 - const handleRenameCommit = async (value: string) => { - setIsRenaming(false); - - if (selectedGroupInfo) { - await handleGroupRenameCommit(selectedGroupInfo.id, value); - return; - } - - const trimmed = value.trim(); - const defaultTitle = getCurrentDefaultTitle(); - const newLayerName = - trimmed === defaultTitle || trimmed === '' ? null : trimmed; - - const selectedElement = - selectedElements.length === 1 ? selectedElements[0] : null; - const stableTarget = - selectedElement && selectedElement.type !== 'plugin' - ? { elementType: selectedElement.type, id: selectedElement.id } - : null; - if (stableTarget && isNativeElementId(stableTarget.id)) { - const target = { - ...stableTarget, - patch: { property: 'layerName', value: newLayerName }, - } as const; - try { - await patchElementLayerNameById( - target.elementType, - target.id, - target.patch.value, - ); - } catch (error) { - console.error('Failed to rename layer', error); - } - } - }; - - // 레이어 이름 변경 취소 - const handleRenameCancel = () => { - renameCancelledRef.current = true; - setIsRenaming(false); - }; - - // 캔버스 컨텍스트 메뉴에서 rename 요청 시 트리거 - const prevRenameSignalRef = useRef(renameRequestSignal); - useEffect(() => { - if (renameRequestSignal !== prevRenameSignalRef.current) { - prevRenameSignalRef.current = renameRequestSignal; - if ( - selectedGroupInfo || - singleKeyPosition || - singleStatPosition || - singleGraphPosition || - singleKnobPosition - ) { - setPanelMode('property'); - handleRenameStart(); - } - } - }, [ - renameRequestSignal, + const { + isRenaming, + renameValue, + setRenameValue, + renameInputRef, + renameCancelledRef, + handleRenameStart, + handleRenameCommit, + handleRenameCancel, + } = usePropertiesPanelRename({ + selectedElements, + selectedKeyType, selectedGroupInfo, singleKeyPosition, singleStatPosition, singleGraphPosition, singleKnobPosition, + singleKeyInfo, + singleKeyCode, setPanelMode, - ]); - - // 선택이 변경되면 rename 모드 해제 - useEffect(() => { - setIsRenaming(false); - }, [selectedElements]); + }); // 스크롤 훅 사용 const { batchScrollRefFor, singleScrollRefFor } = usePanelScroll(); @@ -476,14 +351,6 @@ const PropertiesPanel: React.FC = ({ setGraphClassNameDraft(singleGraphPosition?.className || ''); }, [selectedKeyType, singleGraphIndex, singleGraphPosition?.className]); - useEffect(() => { - if (pluginSettingsPanel) { - setPluginPanelSettings(pluginSettingsPanel.settings || {}); - setIsPluginSettingsSaving(false); - pluginSettingsSavingRef.current = false; - } - }, [pluginSettingsPanel]); - // 선택된 키가 변경될 때 패널 열기/닫기 useEffect(() => { const hasSelection = @@ -645,6 +512,7 @@ const PropertiesPanel: React.FC = ({ // ============================================================================ const handleTogglePanelImpl = useRef<() => void>(() => {}); + // eslint-disable-next-line react-hooks/refs -- 안정 래퍼의 최신 토글 콜백 유지 handleTogglePanelImpl.current = () => { const willOpen = !isPanelVisible; @@ -707,50 +575,6 @@ const PropertiesPanel: React.FC = ({ }); }; - const handlePluginSettingsPanelChange = (key: string, value: unknown) => { - if (!pluginSettingsPanel) return; - setPluginPanelSettings((prev) => { - const next = { ...prev, [key]: value }; - pluginSettingsPanel.onChange(next); - return next; - }); - }; - - const handlePluginSettingsPanelConfirm = async () => { - if (!pluginSettingsPanel || pluginSettingsSavingRef.current) return; - pluginSettingsSavingRef.current = true; - setIsPluginSettingsSaving(true); - try { - await pluginSettingsPanel.onConfirm( - pluginPanelSettings, - pluginSettingsPanel.originalSettings, - ); - pluginSettingsPanel.resolve(true); - } catch (error) { - console.error('[Plugin Settings] Failed to apply settings:', error); - pluginSettingsPanel.resolve(false); - } finally { - pluginSettingsSavingRef.current = false; - setIsPluginSettingsSaving(false); - closePluginSettingsPanel(); - } - }; - - handlePluginSettingsPanelCancelImpl.current = () => { - if (!pluginSettingsPanel || pluginSettingsSavingRef.current) return; - try { - pluginSettingsPanel.onCancel(pluginSettingsPanel.originalSettings); - } catch (error) { - console.error('[Plugin Settings] Failed to cancel settings:', error); - } finally { - pluginSettingsPanel.resolve(false); - closePluginSettingsPanel(); - } - }; - const handlePluginSettingsPanelCancel = () => { - handlePluginSettingsPanelCancelImpl.current(); - }; - // 외부(단축키 등)에서 보낸 사이드 패널 토글 요청 처리 const prevToggleSignalRef = useRef(canvasPanelToggleSignal); useEffect(() => { diff --git a/src/renderer/components/main/Grid/PropertiesPanel/usePluginSettingsPanelController.ts b/src/renderer/components/main/Grid/PropertiesPanel/usePluginSettingsPanelController.ts new file mode 100644 index 000000000..4cfd3d46f --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/usePluginSettingsPanelController.ts @@ -0,0 +1,79 @@ +import { useEffect, useRef, useState } from 'react'; +import { usePropertiesPanelStore } from '@stores/grid/usePropertiesPanelStore'; + +export const usePluginSettingsPanelController = () => { + const pluginSettingsPanel = usePropertiesPanelStore( + (state) => state.pluginSettingsPanel, + ); + const closePluginSettingsPanel = usePropertiesPanelStore( + (state) => state.closePluginSettingsPanel, + ); + const [pluginPanelSettings, setPluginPanelSettings] = useState< + Record + >({}); + const [isPluginSettingsSaving, setIsPluginSettingsSaving] = useState(false); + const pluginSettingsSavingRef = useRef(false); + const cancelRef = useRef<() => void>(() => {}); + + useEffect(() => { + if (pluginSettingsPanel) { + setPluginPanelSettings(pluginSettingsPanel.settings || {}); + setIsPluginSettingsSaving(false); + pluginSettingsSavingRef.current = false; + } + }, [pluginSettingsPanel]); + + const handleChange = (key: string, value: unknown) => { + if (!pluginSettingsPanel) return; + setPluginPanelSettings((previous) => { + const next = { ...previous, [key]: value }; + pluginSettingsPanel.onChange(next); + return next; + }); + }; + + const handleConfirm = async () => { + if (!pluginSettingsPanel || pluginSettingsSavingRef.current) return; + pluginSettingsSavingRef.current = true; + setIsPluginSettingsSaving(true); + try { + await pluginSettingsPanel.onConfirm( + pluginPanelSettings, + pluginSettingsPanel.originalSettings, + ); + pluginSettingsPanel.resolve(true); + } catch (error) { + console.error('[Plugin Settings] Failed to apply settings:', error); + pluginSettingsPanel.resolve(false); + } finally { + pluginSettingsSavingRef.current = false; + setIsPluginSettingsSaving(false); + closePluginSettingsPanel(); + } + }; + + cancelRef.current = () => { + if (!pluginSettingsPanel || pluginSettingsSavingRef.current) return; + try { + pluginSettingsPanel.onCancel(pluginSettingsPanel.originalSettings); + } catch (error) { + console.error('[Plugin Settings] Failed to cancel settings:', error); + } finally { + pluginSettingsPanel.resolve(false); + closePluginSettingsPanel(); + } + }; + const handleCancel = () => { + cancelRef.current(); + }; + + return { + pluginSettingsPanel, + pluginPanelSettings, + isPluginSettingsSaving, + cancelRef, + handleChange, + handleConfirm, + handleCancel, + }; +}; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/usePropertiesPanelRename.ts b/src/renderer/components/main/Grid/PropertiesPanel/usePropertiesPanelRename.ts new file mode 100644 index 000000000..66c31c2e8 --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/usePropertiesPanelRename.ts @@ -0,0 +1,184 @@ +import { useEffect, useRef, useState } from 'react'; +import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; +import { usePropertiesPanelStore } from '@stores/grid/usePropertiesPanelStore'; +import { + patchElementLayerNameById, + renameLayerGroupById, +} from '@src/renderer/editor/runtime/elementOps'; +import { isNativeElementId } from '@src/renderer/editor/model/elementId'; +import { getStatTypeLabel } from './propertyPanelAdapters'; +import type { usePropertiesPanelSelection } from './usePropertiesPanelSelection'; + +type PropertiesPanelSelection = ReturnType; + +interface UsePropertiesPanelRenameOptions { + selectedElements: PropertiesPanelSelection['selectedElements']; + selectedKeyType: string; + selectedGroupInfo: PropertiesPanelSelection['selectedGroupInfo']; + singleKeyPosition: PropertiesPanelSelection['singleKeyPosition']; + singleStatPosition: PropertiesPanelSelection['singleStatPosition']; + singleGraphPosition: PropertiesPanelSelection['singleGraphPosition']; + singleKnobPosition: PropertiesPanelSelection['singleKnobPosition']; + singleKeyInfo: PropertiesPanelSelection['singleKeyInfo']; + singleKeyCode: PropertiesPanelSelection['singleKeyCode']; + setPanelMode: (mode: 'layer' | 'property') => void; +} + +export const usePropertiesPanelRename = ({ + selectedElements, + selectedKeyType, + selectedGroupInfo, + singleKeyPosition, + singleStatPosition, + singleGraphPosition, + singleKnobPosition, + singleKeyInfo, + singleKeyCode, + setPanelMode, +}: UsePropertiesPanelRenameOptions) => { + const [isRenaming, setIsRenaming] = useState(false); + const [renameValue, setRenameValue] = useState(''); + const renameInputRef = useRef(null); + const renameCancelledRef = useRef(false); + const renameRequestSignal = usePropertiesPanelStore( + (state) => state.renameRequestSignal, + ); + + const getCurrentLayerName = (): string => { + if (selectedGroupInfo) return selectedGroupInfo.name || ''; + if (singleKeyPosition) return singleKeyPosition.layerName || ''; + if (singleStatPosition) return singleStatPosition.layerName || ''; + if (singleGraphPosition) return singleGraphPosition.layerName || ''; + if (singleKnobPosition) return singleKnobPosition.layerName || ''; + return ''; + }; + + const getCurrentDefaultTitle = (): string => { + if (selectedGroupInfo) return selectedGroupInfo.name; + if (singleKeyPosition) { + return singleKeyInfo?.displayName || singleKeyCode || 'Key'; + } + if (singleStatPosition) { + return getStatTypeLabel(singleStatPosition.statType ?? null); + } + if (singleGraphPosition) { + return `${getStatTypeLabel(singleGraphPosition.statType ?? null)} Graph`; + } + if (singleKnobPosition) return 'Knob'; + return ''; + }; + + const handleGroupRenameCommit = async (groupId: string, value: string) => { + const trimmed = value.trim(); + if (trimmed === '') return; + + const currentGroups = useLayerGroupStore.getState().layerGroups; + const currentModeGroups = currentGroups[selectedKeyType] || []; + const currentGroup = currentModeGroups.find( + (group) => group.id === groupId, + ); + if (!currentGroup || currentGroup.name === trimmed) return; + + try { + await renameLayerGroupById(selectedKeyType, groupId, trimmed); + } catch (error) { + console.error('Failed to rename group', error); + } + }; + + const handleRenameStartImpl = useRef<() => void>(() => {}); + // eslint-disable-next-line react-hooks/refs -- 안정 래퍼의 최신 이름 변경 콜백 유지 + handleRenameStartImpl.current = () => { + const current = getCurrentLayerName(); + setRenameValue(current || getCurrentDefaultTitle()); + setIsRenaming(true); + requestAnimationFrame(() => { + renameInputRef.current?.focus(); + renameInputRef.current?.select(); + }); + }; + const handleRenameStart = () => { + handleRenameStartImpl.current(); + }; + + const handleRenameCommit = async (value: string) => { + setIsRenaming(false); + + if (selectedGroupInfo) { + await handleGroupRenameCommit(selectedGroupInfo.id, value); + return; + } + + const trimmed = value.trim(); + const defaultTitle = getCurrentDefaultTitle(); + const newLayerName = + trimmed === defaultTitle || trimmed === '' ? null : trimmed; + + const selectedElement = + selectedElements.length === 1 ? selectedElements[0] : null; + const stableTarget = + selectedElement && selectedElement.type !== 'plugin' + ? { elementType: selectedElement.type, id: selectedElement.id } + : null; + if (stableTarget && isNativeElementId(stableTarget.id)) { + const target = { + ...stableTarget, + patch: { property: 'layerName', value: newLayerName }, + } as const; + try { + await patchElementLayerNameById( + target.elementType, + target.id, + target.patch.value, + ); + } catch (error) { + console.error('Failed to rename layer', error); + } + } + }; + + const handleRenameCancel = () => { + renameCancelledRef.current = true; + setIsRenaming(false); + }; + + const prevRenameSignalRef = useRef(renameRequestSignal); + useEffect(() => { + if (renameRequestSignal !== prevRenameSignalRef.current) { + prevRenameSignalRef.current = renameRequestSignal; + if ( + selectedGroupInfo || + singleKeyPosition || + singleStatPosition || + singleGraphPosition || + singleKnobPosition + ) { + setPanelMode('property'); + handleRenameStart(); + } + } + }, [ + renameRequestSignal, + selectedGroupInfo, + singleKeyPosition, + singleStatPosition, + singleGraphPosition, + singleKnobPosition, + setPanelMode, + ]); + + useEffect(() => { + setIsRenaming(false); + }, [selectedElements]); + + return { + isRenaming, + renameValue, + setRenameValue, + renameInputRef, + renameCancelledRef, + handleRenameStart, + handleRenameCommit, + handleRenameCancel, + }; +}; From 4e71c2c1dd41be686d1883a25baf9bbb819a66ac Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 07:15:25 +0900 Subject: [PATCH 014/245] =?UTF-8?q?refactor:=20=ED=94=84=EB=A1=9C=ED=8D=BC?= =?UTF-8?q?=ED=8B=B0=20=ED=8C=A8=EB=84=90=20=EA=B0=80=EC=8B=9C=EC=84=B1=20?= =?UTF-8?q?=EC=83=9D=EB=AA=85=EC=A3=BC=EA=B8=B0=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/main/Grid/PropertiesPanel.tsx | 232 ++--------------- .../usePropertiesPanelVisibility.ts | 234 ++++++++++++++++++ 2 files changed, 259 insertions(+), 207 deletions(-) create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/usePropertiesPanelVisibility.ts diff --git a/src/renderer/components/main/Grid/PropertiesPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel.tsx index 8b4d538bb..6f7671168 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel.tsx @@ -83,6 +83,7 @@ import { } from './PropertiesPanel/propertyPanelAdapters'; import { usePropertiesPanelRename } from './PropertiesPanel/usePropertiesPanelRename'; import { usePluginSettingsPanelController } from './PropertiesPanel/usePluginSettingsPanelController'; +import { usePropertiesPanelVisibility } from './PropertiesPanel/usePropertiesPanelVisibility'; // ============================================================================ // 메인 컴포넌트 Props @@ -206,34 +207,6 @@ const PropertiesPanel: React.FC = ({ (state) => state.setCanvasPanelMode, ); - // panelMode를 ref로도 유지 (useEffect에서 최신 값 참조용) - const panelModeRef = useRef(panelMode); - // eslint-disable-next-line react-hooks/refs -- 이벤트 정산의 최신 패널 모드 유지 - panelModeRef.current = panelMode; - - // 이전 선택 상태 추적 (선택 해제 감지용) - const prevHasSelectionRef = useRef(false); - - // 레이어 패널 내부에서 선택이 발생했는지 추적 (모드 전환 방지용) - const selectionFromLayerPanelRef = useRef(false); - - // 이전 키 타입 추적 (탭 전환 감지용) - const prevKeyTypeRef = useRef(selectedKeyType); - - // 탭 전환으로 인한 선택 해제인지 추적 - const keyTypeChangedRef = useRef(false); - - // 사용자가 명시적으로 패널을 닫았는지 추적 - const manuallyClosedRef = useRef(false); - - // selectedKeyType 변경 감지 (clearSelection보다 먼저 플래그 설정) - useEffect(() => { - if (prevKeyTypeRef.current !== selectedKeyType) { - keyTypeChangedRef.current = true; - prevKeyTypeRef.current = selectedKeyType; - } - }, [selectedKeyType]); - const activeTab = usePropertiesPanelStore( (state) => state.propertyPanelActiveTab, ); @@ -318,6 +291,7 @@ const PropertiesPanel: React.FC = ({ useEffect(() => { if (selectedKeyElements.length === 0) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- 선택 소실 시 로컬 피커 세션 초기화 setBatchCounterColorState('idle'); setBatchPickerFor((current) => (current === 'fill' ? null : current)); } @@ -336,6 +310,7 @@ const PropertiesPanel: React.FC = ({ useEffect(() => { const targetPosition = singleKeyPosition || singleStatPosition; if (targetPosition) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- 선택 대상의 편집 draft 동기화 setLocalState({ dx: targetPosition.dx, dy: targetPosition.dy, @@ -348,193 +323,36 @@ const PropertiesPanel: React.FC = ({ }, [singleKeyPosition, singleStatPosition]); useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- 선택 그래프의 입력 draft 동기화 setGraphClassNameDraft(singleGraphPosition?.className || ''); }, [selectedKeyType, singleGraphIndex, singleGraphPosition?.className]); - // 선택된 키가 변경될 때 패널 열기/닫기 - useEffect(() => { - const hasSelection = - selectedKeyElements.length > 0 || selectedElements.length > 0; - const hadSelection = prevHasSelectionRef.current; - - if (pluginSettingsPanel) { - prevHasSelectionRef.current = hasSelection; - return; - } - - if (hasSelection) { - // 열린 패널의 페이지는 sticky — 레이어 목록 표시 중 캔버스 클릭은 선택만 바꾸고 - // 편집(property) 진입은 더블클릭·목록 더블클릭·헤더 토글만 수행한다 (포토샵식) - if (!hadSelection) { - manuallyClosedRef.current = false; - if (!isPanelVisible) { - setPanelMode('property'); - setIsPanelVisible(true); - } - } else if (!isPanelVisible && !manuallyClosedRef.current) { - setPanelMode('property'); - setIsPanelVisible(true); - } - } else if (hadSelection) { - if (keyTypeChangedRef.current && isPanelVisible) { - setPanelMode('layer'); - } else if ( - isPanelVisible && - (selectionFromLayerPanelRef.current || panelModeRef.current === 'layer') - ) { - setPanelMode('layer'); - } else if (!manuallyClosedRef.current) { - setIsPanelVisible(false); - } - } - - prevHasSelectionRef.current = hasSelection; - selectionFromLayerPanelRef.current = false; - keyTypeChangedRef.current = false; - - setShowImagePicker(false); - setShowGraphImagePicker(false); - setShowBatchImagePicker(false); - // 배치 색상 draft는 피커를 열 때 첫 요소에서 한 번만 떠 온다. - // 열린 채로 선택이 바뀌면 옛 대상 색이 남아 다음 드래그가 그 값을 새 선택에 쓴다 - setBatchPickerFor(null); - closePage(); - }, [ - singleKeyIndex, - selectedKeyElements.length, - selectedElements, - isPanelVisible, - pluginSettingsPanel, - setIsPanelVisible, - setPanelMode, - closePage, - ]); - - // 빈 선택 폴백으로 레이어 목록이 표시되는 동안 내부 모드도 layer로 정규화 — - // property로 남아 있으면 다음 캔버스 클릭이 목록을 건너뛰고 편집으로 점프함 - // (플러그인 설정 패널 종료·설정 왕복 리마운트 경로 포함) - useEffect(() => { - if ( - isPanelVisible && - !pluginSettingsPanel && - panelMode === 'property' && - selectedKeyElements.length === 0 && - selectedElements.length === 0 - ) { - setPanelMode('layer'); - } - }, [ - isPanelVisible, - pluginSettingsPanel, - panelMode, - selectedKeyElements.length, - selectedElements, - setPanelMode, - ]); - - // 다중 선택 시 패널 자동 열기 - 개수는 native+plugin 합산 - useEffect(() => { - if ( - selectedBatchStyleElements.length + selectedPluginElements.length > 1 && - !isPanelVisible && - !manuallyClosedRef.current - ) { - setPanelMode('property'); - setIsPanelVisible(true); - } - }, [ - selectedBatchStyleElements.length, - selectedPluginElements.length, - isPanelVisible, - setIsPanelVisible, - setPanelMode, - ]); - - useEffect(() => { - if (pluginSettingsPanel) { - manuallyClosedRef.current = false; - setPanelMode('property'); - setIsPanelVisible(true); - } - }, [pluginSettingsPanel, setIsPanelVisible, setPanelMode]); - - // 레이어 뷰가 표시된 상태(선택 없음)에서 그리드 빈 공간 클릭 시 패널 닫기 - // panelMode가 property로 남아 있어도 선택이 없으면 레이어 뷰가 표시되므로 동일하게 닫음. - // 분리 창일 때는 접힘이 없다 - 여기서 스토어를 닫으면 도킹 뒤 패널이 접힌 채 돌아온다 - useEffect(() => { - const hasSelection = - selectedKeyElements.length > 0 || selectedElements.length > 0; - if (frameVariant === 'window' || !isPanelVisible || hasSelection) { - return undefined; - } - - const handleGridClick = (e: MouseEvent) => { - const target = e.target as HTMLElement; - - const gridContainer = target.closest('[data-grid-container]'); - if (!gridContainer) { - return; - } - - if ( - target.closest('[class*="properties-panel"]') || - target.closest('[class*="PropertiesPanel"]') || - target.closest('.absolute.right-0.top-0.bottom-0') - ) { - return; - } - - if ( - target.closest('[data-key-element]') || - target.closest('[data-plugin-element]') - ) { - return; - } - - setIsPanelVisible(false); - }; - - document.addEventListener('mousedown', handleGridClick); - return () => { - document.removeEventListener('mousedown', handleGridClick); - }; - }, [ - frameVariant, - isPanelVisible, - selectedKeyElements.length, - selectedKeyLikeElements.length, - selectedElements.length, - setIsPanelVisible, - ]); + const { selectionFromLayerPanelRef, handleTogglePanel } = + usePropertiesPanelVisibility({ + frameVariant, + isPanelVisible, + setIsPanelVisible, + panelMode, + setPanelMode, + selectedKeyType, + singleKeyIndex, + selectedElements, + selectedKeyElementsLength: selectedKeyElements.length, + selectedKeyLikeElementsLength: selectedKeyLikeElements.length, + selectedBatchStyleElementsLength: selectedBatchStyleElements.length, + selectedPluginElementsLength: selectedPluginElements.length, + pluginSettingsPanel, + closePage, + setShowImagePicker, + setShowGraphImagePicker, + setShowBatchImagePicker, + setBatchPickerFor, + }); // ============================================================================ // 핸들러 // ============================================================================ - const handleTogglePanelImpl = useRef<() => void>(() => {}); - // eslint-disable-next-line react-hooks/refs -- 안정 래퍼의 최신 토글 콜백 유지 - handleTogglePanelImpl.current = () => { - const willOpen = !isPanelVisible; - - if (willOpen) { - manuallyClosedRef.current = false; - setIsPanelVisible(true); - const hasSelection = selectedElements.length > 0; - if (!hasSelection) { - setPanelMode('layer'); - } - } else { - manuallyClosedRef.current = true; - setIsPanelVisible(false); - setShowImagePicker(false); - setShowGraphImagePicker(false); - setShowBatchImagePicker(false); - } - }; - const handleTogglePanel = () => { - handleTogglePanelImpl.current(); - }; - const handleToggleMode = () => { setPanelMode(panelMode === 'layer' ? 'property' : 'layer'); }; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/usePropertiesPanelVisibility.ts b/src/renderer/components/main/Grid/PropertiesPanel/usePropertiesPanelVisibility.ts new file mode 100644 index 000000000..af1714665 --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/usePropertiesPanelVisibility.ts @@ -0,0 +1,234 @@ +import { useEffect, useRef } from 'react'; + +type PanelMode = 'layer' | 'property'; + +interface UsePropertiesPanelVisibilityOptions { + frameVariant: 'inline' | 'window'; + isPanelVisible: boolean; + setIsPanelVisible: (visible: boolean) => void; + panelMode: PanelMode; + setPanelMode: (mode: PanelMode) => void; + selectedKeyType: string; + singleKeyIndex: number; + selectedElements: readonly unknown[]; + selectedKeyElementsLength: number; + selectedKeyLikeElementsLength: number; + selectedBatchStyleElementsLength: number; + selectedPluginElementsLength: number; + pluginSettingsPanel: object | null; + closePage: () => void; + setShowImagePicker: (visible: boolean) => void; + setShowGraphImagePicker: (visible: boolean) => void; + setShowBatchImagePicker: (visible: boolean) => void; + setBatchPickerFor: (target: null) => void; +} + +export const usePropertiesPanelVisibility = ({ + frameVariant, + isPanelVisible, + setIsPanelVisible, + panelMode, + setPanelMode, + selectedKeyType, + singleKeyIndex, + selectedElements, + selectedKeyElementsLength, + selectedKeyLikeElementsLength, + selectedBatchStyleElementsLength, + selectedPluginElementsLength, + pluginSettingsPanel, + closePage, + setShowImagePicker, + setShowGraphImagePicker, + setShowBatchImagePicker, + setBatchPickerFor, +}: UsePropertiesPanelVisibilityOptions) => { + const panelModeRef = useRef(panelMode); + // eslint-disable-next-line react-hooks/refs -- 이벤트 정산의 최신 패널 모드 유지 + panelModeRef.current = panelMode; + const prevHasSelectionRef = useRef(false); + const selectionFromLayerPanelRef = useRef(false); + const prevKeyTypeRef = useRef(selectedKeyType); + const keyTypeChangedRef = useRef(false); + const manuallyClosedRef = useRef(false); + + useEffect(() => { + if (prevKeyTypeRef.current !== selectedKeyType) { + keyTypeChangedRef.current = true; + prevKeyTypeRef.current = selectedKeyType; + } + }, [selectedKeyType]); + + useEffect(() => { + const hasSelection = + selectedKeyElementsLength > 0 || selectedElements.length > 0; + const hadSelection = prevHasSelectionRef.current; + + if (pluginSettingsPanel) { + prevHasSelectionRef.current = hasSelection; + return; + } + + if (hasSelection) { + // 열린 패널의 페이지는 sticky — 레이어 목록 표시 중 캔버스 클릭은 선택만 바꾸고 + // 편집(property) 진입은 더블클릭·목록 더블클릭·헤더 토글만 수행한다 (포토샵식) + if (!hadSelection) { + manuallyClosedRef.current = false; + if (!isPanelVisible) { + setPanelMode('property'); + setIsPanelVisible(true); + } + } else if (!isPanelVisible && !manuallyClosedRef.current) { + setPanelMode('property'); + setIsPanelVisible(true); + } + } else if (hadSelection) { + if (keyTypeChangedRef.current && isPanelVisible) { + setPanelMode('layer'); + } else if ( + isPanelVisible && + (selectionFromLayerPanelRef.current || panelModeRef.current === 'layer') + ) { + setPanelMode('layer'); + } else if (!manuallyClosedRef.current) { + setIsPanelVisible(false); + } + } + + prevHasSelectionRef.current = hasSelection; + selectionFromLayerPanelRef.current = false; + keyTypeChangedRef.current = false; + + setShowImagePicker(false); + setShowGraphImagePicker(false); + setShowBatchImagePicker(false); + // 배치 색상 draft는 피커를 열 때 첫 요소에서 한 번만 떠 온다. + // 열린 채로 선택이 바뀌면 옛 대상 색이 남아 다음 드래그가 그 값을 새 선택에 쓴다 + setBatchPickerFor(null); + closePage(); + }, [ + singleKeyIndex, + selectedKeyElementsLength, + selectedElements, + isPanelVisible, + pluginSettingsPanel, + setIsPanelVisible, + setPanelMode, + closePage, + setShowImagePicker, + setShowGraphImagePicker, + setShowBatchImagePicker, + setBatchPickerFor, + ]); + + useEffect(() => { + if ( + isPanelVisible && + !pluginSettingsPanel && + panelMode === 'property' && + selectedKeyElementsLength === 0 && + selectedElements.length === 0 + ) { + setPanelMode('layer'); + } + }, [ + isPanelVisible, + pluginSettingsPanel, + panelMode, + selectedKeyElementsLength, + selectedElements, + setPanelMode, + ]); + + useEffect(() => { + if ( + selectedBatchStyleElementsLength + selectedPluginElementsLength > 1 && + !isPanelVisible && + !manuallyClosedRef.current + ) { + setPanelMode('property'); + setIsPanelVisible(true); + } + }, [ + selectedBatchStyleElementsLength, + selectedPluginElementsLength, + isPanelVisible, + setIsPanelVisible, + setPanelMode, + ]); + + useEffect(() => { + if (pluginSettingsPanel) { + manuallyClosedRef.current = false; + setPanelMode('property'); + setIsPanelVisible(true); + } + }, [pluginSettingsPanel, setIsPanelVisible, setPanelMode]); + + useEffect(() => { + const hasSelection = + selectedKeyElementsLength > 0 || selectedElements.length > 0; + if (frameVariant === 'window' || !isPanelVisible || hasSelection) { + return undefined; + } + + const handleGridClick = (event: MouseEvent) => { + const target = event.target as HTMLElement; + const gridContainer = target.closest('[data-grid-container]'); + if (!gridContainer) return; + + if ( + target.closest('[class*="properties-panel"]') || + target.closest('[class*="PropertiesPanel"]') || + target.closest('.absolute.right-0.top-0.bottom-0') + ) { + return; + } + + if ( + target.closest('[data-key-element]') || + target.closest('[data-plugin-element]') + ) { + return; + } + + setIsPanelVisible(false); + }; + + document.addEventListener('mousedown', handleGridClick); + return () => { + document.removeEventListener('mousedown', handleGridClick); + }; + }, [ + frameVariant, + isPanelVisible, + selectedKeyElementsLength, + selectedKeyLikeElementsLength, + selectedElements.length, + setIsPanelVisible, + ]); + + const handleTogglePanelImpl = useRef<() => void>(() => {}); + // eslint-disable-next-line react-hooks/refs -- 안정 래퍼의 최신 토글 콜백 유지 + handleTogglePanelImpl.current = () => { + const willOpen = !isPanelVisible; + + if (willOpen) { + manuallyClosedRef.current = false; + setIsPanelVisible(true); + const hasSelection = selectedElements.length > 0; + if (!hasSelection) setPanelMode('layer'); + } else { + manuallyClosedRef.current = true; + setIsPanelVisible(false); + setShowImagePicker(false); + setShowGraphImagePicker(false); + setShowBatchImagePicker(false); + } + }; + const handleTogglePanel = () => { + handleTogglePanelImpl.current(); + }; + + return { selectionFromLayerPanelRef, handleTogglePanel }; +}; From 26fd9222645e1b0efde75be110541ec4500ce3a6 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 07:21:19 +0900 Subject: [PATCH 015/245] =?UTF-8?q?refactor:=20=EC=B0=BD=20=EC=A2=8C?= =?UTF-8?q?=ED=91=9C=EC=99=80=20=ED=8C=A8=EB=84=90=20bounds=20=EC=B1=85?= =?UTF-8?q?=EC=9E=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/state/app_state.rs | 984 +---------------- .../src/state/app_state/window_geometry.rs | 989 ++++++++++++++++++ 2 files changed, 1006 insertions(+), 967 deletions(-) create mode 100644 src-tauri/src/state/app_state/window_geometry.rs diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs index 7910a1abd..625e216b5 100644 --- a/src-tauri/src/state/app_state.rs +++ b/src-tauri/src/state/app_state.rs @@ -25,6 +25,23 @@ use tauri::{ use tauri_runtime_wry::wry::dpi::{LogicalPosition, LogicalSize, PhysicalPosition, PhysicalSize}; use tokio::sync::oneshot; +mod window_geometry; + +#[cfg(test)] +use window_geometry::{ + apply_panel_bounds_change, changed_panel_max_height, panel_bounds_from_sample, + panel_height_bounds, panel_position_beside_main, MonitorSpec, PanelBoundsPersistenceState, + PanelBoundsSample, +}; +use window_geometry::{ + compute_overlay_position, convert_physical_bounds_to_logical, defer_overlay_bounds, + defer_overlay_bounds_from_window, flush_deferred_overlay_bounds, main_window_content_origin, + main_window_logical_rect, panel_bounds_sample_from_window, panel_client_to_outer_position, + resolve_panel_window_layout, MonitorData, OverlayPosition, PanelBoundsChange, + PanelBoundsPersistenceController, PanelWindowLayout, +}; +pub use window_geometry::{LogicalPoint, LogicalRect, PanelDragContext}; + use super::{ history::{ HistoryAdmissionGate, HistoryAdmissionLease, HistoryBarrierLease, HistoryBarrierWaiter, @@ -228,66 +245,6 @@ struct ShutdownWatchdogState { stage: &'static str, } -#[derive(Debug, Clone, Copy, PartialEq)] -struct PanelBoundsSample { - position: PhysicalPosition, - position_scale_factor: f64, - size: PhysicalSize, - size_scale_factor: f64, - current_scale_factor: f64, -} - -#[derive(Debug, Clone, Copy)] -enum PanelBoundsChange { - Snapshot(PanelBoundsSample), - Moved(PhysicalPosition), - Resized(PhysicalSize), - ScaleFactorChanged { - position: Option>, - size: PhysicalSize, - scale_factor: f64, - }, -} - -impl PanelBoundsChange { - // 복원에 쓰는 값은 높이뿐이라 이동은 디스크로 가지 않는다. - // x/y는 store 호환을 위해 계속 기록만 되고 창 배치에는 쓰이지 않음 - fn changes_persisted_bounds(self) -> bool { - !matches!(self, Self::Moved(_)) - } -} - -#[derive(Default)] -struct PanelBoundsPersistenceState { - latest: Option, - window: Option, - applied_max_height: Option, - // 초기화로 발생한 resize가 비운 저장값을 되살리지 않게 하는 기본 높이 추적 - unpersisted_default_height: Option, - default_height_pending: bool, - session: u64, - generation: u64, - worker_running: bool, - // dirty는 워커가 처리할 변경이 남았는지, persist_dirty는 그중 저장까지 필요한지 - dirty: bool, - persist_dirty: bool, - active: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq)] -struct PanelBoundsPersistWork { - session: u64, - generation: u64, - sample: PanelBoundsSample, - persist: bool, -} - -struct PanelBoundsPersistenceController { - store: Arc, - state: Mutex, - persist_lock: Mutex<()>, -} - #[derive(Debug, Clone, Copy, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) enum FrontendLifecycleAction { @@ -5656,907 +5613,6 @@ fn disable_system_context_menu(window: &WebviewWindow) -> Result<()> { Ok(()) } -fn convert_physical_bounds_to_logical( - bounds: &OverlayBounds, - monitors: &MonitorData, -) -> Option { - if monitors.is_empty() { - return None; - } - - let center_x = bounds.x + bounds.width / 2.0; - let center_y = bounds.y + bounds.height / 2.0; - - let scale = monitors - .find_by_physical(center_x, center_y) - .map(|spec| spec.scale_factor) - .unwrap_or_else(|| monitors.fallback_scale()); - - scale_physical_bounds_to_logical(bounds, scale) -} - -#[derive(Clone)] -struct MonitorSpec { - logical_origin_x: f64, - logical_origin_y: f64, - logical_width: f64, - logical_height: f64, - physical_origin_x: f64, - physical_origin_y: f64, - physical_width: f64, - physical_height: f64, - scale_factor: f64, -} - -impl MonitorSpec { - fn from_monitor(monitor: Monitor) -> Option { - let scale = monitor.scale_factor(); - // 병리적 scale이 spec에 섞이면 환산이 조용히 깨진다 - if !monitor_scale_is_usable(scale) { - return None; - } - let work_area = monitor.work_area(); - let origin = work_area.position; - let size = work_area.size; - - let logical_origin = origin.to_logical::(scale); - let logical_size = size.to_logical::(scale); - - Some(Self { - logical_origin_x: logical_origin.x, - logical_origin_y: logical_origin.y, - logical_width: logical_size.width, - logical_height: logical_size.height, - physical_origin_x: origin.x as f64, - physical_origin_y: origin.y as f64, - physical_width: size.width as f64, - physical_height: size.height as f64, - scale_factor: scale, - }) - } - - fn matches(&self, other: &Self) -> bool { - (self.physical_origin_x - other.physical_origin_x).abs() < 0.5 - && (self.physical_origin_y - other.physical_origin_y).abs() < 0.5 - && (self.physical_width - other.physical_width).abs() < 0.5 - && (self.physical_height - other.physical_height).abs() < 0.5 - && (self.scale_factor - other.scale_factor).abs() < f64::EPSILON - } - - fn contains_physical(&self, x: f64, y: f64) -> bool { - x >= self.physical_origin_x - && x <= self.physical_origin_x + self.physical_width - && y >= self.physical_origin_y - && y <= self.physical_origin_y + self.physical_height - } - - /// 주어진 사각형과 이 모니터 work_area의 교차 영역 넓이 (logical px²) - fn intersection_area(&self, x: f64, y: f64, width: f64, height: f64) -> f64 { - let left = x.max(self.logical_origin_x); - let top = y.max(self.logical_origin_y); - let right = (x + width).min(self.logical_origin_x + self.logical_width); - let bottom = (y + height).min(self.logical_origin_y + self.logical_height); - (right - left).max(0.0) * (bottom - top).max(0.0) - } - - fn clamp(&self, x: f64, y: f64, width: f64, height: f64) -> OverlayPosition { - let max_x = self.logical_origin_x + (self.logical_width - width).max(0.0); - let max_y = self.logical_origin_y + (self.logical_height - height).max(0.0); - - OverlayPosition { - x: x.clamp(self.logical_origin_x, max_x), - y: y.clamp(self.logical_origin_y, max_y), - } - } -} - -#[derive(Default)] -struct MonitorData { - specs: Vec, - primary_index: Option, -} - -impl MonitorData { - #[cfg(not(target_os = "macos"))] - fn gather(app: &AppHandle) -> Self { - Self::gather_inner(app) - } - - /// macOS: available_monitors/primary_monitor의 Monitor 변환이 NSScreen(AppKit)을 - /// 호출 스레드에서 직접 접근함 → 메인 스레드 밖(async 커맨드 등)에서 호출 시 크래시 (#67) - /// run_on_main_thread는 메인 스레드에서 호출되면 인라인 실행되므로 데드락 없음 - #[cfg(target_os = "macos")] - fn gather(app: &AppHandle) -> Self { - let empty = Self::default(); - let (tx, rx) = std::sync::mpsc::channel(); - let app_handle = app.clone(); - if let Err(err) = app.run_on_main_thread(move || { - let _ = tx.send(Self::gather_inner(&app_handle)); - }) { - log::warn!("monitor gather: failed to dispatch to main thread: {err}"); - return empty; - } - match rx.recv_timeout(std::time::Duration::from_secs(3)) { - Ok(data) => data, - Err(err) => { - log::warn!("monitor gather: main thread result unavailable: {err}"); - empty - } - } - } - - fn gather_inner(app: &AppHandle) -> Self { - let mut specs: Vec = app - .available_monitors() - .ok() - .unwrap_or_default() - .into_iter() - .filter_map(MonitorSpec::from_monitor) - .collect(); - - let mut primary_index = None; - if let Ok(Some(primary)) = app.primary_monitor() { - if let Some(primary_spec) = MonitorSpec::from_monitor(primary) { - primary_index = specs.iter().position(|spec| spec.matches(&primary_spec)); - - if primary_index.is_none() { - specs.push(primary_spec); - primary_index = Some(specs.len() - 1); - } - } - } - - Self { - specs, - primary_index, - } - } - - fn is_empty(&self) -> bool { - self.specs.is_empty() - } - - fn primary_spec(&self) -> Option<&MonitorSpec> { - self.primary_index - .and_then(|idx| self.specs.get(idx)) - .or_else(|| self.specs.first()) - } - - fn fallback_scale(&self) -> f64 { - self.primary_spec() - .map(|spec| spec.scale_factor) - .unwrap_or(1.0) - } - - fn find_by_physical(&self, x: f64, y: f64) -> Option<&MonitorSpec> { - self.specs.iter().find(|spec| spec.contains_physical(x, y)) - } - - /// 주어진 사각형과 가장 많이 겹치는 모니터를 반환 - fn find_best_overlap(&self, x: f64, y: f64, width: f64, height: f64) -> Option<&MonitorSpec> { - self.specs - .iter() - .max_by(|a, b| { - a.intersection_area(x, y, width, height) - .partial_cmp(&b.intersection_area(x, y, width, height)) - .unwrap_or(std::cmp::Ordering::Equal) - }) - .filter(|spec| spec.intersection_area(x, y, width, height) > 0.0) - } - - fn first(&self) -> Option<&MonitorSpec> { - self.specs.first() - } -} - -#[derive(Clone, Copy, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PanelDragContext { - /// 메인 창 outer 사각형 - content 원점 실측 실패 시 근사 폴백 - pub main_frame: Option, - /// 메인 창 content(웹뷰) 원점 - 드래그 도크 존 판정 기준. - /// Windows 메인 창은 프레임리스+그림자(tao undecorated-shadow 인셋, 좌우 ≈8px·상단 0~1px)라 - /// outer와 어긋나고, 렌더러의 outerWidth-innerWidth는 WebView2에서 0이라 여기서 실측한다 - pub main_content_origin: Option, -} - -#[derive(Clone, Copy, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct LogicalPoint { - pub x: f64, - pub y: f64, -} - -/// 논리 좌표계 사각형 - 창 게터가 주는 physical 값을 scale로 나눈 도메인 -#[derive(Clone, Copy, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct LogicalRect { - x: f64, - y: f64, - width: f64, - height: f64, -} - -/// 메인 창의 현재 사각형을 logical로 읽는다. -/// 창 게터는 메인 스레드로 왕복하므로 panel_creation_lock 밖에서만 호출할 것. -/// outer 기준이라 Windows 프레임과 macOS 타이틀바가 포함되는데, 패널은 그 바깥에 붙는 게 맞다 -fn main_window_logical_rect(app: &AppHandle) -> Option { - let window = app.get_webview_window("main")?; - let scale = window - .scale_factor() - .ok() - .filter(|scale| monitor_scale_is_usable(*scale)) - .unwrap_or(1.0); - let position = window.outer_position().ok()?.to_logical::(scale); - let size = window.outer_size().ok()?.to_logical::(scale); - Some(LogicalRect { - x: position.x, - y: position.y, - width: size.width, - height: size.height, - }) -} - -// 렌더러의 드롭 좌표는 "커서 - client 기준 grab 오프셋" = 원하는 client 원점이다. -// 패널 창도 프레임리스+그림자라 Windows에선 outer가 client보다 인셋만큼 크다 - -// set_position(outer)에 그대로 꽂으면 콘텐츠가 그만큼 밀리므로 실측 인셋으로 보정한다. -// macOS는 인셋 0이라 무변화. 실측 실패 시 보정 없이 진행 -fn panel_client_to_outer_position(window: &WebviewWindow, x: f64, y: f64) -> LogicalPosition { - let inset = window - .scale_factor() - .ok() - .filter(|scale| monitor_scale_is_usable(*scale)) - .and_then(|scale| { - let outer = window.outer_position().ok()?.to_logical::(scale); - let inner = window.inner_position().ok()?.to_logical::(scale); - Some((inner.x - outer.x, inner.y - outer.y)) - }) - .unwrap_or((0.0, 0.0)); - LogicalPosition::new(x - inset.0, y - inset.1) -} - -/// 메인 창 content(웹뷰) 영역의 화면 논리 원점. -/// 렌더러 client 좌표 + 이 원점 = 화면 논리 좌표 (드래그 도크 존 판정에 사용) -fn main_window_content_origin(app: &AppHandle) -> Option { - let window = app.get_webview_window("main")?; - let scale = window - .scale_factor() - .ok() - .filter(|scale| monitor_scale_is_usable(*scale)) - .unwrap_or(1.0); - let position = window.inner_position().ok()?.to_logical::(scale); - Some(LogicalPoint { - x: position.x, - y: position.y, - }) -} - -/// 분리 패널을 메인 창 오른쪽에 여백을 두고 세로 중앙으로 붙인다. -/// 오른쪽 자리가 모자라면 왼쪽, 양쪽 다 모자라면 작업 영역 안으로 밀어 넣는다 -fn panel_position_beside_main( - main: &LogicalRect, - panel_height: f64, - work_area: &MonitorSpec, -) -> OverlayPosition { - let right_x = main.x + main.width + PANEL_BESIDE_GAP; - let left_x = main.x - PANEL_BESIDE_GAP - PANEL_WIDTH; - let fits_right = right_x + PANEL_WIDTH <= work_area.logical_origin_x + work_area.logical_width; - let fits_left = left_x >= work_area.logical_origin_x; - // 양쪽 다 안 들어가면 오른쪽 후보를 넘겨 clamp가 작업 영역 오른쪽 끝에 붙이게 둔다 - let x = if fits_right || !fits_left { - right_x - } else { - left_x - }; - // 패널이 화면보다 높으면 clamp가 위쪽 정렬로 떨어뜨린다 - let y = main.y + (main.height - panel_height) / 2.0; - work_area.clamp(x, y, PANEL_WIDTH, panel_height) -} - -struct PanelWindowLayout { - position: Option, - height: f64, - min_height: f64, - max_height: f64, -} - -// 작업 영역이 하한보다 좁으면 하한을 화면에 맞춰 낮춤 - 그러지 않으면 창 아래쪽이 -// 화면 밖으로 나가 리사이즈 가장자리에 손이 닿지 않는다 -fn panel_height_bounds(work_area_height: Option) -> (f64, f64) { - let Some(work_area_height) = - work_area_height.filter(|height| height.is_finite() && *height > 0.0) - else { - return (PANEL_MIN_HEIGHT, PANEL_FALLBACK_MAX_HEIGHT); - }; - let max_height = work_area_height * PANEL_MAX_HEIGHT_RATIO; - (PANEL_MIN_HEIGHT.min(max_height), max_height) -} - -fn resolve_panel_window_layout( - stored_bounds: Option, - main_rect: Option, - monitors: &MonitorData, - fallback_height: Option, -) -> PanelWindowLayout { - // 기준 화면은 메인 창이 놓인 모니터 - 패널이 그 옆에 붙으니 높이 한계도 같은 화면을 따른다 - let target_monitor = main_rect - .and_then(|rect| monitors.find_best_overlap(rect.x, rect.y, rect.width, rect.height)) - .or_else(|| monitors.primary_spec()); - let (min_height, max_height) = - panel_height_bounds(target_monitor.map(|monitor| monitor.logical_height)); - // 저장된 높이가 없으면 메인 창 높이를 기본값으로 (프로그램 높이 동기) - let requested_height = stored_bounds - .map(|bounds| bounds.height) - .or(fallback_height) - .unwrap_or(PANEL_INITIAL_HEIGHT); - let height = requested_height.clamp(min_height, max_height); - // 위치는 열 때마다 메인 창 옆으로 다시 잡는다 - 저장된 x/y는 이동 기록으로만 남고 복원에 쓰지 않음. - // 메인 좌표를 못 읽으면 OS 기본 배치에 맡긴다 - let position = main_rect - .zip(target_monitor) - .map(|(rect, monitor)| panel_position_beside_main(&rect, height, monitor)); - - PanelWindowLayout { - position, - height, - min_height, - max_height, - } -} - -fn changed_panel_max_height(previous: Option, next: f64) -> Option { - if !next.is_finite() || next <= 0.0 { - return None; - } - if previous.is_some_and(|value| (value - next).abs() < 0.5) { - return None; - } - Some(next) -} - -fn panel_bounds_sample_from_window(window: &WebviewWindow) -> Result { - let scale_factor = window.scale_factor().unwrap_or(1.0); - Ok(PanelBoundsSample { - position: window.outer_position()?, - position_scale_factor: scale_factor, - size: window.inner_size()?, - size_scale_factor: scale_factor, - current_scale_factor: scale_factor, - }) -} - -fn valid_panel_scale_factor(scale_factor: f64) -> f64 { - if scale_factor.is_finite() && scale_factor > 0.0 { - scale_factor - } else { - 1.0 - } -} - -fn panel_bounds_from_sample(sample: PanelBoundsSample) -> PanelBounds { - let position = sample - .position - .to_logical::(valid_panel_scale_factor(sample.position_scale_factor)); - let size = sample - .size - .to_logical::(valid_panel_scale_factor(sample.size_scale_factor)); - PanelBounds { - x: position.x, - y: position.y, - height: size.height, - } -} - -fn apply_panel_bounds_change(sample: &mut PanelBoundsSample, change: PanelBoundsChange) { - match change { - PanelBoundsChange::Snapshot(snapshot) => *sample = snapshot, - PanelBoundsChange::Moved(position) => { - sample.position = position; - sample.position_scale_factor = sample.current_scale_factor; - } - PanelBoundsChange::Resized(size) => { - sample.size = size; - sample.size_scale_factor = sample.current_scale_factor; - } - PanelBoundsChange::ScaleFactorChanged { - position, - size, - scale_factor, - } => { - let scale_factor = valid_panel_scale_factor(scale_factor); - sample.current_scale_factor = scale_factor; - if let Some(position) = position { - sample.position = position; - sample.position_scale_factor = scale_factor; - } - sample.size = size; - sample.size_scale_factor = scale_factor; - } - } -} - -impl PanelBoundsPersistenceController { - fn new(store: Arc) -> Self { - Self { - store, - state: Mutex::new(PanelBoundsPersistenceState::default()), - persist_lock: Mutex::new(()), - } - } - - fn attach(&self, window: &WebviewWindow, max_height: f64) -> u64 { - let latest = panel_bounds_sample_from_window(window).ok(); - let mut state = self.state.lock(); - state.session = state.session.wrapping_add(1); - state.latest = latest; - state.window = Some(window.clone()); - state.applied_max_height = Some(max_height); - state.unpersisted_default_height = None; - state.default_height_pending = false; - state.generation = state.generation.wrapping_add(1); - state.dirty = false; - state.persist_dirty = false; - state.active = true; - state.session - } - - fn record_event(self: &Arc, session: u64, change: PanelBoundsChange) { - let should_spawn = { - let mut state = self.state.lock(); - if !state.active || state.session != session { - return; - } - if state.latest.is_none() { - state.latest = state - .window - .as_ref() - .and_then(|window| panel_bounds_sample_from_window(window).ok()); - } - Self::record_change(&mut state, session, change) - }; - if should_spawn { - self.spawn_worker(); - } - } - - fn record_change( - state: &mut PanelBoundsPersistenceState, - session: u64, - change: PanelBoundsChange, - ) -> bool { - if !state.active || state.session != session { - return false; - } - let persist = change.changes_persisted_bounds(); - if let PanelBoundsChange::Snapshot(snapshot) = change { - state.latest = Some(snapshot); - } else { - let Some(latest) = state.latest.as_mut() else { - return false; - }; - apply_panel_bounds_change(latest, change); - } - let persist = Self::should_persist_change(state, persist); - Self::mark_dirty(state, persist) - } - - fn should_persist_change(state: &mut PanelBoundsPersistenceState, persist: bool) -> bool { - if !persist { - return false; - } - let Some(default_height) = state.unpersisted_default_height else { - return true; - }; - let Some(sample) = state.latest else { - state.unpersisted_default_height = None; - state.default_height_pending = false; - return true; - }; - let height = panel_bounds_from_sample(sample).height; - if (height - default_height).abs() < 0.5 { - state.default_height_pending = false; - return false; - } - if state.default_height_pending { - return false; - } - state.unpersisted_default_height = None; - true - } - - // 이동만 바뀌어도 워커는 깨운다 - 모니터가 바뀌면 높이 한계를 다시 걸어야 하기 때문 - fn mark_dirty(state: &mut PanelBoundsPersistenceState, persist: bool) -> bool { - state.generation = state.generation.wrapping_add(1); - state.dirty = true; - state.persist_dirty |= persist; - let should_spawn = !state.worker_running; - state.worker_running = true; - should_spawn - } - - fn take_dirty_work(state: &mut PanelBoundsPersistenceState) -> Option { - let sample = state.latest.filter(|_| state.active && state.dirty)?; - let persist = state.persist_dirty; - state.dirty = false; - state.persist_dirty = false; - Some(PanelBoundsPersistWork { - session: state.session, - generation: state.generation, - sample, - persist, - }) - } - - fn work_is_current(state: &PanelBoundsPersistenceState, work: &PanelBoundsPersistWork) -> bool { - state.active && state.session == work.session && state.generation == work.generation - } - - fn restore_failed_work( - state: &mut PanelBoundsPersistenceState, - work: &PanelBoundsPersistWork, - ) -> bool { - if !Self::work_is_current(state, work) { - return false; - } - state.dirty = true; - state.persist_dirty |= work.persist; - true - } - - fn persist_sample(&self, sample: PanelBoundsSample) -> Result<()> { - let bounds = panel_bounds_from_sample(sample); - self.store - .update_deferred(move |data| { - data.panel_bounds = Some(bounds); - }) - .context("failed to capture settled panel bounds")?; - self.store - .flush() - .context("failed to flush settled panel bounds") - } - - fn clear_saved_bounds(&self, layout: Option<&PanelWindowLayout>) -> Result<()> { - let _persist_guard = self.persist_lock.lock(); - self.store - .update_deferred(|data| data.panel_bounds = None) - .context("failed to clear saved panel bounds")?; - self.store - .flush() - .context("failed to flush cleared panel bounds")?; - - let mut state = self.state.lock(); - let default_height = layout.map(|value| value.height); - let current_height = state - .latest - .map(panel_bounds_from_sample) - .map(|value| value.height); - state.unpersisted_default_height = default_height; - state.default_height_pending = default_height - .zip(current_height) - .is_none_or(|(default, current)| (default - current).abs() >= 0.5); - state.applied_max_height = layout.map(|value| value.max_height); - state.generation = state.generation.wrapping_add(1); - state.dirty = false; - state.persist_dirty = false; - Ok(()) - } - - fn persist_worker_work(&self, work: PanelBoundsPersistWork) -> Result { - // 이동만 바뀐 구간은 디스크를 건드리지 않는다 - 저장 값은 높이뿐이라 쓸 이유가 없다 - if !work.persist { - return Ok(false); - } - let _persist_guard = self.persist_lock.lock(); - if !Self::work_is_current(&self.state.lock(), &work) { - return Ok(false); - } - self.persist_sample(work.sample)?; - Ok(true) - } - - fn spawn_worker(self: &Arc) { - let controller = Arc::clone(self); - tauri::async_runtime::spawn(async move { - loop { - tokio::time::sleep(Duration::from_millis(PANEL_BOUNDS_DEBOUNCE_MS)).await; - let (work, window) = { - let mut state = controller.state.lock(); - let Some(work) = Self::take_dirty_work(&mut state) else { - state.worker_running = false; - return; - }; - (work, state.window.clone()) - }; - - let persist_result = controller.persist_worker_work(work); - if let Err(err) = &persist_result { - log::warn!("failed to persist settled panel bounds: {err:#}"); - } - - if let Some(window) = window { - let constraint_controller = Arc::clone(&controller); - let constraint_window = window.clone(); - if let Err(err) = window.app_handle().run_on_main_thread(move || { - if let Err(err) = constraint_controller.apply_monitor_constraints( - &constraint_window, - work.session, - work.generation, - ) { - log::warn!("failed to update settled panel monitor constraints: {err}"); - } - }) { - log::warn!("failed to schedule settled panel constraints: {err}"); - } - } - - let mut state = controller.state.lock(); - if persist_result.is_err() && Self::restore_failed_work(&mut state, &work) { - state.worker_running = false; - return; - } - if !state.active || !state.dirty { - state.worker_running = false; - return; - } - } - }); - } - - fn apply_monitor_constraints( - &self, - window: &WebviewWindow, - session: u64, - generation: u64, - ) -> Result<()> { - let Some((min_height, monitor_max_height)) = window - .current_monitor()? - .and_then(MonitorSpec::from_monitor) - .map(|monitor| panel_height_bounds(Some(monitor.logical_height))) - else { - return Ok(()); - }; - let max_height = { - let state = self.state.lock(); - if !state.active || state.session != session || state.generation != generation { - return Ok(()); - } - changed_panel_max_height(state.applied_max_height, monitor_max_height) - }; - let Some(max_height) = max_height else { - return Ok(()); - }; - // 좁은 모니터로 옮겨가면 하한도 함께 내려야 창이 화면 밖으로 나가지 않음 - window.set_min_size(Some(tauri::Size::Logical(tauri::LogicalSize::new( - PANEL_WIDTH, - min_height, - ))))?; - window.set_max_size(Some(tauri::Size::Logical(tauri::LogicalSize::new( - PANEL_WIDTH, - max_height, - ))))?; - let mut state = self.state.lock(); - if state.active && state.session == session && state.generation == generation { - state.applied_max_height = Some(max_height); - } - Ok(()) - } - - fn flush_now(&self, window: &WebviewWindow) -> Result<()> { - let _persist_guard = self.persist_lock.lock(); - let generation_before_sample = self.state.lock().generation; - let sampled = panel_bounds_sample_from_window(window); - let persisted = - Self::flush_samples(&self.state, generation_before_sample, sampled, |sample| { - self.persist_sample(sample) - })?; - if !persisted { - self.store - .flush() - .context("failed to flush store with cleared panel bounds")?; - } - Ok(()) - } - - fn flush_samples( - state_mutex: &Mutex, - generation_before_sample: u64, - sampled: Result, - mut persist: impl FnMut(PanelBoundsSample) -> Result<()>, - ) -> Result { - let mut work = { - let mut state = state_mutex.lock(); - let sampled = match sampled { - Ok(sample) => sample, - Err(error) => state.latest.ok_or(error)?, - }; - let sample = if state.generation != generation_before_sample { - state.latest.unwrap_or(sampled) - } else { - sampled - }; - state.latest = Some(sample); - state.generation = state.generation.wrapping_add(1); - state.dirty = false; - state.persist_dirty = false; - let persist = Self::should_persist_change(&mut state, true); - PanelBoundsPersistWork { - session: state.session, - generation: state.generation, - sample, - persist, - } - }; - let mut persisted = false; - - loop { - if work.persist { - if let Err(error) = persist(work.sample) { - Self::restore_failed_work(&mut state_mutex.lock(), &work); - return Err(error); - } - persisted = true; - } - let next = { - let mut state = state_mutex.lock(); - let Some(mut next) = Self::take_dirty_work(&mut state) else { - return Ok(persisted); - }; - next.persist = Self::should_persist_change(&mut state, true); - next - }; - work = next; - } - } - - fn deactivate(&self, session: u64) { - let mut state = self.state.lock(); - Self::deactivate_state(&mut state, session); - } - - fn deactivate_state(state: &mut PanelBoundsPersistenceState, session: u64) -> bool { - if state.session != session { - return false; - } - state.active = false; - state.window = None; - state.applied_max_height = None; - state.unpersisted_default_height = None; - state.default_height_pending = false; - state.generation = state.generation.wrapping_add(1); - state.dirty = false; - state.persist_dirty = false; - true - } -} - -fn compute_overlay_position( - bounds: &OverlayBounds, - had_stored_bounds: bool, - monitors: &MonitorData, -) -> OverlayPosition { - // 최소 가시 면적 — 오버레이 전체 면적의 25% 또는 100×100 중 작은 값 - let min_visible_area = (bounds.width * bounds.height * 0.25).min(100.0 * 100.0); - - if monitors.is_empty() { - return if had_stored_bounds { - OverlayPosition { - x: bounds.x, - y: bounds.y, - } - } else { - OverlayPosition { - x: OVERLAY_MARGIN, - y: OVERLAY_MARGIN, - } - }; - } - - let fallback = monitors - .primary_spec() - .cloned() - .or_else(|| monitors.first().cloned()); - - let Some(fallback_spec) = fallback else { - return OverlayPosition { - x: bounds.x, - y: bounds.y, - }; - }; - - // 저장된 위치가 없으면 기본 위치로 배치 (clamp 적용) - if !had_stored_bounds { - let base_x = fallback_spec.logical_origin_x + fallback_spec.logical_width - - bounds.width - - OVERLAY_MARGIN; - let base_y = fallback_spec.logical_origin_y + fallback_spec.logical_height - - bounds.height - - OVERLAY_MARGIN; - return fallback_spec.clamp(base_x, base_y, bounds.width, bounds.height); - } - - // 저장된 bounds와 가장 많이 겹치는 모니터 탐색 - if let Some(best) = monitors.find_best_overlap(bounds.x, bounds.y, bounds.width, bounds.height) - { - let area = best.intersection_area(bounds.x, bounds.y, bounds.width, bounds.height); - if area >= min_visible_area { - // 충분히 보이므로 저장 좌표 그대로 복원 - return OverlayPosition { - x: bounds.x, - y: bounds.y, - }; - } - // 겹침이 부족하면 해당 모니터에 clamp - return best.clamp(bounds.x, bounds.y, bounds.width, bounds.height); - } - - // 어떤 모니터와도 겹치지 않음 — fallback 모니터에 clamp - fallback_spec.clamp(bounds.x, bounds.y, bounds.width, bounds.height) -} - -fn defer_overlay_bounds_from_window( - window: &WebviewWindow, - store: &Arc, - generation: &Arc, -) -> Result<()> { - // scale 조회가 실패하면 1.0으로 때우지 않는다 - physical 값이 logical 라벨로 굳는다 - let scale_factor = window.scale_factor()?; - let position = window.outer_position()?.to_logical::(scale_factor); - let size = window.outer_size()?.to_logical::(scale_factor); - - defer_overlay_bounds( - store, - generation, - OverlayBounds { - x: position.x, - y: position.y, - width: size.width, - height: size.height, - }, - None, - ) -} - -/// 모든 호출부는 logical 사각형만 넘긴다 - `apply_overlay_frame` 반환값, -/// `MonitorSpec`의 logical 필드, `to_logical(scale)` 산출값이 전부다. -/// 따라서 `overlay_bounds_are_logical = true`는 참인 단언이다. -/// 마커를 인자화해 false를 보존하면 다음 세션의 ensure_overlay_window가 -/// x/y/width/height 전부를 scale로 다시 나눠 이중 환산이 발생한다 -/// (신규 설치 후 위치 초기화, 창 드래그 경로가 즉시 깨진다) -fn defer_overlay_bounds( - store: &Arc, - generation: &Arc, - bounds: OverlayBounds, - content_top_offset: Option, -) -> Result<()> { - store.update_deferred(move |state| { - state.overlay_bounds = Some(bounds); - state.overlay_bounds_are_logical = true; - if let Some(offset) = content_top_offset { - state.overlay_last_content_top_offset = Some(offset); - } - })?; - let scheduled_generation = generation.fetch_add(1, Ordering::SeqCst).wrapping_add(1); - - let store = Arc::clone(store); - let generation = Arc::clone(generation); - tauri::async_runtime::spawn(async move { - tokio::time::sleep(Duration::from_millis(OVERLAY_BOUNDS_DEBOUNCE_MS)).await; - if generation.load(Ordering::SeqCst) != scheduled_generation { - return; - } - if let Err(err) = store.flush() { - log::warn!("failed to flush debounced overlay bounds: {err}"); - } - }); - - Ok(()) -} - -fn flush_deferred_overlay_bounds(store: &Arc, generation: &Arc) -> Result<()> { - generation.fetch_add(1, Ordering::SeqCst); - store.flush() -} - struct KeyboardDaemonTask { generation: u64, running: Arc, @@ -6590,11 +5646,5 @@ impl Drop for KeyboardDaemonTask { } } -#[derive(Clone, Copy)] -struct OverlayPosition { - x: f64, - y: f64, -} - #[cfg(test)] mod tests; diff --git a/src-tauri/src/state/app_state/window_geometry.rs b/src-tauri/src/state/app_state/window_geometry.rs new file mode 100644 index 000000000..197936f3c --- /dev/null +++ b/src-tauri/src/state/app_state/window_geometry.rs @@ -0,0 +1,989 @@ +use super::*; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(super) struct PanelBoundsSample { + pub(super) position: PhysicalPosition, + pub(super) position_scale_factor: f64, + pub(super) size: PhysicalSize, + pub(super) size_scale_factor: f64, + pub(super) current_scale_factor: f64, +} + +#[derive(Debug, Clone, Copy)] +pub(super) enum PanelBoundsChange { + Snapshot(PanelBoundsSample), + Moved(PhysicalPosition), + Resized(PhysicalSize), + ScaleFactorChanged { + position: Option>, + size: PhysicalSize, + scale_factor: f64, + }, +} + +impl PanelBoundsChange { + // 복원에 쓰는 값은 높이뿐이라 이동은 디스크로 가지 않는다. + // x/y는 store 호환을 위해 계속 기록만 되고 창 배치에는 쓰이지 않음 + pub(super) fn changes_persisted_bounds(self) -> bool { + !matches!(self, Self::Moved(_)) + } +} + +#[derive(Default)] +pub(super) struct PanelBoundsPersistenceState { + pub(super) latest: Option, + pub(super) window: Option, + pub(super) applied_max_height: Option, + // 초기화로 발생한 resize가 비운 저장값을 되살리지 않게 하는 기본 높이 추적 + pub(super) unpersisted_default_height: Option, + pub(super) default_height_pending: bool, + pub(super) session: u64, + pub(super) generation: u64, + pub(super) worker_running: bool, + // dirty는 워커가 처리할 변경이 남았는지, persist_dirty는 그중 저장까지 필요한지 + pub(super) dirty: bool, + pub(super) persist_dirty: bool, + pub(super) active: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(super) struct PanelBoundsPersistWork { + pub(super) session: u64, + pub(super) generation: u64, + pub(super) sample: PanelBoundsSample, + pub(super) persist: bool, +} + +pub(super) struct PanelBoundsPersistenceController { + store: Arc, + state: Mutex, + persist_lock: Mutex<()>, +} + +pub(super) fn convert_physical_bounds_to_logical( + bounds: &OverlayBounds, + monitors: &MonitorData, +) -> Option { + if monitors.is_empty() { + return None; + } + + let center_x = bounds.x + bounds.width / 2.0; + let center_y = bounds.y + bounds.height / 2.0; + + let scale = monitors + .find_by_physical(center_x, center_y) + .map(|spec| spec.scale_factor) + .unwrap_or_else(|| monitors.fallback_scale()); + + scale_physical_bounds_to_logical(bounds, scale) +} + +#[derive(Clone)] +pub(super) struct MonitorSpec { + pub(super) logical_origin_x: f64, + pub(super) logical_origin_y: f64, + pub(super) logical_width: f64, + pub(super) logical_height: f64, + pub(super) physical_origin_x: f64, + pub(super) physical_origin_y: f64, + pub(super) physical_width: f64, + pub(super) physical_height: f64, + pub(super) scale_factor: f64, +} + +impl MonitorSpec { + pub(super) fn from_monitor(monitor: Monitor) -> Option { + let scale = monitor.scale_factor(); + // 병리적 scale이 spec에 섞이면 환산이 조용히 깨진다 + if !monitor_scale_is_usable(scale) { + return None; + } + let work_area = monitor.work_area(); + let origin = work_area.position; + let size = work_area.size; + + let logical_origin = origin.to_logical::(scale); + let logical_size = size.to_logical::(scale); + + Some(Self { + logical_origin_x: logical_origin.x, + logical_origin_y: logical_origin.y, + logical_width: logical_size.width, + logical_height: logical_size.height, + physical_origin_x: origin.x as f64, + physical_origin_y: origin.y as f64, + physical_width: size.width as f64, + physical_height: size.height as f64, + scale_factor: scale, + }) + } + + pub(super) fn matches(&self, other: &Self) -> bool { + (self.physical_origin_x - other.physical_origin_x).abs() < 0.5 + && (self.physical_origin_y - other.physical_origin_y).abs() < 0.5 + && (self.physical_width - other.physical_width).abs() < 0.5 + && (self.physical_height - other.physical_height).abs() < 0.5 + && (self.scale_factor - other.scale_factor).abs() < f64::EPSILON + } + + pub(super) fn contains_physical(&self, x: f64, y: f64) -> bool { + x >= self.physical_origin_x + && x <= self.physical_origin_x + self.physical_width + && y >= self.physical_origin_y + && y <= self.physical_origin_y + self.physical_height + } + + /// 주어진 사각형과 이 모니터 work_area의 교차 영역 넓이 (logical px²) + pub(super) fn intersection_area(&self, x: f64, y: f64, width: f64, height: f64) -> f64 { + let left = x.max(self.logical_origin_x); + let top = y.max(self.logical_origin_y); + let right = (x + width).min(self.logical_origin_x + self.logical_width); + let bottom = (y + height).min(self.logical_origin_y + self.logical_height); + (right - left).max(0.0) * (bottom - top).max(0.0) + } + + pub(super) fn clamp(&self, x: f64, y: f64, width: f64, height: f64) -> OverlayPosition { + let max_x = self.logical_origin_x + (self.logical_width - width).max(0.0); + let max_y = self.logical_origin_y + (self.logical_height - height).max(0.0); + + OverlayPosition { + x: x.clamp(self.logical_origin_x, max_x), + y: y.clamp(self.logical_origin_y, max_y), + } + } +} + +#[derive(Default)] +pub(super) struct MonitorData { + pub(super) specs: Vec, + pub(super) primary_index: Option, +} + +impl MonitorData { + #[cfg(not(target_os = "macos"))] + pub(super) fn gather(app: &AppHandle) -> Self { + Self::gather_inner(app) + } + + /// macOS: available_monitors/primary_monitor의 Monitor 변환이 NSScreen(AppKit)을 + /// 호출 스레드에서 직접 접근함 → 메인 스레드 밖(async 커맨드 등)에서 호출 시 크래시 (#67) + /// run_on_main_thread는 메인 스레드에서 호출되면 인라인 실행되므로 데드락 없음 + #[cfg(target_os = "macos")] + pub(super) fn gather(app: &AppHandle) -> Self { + let empty = Self::default(); + let (tx, rx) = std::sync::mpsc::channel(); + let app_handle = app.clone(); + if let Err(err) = app.run_on_main_thread(move || { + let _ = tx.send(Self::gather_inner(&app_handle)); + }) { + log::warn!("monitor gather: failed to dispatch to main thread: {err}"); + return empty; + } + match rx.recv_timeout(std::time::Duration::from_secs(3)) { + Ok(data) => data, + Err(err) => { + log::warn!("monitor gather: main thread result unavailable: {err}"); + empty + } + } + } + + pub(super) fn gather_inner(app: &AppHandle) -> Self { + let mut specs: Vec = app + .available_monitors() + .ok() + .unwrap_or_default() + .into_iter() + .filter_map(MonitorSpec::from_monitor) + .collect(); + + let mut primary_index = None; + if let Ok(Some(primary)) = app.primary_monitor() { + if let Some(primary_spec) = MonitorSpec::from_monitor(primary) { + primary_index = specs.iter().position(|spec| spec.matches(&primary_spec)); + + if primary_index.is_none() { + specs.push(primary_spec); + primary_index = Some(specs.len() - 1); + } + } + } + + Self { + specs, + primary_index, + } + } + + pub(super) fn is_empty(&self) -> bool { + self.specs.is_empty() + } + + pub(super) fn primary_spec(&self) -> Option<&MonitorSpec> { + self.primary_index + .and_then(|idx| self.specs.get(idx)) + .or_else(|| self.specs.first()) + } + + pub(super) fn fallback_scale(&self) -> f64 { + self.primary_spec() + .map(|spec| spec.scale_factor) + .unwrap_or(1.0) + } + + pub(super) fn find_by_physical(&self, x: f64, y: f64) -> Option<&MonitorSpec> { + self.specs.iter().find(|spec| spec.contains_physical(x, y)) + } + + /// 주어진 사각형과 가장 많이 겹치는 모니터를 반환 + pub(super) fn find_best_overlap( + &self, + x: f64, + y: f64, + width: f64, + height: f64, + ) -> Option<&MonitorSpec> { + self.specs + .iter() + .max_by(|a, b| { + a.intersection_area(x, y, width, height) + .partial_cmp(&b.intersection_area(x, y, width, height)) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .filter(|spec| spec.intersection_area(x, y, width, height) > 0.0) + } + + pub(super) fn first(&self) -> Option<&MonitorSpec> { + self.specs.first() + } +} + +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PanelDragContext { + /// 메인 창 outer 사각형 - content 원점 실측 실패 시 근사 폴백 + pub main_frame: Option, + /// 메인 창 content(웹뷰) 원점 - 드래그 도크 존 판정 기준. + /// Windows 메인 창은 프레임리스+그림자(tao undecorated-shadow 인셋, 좌우 ≈8px·상단 0~1px)라 + /// outer와 어긋나고, 렌더러의 outerWidth-innerWidth는 WebView2에서 0이라 여기서 실측한다 + pub main_content_origin: Option, +} + +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LogicalPoint { + pub x: f64, + pub y: f64, +} + +/// 논리 좌표계 사각형 - 창 게터가 주는 physical 값을 scale로 나눈 도메인 +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LogicalRect { + pub(super) x: f64, + pub(super) y: f64, + pub(super) width: f64, + pub(super) height: f64, +} + +/// 메인 창의 현재 사각형을 logical로 읽는다. +/// 창 게터는 메인 스레드로 왕복하므로 panel_creation_lock 밖에서만 호출할 것. +/// outer 기준이라 Windows 프레임과 macOS 타이틀바가 포함되는데, 패널은 그 바깥에 붙는 게 맞다 +pub(super) fn main_window_logical_rect(app: &AppHandle) -> Option { + let window = app.get_webview_window("main")?; + let scale = window + .scale_factor() + .ok() + .filter(|scale| monitor_scale_is_usable(*scale)) + .unwrap_or(1.0); + let position = window.outer_position().ok()?.to_logical::(scale); + let size = window.outer_size().ok()?.to_logical::(scale); + Some(LogicalRect { + x: position.x, + y: position.y, + width: size.width, + height: size.height, + }) +} + +// 렌더러의 드롭 좌표는 "커서 - client 기준 grab 오프셋" = 원하는 client 원점이다. +// 패널 창도 프레임리스+그림자라 Windows에선 outer가 client보다 인셋만큼 크다 - +// set_position(outer)에 그대로 꽂으면 콘텐츠가 그만큼 밀리므로 실측 인셋으로 보정한다. +// macOS는 인셋 0이라 무변화. 실측 실패 시 보정 없이 진행 +pub(super) fn panel_client_to_outer_position( + window: &WebviewWindow, + x: f64, + y: f64, +) -> LogicalPosition { + let inset = window + .scale_factor() + .ok() + .filter(|scale| monitor_scale_is_usable(*scale)) + .and_then(|scale| { + let outer = window.outer_position().ok()?.to_logical::(scale); + let inner = window.inner_position().ok()?.to_logical::(scale); + Some((inner.x - outer.x, inner.y - outer.y)) + }) + .unwrap_or((0.0, 0.0)); + LogicalPosition::new(x - inset.0, y - inset.1) +} + +/// 메인 창 content(웹뷰) 영역의 화면 논리 원점. +/// 렌더러 client 좌표 + 이 원점 = 화면 논리 좌표 (드래그 도크 존 판정에 사용) +pub(super) fn main_window_content_origin(app: &AppHandle) -> Option { + let window = app.get_webview_window("main")?; + let scale = window + .scale_factor() + .ok() + .filter(|scale| monitor_scale_is_usable(*scale)) + .unwrap_or(1.0); + let position = window.inner_position().ok()?.to_logical::(scale); + Some(LogicalPoint { + x: position.x, + y: position.y, + }) +} + +/// 분리 패널을 메인 창 오른쪽에 여백을 두고 세로 중앙으로 붙인다. +/// 오른쪽 자리가 모자라면 왼쪽, 양쪽 다 모자라면 작업 영역 안으로 밀어 넣는다 +pub(super) fn panel_position_beside_main( + main: &LogicalRect, + panel_height: f64, + work_area: &MonitorSpec, +) -> OverlayPosition { + let right_x = main.x + main.width + PANEL_BESIDE_GAP; + let left_x = main.x - PANEL_BESIDE_GAP - PANEL_WIDTH; + let fits_right = right_x + PANEL_WIDTH <= work_area.logical_origin_x + work_area.logical_width; + let fits_left = left_x >= work_area.logical_origin_x; + // 양쪽 다 안 들어가면 오른쪽 후보를 넘겨 clamp가 작업 영역 오른쪽 끝에 붙이게 둔다 + let x = if fits_right || !fits_left { + right_x + } else { + left_x + }; + // 패널이 화면보다 높으면 clamp가 위쪽 정렬로 떨어뜨린다 + let y = main.y + (main.height - panel_height) / 2.0; + work_area.clamp(x, y, PANEL_WIDTH, panel_height) +} + +pub(super) struct PanelWindowLayout { + pub(super) position: Option, + pub(super) height: f64, + pub(super) min_height: f64, + pub(super) max_height: f64, +} + +// 작업 영역이 하한보다 좁으면 하한을 화면에 맞춰 낮춤 - 그러지 않으면 창 아래쪽이 +// 화면 밖으로 나가 리사이즈 가장자리에 손이 닿지 않는다 +pub(super) fn panel_height_bounds(work_area_height: Option) -> (f64, f64) { + let Some(work_area_height) = + work_area_height.filter(|height| height.is_finite() && *height > 0.0) + else { + return (PANEL_MIN_HEIGHT, PANEL_FALLBACK_MAX_HEIGHT); + }; + let max_height = work_area_height * PANEL_MAX_HEIGHT_RATIO; + (PANEL_MIN_HEIGHT.min(max_height), max_height) +} + +pub(super) fn resolve_panel_window_layout( + stored_bounds: Option, + main_rect: Option, + monitors: &MonitorData, + fallback_height: Option, +) -> PanelWindowLayout { + // 기준 화면은 메인 창이 놓인 모니터 - 패널이 그 옆에 붙으니 높이 한계도 같은 화면을 따른다 + let target_monitor = main_rect + .and_then(|rect| monitors.find_best_overlap(rect.x, rect.y, rect.width, rect.height)) + .or_else(|| monitors.primary_spec()); + let (min_height, max_height) = + panel_height_bounds(target_monitor.map(|monitor| monitor.logical_height)); + // 저장된 높이가 없으면 메인 창 높이를 기본값으로 (프로그램 높이 동기) + let requested_height = stored_bounds + .map(|bounds| bounds.height) + .or(fallback_height) + .unwrap_or(PANEL_INITIAL_HEIGHT); + let height = requested_height.clamp(min_height, max_height); + // 위치는 열 때마다 메인 창 옆으로 다시 잡는다 - 저장된 x/y는 이동 기록으로만 남고 복원에 쓰지 않음. + // 메인 좌표를 못 읽으면 OS 기본 배치에 맡긴다 + let position = main_rect + .zip(target_monitor) + .map(|(rect, monitor)| panel_position_beside_main(&rect, height, monitor)); + + PanelWindowLayout { + position, + height, + min_height, + max_height, + } +} + +pub(super) fn changed_panel_max_height(previous: Option, next: f64) -> Option { + if !next.is_finite() || next <= 0.0 { + return None; + } + if previous.is_some_and(|value| (value - next).abs() < 0.5) { + return None; + } + Some(next) +} + +pub(super) fn panel_bounds_sample_from_window(window: &WebviewWindow) -> Result { + let scale_factor = window.scale_factor().unwrap_or(1.0); + Ok(PanelBoundsSample { + position: window.outer_position()?, + position_scale_factor: scale_factor, + size: window.inner_size()?, + size_scale_factor: scale_factor, + current_scale_factor: scale_factor, + }) +} + +pub(super) fn valid_panel_scale_factor(scale_factor: f64) -> f64 { + if scale_factor.is_finite() && scale_factor > 0.0 { + scale_factor + } else { + 1.0 + } +} + +pub(super) fn panel_bounds_from_sample(sample: PanelBoundsSample) -> PanelBounds { + let position = sample + .position + .to_logical::(valid_panel_scale_factor(sample.position_scale_factor)); + let size = sample + .size + .to_logical::(valid_panel_scale_factor(sample.size_scale_factor)); + PanelBounds { + x: position.x, + y: position.y, + height: size.height, + } +} + +pub(super) fn apply_panel_bounds_change(sample: &mut PanelBoundsSample, change: PanelBoundsChange) { + match change { + PanelBoundsChange::Snapshot(snapshot) => *sample = snapshot, + PanelBoundsChange::Moved(position) => { + sample.position = position; + sample.position_scale_factor = sample.current_scale_factor; + } + PanelBoundsChange::Resized(size) => { + sample.size = size; + sample.size_scale_factor = sample.current_scale_factor; + } + PanelBoundsChange::ScaleFactorChanged { + position, + size, + scale_factor, + } => { + let scale_factor = valid_panel_scale_factor(scale_factor); + sample.current_scale_factor = scale_factor; + if let Some(position) = position { + sample.position = position; + sample.position_scale_factor = scale_factor; + } + sample.size = size; + sample.size_scale_factor = scale_factor; + } + } +} + +impl PanelBoundsPersistenceController { + pub(super) fn new(store: Arc) -> Self { + Self { + store, + state: Mutex::new(PanelBoundsPersistenceState::default()), + persist_lock: Mutex::new(()), + } + } + + pub(super) fn attach(&self, window: &WebviewWindow, max_height: f64) -> u64 { + let latest = panel_bounds_sample_from_window(window).ok(); + let mut state = self.state.lock(); + state.session = state.session.wrapping_add(1); + state.latest = latest; + state.window = Some(window.clone()); + state.applied_max_height = Some(max_height); + state.unpersisted_default_height = None; + state.default_height_pending = false; + state.generation = state.generation.wrapping_add(1); + state.dirty = false; + state.persist_dirty = false; + state.active = true; + state.session + } + + pub(super) fn record_event(self: &Arc, session: u64, change: PanelBoundsChange) { + let should_spawn = { + let mut state = self.state.lock(); + if !state.active || state.session != session { + return; + } + if state.latest.is_none() { + state.latest = state + .window + .as_ref() + .and_then(|window| panel_bounds_sample_from_window(window).ok()); + } + Self::record_change(&mut state, session, change) + }; + if should_spawn { + self.spawn_worker(); + } + } + + pub(super) fn record_change( + state: &mut PanelBoundsPersistenceState, + session: u64, + change: PanelBoundsChange, + ) -> bool { + if !state.active || state.session != session { + return false; + } + let persist = change.changes_persisted_bounds(); + if let PanelBoundsChange::Snapshot(snapshot) = change { + state.latest = Some(snapshot); + } else { + let Some(latest) = state.latest.as_mut() else { + return false; + }; + apply_panel_bounds_change(latest, change); + } + let persist = Self::should_persist_change(state, persist); + Self::mark_dirty(state, persist) + } + + pub(super) fn should_persist_change( + state: &mut PanelBoundsPersistenceState, + persist: bool, + ) -> bool { + if !persist { + return false; + } + let Some(default_height) = state.unpersisted_default_height else { + return true; + }; + let Some(sample) = state.latest else { + state.unpersisted_default_height = None; + state.default_height_pending = false; + return true; + }; + let height = panel_bounds_from_sample(sample).height; + if (height - default_height).abs() < 0.5 { + state.default_height_pending = false; + return false; + } + if state.default_height_pending { + return false; + } + state.unpersisted_default_height = None; + true + } + + // 이동만 바뀌어도 워커는 깨운다 - 모니터가 바뀌면 높이 한계를 다시 걸어야 하기 때문 + pub(super) fn mark_dirty(state: &mut PanelBoundsPersistenceState, persist: bool) -> bool { + state.generation = state.generation.wrapping_add(1); + state.dirty = true; + state.persist_dirty |= persist; + let should_spawn = !state.worker_running; + state.worker_running = true; + should_spawn + } + + pub(super) fn take_dirty_work( + state: &mut PanelBoundsPersistenceState, + ) -> Option { + let sample = state.latest.filter(|_| state.active && state.dirty)?; + let persist = state.persist_dirty; + state.dirty = false; + state.persist_dirty = false; + Some(PanelBoundsPersistWork { + session: state.session, + generation: state.generation, + sample, + persist, + }) + } + + pub(super) fn work_is_current( + state: &PanelBoundsPersistenceState, + work: &PanelBoundsPersistWork, + ) -> bool { + state.active && state.session == work.session && state.generation == work.generation + } + + pub(super) fn restore_failed_work( + state: &mut PanelBoundsPersistenceState, + work: &PanelBoundsPersistWork, + ) -> bool { + if !Self::work_is_current(state, work) { + return false; + } + state.dirty = true; + state.persist_dirty |= work.persist; + true + } + + pub(super) fn persist_sample(&self, sample: PanelBoundsSample) -> Result<()> { + let bounds = panel_bounds_from_sample(sample); + self.store + .update_deferred(move |data| { + data.panel_bounds = Some(bounds); + }) + .context("failed to capture settled panel bounds")?; + self.store + .flush() + .context("failed to flush settled panel bounds") + } + + pub(super) fn clear_saved_bounds(&self, layout: Option<&PanelWindowLayout>) -> Result<()> { + let _persist_guard = self.persist_lock.lock(); + self.store + .update_deferred(|data| data.panel_bounds = None) + .context("failed to clear saved panel bounds")?; + self.store + .flush() + .context("failed to flush cleared panel bounds")?; + + let mut state = self.state.lock(); + let default_height = layout.map(|value| value.height); + let current_height = state + .latest + .map(panel_bounds_from_sample) + .map(|value| value.height); + state.unpersisted_default_height = default_height; + state.default_height_pending = default_height + .zip(current_height) + .is_none_or(|(default, current)| (default - current).abs() >= 0.5); + state.applied_max_height = layout.map(|value| value.max_height); + state.generation = state.generation.wrapping_add(1); + state.dirty = false; + state.persist_dirty = false; + Ok(()) + } + + pub(super) fn persist_worker_work(&self, work: PanelBoundsPersistWork) -> Result { + // 이동만 바뀐 구간은 디스크를 건드리지 않는다 - 저장 값은 높이뿐이라 쓸 이유가 없다 + if !work.persist { + return Ok(false); + } + let _persist_guard = self.persist_lock.lock(); + if !Self::work_is_current(&self.state.lock(), &work) { + return Ok(false); + } + self.persist_sample(work.sample)?; + Ok(true) + } + + pub(super) fn spawn_worker(self: &Arc) { + let controller = Arc::clone(self); + tauri::async_runtime::spawn(async move { + loop { + tokio::time::sleep(Duration::from_millis(PANEL_BOUNDS_DEBOUNCE_MS)).await; + let (work, window) = { + let mut state = controller.state.lock(); + let Some(work) = Self::take_dirty_work(&mut state) else { + state.worker_running = false; + return; + }; + (work, state.window.clone()) + }; + + let persist_result = controller.persist_worker_work(work); + if let Err(err) = &persist_result { + log::warn!("failed to persist settled panel bounds: {err:#}"); + } + + if let Some(window) = window { + let constraint_controller = Arc::clone(&controller); + let constraint_window = window.clone(); + if let Err(err) = window.app_handle().run_on_main_thread(move || { + if let Err(err) = constraint_controller.apply_monitor_constraints( + &constraint_window, + work.session, + work.generation, + ) { + log::warn!("failed to update settled panel monitor constraints: {err}"); + } + }) { + log::warn!("failed to schedule settled panel constraints: {err}"); + } + } + + let mut state = controller.state.lock(); + if persist_result.is_err() && Self::restore_failed_work(&mut state, &work) { + state.worker_running = false; + return; + } + if !state.active || !state.dirty { + state.worker_running = false; + return; + } + } + }); + } + + pub(super) fn apply_monitor_constraints( + &self, + window: &WebviewWindow, + session: u64, + generation: u64, + ) -> Result<()> { + let Some((min_height, monitor_max_height)) = window + .current_monitor()? + .and_then(MonitorSpec::from_monitor) + .map(|monitor| panel_height_bounds(Some(monitor.logical_height))) + else { + return Ok(()); + }; + let max_height = { + let state = self.state.lock(); + if !state.active || state.session != session || state.generation != generation { + return Ok(()); + } + changed_panel_max_height(state.applied_max_height, monitor_max_height) + }; + let Some(max_height) = max_height else { + return Ok(()); + }; + // 좁은 모니터로 옮겨가면 하한도 함께 내려야 창이 화면 밖으로 나가지 않음 + window.set_min_size(Some(tauri::Size::Logical(tauri::LogicalSize::new( + PANEL_WIDTH, + min_height, + ))))?; + window.set_max_size(Some(tauri::Size::Logical(tauri::LogicalSize::new( + PANEL_WIDTH, + max_height, + ))))?; + let mut state = self.state.lock(); + if state.active && state.session == session && state.generation == generation { + state.applied_max_height = Some(max_height); + } + Ok(()) + } + + pub(super) fn flush_now(&self, window: &WebviewWindow) -> Result<()> { + let _persist_guard = self.persist_lock.lock(); + let generation_before_sample = self.state.lock().generation; + let sampled = panel_bounds_sample_from_window(window); + let persisted = + Self::flush_samples(&self.state, generation_before_sample, sampled, |sample| { + self.persist_sample(sample) + })?; + if !persisted { + self.store + .flush() + .context("failed to flush store with cleared panel bounds")?; + } + Ok(()) + } + + pub(super) fn flush_samples( + state_mutex: &Mutex, + generation_before_sample: u64, + sampled: Result, + mut persist: impl FnMut(PanelBoundsSample) -> Result<()>, + ) -> Result { + let mut work = { + let mut state = state_mutex.lock(); + let sampled = match sampled { + Ok(sample) => sample, + Err(error) => state.latest.ok_or(error)?, + }; + let sample = if state.generation != generation_before_sample { + state.latest.unwrap_or(sampled) + } else { + sampled + }; + state.latest = Some(sample); + state.generation = state.generation.wrapping_add(1); + state.dirty = false; + state.persist_dirty = false; + let persist = Self::should_persist_change(&mut state, true); + PanelBoundsPersistWork { + session: state.session, + generation: state.generation, + sample, + persist, + } + }; + let mut persisted = false; + + loop { + if work.persist { + if let Err(error) = persist(work.sample) { + Self::restore_failed_work(&mut state_mutex.lock(), &work); + return Err(error); + } + persisted = true; + } + let next = { + let mut state = state_mutex.lock(); + let Some(mut next) = Self::take_dirty_work(&mut state) else { + return Ok(persisted); + }; + next.persist = Self::should_persist_change(&mut state, true); + next + }; + work = next; + } + } + + pub(super) fn deactivate(&self, session: u64) { + let mut state = self.state.lock(); + Self::deactivate_state(&mut state, session); + } + + pub(super) fn deactivate_state(state: &mut PanelBoundsPersistenceState, session: u64) -> bool { + if state.session != session { + return false; + } + state.active = false; + state.window = None; + state.applied_max_height = None; + state.unpersisted_default_height = None; + state.default_height_pending = false; + state.generation = state.generation.wrapping_add(1); + state.dirty = false; + state.persist_dirty = false; + true + } +} + +pub(super) fn compute_overlay_position( + bounds: &OverlayBounds, + had_stored_bounds: bool, + monitors: &MonitorData, +) -> OverlayPosition { + // 최소 가시 면적 — 오버레이 전체 면적의 25% 또는 100×100 중 작은 값 + let min_visible_area = (bounds.width * bounds.height * 0.25).min(100.0 * 100.0); + + if monitors.is_empty() { + return if had_stored_bounds { + OverlayPosition { + x: bounds.x, + y: bounds.y, + } + } else { + OverlayPosition { + x: OVERLAY_MARGIN, + y: OVERLAY_MARGIN, + } + }; + } + + let fallback = monitors + .primary_spec() + .cloned() + .or_else(|| monitors.first().cloned()); + + let Some(fallback_spec) = fallback else { + return OverlayPosition { + x: bounds.x, + y: bounds.y, + }; + }; + + // 저장된 위치가 없으면 기본 위치로 배치 (clamp 적용) + if !had_stored_bounds { + let base_x = fallback_spec.logical_origin_x + fallback_spec.logical_width + - bounds.width + - OVERLAY_MARGIN; + let base_y = fallback_spec.logical_origin_y + fallback_spec.logical_height + - bounds.height + - OVERLAY_MARGIN; + return fallback_spec.clamp(base_x, base_y, bounds.width, bounds.height); + } + + // 저장된 bounds와 가장 많이 겹치는 모니터 탐색 + if let Some(best) = monitors.find_best_overlap(bounds.x, bounds.y, bounds.width, bounds.height) + { + let area = best.intersection_area(bounds.x, bounds.y, bounds.width, bounds.height); + if area >= min_visible_area { + // 충분히 보이므로 저장 좌표 그대로 복원 + return OverlayPosition { + x: bounds.x, + y: bounds.y, + }; + } + // 겹침이 부족하면 해당 모니터에 clamp + return best.clamp(bounds.x, bounds.y, bounds.width, bounds.height); + } + + // 어떤 모니터와도 겹치지 않음 — fallback 모니터에 clamp + fallback_spec.clamp(bounds.x, bounds.y, bounds.width, bounds.height) +} + +pub(super) fn defer_overlay_bounds_from_window( + window: &WebviewWindow, + store: &Arc, + generation: &Arc, +) -> Result<()> { + // scale 조회가 실패하면 1.0으로 때우지 않는다 - physical 값이 logical 라벨로 굳는다 + let scale_factor = window.scale_factor()?; + let position = window.outer_position()?.to_logical::(scale_factor); + let size = window.outer_size()?.to_logical::(scale_factor); + + defer_overlay_bounds( + store, + generation, + OverlayBounds { + x: position.x, + y: position.y, + width: size.width, + height: size.height, + }, + None, + ) +} + +/// 모든 호출부는 logical 사각형만 넘긴다 - `apply_overlay_frame` 반환값, +/// `MonitorSpec`의 logical 필드, `to_logical(scale)` 산출값이 전부다. +/// 따라서 `overlay_bounds_are_logical = true`는 참인 단언이다. +/// 마커를 인자화해 false를 보존하면 다음 세션의 ensure_overlay_window가 +/// x/y/width/height 전부를 scale로 다시 나눠 이중 환산이 발생한다 +/// (신규 설치 후 위치 초기화, 창 드래그 경로가 즉시 깨진다) +pub(super) fn defer_overlay_bounds( + store: &Arc, + generation: &Arc, + bounds: OverlayBounds, + content_top_offset: Option, +) -> Result<()> { + store.update_deferred(move |state| { + state.overlay_bounds = Some(bounds); + state.overlay_bounds_are_logical = true; + if let Some(offset) = content_top_offset { + state.overlay_last_content_top_offset = Some(offset); + } + })?; + let scheduled_generation = generation.fetch_add(1, Ordering::SeqCst).wrapping_add(1); + + let store = Arc::clone(store); + let generation = Arc::clone(generation); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(Duration::from_millis(OVERLAY_BOUNDS_DEBOUNCE_MS)).await; + if generation.load(Ordering::SeqCst) != scheduled_generation { + return; + } + if let Err(err) = store.flush() { + log::warn!("failed to flush debounced overlay bounds: {err}"); + } + }); + + Ok(()) +} + +pub(super) fn flush_deferred_overlay_bounds( + store: &Arc, + generation: &Arc, +) -> Result<()> { + generation.fetch_add(1, Ordering::SeqCst); + store.flush() +} + +#[derive(Clone, Copy)] +pub(super) struct OverlayPosition { + pub(super) x: f64, + pub(super) y: f64, +} From 151fe5291a7fc973e49f95041932d2e32d88ae48 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 07:22:23 +0900 Subject: [PATCH 016/245] =?UTF-8?q?docs:=20=EB=8C=80=ED=98=95=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EB=A6=AC=ED=8C=A9=ED=84=B0=EB=A7=81=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=20=EC=83=81=ED=83=9C=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/code-quality-refactoring-plan.md | 53 ++++++++++++++++----------- 1 file changed, 31 insertions(+), 22 deletions(-) diff --git a/docs/code-quality-refactoring-plan.md b/docs/code-quality-refactoring-plan.md index 6473d9097..3e8015aae 100644 --- a/docs/code-quality-refactoring-plan.md +++ b/docs/code-quality-refactoring-plan.md @@ -16,49 +16,54 @@ | 영역 | 이전 | 현재 | 분리 결과 | | --- | ---: | ---: | --- | -| `PropertiesPanel.tsx` | 3407줄 | 1907줄 | 선택 모델, navigation 훅, 단일 선택 commit handler, 플러그인 설정 폼 분리 | +| `PropertiesPanel.tsx` | 3407줄 | 1549줄 | 선택 모델, navigation, commit handler, 이름 변경 세션, 플러그인 설정 제어, 패널 가시성 생명주기 분리 | | `BatchSelectionPanel.tsx` | 2367줄 | 1277줄 | 공통 handler/header와 그래프·노브·플러그인 전용 패널 분리 | | `SingleSelectionPanel.tsx` | 2061줄 | 674줄 | 그래프·노브 패널과 공통 표시 모델 분리 | | `Settings.tsx` | 1423줄 | 1172줄 | 키음 출력 UI, 비동기 적용 큐 훅, 순수 view model 분리 | +| `editorCoordinator.ts` | 3001줄 | 2156줄 | 의미 연산의 document projection과 영향 필드 계산을 `semanticOpsProjection.ts`로 분리 | +| `elementOps.ts` | 2611줄 | 571줄 | document model, 공통 property, group, style, geometry 연산을 도메인 모듈로 분리하고 기존 facade export 유지 | | `store.rs` | 22978줄 | 3349줄 | 테스트, writer 영속화, 자산 참조, 사운드 복구·trash·sweep 분리 | | `editor_ops.rs` | 10154줄 | 2940줄 | 인라인 테스트 모듈 분리 | | `migration.rs` | 6053줄 | 2238줄 | 인라인 테스트 모듈 분리 | | `editor.rs` | 5777줄 | 2396줄 | 인라인 테스트 모듈 분리 | -| `app_state.rs` | 9314줄 | 6600줄 | 인라인 테스트 모듈 분리 | +| `app_state.rs` | 9314줄 | 5650줄 | 인라인 테스트와 창 좌표·모니터 배치·패널 bounds 영속화 모듈 분리 | | `models/mod.rs` | 4169줄 | 3033줄 | 인라인 테스트 모듈 분리 | ASIO는 장치 I/O 경계와 순수 정책을 `audio/engine/asio.rs`로 분리했다. 버퍼 정규화, 드라이버 목록 정규화, 유효 출력 구성, 빌드 가용성, 오류 코드와 폴백 계약을 하드웨어 없이 검증한다. -## 잔여 대형 프론트엔드 우선순위 +이번 분리는 IPC command/event 이름, store schema, editor wire 형식, 기존 `elementOps` 공개 export를 바꾸지 않았다. 이동한 Rust 창 좌표 코드는 가시성 키워드와 포맷 차이를 제외한 토큰 비교로 원본과 동일함을 확인했다. -아래 줄 수는 이번 브랜치에서 `wc -l`로 측정한 값이다. - -### 1. 에디터 런타임 +## 추가 조사 결과와 잔여 프론트엔드 우선순위 -- `editor/runtime/editorCoordinator.ts` — 3001줄 - - mutation queue와 retry/conflict 처리 - - canonical snapshot 동기화와 event publication - - gesture/operation 결과 projection - - facade에는 public coordinator API와 조립만 유지 -- `editor/runtime/elementOps.ts` — 2611줄 - - geometry, style, paint/shadow, media, batch operation별 모듈 - - 기존 `elementOps` import 경로는 barrel export로 유지 - -검증: coordinator/element operation 단위 테스트, conflict·retry·gesture focused suite, 전체 Vitest. +아래 줄 수는 이번 브랜치에서 `wc -l`로 측정한 값이다. -### 2. Grid 상호작용 +### 1. Grid 상호작용 - `Grid/core/Grid.tsx` — 2303줄 - - scene/layer 조립, 포인터·키보드 interaction, overlay UI 분리 + - scene/layer 조립, 포인터·키보드 interaction, overlay UI가 한 렌더 경계에 결합 - 선택과 resize/drag hook은 facade에서 주입 - `hooks/Grid/useGridSelection.ts` — 1552줄 - - 순수 selection reducer/model과 DOM event adapter 분리 + - 순수 selection reducer/model과 DOM event adapter 분리 후보 - marquee, modifier, group/plugin selection을 각각 테스트 - `components/shared/PluginElement.tsx` — 1647줄 - - runtime props 해석, 측정/geometry, pointer/context menu bridge 분리 + - runtime props 해석, 측정/geometry, pointer/context menu bridge 분리 후보 + +이 영역은 호출 fan-out과 interaction 분기가 가장 높다. 현재 회귀 테스트가 DOM pointer capture, 좌표계, 플러그인 격리를 모두 독립적으로 고정하지 못하므로 이번 단계에서는 직접 이동하지 않았다. 먼저 characterization test를 보강한 뒤 scene 조립 → overlay UI → event adapter 순서로 분리한다. 검증: Grid interaction focused suite, detached panel contract, plugin element isolation, 전체 Vitest. +### 2. 에디터 런타임 후속 경계 + +- `editor/runtime/editorCoordinator.ts` — 2156줄 + - mutation queue와 retry/conflict 처리 + - canonical snapshot 동기화와 event publication + - facade에는 public coordinator API와 조립만 유지 +- `editor/runtime/elementStyleOps.ts` — 1231줄 + - 현재는 style/paint/shadow/media 속성 연산이라는 한 도메인으로 모였음 + - 추가 분리는 속성군별 transition 테스트를 먼저 고정한 뒤 검토 + +검증: coordinator conflict·retry·gesture focused suite, element operation export 계약, 전체 Vitest. + ### 3. 공통 입력과 편집기 - `ColorPicker.tsx` — 1513줄: 색상 상태 모델, gradient editor, palette/history UI 분리 @@ -70,8 +75,9 @@ ASIO는 장치 I/O 경계와 순수 정책을 `audio/engine/asio.rs`로 분리 ## 잔여 대형 백엔드 우선순위 -- `state/app_state.rs` — 6600줄 - - bootstrap, window/event publication, keyboard runtime, shutdown coordinator 분리 +- `state/app_state.rs` — 5650줄 + - 다음 경계는 frontend flush/lifecycle handshake, keyboard runtime, shutdown coordinator + - 창 좌표와 패널 bounds 제어는 `state/app_state/window_geometry.rs` 989줄로 분리 완료 - test-only emitter/harness도 별도 test support 모듈로 이동 - `state/store.rs` — 3349줄 - core transaction facade만 남기고 editor/history transaction과 plugin storage transaction 분리 @@ -80,6 +86,9 @@ ASIO는 장치 I/O 경계와 순수 정책을 `audio/engine/asio.rs`로 분리 - 파일 decode/validation, migration, store transaction, event projection 분리 - `commands/keys/sound.rs` — 2189줄 - scan/library, WAV processing, delete transaction command 분리 +- `audio/engine.rs` — 1712줄 + - 실시간 `audio_thread`의 장치 전환·오류 복구 분기가 결합되어 있음 + - timing 동작을 바꾸지 않도록 상태 전이 characterization test 이후 제어 정책만 분리 store 자산 분리는 다음 불변식을 계속 지킨다. From 4503631a148785eb9d5a1e52f61fe79135d316e0 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 07:28:36 +0900 Subject: [PATCH 017/245] =?UTF-8?q?refactor:=20=EA=B7=B8=EB=A6=AC=EB=93=9C?= =?UTF-8?q?=20=EC=BB=A8=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EB=A9=94=EB=89=B4=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/main/Grid/core/Grid.tsx | 139 +++---------- .../Grid/core/gridContextMenuModel.test.ts | 190 ++++++++++++++++++ .../main/Grid/core/gridContextMenuModel.ts | 80 ++++++++ src/renderer/utils/grid/groupSelection.ts | 53 +++++ 4 files changed, 355 insertions(+), 107 deletions(-) create mode 100644 src/renderer/components/main/Grid/core/gridContextMenuModel.test.ts create mode 100644 src/renderer/components/main/Grid/core/gridContextMenuModel.ts diff --git a/src/renderer/components/main/Grid/core/Grid.tsx b/src/renderer/components/main/Grid/core/Grid.tsx index f22427090..e3b6bcf5e 100644 --- a/src/renderer/components/main/Grid/core/Grid.tsx +++ b/src/renderer/components/main/Grid/core/Grid.tsx @@ -28,7 +28,7 @@ import { resolveElementById } from '@src/renderer/editor/model/elementIdMap'; import { isNativeElementId } from '@src/renderer/editor/model/elementId'; import TabCssModal from '../../Modal/content/editors/TabCssModal'; import TabNoteSettingModal from '../../Modal/content/editors/TabNoteSettingModal'; -import ListPopup, { type ListItem } from '../../Modal/ListPopup'; +import ListPopup from '../../Modal/ListPopup'; import { useKeyStore } from '@stores/data/useKeyStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { useGraphItemStore } from '@stores/data/useGraphItemStore'; @@ -130,6 +130,12 @@ import { type PendingHandlerSlotMap, type StableHandlerSlotMap, } from './stableHandlerSlots'; +import { + buildMixedSelectionMenuItems, + gridAddTypeForMenuItem, + isStableNativeSelection, + shouldOpenMixedSelectionMenu, +} from './gridContextMenuModel'; type ToolbarAddRequest = { id: number; @@ -460,83 +466,25 @@ const Grid = ({ y: number; } | null>(null); const lastMousePosRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); - const mixedSelectionMenuItems = (() => { - const items: ListItem[] = [ - { id: 'delete', label: t('contextMenu.deleteSelected') }, - { id: 'duplicate', label: t('contextMenu.duplicateSelected') }, - ]; - - if (selectedElements.length >= 2) { - // 선택된 요소들의 그룹 상태 확인 - const modeKeyPos = positions[selectedKeyType] || []; - const modeStatPos = - useStatItemStore.getState().positions[selectedKeyType] || []; - const modeGraphPos = - useGraphItemStore.getState().positions[selectedKeyType] || []; - const modeKnobPos = - useKnobItemStore.getState().positions[selectedKeyType] || []; - - let anyInGroup = false; - let allInSameGroup = true; - let firstGroupId; - let first = true; - - const modeGroupIds = new Set( - (useLayerGroupStore.getState().layerGroups[selectedKeyType] || []).map( - (group) => group.id, - ), - ); - selectedElements.forEach((el) => { - let gid; - if (el.type === 'key') { - gid = modeKeyPos.find((position) => position.id === el.id)?.groupId; - } else if (el.type === 'stat') { - gid = modeStatPos.find((position) => position.id === el.id)?.groupId; - } else if (el.type === 'graph') { - gid = modeGraphPos.find((position) => position.id === el.id)?.groupId; - } else if (el.type === 'knob') { - gid = modeKnobPos.find((position) => position.id === el.id)?.groupId; - } else if (el.type === 'plugin') { - // 플러그인 소속도 그룹 메뉴 판정에 포함 - 모드 def가 있는 것만 유효 - const pluginGroupId = usePluginDisplayElementStore - .getState() - .elements.find((candidate) => candidate.fullId === el.id)?.groupId; - gid = - pluginGroupId && modeGroupIds.has(pluginGroupId) - ? pluginGroupId - : undefined; + const mixedSelectionMenuItems = buildMixedSelectionMenuItems( + selectedElements, + selectedElements.length >= 2 + ? { + mode: selectedKeyType, + keyPositions: positions[selectedKeyType] || [], + statPositions: + useStatItemStore.getState().positions[selectedKeyType] || [], + graphPositions: + useGraphItemStore.getState().positions[selectedKeyType] || [], + knobPositions: + useKnobItemStore.getState().positions[selectedKeyType] || [], + pluginElements: usePluginDisplayElementStore.getState().elements, + modeGroups: + useLayerGroupStore.getState().layerGroups[selectedKeyType] || [], } - if (gid) anyInGroup = true; - if (first) { - firstGroupId = gid; - first = false; - } else if (gid !== firstGroupId) allInSameGroup = false; - }); - - if (anyInGroup && allInSameGroup && firstGroupId) { - // 모두 같은 그룹 → 그룹 해제만 - items.push({ id: 'ungroupSelected', label: t('contextMenu.ungroup') }); - } else if (!anyInGroup) { - // 그룹 없음 → 그룹화만 - items.push({ - id: 'groupSelected', - label: t('contextMenu.groupSelected'), - }); - } else { - // 혼합 → 둘 다 - items.push({ - id: 'groupSelected', - label: t('contextMenu.groupSelected'), - }); - items.push({ id: 'ungroupSelected', label: t('contextMenu.ungroup') }); - } - } - - items.push({ id: 'bringToFront', label: t('contextMenu.bringToFront') }); - items.push({ id: 'sendToBack', label: t('contextMenu.sendToBack') }); - - return items; - })(); + : null, + t, + ); const openMixedSelectionContextMenu = ( x: number, @@ -550,12 +498,6 @@ const Grid = ({ setIsContextOpen(true); }; - const shouldOpenMixedSelectionMenu = (clickedId: string) => { - if (selectedElements.length <= 1) return false; - if (!selectedElements.some((el) => el.id === clickedId)) return false; - return true; - }; - // 클라이언트 좌표를 그리드 좌표로 변환 (줌/팬 반영) const computeSnappedCursorFromClient = (clientX: number, clientY: number) => { const gridCoords = clientToGridCoords(clientX, clientY); @@ -590,18 +532,6 @@ const Grid = ({ await pasteElements().catch(reportElementOpError); }; - // 외부 선택 입력도 canonical native id 경계에서 재검증 - const isStableNativeSelection = (el: { - type: string; - id: string; - }): el is { type: 'key' | 'stat' | 'graph' | 'knob'; id: string } => - (el.type === 'key' || - el.type === 'stat' || - el.type === 'graph' || - el.type === 'knob') && - el.id.length > 0 && - isNativeElementId(el.id); - const moveSelectedToFront = async () => { if (selectedElements.length === 0) return; @@ -838,7 +768,10 @@ const Grid = ({ // TypeError가 나고 에러 바운더리가 없어 앱이 통째로 언마운트된다. // id가 없으면 혼합 선택 판정 자체가 성립하지 않으므로 그냥 건너뛴다 const clickedId = clickedPosition?.id; - if (clickedId && shouldOpenMixedSelectionMenu(clickedId)) { + if ( + clickedId && + shouldOpenMixedSelectionMenu(selectedElements, clickedId) + ) { openMixedSelectionContextMenu(clientX, clientY, ref); return; } @@ -1642,7 +1575,8 @@ const Grid = ({ setDuplicateState(null); setDuplicateCursor(null); } - if (!shouldOpenMixedSelectionMenu(elementId)) return false; + if (!shouldOpenMixedSelectionMenu(selectedElements, elementId)) + return false; openMixedSelectionContextMenu( clientX, clientY, @@ -2224,16 +2158,7 @@ const Grid = ({ } // 기본 메뉴 처리 - const addType = - id === 'add' - ? 'key' - : id === 'addStat' - ? 'stat' - : id === 'addGraph' - ? 'graph' - : id === 'addKnob' - ? 'knob' - : null; + const addType = gridAddTypeForMenuItem(id); if (addType && gridAddLocalPos) { addCanvasElementAt( canvasActions, diff --git a/src/renderer/components/main/Grid/core/gridContextMenuModel.test.ts b/src/renderer/components/main/Grid/core/gridContextMenuModel.test.ts new file mode 100644 index 000000000..97e2c5dab --- /dev/null +++ b/src/renderer/components/main/Grid/core/gridContextMenuModel.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from 'vitest'; + +import type { SelectedElement } from '@stores/grid/useGridSelectionStore'; +import type { GroupSelectionSource } from '@utils/grid/groupSelection'; +import { + buildMixedSelectionMenuItems, + gridAddTypeForMenuItem, + isStableNativeSelection, + shouldOpenMixedSelectionMenu, +} from './gridContextMenuModel'; + +const t = (key: string) => key; + +const source = ( + overrides: Partial = {}, +): GroupSelectionSource => ({ + mode: 'default', + keyPositions: [], + statPositions: [], + graphPositions: [], + knobPositions: [], + pluginElements: [], + modeGroups: [{ id: 'group-a' }, { id: 'group-b' }], + ...overrides, +}); + +const ids = (items: ReturnType) => + items.map((item) => item.id); + +describe('gridContextMenuModel', () => { + it('단일 선택은 그룹 상태를 읽지 않고 기존 공통 메뉴 순서를 유지한다', () => { + expect( + ids( + buildMixedSelectionMenuItems( + [{ type: 'key', id: 'key:one', index: 0 }], + null, + t, + ), + ), + ).toEqual(['delete', 'duplicate', 'bringToFront', 'sendToBack']); + }); + + it('그룹이 없는 다중 선택에는 그룹화만 노출한다', () => { + const selected: SelectedElement[] = [ + { type: 'key', id: 'key:one', index: 0 }, + { type: 'stat', id: 'stat:one', index: 0 }, + ]; + expect( + ids( + buildMixedSelectionMenuItems( + selected, + source({ + keyPositions: [{ id: 'key:one' }], + statPositions: [{ id: 'stat:one' }], + }), + t, + ), + ), + ).toEqual([ + 'delete', + 'duplicate', + 'groupSelected', + 'bringToFront', + 'sendToBack', + ]); + }); + + it('같은 그룹의 native와 plugin 선택에는 그룹 해제만 노출한다', () => { + const selected: SelectedElement[] = [ + { type: 'key', id: 'key:one', index: 0 }, + { type: 'plugin', id: 'plugin:one' }, + ]; + expect( + ids( + buildMixedSelectionMenuItems( + selected, + source({ + keyPositions: [{ id: 'key:one', groupId: 'group-a' }], + pluginElements: [ + { + fullId: 'plugin:one', + tabId: 'default', + groupId: 'group-a', + }, + ], + }), + t, + ), + ), + ).toEqual([ + 'delete', + 'duplicate', + 'ungroupSelected', + 'bringToFront', + 'sendToBack', + ]); + }); + + it('그룹 소속이 섞인 선택에는 그룹화와 해제를 기존 순서로 모두 노출한다', () => { + const selected: SelectedElement[] = [ + { type: 'key', id: 'key:one', index: 0 }, + { type: 'graph', id: 'graph:one', index: 0 }, + ]; + expect( + ids( + buildMixedSelectionMenuItems( + selected, + source({ + keyPositions: [{ id: 'key:one', groupId: 'group-a' }], + graphPositions: [{ id: 'graph:one' }], + }), + t, + ), + ), + ).toEqual([ + 'delete', + 'duplicate', + 'groupSelected', + 'ungroupSelected', + 'bringToFront', + 'sendToBack', + ]); + }); + + it('native dangling group은 해제 대상으로, plugin dangling group은 무소속으로 유지한다', () => { + const danglingNative: SelectedElement[] = [ + { type: 'key', id: 'key:one', index: 0 }, + { type: 'stat', id: 'stat:one', index: 0 }, + ]; + expect( + ids( + buildMixedSelectionMenuItems( + danglingNative, + source({ + keyPositions: [{ id: 'key:one', groupId: 'missing' }], + statPositions: [{ id: 'stat:one', groupId: 'missing' }], + }), + t, + ), + ), + ).toContain('ungroupSelected'); + + const danglingPlugins: SelectedElement[] = [ + { type: 'plugin', id: 'plugin:one' }, + { type: 'plugin', id: 'plugin:two' }, + ]; + expect( + ids( + buildMixedSelectionMenuItems( + danglingPlugins, + source({ + pluginElements: [ + { fullId: 'plugin:one', groupId: 'missing' }, + { fullId: 'plugin:two', groupId: 'missing' }, + ], + }), + t, + ), + ), + ).toContain('groupSelected'); + }); + + it('혼합 메뉴는 둘 이상의 현재 선택 멤버를 클릭할 때만 열린다', () => { + const selected: SelectedElement[] = [ + { type: 'key', id: 'key:one', index: 0 }, + { type: 'plugin', id: 'plugin:one' }, + ]; + expect(shouldOpenMixedSelectionMenu(selected, 'plugin:one')).toBe(true); + expect(shouldOpenMixedSelectionMenu(selected, 'missing')).toBe(false); + expect(shouldOpenMixedSelectionMenu(selected.slice(0, 1), 'key:one')).toBe( + false, + ); + }); + + it('native 안정 ID와 그리드 추가 메뉴 ID를 fail-closed로 분류한다', () => { + const nativeId = '11111111-1111-4111-8111-111111111111'; + expect(isStableNativeSelection({ type: 'key', id: nativeId })).toBe(true); + expect(isStableNativeSelection({ type: 'plugin', id: nativeId })).toBe( + false, + ); + expect(isStableNativeSelection({ type: 'key', id: '' })).toBe(false); + expect(isStableNativeSelection({ type: 'key', id: 'legacy' })).toBe(false); + + expect(gridAddTypeForMenuItem('add')).toBe('key'); + expect(gridAddTypeForMenuItem('addStat')).toBe('stat'); + expect(gridAddTypeForMenuItem('addGraph')).toBe('graph'); + expect(gridAddTypeForMenuItem('addKnob')).toBe('knob'); + expect(gridAddTypeForMenuItem('addPlugin')).toBeNull(); + }); +}); diff --git a/src/renderer/components/main/Grid/core/gridContextMenuModel.ts b/src/renderer/components/main/Grid/core/gridContextMenuModel.ts new file mode 100644 index 000000000..515a9f9dc --- /dev/null +++ b/src/renderer/components/main/Grid/core/gridContextMenuModel.ts @@ -0,0 +1,80 @@ +import type { ListItem } from '@components/main/Modal/ListPopup'; +import { isNativeElementId } from '@src/renderer/editor/model/elementId'; +import type { + IndexedSelectableElementType, + SelectedElement, +} from '@stores/grid/useGridSelectionStore'; +import { + summarizeSelectionGrouping, + type GroupSelectionSource, +} from '@utils/grid/groupSelection'; + +type Translate = (key: string) => string; + +export const buildMixedSelectionMenuItems = ( + selectedElements: readonly SelectedElement[], + groupingSource: GroupSelectionSource | null, + t: Translate, +): ListItem[] => { + const items: ListItem[] = [ + { id: 'delete', label: t('contextMenu.deleteSelected') }, + { id: 'duplicate', label: t('contextMenu.duplicateSelected') }, + ]; + + if (selectedElements.length >= 2 && groupingSource) { + const grouping = summarizeSelectionGrouping( + selectedElements, + groupingSource, + ); + if (grouping.kind === 'same') { + items.push({ id: 'ungroupSelected', label: t('contextMenu.ungroup') }); + } else if (grouping.kind === 'none') { + items.push({ + id: 'groupSelected', + label: t('contextMenu.groupSelected'), + }); + } else { + items.push( + { + id: 'groupSelected', + label: t('contextMenu.groupSelected'), + }, + { id: 'ungroupSelected', label: t('contextMenu.ungroup') }, + ); + } + } + + items.push( + { id: 'bringToFront', label: t('contextMenu.bringToFront') }, + { id: 'sendToBack', label: t('contextMenu.sendToBack') }, + ); + return items; +}; + +export const shouldOpenMixedSelectionMenu = ( + selectedElements: readonly SelectedElement[], + clickedId: string, +): boolean => + selectedElements.length > 1 && + selectedElements.some((element) => element.id === clickedId); + +export const isStableNativeSelection = (element: { + type: string; + id: string; +}): element is { type: IndexedSelectableElementType; id: string } => + (element.type === 'key' || + element.type === 'stat' || + element.type === 'graph' || + element.type === 'knob') && + element.id.length > 0 && + isNativeElementId(element.id); + +export const gridAddTypeForMenuItem = ( + id: string, +): IndexedSelectableElementType | null => { + if (id === 'add') return 'key'; + if (id === 'addStat') return 'stat'; + if (id === 'addGraph') return 'graph'; + if (id === 'addKnob') return 'knob'; + return null; +}; diff --git a/src/renderer/utils/grid/groupSelection.ts b/src/renderer/utils/grid/groupSelection.ts index 3609cb9a0..b21a3e8b3 100644 --- a/src/renderer/utils/grid/groupSelection.ts +++ b/src/renderer/utils/grid/groupSelection.ts @@ -34,6 +34,11 @@ export interface GroupSelectionSource { modeGroups: readonly Pick[]; } +export type SelectionGroupingSummary = + | { kind: 'none' } + | { kind: 'same'; groupId: string } + | { kind: 'mixed' }; + const nativeCollections = (source: GroupSelectionSource) => ({ key: source.keyPositions, @@ -55,6 +60,54 @@ const resolveClickedGroupId = ( )?.groupId; }; +const resolveSelectionGroupId = ( + element: SelectedElement, + source: GroupSelectionSource, + modeGroupIds: ReadonlySet, +): string | undefined => { + if (element.type === 'plugin') { + const groupId = source.pluginElements.find( + (candidate) => candidate.fullId === element.id, + )?.groupId; + return groupId && modeGroupIds.has(groupId) ? groupId : undefined; + } + + return nativeCollections(source)[element.type].find( + (position) => position?.id === element.id, + )?.groupId; +}; + +/** + * 선택 집합의 그룹 메뉴 상태를 계산한다. + * native의 기존 dangling groupId는 해제 대상으로 유지하고, plugin은 현재 모드에 + * 정의된 그룹만 유효하게 보는 기존 캔버스 계약을 보존한다. + */ +export const summarizeSelectionGrouping = ( + selectedElements: readonly SelectedElement[], + source: GroupSelectionSource, +): SelectionGroupingSummary => { + const modeGroupIds = new Set(source.modeGroups.map((group) => group.id)); + let firstGroupId: string | undefined; + let anyInGroup = false; + let allInSameGroup = true; + + selectedElements.forEach((element, index) => { + const groupId = resolveSelectionGroupId(element, source, modeGroupIds); + if (groupId) anyInGroup = true; + if (index === 0) { + firstGroupId = groupId; + } else if (groupId !== firstGroupId) { + allInSameGroup = false; + } + }); + + if (anyInGroup && allInSameGroup && firstGroupId) { + return { kind: 'same', groupId: firstGroupId }; + } + if (!anyInGroup) return { kind: 'none' }; + return { kind: 'mixed' }; +}; + /** * 클릭 요소의 그룹 멤버 전체를 SelectedElement 배열로 확장 * - 클릭 요소가 항상 선두 (native·플러그인 클릭 공통 계약) From 40bd6aa93af8d06dd351800971b6865e28980c65 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 07:31:16 +0900 Subject: [PATCH 018/245] =?UTF-8?q?refactor:=20=EA=B7=B8=EB=A6=AC=EB=93=9C?= =?UTF-8?q?=20=EB=B3=B5=EC=A0=9C=20=EA=B3=A0=EC=8A=A4=ED=8A=B8=20UI=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Grid/core/DuplicateElementGhost.test.tsx | 112 ++++++++++++ .../main/Grid/core/DuplicateElementGhost.tsx | 162 +++++++++++++++++ .../components/main/Grid/core/Grid.tsx | 167 +----------------- 3 files changed, 279 insertions(+), 162 deletions(-) create mode 100644 src/renderer/components/main/Grid/core/DuplicateElementGhost.test.tsx create mode 100644 src/renderer/components/main/Grid/core/DuplicateElementGhost.tsx diff --git a/src/renderer/components/main/Grid/core/DuplicateElementGhost.test.tsx b/src/renderer/components/main/Grid/core/DuplicateElementGhost.test.tsx new file mode 100644 index 000000000..a88730a19 --- /dev/null +++ b/src/renderer/components/main/Grid/core/DuplicateElementGhost.test.tsx @@ -0,0 +1,112 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import type { DuplicateState } from '@hooks/Grid/useGridCanvasActions'; +import DuplicateElementGhost from './DuplicateElementGhost'; + +const duplicate = ( + value: Partial & Pick, +): DuplicateState => + ({ + sourceIndex: 0, + keyName: '', + position: { id: 'source', dx: 0, dy: 0 }, + ...value, + } as DuplicateState); + +describe('DuplicateElementGhost', () => { + let host: HTMLDivElement; + let root: Root; + + beforeEach(() => { + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + }); + + afterEach(() => { + act(() => root.unmount()); + host.remove(); + }); + + const renderGhost = ( + duplicateState: DuplicateState | null, + cursor: { x: number; y: number } | null, + ) => { + act(() => { + root.render( + , + ); + }); + }; + + it('복제 상태나 커서가 없으면 고스트를 그리지 않는다', () => { + renderGhost(null, { x: 10, y: 20 }); + expect(host.childElementCount).toBe(0); + + renderGhost(duplicate({ elementType: 'key' }), null); + expect(host.childElementCount).toBe(0); + }); + + it('그래프 고스트는 원본 크기의 중심을 커서에 맞춘다', () => { + renderGhost( + duplicate({ + elementType: 'graph', + position: { + id: 'graph', + dx: 0, + dy: 0, + width: 120, + height: 60, + } as DuplicateState['position'], + }), + { x: 100, y: 80 }, + ); + + const ghost = host.firstElementChild as HTMLElement; + expect(ghost.style.width).toBe('120px'); + expect(ghost.style.height).toBe('60px'); + expect(ghost.style.transform).toBe('translate3d(40px, 50px, 0)'); + expect(ghost.style.overflow).toBe('hidden'); + }); + + it('이미지가 없는 고스트는 동결된 표시 이름과 기본 크기를 유지한다', () => { + renderGhost(duplicate({ elementType: 'key', keyName: 'Enter' }), { + x: 30, + y: 30, + }); + + expect(host.textContent).toContain('Enter'); + const ghost = host.firstElementChild as HTMLElement; + expect(ghost.style.width).toBe('60px'); + expect(ghost.style.height).toBe('60px'); + expect(ghost.style.transform).toBe('translate3d(0px, 0px, 0)'); + }); + + it('replace 이미지는 기본 표면을 억제하고 원본 fit을 사용한다', () => { + const image = 'data:image/png;base64,AA=='; + renderGhost( + duplicate({ + elementType: 'key', + position: { + id: 'key', + dx: 0, + dy: 0, + inactiveImage: image, + imageMode: 'replace', + idleImageFit: 'contain', + } as DuplicateState['position'], + }), + { x: 30, y: 30 }, + ); + + const ghost = host.firstElementChild as HTMLElement; + expect(ghost.style.backgroundColor).toBe('transparent'); + expect(ghost.style.overflow).toBe('hidden'); + const preview = host.querySelector('img') as HTMLImageElement; + expect(preview.src).toBe(image); + expect(preview.style.objectFit).toBe('contain'); + }); +}); diff --git a/src/renderer/components/main/Grid/core/DuplicateElementGhost.tsx b/src/renderer/components/main/Grid/core/DuplicateElementGhost.tsx new file mode 100644 index 000000000..ff8925dc4 --- /dev/null +++ b/src/renderer/components/main/Grid/core/DuplicateElementGhost.tsx @@ -0,0 +1,162 @@ +import type { CSSProperties } from 'react'; + +import type { DuplicateState } from '@hooks/Grid/useGridCanvasActions'; +import { resolveImageSource } from '@utils/core/imageSource'; +import { + DEFAULT_IMAGE_MODE, + imageTransformToCss, +} from '@src/types/key/imageLayer'; +import { + DEFAULT_ELEMENT_ACTIVE_SHADOW_SPEC, + DEFAULT_ELEMENT_BG, + DEFAULT_ELEMENT_FONT, + DEFAULT_ELEMENT_RADIUS, + DEFAULT_ELEMENT_SHADOW_SPEC, +} from '@utils/core/elementDefaults'; +import { resolveElementBorder } from '@utils/core/elementBorder'; +import { gradientRingStyle, gradientToCss } from '@src/types/color'; +import { + elementShadowToCss, + resolveElementShadow, +} from '@src/types/key/shadows'; + +interface DuplicateElementGhostProps { + duplicate: DuplicateState | null; + cursor: { x: number; y: number } | null; +} + +const GhostBorderRing = ({ suppressDefault }: { suppressDefault: boolean }) => { + const border = resolveElementBorder({}, false, { suppressDefault }); + if (!border.gradient || border.width <= 0) return null; + return ( +
); }; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleImagePickerPopup.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleImagePickerPopup.tsx index 3f93786f2..cb6ecd33d 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleImagePickerPopup.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleImagePickerPopup.tsx @@ -1,3 +1,4 @@ +/* eslint-disable react-hooks/refs -- 피커 open 시점 reference DOM 존재 계약 */ import React from 'react'; import type { ImageFit, KeyPosition } from '@src/types/key/keys'; import type { @@ -21,6 +22,10 @@ interface SingleImagePickerPopupProps { panelElement?: HTMLElement | null; canvasAnchor?: GradientCanvasAnchor; showActiveState: boolean; + showTransformControls?: boolean; + bindActiveState?: boolean; + fallbackEmptyImageFit?: boolean; + requireMountedReference?: boolean; onToggle?: () => void; onInactiveImageCommit?: (imageUrl: string) => void; onActiveImageCommit?: (imageUrl: string) => void; @@ -39,6 +44,10 @@ const SingleImagePickerPopup = ({ panelElement, canvasAnchor, showActiveState, + showTransformControls = showActiveState, + bindActiveState = true, + fallbackEmptyImageFit = false, + requireMountedReference = false, onToggle, onInactiveImageCommit, onActiveImageCommit, @@ -76,10 +85,19 @@ const SingleImagePickerPopup = ({ value: applyImageTransformLeaf(keyPosition[property], { leaf, value }), }); }; + const hasMountedReference = + imageButtonRef && + (!requireMountedReference || Boolean(imageButtonRef.current)); + const idleImageFit = fallbackEmptyImageFit + ? keyPosition.idleImageFit || keyPosition.imageFit || 'cover' + : keyPosition.idleImageFit ?? keyPosition.imageFit ?? 'cover'; + const activeImageFit = fallbackEmptyImageFit + ? keyPosition.activeImageFit || keyPosition.imageFit || 'cover' + : keyPosition.activeImageFit ?? keyPosition.imageFit ?? 'cover'; return ( - {open && onToggle && imageButtonRef ? ( + {open && onToggle && hasMountedReference ? ( onInactiveImageCommit?.(imageUrl)} - onActiveImageChange={(imageUrl) => onActiveImageCommit?.(imageUrl)} onIdleTransparentChange={(checked) => onIdleTransparentCommit?.(checked) } - onActiveTransparentChange={(checked) => - onActiveTransparentCommit?.(checked) - } onIdleImageFitChange={(fit) => onIdleImageFitCommit?.(fit as ImageFit) } - onActiveImageFitChange={(fit) => - onActiveImageFitCommit?.(fit as ImageFit) - } onIdleImageReset={() => onInactiveImageCommit?.('')} - onActiveImageReset={() => onActiveImageCommit?.('')} - {...(showActiveState + {...(bindActiveState + ? { + onActiveImageChange: (imageUrl: string) => + onActiveImageCommit?.(imageUrl), + onActiveTransparentChange: (checked: boolean) => + onActiveTransparentCommit?.(checked), + onActiveImageFitChange: (fit: string) => + onActiveImageFitCommit?.(fit as ImageFit), + onActiveImageReset: () => onActiveImageCommit?.(''), + } + : {})} + {...(showTransformControls ? { imageMode: keyPosition.imageMode, idleImageTransform: keyPosition.idleImageTransform, diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleKnobPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleKnobPanel.tsx index f14ee8059..964d133bc 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleKnobPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleKnobPanel.tsx @@ -41,10 +41,8 @@ import { import Checkbox from '@components/main/common/Checkbox'; import ColorPicker from '@components/main/Modal/content/pickers/ColorPicker'; import PopupExit from '@components/main/Modal/PopupExit'; -import ImagePicker from '@components/main/Modal/content/pickers/ImagePicker'; import { ColorSwatchButton } from '@components/main/Modal/content/pickers/ColorSwatch'; import ShadowControls from '../ShadowControls'; -import { AXIS_FIELD_WIDTH } from '@utils/cardRecipes'; import EditSessionBoundary from '../EditSessionBoundary'; import type { GeometryField } from '@src/renderer/editor/runtime/elementOps'; import type { @@ -55,6 +53,8 @@ import type { EditorElementPropertyPatchV1, } from '@src/types/editor'; import RenameIcon from './RenameIcon'; +import SingleGeometrySection from './SingleGeometrySection'; +import SingleImagePickerPopup from './SingleImagePickerPopup'; // ============================================================================ // Single Knob Selection Panel @@ -504,69 +504,13 @@ export const SingleKnobPanel: React.FC = ({ - - - { - handleGeometryCommit?.('dx', value); - }} - onPreview={(value) => handleGeometryPreview?.('dx', value)} - onCancel={() => editGestureController.cancel()} - prefix="X" - width={AXIS_FIELD_WIDTH} - min={-9999} - max={9999} - allowDecimal - decimalScale={1} - /> - { - handleGeometryCommit?.('dy', value); - }} - onPreview={(value) => handleGeometryPreview?.('dy', value)} - onCancel={() => editGestureController.cancel()} - prefix="Y" - width={AXIS_FIELD_WIDTH} - min={-9999} - max={9999} - allowDecimal - decimalScale={1} - /> - - - - { - handleGeometryCommit?.('width', Math.max(20, value)); - }} - onPreview={(value) => - handleGeometryPreview?.('width', Math.max(20, value)) - } - onCancel={() => editGestureController.cancel()} - prefix="W" - width={AXIS_FIELD_WIDTH} - min={20} - max={9999} - /> - { - handleGeometryCommit?.('height', Math.max(20, value)); - }} - onPreview={(value) => - handleGeometryPreview?.('height', Math.max(20, value)) - } - onCancel={() => editGestureController.cancel()} - prefix="H" - width={AXIS_FIELD_WIDTH} - min={20} - max={9999} - /> - - + {/* 회전 배율: 1 = 물리 1회전당 화면 1회전 (축 해상도 무관) */} @@ -783,52 +727,24 @@ export const SingleKnobPanel: React.FC = ({
- - {showImagePicker && imageButtonRef.current ? ( - - onInactiveImageCommit?.(imageUrl) - } - onActiveImageChange={(imageUrl: string) => - onActiveImageCommit?.(imageUrl) - } - onIdleTransparentChange={(value: boolean) => - onIdleTransparentCommit?.(value) - } - onActiveTransparentChange={(value: boolean) => - onActiveTransparentCommit?.(value) - } - onIdleImageFitChange={(fit: string) => - onIdleImageFitCommit?.(fit as ImageFit) - } - onActiveImageFitChange={(fit: string) => - onActiveImageFitCommit?.(fit as ImageFit) - } - onIdleImageReset={() => onInactiveImageCommit?.('')} - onActiveImageReset={() => onActiveImageCommit?.('')} - onClose={() => setShowImagePicker(false)} - /> - ) : null} - + setShowImagePicker(false)} + onInactiveImageCommit={onInactiveImageCommit} + onActiveImageCommit={onActiveImageCommit} + onIdleTransparentCommit={onIdleTransparentCommit} + onActiveTransparentCommit={onActiveTransparentCommit} + onIdleImageFitCommit={onIdleImageFitCommit} + onActiveImageFitCommit={onActiveImageFitCommit} + /> {/* 대기/입력 색상 ColorPicker (키 패널과 동일한 stateMode 토글) */} diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/singleGeometryBindings.test.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/singleGeometryBindings.test.tsx index aa33b7fa3..c8ef1c98c 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/singleGeometryBindings.test.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/singleGeometryBindings.test.tsx @@ -11,6 +11,8 @@ const captured = vi.hoisted(() => ({ onPreview?: (value: number) => void; min?: number; max?: number; + allowDecimal?: boolean; + decimalScale?: number; } >(), numberList: [] as Array<{ @@ -19,6 +21,8 @@ const captured = vi.hoisted(() => ({ onPreview?: (value: number) => void; min?: number; max?: number; + allowDecimal?: boolean; + decimalScale?: number; }>, optionalNumbers: [] as Array<{ value?: number; @@ -55,16 +59,23 @@ const captured = vi.hoisted(() => ({ }>, image: null as null | { completionBinding?: string; + showActiveState?: boolean; + imageMode?: string; + onImageModeChange?: (mode: string) => void; + previewAnchor?: { + kind: 'key' | 'stat' | 'graph' | 'knob'; + id: string; + } | null; onIdleImageChange: (value: string) => void; onIdleImageReset: () => void; onActiveImageChange?: (value: string) => void; onActiveImageReset?: () => void; idleTransparent?: boolean; activeTransparent?: boolean; - onIdleTransparentChange?: (value: boolean) => void; - onActiveTransparentChange?: (value: boolean) => void; idleImageFit?: string; activeImageFit?: string; + onIdleTransparentChange?: (value: boolean) => void; + onActiveTransparentChange?: (value: boolean) => void; onIdleImageFitChange?: (value: string) => void; onActiveImageFitChange?: (value: string) => void; }, @@ -310,6 +321,7 @@ import { } from './SingleSelectionPanel'; import { createDefaultKeyPosition } from '@src/renderer/editor/model/keys'; import { isEditorElementPropertyPatchV1 } from '@src/types/editor'; +import type { ImageFit } from '@src/types/key/keys'; type CompatProps = React.ComponentProps & Record; @@ -1559,6 +1571,91 @@ describe('single geometry input bindings', () => { expect(legacy).not.toHaveBeenCalled(); }); + it.each(['graph', 'knob'] as const)( + '%s ImagePicker는 active-state와 이미지 변환 지원 범위를 유지한다', + (type) => { + const id = '11111111-1111-4111-8111-111111111111'; + const common = { + setPanelElement: vi.fn(), + selectedKeyType: '4key', + isRenaming: false, + renameInputRef: createRef(), + renameValue: '', + setRenameValue: vi.fn(), + renameCancelledRef: { current: false }, + handleRenameCommit: vi.fn(), + handleRenameCancel: vi.fn(), + handleRenameStart: vi.fn(), + singleScrollRefFor: () => vi.fn(), + panelElement: null, + useCustomCSS: false, + t: (key: string) => key, + }; + act(() => { + root.render( + type === 'graph' ? ( + + ) : ( + + ), + ); + }); + if (type === 'knob') { + const configure = [...container.querySelectorAll('button')].find( + (button) => button.textContent === 'propertiesPanel.configure', + ); + act(() => configure?.click()); + } + + expect(captured.image?.showActiveState ?? true).toBe(type === 'knob'); + expect(captured.image?.activeTransparent).toBe(type === 'knob'); + expect(captured.image?.idleImageFit).toBe('contain'); + expect(captured.image?.activeImageFit).toBe('contain'); + expect(captured.image?.imageMode).toBeUndefined(); + expect(captured.image?.onImageModeChange).toBeUndefined(); + expect(typeof captured.image?.onActiveImageChange).toBe( + type === 'knob' ? 'function' : 'undefined', + ); + expect(captured.image?.previewAnchor ?? null).toEqual( + type === 'knob' ? { kind: 'knob', id } : null, + ); + }, + ); + it.each(['graph', 'knob'] as const)( '%s ImagePicker load와 reset은 stable callback이 있으면 legacy를 쓰지 않는다', (type) => { @@ -1906,6 +2003,91 @@ describe('single geometry input bindings', () => { }, ); + it.each([ + ['graph', 0, 0, 200, 100], + ['knob', 60.4, 60.6, 60, 61], + ] as const)( + '%s 기하는 전용 기본 크기·정수 표시·20..9999 정책을 유지한다', + (type, width, height, expectedWidth, expectedHeight) => { + const common = { + setPanelElement: vi.fn(), + selectedKeyType: '4key', + isRenaming: false, + renameInputRef: createRef(), + renameValue: '', + setRenameValue: vi.fn(), + renameCancelledRef: { current: false }, + handleRenameCommit: vi.fn(), + handleRenameCancel: vi.fn(), + handleRenameStart: vi.fn(), + singleScrollRefFor: () => vi.fn(), + panelElement: null, + useCustomCSS: false, + t: (key: string) => key, + }; + act(() => { + root.render( + type === 'graph' ? ( + ()} + graphClassNameDraft="" + setGraphClassNameDraft={vi.fn()} + /> + ) : ( + + ), + ); + }); + + expect(captured.numbers.get('X')).toMatchObject({ + min: -9999, + max: 9999, + allowDecimal: true, + decimalScale: 1, + }); + expect(captured.numbers.get('Y')).toMatchObject({ + min: -9999, + max: 9999, + allowDecimal: true, + decimalScale: 1, + }); + expect(captured.numbers.get('W')).toMatchObject({ + value: expectedWidth, + min: 20, + max: 9999, + }); + expect(captured.numbers.get('H')).toMatchObject({ + value: expectedHeight, + min: 20, + max: 9999, + }); + expect(captured.numbers.get('W')?.allowDecimal).toBeUndefined(); + expect(captured.numbers.get('H')?.decimalScale).toBeUndefined(); + }, + ); + it('knob 민감도는 preview 동안 패널 draft만 바꾸고 onChange에서 한 번 커밋한다', () => { const commit = vi.fn(); act(() => { From 71630a4a46456386121969b0de2028056e20ee7a Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 16:00:58 +0900 Subject: [PATCH 112/245] =?UTF-8?q?test:=20=EC=98=A4=EB=B2=84=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=20=ED=82=A4=20=EC=83=81=ED=83=9C=20=EB=9F=B0=ED=83=80?= =?UTF-8?q?=EC=9E=84=20=EA=B3=84=EC=95=BD=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/windows/overlay/App.test.tsx | 138 +++++++++++++++++++--- 1 file changed, 123 insertions(+), 15 deletions(-) diff --git a/src/renderer/windows/overlay/App.test.tsx b/src/renderer/windows/overlay/App.test.tsx index b1f03b5ac..159a87d2a 100644 --- a/src/renderer/windows/overlay/App.test.tsx +++ b/src/renderer/windows/overlay/App.test.tsx @@ -12,8 +12,11 @@ import { globalThis.IS_REACT_ACT_ENVIRONMENT = true; const mocks = vi.hoisted(() => ({ + axisInitialize: vi.fn(), bootstrap: vi.fn(), + keyInitialize: vi.fn(), keyEventListener: null as null | ((payload: unknown) => void), + keySubscribe: vi.fn(), unsubscribeKeyEvents: vi.fn(), updateTrackLayouts: vi.fn(), handleKeyDown: vi.fn(), @@ -24,7 +27,11 @@ const mocks = vi.hoisted(() => ({ notesRef: { current: {} }, noteBuffer: {}, resyncListener: null as null | (() => void), + resyncSubscribe: vi.fn(), + unsubscribeResync: vi.fn(), keysResetListener: null as null | ((payload: unknown) => void), + keysResetSubscribe: vi.fn(), + unsubscribeKeysReset: vi.fn(), sceneRenders: { count: 0 }, })); @@ -149,23 +156,17 @@ vi.mock('@hooks/shared/useLayoutComputation', () => ({ }), })); vi.mock('@utils/core/axisEventBus', () => ({ - axisEventBus: { initialize: vi.fn() }, + axisEventBus: { initialize: mocks.axisInitialize }, })); vi.mock('@utils/core/keyEventBus', () => ({ keyEventBus: { - subscribe: vi.fn((listener: (payload: unknown) => void) => { - mocks.keyEventListener = listener; - return mocks.unsubscribeKeyEvents; - }), - initialize: vi.fn(() => Promise.resolve()), + subscribe: mocks.keySubscribe, + initialize: mocks.keyInitialize, }, })); vi.mock('@api/modules/obsApi', () => ({ obsApi: { - onResync: vi.fn((listener: () => void) => { - mocks.resyncListener = listener; - return vi.fn(); - }), + onResync: mocks.resyncSubscribe, }, })); @@ -241,20 +242,41 @@ const makeApiMock = () => ({ app: { bootstrap: mocks.bootstrap }, keys: { - onKeysReset: vi.fn((listener: (payload: unknown) => void) => { - mocks.keysResetListener = listener; - return vi.fn(); - }), + onKeysReset: mocks.keysResetSubscribe, }, } as unknown as Window['api']); const resetSharedMocks = () => { + mocks.axisInitialize.mockReset(); mocks.bootstrap.mockReset(); mocks.bootstrap.mockResolvedValue({ activeKeys: [] }); + mocks.keyInitialize.mockReset(); + mocks.keyInitialize.mockResolvedValue(undefined); mocks.keyEventListener = null; + mocks.keySubscribe.mockReset(); + mocks.keySubscribe.mockImplementation( + (listener: (payload: unknown) => void) => { + mocks.keyEventListener = listener; + return mocks.unsubscribeKeyEvents; + }, + ); mocks.resyncListener = null; + mocks.resyncSubscribe.mockReset(); + mocks.resyncSubscribe.mockImplementation((listener: () => void) => { + mocks.resyncListener = listener; + return mocks.unsubscribeResync; + }); mocks.keysResetListener = null; + mocks.keysResetSubscribe.mockReset(); + mocks.keysResetSubscribe.mockImplementation( + (listener: (payload: unknown) => void) => { + mocks.keysResetListener = listener; + return mocks.unsubscribeKeysReset; + }, + ); mocks.unsubscribeKeyEvents.mockClear(); + mocks.unsubscribeResync.mockClear(); + mocks.unsubscribeKeysReset.mockClear(); mocks.updateTrackLayouts.mockClear(); mocks.handleKeyDown.mockClear(); mocks.handleKeyUp.mockClear(); @@ -267,7 +289,13 @@ const resetSharedMocks = () => { describe('overlay active key reconciliation', () => { let container: HTMLDivElement; let root: Root; + let isMounted: boolean; let originalApi: Window['api']; + let initialRuntimeOrder: { + subscribe: number; + initialize: number; + hydrate: number; + }; beforeEach(async () => { originalApi = window.api; @@ -293,18 +321,98 @@ describe('overlay active key reconciliation', () => { await act(async () => { root.render(); }); + isMounted = true; await flushAsync(); + initialRuntimeOrder = { + subscribe: mocks.keySubscribe.mock.invocationCallOrder[0], + initialize: mocks.keyInitialize.mock.invocationCallOrder[0], + hydrate: mocks.bootstrap.mock.invocationCallOrder[0], + }; mocks.bootstrap.mockClear(); + mocks.finalizeAllActive.mockClear(); }); afterEach(() => { - act(() => root.unmount()); + if (isMounted) { + act(() => root.unmount()); + } container.remove(); resetAllKeySignals(); window.api = originalApi; vi.restoreAllMocks(); }); + it('키 버스는 1회 구독하고 구독·초기화 뒤 최초 스냅샷을 수화한다', async () => { + expect(initialRuntimeOrder.subscribe).toBeLessThan( + initialRuntimeOrder.initialize, + ); + expect(initialRuntimeOrder.initialize).toBeLessThan( + initialRuntimeOrder.hydrate, + ); + + await act(async () => { + useKeyStore.setState({ + positions: { + '4key': [createDefaultKeyPosition(10, 20)], + '8key': [], + }, + }); + useSettingsStore.setState({ backgroundColor: '#123456' }); + }); + + expect(mocks.axisInitialize).toHaveBeenCalledTimes(1); + expect(mocks.keySubscribe).toHaveBeenCalledTimes(1); + expect(mocks.keyInitialize).toHaveBeenCalledTimes(1); + }); + + it('탭 전환은 활성 노트를 완료하고 신호를 리셋한 뒤 권위 상태를 재수화한다', async () => { + setKeyActive('KeyK', true); + let wasResetBeforeHydration = false; + mocks.bootstrap.mockImplementationOnce(async () => { + wasResetBeforeHydration = !getKeySignal('KeyK').value; + return { activeKeys: ['KeyQ'] }; + }); + + await act(async () => { + useKeyStore.setState({ selectedKeyType: '8key' }); + }); + await flushAsync(); + + expect(mocks.finalizeAllActive).toHaveBeenCalledTimes(1); + expect(wasResetBeforeHydration).toBe(true); + expect(mocks.finalizeAllActive.mock.invocationCallOrder[0]).toBeLessThan( + mocks.bootstrap.mock.invocationCallOrder[0], + ); + expect(getKeySignal('KeyK').value).toBe(false); + expect(getKeySignal('KeyQ').value).toBe(true); + }); + + it('키 버스 초기화 대기 중 언마운트해도 완료 후 구독을 해제한다', async () => { + act(() => root.unmount()); + isMounted = false; + await flushAsync(); + mocks.unsubscribeKeyEvents.mockClear(); + + const initialize = deferred(); + mocks.keyInitialize.mockReset(); + mocks.keyInitialize.mockReturnValue(initialize.promise); + root = createRoot(container); + await act(async () => { + root.render(); + await Promise.resolve(); + }); + isMounted = true; + + act(() => root.unmount()); + isMounted = false; + expect(mocks.unsubscribeKeyEvents).not.toHaveBeenCalled(); + + initialize.resolve(); + await flushAsync(); + + expect(mocks.unsubscribeKeyEvents).toHaveBeenCalledTimes(1); + }); + it('positions 변경에는 눌림 상태를 재설정하지 않는다', async () => { setKeyActive('KeyK', true); From 8568d5d1eb68c6ce4af625ccb22c2ee2b99da368 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 16:01:02 +0900 Subject: [PATCH 113/245] =?UTF-8?q?refactor:=20=EC=98=A4=EB=B2=84=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=20=ED=82=A4=20=EC=83=81=ED=83=9C=20=EB=9F=B0=ED=83=80?= =?UTF-8?q?=EC=9E=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../overlay/useOverlayKeyStateRuntime.ts | 446 ++++++++++++++++++ src/renderer/windows/overlay/App.tsx | 407 +--------------- 2 files changed, 451 insertions(+), 402 deletions(-) create mode 100644 src/renderer/hooks/overlay/useOverlayKeyStateRuntime.ts diff --git a/src/renderer/hooks/overlay/useOverlayKeyStateRuntime.ts b/src/renderer/hooks/overlay/useOverlayKeyStateRuntime.ts new file mode 100644 index 000000000..8cc8298df --- /dev/null +++ b/src/renderer/hooks/overlay/useOverlayKeyStateRuntime.ts @@ -0,0 +1,446 @@ +import { useEffect, useMemo, useRef } from 'react'; +import { obsApi } from '@api/modules/obsApi'; +import { MAX_EVENT_AGE_MS } from '@constants/inputTiming'; +import type { NoteKeyTiming } from '@hooks/overlay/useNoteSystem'; +import { + resetAllKeySignals, + setKeyActive as setKeyActiveSignal, +} from '@stores/signals/keySignals'; +import type { KeyMappings, KeyPosition, KeySlot } from '@src/types/key/keys'; +import { + buildCanonicalIndexMap, + isSlotAssigned, + slotCanonical, + slotDisplayName, +} from '@utils/keySlot'; + +type KeyDelayTimerHandle = ReturnType; + +type KeyDelayTimerEntry = { + timers: Map void>; +}; + +interface UseOverlayKeyStateRuntimeOptions { + noteEffect: boolean; + keyDisplayDelayMs: number; + keyMappings: KeyMappings; + currentSlots: readonly KeySlot[]; + positions: Record; + selectedKeyType: string; + handleKeyDown: (keyName: string, timing?: NoteKeyTiming) => void; + handleKeyUp: (keyName: string, timing?: NoteKeyTiming) => void; + finalizeAllActive: () => void; + reconcileActiveNotes: (activeKeys: ReadonlySet) => void; +} + +interface OverlayKeyStateRuntime { + currentKeys: string[]; + currentKeyLabels: string[]; +} + +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(); + }); +}; + +const validKeySet = (slots: readonly KeySlot[]) => + new Set(slots.filter(isSlotAssigned).map((slot) => slotCanonical(slot))); + +const validKeySignature = (slots: readonly KeySlot[]) => + JSON.stringify([...validKeySet(slots)].sort()); + +export const useOverlayKeyStateRuntime = ({ + noteEffect, + keyDisplayDelayMs, + keyMappings, + currentSlots, + positions, + selectedKeyType, + handleKeyDown, + handleKeyUp, + finalizeAllActive, + reconcileActiveNotes, +}: UseOverlayKeyStateRuntimeOptions): OverlayKeyStateRuntime => { + // 키 딜레이 타이머 관리 (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]); + + // 탭 전환 시 진행 중인 모든 노트 강제 완료 + useEffect(() => { + finalizeAllActive(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedKeyType]); + + // 구독 콜백이 읽는 최신 컨텍스트 - 구독 자체는 마운트 1회로 고정 + // positions·keyMappings가 deps에 있으면 undo의 canonical 복원마다 재구독되고, + // cleanup의 resetAllKeySignals가 눌림 표시 중간에 끼어들어 이중 깜빡임 발생 + const keyEventContextRef = useRef({ + noteEffect, + keyMappings, + positions, + selectedKeyType, + handleKeyDown, + handleKeyUp, + finalizeAllActive, + reconcileActiveNotes, + }); + useEffect(() => { + keyEventContextRef.current = { + noteEffect, + keyMappings, + positions, + selectedKeyType, + handleKeyDown, + handleKeyUp, + finalizeAllActive, + reconcileActiveNotes, + }; + }); + + // 리셋 이후 이벤트가 도착한 키 추적 - 스냅샷 재수화보다 최신 이벤트가 우선 + const seenSinceResetRef = useRef>(new Set()); + // 대조(reconcile) fetch 이후 도착한 실이벤트 추적 - null이면 수집 안 함 + const reconcileSeenRef = useRef | null>(null); + // 중첩 대조의 낡은 응답 차단 - keys:reset·탭 전환·새 대조가 세대를 올림 + const reconcileGenerationRef = useRef(0); + // 탭 전환 재수화가 구독 확립을 기다릴 수 있게 구독 준비 promise 보관 + const keyEventsReadyRef = useRef | null>(null); + + useEffect(() => { + // 키 딜레이 적용된 신호 업데이트 + const updateKeySignalWithDelay = (key: string, isDown: boolean) => { + const delayMs = keyDisplayDelayMsRef.current; + + let timerEntry = keyDelayTimersRef.current.get(key); + if (!timerEntry) { + timerEntry = { timers: new Map() }; + keyDelayTimersRef.current.set(key, timerEntry); + } + + if (delayMs <= 0) { + 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(() => { + 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.set(timer, apply); + pendingKeyDelayTimersRef.current.set(timer, apply); + }; + + // HID 축 이벤트 버스 초기화 (input:axis 구독 → axisSignals 누적) + import('@utils/core/axisEventBus').then(({ axisEventBus }) => { + axisEventBus.initialize(); + }); + + let hydrationCancelled = false; + + // 키 이벤트 구독을 먼저 확립한 뒤 눌림 스냅샷 요청 + const unsubscribe = import('@utils/core/keyEventBus').then( + async ({ keyEventBus }) => { + if (hydrationCancelled) return undefined; + const unsubscribeKeyEvents = keyEventBus.subscribe( + ({ key, state, mode, eventAgeMs, holdDurationMs }) => { + seenSinceResetRef.current.add(key); + reconcileSeenRef.current?.add(`${mode}::${key}`); + const isDown = state === 'DOWN'; + // 키 UI 업데이트 (딜레이 적용) + updateKeySignalWithDelay(key, isDown); + const { + noteEffect, + keyMappings, + positions, + selectedKeyType, + handleKeyDown, + handleKeyUp, + } = keyEventContextRef.current; + // 노트 이펙트는 즉시 처리 (딜레이 없음) + if (noteEffect) { + // 실제 입력 시각을 복원해 노트 시작 위치를 보정 (프레임 양자화 방지). + // displayTime은 0~MAX_EVENT_AGE_MS 클램프 - 백엔드 stall/클럭 이상 시 + // 노트가 화면 위로 튀는 것을 방지. physTime은 비클램프 - 단/롱 판정의 + // hold 폴백 계산 전용이라 클램프 절단 왜곡을 받지 않음 + const rawAge = Math.max(eventAgeMs ?? 0, 0); + const displayAge = Math.min(rawAge, MAX_EVENT_AGE_MS); + const now = performance.now(); + const timing = { + displayTime: now - displayAge, + physTime: now - rawAge, + holdDurationMs, + }; + + if (isDown) { + // 개별 키의 noteEffectEnabled는 DOWN에만 적용 + // canonical → 대표 슬롯 인덱스 (Multi 우선, 계약 §11) + const currentSlots = keyMappings[selectedKeyType] ?? []; + const currentPositions = positions[selectedKeyType] ?? []; + const keyIndex = + buildCanonicalIndexMap(currentSlots).get(key) ?? -1; + const keyPosition = currentPositions[keyIndex]; + if (keyPosition?.noteEffectEnabled !== false) { + handleKeyDown(key, timing); + } + } else { + // UP은 항상 전달 - DOWN 이후 설정이 꺼진 키의 활성 노트 고착 방지 + handleKeyUp(key, timing); + } + } + }, + ); + + try { + await keyEventBus.initialize(); + } catch (error) { + unsubscribeKeyEvents(); + throw error; + } + + if (hydrationCancelled) return unsubscribeKeyEvents; + + // 지연 생성된 오버레이 hydration — 구독 이후 이벤트가 온 키는 + // 최신 이벤트가 스냅샷보다 우선하며 KPS·노트 통계에는 반영하지 않음 + const hydrationSeen = seenSinceResetRef.current; + void window.api.app + .bootstrap() + .then(({ activeKeys }) => { + if (hydrationCancelled || !activeKeys?.length) return; + // 탭 전환 리셋이 끼었으면 이 스냅샷은 낡음 - 전환 effect의 재수화가 담당 + if (seenSinceResetRef.current !== hydrationSeen) return; + const { keyMappings, selectedKeyType } = keyEventContextRef.current; + const validKeys = validKeySet(keyMappings[selectedKeyType] ?? []); + for (const key of activeKeys) { + if (validKeys.has(key) && !hydrationSeen.has(key)) { + setKeyActiveSignal(key, true); + } + } + }) + .catch((error) => { + if (!hydrationCancelled) { + console.error('Failed to hydrate active key state', error); + } + }); + + return unsubscribeKeyEvents; + }, + ); + keyEventsReadyRef.current = unsubscribe; + void unsubscribe.catch((error) => { + console.error('Failed to initialize key state listener', error); + }); + + // UP 유실 복구 - fresh 스냅샷과 활성 노트·눌림 신호를 대조 (실패 복구 경로) + const reconcileWithBootstrap = async (): Promise => { + const generation = reconcileGenerationRef.current + 1; + reconcileGenerationRef.current = generation; + const sinceFetch = new Set(); + reconcileSeenRef.current = sinceFetch; + try { + const payload = await window.api.app.bootstrap(); + if (hydrationCancelled) return; + // 더 새로운 대조나 keys:reset·탭 전환이 끼었으면 이 응답은 낡음 + if (generation !== reconcileGenerationRef.current) return; + const { selectedKeyType, keyMappings, reconcileActiveNotes } = + keyEventContextRef.current; + // 모드 삼중 일치에서만 대조 - 비원자 스냅샷·낙관적 모드 전환 방어 + if ( + !payload.currentMode || + payload.currentMode !== payload.selectedKeyType || + payload.currentMode !== selectedKeyType + ) { + return; + } + const held = new Set(payload.activeKeys ?? []); + // fetch 이후 실이벤트가 도착한 키는 그 이벤트가 최신 - 대조에서 제외 + for (const entry of sinceFetch) { + const sep = entry.indexOf('::'); + if (sep < 0) continue; + if (entry.slice(0, sep) === payload.currentMode) { + held.add(entry.slice(sep + 2)); + } + } + reconcileActiveNotes(held); + // 고착된 눌림 하이라이트도 같은 기준으로 정정 + const validKeys = validKeySet(keyMappings[selectedKeyType] ?? []); + for (const key of validKeys) { + if (!sinceFetch.has(`${payload.currentMode}::${key}`)) { + setKeyActiveSignal(key, held.has(key)); + } + } + } catch (error) { + if (!hydrationCancelled) { + console.error('Failed to reconcile active notes', error); + } + } finally { + if (reconcileSeenRef.current === sinceFetch) { + reconcileSeenRef.current = null; + } + } + }; + + // OBS Lagged/재연결 스냅샷은 유실된 keys:state를 개별 복구하지 못하므로 대조로 정리 + const unsubscribeResync = obsApi.onResync(() => { + void reconcileWithBootstrap(); + }); + + // 키보드 훅 (재)시작 - 이전 눌림 상태가 통째로 무효화되므로 전체 리셋 후 재수화 + const unsubscribeKeysReset = window.api.keys.onKeysReset(() => { + // 진행 중인 대조의 낡은 스냅샷이 리셋 이후 상태를 덮지 못하게 무효화 + reconcileGenerationRef.current += 1; + const { finalizeAllActive } = keyEventContextRef.current; + finalizeAllActive(); + cancelKeyDelayTimers( + keyDelayTimersRef.current, + pendingKeyDelayTimersRef.current, + ); + const seen = new Set(); + seenSinceResetRef.current = seen; + resetAllKeySignals(); + void window.api.app + .bootstrap() + .then(({ activeKeys }) => { + if (hydrationCancelled || !activeKeys?.length) return; + if (seenSinceResetRef.current !== seen) return; + const { keyMappings, selectedKeyType } = keyEventContextRef.current; + const validKeys = validKeySet(keyMappings[selectedKeyType] ?? []); + for (const key of activeKeys) { + if (validKeys.has(key) && !seen.has(key)) { + setKeyActiveSignal(key, true); + } + } + }) + .catch((error) => { + if (!hydrationCancelled) { + console.error('Failed to rehydrate after keys reset', error); + } + }); + }); + + const keyDelayTimers = keyDelayTimersRef.current; + const pendingKeyDelayTimers = pendingKeyDelayTimersRef.current; + + return () => { + hydrationCancelled = true; + unsubscribeResync(); + unsubscribeKeysReset(); + void unsubscribe + .then((unsub) => { + try { + unsub?.(); + } catch (error) { + console.error('Failed to remove key state listener', error); + } + }) + .catch(() => undefined); + // 키 딜레이 타이머 정리 + cancelKeyDelayTimers(keyDelayTimers, pendingKeyDelayTimers); + // 창 단위 정리 - 마운트 1회 구독이므로 여기는 실제 언마운트에서만 실행됨 + resetAllKeySignals(); + }; + // 콜백이 읽는 값은 keyEventContextRef로 공급 - 구독은 창 수명과 동일 + }, []); + + // 시그널·트랙 키는 canonical, 표시는 합성 라벨 (계약 §3, §11) + const { currentKeys, currentKeyLabels, currentValidKeySignature } = useMemo( + () => ({ + currentKeys: currentSlots.map((slot) => slotCanonical(slot)), + currentKeyLabels: currentSlots.map((slot) => slotDisplayName(slot)), + currentValidKeySignature: validKeySignature(currentSlots), + }), + [currentSlots], + ); + + // 탭·현재 키 집합 전환 시 예약 타이머와 눌림 신호를 권위 상태로 정합 + // positions와 다른 탭의 매핑 변경은 signature가 같아 이 effect를 건드리지 않음 + const keySignalResetArmedRef = useRef(false); + useEffect(() => { + if (!keySignalResetArmedRef.current) { + // 초기 마운트 수화는 구독 effect가 담당 + keySignalResetArmedRef.current = true; + return; + } + // 탭 전환은 진행 중 대조의 스냅샷을 낡게 만든다 + reconcileGenerationRef.current += 1; + let cancelled = false; + cancelKeyDelayTimers( + keyDelayTimersRef.current, + pendingKeyDelayTimersRef.current, + ); + const seen = new Set(); + const validKeys = new Set(JSON.parse(currentValidKeySignature)); + seenSinceResetRef.current = seen; + resetAllKeySignals(); + void Promise.resolve(keyEventsReadyRef.current) + .then(() => window.api.app.bootstrap()) + .then(({ activeKeys }) => { + if (cancelled || !activeKeys?.length) return; + // 이후 전환의 리셋이 끼었으면 그쪽 재수화가 담당 + if (seenSinceResetRef.current !== seen) return; + for (const key of activeKeys) { + if (validKeys.has(key) && !seen.has(key)) { + setKeyActiveSignal(key, true); + } + } + }) + .catch((error) => { + if (!cancelled) { + console.error('Failed to rehydrate active key state', error); + } + }); + return () => { + cancelled = true; + }; + }, [selectedKeyType, currentValidKeySignature]); + + return { currentKeys, currentKeyLabels }; +}; diff --git a/src/renderer/windows/overlay/App.tsx b/src/renderer/windows/overlay/App.tsx index 01cf6de6f..36ba9cdf5 100644 --- a/src/renderer/windows/overlay/App.tsx +++ b/src/renderer/windows/overlay/App.tsx @@ -8,7 +8,6 @@ import { LogicalPosition, PhysicalPosition } from '@tauri-apps/api/dpi'; import { Menu } from '@tauri-apps/api/menu'; import { useTranslation } from '@contexts/useTranslation'; import { DEFAULT_NOTE_SETTINGS } from '@constants/overlayDefaults'; -import { MAX_EVENT_AGE_MS } from '@constants/inputTiming'; import { mergeNoteSettings } from '@src/types/settings/noteSettings'; import { useCustomCssInjection } from '@hooks/app/useCustomCssInjection'; import { useCustomJsInjection } from '@hooks/app/useCustomJsInjection'; @@ -20,8 +19,8 @@ import { } from '@hooks/overlay/useOverlayHitRegions'; import { useTrackReserveTransition } from '@hooks/overlay/useTrackReserveTransition'; import { useOverlayReveal } from '@hooks/overlay/useOverlayReveal'; +import { useOverlayKeyStateRuntime } from '@hooks/overlay/useOverlayKeyStateRuntime'; import { useAppBootstrap } from '@hooks/app/useAppBootstrap'; -import { obsApi } from '@api/modules/obsApi'; import { overlayApi } from '@api/modules/overlayApi'; import { settingsApi } from '@api/modules/settingsApi'; import { appApi, windowApi } from '@api/modules/appApi'; @@ -30,10 +29,6 @@ import { useKeyStore } from '@stores/data/useKeyStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; -import { - setKeyActive as setKeyActiveSignal, - resetAllKeySignals, -} from '@stores/signals/keySignals'; import { useSettingsStore } from '@stores/useSettingsStore'; import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; import { useStoreWithEqualityFn } from 'zustand/traditional'; @@ -43,54 +38,11 @@ import { } from '@utils/plugin/pluginLayoutElements'; import OverlayScene from '@components/shared/OverlayScene'; import { computeLayout } from '@hooks/shared/useLayoutComputation'; -import { - buildCanonicalIndexMap, - isSlotAssigned, - slotCanonical, - slotDisplayName, -} from '@utils/keySlot'; -import type { KeySlot } from '@src/types/key/keys'; - -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(); -}; // 슬라이스 부재 시에도 identity 안정 - computeLayout 메모 deps로 사용됨 // 이종 배열 5곳이 같은 인스턴스를 별칭하므로 freeze로 교차 오염 차단 const EMPTY_SLICE: never[] = Object.freeze([]) as never[]; -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(); - }); -}; - -const validKeySet = (slots: readonly KeySlot[]) => - new Set(slots.filter(isSlotAssigned).map((slot) => slotCanonical(slot))); - -const validKeySignature = (slots: readonly KeySlot[]) => - JSON.stringify([...validKeySet(slots)].sort()); - export default function App() { const isBootstrapped = useKeyStore((state) => state.isBootstrapped); useCustomCssInjection(); @@ -403,37 +355,12 @@ 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]); - - // 탭 전환 시 진행 중인 모든 노트 강제 완료 - useEffect(() => { - finalizeAllActive(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedKeyType]); - - // 구독 콜백이 읽는 최신 컨텍스트 - 구독 자체는 마운트 1회로 고정 - // positions·keyMappings가 deps에 있으면 undo의 canonical 복원마다 재구독되고, - // cleanup의 resetAllKeySignals가 눌림 표시 중간에 끼어들어 이중 깜빡임 발생 - const keyEventContextRef = useRef({ + const currentSlots = keyMappings[selectedKeyType] ?? EMPTY_SLICE; + const { currentKeys, currentKeyLabels } = useOverlayKeyStateRuntime({ noteEffect, + keyDisplayDelayMs, keyMappings, + currentSlots, positions, selectedKeyType, handleKeyDown, @@ -441,330 +368,6 @@ export default function App() { finalizeAllActive, reconcileActiveNotes, }); - useEffect(() => { - keyEventContextRef.current = { - noteEffect, - keyMappings, - positions, - selectedKeyType, - handleKeyDown, - handleKeyUp, - finalizeAllActive, - reconcileActiveNotes, - }; - }); - - // 리셋 이후 이벤트가 도착한 키 추적 - 스냅샷 재수화보다 최신 이벤트가 우선 - const seenSinceResetRef = useRef>(new Set()); - // 대조(reconcile) fetch 이후 도착한 실이벤트 추적 - null이면 수집 안 함 - const reconcileSeenRef = useRef | null>(null); - // 중첩 대조의 낡은 응답 차단 - keys:reset·탭 전환·새 대조가 세대를 올림 - const reconcileGenerationRef = useRef(0); - // 탭 전환 재수화가 구독 확립을 기다릴 수 있게 구독 준비 promise 보관 - const keyEventsReadyRef = useRef | null>(null); - - useEffect(() => { - // 키 딜레이 적용된 신호 업데이트 - const updateKeySignalWithDelay = (key: string, isDown: boolean) => { - const delayMs = keyDisplayDelayMsRef.current; - - let timerEntry = keyDelayTimersRef.current.get(key); - if (!timerEntry) { - timerEntry = { timers: new Map() }; - keyDelayTimersRef.current.set(key, timerEntry); - } - - if (delayMs <= 0) { - 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(() => { - 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.set(timer, apply); - pendingKeyDelayTimersRef.current.set(timer, apply); - }; - - // HID 축 이벤트 버스 초기화 (input:axis 구독 → axisSignals 누적) - import('@utils/core/axisEventBus').then(({ axisEventBus }) => { - axisEventBus.initialize(); - }); - - let hydrationCancelled = false; - - // 키 이벤트 구독을 먼저 확립한 뒤 눌림 스냅샷 요청 - const unsubscribe = import('@utils/core/keyEventBus').then( - async ({ keyEventBus }) => { - if (hydrationCancelled) return undefined; - const unsubscribeKeyEvents = keyEventBus.subscribe( - ({ key, state, mode, eventAgeMs, holdDurationMs }) => { - seenSinceResetRef.current.add(key); - reconcileSeenRef.current?.add(`${mode}::${key}`); - const isDown = state === 'DOWN'; - // 키 UI 업데이트 (딜레이 적용) - updateKeySignalWithDelay(key, isDown); - const { - noteEffect, - keyMappings, - positions, - selectedKeyType, - handleKeyDown, - handleKeyUp, - } = keyEventContextRef.current; - // 노트 이펙트는 즉시 처리 (딜레이 없음) - if (noteEffect) { - // 실제 입력 시각을 복원해 노트 시작 위치를 보정 (프레임 양자화 방지). - // displayTime은 0~MAX_EVENT_AGE_MS 클램프 - 백엔드 stall/클럭 이상 시 - // 노트가 화면 위로 튀는 것을 방지. physTime은 비클램프 - 단/롱 판정의 - // hold 폴백 계산 전용이라 클램프 절단 왜곡을 받지 않음 - const rawAge = Math.max(eventAgeMs ?? 0, 0); - const displayAge = Math.min(rawAge, MAX_EVENT_AGE_MS); - const now = performance.now(); - const timing = { - displayTime: now - displayAge, - physTime: now - rawAge, - holdDurationMs, - }; - - if (isDown) { - // 개별 키의 noteEffectEnabled는 DOWN에만 적용 - // canonical → 대표 슬롯 인덱스 (Multi 우선, 계약 §11) - const currentSlots = keyMappings[selectedKeyType] ?? []; - const currentPositions = positions[selectedKeyType] ?? []; - const keyIndex = - buildCanonicalIndexMap(currentSlots).get(key) ?? -1; - const keyPosition = currentPositions[keyIndex]; - if (keyPosition?.noteEffectEnabled !== false) { - handleKeyDown(key, timing); - } - } else { - // UP은 항상 전달 - DOWN 이후 설정이 꺼진 키의 활성 노트 고착 방지 - handleKeyUp(key, timing); - } - } - }, - ); - - try { - await keyEventBus.initialize(); - } catch (error) { - unsubscribeKeyEvents(); - throw error; - } - - if (hydrationCancelled) return unsubscribeKeyEvents; - - // 지연 생성된 오버레이 hydration — 구독 이후 이벤트가 온 키는 - // 최신 이벤트가 스냅샷보다 우선하며 KPS·노트 통계에는 반영하지 않음 - const hydrationSeen = seenSinceResetRef.current; - void window.api.app - .bootstrap() - .then(({ activeKeys }) => { - if (hydrationCancelled || !activeKeys?.length) return; - // 탭 전환 리셋이 끼었으면 이 스냅샷은 낡음 - 전환 effect의 재수화가 담당 - if (seenSinceResetRef.current !== hydrationSeen) return; - const { keyMappings, selectedKeyType } = keyEventContextRef.current; - const validKeys = validKeySet(keyMappings[selectedKeyType] ?? []); - for (const key of activeKeys) { - if (validKeys.has(key) && !hydrationSeen.has(key)) { - setKeyActiveSignal(key, true); - } - } - }) - .catch((error) => { - if (!hydrationCancelled) { - console.error('Failed to hydrate active key state', error); - } - }); - - return unsubscribeKeyEvents; - }, - ); - keyEventsReadyRef.current = unsubscribe; - void unsubscribe.catch((error) => { - console.error('Failed to initialize key state listener', error); - }); - - // UP 유실 복구 - fresh 스냅샷과 활성 노트·눌림 신호를 대조 (실패 복구 경로) - const reconcileWithBootstrap = async (): Promise => { - const generation = reconcileGenerationRef.current + 1; - reconcileGenerationRef.current = generation; - const sinceFetch = new Set(); - reconcileSeenRef.current = sinceFetch; - try { - const payload = await window.api.app.bootstrap(); - if (hydrationCancelled) return; - // 더 새로운 대조나 keys:reset·탭 전환이 끼었으면 이 응답은 낡음 - if (generation !== reconcileGenerationRef.current) return; - const { selectedKeyType, keyMappings, reconcileActiveNotes } = - keyEventContextRef.current; - // 모드 삼중 일치에서만 대조 - 비원자 스냅샷·낙관적 모드 전환 방어 - if ( - !payload.currentMode || - payload.currentMode !== payload.selectedKeyType || - payload.currentMode !== selectedKeyType - ) { - return; - } - const held = new Set(payload.activeKeys ?? []); - // fetch 이후 실이벤트가 도착한 키는 그 이벤트가 최신 - 대조에서 제외 - for (const entry of sinceFetch) { - const sep = entry.indexOf('::'); - if (sep < 0) continue; - if (entry.slice(0, sep) === payload.currentMode) { - held.add(entry.slice(sep + 2)); - } - } - reconcileActiveNotes(held); - // 고착된 눌림 하이라이트도 같은 기준으로 정정 - const validKeys = validKeySet(keyMappings[selectedKeyType] ?? []); - for (const key of validKeys) { - if (!sinceFetch.has(`${payload.currentMode}::${key}`)) { - setKeyActiveSignal(key, held.has(key)); - } - } - } catch (error) { - if (!hydrationCancelled) { - console.error('Failed to reconcile active notes', error); - } - } finally { - if (reconcileSeenRef.current === sinceFetch) { - reconcileSeenRef.current = null; - } - } - }; - - // OBS Lagged/재연결 스냅샷은 유실된 keys:state를 개별 복구하지 못하므로 대조로 정리 - const unsubscribeResync = obsApi.onResync(() => { - void reconcileWithBootstrap(); - }); - - // 키보드 훅 (재)시작 - 이전 눌림 상태가 통째로 무효화되므로 전체 리셋 후 재수화 - const unsubscribeKeysReset = window.api.keys.onKeysReset(() => { - // 진행 중인 대조의 낡은 스냅샷이 리셋 이후 상태를 덮지 못하게 무효화 - reconcileGenerationRef.current += 1; - const { finalizeAllActive } = keyEventContextRef.current; - finalizeAllActive(); - cancelKeyDelayTimers( - keyDelayTimersRef.current, - pendingKeyDelayTimersRef.current, - ); - const seen = new Set(); - seenSinceResetRef.current = seen; - resetAllKeySignals(); - void window.api.app - .bootstrap() - .then(({ activeKeys }) => { - if (hydrationCancelled || !activeKeys?.length) return; - if (seenSinceResetRef.current !== seen) return; - const { keyMappings, selectedKeyType } = keyEventContextRef.current; - const validKeys = validKeySet(keyMappings[selectedKeyType] ?? []); - for (const key of activeKeys) { - if (validKeys.has(key) && !seen.has(key)) { - setKeyActiveSignal(key, true); - } - } - }) - .catch((error) => { - if (!hydrationCancelled) { - console.error('Failed to rehydrate after keys reset', error); - } - }); - }); - - const keyDelayTimers = keyDelayTimersRef.current; - const pendingKeyDelayTimers = pendingKeyDelayTimersRef.current; - - return () => { - hydrationCancelled = true; - unsubscribeResync(); - unsubscribeKeysReset(); - void unsubscribe - .then((unsub) => { - try { - unsub?.(); - } catch (error) { - console.error('Failed to remove key state listener', error); - } - }) - .catch(() => undefined); - // 키 딜레이 타이머 정리 - cancelKeyDelayTimers(keyDelayTimers, pendingKeyDelayTimers); - // 창 단위 정리 - 마운트 1회 구독이므로 여기는 실제 언마운트에서만 실행됨 - resetAllKeySignals(); - }; - // 콜백이 읽는 값은 keyEventContextRef로 공급 - 구독은 창 수명과 동일 - }, []); - - const currentSlots = keyMappings[selectedKeyType] ?? EMPTY_SLICE; - // 시그널·트랙 키는 canonical, 표시는 합성 라벨 (계약 §3, §11) - const { currentKeys, currentKeyLabels, currentValidKeySignature } = useMemo( - () => ({ - currentKeys: currentSlots.map((slot) => slotCanonical(slot)), - currentKeyLabels: currentSlots.map((slot) => slotDisplayName(slot)), - currentValidKeySignature: validKeySignature(currentSlots), - }), - [currentSlots], - ); - - // 탭·현재 키 집합 전환 시 예약 타이머와 눌림 신호를 권위 상태로 정합 - // positions와 다른 탭의 매핑 변경은 signature가 같아 이 effect를 건드리지 않음 - const keySignalResetArmedRef = useRef(false); - useEffect(() => { - if (!keySignalResetArmedRef.current) { - // 초기 마운트 수화는 구독 effect가 담당 - keySignalResetArmedRef.current = true; - return; - } - // 탭 전환은 진행 중 대조의 스냅샷을 낡게 만든다 - reconcileGenerationRef.current += 1; - let cancelled = false; - cancelKeyDelayTimers( - keyDelayTimersRef.current, - pendingKeyDelayTimersRef.current, - ); - const seen = new Set(); - const validKeys = new Set(JSON.parse(currentValidKeySignature)); - seenSinceResetRef.current = seen; - resetAllKeySignals(); - void Promise.resolve(keyEventsReadyRef.current) - .then(() => window.api.app.bootstrap()) - .then(({ activeKeys }) => { - if (cancelled || !activeKeys?.length) return; - // 이후 전환의 리셋이 끼었으면 그쪽 재수화가 담당 - if (seenSinceResetRef.current !== seen) return; - for (const key of activeKeys) { - if (validKeys.has(key) && !seen.has(key)) { - setKeyActiveSignal(key, true); - } - } - }) - .catch((error) => { - if (!cancelled) { - console.error('Failed to rehydrate active key state', error); - } - }); - return () => { - cancelled = true; - }; - }, [selectedKeyType, currentValidKeySignature]); const currentPositions = positions[selectedKeyType] ?? EMPTY_SLICE; const currentStatPositions = statPositions[selectedKeyType] ?? EMPTY_SLICE; From b94e4684dfe8ffb57c950b27000586de55b8481a Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 16:06:15 +0900 Subject: [PATCH 114/245] =?UTF-8?q?test:=20=EB=A6=AC=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=EC=A6=88=20=EC=95=B5=EC=BB=A4=20=EA=B2=80=EC=A6=9D=EC=9D=84=20?= =?UTF-8?q?=EC=8B=A4=EC=A0=9C=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8?= =?UTF-8?q?=EC=97=90=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/resizeHandleSnap.test.ts | 332 ------------------ .../main/Grid/handles/ResizeHandles.test.tsx | 60 ++++ 2 files changed, 60 insertions(+), 332 deletions(-) delete mode 100644 src/renderer/__tests__/resizeHandleSnap.test.ts diff --git a/src/renderer/__tests__/resizeHandleSnap.test.ts b/src/renderer/__tests__/resizeHandleSnap.test.ts deleted file mode 100644 index cd5fbce12..000000000 --- a/src/renderer/__tests__/resizeHandleSnap.test.ts +++ /dev/null @@ -1,332 +0,0 @@ -/* eslint-disable no-console */ -import { describe, test, expect } from 'vitest'; - -/** - * ResizeHandles.tsx의 스냅 로직 버그 검증 테스트 - * - * 버그: 한쪽 핸들로 리사이즈할 때, 독립적인 위치 스냅이 - * 앵커(고정되어야 할 반대쪽 엣지)를 1~2px 이동시킴 - */ - -const MIN_SIZE = 10; - -// ResizeHandles.tsx 272-273줄과 동일한 스냅 함수 -const snap = (value: number, snapSize: number): number => - Math.round(value / snapSize) * snapSize; - -/** - * 현재 ResizeHandles.tsx의 리사이즈 로직 (버그 있는 버전) - * handleMouseMove 내부 로직을 추출 (줄 276-354) - */ -function currentResizeLogic( - startBounds: { x: number; y: number; width: number; height: number }, - handleDx: -1 | 0 | 1, - handleDy: -1 | 0 | 1, - rawDeltaX: number, - rawDeltaY: number, - snapSize: number, -) { - // 크기 계산 (줄 276-290) - let nextWidth = startBounds.width; - let nextHeight = startBounds.height; - - if (handleDx === -1) { - nextWidth = Math.max(MIN_SIZE, startBounds.width - rawDeltaX); - } else if (handleDx === 1) { - nextWidth = Math.max(MIN_SIZE, startBounds.width + rawDeltaX); - } - - if (handleDy === -1) { - nextHeight = Math.max(MIN_SIZE, startBounds.height - rawDeltaY); - } else if (handleDy === 1) { - nextHeight = Math.max(MIN_SIZE, startBounds.height + rawDeltaY); - } - - // 크기 스냅 (줄 331-333, keepAspect=false 가정) - const newWidth = Math.max(MIN_SIZE, snap(nextWidth, snapSize)); - const newHeight = Math.max(MIN_SIZE, snap(nextHeight, snapSize)); - - // 위치 계산 (줄 337-350) - let newX = startBounds.x; - let newY = startBounds.y; - - if (handleDx === -1) { - newX = startBounds.x + (startBounds.width - newWidth); - } else if (handleDx === 0) { - newX = startBounds.x + (startBounds.width - newWidth) / 2; - } - - if (handleDy === -1) { - newY = startBounds.y + (startBounds.height - newHeight); - } else if (handleDy === 0) { - newY = startBounds.y + (startBounds.height - newHeight) / 2; - } - - // 위치 스냅 (줄 352-354) — 버그 원인 - newX = snap(newX, snapSize); - newY = snap(newY, snapSize); - - return { x: newX, y: newY, width: newWidth, height: newHeight }; -} - -/** - * 수정된 리사이즈 로직 (앵커 엣지 보존) - */ -function fixedResizeLogic( - startBounds: { x: number; y: number; width: number; height: number }, - handleDx: -1 | 0 | 1, - handleDy: -1 | 0 | 1, - rawDeltaX: number, - rawDeltaY: number, - snapSize: number, -) { - let nextWidth = startBounds.width; - let nextHeight = startBounds.height; - - if (handleDx === -1) { - nextWidth = Math.max(MIN_SIZE, startBounds.width - rawDeltaX); - } else if (handleDx === 1) { - nextWidth = Math.max(MIN_SIZE, startBounds.width + rawDeltaX); - } - - if (handleDy === -1) { - nextHeight = Math.max(MIN_SIZE, startBounds.height - rawDeltaY); - } else if (handleDy === 1) { - nextHeight = Math.max(MIN_SIZE, startBounds.height + rawDeltaY); - } - - // 크기 스냅 - let newWidth = Math.max(MIN_SIZE, snap(nextWidth, snapSize)); - let newHeight = Math.max(MIN_SIZE, snap(nextHeight, snapSize)); - - // 수정: 핸들 방향에 따라 올바른 앵커 기반 위치 계산 - let newX = startBounds.x; - let newY = startBounds.y; - - if (handleDx === -1) { - // 좌측 핸들: 우측 엣지가 앵커 - const rightAnchor = startBounds.x + startBounds.width; - newX = snap(rightAnchor - newWidth, snapSize); - newWidth = rightAnchor - newX; // 우측 엣지 정확히 보존 - } else if (handleDx === 1) { - // 우측 핸들: 좌측 엣지가 앵커 — 위치 스냅 불필요 - // newX = startBounds.x (이미 설정됨) - } - // dx === 0: X축 변경 없음 - - if (handleDy === -1) { - // 상단 핸들: 하단 엣지가 앵커 - const bottomAnchor = startBounds.y + startBounds.height; - newY = snap(bottomAnchor - newHeight, snapSize); - newHeight = bottomAnchor - newY; // 하단 엣지 정확히 보존 - } else if (handleDy === 1) { - // 하단 핸들: 상단 엣지가 앵커 — 위치 스냅 불필요 - // newY = startBounds.y (이미 설정됨) - } - // dy === 0: Y축 변경 없음 - - return { x: newX, y: newY, width: newWidth, height: newHeight }; -} - -describe('ResizeHandles 스냅 로직 - 앵커 엣지 보존 검증', () => { - const snapSize = 5; - - describe('비그리드 위치의 요소 (스마트 가이드로 배치됨)', () => { - // 스마트 가이드에 의해 그리드 배수가 아닌 위치에 배치된 요소 - const startBounds = { x: 103, y: 47, width: 50, height: 30 }; - - test('현재 로직: 좌측 핸들 드래그 시 우측 엣지 이동 (버그)', () => { - const rightEdgeBefore = startBounds.x + startBounds.width; // 153 - const result = currentResizeLogic(startBounds, -1, 0, -7, 0, snapSize); - const rightEdgeAfter = result.x + result.width; - - console.log(`[현재 로직] 좌측 핸들 드래그:`); - console.log( - ` 시작: x=${startBounds.x}, w=${startBounds.width}, 우측=${rightEdgeBefore}`, - ); - console.log( - ` 결과: x=${result.x}, w=${result.width}, 우측=${rightEdgeAfter}`, - ); - console.log(` 우측 엣지 이동량: ${rightEdgeAfter - rightEdgeBefore}px`); - - // 버그: 우측 엣지가 이동함 - expect(rightEdgeAfter).not.toBe(rightEdgeBefore); - }); - - test('수정 로직: 좌측 핸들 드래그 시 우측 엣지 보존', () => { - const rightEdgeBefore = startBounds.x + startBounds.width; // 153 - const result = fixedResizeLogic(startBounds, -1, 0, -7, 0, snapSize); - const rightEdgeAfter = result.x + result.width; - - console.log(`[수정 로직] 좌측 핸들 드래그:`); - console.log( - ` 시작: x=${startBounds.x}, w=${startBounds.width}, 우측=${rightEdgeBefore}`, - ); - console.log( - ` 결과: x=${result.x}, w=${result.width}, 우측=${rightEdgeAfter}`, - ); - console.log(` 우측 엣지 이동량: ${rightEdgeAfter - rightEdgeBefore}px`); - - // 수정: 우측 엣지 정확히 보존 - expect(rightEdgeAfter).toBe(rightEdgeBefore); - }); - - test('현재 로직: 우측 핸들 드래그 시 좌측 엣지 이동 (버그)', () => { - const leftEdgeBefore = startBounds.x; // 103 - const result = currentResizeLogic(startBounds, 1, 0, 7, 0, snapSize); - - console.log(`[현재 로직] 우측 핸들 드래그:`); - console.log(` 시작: x=${startBounds.x}, w=${startBounds.width}`); - console.log(` 결과: x=${result.x}, w=${result.width}`); - console.log(` 좌측 엣지 이동량: ${result.x - leftEdgeBefore}px`); - - // 버그: 좌측 엣지(앵커)가 스냅으로 이동함 - expect(result.x).not.toBe(leftEdgeBefore); - }); - - test('수정 로직: 우측 핸들 드래그 시 좌측 엣지 보존', () => { - const leftEdgeBefore = startBounds.x; // 103 - const result = fixedResizeLogic(startBounds, 1, 0, 7, 0, snapSize); - - console.log(`[수정 로직] 우측 핸들 드래그:`); - console.log(` 시작: x=${startBounds.x}, w=${startBounds.width}`); - console.log(` 결과: x=${result.x}, w=${result.width}`); - console.log(` 좌측 엣지 이동량: ${result.x - leftEdgeBefore}px`); - - // 수정: 좌측 엣지 정확히 보존 - expect(result.x).toBe(leftEdgeBefore); - }); - - test('현재 로직: 상단 핸들 드래그 시 하단 엣지 이동 (버그)', () => { - const bottomEdgeBefore = startBounds.y + startBounds.height; // 77 - const result = currentResizeLogic(startBounds, 0, -1, 0, -8, snapSize); - const bottomEdgeAfter = result.y + result.height; - - console.log(`[현재 로직] 상단 핸들 드래그:`); - console.log( - ` 시작: y=${startBounds.y}, h=${startBounds.height}, 하단=${bottomEdgeBefore}`, - ); - console.log( - ` 결과: y=${result.y}, h=${result.height}, 하단=${bottomEdgeAfter}`, - ); - console.log( - ` 하단 엣지 이동량: ${bottomEdgeAfter - bottomEdgeBefore}px`, - ); - - // 버그: 하단 엣지가 이동함 - expect(bottomEdgeAfter).not.toBe(bottomEdgeBefore); - }); - - test('수정 로직: 상단 핸들 드래그 시 하단 엣지 보존', () => { - const bottomEdgeBefore = startBounds.y + startBounds.height; // 77 - const result = fixedResizeLogic(startBounds, 0, -1, 0, -8, snapSize); - const bottomEdgeAfter = result.y + result.height; - - console.log(`[수정 로직] 상단 핸들 드래그:`); - console.log( - ` 시작: y=${startBounds.y}, h=${startBounds.height}, 하단=${bottomEdgeBefore}`, - ); - console.log( - ` 결과: y=${result.y}, h=${result.height}, 하단=${bottomEdgeAfter}`, - ); - console.log( - ` 하단 엣지 이동량: ${bottomEdgeAfter - bottomEdgeBefore}px`, - ); - - // 수정: 하단 엣지 정확히 보존 - expect(bottomEdgeAfter).toBe(bottomEdgeBefore); - }); - }); - - describe('그리드 정렬된 요소 (문제 미발생 케이스)', () => { - const startBounds = { x: 100, y: 50, width: 50, height: 30 }; - - test('그리드 정렬 위치: 현재 로직도 정상 동작', () => { - const rightEdgeBefore = startBounds.x + startBounds.width; // 150 - const result = currentResizeLogic(startBounds, -1, 0, -5, 0, snapSize); - const rightEdgeAfter = result.x + result.width; - - console.log(`[그리드 정렬] 좌측 핸들 드래그:`); - console.log( - ` 시작: x=${startBounds.x}, w=${startBounds.width}, 우측=${rightEdgeBefore}`, - ); - console.log( - ` 결과: x=${result.x}, w=${result.width}, 우측=${rightEdgeAfter}`, - ); - - // 그리드 정렬된 경우 문제 없음 - expect(rightEdgeAfter).toBe(rightEdgeBefore); - }); - }); - - describe('다양한 비그리드 위치에서의 이동량 확인', () => { - const testCases = [ - { x: 101, y: 51, width: 52, height: 33, desc: 'offset 1px' }, - { x: 102, y: 48, width: 53, height: 27, desc: 'offset 2px' }, - { x: 103, y: 47, width: 51, height: 31, desc: 'offset 3px' }, - { x: 104, y: 46, width: 54, height: 29, desc: 'offset 4px' }, - ]; - - test.each(testCases)( - '현재 로직 - $desc: 좌측 핸들 드래그 시 우측 엣지 drift', - (startBounds) => { - const rightAnchor = startBounds.x + startBounds.width; - const result = currentResizeLogic(startBounds, -1, 0, -10, 0, snapSize); - const drift = result.x + result.width - rightAnchor; - console.log( - ` [${startBounds.desc}] start=(${startBounds.x},${ - startBounds.width - }) right=${rightAnchor} → result=(${result.x},${ - result.width - }) right=${result.x + result.width} drift=${drift}px`, - ); - // 대부분 drift가 발생 - }, - ); - - test.each(testCases)( - '수정 로직 - $desc: 좌측 핸들 드래그 시 우측 엣지 보존', - (startBounds) => { - const rightAnchor = startBounds.x + startBounds.width; - const result = fixedResizeLogic(startBounds, -1, 0, -10, 0, snapSize); - const drift = result.x + result.width - rightAnchor; - console.log( - ` [${startBounds.desc}] start=(${startBounds.x},${ - startBounds.width - }) right=${rightAnchor} → result=(${result.x},${ - result.width - }) right=${result.x + result.width} drift=${drift}px`, - ); - expect(drift).toBe(0); - }, - ); - }); - - describe('dx=0 (상/하 핸들)에서 X축 불필요한 이동 검증', () => { - const startBounds = { x: 103, y: 47, width: 50, height: 30 }; - - test('현재 로직: 하단 핸들(dy=1)만 드래그해도 X좌표 이동 (버그)', () => { - const result = currentResizeLogic(startBounds, 0, 1, 0, 10, snapSize); - console.log( - `[현재 로직] 하단 핸들: x=${startBounds.x} → ${result.x} (이동량: ${ - result.x - startBounds.x - }px)`, - ); - - // 버그: 수직 리사이즈인데 X가 스냅되어 이동 - expect(result.x).not.toBe(startBounds.x); - }); - - test('수정 로직: 하단 핸들(dy=1)만 드래그 시 X좌표 보존', () => { - const result = fixedResizeLogic(startBounds, 0, 1, 0, 10, snapSize); - console.log( - `[수정 로직] 하단 핸들: x=${startBounds.x} → ${result.x} (이동량: ${ - result.x - startBounds.x - }px)`, - ); - - // 수정: X축 변경 없음 - expect(result.x).toBe(startBounds.x); - }); - }); -}); diff --git a/src/renderer/components/main/Grid/handles/ResizeHandles.test.tsx b/src/renderer/components/main/Grid/handles/ResizeHandles.test.tsx index 92facb413..08a6407c3 100644 --- a/src/renderer/components/main/Grid/handles/ResizeHandles.test.tsx +++ b/src/renderer/components/main/Grid/handles/ResizeHandles.test.tsx @@ -158,6 +158,66 @@ describe('ResizeHandles frame coalescing', () => { act(() => document.dispatchEvent(new MouseEvent('mouseup'))); }); + it('좌측 핸들은 비그리드 위치에서도 우측 앵커를 보존한다', async () => { + const onResize = vi.fn(); + const bounds = { x: 103, y: 47, width: 50, height: 30 }; + await act(async () => { + root.render(); + }); + const handle = host.querySelector('[data-resize-handle="w"]')!; + act(() => + handle.dispatchEvent( + new MouseEvent('mousedown', { + clientX: 0, + clientY: 0, + bubbles: true, + cancelable: true, + }), + ), + ); + document.dispatchEvent( + new MouseEvent('mousemove', { clientX: -7, clientY: 0 }), + ); + const callback = callbacks.get(1)!; + callbacks.clear(); + act(() => callback(performance.now())); + + const result = onResize.mock.calls[0][0]; + expect(result).toMatchObject({ x: 100, y: 47, width: 53, height: 30 }); + expect(result.x + result.width).toBe(bounds.x + bounds.width); + act(() => document.dispatchEvent(new MouseEvent('mouseup'))); + }); + + it('상단 핸들은 비그리드 위치에서도 하단 앵커를 보존한다', async () => { + const onResize = vi.fn(); + const bounds = { x: 103, y: 47, width: 50, height: 30 }; + await act(async () => { + root.render(); + }); + const handle = host.querySelector('[data-resize-handle="n"]')!; + act(() => + handle.dispatchEvent( + new MouseEvent('mousedown', { + clientX: 0, + clientY: 0, + bubbles: true, + cancelable: true, + }), + ), + ); + document.dispatchEvent( + new MouseEvent('mousemove', { clientX: 0, clientY: -8 }), + ); + const callback = callbacks.get(1)!; + callbacks.clear(); + act(() => callback(performance.now())); + + const result = onResize.mock.calls[0][0]; + expect(result).toMatchObject({ x: 103, y: 35, width: 50, height: 42 }); + expect(result.y + result.height).toBe(bounds.y + bounds.height); + act(() => document.dispatchEvent(new MouseEvent('mouseup'))); + }); + it('mouseup은 예약된 마지막 bounds를 flush한다', async () => { const onResize = vi.fn(); const onResizeEnd = vi.fn(); From 5724ce1c1e64bb7fdc701a380f2f6be8a5deed89 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 16:09:00 +0900 Subject: [PATCH 115/245] =?UTF-8?q?test:=20=EB=A9=94=EC=9D=B8=20=EB=8B=A4?= =?UTF-8?q?=EC=9D=B4=EC=96=BC=EB=A1=9C=EA=B7=B8=20=EB=9F=B0=ED=83=80?= =?UTF-8?q?=EC=9E=84=20=EA=B3=84=EC=95=BD=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../windows/main/App.dialogRuntime.test.tsx | 468 ++++++++++++++++++ 1 file changed, 468 insertions(+) create mode 100644 src/renderer/windows/main/App.dialogRuntime.test.tsx diff --git a/src/renderer/windows/main/App.dialogRuntime.test.tsx b/src/renderer/windows/main/App.dialogRuntime.test.tsx new file mode 100644 index 000000000..23ebfa69e --- /dev/null +++ b/src/renderer/windows/main/App.dialogRuntime.test.tsx @@ -0,0 +1,468 @@ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +interface AlertProps { + isOpen: boolean; + message: string; + type: 'alert' | 'confirm' | 'custom'; + confirmText?: string; + onConfirm: () => void; + onCancel: () => void; +} + +interface ColorPickerProps { + open: boolean; + color: string; + onColorChange: (color: string) => void; + onColorChangeComplete: (color: string) => void; + onClose: () => void; +} + +const mocks = vi.hoisted(() => ({ + alertProps: null as AlertProps | null, + customDialogProps: null as AlertProps | null, + colorPickerProps: [] as ColorPickerProps[], + checkForUpdates: vi.fn(), + setGridAreaHovered: vi.fn(), + t: (key: string) => `translated:${key}`, + updateRuntime: { + updateAvailable: false, + isLatestVersion: false, + updateInfo: null, + dismissUpdate: vi.fn(), + skipVersion: vi.fn(), + checkForUpdates: vi.fn(), + runAutoUpdate: vi.fn(), + isAutoUpdating: false, + autoUpdatePhase: null, + autoUpdateProgress: null, + }, +})); + +vi.mock('@contexts/useTranslation', () => ({ + useTranslation: () => ({ t: mocks.t }), +})); +vi.mock('@components/main/TitleBar', () => ({ default: () => null })); +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/panel/usePanelCloseRequest', () => ({ + usePanelCloseRequest: vi.fn(), +})); +vi.mock('@components/main/Tool/ToolBar', () => ({ default: () => null })); +vi.mock('@components/main/Grid', () => ({ default: () => null })); +vi.mock('@components/main/Settings', () => ({ default: () => null })); +vi.mock('@hooks/useKeyManager', () => ({ + useKeyManager: () => ({ + keyMappings: { '4key': [] }, + positions: { '4key': [] }, + handleKeyMappingChange: vi.fn(), + handleResetCurrentMode: vi.fn(), + handleUndo: vi.fn(), + handleRedo: vi.fn(), + }), +})); +vi.mock('@hooks/Modal/usePalette', () => ({ + usePalette: () => ({ + color: '#ffffff', + palette: false, + setPalette: vi.fn(), + handleColorChange: vi.fn(), + handlePaletteClose: vi.fn(), + }), +})); +vi.mock('@components/main/Modal/content/dialogs/Alert', () => ({ + default: (props: AlertProps) => { + if (props.type === 'custom') mocks.customDialogProps = props; + else mocks.alertProps = props; + return null; + }, +})); +vi.mock('@components/main/Modal/content/settings/NoteSetting', () => ({ + default: () => null, +})); +vi.mock('@components/main/Modal/content/dialogs/UpdateModal', () => ({ + default: () => null, +})); +vi.mock('@components/main/Modal/content/dialogs/updateActionLabel', () => ({ + resolveAutoUpdateActionLabel: () => 'update', +})); +vi.mock('@components/main/Grid/PropertiesPanelHost', () => ({ + default: () => null, +})); +vi.mock('@components/main/Modal/popupLayer', () => ({ + isModalLayerActive: () => false, +})); +vi.mock('@stores/useSettingsStore', () => ({ + useSettingsStore: () => ({ + noteEffect: false, + angleMode: 'degrees', + setAngleMode: vi.fn(), + language: 'ko', + setLanguage: vi.fn(), + noteSettings: {}, + setNoteSettings: vi.fn(), + autoUpdateEnabled: false, + developerModeEnabled: false, + shortcuts: {}, + }), +})); +vi.mock('@components/main/Modal/FloatingPopup', () => ({ + default: () => null, +})); +vi.mock('@hooks/ui/usePopupPresence', () => ({ + useModalPresence: () => ({ + mounted: false, + state: 'entering', + cycle: 0, + }), +})); +vi.mock('@hooks/ui/useRetainedValue', () => ({ + useRetainedWhileOpen: (_open: boolean, value: unknown) => value, +})); +vi.mock('@components/main/Modal/PopupExit', () => ({ + default: ({ children }: { children: React.ReactNode }) => children, +})); +vi.mock('@components/main/Modal/content/pickers/Palette', () => ({ + default: () => null, +})); +vi.mock('@components/main/Modal/content/pickers/ColorPicker', () => ({ + default: (props: ColorPickerProps) => { + mocks.colorPickerProps.push(props); + return null; + }, +})); +vi.mock('@stores/data/useKeyStore', () => ({ + useKeyStore: () => ({ + selectedKeyType: '4key', + setSelectedKeyType: vi.fn(), + isBootstrapped: true, + }), +})); +vi.mock('@hooks/app/useAppBootstrap', () => ({ useAppBootstrap: vi.fn() })); +vi.mock('@hooks/app/usePluginDisplayElementsResponder', () => ({ + usePluginDisplayElementsResponder: vi.fn(), +})); +vi.mock('@hooks/app/useUpdateCheck', () => ({ + UpdateInstalledRestartFailedError: class extends Error { + originalError: unknown; + constructor(originalError: unknown) { + super('restart failed'); + this.originalError = originalError; + } + }, + useUpdateCheck: () => mocks.updateRuntime, + hasPendingPostUpdateReleaseNotice: () => false, + clearPendingPostUpdateReleaseNotice: vi.fn(), +})); +vi.mock('@stores/grid/usePropertiesPanelStore', () => ({ + usePropertiesPanelStore: { + getState: () => ({ requestCanvasPanelToggle: vi.fn() }), + }, +})); +vi.mock('@stores/grid/usePanelHostStore', () => ({ + usePanelHostStore: (selector: (state: { placement: string }) => T) => + selector({ placement: 'docked' }), +})); +vi.mock('@stores/grid/useGridSelectionStore', () => ({ + useGridSelectionStore: { + getState: () => ({ clearSelection: vi.fn() }), + }, +})); +vi.mock('@src/renderer/editor/runtime/historyEditorFlushLock', () => ({ + isHistoryEditorFlushLocked: () => false, +})); +vi.mock('@hooks/useOptimisticBooleanCommit', () => ({ + useOptimisticBooleanCommit: ({ + canonicalValue, + }: { + canonicalValue: boolean; + }) => ({ value: canonicalValue, toggle: vi.fn() }), +})); +vi.mock('@api/modules/keysApi', () => ({ + keysApi: { resetCountersMode: vi.fn() }, +})); +vi.mock('@api/modules/settingsApi', () => ({ + settingsApi: { update: vi.fn() }, +})); +vi.mock('@api/modules/appApi', () => ({ + appApi: { openExternal: vi.fn() }, +})); +vi.mock('@stores/useUIStore', () => ({ + useUIStore: ( + selector: (state: { + setGridAreaHovered: typeof mocks.setGridAreaHovered; + }) => T, + ) => selector({ setGridAreaHovered: mocks.setGridAreaHovered }), +})); + +import App from './App'; + +const renderUpdate = async (callback: () => void) => { + await act(async () => { + callback(); + await Promise.resolve(); + }); +}; + +describe('main dialog runtime 계약', () => { + let container: HTMLDivElement; + let root: Root; + let mounted: boolean; + + beforeEach(async () => { + mocks.alertProps = null; + mocks.customDialogProps = null; + mocks.colorPickerProps.length = 0; + mocks.updateRuntime.checkForUpdates.mockClear(); + 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(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('alert와 confirm 교체 시 이전 요청을 cancel로 settle한다', async () => { + const firstCancel = vi.fn(); + await renderUpdate(() => { + window.__dmn_showConfirm?.('first confirm', vi.fn(), { + onCancel: firstCancel, + }); + }); + await renderUpdate(() => { + window.__dmn_showAlert?.('replacement alert'); + }); + expect(firstCancel).toHaveBeenCalledOnce(); + + const alertDismiss = vi.fn(); + await renderUpdate(() => { + window.__dmn_showAlert?.('second alert', undefined, alertDismiss); + }); + await renderUpdate(() => { + window.__dmn_showConfirm?.('replacement confirm', vi.fn()); + }); + expect(alertDismiss).toHaveBeenCalledOnce(); + }); + + it('confirm과 cancel은 먼저 닫아 콜백이 연 다음 dialog를 보존한다', async () => { + await renderUpdate(() => { + window.__dmn_showConfirm?.('confirm source', () => { + window.__dmn_showAlert?.('opened from confirm'); + }); + }); + await renderUpdate(() => { + mocks.alertProps?.onConfirm(); + }); + expect(mocks.alertProps).toMatchObject({ + isOpen: true, + message: 'opened from confirm', + }); + + await renderUpdate(() => { + window.__dmn_showConfirm?.('cancel source', vi.fn(), { + onCancel: () => { + window.__dmn_showAlert?.('opened from cancel'); + }, + }); + }); + await renderUpdate(() => { + mocks.alertProps?.onCancel(); + }); + expect(mocks.alertProps).toMatchObject({ + isOpen: true, + message: 'opened from cancel', + }); + }); + + it('언마운트에서 대기 중 alert와 custom dialog를 cancel로 settle한다', async () => { + const alertCancel = vi.fn(); + const customCancel = vi.fn(); + await renderUpdate(() => { + window.__dmn_showConfirm?.('pending', vi.fn(), { + onCancel: alertCancel, + }); + window.__dmn_showCustomDialog?.('

pending

', { + onCancel: customCancel, + }); + }); + + act(() => root.unmount()); + mounted = false; + + expect(alertCancel).toHaveBeenCalledOnce(); + expect(customCancel).toHaveBeenCalledOnce(); + }); + + it('custom dialog 교체를 settle하고 dialog 소유 color surface를 함께 닫는다', async () => { + const firstCancel = vi.fn(); + await renderUpdate(() => { + window.__dmn_showCustomDialog?.('

first

', { + onCancel: firstCancel, + }); + }); + await renderUpdate(() => { + window.__dmn_showCustomDialog?.('

second

'); + }); + expect(firstCancel).toHaveBeenCalledOnce(); + + const dialogRoot = document.createElement('div'); + dialogRoot.setAttribute('data-plugin-dialog-content', ''); + const anchor = document.createElement('button'); + dialogRoot.appendChild(anchor); + const onClose = vi.fn(); + await renderUpdate(() => { + window.__dmn_showColorPicker?.({ + initialColor: '#111111', + onColorChange: vi.fn(), + referenceElement: anchor, + onClose, + }); + }); + await renderUpdate(() => { + mocks.customDialogProps?.onCancel(); + }); + + expect(onClose).toHaveBeenCalledOnce(); + expect(window.__dmn_getColorPickerState?.().isOpen).toBe(false); + }); + + it('color picker는 같은 id를 toggle하고 다른 id는 timer 뒤 전환한다', async () => { + vi.useFakeTimers(); + const firstClose = vi.fn(); + await renderUpdate(() => { + window.__dmn_showColorPicker?.({ + initialColor: '#111111', + onColorChange: vi.fn(), + id: 'first', + onClose: firstClose, + }); + }); + await renderUpdate(() => { + window.__dmn_showColorPicker?.({ + initialColor: '#222222', + onColorChange: vi.fn(), + id: 'first', + }); + }); + expect(firstClose).toHaveBeenCalledOnce(); + expect(window.__dmn_getColorPickerState?.()).toMatchObject({ + isOpen: false, + id: 'first', + }); + + await renderUpdate(() => { + window.__dmn_showColorPicker?.({ + initialColor: '#333333', + onColorChange: vi.fn(), + id: 'source', + }); + }); + await renderUpdate(() => { + window.__dmn_showColorPicker?.({ + initialColor: '#444444', + onColorChange: vi.fn(), + id: 'target', + }); + }); + expect(window.__dmn_getColorPickerState?.()).toMatchObject({ + isOpen: false, + id: 'source', + }); + + await act(async () => { + vi.runOnlyPendingTimers(); + await Promise.resolve(); + }); + expect(window.__dmn_getColorPickerState?.()).toMatchObject({ + isOpen: true, + id: 'target', + color: '#444444', + }); + }); + + it('퇴장 중 이전 color picker 세션 callback을 새 세션과 분리한다', async () => { + vi.useFakeTimers(); + const firstChange = vi.fn(); + const firstComplete = vi.fn(); + const nextChange = vi.fn(); + const nextComplete = vi.fn(); + await renderUpdate(() => { + window.__dmn_showColorPicker?.({ + initialColor: '#111111', + onColorChange: firstChange, + onColorChangeComplete: firstComplete, + id: 'first', + }); + }); + const firstSession = mocks.colorPickerProps.at(-1); + + await renderUpdate(() => { + window.__dmn_showColorPicker?.({ + initialColor: '#222222', + onColorChange: nextChange, + onColorChangeComplete: nextComplete, + id: 'next', + }); + }); + await act(async () => { + vi.runOnlyPendingTimers(); + await Promise.resolve(); + }); + const nextSession = mocks.colorPickerProps.at(-1); + + await renderUpdate(() => { + firstSession?.onColorChange('#aaaaaa'); + firstSession?.onColorChangeComplete('#bbbbbb'); + nextSession?.onColorChange('#cccccc'); + nextSession?.onColorChangeComplete('#dddddd'); + }); + + expect(firstChange).toHaveBeenCalledWith('#aaaaaa'); + expect(firstComplete).toHaveBeenCalledWith('#bbbbbb'); + expect(nextChange).toHaveBeenCalledWith('#cccccc'); + expect(nextComplete).toHaveBeenCalledWith('#dddddd'); + }); + + it('global API를 매 render 최신 함수로 교체하고 unmount에서 제거한다', async () => { + const initialShowAlert = window.__dmn_showAlert; + expect(initialShowAlert).toBeTypeOf('function'); + expect(window.__dmn_showConfirm).toBeTypeOf('function'); + expect(window.__dmn_showCustomDialog).toBeTypeOf('function'); + expect(window.__dmn_showColorPicker).toBeTypeOf('function'); + expect(window.__dmn_getColorPickerState).toBeTypeOf('function'); + + await renderUpdate(() => { + initialShowAlert?.('translated fallback'); + }); + expect(mocks.alertProps?.confirmText).toBe('translated:common.confirm'); + expect(window.__dmn_showAlert).not.toBe(initialShowAlert); + + act(() => root.unmount()); + mounted = false; + expect(window.__dmn_showAlert).toBeUndefined(); + expect(window.__dmn_showConfirm).toBeUndefined(); + expect(window.__dmn_showCustomDialog).toBeUndefined(); + expect(window.__dmn_showColorPicker).toBeUndefined(); + expect(window.__dmn_getColorPickerState).toBeUndefined(); + }); +}); From 9fa7b4085c5adf616631c4129a4398100bab4ef5 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 16:09:04 +0900 Subject: [PATCH 116/245] =?UTF-8?q?refactor:=20=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EB=8B=A4=EC=9D=B4=EC=96=BC=EB=A1=9C=EA=B7=B8=20=EB=9F=B0?= =?UTF-8?q?=ED=83=80=EC=9E=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/windows/main/App.tsx | 351 +-------------- .../windows/main/useMainDialogRuntime.ts | 418 ++++++++++++++++++ 2 files changed, 437 insertions(+), 332 deletions(-) create mode 100644 src/renderer/windows/main/useMainDialogRuntime.ts diff --git a/src/renderer/windows/main/App.tsx b/src/renderer/windows/main/App.tsx index b6af51958..69e9e0fe1 100644 --- a/src/renderer/windows/main/App.tsx +++ b/src/renderer/windows/main/App.tsx @@ -1,8 +1,8 @@ import React, { useRef, useState, useEffect } from 'react'; import { - closeCustomDialogOwnedSurface, - replaceCustomDialogCallbacks, -} from './customDialogCallbacks'; + useMainDialogRuntime, + useMainDialogRuntimeLifecycle, +} from './useMainDialogRuntime'; import { useTranslation } from '@contexts/useTranslation'; import TitleBar from '@components/main/TitleBar'; import { useCustomCssInjection } from '@hooks/app/useCustomCssInjection'; @@ -235,58 +235,21 @@ export default function App() { }, [developerModeEnabled]); const { t } = useTranslation(); - const confirmCallbackRef = useRef<(() => void) | null>(null); - const cancelCallbackRef = useRef<(() => void) | null>(null); - const [alertState, setAlertState] = useState(() => ({ - isOpen: false, - message: '', - confirmText: t('common.confirm'), - cancelText: undefined as string | undefined, - danger: false, - type: 'alert' as 'alert' | 'confirm' | 'custom', - })); - - // Custom Dialog 상태 (HTML 콘텐츠) - const customDialogCallbackRef = useRef<{ - onConfirm?: () => void; - onCancel?: () => void; - }>({}); - const [customDialogState, setCustomDialogState] = useState<{ - isOpen: boolean; - html: string; - confirmText?: string; - cancelText?: string; - showCancel?: boolean; - onContentMount?: (element: HTMLElement) => void | (() => void); - }>({ - isOpen: false, - html: '', - confirmText: undefined, - cancelText: undefined, - showCancel: false, - onContentMount: undefined, - }); - - // Global Color Picker 상태 - const colorPickerCloseCallbackRef = useRef<(() => void) | null>(null); - // 콜백은 ref가 아니라 열림 상태에 함께 싣는다. 퇴장 유예 동안 다른 피커가 - // 열리면 ref는 이미 새 주인을 가리켜, 옛 피커의 마지막 커밋이 엉뚱한 대상에 꽂힌다. - // 상태에 실으면 엘리먼트가 그 세션의 콜백을 그대로 들고 퇴장한다 - const [colorPickerState, setColorPickerState] = useState<{ - isOpen: boolean; - color: string; - position?: { x: number; y: number }; - id?: string; - referenceElement?: HTMLElement; - onChange?: (color: string) => void; - onComplete?: (color: string) => void; - }>({ - isOpen: false, - color: '#FFFFFF', - position: undefined, - id: undefined, - referenceElement: undefined, - }); + const dialogRuntime = useMainDialogRuntime({ t }); + const { + alertState, + customDialogState, + colorPickerState, + showAlert, + showConfirm, + handleAlertConfirm, + handleAlertCancel, + handleCustomDialogConfirm, + handleCustomDialogCancel, + closeColorPicker, + handleGlobalColorChange, + handleGlobalColorChangeComplete, + } = dialogRuntime; useEffect(() => { const handler = (e: KeyboardEvent) => { @@ -353,33 +316,6 @@ export default function App() { return () => window.removeEventListener('keydown', handler, true); }, [shortcuts?.toggleSettingsPanel, isSettingsOpen]); - // 새 요청이 기존 alert/confirm을 대체할 때 이전 콜백을 settle해 Promise 유실 방지 - const settlePendingDialog = () => { - const cancel = cancelCallbackRef.current; - confirmCallbackRef.current = null; - cancelCallbackRef.current = null; - cancel?.(); - }; - - const showAlert = ( - message: string, - confirmText?: string, - onDismiss?: () => void, - ) => { - settlePendingDialog(); - // alert는 확인·배경 클릭 어느 경로로 닫혀도 동일하게 settle - confirmCallbackRef.current = onDismiss ?? null; - cancelCallbackRef.current = onDismiss ?? null; - setAlertState({ - isOpen: true, - message, - type: 'alert', - confirmText: confirmText || t('common.confirm'), - cancelText: undefined, - danger: false, - }); - }; - // 정산 실패로 분리/도킹하지 못하면 알린다. 조용히 끝나면 버튼이 먹통으로 보인다 const handlePanelTransitionFailure = (kind: 'detach' | 'dock') => { showAlert( @@ -442,51 +378,6 @@ export default function App() { dismissUpdate(); }; - const showConfirm = ( - message: string, - onConfirm: () => void, - options?: { - onCancel?: () => void; - confirmText?: string; - cancelText?: string; - danger?: boolean; - }, - ) => { - settlePendingDialog(); - confirmCallbackRef.current = - typeof onConfirm === 'function' ? onConfirm : null; - cancelCallbackRef.current = - typeof options?.onCancel === 'function' ? options.onCancel : null; - setAlertState({ - isOpen: true, - message, - confirmText: options?.confirmText || t('common.confirm'), - cancelText: options?.cancelText, - danger: options?.danger ?? false, - type: 'confirm', - }); - }; - - const closeAlert = () => { - setAlertState({ - isOpen: false, - message: '', - confirmText: t('common.confirm'), - cancelText: undefined, - danger: false, - type: 'alert', - }); - confirmCallbackRef.current = null; - cancelCallbackRef.current = null; - }; - - // 닫은 뒤 콜백 실행 — 콜백이 동기적으로 새 다이얼로그를 열어도 닫히지 않게 - const handleAlertConfirm = () => { - const callback = confirmCallbackRef.current; - closeAlert(); - callback?.(); - }; - // 노트 설정 모달 수명 - 퇴장 모션이 도는 동안 마운트를 유지한다. // 설정값도 같이 붙잡는다. 스토어가 먼저 비면 잔상이 빈 카드가 된다 const noteSettingOpen = Boolean( @@ -503,211 +394,7 @@ export default function App() { isLatestVersion, }); - const handleAlertCancel = () => { - const callback = cancelCallbackRef.current; - closeAlert(); - callback?.(); - }; - - // 언마운트 시 대기 중 다이얼로그 Promise settle (HMR·루트 교체 대비) - useEffect( - () => () => { - const cancel = cancelCallbackRef.current; - confirmCallbackRef.current = null; - cancelCallbackRef.current = null; - cancel?.(); - replaceCustomDialogCallbacks(customDialogCallbackRef, {}); - }, - [], - ); - - // Custom Dialog 핸들러 - const showCustomDialog = ( - html: string, - options?: { - onConfirm?: () => void; - onCancel?: () => void; - confirmText?: string; - cancelText?: string; - showCancel?: boolean; - onContentMount?: (element: HTMLElement) => void | (() => void); - }, - ) => { - if (colorPickerState.isOpen) { - closeCustomDialogOwnedSurface( - colorPickerState.referenceElement, - closeColorPicker, - ); - } - replaceCustomDialogCallbacks(customDialogCallbackRef, { - onConfirm: options?.onConfirm, - onCancel: options?.onCancel, - }); - setCustomDialogState({ - isOpen: true, - html, - confirmText: options?.confirmText, - cancelText: options?.cancelText, - showCancel: options?.showCancel ?? false, - onContentMount: options?.onContentMount, - }); - }; - - const closeCustomDialog = () => { - // 다이얼로그 내부 앵커에 붙은 전역 피커는 다이얼로그와 함께 정리 - if (colorPickerState.isOpen) { - closeCustomDialogOwnedSurface( - colorPickerState.referenceElement, - closeColorPicker, - ); - } - setCustomDialogState({ - isOpen: false, - html: '', - confirmText: undefined, - cancelText: undefined, - showCancel: false, - onContentMount: undefined, - }); - customDialogCallbackRef.current = {}; - }; - - const handleCustomDialogConfirm = () => { - if (customDialogCallbackRef.current.onConfirm) { - customDialogCallbackRef.current.onConfirm(); - } - closeCustomDialog(); - }; - - const handleCustomDialogCancel = () => { - if (customDialogCallbackRef.current.onCancel) { - customDialogCallbackRef.current.onCancel(); - } - closeCustomDialog(); - }; - - // Global Color Picker 핸들러 - const showColorPickerImpl = useRef< - (options: { - initialColor: string; - onColorChange: (color: string) => void; - position?: { x: number; y: number }; - id?: string; - referenceElement?: HTMLElement; - onClose?: () => void; - onColorChangeComplete?: (color: string) => void; - }) => void - >(() => {}); - const showColorPicker = (options: { - initialColor: string; - onColorChange: (color: string) => void; - position?: { x: number; y: number }; - id?: string; - referenceElement?: HTMLElement; - onClose?: () => void; - onColorChangeComplete?: (color: string) => void; - }) => { - showColorPickerImpl.current(options); - }; - - const openColorPickerWithOptions = (options: { - initialColor: string; - onColorChange: (color: string) => void; - position?: { x: number; y: number }; - id?: string; - referenceElement?: HTMLElement; - onClose?: () => void; - onColorChangeComplete?: (color: string) => void; - }) => { - colorPickerCloseCallbackRef.current = options.onClose || null; - setColorPickerState({ - isOpen: true, - color: options.initialColor, - position: options.position, - id: options.id, - referenceElement: options.referenceElement, - onChange: options.onColorChange, - onComplete: options.onColorChangeComplete, - }); - }; - - const closeColorPicker = () => { - if (colorPickerCloseCallbackRef.current) { - colorPickerCloseCallbackRef.current(); - } - // 세션 콜백은 지우지 않는다. 퇴장 중 언마운트 커밋이 아직 남아 있고, - // 그 커밋은 이 세션의 대상으로 가야 한다 - setColorPickerState((prev) => ({ ...prev, isOpen: false })); - colorPickerCloseCallbackRef.current = null; - }; - - useEffect(() => { - showColorPickerImpl.current = (options: { - initialColor: string; - onColorChange: (color: string) => void; - position?: { x: number; y: number }; - id?: string; - referenceElement?: HTMLElement; - onClose?: () => void; - onColorChangeComplete?: (color: string) => void; - }) => { - // Toggle logic - 이미 열려있으면 닫기만 하고 종료 - if ( - options.id && - colorPickerState.isOpen && - colorPickerState.id === options.id - ) { - closeColorPicker(); - return; - } - - // 다른 컬러 픽커가 열려있으면 먼저 닫기 - if (colorPickerState.isOpen) { - closeColorPicker(); - // 약간의 지연 후 새 컬러 픽커 열기 (상태 갱신을 위해) - setTimeout(() => { - openColorPickerWithOptions(options); - }, 0); - return; - } - - openColorPickerWithOptions(options); - }; - }); - - // 콜백을 상태에서 꺼내므로 이 클로저는 열림 세션에 묶인다. - // 엘리먼트가 붙잡히면 클로저도 함께 붙잡혀 퇴장 구간의 마지막 커밋이 제 대상으로 간다 - const handleGlobalColorChange = (newColor: string) => { - setColorPickerState((prev) => ({ ...prev, color: newColor })); - colorPickerState.onChange?.(newColor); - }; - - const handleGlobalColorChangeComplete = (newColor: string) => { - colorPickerState.onComplete?.(newColor); - }; - - const colorPickerStateRef = useRef(colorPickerState); - useEffect(() => { - colorPickerStateRef.current = colorPickerState; - }, [colorPickerState]); - const getColorPickerState = () => colorPickerStateRef.current; - - // Dialog API를 전역으로 노출 - useEffect(() => { - window.__dmn_showAlert = showAlert; - window.__dmn_showConfirm = showConfirm; - window.__dmn_showCustomDialog = showCustomDialog; - window.__dmn_showColorPicker = showColorPicker; - window.__dmn_getColorPickerState = getColorPickerState; - - return () => { - delete window.__dmn_showAlert; - delete window.__dmn_showConfirm; - delete window.__dmn_showCustomDialog; - delete window.__dmn_showColorPicker; - delete window.__dmn_getColorPickerState; - }; - }); + useMainDialogRuntimeLifecycle(dialogRuntime); return (
diff --git a/src/renderer/windows/main/useMainDialogRuntime.ts b/src/renderer/windows/main/useMainDialogRuntime.ts new file mode 100644 index 000000000..e3e00feb8 --- /dev/null +++ b/src/renderer/windows/main/useMainDialogRuntime.ts @@ -0,0 +1,418 @@ +import { useEffect, useRef, useState } from 'react'; +import { + closeCustomDialogOwnedSurface, + replaceCustomDialogCallbacks, + type CustomDialogCallbacks, +} from './customDialogCallbacks'; + +type Translate = (key: string) => string; + +interface AlertState { + isOpen: boolean; + message: string; + confirmText: string; + cancelText?: string; + danger: boolean; + type: 'alert' | 'confirm' | 'custom'; +} + +interface CustomDialogState { + isOpen: boolean; + html: string; + confirmText?: string; + cancelText?: string; + showCancel?: boolean; + onContentMount?: (element: HTMLElement) => void | (() => void); +} + +interface ColorPickerState { + isOpen: boolean; + color: string; + position?: { x: number; y: number }; + id?: string; + referenceElement?: HTMLElement; + onChange?: (color: string) => void; + onComplete?: (color: string) => void; +} + +interface ColorPickerOptions { + initialColor: string; + onColorChange: (color: string) => void; + position?: { x: number; y: number }; + id?: string; + referenceElement?: HTMLElement; + onClose?: () => void; + onColorChangeComplete?: (color: string) => void; +} + +interface UseMainDialogRuntimeOptions { + t: Translate; +} + +export interface MainDialogRuntime { + alertState: AlertState; + customDialogState: CustomDialogState; + colorPickerState: ColorPickerState; + showAlert: ( + message: string, + confirmText?: string, + onDismiss?: () => void, + ) => void; + showConfirm: ( + message: string, + onConfirm: () => void, + options?: { + onCancel?: () => void; + confirmText?: string; + cancelText?: string; + danger?: boolean; + }, + ) => void; + handleAlertConfirm: () => void; + handleAlertCancel: () => void; + showCustomDialog: ( + html: string, + options?: { + onConfirm?: () => void; + onCancel?: () => void; + confirmText?: string; + cancelText?: string; + showCancel?: boolean; + onContentMount?: (element: HTMLElement) => void | (() => void); + }, + ) => void; + handleCustomDialogConfirm: () => void; + handleCustomDialogCancel: () => void; + openColorPickerWithOptions: (options: ColorPickerOptions) => void; + closeColorPicker: () => void; + handleGlobalColorChange: (newColor: string) => void; + handleGlobalColorChangeComplete: (newColor: string) => void; + confirmCallbackRef: React.RefObject<(() => void) | null>; + cancelCallbackRef: React.RefObject<(() => void) | null>; + customDialogCallbackRef: React.RefObject; +} + +export const useMainDialogRuntime = ({ + t, +}: UseMainDialogRuntimeOptions): MainDialogRuntime => { + const confirmCallbackRef = useRef<(() => void) | null>(null); + const cancelCallbackRef = useRef<(() => void) | null>(null); + const [alertState, setAlertState] = useState(() => ({ + isOpen: false, + message: '', + confirmText: t('common.confirm'), + cancelText: undefined, + danger: false, + type: 'alert', + })); + + // Custom Dialog 상태 (HTML 콘텐츠) + const customDialogCallbackRef = useRef({}); + const [customDialogState, setCustomDialogState] = useState( + { + isOpen: false, + html: '', + confirmText: undefined, + cancelText: undefined, + showCancel: false, + onContentMount: undefined, + }, + ); + + // Global Color Picker 상태 + const colorPickerCloseCallbackRef = useRef<(() => void) | null>(null); + // 콜백은 ref가 아니라 열림 상태에 함께 싣는다. 퇴장 유예 동안 다른 피커가 + // 열리면 ref는 이미 새 주인을 가리켜, 옛 피커의 마지막 커밋이 엉뚱한 대상에 꽂힌다. + // 상태에 실으면 엘리먼트가 그 세션의 콜백을 그대로 들고 퇴장한다 + const [colorPickerState, setColorPickerState] = useState({ + isOpen: false, + color: '#FFFFFF', + position: undefined, + id: undefined, + referenceElement: undefined, + }); + + // 새 요청이 기존 alert/confirm을 대체할 때 이전 콜백을 settle해 Promise 유실 방지 + const settlePendingDialog = () => { + const cancel = cancelCallbackRef.current; + confirmCallbackRef.current = null; + cancelCallbackRef.current = null; + cancel?.(); + }; + + const showAlert = ( + message: string, + confirmText?: string, + onDismiss?: () => void, + ) => { + settlePendingDialog(); + // alert는 확인·배경 클릭 어느 경로로 닫혀도 동일하게 settle + confirmCallbackRef.current = onDismiss ?? null; + cancelCallbackRef.current = onDismiss ?? null; + setAlertState({ + isOpen: true, + message, + type: 'alert', + confirmText: confirmText || t('common.confirm'), + cancelText: undefined, + danger: false, + }); + }; + + const showConfirm = ( + message: string, + onConfirm: () => void, + options?: { + onCancel?: () => void; + confirmText?: string; + cancelText?: string; + danger?: boolean; + }, + ) => { + settlePendingDialog(); + confirmCallbackRef.current = + typeof onConfirm === 'function' ? onConfirm : null; + cancelCallbackRef.current = + typeof options?.onCancel === 'function' ? options.onCancel : null; + setAlertState({ + isOpen: true, + message, + confirmText: options?.confirmText || t('common.confirm'), + cancelText: options?.cancelText, + danger: options?.danger ?? false, + type: 'confirm', + }); + }; + + const closeAlert = () => { + setAlertState({ + isOpen: false, + message: '', + confirmText: t('common.confirm'), + cancelText: undefined, + danger: false, + type: 'alert', + }); + confirmCallbackRef.current = null; + cancelCallbackRef.current = null; + }; + + // 닫은 뒤 콜백 실행 — 콜백이 동기적으로 새 다이얼로그를 열어도 닫히지 않게 + const handleAlertConfirm = () => { + const callback = confirmCallbackRef.current; + closeAlert(); + callback?.(); + }; + + const handleAlertCancel = () => { + const callback = cancelCallbackRef.current; + closeAlert(); + callback?.(); + }; + + // Custom Dialog 핸들러 + const showCustomDialog = ( + html: string, + options?: { + onConfirm?: () => void; + onCancel?: () => void; + confirmText?: string; + cancelText?: string; + showCancel?: boolean; + onContentMount?: (element: HTMLElement) => void | (() => void); + }, + ) => { + if (colorPickerState.isOpen) { + closeCustomDialogOwnedSurface( + colorPickerState.referenceElement, + closeColorPicker, + ); + } + replaceCustomDialogCallbacks(customDialogCallbackRef, { + onConfirm: options?.onConfirm, + onCancel: options?.onCancel, + }); + setCustomDialogState({ + isOpen: true, + html, + confirmText: options?.confirmText, + cancelText: options?.cancelText, + showCancel: options?.showCancel ?? false, + onContentMount: options?.onContentMount, + }); + }; + + const closeCustomDialog = () => { + // 다이얼로그 내부 앵커에 붙은 전역 피커는 다이얼로그와 함께 정리 + if (colorPickerState.isOpen) { + closeCustomDialogOwnedSurface( + colorPickerState.referenceElement, + closeColorPicker, + ); + } + setCustomDialogState({ + isOpen: false, + html: '', + confirmText: undefined, + cancelText: undefined, + showCancel: false, + onContentMount: undefined, + }); + customDialogCallbackRef.current = {}; + }; + + const handleCustomDialogConfirm = () => { + if (customDialogCallbackRef.current.onConfirm) { + customDialogCallbackRef.current.onConfirm(); + } + closeCustomDialog(); + }; + + const handleCustomDialogCancel = () => { + if (customDialogCallbackRef.current.onCancel) { + customDialogCallbackRef.current.onCancel(); + } + closeCustomDialog(); + }; + + const openColorPickerWithOptions = (options: ColorPickerOptions) => { + colorPickerCloseCallbackRef.current = options.onClose || null; + setColorPickerState({ + isOpen: true, + color: options.initialColor, + position: options.position, + id: options.id, + referenceElement: options.referenceElement, + onChange: options.onColorChange, + onComplete: options.onColorChangeComplete, + }); + }; + + const closeColorPicker = () => { + if (colorPickerCloseCallbackRef.current) { + colorPickerCloseCallbackRef.current(); + } + // 세션 콜백은 지우지 않는다. 퇴장 중 언마운트 커밋이 아직 남아 있고, + // 그 커밋은 이 세션의 대상으로 가야 한다 + setColorPickerState((prev) => ({ ...prev, isOpen: false })); + colorPickerCloseCallbackRef.current = null; + }; + + // 콜백을 상태에서 꺼내므로 이 클로저는 열림 세션에 묶인다. + // 엘리먼트가 붙잡히면 클로저도 함께 붙잡혀 퇴장 구간의 마지막 커밋이 제 대상으로 간다 + const handleGlobalColorChange = (newColor: string) => { + setColorPickerState((prev) => ({ ...prev, color: newColor })); + colorPickerState.onChange?.(newColor); + }; + + const handleGlobalColorChangeComplete = (newColor: string) => { + colorPickerState.onComplete?.(newColor); + }; + + return { + alertState, + customDialogState, + colorPickerState, + showAlert, + showConfirm, + handleAlertConfirm, + handleAlertCancel, + showCustomDialog, + handleCustomDialogConfirm, + handleCustomDialogCancel, + openColorPickerWithOptions, + closeColorPicker, + handleGlobalColorChange, + handleGlobalColorChangeComplete, + confirmCallbackRef, + cancelCallbackRef, + customDialogCallbackRef, + }; +}; + +export const useMainDialogRuntimeLifecycle = ( + runtime: MainDialogRuntime, +): void => { + const { + colorPickerState, + showAlert, + showConfirm, + showCustomDialog, + openColorPickerWithOptions, + closeColorPicker, + confirmCallbackRef, + cancelCallbackRef, + customDialogCallbackRef, + } = runtime; + + // 언마운트 시 대기 중 다이얼로그 Promise settle (HMR·루트 교체 대비) + useEffect( + () => () => { + const cancel = cancelCallbackRef.current; + confirmCallbackRef.current = null; + cancelCallbackRef.current = null; + cancel?.(); + replaceCustomDialogCallbacks(customDialogCallbackRef, {}); + }, + // runtime ref 수명 고정 + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + // Global Color Picker 핸들러 + const showColorPickerImpl = useRef<(options: ColorPickerOptions) => void>( + () => {}, + ); + const showColorPicker = (options: ColorPickerOptions) => { + showColorPickerImpl.current(options); + }; + + useEffect(() => { + showColorPickerImpl.current = (options: ColorPickerOptions) => { + // Toggle logic - 이미 열려있으면 닫기만 하고 종료 + if ( + options.id && + colorPickerState.isOpen && + colorPickerState.id === options.id + ) { + closeColorPicker(); + return; + } + + // 다른 컬러 픽커가 열려있으면 먼저 닫기 + if (colorPickerState.isOpen) { + closeColorPicker(); + // 약간의 지연 후 새 컬러 픽커 열기 (상태 갱신을 위해) + setTimeout(() => { + openColorPickerWithOptions(options); + }, 0); + return; + } + + openColorPickerWithOptions(options); + }; + }); + + const colorPickerStateRef = useRef(colorPickerState); + useEffect(() => { + colorPickerStateRef.current = colorPickerState; + }, [colorPickerState]); + const getColorPickerState = () => colorPickerStateRef.current; + + // Dialog API를 전역으로 노출 + useEffect(() => { + window.__dmn_showAlert = showAlert; + window.__dmn_showConfirm = showConfirm; + window.__dmn_showCustomDialog = showCustomDialog; + window.__dmn_showColorPicker = showColorPicker; + window.__dmn_getColorPickerState = getColorPickerState; + + return () => { + delete window.__dmn_showAlert; + delete window.__dmn_showConfirm; + delete window.__dmn_showCustomDialog; + delete window.__dmn_showColorPicker; + delete window.__dmn_getColorPickerState; + }; + }); +}; From fe8a7ff91c887259de012437e4ca80c1dc8b607a Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 16:10:31 +0900 Subject: [PATCH 117/245] =?UTF-8?q?refactor:=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=86=90=EC=83=81=20=EB=B3=B5?= =?UTF-8?q?=EA=B5=AC=20=EB=AA=A8=EB=93=88=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/state/migration.rs | 772 +--------------------- src-tauri/src/state/migration/recovery.rs | 765 +++++++++++++++++++++ 2 files changed, 773 insertions(+), 764 deletions(-) create mode 100644 src-tauri/src/state/migration/recovery.rs diff --git a/src-tauri/src/state/migration.rs b/src-tauri/src/state/migration.rs index de93e9296..5649b5b73 100644 --- a/src-tauri/src/state/migration.rs +++ b/src-tauri/src/state/migration.rs @@ -39,6 +39,14 @@ use crate::{ services::font_metadata::parse_font_metadata, }; +mod recovery; + +#[cfg(test)] +use recovery::{ + recover_key_mapping_entries, recover_local_font_enabled, recover_sound_library_entries, +}; +use recovery::{repair_custom_tab_key_layout_pairs, repair_legacy_state}; + const LEGACY_OVERLAY_WIDTH: f64 = 860.0; const LEGACY_OVERLAY_HEIGHT: f64 = 320.0; const APP_DATA_MARKER: &str = "com.dmnote.desktop"; @@ -1358,770 +1366,6 @@ fn merge_default_counters(target: &mut KeyCounters, keys: &KeyMappings) { target.retain(|mode, _| available_modes.contains(mode)); } -/// 레거시/비정상 store 파일 필드별 복구 -fn repair_legacy_state(value: Value) -> AppStoreData { - let Value::Object(mut source) = value else { - return normalize_state(AppStoreData::default()); - }; - migrate_legacy_repair_fields(&mut source); - let source_keys = source.get("keys").cloned(); - let source_key_positions = source.get("keyPositions").cloned(); - - let Value::Object(mut recovered) = - serde_json::to_value(AppStoreData::default()).unwrap_or_else(|_| Value::Object(Map::new())) - else { - return normalize_state(AppStoreData::default()); - }; - - for (field, value) in source { - let previous = recovered.insert(field.clone(), value.clone()); - if serde_json::from_value::(Value::Object(recovered.clone())).is_err() { - if let Some(partial) = recover_collection_field(&field, &value) { - recovered.insert(field.clone(), partial); - if serde_json::from_value::(Value::Object(recovered.clone())).is_ok() - { - continue; - } - } - - log::warn!("[Store] Resetting invalid field to default: {field}"); - match previous { - Some(value) => { - recovered.insert(field, value); - } - None => { - recovered.remove(&field); - } - } - } - } - - let mut data = - serde_json::from_value::(Value::Object(recovered)).unwrap_or_default(); - migrate_legacy_knob_sensitivity(&mut data); - repair_image_transforms(&mut data); - repair_semantic_identities(&mut data); - repair_custom_tab_key_layout_pairs( - &mut data, - source_keys.as_ref(), - source_key_positions.as_ref(), - ); - canonicalize_gradient_pairs(&mut data); - canonicalize_image_modes(&mut data); - migrate_custom_css_history_timestamps(&mut data.custom_css_history); - normalize_state(data) -} - -fn repair_custom_tab_key_layout_pairs( - data: &mut AppStoreData, - source_keys: Option<&Value>, - source_key_positions: Option<&Value>, -) -> bool { - let tab_ids = data - .custom_tabs - .iter() - .map(|tab| tab.id.clone()) - .collect::>(); - let mut repaired = false; - - for mode in tab_ids { - let source_keys = source_mode_array(source_keys, &mode); - let source_positions = source_mode_array(source_key_positions, &mode); - - match (source_keys, source_positions) { - (Some(_), Some(_)) => {} - (None, Some(positions)) => { - log::warn!( - "[Store] Rebuilding invalid keys mode '{mode}' with {} unassigned entries during recovery", - positions.len() - ); - data.keys - .insert(mode, vec![KeySlot::default(); positions.len()]); - repaired = true; - } - (Some(keys), None) => { - log::warn!( - "[Store] Rebuilding invalid keyPositions mode '{mode}' with {} default entries during recovery", - keys.len() - ); - data.key_positions - .insert(mode, vec![KeyPosition::default(); keys.len()]); - repaired = true; - } - (None, None) => { - log::warn!( - "[Store] Initializing missing keys and keyPositions modes '{mode}' during recovery" - ); - data.keys.insert(mode.clone(), Vec::new()); - data.key_positions.insert(mode, Vec::new()); - repaired = true; - } - } - } - - repaired -} - -fn source_mode_array<'a>(collection: Option<&'a Value>, mode: &str) -> Option<&'a [Value]> { - collection? - .as_object()? - .get(mode)? - .as_array() - .map(Vec::as_slice) -} - -fn recover_collection_field(field: &str, value: &Value) -> Option { - match field { - "noteSettings" => recover_object_fields::(field, value), - "customTabs" => recover_array_entries::(field, value), - "keys" => recover_key_mapping_entries(value), - "soundLibrary" => recover_sound_library_entries(value), - "keyPositions" => recover_key_position_entries(field, value), - "statPositions" => recover_key_position_backed_entries::( - field, - value, - has_valid_stat_identity, - ), - "graphPositions" => recover_key_position_backed_entries::( - field, - value, - has_valid_graph_identity, - ), - "knobPositions" => recover_key_position_backed_entries::( - field, - value, - has_valid_knob_identity, - ), - "layerGroups" => recover_position_entries::(field, value), - "keyCounters" => recover_key_counter_entries(value), - "customCss" => recover_object_fields::(field, value), - "customCssHistory" => recover_array_entries::(field, value), - "fontSettings" => recover_font_settings(value), - "counterAnimationPresets" => recover_array_entries::(field, value), - "tabCssOverrides" => recover_map_object_entries::(field, value), - "tabNoteOverrides" => recover_map_object_entries::(field, value), - "customJs" => recover_custom_js(value), - "gridSettings" => recover_object_fields::(field, value), - "shortcuts" => recover_object_fields::(field, value), - _ => None, - } -} - -fn recover_object_fields(field: &str, value: &Value) -> Option -where - T: Default + DeserializeOwned + Serialize, -{ - let Value::Object(source) = value else { - return None; - }; - let Value::Object(mut recovered) = serde_json::to_value(T::default()).ok()? else { - return None; - }; - - for (name, entry) in source { - let previous = recovered.insert(name.clone(), entry.clone()); - if serde_json::from_value::(Value::Object(recovered.clone())).is_err() { - log::warn!( - "[Store] Resetting invalid {field} child '{name}' to its default during recovery" - ); - match previous { - Some(value) => { - recovered.insert(name.clone(), value); - } - None => { - recovered.remove(name); - } - } - } - } - Some(Value::Object(recovered)) -} - -fn recover_key_position_fields(field: &str, value: &Value) -> Option { - let Value::Object(source) = value else { - return None; - }; - let Value::Object(mut recovered) = recover_object_fields::(field, value)? else { - return None; - }; - - // sparse 폰트 필드는 부재 자체가 레거시 의미를 가진다 - if !source.contains_key("fontWeight") { - recovered.remove("fontWeight"); - } - let source_weight_is_valid = source - .get("fontWeight") - .is_none_or(|entry| serde_json::from_value::>(entry.clone()).is_ok()); - if !source.contains_key("fontBold") && source_weight_is_valid { - recovered.remove("fontBold"); - } - - Some(Value::Object(recovered)) -} - -fn recover_array_entries(field: &str, value: &Value) -> Option -where - T: DeserializeOwned, -{ - let Value::Array(entries) = value else { - return None; - }; - - let mut recovered = Vec::with_capacity(entries.len()); - for (index, entry) in entries.iter().enumerate() { - match serde_json::from_value::(entry.clone()) { - Ok(_) => recovered.push(entry.clone()), - Err(err) => { - log::warn!( - "[Store] Removing invalid {field} entry '[{index}]' during recovery: {err}" - ); - } - } - } - Some(Value::Array(recovered)) -} - -fn recover_key_mapping_entries(value: &Value) -> Option { - let Value::Object(modes) = value else { - return None; - }; - - let defaults = default_keys(); - let mut recovered_modes = Map::new(); - for (mode, entries) in modes { - let recovered_entries = match entries { - Value::Array(entries) => entries - .iter() - .map(|entry| { - serde_json::to_value(normalize_key_slot(entry.clone())) - .unwrap_or(Value::String(String::new())) - }) - .collect(), - _ => { - log::warn!( - "[Store] Replacing invalid keys mode '{mode}' during recovery: expected an array" - ); - defaults - .get(mode) - .map(|keys| { - keys.iter() - .filter_map(|slot| serde_json::to_value(slot).ok()) - .collect() - }) - .unwrap_or_default() - } - }; - recovered_modes.insert(mode.clone(), Value::Array(recovered_entries)); - } - Some(Value::Object(recovered_modes)) -} - -fn recover_key_counter_entries(value: &Value) -> Option { - let Value::Object(modes) = value else { - return None; - }; - - let mut recovered_modes = Map::new(); - for (mode, counters) in modes { - let Value::Object(counters) = counters else { - log::warn!( - "[Store] Replacing invalid keyCounters mode '{mode}' with an empty map during recovery" - ); - recovered_modes.insert(mode.clone(), Value::Object(Map::new())); - continue; - }; - - let mut recovered_counters = Map::new(); - for (key, count) in counters { - if serde_json::from_value::(count.clone()).is_ok() { - recovered_counters.insert(key.clone(), count.clone()); - } else { - log::warn!( - "[Store] Removing invalid keyCounters entry '{mode}.{key}' during recovery" - ); - } - } - recovered_modes.insert(mode.clone(), Value::Object(recovered_counters)); - } - Some(Value::Object(recovered_modes)) -} - -fn recover_custom_js(value: &Value) -> Option { - let Value::Object(settings) = value else { - return None; - }; - let mut candidate = settings.clone(); - if let Some(plugins) = settings.get("plugins") { - let recovered_plugins = recover_array_entries::("customJs.plugins", plugins) - .unwrap_or_else(|| Value::Array(Vec::new())); - candidate.insert("plugins".to_string(), recovered_plugins); - } - recover_object_fields::("customJs", &Value::Object(candidate)) -} - -fn recover_sound_library_entries(value: &Value) -> Option { - let Value::Object(entries) = value else { - return None; - }; - let default_entry = serde_json::to_value(SoundLibraryEntry::default()).ok()?; - let mut recovered = Map::new(); - - for (key, entry) in entries { - if serde_json::from_value::(entry.clone()).is_ok() { - recovered.insert(key.clone(), entry.clone()); - continue; - } - - let entry_name = format!("soundLibrary.{key}"); - if let Some(partial) = recover_object_fields::(&entry_name, entry) { - if serde_json::from_value::(partial.clone()).is_ok() { - recovered.insert(key.clone(), partial); - continue; - } - } - - // 외래 기기의 관리 사운드 경로 키도 재건 대상 — 삭제하면 재귀화 치유 기회가 사라짐 - let rebuildable_key = Path::new(key).is_absolute() - || matches!( - parse_portable_asset_reference(key), - Some((AssetCategory::Sounds, _)) - ); - if rebuildable_key { - log::warn!( - "[Store] Rebuilding invalid soundLibrary entry '{key}' from its asset path during recovery" - ); - recovered.insert(key.clone(), default_entry.clone()); - } else { - log::warn!( - "[Store] Removing invalid soundLibrary entry '{key}' with a non-absolute path during recovery" - ); - } - } - - Some(Value::Object(recovered)) -} - -fn recover_map_object_entries(field: &str, value: &Value) -> Option -where - T: Default + DeserializeOwned + Serialize, -{ - let Value::Object(entries) = value else { - return None; - }; - - let mut recovered = Map::new(); - for (key, entry) in entries { - if serde_json::from_value::(entry.clone()).is_ok() { - recovered.insert(key.clone(), entry.clone()); - continue; - } - - let entry_name = format!("{field}.{key}"); - let Some(partial) = recover_object_fields::(&entry_name, entry) else { - log::warn!("[Store] Removing invalid {field} entry '{key}' during recovery"); - continue; - }; - if serde_json::from_value::(partial.clone()).is_ok() { - recovered.insert(key.clone(), partial); - } else { - log::warn!("[Store] Removing unrecoverable {field} entry '{key}' during recovery"); - } - } - Some(Value::Object(recovered)) -} - -fn recover_key_position_backed_entries( - field: &str, - value: &Value, - has_valid_identity: fn(&Value) -> bool, -) -> Option -where - T: DeserializeOwned, -{ - let Value::Object(modes) = value else { - return None; - }; - - let mut recovered_modes = Map::new(); - for (mode, entries) in modes { - let Value::Array(entries) = entries else { - log::warn!( - "[Store] Removing invalid {field} mode '{mode}' during recovery: expected an array" - ); - continue; - }; - - let mut recovered_entries = Vec::with_capacity(entries.len()); - for (index, entry) in entries.iter().enumerate() { - if serde_json::from_value::(entry.clone()).is_ok() { - recovered_entries.push(entry.clone()); - continue; - } - - let entry_name = format!("{field}.{mode}[{index}]"); - let mut candidate = entry.clone(); - recover_image_transform_children(&entry_name, &mut candidate); - recover_invalid_counter_fill_gradient_children(&entry_name, &mut candidate); - if serde_json::from_value::(candidate.clone()).is_ok() { - recovered_entries.push(candidate); - continue; - } - - if !has_valid_identity(&candidate) { - log::warn!( - "[Store] Removing invalid {field} entry '{mode}[{index}]' with a damaged identity during recovery" - ); - continue; - } - - let Some(partial) = recover_key_position_fields(&entry_name, &candidate) else { - log::warn!( - "[Store] Removing invalid {field} entry '{mode}[{index}]' during recovery" - ); - continue; - }; - if serde_json::from_value::(partial.clone()).is_ok() { - recovered_entries.push(partial); - } else { - log::warn!( - "[Store] Removing invalid {field} entry '{mode}[{index}]' with damaged non-layout fields during recovery" - ); - } - } - recovered_modes.insert(mode.clone(), Value::Array(recovered_entries)); - } - - Some(Value::Object(recovered_modes)) -} - -fn value_field_deserializes(value: &Value, field: &str) -> bool -where - T: DeserializeOwned, -{ - value - .get(field) - .is_some_and(|entry| serde_json::from_value::(entry.clone()).is_ok()) -} - -fn has_valid_stat_identity(value: &Value) -> bool { - value_field_deserializes::(value, "statType") -} - -fn has_valid_graph_identity(value: &Value) -> bool { - value_field_deserializes::(value, "statType") - && value_field_deserializes::(value, "graphType") -} - -fn has_valid_knob_identity(value: &Value) -> bool { - value.get("axisId").is_none_or(Value::is_string) -} - -fn recover_key_position_entries(field: &str, value: &Value) -> Option { - let Value::Object(modes) = value else { - return None; - }; - let default_position = match serde_json::to_value(KeyPosition::default()) { - Ok(value) => value, - Err(err) => { - log::warn!("[Store] Failed to serialize the default {field} entry: {err}"); - return None; - } - }; - - let mut recovered_modes = Map::new(); - for (mode, entries) in modes { - let Value::Array(entries) = entries else { - log::warn!( - "[Store] Removing invalid {field} mode '{mode}' during recovery: expected an array" - ); - continue; - }; - - let mut recovered_entries = Vec::with_capacity(entries.len()); - for (index, entry) in entries.iter().enumerate() { - match serde_json::from_value::(entry.clone()) { - Ok(_) => recovered_entries.push(entry.clone()), - Err(err) => { - let entry_name = format!("{field}.{mode}[{index}]"); - let mut candidate = entry.clone(); - recover_image_transform_children(&entry_name, &mut candidate); - recover_invalid_counter_fill_gradient_children(&entry_name, &mut candidate); - let recovered = if serde_json::from_value::(candidate.clone()) - .is_ok() - { - candidate - } else { - recover_key_position_fields(&entry_name, &candidate) - .filter(|candidate| { - serde_json::from_value::(candidate.clone()).is_ok() - }) - .unwrap_or_else(|| { - log::warn!( - "[Store] Replacing invalid {field} entry '{mode}[{index}]' with default during recovery: {err}" - ); - default_position.clone() - }) - }; - recovered_entries.push(recovered); - } - } - } - recovered_modes.insert(mode.clone(), Value::Array(recovered_entries)); - } - Some(Value::Object(recovered_modes)) -} - -fn recover_image_transform_children(entry_name: &str, value: &mut Value) -> bool { - let Some(position) = value.as_object_mut() else { - return false; - }; - let mut repaired = false; - - let invalid_mode = position - .get("imageMode") - .is_some_and(|mode| serde_json::from_value::>(mode.clone()).is_err()); - if invalid_mode { - log::warn!("[Store] Resetting invalid {entry_name}.imageMode to None during recovery"); - position.remove("imageMode"); - repaired = true; - } - - for field in ["idleImageTransform", "activeImageTransform"] { - let invalid_object = position - .get(field) - .is_some_and(|transform| !transform.is_null() && !transform.is_object()); - if invalid_object { - log::warn!("[Store] Resetting invalid {entry_name}.{field} to None during recovery"); - position.remove(field); - repaired = true; - continue; - } - let Some(transform) = position.get_mut(field).and_then(Value::as_object_mut) else { - continue; - }; - for (leaf, fallback, minimum, maximum) in [ - ( - "offsetX", - 0.0, - IMAGE_TRANSFORM_OFFSET_MIN, - IMAGE_TRANSFORM_OFFSET_MAX, - ), - ( - "offsetY", - 0.0, - IMAGE_TRANSFORM_OFFSET_MIN, - IMAGE_TRANSFORM_OFFSET_MAX, - ), - ( - "rotation", - 0.0, - IMAGE_TRANSFORM_ROTATION_MIN, - IMAGE_TRANSFORM_ROTATION_MAX, - ), - ( - "scale", - 1.0, - IMAGE_TRANSFORM_SCALE_MIN, - IMAGE_TRANSFORM_SCALE_MAX, - ), - ] { - let invalid = transform - .get(leaf) - .and_then(Value::as_f64) - .is_none_or(|value| !value.is_finite() || !(minimum..=maximum).contains(&value)); - if invalid { - log::warn!( - "[Store] Resetting invalid {entry_name}.{field}.{leaf} to identity during recovery" - ); - transform.insert(leaf.to_string(), serde_json::json!(fallback)); - repaired = true; - } - } - } - repaired -} - -fn recover_invalid_counter_fill_gradient_children(entry_name: &str, value: &mut Value) -> bool { - let Some(counter) = value.get_mut("counter").and_then(Value::as_object_mut) else { - return false; - }; - - let mut changed = false; - for field in ["fillIdleGradient", "fillActiveGradient"] { - let invalid = counter.get(field).is_some_and(|gradient| { - serde_json::from_value::>(gradient.clone()).is_err() - }); - if invalid { - log::warn!( - "[Store] Resetting invalid {entry_name}.counter.{field} to None during recovery" - ); - counter.remove(field); - changed = true; - } - } - changed -} - -fn recover_position_entries(field: &str, value: &Value) -> Option -where - T: DeserializeOwned, -{ - let Value::Object(modes) = value else { - return None; - }; - - let mut recovered_modes = Map::new(); - for (mode, entries) in modes { - let Value::Array(entries) = entries else { - log::warn!( - "[Store] Removing invalid {field} mode '{mode}' during recovery: expected an array" - ); - continue; - }; - - let mut recovered_entries = Vec::with_capacity(entries.len()); - for (index, entry) in entries.iter().enumerate() { - match serde_json::from_value::(entry.clone()) { - Ok(_) => recovered_entries.push(entry.clone()), - Err(err) => { - log::warn!( - "[Store] Removing invalid {field} entry '{mode}[{index}]' during recovery: {err}" - ); - } - } - } - recovered_modes.insert(mode.clone(), Value::Array(recovered_entries)); - } - Some(Value::Object(recovered_modes)) -} - -fn recover_font_settings(value: &Value) -> Option { - let Value::Object(settings) = value else { - return None; - }; - let mut recovered = settings.clone(); - let Some(custom_fonts) = settings.get("customFonts") else { - return Some(Value::Object(recovered)); - }; - let Value::Array(custom_fonts) = custom_fonts else { - log::warn!( - "[Store] Removing invalid fontSettings.customFonts collection during recovery: expected an array" - ); - recovered.insert("customFonts".to_string(), Value::Array(Vec::new())); - return Some(Value::Object(recovered)); - }; - - let mut recovered_fonts = Vec::with_capacity(custom_fonts.len()); - for (index, font) in custom_fonts.iter().enumerate() { - let mut candidate = font.clone(); - let invalid_weight_ranges = candidate.get("weightRanges").is_some_and(|ranges| { - serde_json::from_value::>(ranges.clone()).is_err() - }); - if invalid_weight_ranges { - if let Some(object) = candidate.as_object_mut() { - object.remove("weightRanges"); - } - log::warn!( - "[Store] Resetting invalid fontSettings.customFonts entry '[{index}].weightRanges' during recovery" - ); - } - - match serde_json::from_value::(candidate.clone()) { - Ok(_) => recovered_fonts.push(candidate), - Err(err) => { - if let Some(recovered) = recover_local_font_enabled(&candidate) { - log::warn!( - "[Store] Disabling fontSettings.customFonts entry '[{index}]' with an invalid enabled field during recovery" - ); - recovered_fonts.push(recovered); - } else { - log::warn!( - "[Store] Removing invalid fontSettings.customFonts entry '[{index}]' during recovery: {err}" - ); - } - } - } - } - recovered.insert("customFonts".to_string(), Value::Array(recovered_fonts)); - Some(Value::Object(recovered)) -} - -fn recover_local_font_enabled(value: &Value) -> Option { - let Value::Object(source) = value else { - return None; - }; - if source.get("enabled").is_some_and(Value::is_boolean) { - return None; - } - let id = source.get("id").and_then(Value::as_str)?; - let name = source.get("name").and_then(Value::as_str)?; - if id.trim().is_empty() || name.trim().is_empty() { - return None; - } - let font_type = source - .get("type") - .and_then(|font_type| serde_json::from_value::(font_type.clone()).ok())?; - if font_type != FontType::Local { - return None; - } - let local_path = source.get("localPath").and_then(Value::as_str)?; - let trimmed_path = local_path.trim(); - // 외래 기기의 관리 폰트 경로도 유효한 복구 정체성 — 항목 삭제 대신 비활성 복구 - let portable_font_path = matches!( - parse_portable_asset_reference(trimmed_path), - Some((AssetCategory::Fonts, _)) - ); - if trimmed_path.is_empty() || (!Path::new(trimmed_path).is_absolute() && !portable_font_path) { - return None; - } - - let mut candidate = source.clone(); - candidate.insert("enabled".to_string(), Value::Bool(false)); - serde_json::from_value::(Value::Object(candidate.clone())).ok()?; - Some(Value::Object(candidate)) -} - -fn migrate_legacy_repair_fields(fields: &mut Map) { - promote_legacy_field(fields, "useCustomCss", "useCustomCSS"); - promote_legacy_field(fields, "customCss", "customCSS"); - promote_legacy_field(fields, "useCustomJs", "useCustomJS"); - promote_legacy_field(fields, "customJs", "customJS"); - - let legacy_bounds = fields.remove("overlayWindowBounds"); - let legacy_position = fields.remove("overlayWindowPosition"); - if fields.contains_key("overlayBounds") { - return; - } - if let Some(value) = legacy_bounds { - if serde_json::from_value::(value.clone()).is_ok() { - fields.insert("overlayBounds".to_string(), value); - return; - } - } - if let Some(value) = legacy_position { - if let Ok(position) = serde_json::from_value::(value) { - fields.insert( - "overlayBounds".to_string(), - serde_json::json!({ - "x": position.x, - "y": position.y, - "width": LEGACY_OVERLAY_WIDTH, - "height": LEGACY_OVERLAY_HEIGHT, - }), - ); - } - } -} - -fn promote_legacy_field(fields: &mut Map, current: &str, legacy: &str) { - if fields.contains_key(current) { - return; - } - if let Some(value) = fields.remove(legacy) { - fields.insert(current.to_string(), value); - } -} - fn option_has_non_empty_text(value: &Option) -> bool { value .as_ref() diff --git a/src-tauri/src/state/migration/recovery.rs b/src-tauri/src/state/migration/recovery.rs new file mode 100644 index 000000000..50389c78a --- /dev/null +++ b/src-tauri/src/state/migration/recovery.rs @@ -0,0 +1,765 @@ +use super::*; + +/// 레거시/비정상 store 파일 필드별 복구 +pub(super) fn repair_legacy_state(value: Value) -> AppStoreData { + let Value::Object(mut source) = value else { + return normalize_state(AppStoreData::default()); + }; + migrate_legacy_repair_fields(&mut source); + let source_keys = source.get("keys").cloned(); + let source_key_positions = source.get("keyPositions").cloned(); + + let Value::Object(mut recovered) = + serde_json::to_value(AppStoreData::default()).unwrap_or_else(|_| Value::Object(Map::new())) + else { + return normalize_state(AppStoreData::default()); + }; + + for (field, value) in source { + let previous = recovered.insert(field.clone(), value.clone()); + if serde_json::from_value::(Value::Object(recovered.clone())).is_err() { + if let Some(partial) = recover_collection_field(&field, &value) { + recovered.insert(field.clone(), partial); + if serde_json::from_value::(Value::Object(recovered.clone())).is_ok() + { + continue; + } + } + + log::warn!("[Store] Resetting invalid field to default: {field}"); + match previous { + Some(value) => { + recovered.insert(field, value); + } + None => { + recovered.remove(&field); + } + } + } + } + + let mut data = + serde_json::from_value::(Value::Object(recovered)).unwrap_or_default(); + migrate_legacy_knob_sensitivity(&mut data); + repair_image_transforms(&mut data); + repair_semantic_identities(&mut data); + repair_custom_tab_key_layout_pairs( + &mut data, + source_keys.as_ref(), + source_key_positions.as_ref(), + ); + canonicalize_gradient_pairs(&mut data); + canonicalize_image_modes(&mut data); + migrate_custom_css_history_timestamps(&mut data.custom_css_history); + normalize_state(data) +} + +pub(super) fn repair_custom_tab_key_layout_pairs( + data: &mut AppStoreData, + source_keys: Option<&Value>, + source_key_positions: Option<&Value>, +) -> bool { + let tab_ids = data + .custom_tabs + .iter() + .map(|tab| tab.id.clone()) + .collect::>(); + let mut repaired = false; + + for mode in tab_ids { + let source_keys = source_mode_array(source_keys, &mode); + let source_positions = source_mode_array(source_key_positions, &mode); + + match (source_keys, source_positions) { + (Some(_), Some(_)) => {} + (None, Some(positions)) => { + log::warn!( + "[Store] Rebuilding invalid keys mode '{mode}' with {} unassigned entries during recovery", + positions.len() + ); + data.keys + .insert(mode, vec![KeySlot::default(); positions.len()]); + repaired = true; + } + (Some(keys), None) => { + log::warn!( + "[Store] Rebuilding invalid keyPositions mode '{mode}' with {} default entries during recovery", + keys.len() + ); + data.key_positions + .insert(mode, vec![KeyPosition::default(); keys.len()]); + repaired = true; + } + (None, None) => { + log::warn!( + "[Store] Initializing missing keys and keyPositions modes '{mode}' during recovery" + ); + data.keys.insert(mode.clone(), Vec::new()); + data.key_positions.insert(mode, Vec::new()); + repaired = true; + } + } + } + + repaired +} + +fn source_mode_array<'a>(collection: Option<&'a Value>, mode: &str) -> Option<&'a [Value]> { + collection? + .as_object()? + .get(mode)? + .as_array() + .map(Vec::as_slice) +} + +fn recover_collection_field(field: &str, value: &Value) -> Option { + match field { + "noteSettings" => recover_object_fields::(field, value), + "customTabs" => recover_array_entries::(field, value), + "keys" => recover_key_mapping_entries(value), + "soundLibrary" => recover_sound_library_entries(value), + "keyPositions" => recover_key_position_entries(field, value), + "statPositions" => recover_key_position_backed_entries::( + field, + value, + has_valid_stat_identity, + ), + "graphPositions" => recover_key_position_backed_entries::( + field, + value, + has_valid_graph_identity, + ), + "knobPositions" => recover_key_position_backed_entries::( + field, + value, + has_valid_knob_identity, + ), + "layerGroups" => recover_position_entries::(field, value), + "keyCounters" => recover_key_counter_entries(value), + "customCss" => recover_object_fields::(field, value), + "customCssHistory" => recover_array_entries::(field, value), + "fontSettings" => recover_font_settings(value), + "counterAnimationPresets" => recover_array_entries::(field, value), + "tabCssOverrides" => recover_map_object_entries::(field, value), + "tabNoteOverrides" => recover_map_object_entries::(field, value), + "customJs" => recover_custom_js(value), + "gridSettings" => recover_object_fields::(field, value), + "shortcuts" => recover_object_fields::(field, value), + _ => None, + } +} + +fn recover_object_fields(field: &str, value: &Value) -> Option +where + T: Default + DeserializeOwned + Serialize, +{ + let Value::Object(source) = value else { + return None; + }; + let Value::Object(mut recovered) = serde_json::to_value(T::default()).ok()? else { + return None; + }; + + for (name, entry) in source { + let previous = recovered.insert(name.clone(), entry.clone()); + if serde_json::from_value::(Value::Object(recovered.clone())).is_err() { + log::warn!( + "[Store] Resetting invalid {field} child '{name}' to its default during recovery" + ); + match previous { + Some(value) => { + recovered.insert(name.clone(), value); + } + None => { + recovered.remove(name); + } + } + } + } + Some(Value::Object(recovered)) +} + +fn recover_key_position_fields(field: &str, value: &Value) -> Option { + let Value::Object(source) = value else { + return None; + }; + let Value::Object(mut recovered) = recover_object_fields::(field, value)? else { + return None; + }; + + // sparse 폰트 필드는 부재 자체가 레거시 의미를 가진다 + if !source.contains_key("fontWeight") { + recovered.remove("fontWeight"); + } + let source_weight_is_valid = source + .get("fontWeight") + .is_none_or(|entry| serde_json::from_value::>(entry.clone()).is_ok()); + if !source.contains_key("fontBold") && source_weight_is_valid { + recovered.remove("fontBold"); + } + + Some(Value::Object(recovered)) +} + +fn recover_array_entries(field: &str, value: &Value) -> Option +where + T: DeserializeOwned, +{ + let Value::Array(entries) = value else { + return None; + }; + + let mut recovered = Vec::with_capacity(entries.len()); + for (index, entry) in entries.iter().enumerate() { + match serde_json::from_value::(entry.clone()) { + Ok(_) => recovered.push(entry.clone()), + Err(err) => { + log::warn!( + "[Store] Removing invalid {field} entry '[{index}]' during recovery: {err}" + ); + } + } + } + Some(Value::Array(recovered)) +} + +pub(super) fn recover_key_mapping_entries(value: &Value) -> Option { + let Value::Object(modes) = value else { + return None; + }; + + let defaults = default_keys(); + let mut recovered_modes = Map::new(); + for (mode, entries) in modes { + let recovered_entries = match entries { + Value::Array(entries) => entries + .iter() + .map(|entry| { + serde_json::to_value(normalize_key_slot(entry.clone())) + .unwrap_or(Value::String(String::new())) + }) + .collect(), + _ => { + log::warn!( + "[Store] Replacing invalid keys mode '{mode}' during recovery: expected an array" + ); + defaults + .get(mode) + .map(|keys| { + keys.iter() + .filter_map(|slot| serde_json::to_value(slot).ok()) + .collect() + }) + .unwrap_or_default() + } + }; + recovered_modes.insert(mode.clone(), Value::Array(recovered_entries)); + } + Some(Value::Object(recovered_modes)) +} + +fn recover_key_counter_entries(value: &Value) -> Option { + let Value::Object(modes) = value else { + return None; + }; + + let mut recovered_modes = Map::new(); + for (mode, counters) in modes { + let Value::Object(counters) = counters else { + log::warn!( + "[Store] Replacing invalid keyCounters mode '{mode}' with an empty map during recovery" + ); + recovered_modes.insert(mode.clone(), Value::Object(Map::new())); + continue; + }; + + let mut recovered_counters = Map::new(); + for (key, count) in counters { + if serde_json::from_value::(count.clone()).is_ok() { + recovered_counters.insert(key.clone(), count.clone()); + } else { + log::warn!( + "[Store] Removing invalid keyCounters entry '{mode}.{key}' during recovery" + ); + } + } + recovered_modes.insert(mode.clone(), Value::Object(recovered_counters)); + } + Some(Value::Object(recovered_modes)) +} + +fn recover_custom_js(value: &Value) -> Option { + let Value::Object(settings) = value else { + return None; + }; + let mut candidate = settings.clone(); + if let Some(plugins) = settings.get("plugins") { + let recovered_plugins = recover_array_entries::("customJs.plugins", plugins) + .unwrap_or_else(|| Value::Array(Vec::new())); + candidate.insert("plugins".to_string(), recovered_plugins); + } + recover_object_fields::("customJs", &Value::Object(candidate)) +} + +pub(super) fn recover_sound_library_entries(value: &Value) -> Option { + let Value::Object(entries) = value else { + return None; + }; + let default_entry = serde_json::to_value(SoundLibraryEntry::default()).ok()?; + let mut recovered = Map::new(); + + for (key, entry) in entries { + if serde_json::from_value::(entry.clone()).is_ok() { + recovered.insert(key.clone(), entry.clone()); + continue; + } + + let entry_name = format!("soundLibrary.{key}"); + if let Some(partial) = recover_object_fields::(&entry_name, entry) { + if serde_json::from_value::(partial.clone()).is_ok() { + recovered.insert(key.clone(), partial); + continue; + } + } + + // 외래 기기의 관리 사운드 경로 키도 재건 대상 — 삭제하면 재귀화 치유 기회가 사라짐 + let rebuildable_key = Path::new(key).is_absolute() + || matches!( + parse_portable_asset_reference(key), + Some((AssetCategory::Sounds, _)) + ); + if rebuildable_key { + log::warn!( + "[Store] Rebuilding invalid soundLibrary entry '{key}' from its asset path during recovery" + ); + recovered.insert(key.clone(), default_entry.clone()); + } else { + log::warn!( + "[Store] Removing invalid soundLibrary entry '{key}' with a non-absolute path during recovery" + ); + } + } + + Some(Value::Object(recovered)) +} + +fn recover_map_object_entries(field: &str, value: &Value) -> Option +where + T: Default + DeserializeOwned + Serialize, +{ + let Value::Object(entries) = value else { + return None; + }; + + let mut recovered = Map::new(); + for (key, entry) in entries { + if serde_json::from_value::(entry.clone()).is_ok() { + recovered.insert(key.clone(), entry.clone()); + continue; + } + + let entry_name = format!("{field}.{key}"); + let Some(partial) = recover_object_fields::(&entry_name, entry) else { + log::warn!("[Store] Removing invalid {field} entry '{key}' during recovery"); + continue; + }; + if serde_json::from_value::(partial.clone()).is_ok() { + recovered.insert(key.clone(), partial); + } else { + log::warn!("[Store] Removing unrecoverable {field} entry '{key}' during recovery"); + } + } + Some(Value::Object(recovered)) +} + +fn recover_key_position_backed_entries( + field: &str, + value: &Value, + has_valid_identity: fn(&Value) -> bool, +) -> Option +where + T: DeserializeOwned, +{ + let Value::Object(modes) = value else { + return None; + }; + + let mut recovered_modes = Map::new(); + for (mode, entries) in modes { + let Value::Array(entries) = entries else { + log::warn!( + "[Store] Removing invalid {field} mode '{mode}' during recovery: expected an array" + ); + continue; + }; + + let mut recovered_entries = Vec::with_capacity(entries.len()); + for (index, entry) in entries.iter().enumerate() { + if serde_json::from_value::(entry.clone()).is_ok() { + recovered_entries.push(entry.clone()); + continue; + } + + let entry_name = format!("{field}.{mode}[{index}]"); + let mut candidate = entry.clone(); + recover_image_transform_children(&entry_name, &mut candidate); + recover_invalid_counter_fill_gradient_children(&entry_name, &mut candidate); + if serde_json::from_value::(candidate.clone()).is_ok() { + recovered_entries.push(candidate); + continue; + } + + if !has_valid_identity(&candidate) { + log::warn!( + "[Store] Removing invalid {field} entry '{mode}[{index}]' with a damaged identity during recovery" + ); + continue; + } + + let Some(partial) = recover_key_position_fields(&entry_name, &candidate) else { + log::warn!( + "[Store] Removing invalid {field} entry '{mode}[{index}]' during recovery" + ); + continue; + }; + if serde_json::from_value::(partial.clone()).is_ok() { + recovered_entries.push(partial); + } else { + log::warn!( + "[Store] Removing invalid {field} entry '{mode}[{index}]' with damaged non-layout fields during recovery" + ); + } + } + recovered_modes.insert(mode.clone(), Value::Array(recovered_entries)); + } + + Some(Value::Object(recovered_modes)) +} + +fn value_field_deserializes(value: &Value, field: &str) -> bool +where + T: DeserializeOwned, +{ + value + .get(field) + .is_some_and(|entry| serde_json::from_value::(entry.clone()).is_ok()) +} + +fn has_valid_stat_identity(value: &Value) -> bool { + value_field_deserializes::(value, "statType") +} + +fn has_valid_graph_identity(value: &Value) -> bool { + value_field_deserializes::(value, "statType") + && value_field_deserializes::(value, "graphType") +} + +fn has_valid_knob_identity(value: &Value) -> bool { + value.get("axisId").is_none_or(Value::is_string) +} + +fn recover_key_position_entries(field: &str, value: &Value) -> Option { + let Value::Object(modes) = value else { + return None; + }; + let default_position = match serde_json::to_value(KeyPosition::default()) { + Ok(value) => value, + Err(err) => { + log::warn!("[Store] Failed to serialize the default {field} entry: {err}"); + return None; + } + }; + + let mut recovered_modes = Map::new(); + for (mode, entries) in modes { + let Value::Array(entries) = entries else { + log::warn!( + "[Store] Removing invalid {field} mode '{mode}' during recovery: expected an array" + ); + continue; + }; + + let mut recovered_entries = Vec::with_capacity(entries.len()); + for (index, entry) in entries.iter().enumerate() { + match serde_json::from_value::(entry.clone()) { + Ok(_) => recovered_entries.push(entry.clone()), + Err(err) => { + let entry_name = format!("{field}.{mode}[{index}]"); + let mut candidate = entry.clone(); + recover_image_transform_children(&entry_name, &mut candidate); + recover_invalid_counter_fill_gradient_children(&entry_name, &mut candidate); + let recovered = if serde_json::from_value::(candidate.clone()) + .is_ok() + { + candidate + } else { + recover_key_position_fields(&entry_name, &candidate) + .filter(|candidate| { + serde_json::from_value::(candidate.clone()).is_ok() + }) + .unwrap_or_else(|| { + log::warn!( + "[Store] Replacing invalid {field} entry '{mode}[{index}]' with default during recovery: {err}" + ); + default_position.clone() + }) + }; + recovered_entries.push(recovered); + } + } + } + recovered_modes.insert(mode.clone(), Value::Array(recovered_entries)); + } + Some(Value::Object(recovered_modes)) +} + +fn recover_image_transform_children(entry_name: &str, value: &mut Value) -> bool { + let Some(position) = value.as_object_mut() else { + return false; + }; + let mut repaired = false; + + let invalid_mode = position + .get("imageMode") + .is_some_and(|mode| serde_json::from_value::>(mode.clone()).is_err()); + if invalid_mode { + log::warn!("[Store] Resetting invalid {entry_name}.imageMode to None during recovery"); + position.remove("imageMode"); + repaired = true; + } + + for field in ["idleImageTransform", "activeImageTransform"] { + let invalid_object = position + .get(field) + .is_some_and(|transform| !transform.is_null() && !transform.is_object()); + if invalid_object { + log::warn!("[Store] Resetting invalid {entry_name}.{field} to None during recovery"); + position.remove(field); + repaired = true; + continue; + } + let Some(transform) = position.get_mut(field).and_then(Value::as_object_mut) else { + continue; + }; + for (leaf, fallback, minimum, maximum) in [ + ( + "offsetX", + 0.0, + IMAGE_TRANSFORM_OFFSET_MIN, + IMAGE_TRANSFORM_OFFSET_MAX, + ), + ( + "offsetY", + 0.0, + IMAGE_TRANSFORM_OFFSET_MIN, + IMAGE_TRANSFORM_OFFSET_MAX, + ), + ( + "rotation", + 0.0, + IMAGE_TRANSFORM_ROTATION_MIN, + IMAGE_TRANSFORM_ROTATION_MAX, + ), + ( + "scale", + 1.0, + IMAGE_TRANSFORM_SCALE_MIN, + IMAGE_TRANSFORM_SCALE_MAX, + ), + ] { + let invalid = transform + .get(leaf) + .and_then(Value::as_f64) + .is_none_or(|value| !value.is_finite() || !(minimum..=maximum).contains(&value)); + if invalid { + log::warn!( + "[Store] Resetting invalid {entry_name}.{field}.{leaf} to identity during recovery" + ); + transform.insert(leaf.to_string(), serde_json::json!(fallback)); + repaired = true; + } + } + } + repaired +} + +fn recover_invalid_counter_fill_gradient_children(entry_name: &str, value: &mut Value) -> bool { + let Some(counter) = value.get_mut("counter").and_then(Value::as_object_mut) else { + return false; + }; + + let mut changed = false; + for field in ["fillIdleGradient", "fillActiveGradient"] { + let invalid = counter.get(field).is_some_and(|gradient| { + serde_json::from_value::>(gradient.clone()).is_err() + }); + if invalid { + log::warn!( + "[Store] Resetting invalid {entry_name}.counter.{field} to None during recovery" + ); + counter.remove(field); + changed = true; + } + } + changed +} + +fn recover_position_entries(field: &str, value: &Value) -> Option +where + T: DeserializeOwned, +{ + let Value::Object(modes) = value else { + return None; + }; + + let mut recovered_modes = Map::new(); + for (mode, entries) in modes { + let Value::Array(entries) = entries else { + log::warn!( + "[Store] Removing invalid {field} mode '{mode}' during recovery: expected an array" + ); + continue; + }; + + let mut recovered_entries = Vec::with_capacity(entries.len()); + for (index, entry) in entries.iter().enumerate() { + match serde_json::from_value::(entry.clone()) { + Ok(_) => recovered_entries.push(entry.clone()), + Err(err) => { + log::warn!( + "[Store] Removing invalid {field} entry '{mode}[{index}]' during recovery: {err}" + ); + } + } + } + recovered_modes.insert(mode.clone(), Value::Array(recovered_entries)); + } + Some(Value::Object(recovered_modes)) +} + +fn recover_font_settings(value: &Value) -> Option { + let Value::Object(settings) = value else { + return None; + }; + let mut recovered = settings.clone(); + let Some(custom_fonts) = settings.get("customFonts") else { + return Some(Value::Object(recovered)); + }; + let Value::Array(custom_fonts) = custom_fonts else { + log::warn!( + "[Store] Removing invalid fontSettings.customFonts collection during recovery: expected an array" + ); + recovered.insert("customFonts".to_string(), Value::Array(Vec::new())); + return Some(Value::Object(recovered)); + }; + + let mut recovered_fonts = Vec::with_capacity(custom_fonts.len()); + for (index, font) in custom_fonts.iter().enumerate() { + let mut candidate = font.clone(); + let invalid_weight_ranges = candidate.get("weightRanges").is_some_and(|ranges| { + serde_json::from_value::>(ranges.clone()).is_err() + }); + if invalid_weight_ranges { + if let Some(object) = candidate.as_object_mut() { + object.remove("weightRanges"); + } + log::warn!( + "[Store] Resetting invalid fontSettings.customFonts entry '[{index}].weightRanges' during recovery" + ); + } + + match serde_json::from_value::(candidate.clone()) { + Ok(_) => recovered_fonts.push(candidate), + Err(err) => { + if let Some(recovered) = recover_local_font_enabled(&candidate) { + log::warn!( + "[Store] Disabling fontSettings.customFonts entry '[{index}]' with an invalid enabled field during recovery" + ); + recovered_fonts.push(recovered); + } else { + log::warn!( + "[Store] Removing invalid fontSettings.customFonts entry '[{index}]' during recovery: {err}" + ); + } + } + } + } + recovered.insert("customFonts".to_string(), Value::Array(recovered_fonts)); + Some(Value::Object(recovered)) +} + +pub(super) fn recover_local_font_enabled(value: &Value) -> Option { + let Value::Object(source) = value else { + return None; + }; + if source.get("enabled").is_some_and(Value::is_boolean) { + return None; + } + let id = source.get("id").and_then(Value::as_str)?; + let name = source.get("name").and_then(Value::as_str)?; + if id.trim().is_empty() || name.trim().is_empty() { + return None; + } + let font_type = source + .get("type") + .and_then(|font_type| serde_json::from_value::(font_type.clone()).ok())?; + if font_type != FontType::Local { + return None; + } + let local_path = source.get("localPath").and_then(Value::as_str)?; + let trimmed_path = local_path.trim(); + // 외래 기기의 관리 폰트 경로도 유효한 복구 정체성 — 항목 삭제 대신 비활성 복구 + let portable_font_path = matches!( + parse_portable_asset_reference(trimmed_path), + Some((AssetCategory::Fonts, _)) + ); + if trimmed_path.is_empty() || (!Path::new(trimmed_path).is_absolute() && !portable_font_path) { + return None; + } + + let mut candidate = source.clone(); + candidate.insert("enabled".to_string(), Value::Bool(false)); + serde_json::from_value::(Value::Object(candidate.clone())).ok()?; + Some(Value::Object(candidate)) +} + +fn migrate_legacy_repair_fields(fields: &mut Map) { + promote_legacy_field(fields, "useCustomCss", "useCustomCSS"); + promote_legacy_field(fields, "customCss", "customCSS"); + promote_legacy_field(fields, "useCustomJs", "useCustomJS"); + promote_legacy_field(fields, "customJs", "customJS"); + + let legacy_bounds = fields.remove("overlayWindowBounds"); + let legacy_position = fields.remove("overlayWindowPosition"); + if fields.contains_key("overlayBounds") { + return; + } + if let Some(value) = legacy_bounds { + if serde_json::from_value::(value.clone()).is_ok() { + fields.insert("overlayBounds".to_string(), value); + return; + } + } + if let Some(value) = legacy_position { + if let Ok(position) = serde_json::from_value::(value) { + fields.insert( + "overlayBounds".to_string(), + serde_json::json!({ + "x": position.x, + "y": position.y, + "width": LEGACY_OVERLAY_WIDTH, + "height": LEGACY_OVERLAY_HEIGHT, + }), + ); + } + } +} + +fn promote_legacy_field(fields: &mut Map, current: &str, legacy: &str) { + if fields.contains_key(current) { + return; + } + if let Some(value) = fields.remove(legacy) { + fields.insert(current.to_string(), value); + } +} From 2c0857511ae4245bc2658c9b70e22761146fd78a Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 16:13:34 +0900 Subject: [PATCH 118/245] =?UTF-8?q?test:=20=EB=85=B8=EB=B8=8C=20=ED=91=9C?= =?UTF-8?q?=EB=A9=B4=20=ED=8E=98=EC=9D=B8=ED=8A=B8=20=EC=A0=84=ED=99=98=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../single/singleGeometryBindings.test.tsx | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/singleGeometryBindings.test.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/singleGeometryBindings.test.tsx index c8ef1c98c..9b7eafb90 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/singleGeometryBindings.test.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/singleGeometryBindings.test.tsx @@ -739,6 +739,80 @@ describe('single geometry input bindings', () => { }, ); + it('knob background paint는 idle/active preview와 final descriptor를 분리한다', () => { + const preview = vi.fn(); + const commit = vi.fn(); + const legacy = vi.fn(); + act(() => { + root.render( + ()} + renameValue="" + setRenameValue={vi.fn()} + renameCancelledRef={{ current: false }} + handleRenameCommit={vi.fn()} + handleRenameCancel={vi.fn()} + handleRenameStart={vi.fn()} + handleKnobUpdate={legacy} + onPaintPreview={preview} + onPaintCommit={commit} + singleScrollRefFor={() => vi.fn()} + panelElement={null} + useCustomCSS={false} + t={(key) => key} + />, + ); + }); + + act(() => captured.swatches[0]?.onClick()); + expect(captured.color?.stateMode).toBe('idle'); + act(() => captured.color?.onColorChange(' idle preview ')); + act(() => captured.color?.onColorChangeComplete(' idle final ')); + act(() => captured.color?.onStateModeChange?.('active')); + act(() => captured.color?.onColorChange(' active preview ')); + act(() => captured.color?.onColorChangeComplete(' active final ')); + + expect(preview.mock.calls).toEqual([ + [ + { + property: 'backgroundPaint', + value: { color: ' idle preview ', gradient: null }, + }, + ], + [ + { + property: 'activeBackgroundPaint', + value: { color: ' active preview ', gradient: null }, + }, + ], + ]); + expect(commit.mock.calls).toEqual([ + [ + { + property: 'backgroundPaint', + value: { color: ' idle final ', gradient: null }, + }, + ], + [ + { + property: 'activeBackgroundPaint', + value: { color: ' active final ', gradient: null }, + }, + ], + ]); + expect(legacy).not.toHaveBeenCalled(); + }); + it.each([ ['key', true], ['stat', false], From 2e6f78eb37731e273c81c216fec81818aedfd5ef Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 16:16:31 +0900 Subject: [PATCH 119/245] =?UTF-8?q?refactor:=20=ED=82=A4=EC=99=80=20?= =?UTF-8?q?=EB=85=B8=EB=B8=8C=20=ED=91=9C=EB=A9=B4=20=ED=8E=98=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=9C=EC=96=B4=20=EA=B3=B5=EC=9A=A9=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../single/SingleKnobPanel.tsx | 235 ++---------------- .../single/useSingleStyleColorController.ts | 24 +- 2 files changed, 48 insertions(+), 211 deletions(-) diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleKnobPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleKnobPanel.tsx index 964d133bc..d5bac8f97 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleKnobPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleKnobPanel.tsx @@ -7,24 +7,13 @@ import { isNativeElementId } from '@src/renderer/editor/model/elementId'; import type { EditStateAnchor } from '@stores/grid/useEditStatePreviewStore'; import type { ImageFit } from '@src/types/key/keys'; import type { KnobItemPosition } from '@src/types/key/knobs'; -import { - paintDescriptor, - gradientToCss, - type ColorModeValue, - type GradientSpec, -} from '@src/types/color'; -import { useGradientColorState } from '@hooks/pickers/useGradientColorState'; +import { gradientToCss } from '@src/types/color'; import { axisEventBus } from '@utils/core/axisEventBus'; import { - DEFAULT_ELEMENT_BG, - DEFAULT_ELEMENT_BORDER, DEFAULT_ELEMENT_BORDER_WIDTH, - DEFAULT_ELEMENT_ACTIVE_BORDER, - DEFAULT_ELEMENT_ACTIVE_BG, DEFAULT_ELEMENT_SHADOW_SPEC, DEFAULT_ELEMENT_ACTIVE_SHADOW_SPEC, } from '@utils/core/elementDefaults'; -import { resolveElementBorder } from '@utils/core/elementBorder'; import { elementShadowLeafFromPartial, resolveElementShadowForPosition, @@ -55,6 +44,7 @@ import type { import RenameIcon from './RenameIcon'; import SingleGeometrySection from './SingleGeometrySection'; import SingleImagePickerPopup from './SingleImagePickerPopup'; +import { useSingleStyleColorController } from './useSingleStyleColorController'; // ============================================================================ // Single Knob Selection Panel @@ -193,208 +183,37 @@ export const SingleKnobPanel: React.FC = ({ ? singleKnobPosition.axisId.replace(/^HIDA:/, '') : t('propertiesPanel.knobAxisUnset') || '미지정'; - // 대기/입력 색상 (키 패널과 동일한 기본값/전환 로직) - const DEFAULT_KNOB_BACKGROUND_COLOR = DEFAULT_ELEMENT_BG; - const DEFAULT_KNOB_BORDER_COLOR = DEFAULT_ELEMENT_BORDER; - const DEFAULT_KNOB_ACTIVE_BACKGROUND_COLOR = DEFAULT_ELEMENT_ACTIVE_BG; - const DEFAULT_KNOB_ACTIVE_BORDER_COLOR = DEFAULT_ELEMENT_ACTIVE_BORDER; - - type KnobColorTarget = 'backgroundColor' | 'borderColor'; - type KnobColorProperty = - | KnobColorTarget - | 'activeBackgroundColor' - | 'activeBorderColor'; - - const [pickerFor, setPickerFor] = useState(null); - const [colorState, setColorState] = useState<'idle' | 'active'>('idle'); - const bgColorBtnRef = useRef(null); - const borderColorBtnRef = useRef(null); - - const [localColors, setLocalColors] = useState< - Record - >({ - backgroundColor: - singleKnobPosition.backgroundColor || DEFAULT_KNOB_BACKGROUND_COLOR, - activeBackgroundColor: - singleKnobPosition.activeBackgroundColor || - singleKnobPosition.backgroundColor || - DEFAULT_KNOB_ACTIVE_BACKGROUND_COLOR, - borderColor: singleKnobPosition.borderColor || DEFAULT_KNOB_BORDER_COLOR, - activeBorderColor: - singleKnobPosition.activeBorderColor || - singleKnobPosition.borderColor || - DEFAULT_KNOB_ACTIVE_BORDER_COLOR, - }); - - // 피커는 대상 변경 시 닫는다. 축 캡처는 시작 ID를 별도로 동결하므로 - // 재정렬이나 모드 전환 뒤에도 시작 대상을 유지한다 - useEffect(() => { - setPickerFor(null); - setShowImagePicker(false); - }, [selectedKeyType, singleKnobPosition.id]); - - // 피커가 닫혀있을 때만 외부 prop과 동기화 - useEffect(() => { - if (!pickerFor) { - setLocalColors({ - backgroundColor: - singleKnobPosition.backgroundColor || DEFAULT_KNOB_BACKGROUND_COLOR, - activeBackgroundColor: - singleKnobPosition.activeBackgroundColor || - singleKnobPosition.backgroundColor || - DEFAULT_KNOB_ACTIVE_BACKGROUND_COLOR, - borderColor: - singleKnobPosition.borderColor || DEFAULT_KNOB_BORDER_COLOR, - activeBorderColor: - singleKnobPosition.activeBorderColor || - singleKnobPosition.borderColor || - DEFAULT_KNOB_ACTIVE_BORDER_COLOR, - }); - } - }, [ - pickerFor, - singleKnobPosition.backgroundColor, - singleKnobPosition.activeBackgroundColor, - singleKnobPosition.borderColor, - singleKnobPosition.activeBorderColor, - DEFAULT_KNOB_BACKGROUND_COLOR, - DEFAULT_KNOB_ACTIVE_BACKGROUND_COLOR, - DEFAULT_KNOB_BORDER_COLOR, - DEFAULT_KNOB_ACTIVE_BORDER_COLOR, - ]); - - const resolveColorProperty = (target: KnobColorTarget): KnobColorProperty => - colorState === 'active' - ? target === 'backgroundColor' - ? 'activeBackgroundColor' - : 'activeBorderColor' - : target; - - const activeColorPropertyFor = ( - target: KnobColorTarget, - ): 'activeBackgroundColor' | 'activeBorderColor' => - target === 'backgroundColor' - ? 'activeBackgroundColor' - : 'activeBorderColor'; - - const isNonEmptyString = (value: unknown): value is string => - typeof value === 'string' && value.trim().length > 0; - - const colorValueFor = (target: KnobColorTarget): string => - localColors[resolveColorProperty(target)]; - - const handleColorChange = (target: KnobColorTarget, color: string) => { - const prop = resolveColorProperty(target); - setLocalColors((prev) => ({ ...prev, [prop]: color })); - }; - - // ── 그라데이션 배선 (키 패널과 동일 패턴) — 단색 커밋도 이 경로로 통합 ── - - // 테두리 미지정이면 렌더와 같은 기본 립을 편집 대상으로 보여 준다 - const defaultKnobBorderGradientFor = ( - active: boolean, - ): GradientSpec | null => { - const resolved = resolveElementBorder(singleKnobPosition, active); - return resolved.isDefault ? resolved.gradient : null; - }; - - const storedGradientOf = (prop: KnobColorProperty): GradientSpec | null => { - switch (prop) { - case 'backgroundColor': - return singleKnobPosition.backgroundGradient ?? null; - case 'activeBackgroundColor': - return singleKnobPosition.activeBackgroundGradient ?? null; - case 'borderColor': - return ( - singleKnobPosition.borderGradient ?? - defaultKnobBorderGradientFor(false) - ); - case 'activeBorderColor': - return ( - singleKnobPosition.activeBorderGradient ?? - defaultKnobBorderGradientFor(true) - ); - default: - return null; - } - }; - - const gradientSpecFor = (target: KnobColorTarget): GradientSpec | null => { - const idleGradient = storedGradientOf(target); - if (colorState !== 'active') return idleGradient; - const activeProp = activeColorPropertyFor(target); - const activeGradient = storedGradientOf(activeProp); - const activeHasValue = - isNonEmptyString(singleKnobPosition[activeProp]) || - activeGradient != null; - return activeHasValue ? activeGradient : idleGradient; - }; - - const handleGradientCommit = (value: ColorModeValue) => { - if (!pickerFor) return; - const prop = resolveColorProperty(pickerFor); - const descriptor = paintDescriptor(value); - const paintField = - colorState === 'active' - ? pickerFor === 'backgroundColor' - ? 'activeBackgroundPaint' - : 'activeBorderPaint' - : pickerFor === 'backgroundColor' - ? 'backgroundPaint' - : 'borderPaint'; - setLocalColors((prev) => ({ ...prev, [prop]: descriptor.color })); - onPaintCommit?.({ property: paintField, value: descriptor } as never); - }; - - const knobGradientState = useGradientColorState({ - pair: pickerFor - ? { - color: colorValueFor(pickerFor), - gradient: gradientSpecFor(pickerFor), - } - : {}, - fallbackColor: '#ffffff', - contextKey: `knob:${selectedKeyType}:${singleKnobPosition.id}:${ - pickerFor ?? 'none' - }:${colorState}`, - canvasAnchor: pickerFor - ? singleKnobPosition.id && isNativeElementId(singleKnobPosition.id) - ? { kind: 'knob', id: singleKnobPosition.id } - : undefined - : undefined, - canvasSurface: pickerFor === 'borderColor' ? 'border' : 'background', - canvasState: colorState, - onPreview: (value) => { - if (!pickerFor) return; - const prop = resolveColorProperty(pickerFor); - const descriptor = paintDescriptor(value); - setLocalColors((prev) => ({ ...prev, [prop]: descriptor.color })); - const paintField = - colorState === 'active' - ? pickerFor === 'backgroundColor' - ? 'activeBackgroundPaint' - : 'activeBorderPaint' - : pickerFor === 'backgroundColor' - ? 'backgroundPaint' - : 'borderPaint'; - onPaintPreview?.({ property: paintField, value: descriptor } as never); - }, - onCancel: () => editGestureController.cancel(), - onCommit: handleGradientCommit, - }); - - const handlePickerToggle = (target: KnobColorTarget) => { - setPickerFor((prev) => (prev === target ? null : target)); - // 새로 열 때는 항상 대기 탭에서 시작 - if (pickerFor !== target) setColorState('idle'); - }; - // 그림자·이미지 피커의 캔버스 상태 프리뷰 대상 (색 피커는 세션 훅이 발행) const knobPreviewAnchor: EditStateAnchor | null = singleKnobPosition.id && isNativeElementId(singleKnobPosition.id) ? { kind: 'knob', id: singleKnobPosition.id } : null; + const { + pickerFor, + setPickerFor, + effectiveColorState: colorState, + setColorState, + backgroundColorButtonRef: bgColorBtnRef, + borderColorButtonRef: borderColorBtnRef, + handlePickerToggle, + colorValueFor, + handleColorChange, + gradientSpecFor, + gradientState: knobGradientState, + } = useSingleStyleColorController({ + keyPosition: singleKnobPosition, + shadowActiveState: true, + canvasAnchor: knobPreviewAnchor ?? undefined, + contextSelectedKeyType: selectedKeyType, + pickerResetScope: selectedKeyType, + pickerResetTarget: singleKnobPosition.id, + onPickerReset: setShowImagePicker, + suppressDefaultBorderForImage: false, + onPaintPreview, + onPaintCommit, + }); + // 라운딩 기본값: 미지정 시 원형(짧은 변의 절반) const effectiveBorderRadius = singleKnobPosition.borderRadius ?? diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/useSingleStyleColorController.ts b/src/renderer/components/main/Grid/PropertiesPanel/single/useSingleStyleColorController.ts index 2aaa00a2d..173d431fc 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/useSingleStyleColorController.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/useSingleStyleColorController.ts @@ -40,6 +40,11 @@ interface UseSingleStyleColorControllerOptions { keyPosition: KeyPosition; shadowActiveState: boolean; canvasAnchor?: GradientCanvasAnchor; + contextSelectedKeyType?: string; + pickerResetScope?: string; + pickerResetTarget?: string; + onPickerReset?: (open: false) => void; + suppressDefaultBorderForImage?: boolean; onPaintPreview?: (patch: EditorPaintPropertyPatchV1) => void; onPaintCommit?: (patch: EditorPaintPropertyPatchV1) => void; } @@ -68,20 +73,31 @@ export const useSingleStyleColorController = ({ keyPosition, shadowActiveState, canvasAnchor, + contextSelectedKeyType, + pickerResetScope, + pickerResetTarget, + onPickerReset, + suppressDefaultBorderForImage = true, onPaintPreview, onPaintCommit, }: UseSingleStyleColorControllerOptions) => { const [pickerFor, setPickerFor] = useState(null); const [colorState, setColorState] = useState('idle'); const effectiveColorState = shadowActiveState ? colorState : 'idle'; - const selectedKeyType = useKeyStore((state) => state.selectedKeyType); + const selectedKeyType = useKeyStore( + (state) => contextSelectedKeyType ?? state.selectedKeyType, + ); useEffect(() => { if (!shadowActiveState) { setColorState('idle'); setPickerFor(null); } - }, [shadowActiveState]); + if (pickerResetScope !== undefined || pickerResetTarget !== undefined) { + setPickerFor(null); + onPickerReset?.(false); + } + }, [onPickerReset, pickerResetScope, pickerResetTarget, shadowActiveState]); const backgroundColorButtonRef = useRef(null); const borderColorButtonRef = useRef(null); @@ -176,7 +192,9 @@ export const useSingleStyleColorController = ({ if (target === 'borderColor') { const active = effectiveColorState === 'active'; return resolveElementBorder(keyPosition, active, { - suppressDefault: elementImageReplacesSurface(keyPosition, active), + suppressDefault: + suppressDefaultBorderForImage && + elementImageReplacesSurface(keyPosition, active), }).gradient; } const idleGradient = storedGradientOf(target); From 50031925d1a07b12967d3e545845546befe50457 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 16:17:57 +0900 Subject: [PATCH 120/245] =?UTF-8?q?test:=20=EC=B9=B4=EC=9A=B4=ED=84=B0=20?= =?UTF-8?q?=EC=9E=AC=EB=8F=99=EA=B8=B0=ED=99=94=20=EC=A7=81=EB=A0=AC?= =?UTF-8?q?=ED=99=94=20=EA=B3=84=EC=95=BD=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...seAppBootstrap.counterDelayTimers.test.tsx | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/renderer/hooks/app/useAppBootstrap.counterDelayTimers.test.tsx b/src/renderer/hooks/app/useAppBootstrap.counterDelayTimers.test.tsx index ea9a40062..1e0fe5b6b 100644 --- a/src/renderer/hooks/app/useAppBootstrap.counterDelayTimers.test.tsx +++ b/src/renderer/hooks/app/useAppBootstrap.counterDelayTimers.test.tsx @@ -668,6 +668,54 @@ describe('카운터 지연 타이머', () => { expect(getKeyCounterSignal('4key', 'KeyK').value).toBe(1); }); + it('진행 중 연속 OBS 재동기화 요청을 한 번으로 합쳐 순서대로 적용한다', async () => { + let resolveFirst!: (bootstrap: BootstrapPayload) => void; + let resolveSecond!: (bootstrap: BootstrapPayload) => void; + const first = new Promise((resolve) => { + resolveFirst = resolve; + }); + const second = new Promise((resolve) => { + resolveSecond = resolve; + }); + mocks.bootstrap.mockReset(); + mocks.bootstrap.mockReturnValueOnce(first).mockReturnValueOnce(second); + + act(() => { + mocks.resyncListener?.(); + mocks.resyncListener?.(); + mocks.resyncListener?.(); + }); + + expect(mocks.bootstrap).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveFirst(makeBootstrap({ '4key': { KeyK: 1 } }, 30000, 10)); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mocks.bootstrap).toHaveBeenCalledTimes(2); + expect(getKeyCounterSignal('4key', 'KeyK').value).toBe(1); + + await act(async () => { + resolveSecond(makeBootstrap({ '4key': { KeyK: 2 } }, 30000, 11)); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mocks.bootstrap).toHaveBeenCalledTimes(2); + expect(getKeyCounterSignal('4key', 'KeyK').value).toBe(2); + }); + + it('같은 세션에서 최신 revision보다 오래된 delta를 무시한다', () => { + emitCounter(5, '4key', 'KeyK', 5); + emitCounter(4, '4key', 'KeyK', 4); + + expect(vi.getTimerCount()).toBe(1); + vi.advanceTimersByTime(30000); + expect(getKeyCounterSignal('4key', 'KeyK').value).toBe(5); + }); + it('unmount 시 모든 카운터 타이머를 취소한다', () => { const transitions: number[] = []; const unsubscribe = getKeyCounterSignal('4key', 'KeyK').subscribe( From 15e9e05687e0db055fd800295c7f5d15e5655387 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 16:18:01 +0900 Subject: [PATCH 121/245] =?UTF-8?q?refactor:=20=EC=95=B1=20=EC=B9=B4?= =?UTF-8?q?=EC=9A=B4=ED=84=B0=20=EC=9E=AC=EB=8F=99=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?=EB=9F=B0=ED=83=80=EC=9E=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/hooks/app/appCounterRuntime.ts | 395 +++++++++++++++++++ src/renderer/hooks/app/useAppBootstrap.ts | 401 ++------------------ 2 files changed, 420 insertions(+), 376 deletions(-) create mode 100644 src/renderer/hooks/app/appCounterRuntime.ts diff --git a/src/renderer/hooks/app/appCounterRuntime.ts b/src/renderer/hooks/app/appCounterRuntime.ts new file mode 100644 index 000000000..b9c6588c2 --- /dev/null +++ b/src/renderer/hooks/app/appCounterRuntime.ts @@ -0,0 +1,395 @@ +import { getUndoRedoInProgress } from '@api/pluginDisplayElements'; +import { + applyCounterCacheSnapshot, + setCachedKeyCounter, +} from '@stores/signals/keyCounterCache'; +import { + applyCounterSnapshot, + setKeyCounter, +} from '@stores/signals/keyCounterSignals'; +import { useKeyStore } from '@stores/data/useKeyStore'; +import { + useSettingsStore, + type SettingsStateSnapshot, +} from '@stores/useSettingsStore'; +import type { CanonicalBootstrapPayload } from '@src/types/app'; +import type { KeyCounters } from '@src/types/key/keys'; +import type { KeyCounterUpdate } from '@src/types/plugin/api'; +import { mergeNoteSettings } from '@src/types/settings/noteSettings'; + +type CounterDelayTimerHandle = ReturnType; + +interface DelayedCounterUpdate extends KeyCounterUpdate { + apply: () => void; +} + +interface CounterStateSnapshot { + sessionId: string; + revision: number; + counters: KeyCounters; +} + +export interface CounterResyncContext { + latestUpdates: Map; + latestSnapshot: { sessionId: string; revision: number } | null; +} + +interface RunCounterResyncOptions { + loadBootstrap: () => Promise; + applySnapshot: ( + bootstrap: CanonicalBootstrapPayload, + context: CounterResyncContext, + ) => void; + syncEditor: () => Promise; +} + +export 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 createCounterResyncContext = (): CounterResyncContext => ({ + latestUpdates: new Map(), + latestSnapshot: null, +}); + +export const createAppCounterRuntime = (isOverlayWindow: boolean) => { + const counterDelayTimers = new Map< + string, + Map + >(); + const pendingCounterDelayTimers = new Map< + CounterDelayTimerHandle, + DelayedCounterUpdate + >(); + let disposed = false; + let initialApplied = false; + let resyncInFlight = false; + let resyncQueued = false; + let latestCounterSessionId: string | null = null; + let latestCounterRevision = 0; + let counterResyncContext: CounterResyncContext | null = null; + + const composeCounterKey = (mode?: string, key?: string) => + `${mode || '__unknown_mode__'}::${key || '__unknown_key__'}`; + + const getLatestPendingCounterUpdate = (composedKey: string) => { + const timers = counterDelayTimers.get(composedKey); + if (!timers) return null; + + let latest: DelayedCounterUpdate | null = null; + timers.forEach((update) => { + if (latest === null || update.revision > latest.revision) { + latest = update; + } + }); + return latest; + }; + + const clearCounterDelayTimers = (composedKey?: string) => { + if (composedKey) { + const timers = counterDelayTimers.get(composedKey); + if (timers) { + timers.forEach((_update, timer) => { + clearTimeout(timer); + pendingCounterDelayTimers.delete(timer); + }); + counterDelayTimers.delete(composedKey); + } + return; + } + + pendingCounterDelayTimers.forEach((_update, timer) => clearTimeout(timer)); + pendingCounterDelayTimers.clear(); + counterDelayTimers.forEach((timers) => timers.clear()); + counterDelayTimers.clear(); + }; + + const discardCounterDelayTimers = ( + shouldDiscard: (update: DelayedCounterUpdate) => boolean, + ) => { + counterDelayTimers.forEach((timers, composedKey) => { + timers.forEach((update, timer) => { + if (!shouldDiscard(update)) return; + clearTimeout(timer); + timers.delete(timer); + pendingCounterDelayTimers.delete(timer); + }); + if (timers.size === 0) { + counterDelayTimers.delete(composedKey); + } + }); + }; + + const flushCounterDelayTimers = () => { + const pending = [...pendingCounterDelayTimers.entries()]; + pendingCounterDelayTimers.clear(); + counterDelayTimers.forEach((timers) => timers.clear()); + counterDelayTimers.clear(); + + pending.forEach(([timer, update]) => { + clearTimeout(timer); + update.apply(); + }); + }; + + const getCounterDelayMs = () => { + const { noteSettings, tabNoteOverrides } = useSettingsStore.getState(); + const { selectedKeyType } = useKeyStore.getState(); + return resolveCounterDelayMs( + noteSettings, + tabNoteOverrides, + selectedKeyType, + ); + }; + + const scheduleCounterUpdate = (event: KeyCounterUpdate) => { + const delayMs = getCounterDelayMs(); + const composedKey = composeCounterKey(event.mode, event.key); + + if (delayMs <= 0) { + clearCounterDelayTimers(composedKey); + setKeyCounter(event.mode, event.key, event.count); + return; + } + + const apply = () => { + if (disposed) return; + setKeyCounter(event.mode, event.key, event.count); + }; + const update: DelayedCounterUpdate = { + apply, + mode: event.mode, + key: event.key, + count: event.count, + sessionId: event.sessionId, + revision: event.revision, + }; + const timer = setTimeout(() => { + const pendingUpdate = pendingCounterDelayTimers.get(timer); + if (!pendingUpdate) return; + + pendingCounterDelayTimers.delete(timer); + const timers = counterDelayTimers.get(composedKey); + timers?.delete(timer); + if (timers?.size === 0) { + counterDelayTimers.delete(composedKey); + } + pendingUpdate.apply(); + }, delayMs); + + const existing = counterDelayTimers.get(composedKey); + if (existing) { + existing.set(timer, update); + } else { + counterDelayTimers.set(composedKey, new Map([[timer, update]])); + } + pendingCounterDelayTimers.set(timer, update); + }; + + const adoptCounterSession = (sessionId: string) => { + if (latestCounterSessionId === sessionId) return; + + latestCounterSessionId = sessionId; + latestCounterRevision = 0; + clearCounterDelayTimers(); + if (counterResyncContext) { + counterResyncContext.latestUpdates.clear(); + counterResyncContext.latestSnapshot = null; + } + }; + + const reconcileResyncCounters = ( + counters: KeyCounters, + sessionId: string, + revision: number, + context: CounterResyncContext, + ) => { + const reconciled = Object.fromEntries( + Object.entries(counters).map(([mode, entries]) => [mode, { ...entries }]), + ) as KeyCounters; + + context.latestUpdates.forEach( + ({ + mode, + key, + count, + sessionId: eventSessionId, + revision: eventRevision, + }) => { + if (eventSessionId !== sessionId || eventRevision <= revision) return; + const modeCounters = (reconciled[mode] ??= {}); + modeCounters[key] = count; + }, + ); + return reconciled; + }; + + const applyResyncCounters = ( + counters: KeyCounters, + sessionId: string, + revision: number, + context: CounterResyncContext, + ) => { + adoptCounterSession(sessionId); + // bootstrap 캡처 뒤 수신한 최신 전체 스냅샷 우선권 + if ( + context.latestSnapshot?.sessionId === sessionId && + context.latestSnapshot.revision > revision + ) { + return; + } + + const reconciled = reconcileResyncCounters( + counters, + sessionId, + revision, + context, + ); + discardCounterDelayTimers( + (pending) => + pending.sessionId !== sessionId || pending.revision <= revision, + ); + + applyCounterCacheSnapshot(reconciled); + if (isOverlayWindow) { + applyCounterSnapshot(reconciled, (composed) => { + const pending = getLatestPendingCounterUpdate(composed); + if (pending?.sessionId === sessionId && pending.revision > revision) { + return true; + } + const update = context.latestUpdates.get(composed); + return Boolean( + update?.sessionId === sessionId && update.revision > revision, + ); + }); + } + latestCounterRevision = Math.max(latestCounterRevision, revision); + }; + + const handleCounterState = (event: CounterStateSnapshot) => { + adoptCounterSession(event.sessionId); + if (event.revision <= latestCounterRevision) return; + latestCounterRevision = event.revision; + + const context = counterResyncContext; + if (context) { + if ( + context.latestSnapshot?.sessionId !== event.sessionId || + event.revision > context.latestSnapshot.revision + ) { + context.latestSnapshot = { + sessionId: event.sessionId, + revision: event.revision, + }; + } + context.latestUpdates.forEach((update, composed) => { + if ( + update.sessionId !== event.sessionId || + update.revision <= event.revision + ) { + context.latestUpdates.delete(composed); + } + }); + } + clearCounterDelayTimers(); + applyCounterCacheSnapshot(event.counters); + if (getUndoRedoInProgress()) return; + if (isOverlayWindow) { + applyCounterSnapshot(event.counters); + } + }; + + const handleCounterChanged = (event: KeyCounterUpdate) => { + adoptCounterSession(event.sessionId); + if (event.revision <= latestCounterRevision) return; + latestCounterRevision = event.revision; + + setCachedKeyCounter(event.mode, event.key, event.count); + const composed = composeCounterKey(event.mode, event.key); + const previous = counterResyncContext?.latestUpdates.get(composed); + if (!previous || event.revision > previous.revision) { + counterResyncContext?.latestUpdates.set(composed, event); + } + if (isOverlayWindow) { + scheduleCounterUpdate(event); + } + }; + + const beginCounterResync = () => { + const context = createCounterResyncContext(); + counterResyncContext = context; + return context; + }; + + const releaseCounterResync = (context: CounterResyncContext) => { + if (counterResyncContext === context) { + counterResyncContext = null; + } + }; + + // 초기 적용 및 진행 중 요청의 단일 후속 실행 + const runResync = async ({ + loadBootstrap, + applySnapshot, + syncEditor, + }: RunCounterResyncOptions) => { + if (disposed) return; + if (!initialApplied || resyncInFlight) { + resyncQueued = true; + return; + } + resyncInFlight = true; + do { + resyncQueued = false; + const counterContext = beginCounterResync(); + try { + const bootstrap = await loadBootstrap(); + if (disposed) return; + applySnapshot(bootstrap, counterContext); + releaseCounterResync(counterContext); + await syncEditor(); + } catch (error) { + console.error('OBS 재동기화 실패', error); + } finally { + releaseCounterResync(counterContext); + } + } while (resyncQueued && !disposed); + resyncInFlight = false; + }; + + const markInitialApplied = () => { + initialApplied = true; + return resyncQueued; + }; + + const markDisposed = () => { + disposed = true; + }; + + const dispose = () => { + markDisposed(); + clearCounterDelayTimers(); + }; + + return { + applyResyncCounters, + beginCounterResync, + dispose, + flushCounterDelayTimers, + handleCounterChanged, + handleCounterState, + markInitialApplied, + markDisposed, + releaseCounterResync, + runResync, + }; +}; diff --git a/src/renderer/hooks/app/useAppBootstrap.ts b/src/renderer/hooks/app/useAppBootstrap.ts index 005ee7516..ac2d6b756 100644 --- a/src/renderer/hooks/app/useAppBootstrap.ts +++ b/src/renderer/hooks/app/useAppBootstrap.ts @@ -10,15 +10,6 @@ import { useSettingsStore, type SettingsStateSnapshot, } from '@stores/useSettingsStore'; -import { - applyCounterSnapshot, - setKeyCounter, -} from '@stores/signals/keyCounterSignals'; -import { - applyCounterCacheSnapshot, - setCachedKeyCounter, -} from '@stores/signals/keyCounterCache'; -import { getUndoRedoInProgress } from '@api/pluginDisplayElements'; import { obsApi } from '@api/modules/obsApi'; import { overlayApi } from '@api/modules/overlayApi'; import { notifyLocaleChanged, subscribe } from '@api/modules/shared'; @@ -51,10 +42,7 @@ import { import type { CanonicalBootstrapPayload } from '@src/types/app'; import type { CustomTab, KeyCounters } from '@src/types/key/keys'; import type { EditorCoordinatorState } from '@src/renderer/editor/runtime/editorCoordinator'; -import { - mergeNoteSettings, - type TabNoteOverrides, -} from '@src/types/settings/noteSettings'; +import type { TabNoteOverrides } from '@src/types/settings/noteSettings'; import type { SettingsDiff, OverlayResizeAnchor, @@ -70,6 +58,11 @@ import { } from '@utils/grid/cursorUtils'; import { isEditorCapacityFailure } from '@src/types/editor'; import type { CustomJs, JsPlugin } from '@src/types/plugin/js'; +import { + createAppCounterRuntime, + resolveCounterDelayMs, + type CounterResyncContext, +} from './appCounterRuntime'; function clonePlugins(source?: CustomJs | null): JsPlugin[] { if (!source) return []; @@ -180,89 +173,6 @@ export function useAppBootstrap() { let conflictDialogOpen = false; let lastShownPermanentEditorError: unknown = null; - // 키 표시 딜레이와 동기화를 위한 카운터 업데이트 지연 - type CounterDelayTimerHandle = ReturnType; - interface DelayedCounterUpdate { - apply: () => void; - mode: string; - key: string; - count: number; - sessionId: string; - revision: number; - } - const counterDelayTimers = new Map< - string, - Map - >(); - const pendingCounterDelayTimers = new Map< - CounterDelayTimerHandle, - DelayedCounterUpdate - >(); - - const composeCounterKey = (mode?: string, key?: string) => - `${mode || '__unknown_mode__'}::${key || '__unknown_key__'}`; - - const getLatestPendingCounterUpdate = (composedKey: string) => { - const timers = counterDelayTimers.get(composedKey); - if (!timers) return null; - - let latest: DelayedCounterUpdate | null = null; - timers.forEach((update) => { - if (latest === null || update.revision > latest.revision) { - latest = update; - } - }); - return latest; - }; - - const clearCounterDelayTimers = (composedKey?: string) => { - if (composedKey) { - const timers = counterDelayTimers.get(composedKey); - if (timers) { - timers.forEach((_update, timer) => { - clearTimeout(timer); - pendingCounterDelayTimers.delete(timer); - }); - counterDelayTimers.delete(composedKey); - } - return; - } - - pendingCounterDelayTimers.forEach((_update, timer) => - clearTimeout(timer), - ); - pendingCounterDelayTimers.clear(); - counterDelayTimers.forEach((timers) => timers.clear()); - counterDelayTimers.clear(); - }; - - const discardCounterDelayTimers = ( - shouldDiscard: (update: DelayedCounterUpdate) => boolean, - ) => { - counterDelayTimers.forEach((timers, composedKey) => { - timers.forEach((update, timer) => { - if (!shouldDiscard(update)) return; - clearTimeout(timer); - timers.delete(timer); - pendingCounterDelayTimers.delete(timer); - }); - if (timers.size === 0) { - counterDelayTimers.delete(composedKey); - } - }); - }; - - const flushCounterDelayTimers = () => { - const pending = [...pendingCounterDelayTimers.entries()]; - pendingCounterDelayTimers.clear(); - counterDelayTimers.forEach((timers) => timers.clear()); - counterDelayTimers.clear(); - - pending.forEach(([timer, update]) => { - clearTimeout(timer); - update.apply(); - }); - }; const scheduleEditorCoordinatorRecovery = () => { if (disposed || editorCoordinatorRetryTimer !== null) return; @@ -278,79 +188,6 @@ export function useAppBootstrap() { }, 1_000); }; - 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, - count: number, - sessionId: string, - revision: number, - ) => { - const delayMs = getCounterDelayMs(); - const composedKey = composeCounterKey(mode, key); - - if (delayMs <= 0) { - clearCounterDelayTimers(composedKey); - setKeyCounter(mode, key, count); - return; - } - - const apply = () => { - if (disposed) return; - setKeyCounter(mode, key, count); - }; - const update: DelayedCounterUpdate = { - apply, - mode, - key, - count, - sessionId, - revision, - }; - const timer = setTimeout(() => { - const pendingUpdate = pendingCounterDelayTimers.get(timer); - if (!pendingUpdate) return; - - pendingCounterDelayTimers.delete(timer); - const timers = counterDelayTimers.get(composedKey); - timers?.delete(timer); - if (timers?.size === 0) { - counterDelayTimers.delete(composedKey); - } - pendingUpdate.apply(); - }, delayMs); - - const existing = counterDelayTimers.get(composedKey); - if (existing) { - existing.set(timer, update); - } else { - counterDelayTimers.set(composedKey, new Map([[timer, update]])); - } - pendingCounterDelayTimers.set(timer, update); - }; - const { setAll, merge } = useSettingsStore.getState(); const getEditorCopy = (key: string, korean: string, english: string) => { @@ -513,109 +350,7 @@ export function useAppBootstrap() { // ── OBS WS 재연결/lag 복구 시 전체 상태 재동기화 ── // obs:resync는 ipcShim 로컬 합성 이벤트 — 네이티브 윈도우에서는 발화하지 않음 - let initialApplied = false; - let resyncInFlight = false; - let resyncQueued = false; - let latestCounterSessionId: string | null = null; - let latestCounterRevision = 0; - interface CounterResyncContext { - latestUpdates: Map< - string, - { - mode: string; - key: string; - count: number; - sessionId: string; - revision: number; - } - >; - latestSnapshot: { sessionId: string; revision: number } | null; - } - let counterResyncContext: CounterResyncContext | null = null; - - const adoptCounterSession = (sessionId: string) => { - if (latestCounterSessionId === sessionId) return; - - latestCounterSessionId = sessionId; - latestCounterRevision = 0; - clearCounterDelayTimers(); - if (counterResyncContext) { - counterResyncContext.latestUpdates.clear(); - counterResyncContext.latestSnapshot = null; - } - }; - - const reconcileResyncCounters = ( - counters: KeyCounters, - sessionId: string, - revision: number, - context: CounterResyncContext, - ) => { - const reconciled = Object.fromEntries( - Object.entries(counters).map(([mode, entries]) => [ - mode, - { ...entries }, - ]), - ) as KeyCounters; - - context.latestUpdates.forEach( - ({ - mode, - key, - count, - sessionId: eventSessionId, - revision: eventRevision, - }) => { - if (eventSessionId !== sessionId || eventRevision <= revision) return; - const modeCounters = (reconciled[mode] ??= {}); - modeCounters[key] = count; - }, - ); - return reconciled; - }; - - const applyResyncCounters = ( - counters: KeyCounters, - sessionId: string, - revision: number, - context: CounterResyncContext, - ) => { - adoptCounterSession(sessionId); - // bootstrap 캡처 뒤 도착한 전체 스냅샷(reset/undo 등)이 이미 더 최신이면 - // 카운터 슬라이스만 되돌리지 않음 - if ( - context.latestSnapshot?.sessionId === sessionId && - context.latestSnapshot.revision > revision - ) { - return; - } - - const reconciled = reconcileResyncCounters( - counters, - sessionId, - revision, - context, - ); - discardCounterDelayTimers( - (pending) => - pending.sessionId !== sessionId || pending.revision <= revision, - ); - - applyCounterCacheSnapshot(reconciled); - if (isOverlayWindow) { - applyCounterSnapshot(reconciled, (composed) => { - const pending = getLatestPendingCounterUpdate(composed); - if (pending?.sessionId === sessionId && pending.revision > revision) { - return true; - } - const update = context.latestUpdates.get(composed); - return Boolean( - update?.sessionId === sessionId && update.revision > revision, - ); - }); - } - latestCounterRevision = Math.max(latestCounterRevision, revision); - }; + const counterRuntime = createAppCounterRuntime(isOverlayWindow); // EditorDocument 밖의 슬라이스를 "변경 시에만" 적용 — 동일 데이터 재적용으로 인한 참조 // 변경이 overlay 키 이벤트 effect 재실행(키 하이라이트 리셋) 등 시각적 @@ -628,7 +363,7 @@ export function useAppBootstrap() { // 설정/모드 적용은 구독자를 통해 대기 카운터를 flush할 수 있으므로, // 카운터의 인과 순서를 먼저 확정한 뒤 나머지 스냅샷을 적용 - applyResyncCounters( + counterRuntime.applyResyncCounters( bootstrap.keyCounters, bootstrap.keyCountersSessionId, bootstrap.keyCountersRevision, @@ -683,59 +418,26 @@ export function useAppBootstrap() { finalizeBootstrap(); }; - // 연속 발화 코얼레서: 초기 적용 전/진행 중 요청은 큐잉 후 1회 재실행 - const runResync = async () => { - if (disposed) return; - if (!initialApplied || resyncInFlight) { - resyncQueued = true; - return; - } - resyncInFlight = true; - do { - resyncQueued = false; - const counterContext: CounterResyncContext = { - latestUpdates: new Map(), - latestSnapshot: null, - }; - counterResyncContext = counterContext; - try { - const bootstrap = await window.api.app.bootstrap(); - if (disposed) return; - applyResyncSnapshot(bootstrap, counterContext); - if (counterResyncContext === counterContext) { - counterResyncContext = null; - } - await editorCoordinator.sync(); - } catch (error) { - console.error('OBS 재동기화 실패', error); - } finally { - if (counterResyncContext === counterContext) { - counterResyncContext = null; - } - } - } while (resyncQueued && !disposed); - resyncInFlight = false; - }; + const runResync = () => + counterRuntime.runResync({ + loadBootstrap: () => window.api.app.bootstrap(), + applySnapshot: applyResyncSnapshot, + syncEditor: () => editorCoordinator.sync(), + }); - const initialCounterContext: CounterResyncContext = { - latestUpdates: new Map(), - latestSnapshot: null, - }; - counterResyncContext = initialCounterContext; + const initialCounterContext = counterRuntime.beginCounterResync(); (async () => { try { const bootstrap = await window.api.app.bootstrap(); if (disposed) return; initDefaults(bootstrap.defaults); - applyResyncCounters( + counterRuntime.applyResyncCounters( bootstrap.keyCounters, bootstrap.keyCountersSessionId, bootstrap.keyCountersRevision, initialCounterContext, ); - if (counterResyncContext === initialCounterContext) { - counterResyncContext = null; - } + counterRuntime.releaseCounterResync(initialCounterContext); setAll(buildSettingsSnapshot(bootstrap, {})); useFontStore.setState({ customFonts: bootstrap.settings.fontSettings.customFonts.map( @@ -813,13 +515,10 @@ export function useAppBootstrap() { } catch (error) { console.error('초기 부트스트랩 실패', error); } finally { - if (counterResyncContext === initialCounterContext) { - counterResyncContext = null; - } + counterRuntime.releaseCounterResync(initialCounterContext); // 초기 성공/실패와 무관하게 재동기화 게이트를 연다 — 초기 실패 // 시에도 다음 obs:resync가 전체 상태를 복구할 수 있어야 함 - initialApplied = true; - if (resyncQueued) { + if (counterRuntime.markInitialApplied()) { void runResync(); } } @@ -899,59 +598,8 @@ export function useAppBootstrap() { sessionId: string; revision: number; counters: KeyCounters; - }>('keys:counters-state', (event) => { - adoptCounterSession(event.sessionId); - if (event.revision <= latestCounterRevision) return; - latestCounterRevision = event.revision; - - const context = counterResyncContext; - if (context) { - if ( - context.latestSnapshot?.sessionId !== event.sessionId || - event.revision > context.latestSnapshot.revision - ) { - context.latestSnapshot = { - sessionId: event.sessionId, - revision: event.revision, - }; - } - context.latestUpdates.forEach((update, composed) => { - if ( - update.sessionId !== event.sessionId || - update.revision <= event.revision - ) { - context.latestUpdates.delete(composed); - } - }); - } - clearCounterDelayTimers(); - applyCounterCacheSnapshot(event.counters); - if (getUndoRedoInProgress()) return; - if (isOverlayWindow) { - applyCounterSnapshot(event.counters); - } - }), - window.api.keys.onCounterChanged((event) => { - adoptCounterSession(event.sessionId); - if (event.revision <= latestCounterRevision) return; - latestCounterRevision = event.revision; - - setCachedKeyCounter(event.mode, event.key, event.count); - const composed = composeCounterKey(event.mode, event.key); - const previous = counterResyncContext?.latestUpdates.get(composed); - if (!previous || event.revision > previous.revision) { - counterResyncContext?.latestUpdates.set(composed, event); - } - if (isOverlayWindow) { - scheduleCounterUpdate( - event.mode, - event.key, - event.count, - event.sessionId, - event.revision, - ); - } - }), + }>('keys:counters-state', counterRuntime.handleCounterState), + window.api.keys.onCounterChanged(counterRuntime.handleCounterChanged), window.api.keys.customTabs.onChanged( ({ customTabs, selectedKeyType }) => { adoptSelectedKeyType(customTabs, selectedKeyType); @@ -1025,12 +673,12 @@ export function useAppBootstrap() { selectedKeyType, ); if (nextDelay !== prevDelay) { - flushCounterDelayTimers(); + counterRuntime.flushCounterDelayTimers(); } }), useKeyStore.subscribe((state, previousState) => { if (state.selectedKeyType !== previousState.selectedKeyType) { - flushCounterDelayTimers(); + counterRuntime.flushCounterDelayTimers(); } }), ]; @@ -1047,6 +695,7 @@ export function useAppBootstrap() { return () => { disposed = true; + counterRuntime.markDisposed(); if (editorCoordinatorRetryTimer !== null) { clearTimeout(editorCoordinatorRetryTimer); editorCoordinatorRetryTimer = null; @@ -1061,7 +710,7 @@ export function useAppBootstrap() { console.error('구독 해제 실패', error); } }); - clearCounterDelayTimers(); + counterRuntime.dispose(); }; }, []); } From 129e3899564ebc555fd672cc27089920f7a553d5 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 16:20:14 +0900 Subject: [PATCH 122/245] =?UTF-8?q?test:=20=EC=88=AB=EC=9E=90=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20=EC=A0=95=EC=B1=85=20=EA=B3=B5=ED=86=B5=20=EA=B3=84?= =?UTF-8?q?=EC=95=BD=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/common/NumberInput.policy.test.tsx | 327 ++++++++++++++++++ 1 file changed, 327 insertions(+) create mode 100644 src/renderer/components/main/common/NumberInput.policy.test.tsx diff --git a/src/renderer/components/main/common/NumberInput.policy.test.tsx b/src/renderer/components/main/common/NumberInput.policy.test.tsx new file mode 100644 index 000000000..b183ccb96 --- /dev/null +++ b/src/renderer/components/main/common/NumberInput.policy.test.tsx @@ -0,0 +1,327 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { describe, expect, it } from 'vitest'; +import { NumberInput, OptionalNumberInput } from './NumberInput'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const setInputValue = (input: HTMLInputElement, value: string) => { + const setter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )?.set; + setter?.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); +}; + +const pressKey = ( + input: HTMLInputElement, + key: string, + init: KeyboardEventInit = {}, + staleMs?: number, +) => { + const event = new KeyboardEvent('keydown', { + key, + bubbles: true, + cancelable: true, + ...init, + }); + if (staleMs !== undefined) { + Object.defineProperty(event, 'timeStamp', { + value: event.timeStamp - staleMs, + }); + } + input.dispatchEvent(event); + return event; +}; + +const releaseKey = (input: HTMLInputElement, key: string) => { + input.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true })); +}; + +interface CallbackLog { + onChange: (value?: number) => void; + onPreview: (value?: number) => void; + onBlur: (value?: number) => void; + onCancel: () => void; +} + +const createCallbackLog = () => { + const entries: string[] = []; + const formatValue = (value?: number) => value ?? 'unset'; + const callbacks: CallbackLog = { + onChange: (value) => entries.push(`change:${formatValue(value)}`), + onPreview: (value) => entries.push(`preview:${formatValue(value)}`), + onBlur: (value) => entries.push(`blur:${formatValue(value)}`), + onCancel: () => entries.push('cancel'), + }; + return { entries, callbacks }; +}; + +const withRenderedInput = ( + renderInput: (callbacks: CallbackLog) => React.ReactNode, + run: (input: HTMLInputElement, entries: string[]) => void, +) => { + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + const { entries, callbacks } = createCallbackLog(); + act(() => root.render(renderInput(callbacks))); + const input = container.querySelector('input')!; + try { + run(input, entries); + } finally { + act(() => root.unmount()); + container.remove(); + } +}; + +type InputKind = 'required' | 'optional'; + +const renderCommonInput = (kind: InputKind, callbacks: CallbackLog) => + kind === 'required' ? ( + + ) : ( + + ); + +describe('숫자 편집 세션 공통 정책', () => { + it.each([ + { + name: '숫자 preview 뒤 blur 확정', + action: (input: HTMLInputElement) => { + setInputValue(input, '12'); + input.blur(); + }, + // 부모 prop을 갱신하지 않는 harness에서는 blur effect가 기존 값을 다시 표시 + value: '10', + log: ['preview:12', 'change:12', 'blur:12'], + }, + { + name: '수식 Enter 확정', + action: (input: HTMLInputElement) => { + setInputValue(input, '2+3'); + pressKey(input, 'Enter'); + }, + value: '10', + log: ['change:5', 'blur:5'], + }, + { + name: 'Escape preview 취소', + action: (input: HTMLInputElement) => { + setInputValue(input, '12'); + pressKey(input, 'Escape'); + }, + value: '10', + log: ['preview:12', 'cancel'], + }, + { + name: '방향키 preview 뒤 blur 확정', + action: (input: HTMLInputElement) => { + pressKey(input, 'ArrowUp'); + input.blur(); + }, + value: '10', + log: ['preview:11', 'change:11', 'blur:11'], + }, + { + name: 'held key 최종값 flush 뒤 blur 확정', + action: (input: HTMLInputElement) => { + pressKey(input, 'ArrowUp', { repeat: true }); + pressKey(input, 'ArrowUp', { repeat: true }); + releaseKey(input, 'ArrowUp'); + input.blur(); + }, + value: '10', + log: ['preview:12', 'change:12', 'blur:12'], + }, + { + name: '묵은 repeat 폐기', + action: (input: HTMLInputElement) => { + pressKey(input, 'ArrowUp', { repeat: true }, 400); + }, + value: '10', + log: [], + }, + ])( + '$name의 순서와 표시값이 양 컴포넌트에서 같다', + ({ action, value, log }) => { + const outcomes: Array<{ value: string; log: string[] }> = []; + + for (const kind of ['required', 'optional'] as const) { + withRenderedInput( + (callbacks) => renderCommonInput(kind, callbacks), + (input, entries) => { + act(() => input.focus()); + act(() => action(input)); + outcomes.push({ value: input.value, log: [...entries] }); + }, + ); + } + + expect(outcomes).toEqual([ + { value, log }, + { value, log }, + ]); + }, + ); +}); + +describe('숫자 편집 세션 차이 정책', () => { + it.each([ + { + name: 'required 빈 draft는 취소', + render: (callbacks: CallbackLog) => ( + + ), + action: (input: HTMLInputElement) => { + setInputValue(input, ''); + input.blur(); + }, + value: '10', + log: ['cancel'], + }, + { + name: 'optional 빈 draft는 unset', + render: (callbacks: CallbackLog) => ( + + ), + action: (input: HTMLInputElement) => { + setInputValue(input, ''); + input.blur(); + }, + value: '10', + log: ['preview:unset', 'change:unset', 'blur:unset'], + }, + { + name: 'required는 음수 min 허용', + render: (callbacks: CallbackLog) => ( + + ), + action: (input: HTMLInputElement) => { + setInputValue(input, '-2'); + input.blur(); + }, + value: '0', + log: ['preview:-2', 'change:-2', 'blur:-2'], + }, + { + name: 'optional allowNegative=false는 domainMin 0', + render: (callbacks: CallbackLog) => ( + + ), + action: (input: HTMLInputElement) => { + setInputValue(input, '-2'); + input.blur(); + }, + value: '0', + log: ['preview:0', 'change:0', 'blur:0'], + }, + { + name: 'optional placeholder는 unset step 기준', + render: (callbacks: CallbackLog) => ( + + ), + action: (input: HTMLInputElement) => { + pressKey(input, 'ArrowUp'); + input.blur(); + }, + value: '', + log: ['preview:17', 'change:17', 'blur:17'], + }, + { + name: 'required string value와 custom step 유지', + render: (callbacks: CallbackLog) => ( + + ), + action: (input: HTMLInputElement) => { + pressKey(input, 'ArrowUp'); + input.blur(); + }, + value: '1.5', + log: ['preview:2', 'change:2', 'blur:2'], + }, + ])('$name', ({ render, action, value, log }) => { + withRenderedInput(render, (input, entries) => { + act(() => input.focus()); + act(() => action(input)); + expect(input.value).toBe(value); + expect(entries).toEqual(log); + }); + }); + + it('required 전용 disabled와 ariaLabel 계약을 유지한다', () => { + withRenderedInput( + (callbacks) => ( + + ), + (input, entries) => { + expect(input.disabled).toBe(true); + expect(input.getAttribute('aria-label')).toBe('폭'); + expect(entries).toEqual([]); + }, + ); + }); +}); From 47da3c4021c68554efb191103a14dc6a6d5f7b6a Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 16:20:21 +0900 Subject: [PATCH 123/245] =?UTF-8?q?refactor:=20=EC=88=AB=EC=9E=90=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=ED=8E=B8=EC=A7=91=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EA=B3=B5=EC=9A=A9=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/main/common/NumberInput.tsx | 1216 ++--------------- .../main/common/numericEditSessionModel.ts | 103 ++ .../main/common/useNumericEditSession.ts | 603 ++++++++ 3 files changed, 784 insertions(+), 1138 deletions(-) create mode 100644 src/renderer/components/main/common/numericEditSessionModel.ts create mode 100644 src/renderer/components/main/common/useNumericEditSession.ts diff --git a/src/renderer/components/main/common/NumberInput.tsx b/src/renderer/components/main/common/NumberInput.tsx index 211f037fb..9359be4f8 100644 --- a/src/renderer/components/main/common/NumberInput.tsx +++ b/src/renderer/components/main/common/NumberInput.tsx @@ -1,28 +1,12 @@ -import React, { useState, useEffect, useLayoutEffect, useRef } from 'react'; +import React from 'react'; import type { CommitStrategy } from '@hooks/useOptimisticBooleanCommit'; -import { useDigitPop } from '@hooks/ui/useDigitPop'; -import { useFrameCoalescer } from '@hooks/ui/useFrameCoalescer'; -import { useFieldError } from '@hooks/ui/useFieldError'; -import { - isStaleRepeat, - parseLeadingNumber, - resolveStepper, -} from '@utils/core/numberStep'; -import { - evaluateArithmeticExpression, - MAX_EXPRESSION_LENGTH, -} from '@utils/core/arithmeticExpression'; import { I18nContext } from '@contexts/I18nContextDef'; -import { useAfterPaintValueCommit } from '@hooks/useAfterPaintValueCommit'; -import { useScrubDrag } from '@hooks/ui/useScrubDrag'; import { NumberInputField, NumberInputShell } from './NumberInputChrome'; import { - ARITHMETIC_INPUT_PATTERN, - canParseNumericInput, - isExpressionDraft, - isPartialNumericInput, - stepDirection, -} from './numberInputModel'; + createOptionalNumericEditPolicy, + createRequiredNumericEditPolicy, + useNumericEditSession, +} from './useNumericEditSession'; export interface NumberInputProps { value: number | string; @@ -84,7 +68,7 @@ const useNumberInputMessages = () => { }; }; -export const NumberInput: React.FC = ({ +export const NumberInput = ({ value, onChange, commitStrategy = 'after-paint', @@ -104,583 +88,48 @@ export const NumberInput: React.FC = ({ disabled = false, isMixed = false, mixedPlaceholder = 'Mixed', -}) => { +}: NumberInputProps) => { const messages = useNumberInputMessages(); - const resolvedDecimalScale = allowDecimal - ? Math.max(0, Math.floor(decimalScale)) - : 0; - const supportsDecimal = resolvedDecimalScale > 0; - - const normalizePrecision = (num: number): number => { - if (!supportsDecimal) return num; - return Number(num.toFixed(resolvedDecimalScale)); - }; - - const sanitizeNumericInput = (raw: string): string => { - let sanitized = raw.replace(supportsDecimal ? /[^0-9.-]/g : /[^0-9-]/g, ''); - - const isNegative = sanitized.startsWith('-'); - sanitized = sanitized.replace(/-/g, ''); - if (isNegative) { - sanitized = `-${sanitized}`; - } - - if (!supportsDecimal) { - return sanitized; - } - - const sign = sanitized.startsWith('-') ? '-' : ''; - const unsigned = sign ? sanitized.slice(1) : sanitized; - const dotIndex = unsigned.indexOf('.'); - - if (dotIndex === -1) { - return `${sign}${unsigned}`; - } - - const integerPart = unsigned.slice(0, dotIndex); - const fractionalPart = unsigned - .slice(dotIndex + 1) - .replace(/\./g, '') - .slice(0, resolvedDecimalScale); - - return `${sign}${integerPart}.${fractionalPart}`; - }; - - const normalizeDraftInput = (raw: string): string | null => { - // 잘라서 받으면 사용자가 넣지 않은 다른 유효 수식이 확정될 수 있어 통째로 거절한다 - if (raw.length > MAX_EXPRESSION_LENGTH) return null; - if (!ARITHMETIC_INPUT_PATTERN.test(raw)) return null; - if (isPartialNumericInput(raw)) { - return sanitizeNumericInput(raw); - } - if (canParseNumericInput(raw)) { - return !supportsDecimal && raw.includes('.') - ? raw - : sanitizeNumericInput(raw); - } - return raw; - }; - - // 정밀도 정규화가 먼저다. 클램프를 먼저 하면 반올림이 값을 경계 밖으로 밀어낸다 - // (자릿수 1에 max 0.96이면 0.96으로 자른 뒤 1.0이 되어 상한을 넘는다) - const clampValue = (num: number): number => - Math.min(Math.max(normalizePrecision(num), min), max); - - const parseAndClamp = (input: string): number | null => { - if (!canParseNumericInput(input)) { - return null; - } - - const parsed = Number(input); - return clampValue(supportsDecimal ? parsed : Math.round(parsed)); - }; - - const evaluateAndClampExpression = (input: string): number | null => { - const evaluated = evaluateArithmeticExpression(input); - if (evaluated === null) return null; - return clampValue(supportsDecimal ? evaluated : Math.round(evaluated)); - }; - - // blur 표시값에만 단위를 붙여 값과 함께 가운데 정렬 (포커스 시 숫자만) - const getDisplayValue = (val: number | string): string => { - const normalized = typeof val === 'number' ? normalizePrecision(val) : val; - return suffix ? `${normalized}${suffix}` : String(normalized); - }; - - const [localValue, setLocalValue] = useState( - isMixed ? '' : getDisplayValue(value), + const edit = useNumericEditSession( + createRequiredNumericEditPolicy({ + value, + onChange, + commitStrategy, + onBlur, + onPreview, + onCancel, + min, + max, + suffix, + allowDecimal, + decimalScale, + step, + disabled, + isMixed, + hasPrefix: Boolean(prefix), + }), ); - const [isFocused, setIsFocused] = useState(false); - const fieldError = useFieldError(); - // Escape로 blur된 경우 확정 없이 원복 - const escapedRef = useRef(false); - const liveCommit = onPreview ?? onChange; - const { scheduleCommit, cancelPendingCommit } = - useAfterPaintValueCommit({ - onCommit: liveCommit, - strategy: commitStrategy, - }); - const digitPop = useDigitPop(); - const stepFrame = useFrameCoalescer(); - // 편집 중 권위값. 같은 React 커밋 안에서 이벤트가 여러 개 처리되면 state는 아직 - // 이전 값이라 스텝 기준이 어긋난다. 화면에 나간 값은 renderedRef가 따로 기억한다 - const draftRef = useRef(localValue); - const renderedRef = useRef(localValue); - const hasUserInputRef = useRef(false); - // 꾹 누르고 있는 키. 이 구간은 값만 바뀌고 재생하지 않는다. - // 키를 구분해야 위를 누른 채 아래를 뗐을 때 엉뚱하게 끝나지 않는다 - const holdKeyRef = useRef(null); - const lastEmittedRef = useRef(null); - // 스텝이 출발한 값. Mixed처럼 표시가 비어 있으면 화면 문자열로는 방향을 알 수 없다 - const stepBaseRef = useRef(0); - const suppressDigitPopRef = useRef(false); - const committedValueRef = useRef(value); - const committedMixedRef = useRef(isMixed); - // 이번 편집에서 값을 내보냈는지. 되돌릴 게 없으면 취소가 호출부를 건드리면 안 된다 - const emittedRef = useRef(false); - - // 권위값·화면값·비교 기준을 한 번에 맞춘다. 셋이 갈라지면 스텝이 화면과 다른 수에서 이어진다 - const syncText = (text: string) => { - draftRef.current = text; - renderedRef.current = text; - setLocalValue(text); - }; - - // 발행은 한 곳으로 모은다. 타이핑이 내보낸 값을 스텝이 모르면 - // 스텝이 우연히 이전 스텝값과 같아졌을 때 발행이 통째로 빠진다 - const emitValue = (next: number) => { - lastEmittedRef.current = next; - emittedRef.current = true; - scheduleCommit(next); - }; - - // 취소는 값을 내보낸 것과 같은 채널로 되돌린다. - // onPreview가 없는 입력은 타이핑이 onChange로 이미 저장까지 갔으므로 되돌릴 길이 그것뿐이다 - const restorePreview = () => { - if (onCancel) { - onCancel(); - return; - } - if (!emittedRef.current) return; - // Mixed는 되돌릴 값이 하나가 아니다. 표시되던 대표값을 발행하면 - // 호출부가 그 값을 선택된 요소 전부에 적용해 요소별 값이 사라진다. - // 지금도 함께 보는 이유: 분리 패널 selection sync는 포커스를 유지한 채 선택만 - // 갈아끼워서, 편집을 시작한 선택과 지금 쓰이는 대상이 다를 수 있다. - // 항목별 복원은 gesture를 가진 onCancel만 할 수 있다 - if (committedMixedRef.current || isMixed) return; - - const committedValue = committedValueRef.current; - const committed = - typeof committedValue === 'number' - ? committedValue - : Number(committedValue); - if (Number.isFinite(committed)) (onPreview ?? onChange)(committed); - }; - - const cancelDraft = () => { - // 손대지 않은 필드의 Escape는 취소 채널도 건드리지 않는다 - onCancel은 살아 있는 - // preview 게스처를 파괴한다. 타이핑·스텝이 있었으면 발행 전이라도 되돌린다 - const touched = hasUserInputRef.current || emittedRef.current; - cancelPendingCommit(); - syncText( - committedMixedRef.current - ? '' - : getDisplayValue(committedValueRef.current), - ); - hasUserInputRef.current = false; - fieldError.clear(); - digitPop.clear(); - if (touched) restorePreview(); - }; - - // 스텝 기준은 편집 중인 값이 우선. 비었거나 부호만 남은 중간 상태면 확정값으로 돌아간다. - // Mixed는 대표값이 기준 - 타이핑과 마찬가지로 절대값을 전체에 적용한다 - const resolveStepBase = (): number => { - // sanitizer는 숫자와 부호만 남기므로 지수 표기가 전혀 다른 수가 된다 (1e-7 -> 17). - // 그대로 숫자로 읽히는 draft는 손대지 않고 쓴다 - if (canParseNumericInput(draftRef.current)) { - return Number(draftRef.current); - } - const typed = sanitizeNumericInput(draftRef.current); - if (canParseNumericInput(typed)) return Number(typed); - const committed = typeof value === 'number' ? value : Number(value); - return Number.isFinite(committed) ? committed : 0; - }; - - // 한 프레임에 쌓인 스텝을 합쳐 한 번만 내보낸다. 스텝마다 화면과 캔버스를 갱신하면 - // 처리가 키 반복을 못 따라가 이벤트가 밀리고, 손을 뗀 뒤에도 값이 계속 올라간다. - // 꾹 누르는 동안에는 값만 바꾼다 - 자릿수가 매 스텝 깜빡이면 값을 눈으로 좇을 수 없다 - const flushStep = () => { - const suppressDigitPop = suppressDigitPopRef.current; - suppressDigitPopRef.current = false; - // 예약과 실행 사이에 min/max가 바뀔 수 있어 실행 시점 규칙으로 다시 재운다. - // 화면과 내보내는 값이 갈라지지 않게 둘 다 이 결과를 쓴다 - const parsed = parseAndClamp(draftRef.current); - const nextText = parsed === null ? draftRef.current : String(parsed); - const prevText = renderedRef.current; - - // 표시가 이미 최신이어도 아래 발행 게이트는 지나야 한다. - // 간격 때문에 미뤄둔 마지막 값이 여기서 나간다 - if (nextText === prevText) { - draftRef.current = nextText; - } else { - // 꾹 누르는 구간은 재생하지 않는다. 반복 간격이 재생보다 짧아 - // 자릿수가 계속 깜빡이면 값을 눈으로 좇을 수 없다 - if (holdKeyRef.current === null && !suppressDigitPop) { - const prevNumber = canParseNumericInput(prevText) - ? Number(prevText) - : stepBaseRef.current; - digitPop.play( - prevText, - nextText, - stepDirection(prevNumber, Number(nextText)), - ); - } - syncText(nextText); - } - - if (parsed === null) return; - if (parsed === lastEmittedRef.current) return; - // 타이핑과 같은 경로 - 확정은 blur/Enter가 한 번에 한다 - emitValue(parsed); - }; - - const stepBy = ( - stepper: (base: number) => number, - repeat: boolean, - key: string, - ): boolean => { - const base = resolveStepBase(); - // 반올림은 스텝 뒤에 - 먼저 반올림하면 63.4 ↓가 63을 건너뛰고 62가 된다 - const stepped = stepper(base); - const next = clampValue(supportsDecimal ? stepped : Math.round(stepped)); - const nextText = String(next); - // 상·하한에 닿아 값이 그대로면 할 일이 없다 - if (nextText === draftRef.current) { - suppressDigitPopRef.current = false; - return false; - } - - // keyup을 놓쳐도 다음 단발 누름이 꾹 누르기 상태를 되돌린다 - holdKeyRef.current = repeat ? key : null; - - stepBaseRef.current = base; - draftRef.current = nextText; - hasUserInputRef.current = true; - - if (repeat) { - // 예약해 둔 함수가 아니라 최신 렌더의 것을 실행해야 한다. - // 예약과 실행 사이에 min/max가 바뀌면 낡은 규칙으로 확정된다. - // 대상 전환은 이 경로로 막지 않는다 - 최신 콜백은 새 대상을 가리킨다 - stepFrame.schedule(() => flushStepRef.current()); - return true; - } - stepFrame.cancel(); - flushStep(); - return true; - }; - - const flushStepRef = useRef(flushStep); - useLayoutEffect(() => { - flushStepRef.current = flushStep; - }); - - // 손을 떼면 밀린 스텝과 최종값을 간격 제한 없이 반영한다. - // 재생은 하지 않는다 - 꾹 눌러 이동하는 동안은 처음부터 끝까지 값만 움직인다. - // keyup을 못 받아도 값은 이미 맞고, 다음 단발 누름이 상태를 되돌린다 - const handleKeyUp = (e: React.KeyboardEvent) => { - if (e.key !== holdKeyRef.current) return; - - stepFrame.cancel(); - flushStep(); - holdKeyRef.current = null; - }; - - // 숫자, 마이너스, 소수점(옵션), 백스페이스, Delete, 화살표, Tab, Enter만 허용 - const handleKeyDown = (e: React.KeyboardEvent) => { - // Enter는 blur를 통해 확정, Escape는 확정 없이 원복 - if (e.key === 'Enter') { - if (isExpressionDraft(draftRef.current)) { - const evaluated = evaluateAndClampExpression(draftRef.current); - if (evaluated === null) { - e.preventDefault(); - fieldError.raise(); - return; - } - fieldError.clear(); - digitPop.clear(); - } - e.currentTarget.blur(); - return; - } - if (e.key === 'Escape') { - // 되돌릴 게 있을 때만 이 필드가 Escape를 소비한다. - // 팝업과 모달은 defaultPrevented로 한 겹씩 닫으므로, 편집을 되돌리는 Escape가 - // 그대로 올라가면 값만 되돌리려다 감싸는 피커까지 접힌다. - // 반대로 손대지 않은 필드가 삼키면 모달이 첫 Escape에 안 닫힌다 - if (hasUserInputRef.current) e.preventDefault(); - escapedRef.current = true; - e.currentTarget.blur(); - return; - } - - // 위아래 방향키는 값 조절. Ctrl/Cmd 조합은 캐럿 이동 관습이라 건드리지 않는다 - if (!e.ctrlKey && !e.metaKey) { - const stepper = resolveStepper(e.key, e, resolvedDecimalScale, step); - if (stepper !== null) { - // 버리는 이벤트도 기본 동작(캐럿 이동)은 막아야 한다 - e.preventDefault(); - // 스크럽 중에는 포인터가 값의 권위다. 붙잡은 방향키 반복이 끼어들지 않게 - if (scrub.active) return; - if (isStaleRepeat(e.nativeEvent)) return; - if (isExpressionDraft(draftRef.current)) { - const evaluated = evaluateAndClampExpression(draftRef.current); - if (evaluated === null) { - // Enter와 같은 신호를 준다. 아무 반응이 없으면 방향키가 죽은 것으로 읽힌다. - // 반복은 재생을 매번 끊으므로 첫 누름만 - if (!e.repeat) fieldError.raise(); - return; - } - - stepFrame.cancel(); - holdKeyRef.current = null; - syncText(String(evaluated)); - hasUserInputRef.current = true; - fieldError.clear(); - digitPop.clear(); - suppressDigitPopRef.current = true; - const stepped = stepBy(stepper, e.repeat, e.key); - if (!stepped && evaluated !== lastEmittedRef.current) { - emitValue(evaluated); - } - return; - } - stepBy(stepper, e.repeat, e.key); - return; - } - } - - const allowedKeys = [ - 'Backspace', - 'Delete', - 'ArrowLeft', - 'ArrowRight', - 'Tab', - 'Home', - 'End', - ]; - - // 허용된 특수 키 - if (allowedKeys.includes(e.key)) { - return; - } - - // Ctrl/Cmd 조합 허용 (복사, 붙여넣기, 전체선택 등) - if (e.ctrlKey || e.metaKey) { - return; - } - - // 숫자와 수식 문자 - if (/^[0-9+\-*/()]$/.test(e.key) || e.key === ' ') { - return; - } - - if (e.key === '.' || e.key === 'Decimal') { - return; - } - - // 그 외 모든 키 입력 차단 - e.preventDefault(); - }; - - const handleChange = (e: React.ChangeEvent) => { - // 밀린 스텝이 뒤늦게 도착하면 방금 친 글자를 덮는다. - // 타이핑이 끼어들면 꾹 누르기 구간도 끝난 것으로 본다 - stepFrame.cancel(); - holdKeyRef.current = null; - suppressDigitPopRef.current = false; - - const newValue = normalizeDraftInput(e.target.value); - if (newValue === null) { - e.currentTarget.value = draftRef.current; - return; - } - - syncText(newValue); - hasUserInputRef.current = true; - fieldError.clear(); - - if (isExpressionDraft(newValue)) return; - const clamped = parseAndClamp(newValue); - if (clamped !== null) { - // 게스처 모드에서는 타이핑이 preview로만 흐름 - emitValue(clamped); - } - }; - - const handleFocus = () => { - setIsFocused(true); - hasUserInputRef.current = false; - escapedRef.current = false; - lastEmittedRef.current = null; - emittedRef.current = false; - fieldError.clear(); - committedValueRef.current = value; - committedMixedRef.current = isMixed; - if (!isMixed) { - const numericValue = - typeof value === 'number' - ? String(normalizePrecision(value)) - : String(value); - syncText(numericValue); - } else { - syncText(''); - } - }; - - // 포커스 중 표시는 숫자만, 아니면 단위까지 - const draftTextFor = (num: number): string => - isFocused ? String(normalizePrecision(num)) : getDisplayValue(num); - - // 접두 스크럽은 preview 채널이 있을 때만 켠다. 없으면 liveCommit이 onChange라 - // 이동마다 저장이 나가 방향키 꾹 누르기와 같은 적체가 생긴다 - const scrubEnabled = Boolean(prefix) && Boolean(onPreview) && !disabled; - const scrub = useScrubDrag({ - enabled: scrubEnabled, - resolveBase: () => { - digitPop.clear(); - // 비포커스 표시값의 %, ° 같은 suffix는 수식 문자가 아니다 - // 실제 수식을 편집 중인 포커스 상태에서만 평가 경로로 보낸다 - if (isFocused && isExpressionDraft(draftRef.current)) { - const evaluated = evaluateAndClampExpression(draftRef.current); - if (evaluated === null) { - fieldError.raise(); - return null; - } - syncText(String(evaluated)); - hasUserInputRef.current = true; - fieldError.clear(); - digitPop.clear(); - return evaluated; - } - return resolveStepBase(); - }, - step: step ?? 1, - range: { min, max }, - quantize: (raw) => clampValue(supportsDecimal ? raw : Math.round(raw)), - // 접두 손잡이와 입력은 같은 label 아래에 있다 - ownsFocus: (active, handle) => - handle.parentElement?.contains(active) ?? false, - onMove: (next) => { - // 밀린 키 스텝은 버린다. 드래그가 그 값 위에서 이어지면 손을 뗀 뒤 값이 한 번 더 뛴다 - stepFrame.cancel(); - holdKeyRef.current = null; - // 포커스를 옮기지 않는 스크럽에서도 단위 유무가 바뀌지 않게 표시 형식을 유지 - syncText(draftTextFor(next)); - hasUserInputRef.current = true; - emitValue(next); - }, - onCommit: (final) => { - cancelPendingCommit(); - syncText(draftTextFor(final)); - hasUserInputRef.current = false; - committedValueRef.current = final; - committedMixedRef.current = false; - emittedRef.current = false; - lastEmittedRef.current = null; - onChange(final); - }, - onCancel: () => { - cancelPendingCommit(); - syncText( - committedMixedRef.current - ? '' - : draftTextFor(Number(committedValueRef.current)), - ); - hasUserInputRef.current = false; - fieldError.clear(); - digitPop.clear(); - restorePreview(); - }, - }); - - useEffect(() => { - // 스크럽 중에는 로컬 draft가 표시 권위다. after-paint preview가 늦게 돌아와도 - // 현재 포인터 값과 단위 문자열을 한 프레임 전 값으로 덮지 않는다 - if (!isFocused && !scrub.active) { - syncText(isMixed ? '' : getDisplayValue(value)); - hasUserInputRef.current = false; - committedValueRef.current = value; - committedMixedRef.current = isMixed; - } - // active 종료만으로 옛 prop을 다시 쓰지 않는다. 최종 onChange의 value 변경이 동기화한다 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [value, isFocused, isMixed]); - - const handleBlur = () => { - setIsFocused(false); - // 확정과 취소 모두 최종값을 직접 들고 간다. 밀린 스텝이 그 뒤에 도착하면 - // 취소한 값이 되살아나거나 확정 뒤에 값이 한 번 더 움직인다 - stepFrame.cancel(); - holdKeyRef.current = null; - - // 끌고 있는 도중의 blur(창 전환, 분리 패널 포커스 정산)는 취소다. - // 드래그 draft를 확정하면 창 blur 리스너가 저장을 만든다. - // 잡기만 하고 안 움직인 세션은 값을 건드리지 않았으므로 평소 정산으로 이어간다 - if (scrub.cancel()) return; - - // Escape는 확정 없이 표시값 원복 - if (escapedRef.current) { - escapedRef.current = false; - cancelDraft(); - return; - } - - // 확정 기준은 화면 state가 아니라 권위값. 마지막 입력과 blur가 같은 커밋에 - // 몰리면 state는 아직 이전 값이다 - // 손대지 않은 필드는 아무것도 쓰지 않는다. 포커스 중 외부에서 값이 바뀌었으면 - // 편집 시작 때 떠 온 옛 draft가 새 값을 덮어쓴다 - if (!hasUserInputRef.current) { - syncText(isMixed ? '' : getDisplayValue(value)); - fieldError.clear(); - onBlur?.(); - return; - } - - if (isExpressionDraft(draftRef.current)) { - const evaluated = evaluateAndClampExpression(draftRef.current); - if (evaluated === null) { - cancelDraft(); - return; - } - - syncText(getDisplayValue(evaluated)); - hasUserInputRef.current = false; - fieldError.clear(); - digitPop.clear(); - cancelPendingCommit(); - onChange(evaluated); - onBlur?.(evaluated); - return; - } - - const clamped = parseAndClamp(draftRef.current); - if (clamped === null) { - cancelDraft(); - return; - } - - syncText(getDisplayValue(clamped)); - cancelPendingCommit(); - onChange(clamped); - hasUserInputRef.current = false; - fieldError.clear(); - // 확정값을 그대로 넘긴다. onChange가 예약한 state를 호출부가 같은 이벤트에서 - // 읽으면 아직 이전 값이라, 밀린 스텝이 있는 채로 blur하면 옛 값이 저장된다 - onBlur?.(clamped ?? undefined); - }; - - // Mixed 상태일 때 placeholder 표시 여부 - const showMixedPlaceholder = isMixed && !isFocused && localValue === ''; + const showMixedPlaceholder = + isMixed && !edit.isFocused && edit.localValue === ''; return ( = ({ } ariaLabel={ariaLabel} disabled={disabled} - pop={digitPop.pop} - invalid={fieldError.active} + pop={edit.digitPop.pop} + invalid={edit.fieldError.active} tooltip={messages.expressionHint} /> ); }; -// ============================================================================ -// OptionalNumberInput (빈 값 -> undefined 허용, placeholder 지원) -// ============================================================================ - -export const OptionalNumberInput: React.FC = ({ +export const OptionalNumberInput = ({ value, onChange, commitStrategy = 'after-paint', @@ -720,537 +165,32 @@ export const OptionalNumberInput: React.FC = ({ decimalScale = 1, isMixed = false, mixedPlaceholder = 'Mixed', -}) => { +}: OptionalNumberInputProps) => { const messages = useNumberInputMessages(); - const resolvedDecimalScale = allowDecimal - ? Math.max(0, Math.floor(decimalScale)) - : 0; - const supportsDecimal = resolvedDecimalScale > 0; - const inputMode = supportsDecimal ? 'decimal' : 'numeric'; - - const normalizePrecision = (num: number): number => { - if (!supportsDecimal) return num; - return Number(num.toFixed(resolvedDecimalScale)); - }; - - // 숫자 모드에서는 부호를 맨 앞에 하나, 소수점도 하나만 유지 - const sanitizeInput = (raw: string): string => { - const pattern = supportsDecimal ? /[^0-9.-]/g : /[^0-9-]/g; - let sanitized = raw.replace(pattern, ''); - - const isNegative = sanitized.startsWith('-'); - sanitized = sanitized.replace(/-/g, ''); - if (isNegative) sanitized = `-${sanitized}`; - - if (!supportsDecimal) return sanitized; - - const sign = sanitized.startsWith('-') ? '-' : ''; - const unsigned = sign ? sanitized.slice(1) : sanitized; - const dotIndex = unsigned.indexOf('.'); - if (dotIndex === -1) return `${sign}${unsigned}`; - - const integerPart = unsigned.slice(0, dotIndex); - const fractionalPart = unsigned - .slice(dotIndex + 1) - .replace(/\./g, '') - .slice(0, resolvedDecimalScale); - return `${sign}${integerPart}.${fractionalPart}`; - }; - - const normalizeDraftInput = (raw: string): string | null => { - // 잘라서 받으면 사용자가 넣지 않은 다른 유효 수식이 확정될 수 있어 통째로 거절한다 - if (raw.length > MAX_EXPRESSION_LENGTH) return null; - if (!ARITHMETIC_INPUT_PATTERN.test(raw)) return null; - if (isPartialNumericInput(raw)) { - return sanitizeInput(raw); - } - if (canParseNumericInput(raw)) { - return !supportsDecimal && raw.includes('.') ? raw : sanitizeInput(raw); - } - return raw; - }; - - // 정밀도 정규화가 먼저다. 클램프를 먼저 하면 반올림이 값을 경계 밖으로 밀어낸다 - // (자릿수 1에 max 0.96이면 0.96으로 자른 뒤 1.0이 되어 상한을 넘는다) - const domainMin = allowNegative ? min : Math.max(0, min); - const clampValue = (num: number): number => - Math.min(Math.max(normalizePrecision(num), domainMin), max); - - const evaluateAndClampExpression = (input: string): number | null => { - const evaluated = evaluateArithmeticExpression(input); - if (evaluated === null) return null; - return clampValue(supportsDecimal ? evaluated : Math.round(evaluated)); - }; - - // blur 표시값에만 단위를 붙여 값과 함께 가운데 정렬 (포커스 시 숫자만) - const getDisplayValue = (val: number): string => { - const normalized = normalizePrecision(val); - return suffix ? `${normalized}${suffix}` : String(normalized); - }; - - const [localValue, setLocalValue] = useState(() => { - if (isMixed || value == null) return ''; - return getDisplayValue(value); - }); - const [isFocused, setIsFocused] = useState(false); - const fieldError = useFieldError(); - // Escape로 blur된 경우 확정 없이 원복 - const escapedRef = useRef(false); - const liveCommit = onPreview ?? onChange; - const { scheduleCommit, cancelPendingCommit } = useAfterPaintValueCommit< - number | undefined - >({ - onCommit: liveCommit, - strategy: commitStrategy, - }); - const digitPop = useDigitPop(); - const stepFrame = useFrameCoalescer(); - // 편집 중 권위값. 같은 React 커밋 안에서 이벤트가 여러 개 처리되면 state는 아직 - // 이전 값이라 스텝 기준이 어긋난다. 화면에 나간 값은 renderedRef가 따로 기억한다 - const draftRef = useRef(localValue); - const renderedRef = useRef(localValue); - const hasUserInputRef = useRef(false); - // 꾹 누르고 있는 키. 이 구간은 값만 바뀌고 재생하지 않는다. - // 키를 구분해야 위를 누른 채 아래를 뗐을 때 엉뚱하게 끝나지 않는다 - const holdKeyRef = useRef(null); - const lastEmittedRef = useRef(null); - // 스텝이 출발한 값. 빈 값에서 시작하면 화면 문자열로는 방향을 알 수 없다 - const stepBaseRef = useRef(0); - const suppressDigitPopRef = useRef(false); - const committedValueRef = useRef(value); - const committedMixedRef = useRef(isMixed); - // 이번 편집에서 값을 내보냈는지. 되돌릴 게 없으면 취소가 호출부를 건드리면 안 된다 - const emittedRef = useRef(false); - - // 권위값·화면값·비교 기준을 한 번에 맞춘다. 셋이 갈라지면 스텝이 화면과 다른 수에서 이어진다 - const syncText = (text: string) => { - draftRef.current = text; - renderedRef.current = text; - setLocalValue(text); - }; - - // 발행은 한 곳으로 모은다. 타이핑이 내보낸 값을 스텝이 모르면 - // 스텝이 우연히 이전 스텝값과 같아졌을 때 발행이 통째로 빠진다. - // unset은 숫자가 아니므로 기준을 비워 다음 스텝이 반드시 나가게 한다 - const emitValue = (next: number | undefined) => { - lastEmittedRef.current = next ?? null; - emittedRef.current = true; - scheduleCommit(next); - }; - - // 취소는 값을 내보낸 것과 같은 채널로 되돌린다. - // onPreview가 없는 입력은 타이핑이 onChange로 이미 저장까지 갔으므로 되돌릴 길이 그것뿐이다 - const restorePreview = () => { - if (onCancel) { - onCancel(); - return; - } - if (!emittedRef.current) return; - // Mixed는 되돌릴 값이 하나가 아니다. 표시되던 대표값을 발행하면 - // 호출부가 그 값을 선택된 요소 전부에 적용해 요소별 값이 사라진다. - // 지금도 함께 보는 이유: 분리 패널 selection sync는 포커스를 유지한 채 선택만 - // 갈아끼워서, 편집을 시작한 선택과 지금 쓰이는 대상이 다를 수 있다. - // 항목별 복원은 gesture를 가진 onCancel만 할 수 있다 - if (committedMixedRef.current || isMixed) return; - (onPreview ?? onChange)(committedValueRef.current); - }; - - const cancelDraft = () => { - // 손대지 않은 필드의 Escape는 취소 채널도 건드리지 않는다 - onCancel은 살아 있는 - // preview 게스처를 파괴한다. 타이핑·스텝이 있었으면 발행 전이라도 되돌린다 - const touched = hasUserInputRef.current || emittedRef.current; - cancelPendingCommit(); - const committedValue = committedValueRef.current; - syncText( - committedMixedRef.current || committedValue == null - ? '' - : getDisplayValue(committedValue), - ); - hasUserInputRef.current = false; - fieldError.clear(); - digitPop.clear(); - if (touched) restorePreview(); - }; - - // 값이 비어 있으면 placeholder에 보이는 상속값이 기준이다. - // 화면에 16px이 떠 있는데 0에서 시작하면 방향키가 값을 되돌리는 것처럼 보인다 - const resolveStepBase = (): number => { - // sanitizer는 숫자와 부호만 남기므로 지수 표기가 전혀 다른 수가 된다 (1e-7 -> 17). - // 그대로 숫자로 읽히는 draft는 손대지 않고 쓴다 - if (canParseNumericInput(draftRef.current)) { - return Number(draftRef.current); - } - const typed = sanitizeInput(draftRef.current); - if (canParseNumericInput(typed)) { - return Number(typed); - } - if (value != null) return value; - return parseLeadingNumber(placeholder) ?? 0; - }; - - // 한 프레임에 쌓인 스텝을 합쳐 한 번만 내보낸다. 스텝마다 화면과 캔버스를 갱신하면 - // 처리가 키 반복을 못 따라가 이벤트가 밀리고, 손을 뗀 뒤에도 값이 계속 올라간다. - // 꾹 누르는 동안에는 값만 바꾼다 - 자릿수가 매 스텝 깜빡이면 값을 눈으로 좇을 수 없다 - const flushStep = () => { - const suppressDigitPop = suppressDigitPopRef.current; - suppressDigitPopRef.current = false; - // 예약과 실행 사이에 min/max가 바뀔 수 있어 실행 시점 규칙으로 다시 재운다. - // 화면과 내보내는 값이 갈라지지 않게 둘 다 이 결과를 쓴다 - const clamped = clampValue(Number(draftRef.current)); - const nextText = String(clamped); - const prevText = renderedRef.current; - - // 표시가 이미 최신이어도 아래 발행 게이트는 지나야 한다. - // 간격 때문에 미뤄둔 마지막 값이 여기서 나간다 - if (nextText === prevText) { - draftRef.current = nextText; - } else { - // 꾹 누르는 구간은 재생하지 않는다. 반복 간격이 재생보다 짧아 - // 자릿수가 계속 깜빡이면 값을 눈으로 좇을 수 없다 - if (holdKeyRef.current === null && !suppressDigitPop) { - const prevNumber = isPartialNumericInput(prevText) - ? stepBaseRef.current - : Number(prevText); - digitPop.play(prevText, nextText, stepDirection(prevNumber, clamped)); - } - syncText(nextText); - } - - if (clamped === lastEmittedRef.current) return; - // 타이핑과 같은 경로 - 확정은 blur/Enter가 한 번에 한다 - emitValue(clamped); - }; - - const stepBy = ( - stepper: (base: number) => number, - repeat: boolean, - key: string, - ): boolean => { - const base = resolveStepBase(); - // 반올림은 스텝 뒤에 - 먼저 반올림하면 63.4 ↓가 63을 건너뛰고 62가 된다 - const stepped = stepper(base); - const next = clampValue(supportsDecimal ? stepped : Math.round(stepped)); - const nextText = String(next); - // 상·하한에 닿아 값이 그대로면 할 일이 없다 - if (nextText === draftRef.current) { - suppressDigitPopRef.current = false; - return false; - } - - // keyup을 놓쳐도 다음 단발 누름이 꾹 누르기 상태를 되돌린다 - holdKeyRef.current = repeat ? key : null; - - stepBaseRef.current = base; - draftRef.current = nextText; - hasUserInputRef.current = true; - - if (repeat) { - // 예약해 둔 함수가 아니라 최신 렌더의 것을 실행해야 한다. - // 예약과 실행 사이에 min/max가 바뀌면 낡은 규칙으로 확정된다. - // 대상 전환은 이 경로로 막지 않는다 - 최신 콜백은 새 대상을 가리킨다 - stepFrame.schedule(() => flushStepRef.current()); - return true; - } - stepFrame.cancel(); - flushStep(); - return true; - }; - - const flushStepRef = useRef(flushStep); - useLayoutEffect(() => { - flushStepRef.current = flushStep; - }); - - // 손을 떼면 밀린 스텝과 최종값을 간격 제한 없이 반영한다. - // 재생은 하지 않는다 - 꾹 눌러 이동하는 동안은 처음부터 끝까지 값만 움직인다. - // keyup을 못 받아도 값은 이미 맞고, 다음 단발 누름이 상태를 되돌린다 - const handleKeyUp = (e: React.KeyboardEvent) => { - if (e.key !== holdKeyRef.current) return; - - stepFrame.cancel(); - flushStep(); - holdKeyRef.current = null; - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - // Enter는 blur를 통해 확정, Escape는 확정 없이 원복 - if (e.key === 'Enter') { - if (isExpressionDraft(draftRef.current)) { - const evaluated = evaluateAndClampExpression(draftRef.current); - if (evaluated === null) { - e.preventDefault(); - fieldError.raise(); - return; - } - fieldError.clear(); - digitPop.clear(); - } - e.currentTarget.blur(); - return; - } - if (e.key === 'Escape') { - // 되돌릴 게 있을 때만 이 필드가 Escape를 소비한다. - // 팝업과 모달은 defaultPrevented로 한 겹씩 닫으므로, 편집을 되돌리는 Escape가 - // 그대로 올라가면 값만 되돌리려다 감싸는 피커까지 접힌다. - // 반대로 손대지 않은 필드가 삼키면 모달이 첫 Escape에 안 닫힌다 - if (hasUserInputRef.current) e.preventDefault(); - escapedRef.current = true; - e.currentTarget.blur(); - return; - } - - // 위아래 방향키는 값 조절. Ctrl/Cmd 조합은 캐럿 이동 관습이라 건드리지 않는다 - if (!e.ctrlKey && !e.metaKey) { - const stepper = resolveStepper(e.key, e, resolvedDecimalScale); - if (stepper !== null) { - // 버리는 이벤트도 기본 동작(캐럿 이동)은 막아야 한다 - e.preventDefault(); - if (isStaleRepeat(e.nativeEvent)) return; - if (isExpressionDraft(draftRef.current)) { - const evaluated = evaluateAndClampExpression(draftRef.current); - if (evaluated === null) { - // Enter와 같은 신호를 준다. 아무 반응이 없으면 방향키가 죽은 것으로 읽힌다. - // 반복은 재생을 매번 끊으므로 첫 누름만 - if (!e.repeat) fieldError.raise(); - return; - } - - stepFrame.cancel(); - holdKeyRef.current = null; - syncText(String(evaluated)); - hasUserInputRef.current = true; - fieldError.clear(); - digitPop.clear(); - suppressDigitPopRef.current = true; - const stepped = stepBy(stepper, e.repeat, e.key); - if (!stepped && evaluated !== lastEmittedRef.current) { - emitValue(evaluated); - } - return; - } - stepBy(stepper, e.repeat, e.key); - return; - } - } - - const allowedKeys = [ - 'Backspace', - 'Delete', - 'ArrowLeft', - 'ArrowRight', - 'Tab', - 'Home', - 'End', - ]; - - if (allowedKeys.includes(e.key)) return; - if (e.ctrlKey || e.metaKey) return; - if (/^[0-9+\-*/()]$/.test(e.key) || e.key === ' ') return; - if (e.key === '.' || e.key === 'Decimal') return; - - e.preventDefault(); - }; - - const handleChange = (e: React.ChangeEvent) => { - // 밀린 스텝이 뒤늦게 도착하면 방금 친 글자를 덮는다. - // 타이핑이 끼어들면 꾹 누르기 구간도 끝난 것으로 본다 - stepFrame.cancel(); - holdKeyRef.current = null; - suppressDigitPopRef.current = false; - - const newValue = normalizeDraftInput(e.target.value); - if (newValue === null) { - e.currentTarget.value = draftRef.current; - return; - } - - syncText(newValue); - hasUserInputRef.current = true; - fieldError.clear(); - - // 빈 값만 unset, 부호·소수점만 남은 중간 상태는 commit하지 않고 입력 유지 - if (newValue === '') { - emitValue(undefined); - return; - } - if (isPartialNumericInput(newValue) || isExpressionDraft(newValue)) { - return; - } - - const numValue = Number(newValue); - if (!Number.isFinite(numValue)) return; - - emitValue(clampValue(supportsDecimal ? numValue : Math.round(numValue))); - }; - - const handleFocus = () => { - setIsFocused(true); - hasUserInputRef.current = false; - escapedRef.current = false; - lastEmittedRef.current = null; - emittedRef.current = false; - fieldError.clear(); - committedValueRef.current = value; - committedMixedRef.current = isMixed; - // 포커스 표시도 blur 표시와 같은 자릿수 - 12.34가 포커스 순간 12.3에서 되살아나지 않게 - syncText( - !isMixed && value != null ? String(normalizePrecision(value)) : '', - ); - }; - - // 포커스 중 표시는 숫자만, 아니면 단위까지 - const draftTextFor = (num: number): string => - isFocused ? String(normalizePrecision(num)) : getDisplayValue(num); - - // 접두 스크럽은 preview 채널이 있을 때만 켠다 (NumberInput과 같은 정책). - // 값이 비어 있으면 placeholder의 상속값에서 출발한다 - 방향키와 같은 기준. - // disabled prop이 생기면 NumberInput처럼 `&& !disabled`를 함께 걸 것 - const scrubEnabled = Boolean(prefix) && Boolean(onPreview); - const scrub = useScrubDrag({ - enabled: scrubEnabled, - resolveBase: () => { - digitPop.clear(); - if (isFocused && isExpressionDraft(draftRef.current)) { - const evaluated = evaluateAndClampExpression(draftRef.current); - if (evaluated === null) { - fieldError.raise(); - return null; - } - syncText(String(evaluated)); - hasUserInputRef.current = true; - fieldError.clear(); - digitPop.clear(); - return evaluated; - } - return resolveStepBase(); - }, - step: 1, - range: { min: domainMin, max }, - quantize: (raw) => clampValue(supportsDecimal ? raw : Math.round(raw)), - ownsFocus: (active, handle) => - handle.parentElement?.contains(active) ?? false, - onMove: (next) => { - stepFrame.cancel(); - holdKeyRef.current = null; - syncText(draftTextFor(next)); - hasUserInputRef.current = true; - emitValue(next); - }, - onCommit: (final) => { - cancelPendingCommit(); - syncText(draftTextFor(final)); - hasUserInputRef.current = false; - committedValueRef.current = final; - committedMixedRef.current = false; - emittedRef.current = false; - lastEmittedRef.current = null; - onChange(final); - }, - onCancel: () => { - cancelPendingCommit(); - const committed = committedValueRef.current; - syncText( - committedMixedRef.current || committed == null - ? '' - : draftTextFor(committed), - ); - hasUserInputRef.current = false; - fieldError.clear(); - digitPop.clear(); - restorePreview(); - }, - }); - - useEffect(() => { - // 스크럽 중에는 로컬 draft가 표시 권위다 - 부모 preview 에코가 드래그 중 값을 - // 덮거나, value가 undefined를 지나는 순간 칸을 비우지 않게 - if (!isFocused && !scrub.active) { - syncText(isMixed || value == null ? '' : getDisplayValue(value)); - hasUserInputRef.current = false; - committedValueRef.current = value; - committedMixedRef.current = isMixed; - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [value, isFocused, isMixed]); - - const handleBlur = () => { - setIsFocused(false); - // 확정과 취소 모두 최종값을 직접 들고 간다. 밀린 스텝이 그 뒤에 도착하면 - // 취소한 값이 되살아나거나 확정 뒤에 값이 한 번 더 움직인다 - stepFrame.cancel(); - holdKeyRef.current = null; - - // 끌고 있는 도중의 blur는 취소다 (NumberInput과 동일) - if (scrub.cancel()) return; - - // Escape는 확정 없이 표시값 원복 - if (escapedRef.current) { - escapedRef.current = false; - cancelDraft(); - return; - } - - // 확정 기준은 화면 state가 아니라 권위값. 마지막 입력과 blur가 같은 커밋에 - // 몰리면 state는 아직 이전 값이다 - // 손대지 않은 필드는 아무것도 쓰지 않는다. Mixed가 풀리며 값이 들어온 뒤 - // 그대로 blur하면 빈 draft가 그 값을 지운다 - if (!hasUserInputRef.current) { - syncText(isMixed || value == null ? '' : getDisplayValue(value)); - fieldError.clear(); - onBlur?.(); - return; - } - - if (draftRef.current === '') { - syncText(''); - cancelPendingCommit(); - onChange(undefined); - hasUserInputRef.current = false; - fieldError.clear(); - digitPop.clear(); - onBlur?.(); - return; - } - - if (isExpressionDraft(draftRef.current)) { - const evaluated = evaluateAndClampExpression(draftRef.current); - if (evaluated === null) { - cancelDraft(); - return; - } - - syncText(getDisplayValue(evaluated)); - cancelPendingCommit(); - onChange(evaluated); - hasUserInputRef.current = false; - fieldError.clear(); - digitPop.clear(); - onBlur?.(evaluated); - return; - } - - const cleaned = draftRef.current; - if (isPartialNumericInput(cleaned) || !Number.isFinite(Number(cleaned))) { - cancelDraft(); - return; - } - - const parsed = Number(cleaned); - const clamped = clampValue(supportsDecimal ? parsed : Math.round(parsed)); - syncText(getDisplayValue(clamped)); - cancelPendingCommit(); - onChange(clamped); - hasUserInputRef.current = false; - // 확정값을 그대로 넘긴다. onChange가 예약한 state를 호출부가 같은 이벤트에서 - // 읽으면 아직 이전 값이라, 밀린 스텝이 있는 채로 blur하면 옛 값이 저장된다 - onBlur?.(clamped); - }; - - const showMixedPlaceholder = isMixed && !isFocused && localValue === ''; + const edit = useNumericEditSession( + createOptionalNumericEditPolicy({ + value, + onChange, + commitStrategy, + onBlur, + onPreview, + onCancel, + min, + max, + suffix, + placeholder, + allowNegative, + allowDecimal, + decimalScale, + isMixed, + hasPrefix: Boolean(prefix), + }), + ); + const showMixedPlaceholder = + isMixed && !edit.isFocused && edit.localValue === ''; const effectivePlaceholder = showMixedPlaceholder ? mixedPlaceholder : placeholder; - const placeholderClass = effectivePlaceholder ? 'placeholder:text-fg-faint placeholder:italic' : ''; @@ -1259,26 +199,26 @@ export const OptionalNumberInput: React.FC = ({ return ( diff --git a/src/renderer/components/main/common/numericEditSessionModel.ts b/src/renderer/components/main/common/numericEditSessionModel.ts new file mode 100644 index 000000000..84431918e --- /dev/null +++ b/src/renderer/components/main/common/numericEditSessionModel.ts @@ -0,0 +1,103 @@ +import { + evaluateArithmeticExpression, + MAX_EXPRESSION_LENGTH, +} from '@utils/core/arithmeticExpression'; +import { + ARITHMETIC_INPUT_PATTERN, + canParseNumericInput, + isPartialNumericInput, +} from './numberInputModel'; + +interface NumericEditModelOptions { + allowDecimal: boolean; + decimalScale: number; + min: number; + max: number; + suffix?: string; +} + +export const createNumericEditModel = ({ + allowDecimal, + decimalScale, + min, + max, + suffix, +}: NumericEditModelOptions) => { + const resolvedDecimalScale = allowDecimal + ? Math.max(0, Math.floor(decimalScale)) + : 0; + const supportsDecimal = resolvedDecimalScale > 0; + + const normalizePrecision = (num: number): number => { + if (!supportsDecimal) return num; + return Number(num.toFixed(resolvedDecimalScale)); + }; + + // 부호는 맨 앞에 하나, 소수점도 하나만 유지 + const sanitizeNumericInput = (raw: string): string => { + let sanitized = raw.replace(supportsDecimal ? /[^0-9.-]/g : /[^0-9-]/g, ''); + const isNegative = sanitized.startsWith('-'); + sanitized = sanitized.replace(/-/g, ''); + if (isNegative) sanitized = `-${sanitized}`; + if (!supportsDecimal) return sanitized; + + const sign = sanitized.startsWith('-') ? '-' : ''; + const unsigned = sign ? sanitized.slice(1) : sanitized; + const dotIndex = unsigned.indexOf('.'); + if (dotIndex === -1) return `${sign}${unsigned}`; + + const integerPart = unsigned.slice(0, dotIndex); + const fractionalPart = unsigned + .slice(dotIndex + 1) + .replace(/\./g, '') + .slice(0, resolvedDecimalScale); + return `${sign}${integerPart}.${fractionalPart}`; + }; + + const normalizeDraftInput = (raw: string): string | null => { + // 잘라서 다른 유효 수식으로 확정되는 상황 방지 + if (raw.length > MAX_EXPRESSION_LENGTH) return null; + if (!ARITHMETIC_INPUT_PATTERN.test(raw)) return null; + if (isPartialNumericInput(raw)) return sanitizeNumericInput(raw); + if (canParseNumericInput(raw)) { + return !supportsDecimal && raw.includes('.') + ? raw + : sanitizeNumericInput(raw); + } + return raw; + }; + + // 정밀도 정규화 뒤 도메인 제한 + const clampValue = (num: number): number => + Math.min(Math.max(normalizePrecision(num), min), max); + + const parseAndClamp = (input: string): number | null => { + if (!canParseNumericInput(input)) return null; + const parsed = Number(input); + return clampValue(supportsDecimal ? parsed : Math.round(parsed)); + }; + + const evaluateAndClampExpression = (input: string): number | null => { + const evaluated = evaluateArithmeticExpression(input); + if (evaluated === null) return null; + return clampValue(supportsDecimal ? evaluated : Math.round(evaluated)); + }; + + const getDisplayValue = (value: number | string): string => { + const normalized = + typeof value === 'number' ? normalizePrecision(value) : value; + return suffix ? `${normalized}${suffix}` : String(normalized); + }; + + return { + resolvedDecimalScale, + supportsDecimal, + normalizePrecision, + sanitizeNumericInput, + normalizeDraftInput, + clampValue, + parseAndClamp, + evaluateAndClampExpression, + getDisplayValue, + }; +}; diff --git a/src/renderer/components/main/common/useNumericEditSession.ts b/src/renderer/components/main/common/useNumericEditSession.ts new file mode 100644 index 000000000..aef8b6b97 --- /dev/null +++ b/src/renderer/components/main/common/useNumericEditSession.ts @@ -0,0 +1,603 @@ +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import type { CommitStrategy } from '@hooks/useOptimisticBooleanCommit'; +import { useAfterPaintValueCommit } from '@hooks/useAfterPaintValueCommit'; +import { useDigitPop } from '@hooks/ui/useDigitPop'; +import { useFieldError } from '@hooks/ui/useFieldError'; +import { useFrameCoalescer } from '@hooks/ui/useFrameCoalescer'; +import { useScrubDrag } from '@hooks/ui/useScrubDrag'; +import { + isStaleRepeat, + parseLeadingNumber, + resolveStepper, +} from '@utils/core/numberStep'; +import { + canParseNumericInput, + isExpressionDraft, + isPartialNumericInput, + stepDirection, +} from './numberInputModel'; +import { createNumericEditModel } from './numericEditSessionModel'; + +interface NumericEditPolicyBase { + commitStrategy: CommitStrategy; + min: number; + max: number; + suffix?: string; + allowDecimal: boolean; + decimalScale: number; + isMixed: boolean; + hasPrefix: boolean; + onCancel?: () => void; + onBlur?: (value?: number) => void; +} + +export interface RequiredNumericEditPolicy extends NumericEditPolicyBase { + kind: 'required'; + value: number | string; + onChange: (value: number) => void; + onPreview?: (value: number) => void; + step?: number; + disabled: boolean; +} + +export interface OptionalNumericEditPolicy extends NumericEditPolicyBase { + kind: 'optional'; + value?: number; + onChange: (value?: number) => void; + onPreview?: (value?: number) => void; + placeholder?: string; + allowNegative: boolean; +} + +export type NumericEditPolicy = + | RequiredNumericEditPolicy + | OptionalNumericEditPolicy; + +export const createRequiredNumericEditPolicy = ( + policy: Omit, +): RequiredNumericEditPolicy => ({ kind: 'required', ...policy }); + +export const createOptionalNumericEditPolicy = ( + policy: Omit, +): OptionalNumericEditPolicy => ({ kind: 'optional', ...policy }); + +export const useNumericEditSession = (policy: NumericEditPolicy) => { + const isOptional = policy.kind === 'optional'; + const { + value, + onPreview, + onCancel, + onBlur, + commitStrategy, + min, + max, + suffix, + allowDecimal, + decimalScale, + isMixed, + hasPrefix, + } = policy; + // Optional의 음수 금지 정책은 최종 도메인에만 적용 + const domainMin = + policy.kind === 'optional' && !policy.allowNegative + ? Math.max(0, min) + : min; + const { + resolvedDecimalScale, + supportsDecimal, + normalizePrecision, + sanitizeNumericInput, + normalizeDraftInput, + clampValue, + parseAndClamp, + evaluateAndClampExpression, + getDisplayValue, + } = createNumericEditModel({ + allowDecimal, + decimalScale, + min: domainMin, + max, + suffix, + }); + + const initialText = + isMixed || (isOptional && value == null) ? '' : getDisplayValue(value!); + const [localValue, setLocalValue] = useState(initialText); + const [isFocused, setIsFocused] = useState(false); + const fieldError = useFieldError(); + const escapedRef = useRef(false); + + const commitLiveValue = (next: number | undefined) => { + if (policy.kind === 'required') { + if (next !== undefined) (policy.onPreview ?? policy.onChange)(next); + return; + } + (policy.onPreview ?? policy.onChange)(next); + }; + const { scheduleCommit, cancelPendingCommit } = useAfterPaintValueCommit< + number | undefined + >({ + onCommit: commitLiveValue, + strategy: commitStrategy, + }); + const digitPop = useDigitPop(); + const stepFrame = useFrameCoalescer(); + // draft는 이벤트 사이의 권위값, rendered는 실제 화면 비교 기준 + const draftRef = useRef(localValue); + const renderedRef = useRef(localValue); + const hasUserInputRef = useRef(false); + const holdKeyRef = useRef(null); + const lastEmittedRef = useRef(null); + const stepBaseRef = useRef(0); + const suppressDigitPopRef = useRef(false); + const committedValueRef = useRef(value); + const committedMixedRef = useRef(isMixed); + const emittedRef = useRef(false); + + const syncText = (text: string) => { + draftRef.current = text; + renderedRef.current = text; + setLocalValue(text); + }; + + const emitValue = (next: number | undefined) => { + lastEmittedRef.current = next ?? null; + emittedRef.current = true; + scheduleCommit(next); + }; + + const restorePreview = () => { + if (onCancel) { + onCancel(); + return; + } + if (!emittedRef.current) return; + if (committedMixedRef.current || isMixed) return; + + const committedValue = committedValueRef.current; + if (policy.kind === 'optional') { + (policy.onPreview ?? policy.onChange)( + committedValue as number | undefined, + ); + return; + } + const committed = + typeof committedValue === 'number' + ? committedValue + : Number(committedValue); + if (Number.isFinite(committed)) { + (policy.onPreview ?? policy.onChange)(committed); + } + }; + + const cancelDraft = () => { + const touched = hasUserInputRef.current || emittedRef.current; + cancelPendingCommit(); + const committedValue = committedValueRef.current; + syncText( + committedMixedRef.current || (isOptional && committedValue == null) + ? '' + : getDisplayValue(committedValue!), + ); + hasUserInputRef.current = false; + fieldError.clear(); + digitPop.clear(); + if (touched) restorePreview(); + }; + + const resolveStepBase = (): number => { + if (canParseNumericInput(draftRef.current)) { + return Number(draftRef.current); + } + const typed = sanitizeNumericInput(draftRef.current); + if (canParseNumericInput(typed)) return Number(typed); + + if (policy.kind === 'optional') { + if (policy.value != null) return policy.value; + return parseLeadingNumber(policy.placeholder) ?? 0; + } + const committed = + typeof policy.value === 'number' ? policy.value : Number(policy.value); + return Number.isFinite(committed) ? committed : 0; + }; + + const invokeChange = (next: number | undefined) => { + if (policy.kind === 'required') { + if (next !== undefined) policy.onChange(next); + return; + } + policy.onChange(next); + }; + + const flushStep = () => { + const suppressDigitPop = suppressDigitPopRef.current; + suppressDigitPopRef.current = false; + const parsed = isOptional + ? clampValue(Number(draftRef.current)) + : parseAndClamp(draftRef.current); + const nextText = parsed === null ? draftRef.current : String(parsed); + const prevText = renderedRef.current; + + if (nextText === prevText) { + draftRef.current = nextText; + } else { + if (holdKeyRef.current === null && !suppressDigitPop) { + const prevNumber = isOptional + ? isPartialNumericInput(prevText) + ? stepBaseRef.current + : Number(prevText) + : canParseNumericInput(prevText) + ? Number(prevText) + : stepBaseRef.current; + digitPop.play( + prevText, + nextText, + stepDirection(prevNumber, Number(nextText)), + ); + } + syncText(nextText); + } + + if (parsed === null || parsed === lastEmittedRef.current) return; + emitValue(parsed); + }; + + const stepBy = ( + stepper: (base: number) => number, + repeat: boolean, + key: string, + ): boolean => { + const base = resolveStepBase(); + const stepped = stepper(base); + const next = clampValue(supportsDecimal ? stepped : Math.round(stepped)); + const nextText = String(next); + if (nextText === draftRef.current) { + suppressDigitPopRef.current = false; + return false; + } + + holdKeyRef.current = repeat ? key : null; + stepBaseRef.current = base; + draftRef.current = nextText; + hasUserInputRef.current = true; + + if (repeat) { + stepFrame.schedule(() => flushStepRef.current()); + return true; + } + stepFrame.cancel(); + flushStep(); + return true; + }; + + const flushStepRef = useRef(flushStep); + useLayoutEffect(() => { + flushStepRef.current = flushStep; + }); + + const handleKeyUp = (event: React.KeyboardEvent) => { + if (event.key !== holdKeyRef.current) return; + stepFrame.cancel(); + flushStep(); + holdKeyRef.current = null; + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + if (isExpressionDraft(draftRef.current)) { + const evaluated = evaluateAndClampExpression(draftRef.current); + if (evaluated === null) { + event.preventDefault(); + fieldError.raise(); + return; + } + fieldError.clear(); + digitPop.clear(); + } + event.currentTarget.blur(); + return; + } + if (event.key === 'Escape') { + if (hasUserInputRef.current) event.preventDefault(); + escapedRef.current = true; + event.currentTarget.blur(); + return; + } + + if (!event.ctrlKey && !event.metaKey) { + const customStep = policy.kind === 'required' ? policy.step : undefined; + const stepper = resolveStepper( + event.key, + event, + resolvedDecimalScale, + customStep, + ); + if (stepper !== null) { + event.preventDefault(); + // required 입력의 스크럽 권위 정책 유지 + if (policy.kind === 'required' && scrub.active) return; + if (isStaleRepeat(event.nativeEvent)) return; + if (isExpressionDraft(draftRef.current)) { + const evaluated = evaluateAndClampExpression(draftRef.current); + if (evaluated === null) { + if (!event.repeat) fieldError.raise(); + return; + } + + stepFrame.cancel(); + holdKeyRef.current = null; + syncText(String(evaluated)); + hasUserInputRef.current = true; + fieldError.clear(); + digitPop.clear(); + suppressDigitPopRef.current = true; + const stepped = stepBy(stepper, event.repeat, event.key); + if (!stepped && evaluated !== lastEmittedRef.current) { + emitValue(evaluated); + } + return; + } + stepBy(stepper, event.repeat, event.key); + return; + } + } + + const allowedKeys = [ + 'Backspace', + 'Delete', + 'ArrowLeft', + 'ArrowRight', + 'Tab', + 'Home', + 'End', + ]; + if (allowedKeys.includes(event.key)) return; + if (event.ctrlKey || event.metaKey) return; + if (/^[0-9+\-*/()]$/.test(event.key) || event.key === ' ') return; + if (event.key === '.' || event.key === 'Decimal') return; + event.preventDefault(); + }; + + const handleChange = (event: React.ChangeEvent) => { + stepFrame.cancel(); + holdKeyRef.current = null; + suppressDigitPopRef.current = false; + + const newValue = normalizeDraftInput(event.target.value); + if (newValue === null) { + event.currentTarget.value = draftRef.current; + return; + } + + syncText(newValue); + hasUserInputRef.current = true; + fieldError.clear(); + + if (policy.kind === 'optional') { + if (newValue === '') { + emitValue(undefined); + return; + } + if (isPartialNumericInput(newValue) || isExpressionDraft(newValue)) { + return; + } + const numericValue = Number(newValue); + if (!Number.isFinite(numericValue)) return; + emitValue( + clampValue(supportsDecimal ? numericValue : Math.round(numericValue)), + ); + return; + } + + if (isExpressionDraft(newValue)) return; + const clamped = parseAndClamp(newValue); + if (clamped !== null) emitValue(clamped); + }; + + const handleFocus = () => { + setIsFocused(true); + hasUserInputRef.current = false; + escapedRef.current = false; + lastEmittedRef.current = null; + emittedRef.current = false; + fieldError.clear(); + committedValueRef.current = value; + committedMixedRef.current = isMixed; + + if (policy.kind === 'optional') { + syncText( + !isMixed && policy.value != null + ? String(normalizePrecision(policy.value)) + : '', + ); + return; + } + if (isMixed) { + syncText(''); + return; + } + syncText( + typeof policy.value === 'number' + ? String(normalizePrecision(policy.value)) + : String(policy.value), + ); + }; + + const draftTextFor = (numericValue: number): string => + isFocused + ? String(normalizePrecision(numericValue)) + : getDisplayValue(numericValue); + + const scrubEnabled = + hasPrefix && + Boolean(onPreview) && + (policy.kind === 'optional' || !policy.disabled); + const scrub = useScrubDrag({ + enabled: scrubEnabled, + resolveBase: () => { + digitPop.clear(); + if (isFocused && isExpressionDraft(draftRef.current)) { + const evaluated = evaluateAndClampExpression(draftRef.current); + if (evaluated === null) { + fieldError.raise(); + return null; + } + syncText(String(evaluated)); + hasUserInputRef.current = true; + fieldError.clear(); + digitPop.clear(); + return evaluated; + } + return resolveStepBase(); + }, + step: policy.kind === 'required' ? policy.step ?? 1 : 1, + range: { min: domainMin, max }, + quantize: (raw) => clampValue(supportsDecimal ? raw : Math.round(raw)), + ownsFocus: (active, handle) => + handle.parentElement?.contains(active) ?? false, + onMove: (next) => { + stepFrame.cancel(); + holdKeyRef.current = null; + syncText(draftTextFor(next)); + hasUserInputRef.current = true; + emitValue(next); + }, + onCommit: (final) => { + cancelPendingCommit(); + syncText(draftTextFor(final)); + hasUserInputRef.current = false; + committedValueRef.current = final; + committedMixedRef.current = false; + emittedRef.current = false; + lastEmittedRef.current = null; + invokeChange(final); + }, + onCancel: () => { + cancelPendingCommit(); + const committed = committedValueRef.current; + syncText( + committedMixedRef.current || (isOptional && committed == null) + ? '' + : draftTextFor(Number(committed)), + ); + hasUserInputRef.current = false; + fieldError.clear(); + digitPop.clear(); + restorePreview(); + }, + }); + + useEffect(() => { + if (!isFocused && !scrub.active) { + syncText( + isMixed || (isOptional && value == null) ? '' : getDisplayValue(value!), + ); + hasUserInputRef.current = false; + committedValueRef.current = value; + committedMixedRef.current = isMixed; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value, isFocused, isMixed]); + + const handleBlur = () => { + setIsFocused(false); + stepFrame.cancel(); + holdKeyRef.current = null; + + if (scrub.cancel()) return; + if (escapedRef.current) { + escapedRef.current = false; + cancelDraft(); + return; + } + + if (!hasUserInputRef.current) { + syncText( + isMixed || (isOptional && value == null) ? '' : getDisplayValue(value!), + ); + fieldError.clear(); + onBlur?.(); + return; + } + + if (policy.kind === 'optional' && draftRef.current === '') { + syncText(''); + cancelPendingCommit(); + policy.onChange(undefined); + hasUserInputRef.current = false; + fieldError.clear(); + digitPop.clear(); + policy.onBlur?.(); + return; + } + + if (isExpressionDraft(draftRef.current)) { + const evaluated = evaluateAndClampExpression(draftRef.current); + if (evaluated === null) { + cancelDraft(); + return; + } + + syncText(getDisplayValue(evaluated)); + if (policy.kind === 'required') { + hasUserInputRef.current = false; + fieldError.clear(); + digitPop.clear(); + cancelPendingCommit(); + policy.onChange(evaluated); + policy.onBlur?.(evaluated); + return; + } + cancelPendingCommit(); + policy.onChange(evaluated); + hasUserInputRef.current = false; + fieldError.clear(); + digitPop.clear(); + policy.onBlur?.(evaluated); + return; + } + + if (policy.kind === 'optional') { + const cleaned = draftRef.current; + if (isPartialNumericInput(cleaned) || !Number.isFinite(Number(cleaned))) { + cancelDraft(); + return; + } + const parsed = Number(cleaned); + const clamped = clampValue(supportsDecimal ? parsed : Math.round(parsed)); + syncText(getDisplayValue(clamped)); + cancelPendingCommit(); + policy.onChange(clamped); + hasUserInputRef.current = false; + policy.onBlur?.(clamped); + return; + } + + const clamped = parseAndClamp(draftRef.current); + if (clamped === null) { + cancelDraft(); + return; + } + syncText(getDisplayValue(clamped)); + cancelPendingCommit(); + policy.onChange(clamped); + hasUserInputRef.current = false; + fieldError.clear(); + policy.onBlur?.(clamped ?? undefined); + }; + + return { + localValue, + isFocused, + supportsDecimal, + fieldError, + digitPop, + scrub, + scrubEnabled, + handleChange, + handleKeyDown, + handleKeyUp, + handleFocus, + handleBlur, + }; +}; From 3a48c57301b76f7708d4e187f1e4ba893dd32490 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 16:21:13 +0900 Subject: [PATCH 124/245] =?UTF-8?q?refactor:=20=EC=98=A4=EB=B2=84=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=20=ED=9E=88=ED=8A=B8=20=ED=94=8C=EB=9E=AB=ED=8F=BC=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/services/overlay_hit.rs | 936 +----------------- .../services/overlay_hit/platform/fallback.rs | 20 + .../services/overlay_hit/platform/macos.rs | 431 ++++++++ .../services/overlay_hit/platform/windows.rs | 465 +++++++++ 4 files changed, 922 insertions(+), 930 deletions(-) create mode 100644 src-tauri/src/services/overlay_hit/platform/fallback.rs create mode 100644 src-tauri/src/services/overlay_hit/platform/macos.rs create mode 100644 src-tauri/src/services/overlay_hit/platform/windows.rs diff --git a/src-tauri/src/services/overlay_hit.rs b/src-tauri/src/services/overlay_hit.rs index e47eafe26..7e9fed53d 100644 --- a/src-tauri/src/services/overlay_hit.rs +++ b/src-tauri/src/services/overlay_hit.rs @@ -520,940 +520,16 @@ fn clip_hit_rect_to_bounds( } #[cfg(target_os = "macos")] -mod platform { - use std::{ - os::raw::c_void, - panic::{catch_unwind, AssertUnwindSafe}, - }; - - use anyhow::{anyhow, Context, Result}; - use cocoa::{ - appkit::{NSBackingStoreBuffered, NSEvent, NSEventType}, - base::{id, nil, BOOL, NO, YES}, - foundation::{NSInteger, NSPoint, NSRect, NSSize}, - }; - use objc::{ - class, - declare::ClassDecl, - msg_send, - runtime::{Class, Object, Sel}, - sel, sel_impl, - }; - use once_cell::sync::Lazy; - use tauri::{AppHandle, Emitter, WebviewWindow}; - - use super::{ - clip_hit_rect_to_bounds, hit_region_status, HitRegionStatus, OverlayHitContextMenuPayload, - OverlayHitDesiredState, OverlayHitRect, OVERLAY_LABEL, - }; - - const HIT_CONTEXT_IVAR: &str = "dmNoteOverlayHitContext"; - const NS_NONACTIVATING_PANEL_MASK: u64 = 1 << 7; - const NS_WINDOW_ABOVE: i64 = 1; - - #[derive(Default)] - pub(super) struct NativeState { - panels: Vec, - } - - struct HitPanel { - panel: usize, - view: usize, - context: Box, - } - - struct HitContext { - app: AppHandle, - parent: usize, - rect: OverlayHitRect, - active: bool, - } - - struct ObjectiveCClass(&'static Class); - - unsafe impl Send for ObjectiveCClass {} - unsafe impl Sync for ObjectiveCClass {} - - static HIT_PANEL_CLASS: Lazy = Lazy::new(|| unsafe { - if let Some(class) = Class::get("DmNoteOverlayHitPanel") { - return ObjectiveCClass(class); - } - let superclass = class!(NSPanel); - let mut declaration = - ClassDecl::new("DmNoteOverlayHitPanel", superclass).expect("hit panel class"); - declaration.add_method( - sel!(canBecomeKeyWindow), - never_becomes_key as extern "C" fn(&Object, Sel) -> BOOL, - ); - declaration.add_method( - sel!(canBecomeMainWindow), - never_becomes_key as extern "C" fn(&Object, Sel) -> BOOL, - ); - ObjectiveCClass(declaration.register()) - }); - - static HIT_VIEW_CLASS: Lazy = Lazy::new(|| unsafe { - if let Some(class) = Class::get("DmNoteOverlayHitView") { - return ObjectiveCClass(class); - } - let superclass = class!(NSView); - let mut declaration = - ClassDecl::new("DmNoteOverlayHitView", superclass).expect("hit view class"); - declaration.add_method( - sel!(acceptsFirstMouse:), - accepts_first_mouse as extern "C" fn(&Object, Sel, id) -> BOOL, - ); - declaration.add_method( - sel!(acceptsFirstResponder), - never_becomes_key as extern "C" fn(&Object, Sel) -> BOOL, - ); - declaration.add_method( - sel!(mouseDown:), - mouse_down as extern "C" fn(&Object, Sel, id), - ); - declaration.add_method( - sel!(rightMouseUp:), - right_mouse_up as extern "C" fn(&Object, Sel, id), - ); - declaration.add_method( - sel!(resetCursorRects), - reset_cursor_rects as extern "C" fn(&Object, Sel), - ); - declaration.add_ivar::<*mut c_void>(HIT_CONTEXT_IVAR); - ObjectiveCClass(declaration.register()) - }); - - extern "C" fn never_becomes_key(_this: &Object, _selector: Sel) -> BOOL { - NO - } - - extern "C" fn accepts_first_mouse(_this: &Object, _selector: Sel, _event: id) -> BOOL { - YES - } - - extern "C" fn mouse_down(this: &Object, _selector: Sel, event: id) { - let _ = catch_unwind(AssertUnwindSafe(|| unsafe { - let cursor: id = msg_send![class!(NSCursor), closedHandCursor]; - let _: () = msg_send![cursor, set]; - // performWindowDragWithEvent는 모달 이벤트 루프를 돈다 - 그 사이 창 이동이 - // reconcile을 재진입시켜 이 컨텍스트를 덮어쓰거나 패널을 해제할 수 있으므로 - // 필요한 값만 복사해 참조를 놓고 호출한다 - let drag_target = hit_context(this) - .filter(|context| context.active) - .map(|context| (context.parent, context.rect)); - if let Some((parent, rect)) = drag_target { - if let Err(error) = start_parent_drag(parent, rect, event) { - log::warn!("failed to start macOS overlay hit drag: {error:#}"); - } - } - let cursor: id = msg_send![class!(NSCursor), openHandCursor]; - let _: () = msg_send![cursor, set]; - })); - } - - extern "C" fn right_mouse_up(this: &Object, _selector: Sel, event: id) { - let _ = catch_unwind(AssertUnwindSafe(|| unsafe { - let Some(context) = hit_context(this) else { - return; - }; - if !context.active { - return; - } - let location: NSPoint = msg_send![event, locationInWindow]; - let payload = OverlayHitContextMenuPayload { - x: context.rect.x + location.x, - y: context.rect.y + context.rect.height - location.y, - }; - if let Err(error) = - context - .app - .emit_to(OVERLAY_LABEL, "overlay-hit-context-menu", payload) - { - log::warn!("failed to emit macOS overlay hit context menu: {error}"); - } - })); - } - - extern "C" fn reset_cursor_rects(this: &Object, _selector: Sel) { - unsafe { - let bounds: NSRect = msg_send![this, bounds]; - let cursor: id = msg_send![class!(NSCursor), openHandCursor]; - let _: () = msg_send![this, addCursorRect: bounds cursor: cursor]; - } - } - - unsafe fn hit_context(this: &Object) -> Option<&HitContext> { - let context: *mut c_void = *this.get_ivar(HIT_CONTEXT_IVAR); - context.cast::().as_ref() - } - - unsafe fn start_parent_drag( - parent: usize, - rect: OverlayHitRect, - source_event: id, - ) -> Result<()> { - if source_event.is_null() || parent == 0 { - return Err(anyhow!("overlay hit drag context is unavailable")); - } - let parent = parent as id; - let content_view: id = msg_send![parent, contentView]; - if content_view.is_null() { - return Err(anyhow!("overlay content view is unavailable")); - } - - let panel_location: NSPoint = msg_send![source_event, locationInWindow]; - let client_x = rect.x + panel_location.x; - let client_y = rect.y + rect.height - panel_location.y; - let content_bounds: NSRect = msg_send![content_view, bounds]; - let content_location = NSPoint::new(client_x, content_bounds.size.height - client_y); - let window_location: NSPoint = - msg_send![content_view, convertPoint: content_location toView: nil]; - let target_window_number: NSInteger = msg_send![parent, windowNumber]; - let modifier_flags = NSEvent::modifierFlags(source_event); - let timestamp = NSEvent::timestamp(source_event); - let event_number = NSEvent::eventNumber(source_event); - let drag_event = NSEvent::mouseEventWithType_location_modifierFlags_timestamp_windowNumber_context_eventNumber_clickCount_pressure_( - nil, - NSEventType::NSLeftMouseDown, - window_location, - modifier_flags, - timestamp, - target_window_number, - nil, - event_number, - 1, - 1.0, - ); - if drag_event.is_null() { - return Err(anyhow!("failed to synthesize overlay drag event")); - } - let _: () = msg_send![parent, performWindowDragWithEvent: drag_event]; - Ok(()) - } - - pub(super) fn parent_identity(overlay: Option<&WebviewWindow>) -> Result> { - let Some(overlay) = overlay else { - return Ok(None); - }; - let parent = overlay - .ns_window() - .context("failed to get overlay NSWindow")? as id; - if parent.is_null() { - return Err(anyhow!("overlay NSWindow is unavailable")); - } - Ok(Some(parent as usize)) - } - - pub(super) fn reconcile( - app: &AppHandle, - overlay: Option<&WebviewWindow>, - desired: &OverlayHitDesiredState, - native: &mut NativeState, - ) -> Result { - let Some(overlay) = overlay else { - hide_panels(native); - return Ok(HitRegionStatus::Applied); - }; - let parent = overlay - .ns_window() - .context("failed to get overlay NSWindow")? as id; - if parent.is_null() { - hide_panels(native); - return Ok(HitRegionStatus::Applied); - } - - let parent_visible: BOOL = unsafe { msg_send![parent, isVisible] }; - // 프론트가 "키 0개"를 확정 보고했으면 콘텐츠 전체를 잡는다 - 모든 키를 숨기거나 - // 커스텀 CSS로 지웠을 때 창을 옮길 수도 우클릭할 수도 없게 되는 것을 막는다. - // 첫 측정 전(last_revision == None)에는 그대로 클릭 통과 - let clipped_rects = if desired.rects.is_empty() { - if desired.last_revision.is_some() { - let (content_width, content_height) = content_size(parent)?; - vec![OverlayHitRect { - x: 0.0, - y: 0.0, - width: content_width, - height: content_height, - }] - } else { - Vec::new() - } - } else { - let (content_width, content_height) = content_size(parent)?; - desired - .rects - .iter() - .filter_map(|rect| clip_hit_rect_to_bounds(*rect, content_width, content_height)) - .collect::>() - }; - let active = - desired.visible && !desired.locked && !clipped_rects.is_empty() && parent_visible != NO; - if !active { - hide_panels(native); - if clipped_rects.is_empty() { - resize_panel_pool(app, parent, &clipped_rects, native)?; - } - if desired.visible && !desired.locked && parent_visible != NO { - return Ok(hit_region_status( - desired.rects.is_empty(), - clipped_rects.len(), - )); - } - return Ok(HitRegionStatus::Applied); - } - resize_panel_pool(app, parent, &clipped_rects, native)?; - - for (panel, rect) in native.panels.iter_mut().zip(&clipped_rects) { - panel.context.parent = parent as usize; - panel.context.rect = *rect; - let frame = panel_frame(parent, rect)?; - unsafe { - let panel_id = panel.panel as id; - let view_id = panel.view as id; - let current_parent: id = msg_send![panel_id, parentWindow]; - if current_parent != parent { - if !current_parent.is_null() { - let _: () = msg_send![current_parent, removeChildWindow: panel_id]; - } - let _: () = - msg_send![parent, addChildWindow: panel_id ordered: NS_WINDOW_ABOVE]; - } - let parent_level: NSInteger = msg_send![parent, level]; - let _: () = msg_send![panel_id, setLevel: parent_level]; - let _: () = msg_send![panel_id, setFrame: frame display: NO]; - let _: () = msg_send![panel_id, setIgnoresMouseEvents: NO]; - let _: () = msg_send![panel_id, invalidateCursorRectsForView: view_id]; - panel.context.active = true; - let _: () = msg_send![panel_id, orderFront: nil]; - } - } - Ok(HitRegionStatus::Applied) - } - - fn content_size(parent: id) -> Result<(f64, f64)> { - unsafe { - let content_view: id = msg_send![parent, contentView]; - if content_view.is_null() { - return Err(anyhow!("overlay content view is unavailable")); - } - let content_bounds: NSRect = msg_send![content_view, bounds]; - Ok((content_bounds.size.width, content_bounds.size.height)) - } - } - - fn resize_panel_pool( - app: &AppHandle, - parent: id, - rects: &[OverlayHitRect], - native: &mut NativeState, - ) -> Result<()> { - while native.panels.len() > rects.len() { - if let Some(panel) = native.panels.pop() { - destroy_panel(panel); - } - } - while native.panels.len() < rects.len() { - let rect = rects[native.panels.len()]; - native.panels.push(create_panel(app, parent, rect)?); - } - Ok(()) - } - - fn create_panel(app: &AppHandle, parent: id, rect: OverlayHitRect) -> Result { - let frame = panel_frame(parent, &rect)?; - let mut context = Box::new(HitContext { - app: app.clone(), - parent: parent as usize, - rect, - active: false, - }); - unsafe { - let panel: id = msg_send![HIT_PANEL_CLASS.0, alloc]; - let panel: id = msg_send![panel, - initWithContentRect: frame - styleMask: NS_NONACTIVATING_PANEL_MASK - backing: NSBackingStoreBuffered - defer: NO - ]; - if panel.is_null() { - return Err(anyhow!("failed to create overlay hit NSPanel")); - } - - let view_frame = NSRect::new(NSPoint::new(0.0, 0.0), frame.size); - let view: id = msg_send![HIT_VIEW_CLASS.0, alloc]; - let view: id = msg_send![view, initWithFrame: view_frame]; - if view.is_null() { - let _: () = msg_send![panel, release]; - return Err(anyhow!("failed to create overlay hit NSView")); - } - let context_pointer = (&mut *context as *mut HitContext).cast::(); - (*view).set_ivar(HIT_CONTEXT_IVAR, context_pointer); - - let clear_color: id = msg_send![class!(NSColor), clearColor]; - let _: () = msg_send![panel, setOpaque: NO]; - let _: () = msg_send![panel, setBackgroundColor: clear_color]; - let _: () = msg_send![panel, setHasShadow: NO]; - let _: () = msg_send![panel, setReleasedWhenClosed: NO]; - let _: () = msg_send![panel, setHidesOnDeactivate: NO]; - let _: () = msg_send![panel, setIgnoresMouseEvents: NO]; - let _: () = msg_send![view, setAlphaValue: 0.0_f64]; - let _: () = msg_send![panel, setContentView: view]; - let _: () = msg_send![view, release]; - let _: () = msg_send![parent, addChildWindow: panel ordered: NS_WINDOW_ABOVE]; - - Ok(HitPanel { - panel: panel as usize, - view: view as usize, - context, - }) - } - } - - fn panel_frame(parent: id, rect: &OverlayHitRect) -> Result { - unsafe { - let content_view: id = msg_send![parent, contentView]; - if content_view.is_null() { - return Err(anyhow!("overlay content view is unavailable")); - } - let content_bounds: NSRect = msg_send![content_view, bounds]; - let local_frame = NSRect::new( - NSPoint::new(rect.x, content_bounds.size.height - rect.y - rect.height), - NSSize::new(rect.width, rect.height), - ); - let window_frame: NSRect = - msg_send![content_view, convertRect: local_frame toView: nil]; - let screen_frame: NSRect = msg_send![parent, convertRectToScreen: window_frame]; - Ok(screen_frame) - } - } - - fn hide_panels(native: &mut NativeState) { - for panel in &mut native.panels { - panel.context.parent = 0; - panel.context.active = false; - unsafe { - let panel_id = panel.panel as id; - let _: () = msg_send![panel_id, setIgnoresMouseEvents: YES]; - let _: () = msg_send![panel_id, orderOut: nil]; - } - } - } - - fn destroy_panel(panel: HitPanel) { - unsafe { - let panel_id = panel.panel as id; - let view_id = panel.view as id; - (*view_id).set_ivar(HIT_CONTEXT_IVAR, std::ptr::null_mut::()); - let parent: id = msg_send![panel_id, parentWindow]; - if !parent.is_null() { - let _: () = msg_send![parent, removeChildWindow: panel_id]; - } - let _: () = msg_send![panel_id, orderOut: nil]; - let _: () = msg_send![panel_id, close]; - let _: () = msg_send![panel_id, release]; - } - } - - #[cfg(test)] - pub(super) fn register_classes_for_test() { - Lazy::force(&HIT_PANEL_CLASS); - Lazy::force(&HIT_VIEW_CLASS); - } -} +#[path = "overlay_hit/platform/macos.rs"] +mod platform; #[cfg(target_os = "windows")] -mod platform { - use std::{ - ffi::c_void, - mem::size_of, - panic::{catch_unwind, AssertUnwindSafe}, - sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}, - }; - - use anyhow::{anyhow, Context, Result}; - use parking_lot::RwLock; - use tauri::{AppHandle, Emitter, WebviewWindow}; - use windows::{ - core::{w, Error as WindowsError}, - Win32::{ - Foundation::{ - GetLastError, ERROR_CLASS_ALREADY_EXISTS, HINSTANCE, HWND, LPARAM, LRESULT, POINT, - RECT, WPARAM, - }, - Graphics::Gdi::{ - ClientToScreen, CombineRgn, CreateRectRgn, DeleteObject, SetWindowRgn, - ValidateRect, HGDIOBJ, RGN_OR, - }, - System::LibraryLoader::GetModuleHandleW, - UI::{ - HiDpi::GetDpiForWindow, - Input::KeyboardAndMouse::ReleaseCapture, - Shell::{DefSubclassProc, RemoveWindowSubclass, SetWindowSubclass}, - WindowsAndMessaging::{ - CreateWindowExW, DefWindowProcW, DestroyWindow, GetClientRect, GetCursorPos, - GetWindowLongPtrW, IsWindow, IsWindowVisible, LoadCursorW, PostMessageW, - RegisterClassExW, SetCursor, SetWindowLongPtrW, SetWindowPos, ShowWindow, - CREATESTRUCTW, GWLP_USERDATA, HTCAPTION, HWND_NOTOPMOST, HWND_TOPMOST, - IDC_SIZEALL, MA_NOACTIVATE, SWP_NOACTIVATE, SWP_NOOWNERZORDER, SWP_SHOWWINDOW, - SW_HIDE, WM_CLOSE, WM_DPICHANGED, WM_ERASEBKGND, WM_LBUTTONDOWN, - WM_MOUSEACTIVATE, WM_NCCREATE, WM_NCDESTROY, WM_NCLBUTTONDOWN, WM_PAINT, - WM_RBUTTONUP, WM_SETCURSOR, WM_WINDOWPOSCHANGED, WNDCLASSEXW, WS_EX_NOACTIVATE, - WS_EX_NOREDIRECTIONBITMAP, WS_EX_TOOLWINDOW, WS_POPUP, - }, - }, - }, - }; - - use super::{ - hit_region_status, HitRegionStatus, OverlayHitContextMenuPayload, OverlayHitDesiredState, - OverlayHitRect, OVERLAY_LABEL, - }; - - const PARENT_SUBCLASS_ID: usize = 0x444d_4849; - #[derive(Default)] - pub(super) struct NativeState { - context: Option>, - } - - struct HitContext { - app: AppHandle, - parent: AtomicUsize, - hit: AtomicUsize, - rects: RwLock>, - // CSS px -> 물리 px 배율. f64 원자값이 없어 비트 패턴으로 보관 - device_pixel_ratio: AtomicU64, - active: AtomicBool, - always_on_top: AtomicBool, - } - - pub(super) fn parent_identity(overlay: Option<&WebviewWindow>) -> Result> { - let Some(overlay) = overlay else { - return Ok(None); - }; - let parent = overlay.hwnd().context("failed to get overlay HWND")?; - Ok(Some(parent.0 as usize)) - } - - pub(super) fn reconcile( - app: &AppHandle, - overlay: Option<&WebviewWindow>, - desired: &OverlayHitDesiredState, - native: &mut NativeState, - ) -> Result { - let Some(overlay) = overlay else { - hide_native(native); - return Ok(HitRegionStatus::Applied); - }; - let parent = overlay.hwnd().context("failed to get overlay HWND")?; - ensure_native(app, parent, native)?; - let context = native - .context - .as_ref() - .ok_or_else(|| anyhow!("overlay hit HWND context is unavailable"))?; - *context.rects.write() = desired.rects.clone(); - context - .device_pixel_ratio - .store(desired.device_pixel_ratio.to_bits(), Ordering::Release); - // rect가 비어도 측정이 끝났으면 활성 - apply_hit_region이 클라이언트 전체를 잡는다 - context.active.store( - desired.visible && !desired.locked && desired.last_revision.is_some(), - Ordering::Release, - ); - context - .always_on_top - .store(desired.always_on_top, Ordering::Release); - unsafe { sync_hit_window(context) } - } - - fn ensure_native(app: &AppHandle, parent: HWND, native: &mut NativeState) -> Result<()> { - let needs_recreate = native.context.as_ref().is_none_or(|context| unsafe { - context.parent.load(Ordering::Acquire) != parent.0 as usize - || !IsWindow(Some(hwnd(context.hit.load(Ordering::Acquire)))).as_bool() - }); - if !needs_recreate { - return Ok(()); - } - destroy_native(native); - register_hit_window_class()?; - - let mut context = Box::new(HitContext { - app: app.clone(), - parent: AtomicUsize::new(parent.0 as usize), - hit: AtomicUsize::new(0), - rects: RwLock::new(Vec::new()), - device_pixel_ratio: AtomicU64::new(1.0f64.to_bits()), - active: AtomicBool::new(false), - always_on_top: AtomicBool::new(true), - }); - let context_pointer = (&mut *context as *mut HitContext).cast::(); - let module = unsafe { GetModuleHandleW(None) }.context("failed to get module handle")?; - let hit = unsafe { - CreateWindowExW( - WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW | WS_EX_NOREDIRECTIONBITMAP, - w!("DmNoteOverlayHitWindow"), - w!(""), - WS_POPUP, - 0, - 0, - 0, - 0, - Some(parent), - None, - Some(HINSTANCE(module.0)), - Some(context_pointer.cast_const()), - ) - } - .context("failed to create overlay hit HWND")?; - context.hit.store(hit.0 as usize, Ordering::Release); - let installed = unsafe { - SetWindowSubclass( - parent, - Some(parent_subclass_proc), - PARENT_SUBCLASS_ID, - context_pointer as usize, - ) - }; - if !installed.as_bool() { - unsafe { - SetWindowLongPtrW(hit, GWLP_USERDATA, 0); - let _ = DestroyWindow(hit); - } - return Err(anyhow!("failed to subclass overlay HWND")); - } - native.context = Some(context); - Ok(()) - } - - fn register_hit_window_class() -> Result<()> { - let module = unsafe { GetModuleHandleW(None) }.context("failed to get module handle")?; - let class = WNDCLASSEXW { - cbSize: size_of::() as u32, - lpfnWndProc: Some(hit_window_proc), - hInstance: HINSTANCE(module.0), - lpszClassName: w!("DmNoteOverlayHitWindow"), - ..Default::default() - }; - let atom = unsafe { RegisterClassExW(&class) }; - if atom == 0 && unsafe { GetLastError() } != ERROR_CLASS_ALREADY_EXISTS { - return Err(WindowsError::from_win32().into()); - } - Ok(()) - } - - unsafe extern "system" fn hit_window_proc( - window: HWND, - message: u32, - wparam: WPARAM, - lparam: LPARAM, - ) -> LRESULT { - catch_unwind(AssertUnwindSafe(|| unsafe { - if message == WM_NCCREATE { - let create = &*(lparam.0 as *const CREATESTRUCTW); - let context = create.lpCreateParams as *mut HitContext; - SetWindowLongPtrW(window, GWLP_USERDATA, context as isize); - if let Some(context) = context.as_ref() { - context.hit.store(window.0 as usize, Ordering::Release); - } - } - - let context = GetWindowLongPtrW(window, GWLP_USERDATA) as *mut HitContext; - match message { - WM_MOUSEACTIVATE => return LRESULT(MA_NOACTIVATE as isize), - WM_LBUTTONDOWN => { - if let Some(context) = context - .as_ref() - .filter(|context| context.active.load(Ordering::Acquire)) - { - begin_parent_drag(context); - } - return LRESULT(0); - } - WM_RBUTTONUP => { - if let Some(context) = context - .as_ref() - .filter(|context| context.active.load(Ordering::Acquire)) - { - emit_context_menu(context, lparam); - } - return LRESULT(0); - } - WM_SETCURSOR => { - if let Ok(cursor) = LoadCursorW(None, IDC_SIZEALL) { - SetCursor(Some(cursor)); - return LRESULT(1); - } - } - // 작업 표시줄 그룹 종료 등 외부 WM_CLOSE로 히트 창이 사라지면 - // 재생성 트리거(reconcile)가 올 때까지 상호작용이 죽는다 - WM_CLOSE => return LRESULT(0), - WM_ERASEBKGND => return LRESULT(1), - WM_PAINT => { - let _ = ValidateRect(Some(window), None); - return LRESULT(0); - } - WM_NCDESTROY => { - if let Some(context) = context.as_ref() { - context.hit.store(0, Ordering::Release); - } - SetWindowLongPtrW(window, GWLP_USERDATA, 0); - } - _ => {} - } - DefWindowProcW(window, message, wparam, lparam) - })) - .unwrap_or_else(|_| DefWindowProcW(window, message, wparam, lparam)) - } - - unsafe extern "system" fn parent_subclass_proc( - window: HWND, - message: u32, - wparam: WPARAM, - lparam: LPARAM, - _subclass_id: usize, - reference_data: usize, - ) -> LRESULT { - catch_unwind(AssertUnwindSafe(|| unsafe { - let context = (reference_data as *mut HitContext).as_ref(); - if let Some(context) = context { - match message { - WM_DPICHANGED | WM_WINDOWPOSCHANGED => { - if let Err(error) = sync_hit_window(context) { - log::warn!("failed to follow overlay HWND: {error:#}"); - } - } - WM_NCDESTROY => { - context.parent.store(0, Ordering::Release); - let hit = hwnd(context.hit.load(Ordering::Acquire)); - if IsWindow(Some(hit)).as_bool() { - let _ = ShowWindow(hit, SW_HIDE); - } - } - _ => {} - } - } - DefSubclassProc(window, message, wparam, lparam) - })) - .unwrap_or_else(|_| DefSubclassProc(window, message, wparam, lparam)) - } - - unsafe fn sync_hit_window(context: &HitContext) -> Result { - let parent = hwnd(context.parent.load(Ordering::Acquire)); - let hit = hwnd(context.hit.load(Ordering::Acquire)); - if !IsWindow(Some(parent)).as_bool() || !IsWindow(Some(hit)).as_bool() { - return Ok(HitRegionStatus::Applied); - } - if !context.active.load(Ordering::Acquire) || !IsWindowVisible(parent).as_bool() { - let _ = ShowWindow(hit, SW_HIDE); - return Ok(HitRegionStatus::Applied); - } - - let mut client = RECT::default(); - GetClientRect(parent, &mut client).context("failed to read overlay client rect")?; - let width = client.right - client.left; - let height = client.bottom - client.top; - if width <= 0 || height <= 0 { - let _ = ShowWindow(hit, SW_HIDE); - return Ok(HitRegionStatus::Applied); - } - let mut origin = POINT::default(); - if !ClientToScreen(parent, &mut origin).as_bool() { - return Err(WindowsError::from_win32().into()); - } - - if !apply_hit_region(context, hit, width, height)? { - let _ = ShowWindow(hit, SW_HIDE); - return Ok(HitRegionStatus::FullyClipped); - } - let insert_after = if context.always_on_top.load(Ordering::Acquire) { - HWND_TOPMOST - } else { - HWND_NOTOPMOST - }; - SetWindowPos( - hit, - Some(insert_after), - origin.x, - origin.y, - width, - height, - SWP_NOACTIVATE | SWP_NOOWNERZORDER | SWP_SHOWWINDOW, - ) - .context("failed to position overlay hit HWND")?; - Ok(HitRegionStatus::Applied) - } - - unsafe fn apply_hit_region( - context: &HitContext, - hit: HWND, - client_width: i32, - client_height: i32, - ) -> Result { - let union = CreateRectRgn(0, 0, 0, 0); - if union.0.is_null() { - return Err(WindowsError::from_win32().into()); - } - // 웹뷰 실측 배율(devicePixelRatio)을 쓴다 - WebView2 보정 줌이 곱해져 있어 - // GetDpiForWindow/96으로는 접근성 텍스트 배율 사용자에서 좌표가 어긋난다. - // (emit_context_menu의 dpi 나눗셈은 LogicalPosition 계약이라 별개 - 함께 바꾸지 말 것) - let scale = f64::from_bits(context.device_pixel_ratio.load(Ordering::Acquire)); - let scale = if scale.is_finite() && scale > 0.0 { - scale - } else { - f64::from(GetDpiForWindow(hwnd( - context.parent.load(Ordering::Acquire), - ))) / 96.0 - }; - let scale = if scale.is_finite() && scale > 0.0 { - scale - } else { - 1.0 - }; - let mut region_count = 0usize; - let measured_rects = context.rects.read(); - // 측정 결과가 통째로 비었는지와, 클리핑으로 전부 날아갔는지는 다르다. - // 후자는 리사이즈·DPI 변경 직후의 과도기라 창 전체를 잡으면 안 된다 - let measured_empty = measured_rects.is_empty(); - for rect in measured_rects.iter() { - let left = (rect.x * scale).floor().clamp(0.0, f64::from(client_width)) as i32; - let top = (rect.y * scale) - .floor() - .clamp(0.0, f64::from(client_height)) as i32; - let right = ((rect.x + rect.width) * scale) - .ceil() - .clamp(0.0, f64::from(client_width)) as i32; - let bottom = ((rect.y + rect.height) * scale) - .ceil() - .clamp(0.0, f64::from(client_height)) as i32; - if right <= left || bottom <= top { - continue; - } - let part = CreateRectRgn(left, top, right, bottom); - if part.0.is_null() { - let _ = DeleteObject(HGDIOBJ(union.0)); - return Err(WindowsError::from_win32().into()); - } - let _ = CombineRgn(Some(union), Some(union), Some(part), RGN_OR); - let _ = DeleteObject(HGDIOBJ(part.0)); - region_count += 1; - } - if region_count == 0 { - if !measured_empty { - // 옛 rect가 새 클라이언트 밖으로 밀린 과도기 - 다음 측정까지 숨긴다 - let _ = DeleteObject(HGDIOBJ(union.0)); - return Ok(false); - } - // 측정 결과가 "키 0개" - 창 전체를 잡아 이식 전 동작으로 폴백한다 - let full = CreateRectRgn(0, 0, client_width, client_height); - if full.0.is_null() { - let _ = DeleteObject(HGDIOBJ(union.0)); - return Err(WindowsError::from_win32().into()); - } - let _ = CombineRgn(Some(union), Some(union), Some(full), RGN_OR); - let _ = DeleteObject(HGDIOBJ(full.0)); - } - if SetWindowRgn(hit, Some(union), false) == 0 { - let _ = DeleteObject(HGDIOBJ(union.0)); - return Err(WindowsError::from_win32().into()); - } - Ok(hit_region_status(measured_empty, region_count) == HitRegionStatus::Applied) - } - - unsafe fn begin_parent_drag(context: &HitContext) { - let parent = hwnd(context.parent.load(Ordering::Acquire)); - if !IsWindow(Some(parent)).as_bool() { - return; - } - let mut cursor = POINT::default(); - if GetCursorPos(&mut cursor).is_err() { - return; - } - let _ = ReleaseCapture(); - let packed = ((cursor.y as u32 & 0xffff) << 16) | (cursor.x as u32 & 0xffff); - let _ = PostMessageW( - Some(parent), - WM_NCLBUTTONDOWN, - WPARAM(HTCAPTION as usize), - LPARAM(packed as isize), - ); - } - - unsafe fn emit_context_menu(context: &HitContext, lparam: LPARAM) { - let parent = hwnd(context.parent.load(Ordering::Acquire)); - let dpi = if IsWindow(Some(parent)).as_bool() { - GetDpiForWindow(parent).max(96) - } else { - 96 - }; - let scale = f64::from(dpi) / 96.0; - let x = f64::from(lparam.0 as i16) / scale; - let y = f64::from((lparam.0 >> 16) as i16) / scale; - let payload = OverlayHitContextMenuPayload { x, y }; - if let Err(error) = context - .app - .emit_to(OVERLAY_LABEL, "overlay-hit-context-menu", payload) - { - log::warn!("failed to emit Windows overlay hit context menu: {error}"); - } - } - - fn hide_native(native: &mut NativeState) { - if let Some(context) = native.context.as_ref() { - context.active.store(false, Ordering::Release); - let hit = hwnd(context.hit.load(Ordering::Acquire)); - unsafe { - if IsWindow(Some(hit)).as_bool() { - let _ = ShowWindow(hit, SW_HIDE); - } - } - } - } - - fn destroy_native(native: &mut NativeState) { - let Some(context) = native.context.take() else { - return; - }; - let parent = hwnd(context.parent.load(Ordering::Acquire)); - let hit = hwnd(context.hit.load(Ordering::Acquire)); - unsafe { - if IsWindow(Some(parent)).as_bool() { - let _ = - RemoveWindowSubclass(parent, Some(parent_subclass_proc), PARENT_SUBCLASS_ID); - } - if IsWindow(Some(hit)).as_bool() { - SetWindowLongPtrW(hit, GWLP_USERDATA, 0); - let _ = DestroyWindow(hit); - } - } - } - - fn hwnd(value: usize) -> HWND { - HWND(value as *mut c_void) - } -} +#[path = "overlay_hit/platform/windows.rs"] +mod platform; #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] -mod platform { - use anyhow::Result; - use tauri::{AppHandle, WebviewWindow}; - - use super::{HitRegionStatus, OverlayHitDesiredState}; - - #[derive(Default)] - pub(super) struct NativeState; - - pub(super) fn parent_identity(overlay: Option<&WebviewWindow>) -> Result> { - Ok(overlay.map(|_| 1)) - } - - pub(super) fn reconcile( - _app: &AppHandle, - _overlay: Option<&WebviewWindow>, - _desired: &OverlayHitDesiredState, - _native: &mut NativeState, - ) -> Result { - Ok(HitRegionStatus::Applied) - } -} +#[path = "overlay_hit/platform/fallback.rs"] +mod platform; #[cfg(test)] mod tests { diff --git a/src-tauri/src/services/overlay_hit/platform/fallback.rs b/src-tauri/src/services/overlay_hit/platform/fallback.rs new file mode 100644 index 000000000..56d5b324f --- /dev/null +++ b/src-tauri/src/services/overlay_hit/platform/fallback.rs @@ -0,0 +1,20 @@ +use anyhow::Result; +use tauri::{AppHandle, WebviewWindow}; + +use super::{HitRegionStatus, OverlayHitDesiredState}; + +#[derive(Default)] +pub(super) struct NativeState; + +pub(super) fn parent_identity(overlay: Option<&WebviewWindow>) -> Result> { + Ok(overlay.map(|_| 1)) +} + +pub(super) fn reconcile( + _app: &AppHandle, + _overlay: Option<&WebviewWindow>, + _desired: &OverlayHitDesiredState, + _native: &mut NativeState, +) -> Result { + Ok(HitRegionStatus::Applied) +} diff --git a/src-tauri/src/services/overlay_hit/platform/macos.rs b/src-tauri/src/services/overlay_hit/platform/macos.rs new file mode 100644 index 000000000..698ac8078 --- /dev/null +++ b/src-tauri/src/services/overlay_hit/platform/macos.rs @@ -0,0 +1,431 @@ +use std::{ + os::raw::c_void, + panic::{catch_unwind, AssertUnwindSafe}, +}; + +use anyhow::{anyhow, Context, Result}; +use cocoa::{ + appkit::{NSBackingStoreBuffered, NSEvent, NSEventType}, + base::{id, nil, BOOL, NO, YES}, + foundation::{NSInteger, NSPoint, NSRect, NSSize}, +}; +use objc::{ + class, + declare::ClassDecl, + msg_send, + runtime::{Class, Object, Sel}, + sel, sel_impl, +}; +use once_cell::sync::Lazy; +use tauri::{AppHandle, Emitter, WebviewWindow}; + +use super::{ + clip_hit_rect_to_bounds, hit_region_status, HitRegionStatus, OverlayHitContextMenuPayload, + OverlayHitDesiredState, OverlayHitRect, OVERLAY_LABEL, +}; + +const HIT_CONTEXT_IVAR: &str = "dmNoteOverlayHitContext"; +const NS_NONACTIVATING_PANEL_MASK: u64 = 1 << 7; +const NS_WINDOW_ABOVE: i64 = 1; + +#[derive(Default)] +pub(super) struct NativeState { + panels: Vec, +} + +struct HitPanel { + panel: usize, + view: usize, + context: Box, +} + +struct HitContext { + app: AppHandle, + parent: usize, + rect: OverlayHitRect, + active: bool, +} + +struct ObjectiveCClass(&'static Class); + +unsafe impl Send for ObjectiveCClass {} +unsafe impl Sync for ObjectiveCClass {} + +static HIT_PANEL_CLASS: Lazy = Lazy::new(|| unsafe { + if let Some(class) = Class::get("DmNoteOverlayHitPanel") { + return ObjectiveCClass(class); + } + let superclass = class!(NSPanel); + let mut declaration = + ClassDecl::new("DmNoteOverlayHitPanel", superclass).expect("hit panel class"); + declaration.add_method( + sel!(canBecomeKeyWindow), + never_becomes_key as extern "C" fn(&Object, Sel) -> BOOL, + ); + declaration.add_method( + sel!(canBecomeMainWindow), + never_becomes_key as extern "C" fn(&Object, Sel) -> BOOL, + ); + ObjectiveCClass(declaration.register()) +}); + +static HIT_VIEW_CLASS: Lazy = Lazy::new(|| unsafe { + if let Some(class) = Class::get("DmNoteOverlayHitView") { + return ObjectiveCClass(class); + } + let superclass = class!(NSView); + let mut declaration = + ClassDecl::new("DmNoteOverlayHitView", superclass).expect("hit view class"); + declaration.add_method( + sel!(acceptsFirstMouse:), + accepts_first_mouse as extern "C" fn(&Object, Sel, id) -> BOOL, + ); + declaration.add_method( + sel!(acceptsFirstResponder), + never_becomes_key as extern "C" fn(&Object, Sel) -> BOOL, + ); + declaration.add_method( + sel!(mouseDown:), + mouse_down as extern "C" fn(&Object, Sel, id), + ); + declaration.add_method( + sel!(rightMouseUp:), + right_mouse_up as extern "C" fn(&Object, Sel, id), + ); + declaration.add_method( + sel!(resetCursorRects), + reset_cursor_rects as extern "C" fn(&Object, Sel), + ); + declaration.add_ivar::<*mut c_void>(HIT_CONTEXT_IVAR); + ObjectiveCClass(declaration.register()) +}); + +extern "C" fn never_becomes_key(_this: &Object, _selector: Sel) -> BOOL { + NO +} + +extern "C" fn accepts_first_mouse(_this: &Object, _selector: Sel, _event: id) -> BOOL { + YES +} + +extern "C" fn mouse_down(this: &Object, _selector: Sel, event: id) { + let _ = catch_unwind(AssertUnwindSafe(|| unsafe { + let cursor: id = msg_send![class!(NSCursor), closedHandCursor]; + let _: () = msg_send![cursor, set]; + // performWindowDragWithEvent는 모달 이벤트 루프를 돈다 - 그 사이 창 이동이 + // reconcile을 재진입시켜 이 컨텍스트를 덮어쓰거나 패널을 해제할 수 있으므로 + // 필요한 값만 복사해 참조를 놓고 호출한다 + let drag_target = hit_context(this) + .filter(|context| context.active) + .map(|context| (context.parent, context.rect)); + if let Some((parent, rect)) = drag_target { + if let Err(error) = start_parent_drag(parent, rect, event) { + log::warn!("failed to start macOS overlay hit drag: {error:#}"); + } + } + let cursor: id = msg_send![class!(NSCursor), openHandCursor]; + let _: () = msg_send![cursor, set]; + })); +} + +extern "C" fn right_mouse_up(this: &Object, _selector: Sel, event: id) { + let _ = catch_unwind(AssertUnwindSafe(|| unsafe { + let Some(context) = hit_context(this) else { + return; + }; + if !context.active { + return; + } + let location: NSPoint = msg_send![event, locationInWindow]; + let payload = OverlayHitContextMenuPayload { + x: context.rect.x + location.x, + y: context.rect.y + context.rect.height - location.y, + }; + if let Err(error) = context + .app + .emit_to(OVERLAY_LABEL, "overlay-hit-context-menu", payload) + { + log::warn!("failed to emit macOS overlay hit context menu: {error}"); + } + })); +} + +extern "C" fn reset_cursor_rects(this: &Object, _selector: Sel) { + unsafe { + let bounds: NSRect = msg_send![this, bounds]; + let cursor: id = msg_send![class!(NSCursor), openHandCursor]; + let _: () = msg_send![this, addCursorRect: bounds cursor: cursor]; + } +} + +unsafe fn hit_context(this: &Object) -> Option<&HitContext> { + let context: *mut c_void = *this.get_ivar(HIT_CONTEXT_IVAR); + context.cast::().as_ref() +} + +unsafe fn start_parent_drag(parent: usize, rect: OverlayHitRect, source_event: id) -> Result<()> { + if source_event.is_null() || parent == 0 { + return Err(anyhow!("overlay hit drag context is unavailable")); + } + let parent = parent as id; + let content_view: id = msg_send![parent, contentView]; + if content_view.is_null() { + return Err(anyhow!("overlay content view is unavailable")); + } + + let panel_location: NSPoint = msg_send![source_event, locationInWindow]; + let client_x = rect.x + panel_location.x; + let client_y = rect.y + rect.height - panel_location.y; + let content_bounds: NSRect = msg_send![content_view, bounds]; + let content_location = NSPoint::new(client_x, content_bounds.size.height - client_y); + let window_location: NSPoint = + msg_send![content_view, convertPoint: content_location toView: nil]; + let target_window_number: NSInteger = msg_send![parent, windowNumber]; + let modifier_flags = NSEvent::modifierFlags(source_event); + let timestamp = NSEvent::timestamp(source_event); + let event_number = NSEvent::eventNumber(source_event); + let drag_event = NSEvent::mouseEventWithType_location_modifierFlags_timestamp_windowNumber_context_eventNumber_clickCount_pressure_( + nil, + NSEventType::NSLeftMouseDown, + window_location, + modifier_flags, + timestamp, + target_window_number, + nil, + event_number, + 1, + 1.0, + ); + if drag_event.is_null() { + return Err(anyhow!("failed to synthesize overlay drag event")); + } + let _: () = msg_send![parent, performWindowDragWithEvent: drag_event]; + Ok(()) +} + +pub(super) fn parent_identity(overlay: Option<&WebviewWindow>) -> Result> { + let Some(overlay) = overlay else { + return Ok(None); + }; + let parent = overlay + .ns_window() + .context("failed to get overlay NSWindow")? as id; + if parent.is_null() { + return Err(anyhow!("overlay NSWindow is unavailable")); + } + Ok(Some(parent as usize)) +} + +pub(super) fn reconcile( + app: &AppHandle, + overlay: Option<&WebviewWindow>, + desired: &OverlayHitDesiredState, + native: &mut NativeState, +) -> Result { + let Some(overlay) = overlay else { + hide_panels(native); + return Ok(HitRegionStatus::Applied); + }; + let parent = overlay + .ns_window() + .context("failed to get overlay NSWindow")? as id; + if parent.is_null() { + hide_panels(native); + return Ok(HitRegionStatus::Applied); + } + + let parent_visible: BOOL = unsafe { msg_send![parent, isVisible] }; + // 프론트가 "키 0개"를 확정 보고했으면 콘텐츠 전체를 잡는다 - 모든 키를 숨기거나 + // 커스텀 CSS로 지웠을 때 창을 옮길 수도 우클릭할 수도 없게 되는 것을 막는다. + // 첫 측정 전(last_revision == None)에는 그대로 클릭 통과 + let clipped_rects = if desired.rects.is_empty() { + if desired.last_revision.is_some() { + let (content_width, content_height) = content_size(parent)?; + vec![OverlayHitRect { + x: 0.0, + y: 0.0, + width: content_width, + height: content_height, + }] + } else { + Vec::new() + } + } else { + let (content_width, content_height) = content_size(parent)?; + desired + .rects + .iter() + .filter_map(|rect| clip_hit_rect_to_bounds(*rect, content_width, content_height)) + .collect::>() + }; + let active = + desired.visible && !desired.locked && !clipped_rects.is_empty() && parent_visible != NO; + if !active { + hide_panels(native); + if clipped_rects.is_empty() { + resize_panel_pool(app, parent, &clipped_rects, native)?; + } + if desired.visible && !desired.locked && parent_visible != NO { + return Ok(hit_region_status( + desired.rects.is_empty(), + clipped_rects.len(), + )); + } + return Ok(HitRegionStatus::Applied); + } + resize_panel_pool(app, parent, &clipped_rects, native)?; + + for (panel, rect) in native.panels.iter_mut().zip(&clipped_rects) { + panel.context.parent = parent as usize; + panel.context.rect = *rect; + let frame = panel_frame(parent, rect)?; + unsafe { + let panel_id = panel.panel as id; + let view_id = panel.view as id; + let current_parent: id = msg_send![panel_id, parentWindow]; + if current_parent != parent { + if !current_parent.is_null() { + let _: () = msg_send![current_parent, removeChildWindow: panel_id]; + } + let _: () = msg_send![parent, addChildWindow: panel_id ordered: NS_WINDOW_ABOVE]; + } + let parent_level: NSInteger = msg_send![parent, level]; + let _: () = msg_send![panel_id, setLevel: parent_level]; + let _: () = msg_send![panel_id, setFrame: frame display: NO]; + let _: () = msg_send![panel_id, setIgnoresMouseEvents: NO]; + let _: () = msg_send![panel_id, invalidateCursorRectsForView: view_id]; + panel.context.active = true; + let _: () = msg_send![panel_id, orderFront: nil]; + } + } + Ok(HitRegionStatus::Applied) +} + +fn content_size(parent: id) -> Result<(f64, f64)> { + unsafe { + let content_view: id = msg_send![parent, contentView]; + if content_view.is_null() { + return Err(anyhow!("overlay content view is unavailable")); + } + let content_bounds: NSRect = msg_send![content_view, bounds]; + Ok((content_bounds.size.width, content_bounds.size.height)) + } +} + +fn resize_panel_pool( + app: &AppHandle, + parent: id, + rects: &[OverlayHitRect], + native: &mut NativeState, +) -> Result<()> { + while native.panels.len() > rects.len() { + if let Some(panel) = native.panels.pop() { + destroy_panel(panel); + } + } + while native.panels.len() < rects.len() { + let rect = rects[native.panels.len()]; + native.panels.push(create_panel(app, parent, rect)?); + } + Ok(()) +} + +fn create_panel(app: &AppHandle, parent: id, rect: OverlayHitRect) -> Result { + let frame = panel_frame(parent, &rect)?; + let mut context = Box::new(HitContext { + app: app.clone(), + parent: parent as usize, + rect, + active: false, + }); + unsafe { + let panel: id = msg_send![HIT_PANEL_CLASS.0, alloc]; + let panel: id = msg_send![panel, + initWithContentRect: frame + styleMask: NS_NONACTIVATING_PANEL_MASK + backing: NSBackingStoreBuffered + defer: NO + ]; + if panel.is_null() { + return Err(anyhow!("failed to create overlay hit NSPanel")); + } + + let view_frame = NSRect::new(NSPoint::new(0.0, 0.0), frame.size); + let view: id = msg_send![HIT_VIEW_CLASS.0, alloc]; + let view: id = msg_send![view, initWithFrame: view_frame]; + if view.is_null() { + let _: () = msg_send![panel, release]; + return Err(anyhow!("failed to create overlay hit NSView")); + } + let context_pointer = (&mut *context as *mut HitContext).cast::(); + (*view).set_ivar(HIT_CONTEXT_IVAR, context_pointer); + + let clear_color: id = msg_send![class!(NSColor), clearColor]; + let _: () = msg_send![panel, setOpaque: NO]; + let _: () = msg_send![panel, setBackgroundColor: clear_color]; + let _: () = msg_send![panel, setHasShadow: NO]; + let _: () = msg_send![panel, setReleasedWhenClosed: NO]; + let _: () = msg_send![panel, setHidesOnDeactivate: NO]; + let _: () = msg_send![panel, setIgnoresMouseEvents: NO]; + let _: () = msg_send![view, setAlphaValue: 0.0_f64]; + let _: () = msg_send![panel, setContentView: view]; + let _: () = msg_send![view, release]; + let _: () = msg_send![parent, addChildWindow: panel ordered: NS_WINDOW_ABOVE]; + + Ok(HitPanel { + panel: panel as usize, + view: view as usize, + context, + }) + } +} + +fn panel_frame(parent: id, rect: &OverlayHitRect) -> Result { + unsafe { + let content_view: id = msg_send![parent, contentView]; + if content_view.is_null() { + return Err(anyhow!("overlay content view is unavailable")); + } + let content_bounds: NSRect = msg_send![content_view, bounds]; + let local_frame = NSRect::new( + NSPoint::new(rect.x, content_bounds.size.height - rect.y - rect.height), + NSSize::new(rect.width, rect.height), + ); + let window_frame: NSRect = msg_send![content_view, convertRect: local_frame toView: nil]; + let screen_frame: NSRect = msg_send![parent, convertRectToScreen: window_frame]; + Ok(screen_frame) + } +} + +fn hide_panels(native: &mut NativeState) { + for panel in &mut native.panels { + panel.context.parent = 0; + panel.context.active = false; + unsafe { + let panel_id = panel.panel as id; + let _: () = msg_send![panel_id, setIgnoresMouseEvents: YES]; + let _: () = msg_send![panel_id, orderOut: nil]; + } + } +} + +fn destroy_panel(panel: HitPanel) { + unsafe { + let panel_id = panel.panel as id; + let view_id = panel.view as id; + (*view_id).set_ivar(HIT_CONTEXT_IVAR, std::ptr::null_mut::()); + let parent: id = msg_send![panel_id, parentWindow]; + if !parent.is_null() { + let _: () = msg_send![parent, removeChildWindow: panel_id]; + } + let _: () = msg_send![panel_id, orderOut: nil]; + let _: () = msg_send![panel_id, close]; + let _: () = msg_send![panel_id, release]; + } +} + +#[cfg(test)] +pub(super) fn register_classes_for_test() { + Lazy::force(&HIT_PANEL_CLASS); + Lazy::force(&HIT_VIEW_CLASS); +} diff --git a/src-tauri/src/services/overlay_hit/platform/windows.rs b/src-tauri/src/services/overlay_hit/platform/windows.rs new file mode 100644 index 000000000..2f58a3a59 --- /dev/null +++ b/src-tauri/src/services/overlay_hit/platform/windows.rs @@ -0,0 +1,465 @@ +use std::{ + ffi::c_void, + mem::size_of, + panic::{catch_unwind, AssertUnwindSafe}, + sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}, +}; + +use anyhow::{anyhow, Context, Result}; +use parking_lot::RwLock; +use tauri::{AppHandle, Emitter, WebviewWindow}; +use windows::{ + core::{w, Error as WindowsError}, + Win32::{ + Foundation::{ + GetLastError, ERROR_CLASS_ALREADY_EXISTS, HINSTANCE, HWND, LPARAM, LRESULT, POINT, + RECT, WPARAM, + }, + Graphics::Gdi::{ + ClientToScreen, CombineRgn, CreateRectRgn, DeleteObject, SetWindowRgn, ValidateRect, + HGDIOBJ, RGN_OR, + }, + System::LibraryLoader::GetModuleHandleW, + UI::{ + HiDpi::GetDpiForWindow, + Input::KeyboardAndMouse::ReleaseCapture, + Shell::{DefSubclassProc, RemoveWindowSubclass, SetWindowSubclass}, + WindowsAndMessaging::{ + CreateWindowExW, DefWindowProcW, DestroyWindow, GetClientRect, GetCursorPos, + GetWindowLongPtrW, IsWindow, IsWindowVisible, LoadCursorW, PostMessageW, + RegisterClassExW, SetCursor, SetWindowLongPtrW, SetWindowPos, ShowWindow, + CREATESTRUCTW, GWLP_USERDATA, HTCAPTION, HWND_NOTOPMOST, HWND_TOPMOST, IDC_SIZEALL, + MA_NOACTIVATE, SWP_NOACTIVATE, SWP_NOOWNERZORDER, SWP_SHOWWINDOW, SW_HIDE, + WM_CLOSE, WM_DPICHANGED, WM_ERASEBKGND, WM_LBUTTONDOWN, WM_MOUSEACTIVATE, + WM_NCCREATE, WM_NCDESTROY, WM_NCLBUTTONDOWN, WM_PAINT, WM_RBUTTONUP, WM_SETCURSOR, + WM_WINDOWPOSCHANGED, WNDCLASSEXW, WS_EX_NOACTIVATE, WS_EX_NOREDIRECTIONBITMAP, + WS_EX_TOOLWINDOW, WS_POPUP, + }, + }, + }, +}; + +use super::{ + hit_region_status, HitRegionStatus, OverlayHitContextMenuPayload, OverlayHitDesiredState, + OverlayHitRect, OVERLAY_LABEL, +}; + +const PARENT_SUBCLASS_ID: usize = 0x444d_4849; +#[derive(Default)] +pub(super) struct NativeState { + context: Option>, +} + +struct HitContext { + app: AppHandle, + parent: AtomicUsize, + hit: AtomicUsize, + rects: RwLock>, + // CSS px -> 물리 px 배율. f64 원자값이 없어 비트 패턴으로 보관 + device_pixel_ratio: AtomicU64, + active: AtomicBool, + always_on_top: AtomicBool, +} + +pub(super) fn parent_identity(overlay: Option<&WebviewWindow>) -> Result> { + let Some(overlay) = overlay else { + return Ok(None); + }; + let parent = overlay.hwnd().context("failed to get overlay HWND")?; + Ok(Some(parent.0 as usize)) +} + +pub(super) fn reconcile( + app: &AppHandle, + overlay: Option<&WebviewWindow>, + desired: &OverlayHitDesiredState, + native: &mut NativeState, +) -> Result { + let Some(overlay) = overlay else { + hide_native(native); + return Ok(HitRegionStatus::Applied); + }; + let parent = overlay.hwnd().context("failed to get overlay HWND")?; + ensure_native(app, parent, native)?; + let context = native + .context + .as_ref() + .ok_or_else(|| anyhow!("overlay hit HWND context is unavailable"))?; + *context.rects.write() = desired.rects.clone(); + context + .device_pixel_ratio + .store(desired.device_pixel_ratio.to_bits(), Ordering::Release); + // rect가 비어도 측정이 끝났으면 활성 - apply_hit_region이 클라이언트 전체를 잡는다 + context.active.store( + desired.visible && !desired.locked && desired.last_revision.is_some(), + Ordering::Release, + ); + context + .always_on_top + .store(desired.always_on_top, Ordering::Release); + unsafe { sync_hit_window(context) } +} + +fn ensure_native(app: &AppHandle, parent: HWND, native: &mut NativeState) -> Result<()> { + let needs_recreate = native.context.as_ref().is_none_or(|context| unsafe { + context.parent.load(Ordering::Acquire) != parent.0 as usize + || !IsWindow(Some(hwnd(context.hit.load(Ordering::Acquire)))).as_bool() + }); + if !needs_recreate { + return Ok(()); + } + destroy_native(native); + register_hit_window_class()?; + + let mut context = Box::new(HitContext { + app: app.clone(), + parent: AtomicUsize::new(parent.0 as usize), + hit: AtomicUsize::new(0), + rects: RwLock::new(Vec::new()), + device_pixel_ratio: AtomicU64::new(1.0f64.to_bits()), + active: AtomicBool::new(false), + always_on_top: AtomicBool::new(true), + }); + let context_pointer = (&mut *context as *mut HitContext).cast::(); + let module = unsafe { GetModuleHandleW(None) }.context("failed to get module handle")?; + let hit = unsafe { + CreateWindowExW( + WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW | WS_EX_NOREDIRECTIONBITMAP, + w!("DmNoteOverlayHitWindow"), + w!(""), + WS_POPUP, + 0, + 0, + 0, + 0, + Some(parent), + None, + Some(HINSTANCE(module.0)), + Some(context_pointer.cast_const()), + ) + } + .context("failed to create overlay hit HWND")?; + context.hit.store(hit.0 as usize, Ordering::Release); + let installed = unsafe { + SetWindowSubclass( + parent, + Some(parent_subclass_proc), + PARENT_SUBCLASS_ID, + context_pointer as usize, + ) + }; + if !installed.as_bool() { + unsafe { + SetWindowLongPtrW(hit, GWLP_USERDATA, 0); + let _ = DestroyWindow(hit); + } + return Err(anyhow!("failed to subclass overlay HWND")); + } + native.context = Some(context); + Ok(()) +} + +fn register_hit_window_class() -> Result<()> { + let module = unsafe { GetModuleHandleW(None) }.context("failed to get module handle")?; + let class = WNDCLASSEXW { + cbSize: size_of::() as u32, + lpfnWndProc: Some(hit_window_proc), + hInstance: HINSTANCE(module.0), + lpszClassName: w!("DmNoteOverlayHitWindow"), + ..Default::default() + }; + let atom = unsafe { RegisterClassExW(&class) }; + if atom == 0 && unsafe { GetLastError() } != ERROR_CLASS_ALREADY_EXISTS { + return Err(WindowsError::from_win32().into()); + } + Ok(()) +} + +unsafe extern "system" fn hit_window_proc( + window: HWND, + message: u32, + wparam: WPARAM, + lparam: LPARAM, +) -> LRESULT { + catch_unwind(AssertUnwindSafe(|| unsafe { + if message == WM_NCCREATE { + let create = &*(lparam.0 as *const CREATESTRUCTW); + let context = create.lpCreateParams as *mut HitContext; + SetWindowLongPtrW(window, GWLP_USERDATA, context as isize); + if let Some(context) = context.as_ref() { + context.hit.store(window.0 as usize, Ordering::Release); + } + } + + let context = GetWindowLongPtrW(window, GWLP_USERDATA) as *mut HitContext; + match message { + WM_MOUSEACTIVATE => return LRESULT(MA_NOACTIVATE as isize), + WM_LBUTTONDOWN => { + if let Some(context) = context + .as_ref() + .filter(|context| context.active.load(Ordering::Acquire)) + { + begin_parent_drag(context); + } + return LRESULT(0); + } + WM_RBUTTONUP => { + if let Some(context) = context + .as_ref() + .filter(|context| context.active.load(Ordering::Acquire)) + { + emit_context_menu(context, lparam); + } + return LRESULT(0); + } + WM_SETCURSOR => { + if let Ok(cursor) = LoadCursorW(None, IDC_SIZEALL) { + SetCursor(Some(cursor)); + return LRESULT(1); + } + } + // 작업 표시줄 그룹 종료 등 외부 WM_CLOSE로 히트 창이 사라지면 + // 재생성 트리거(reconcile)가 올 때까지 상호작용이 죽는다 + WM_CLOSE => return LRESULT(0), + WM_ERASEBKGND => return LRESULT(1), + WM_PAINT => { + let _ = ValidateRect(Some(window), None); + return LRESULT(0); + } + WM_NCDESTROY => { + if let Some(context) = context.as_ref() { + context.hit.store(0, Ordering::Release); + } + SetWindowLongPtrW(window, GWLP_USERDATA, 0); + } + _ => {} + } + DefWindowProcW(window, message, wparam, lparam) + })) + .unwrap_or_else(|_| DefWindowProcW(window, message, wparam, lparam)) +} + +unsafe extern "system" fn parent_subclass_proc( + window: HWND, + message: u32, + wparam: WPARAM, + lparam: LPARAM, + _subclass_id: usize, + reference_data: usize, +) -> LRESULT { + catch_unwind(AssertUnwindSafe(|| unsafe { + let context = (reference_data as *mut HitContext).as_ref(); + if let Some(context) = context { + match message { + WM_DPICHANGED | WM_WINDOWPOSCHANGED => { + if let Err(error) = sync_hit_window(context) { + log::warn!("failed to follow overlay HWND: {error:#}"); + } + } + WM_NCDESTROY => { + context.parent.store(0, Ordering::Release); + let hit = hwnd(context.hit.load(Ordering::Acquire)); + if IsWindow(Some(hit)).as_bool() { + let _ = ShowWindow(hit, SW_HIDE); + } + } + _ => {} + } + } + DefSubclassProc(window, message, wparam, lparam) + })) + .unwrap_or_else(|_| DefSubclassProc(window, message, wparam, lparam)) +} + +unsafe fn sync_hit_window(context: &HitContext) -> Result { + let parent = hwnd(context.parent.load(Ordering::Acquire)); + let hit = hwnd(context.hit.load(Ordering::Acquire)); + if !IsWindow(Some(parent)).as_bool() || !IsWindow(Some(hit)).as_bool() { + return Ok(HitRegionStatus::Applied); + } + if !context.active.load(Ordering::Acquire) || !IsWindowVisible(parent).as_bool() { + let _ = ShowWindow(hit, SW_HIDE); + return Ok(HitRegionStatus::Applied); + } + + let mut client = RECT::default(); + GetClientRect(parent, &mut client).context("failed to read overlay client rect")?; + let width = client.right - client.left; + let height = client.bottom - client.top; + if width <= 0 || height <= 0 { + let _ = ShowWindow(hit, SW_HIDE); + return Ok(HitRegionStatus::Applied); + } + let mut origin = POINT::default(); + if !ClientToScreen(parent, &mut origin).as_bool() { + return Err(WindowsError::from_win32().into()); + } + + if !apply_hit_region(context, hit, width, height)? { + let _ = ShowWindow(hit, SW_HIDE); + return Ok(HitRegionStatus::FullyClipped); + } + let insert_after = if context.always_on_top.load(Ordering::Acquire) { + HWND_TOPMOST + } else { + HWND_NOTOPMOST + }; + SetWindowPos( + hit, + Some(insert_after), + origin.x, + origin.y, + width, + height, + SWP_NOACTIVATE | SWP_NOOWNERZORDER | SWP_SHOWWINDOW, + ) + .context("failed to position overlay hit HWND")?; + Ok(HitRegionStatus::Applied) +} + +unsafe fn apply_hit_region( + context: &HitContext, + hit: HWND, + client_width: i32, + client_height: i32, +) -> Result { + let union = CreateRectRgn(0, 0, 0, 0); + if union.0.is_null() { + return Err(WindowsError::from_win32().into()); + } + // 웹뷰 실측 배율(devicePixelRatio)을 쓴다 - WebView2 보정 줌이 곱해져 있어 + // GetDpiForWindow/96으로는 접근성 텍스트 배율 사용자에서 좌표가 어긋난다. + // (emit_context_menu의 dpi 나눗셈은 LogicalPosition 계약이라 별개 - 함께 바꾸지 말 것) + let scale = f64::from_bits(context.device_pixel_ratio.load(Ordering::Acquire)); + let scale = if scale.is_finite() && scale > 0.0 { + scale + } else { + f64::from(GetDpiForWindow(hwnd( + context.parent.load(Ordering::Acquire), + ))) / 96.0 + }; + let scale = if scale.is_finite() && scale > 0.0 { + scale + } else { + 1.0 + }; + let mut region_count = 0usize; + let measured_rects = context.rects.read(); + // 측정 결과가 통째로 비었는지와, 클리핑으로 전부 날아갔는지는 다르다. + // 후자는 리사이즈·DPI 변경 직후의 과도기라 창 전체를 잡으면 안 된다 + let measured_empty = measured_rects.is_empty(); + for rect in measured_rects.iter() { + let left = (rect.x * scale).floor().clamp(0.0, f64::from(client_width)) as i32; + let top = (rect.y * scale) + .floor() + .clamp(0.0, f64::from(client_height)) as i32; + let right = ((rect.x + rect.width) * scale) + .ceil() + .clamp(0.0, f64::from(client_width)) as i32; + let bottom = ((rect.y + rect.height) * scale) + .ceil() + .clamp(0.0, f64::from(client_height)) as i32; + if right <= left || bottom <= top { + continue; + } + let part = CreateRectRgn(left, top, right, bottom); + if part.0.is_null() { + let _ = DeleteObject(HGDIOBJ(union.0)); + return Err(WindowsError::from_win32().into()); + } + let _ = CombineRgn(Some(union), Some(union), Some(part), RGN_OR); + let _ = DeleteObject(HGDIOBJ(part.0)); + region_count += 1; + } + if region_count == 0 { + if !measured_empty { + // 옛 rect가 새 클라이언트 밖으로 밀린 과도기 - 다음 측정까지 숨긴다 + let _ = DeleteObject(HGDIOBJ(union.0)); + return Ok(false); + } + // 측정 결과가 "키 0개" - 창 전체를 잡아 이식 전 동작으로 폴백한다 + let full = CreateRectRgn(0, 0, client_width, client_height); + if full.0.is_null() { + let _ = DeleteObject(HGDIOBJ(union.0)); + return Err(WindowsError::from_win32().into()); + } + let _ = CombineRgn(Some(union), Some(union), Some(full), RGN_OR); + let _ = DeleteObject(HGDIOBJ(full.0)); + } + if SetWindowRgn(hit, Some(union), false) == 0 { + let _ = DeleteObject(HGDIOBJ(union.0)); + return Err(WindowsError::from_win32().into()); + } + Ok(hit_region_status(measured_empty, region_count) == HitRegionStatus::Applied) +} + +unsafe fn begin_parent_drag(context: &HitContext) { + let parent = hwnd(context.parent.load(Ordering::Acquire)); + if !IsWindow(Some(parent)).as_bool() { + return; + } + let mut cursor = POINT::default(); + if GetCursorPos(&mut cursor).is_err() { + return; + } + let _ = ReleaseCapture(); + let packed = ((cursor.y as u32 & 0xffff) << 16) | (cursor.x as u32 & 0xffff); + let _ = PostMessageW( + Some(parent), + WM_NCLBUTTONDOWN, + WPARAM(HTCAPTION as usize), + LPARAM(packed as isize), + ); +} + +unsafe fn emit_context_menu(context: &HitContext, lparam: LPARAM) { + let parent = hwnd(context.parent.load(Ordering::Acquire)); + let dpi = if IsWindow(Some(parent)).as_bool() { + GetDpiForWindow(parent).max(96) + } else { + 96 + }; + let scale = f64::from(dpi) / 96.0; + let x = f64::from(lparam.0 as i16) / scale; + let y = f64::from((lparam.0 >> 16) as i16) / scale; + let payload = OverlayHitContextMenuPayload { x, y }; + if let Err(error) = context + .app + .emit_to(OVERLAY_LABEL, "overlay-hit-context-menu", payload) + { + log::warn!("failed to emit Windows overlay hit context menu: {error}"); + } +} + +fn hide_native(native: &mut NativeState) { + if let Some(context) = native.context.as_ref() { + context.active.store(false, Ordering::Release); + let hit = hwnd(context.hit.load(Ordering::Acquire)); + unsafe { + if IsWindow(Some(hit)).as_bool() { + let _ = ShowWindow(hit, SW_HIDE); + } + } + } +} + +fn destroy_native(native: &mut NativeState) { + let Some(context) = native.context.take() else { + return; + }; + let parent = hwnd(context.parent.load(Ordering::Acquire)); + let hit = hwnd(context.hit.load(Ordering::Acquire)); + unsafe { + if IsWindow(Some(parent)).as_bool() { + let _ = RemoveWindowSubclass(parent, Some(parent_subclass_proc), PARENT_SUBCLASS_ID); + } + if IsWindow(Some(hit)).as_bool() { + SetWindowLongPtrW(hit, GWLP_USERDATA, 0); + let _ = DestroyWindow(hit); + } + } +} + +fn hwnd(value: usize) -> HWND { + HWND(value as *mut c_void) +} From fecea0e3800867d8ab32d58f7359127d6a7b2099 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 16:23:06 +0900 Subject: [PATCH 125/245] =?UTF-8?q?test:=20=EB=B0=B0=EC=B9=98=20=EB=85=B8?= =?UTF-8?q?=ED=8A=B8=20=ED=88=AC=EB=AA=85=EB=8F=84=20=ED=94=BC=EC=BB=A4=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../batchPickerBindingOwnership.test.tsx | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/batchPickerBindingOwnership.test.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/batchPickerBindingOwnership.test.tsx index 848fa0aeb..88bedef49 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/batchPickerBindingOwnership.test.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/batchPickerBindingOwnership.test.tsx @@ -49,6 +49,10 @@ const captured = vi.hoisted(() => ({ ) => void; onOpacityPercentChange?: (value: number) => void; onOpacityPercentChangeComplete?: (value: number) => void; + onOpacityPercentCancel?: () => void; + opacityPercent?: number; + opacityPercentLabel?: string; + hideColorAlpha?: boolean; }, checkboxes: [] as Array<{ checked: boolean; onChange: () => void }>, dropdowns: [] as Array<{ @@ -713,6 +717,67 @@ describe('배치 피커 결합 소유권 (프로덕션 배선)', () => { expect(captured.color?.color).toBe('#112233'); }); + it('배치 단색 노트 피커는 투명도 preview·commit·Escape 복원을 정식 채널로 전달한다', () => { + const canonical = { + ...keyAt(ID_A), + noteColor: '#112233', + noteOpacity: 42, + }; + const props = panelProps(); + props.activeTab = 'note'; + props.batchPickerFor = 'noteColor'; + props.getSelectedKeyOnlyPositions = () => [ + { index: 0, position: canonical }, + ]; + props.getMixedValueCanonical = ((getter: (position: never) => unknown) => ({ + isMixed: false, + value: getter(canonical as never), + })) as PanelProps['getMixedValueCanonical']; + + act(() => { + root.render( + + + , + ); + }); + + expect(captured.color).toMatchObject({ + color: '#112233', + hideColorAlpha: true, + opacityPercent: 42, + opacityPercentLabel: 'keySetting.noteOpacity', + }); + + act(() => captured.color?.onOpacityPercentChange?.(55)); + expect(gestures.preview).toHaveBeenLastCalledWith( + '4key', + [{ id: ID_A, patch: { noteOpacity: 55 } }], + { domain: 'keyPosition' }, + ); + + act(() => captured.color?.onOpacityPercentChangeComplete?.(60)); + expect(patches.patchNotePaintByIds).toHaveBeenLastCalledWith( + [ID_A], + { property: 'notePaint', value: { opacity: 60 } }, + { gestureId: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee' }, + ); + + gestures.cancel.mockClear(); + act(() => captured.color?.onOpacityPercentChange?.(70)); + act(() => captured.color?.onOpacityPercentCancel?.()); + expect(gestures.cancel).toHaveBeenCalledOnce(); + expect(captured.color?.opacityPercent).toBe(42); + }); + it.each(['main'] as const)( '%s batch counter picker는 open 시점 key/stat만 한 exact intent로 보낸다', (windowType) => { From 9ffd61f78efb12b252600ea03372f0aca04e423e Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 16:23:09 +0900 Subject: [PATCH 126/245] =?UTF-8?q?refactor:=20=EB=B0=B0=EC=B9=98=20?= =?UTF-8?q?=EC=83=89=EC=83=81=20=ED=94=BC=EC=BB=A4=20=ED=91=9C=EB=A9=B4=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../batch/BatchColorPickerPopup.tsx | 334 ++++++++++++++++++ .../batch/BatchSelectionPanel.tsx | 295 ++-------------- 2 files changed, 367 insertions(+), 262 deletions(-) create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/batch/BatchColorPickerPopup.tsx diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchColorPickerPopup.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchColorPickerPopup.tsx new file mode 100644 index 000000000..87d595e09 --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchColorPickerPopup.tsx @@ -0,0 +1,334 @@ +import React from 'react'; +import ColorPicker from '@components/main/Modal/content/pickers/ColorPicker'; +import PopupExit from '@components/main/Modal/PopupExit'; +import { editGestureController } from '@src/renderer/editor/runtime/editGestureController'; +import type { + EditorCounterFillPropertyPatchV1, + EditorNotePaintPropertyPatchV1, +} from '@src/types/editor'; +import { + normalizeCounterSettings, + type KeyCounterSettings, + type KeyPosition, + type NoteColor, +} from '@src/types/key/keys'; +import { + hexWithAlphaPercent, + parseAlphaPercent, + toRgbHexColor, +} from '@utils/color/colorUtils'; +import type { + BatchLocalColors, + BatchPickerTarget, + MixedValueGetter, +} from './batchPanelShared'; +import type { BatchNoteSurface, useBatchNotePaint } from './useBatchNotePaint'; + +type BatchNotePaintController = ReturnType; + +interface BatchColorPickerPopupProps { + batchPickerFor: BatchPickerTarget; + setBatchPickerFor: (value: BatchPickerTarget) => void; + openNoteSurface: BatchNoteSurface | null; + batchNotePaint: BatchNotePaintController; + batchCounterSettings: KeyCounterSettings; + batchCounterColorState: 'idle' | 'active'; + setBatchCounterColorState: (value: 'idle' | 'active') => void; + setBatchLocalColors: React.Dispatch>; + handleBatchPickerColorChange: (newColor: NoteColor) => void; + handleBatchPickerColorChangeComplete: (newColor: NoteColor) => void; + handleBatchFillPickerColorChangeComplete: ( + newColor: string, + onCounterFillCommit: (patch: EditorCounterFillPropertyPatchV1) => void, + ) => void; + getBatchPickerColor: () => NoteColor | string; + getBatchPickerRef: () => React.RefObject | null; + batchColorPickerInteractiveRefs: React.RefObject[]; + panelElement: HTMLDivElement | null; + selectedKeyCount: number; + counterFillTargetCount: number; + commitCounterFill?: (patch: EditorCounterFillPropertyPatchV1) => void; + previewNotePaint?: (patch: EditorNotePaintPropertyPatchV1) => void; + commitNotePaint?: (patch: EditorNotePaintPropertyPatchV1) => void; + noteMixedValueGetter: MixedValueGetter; + getMixedValue: MixedValueGetter; + getMixedValueActiveCapable: MixedValueGetter; + getMixedValueCanonical: MixedValueGetter; + t: (key: string) => string | undefined; +} + +const BatchColorPickerPopup = ({ + batchPickerFor, + setBatchPickerFor, + openNoteSurface, + batchNotePaint, + batchCounterSettings, + batchCounterColorState, + setBatchCounterColorState, + setBatchLocalColors, + handleBatchPickerColorChange, + handleBatchPickerColorChangeComplete, + handleBatchFillPickerColorChangeComplete, + getBatchPickerColor, + getBatchPickerRef, + batchColorPickerInteractiveRefs, + panelElement, + selectedKeyCount, + counterFillTargetCount, + commitCounterFill, + previewNotePaint, + commitNotePaint, + noteMixedValueGetter, + getMixedValue, + getMixedValueActiveCapable, + getMixedValueCanonical, + t, +}: BatchColorPickerPopupProps) => { + const noteOpacityMixed = noteMixedValueGetter( + (pos) => pos.noteOpacity, + 80, + ).isMixed; + const glowOpacityMixed = noteMixedValueGetter( + (pos) => pos.noteGlowOpacity, + 70, + ).isMixed; + + // 열린 피커의 hex 칸과 % 칸의 독립 Mixed 판정 + const batchPickerMixed = ((): { hex: boolean; alpha: boolean } => { + const paintHex = (value: NoteColor | undefined) => + typeof value === 'string' ? toRgbHexColor(value) : value; + if ( + openNoteSurface && + batchNotePaint.states[openNoteSurface].format === 'gradient' + ) { + return { hex: false, alpha: false }; + } + switch (batchPickerFor) { + case 'noteColor': + return { + hex: noteMixedValueGetter((pos) => paintHex(pos.noteColor), '#FFFFFF') + .isMixed, + alpha: noteOpacityMixed, + }; + case 'glowColor': + return { + hex: noteMixedValueGetter( + (pos) => paintHex(pos.noteGlowColor ?? pos.noteColor), + '#FFFFFF', + ).isMixed, + alpha: glowOpacityMixed, + }; + case 'borderColor': + return { + hex: noteMixedValueGetter( + (pos) => toRgbHexColor(pos.noteBorderColor), + '', + ).isMixed, + alpha: noteMixedValueGetter((pos) => pos.noteBorderOpacity, 100) + .isMixed, + }; + case 'fill': { + // 입력 상태 색은 통계를 편집하지 않으므로 Mixed도 같은 집합으로 + const state = batchCounterColorState === 'active' ? 'active' : 'idle'; + const mixedFn = + state === 'active' ? getMixedValueActiveCapable : getMixedValue; + const colorOf = (pos: KeyPosition) => + normalizeCounterSettings(pos.counter).fill[state]; + return { + hex: mixedFn((pos) => toRgbHexColor(colorOf(pos)), '').isMixed, + alpha: mixedFn((pos) => parseAlphaPercent(colorOf(pos)), 100).isMixed, + }; + } + default: + return { hex: false, alpha: false }; + } + })(); + + return ( + + {batchPickerFor ? ( + { + if (openNoteSurface) { + if (typeof color !== 'string') return; + if ( + openNoteSurface === 'border' && + batchNotePaint.states.border.format !== 'gradient' + ) { + batchNotePaint.previewBorderSolid(color); + return; + } + batchNotePaint.activeState.handlePickerColorChange(color, false); + return; + } + handleBatchPickerColorChange(color); + }} + onColorChangeComplete={(color) => { + if (openNoteSurface) { + if (typeof color !== 'string') return; + if ( + openNoteSurface === 'border' && + batchNotePaint.states.border.format !== 'gradient' + ) { + batchNotePaint.commitBorderSolid(color); + return; + } + batchNotePaint.activeState.handlePickerColorChange(color, true); + return; + } + if ( + commitCounterFill && + batchPickerFor === 'fill' && + typeof color === 'string' + ) { + handleBatchFillPickerColorChangeComplete( + color, + commitCounterFill, + ); + return; + } + if (batchPickerFor === 'fill' && counterFillTargetCount === 0) { + return; + } + handleBatchPickerColorChangeComplete(color); + }} + onClose={() => setBatchPickerFor(null)} + interactiveRefs={batchColorPickerInteractiveRefs} + solidOnly={true} + stateMode={ + batchPickerFor === 'fill' && selectedKeyCount > 0 + ? batchCounterColorState + : undefined + } + onStateModeChange={ + batchPickerFor === 'fill' && selectedKeyCount > 0 + ? setBatchCounterColorState + : undefined + } + onInputCancel={(_target, restoredColor) => { + if (openNoteSurface) { + if (typeof restoredColor !== 'string') return; + const state = batchNotePaint.states[openNoteSurface]; + if (openNoteSurface === 'border' && state.format !== 'gradient') { + batchNotePaint.previewBorderSolid(restoredColor); + } else { + state.handlePickerColorChange(restoredColor, false); + } + editGestureController.cancel(); + return; + } + if (batchPickerFor === 'fill') { + const state = + batchCounterColorState === 'active' ? 'active' : 'idle'; + setBatchLocalColors((prev) => ({ + ...prev, + [state === 'active' ? 'fillActive' : 'fillIdle']: + batchCounterSettings.fill[state], + })); + } + }} + hexMixed={batchPickerMixed.hex} + opacityPercentMixed={batchPickerMixed.alpha} + headerSlot={ + openNoteSurface ? batchNotePaint.activeState.headerSlot : undefined + } + footerSlot={ + openNoteSurface ? batchNotePaint.activeState.footerSlot : undefined + } + gradientSpec={ + openNoteSurface + ? batchNotePaint.activeState.paletteGradientSpec + : undefined + } + onGradientSpecSelect={ + openNoteSurface + ? batchNotePaint.activeState.handleGradientSpecSelect + : undefined + } + {...((openNoteSurface === 'note' || openNoteSurface === 'glow') && + batchNotePaint.states[openNoteSurface].format !== 'gradient' + ? { + // 단색 형식의 색 알파는 저장 시 hex 변환으로 버려지므로 항상 숨김 + hideColorAlpha: true, + } + : {})} + {...((openNoteSurface === 'note' || openNoteSurface === 'glow') && + batchNotePaint.states[openNoteSurface].format !== 'gradient' && + !batchNotePaint.anyPresented[openNoteSurface] + ? { + // 전부 단색인 선택의 외부 투명도 조절기 + opacityPercent: + openNoteSurface === 'note' + ? batchNotePaint.noteOpacity + : batchNotePaint.glowOpacity, + onOpacityPercentChange: (value: number) => { + if (openNoteSurface === 'note') { + batchNotePaint.setNoteOpacity(value); + previewNotePaint?.({ + property: 'notePaint', + value: { opacity: value }, + }); + return; + } + batchNotePaint.setGlowOpacity(value); + previewNotePaint?.({ + property: 'noteGlowPaint', + value: { opacity: value }, + }); + }, + onOpacityPercentChangeComplete: (value: number) => { + const surface = openNoteSurface; + if (surface === 'note') { + batchNotePaint.setNoteOpacity(value); + } else { + batchNotePaint.setGlowOpacity(value); + } + // 단색 형식의 {opacity} 단독 커밋 + commitNotePaint?.({ + property: + surface === 'note' ? 'notePaint' : 'noteGlowPaint', + value: { opacity: value }, + }); + }, + onOpacityPercentCancel: () => { + // Escape 게스처 취소와 canonical 대표값 복원 + editGestureController.cancel(); + if (openNoteSurface === 'note') { + batchNotePaint.setNoteOpacity( + getMixedValueCanonical((pos) => pos.noteOpacity, 80) + .value, + ); + return; + } + batchNotePaint.setGlowOpacity( + getMixedValueCanonical((pos) => pos.noteGlowOpacity, 70) + .value, + ); + }, + opacityPercentLabel: + openNoteSurface === 'note' + ? t('keySetting.noteOpacity') || '노트 투명도' + : t('keySetting.noteGlowOpacity') || '글로우 투명도', + } + : {})} + /> + ) : null} + + ); +}; + +export default BatchColorPickerPopup; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx index 8bc2622d5..10108a43b 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx @@ -16,8 +16,6 @@ import { TABS, TabType, } from '../index'; -import ColorPicker from '@components/main/Modal/content/pickers/ColorPicker'; -import PopupExit from '@components/main/Modal/PopupExit'; import EditSessionBoundary from '../EditSessionBoundary'; import { patchCounterAnimationEnabledByTargets, @@ -47,15 +45,12 @@ import type { EditorCounterFillPropertyPatchV1, } from '@src/types/editor'; import { projectNotePaintPatch } from '@src/types/key/notePaint'; -import { - hexWithAlphaPercent, - parseAlphaPercent, - toRgbHexColor, -} from '@utils/color/colorUtils'; +import { hexWithAlphaPercent } from '@utils/color/colorUtils'; import type { BatchElementPropertyUpdate } from '../types'; import { useBatchNotePaint, type BatchNoteSurface } from './useBatchNotePaint'; import BatchPanelHeader from './BatchPanelHeader'; import BatchImagePickerPopup from './BatchImagePickerPopup'; +import BatchColorPickerPopup from './BatchColorPickerPopup'; import BatchGraphSettingsSection from './BatchGraphSettingsSection'; import { createBatchGraphSettingsModel } from './batchGraphSettingsModel'; import { @@ -597,11 +592,6 @@ export const BatchKeyLikePanel: React.FC = ({ // NOTE 탭은 키만 편집하므로 Mixed도 키 기준. 통계가 섞인 선택에서 통계 값이 Mixed를 만들지 않게 const noteMixedFn = selectedKeyElements.length > 0 ? getMixedValueKeysOnly : getMixedValue; - const noteOpacityMixed = noteMixedFn((pos) => pos.noteOpacity, 80).isMixed; - const glowOpacityMixed = noteMixedFn( - (pos) => pos.noteGlowOpacity, - 70, - ).isMixed; const batchSpacing = getBatchSpacingValue(); const graphSettings = createBatchGraphSettingsModel( getMixedValueGraphs, @@ -618,54 +608,6 @@ export const BatchKeyLikePanel: React.FC = ({ batchCounterColorState, ); - // 열린 배치 피커의 hex 칸·% 칸 Mixed. 두 칸은 따로 판단하고, 저장 표현(대소문자·rgba·hex8)이 - // 달라도 같은 색이면 공통값으로 본다. 그라데이션 형식은 선택 스톱을 편집하므로 칸 Mixed를 두지 않는다 - const batchPickerMixed = ((): { hex: boolean; alpha: boolean } => { - const paintHex = (value: NoteColor | undefined) => - typeof value === 'string' ? toRgbHexColor(value) : value; - if ( - openNoteSurface && - batchNotePaint.states[openNoteSurface].format === 'gradient' - ) { - return { hex: false, alpha: false }; - } - switch (batchPickerFor) { - case 'noteColor': - return { - hex: noteMixedFn((pos) => paintHex(pos.noteColor), '#FFFFFF').isMixed, - alpha: noteOpacityMixed, - }; - case 'glowColor': - return { - hex: noteMixedFn( - (pos) => paintHex(pos.noteGlowColor ?? pos.noteColor), - '#FFFFFF', - ).isMixed, - alpha: glowOpacityMixed, - }; - case 'borderColor': - return { - hex: noteMixedFn((pos) => toRgbHexColor(pos.noteBorderColor), '') - .isMixed, - alpha: noteMixedFn((pos) => pos.noteBorderOpacity, 100).isMixed, - }; - case 'fill': { - // 입력 상태 색은 통계를 편집하지 않으므로 Mixed도 같은 집합으로 - const state = batchCounterColorState === 'active' ? 'active' : 'idle'; - const mixedFn = - state === 'active' ? getMixedValueActiveCapable : getMixedValue; - const colorOf = (pos: KeyPosition) => - normalizeCounterSettings(pos.counter).fill[state]; - return { - hex: mixedFn((pos) => toRgbHexColor(colorOf(pos)), '').isMixed, - alpha: mixedFn((pos) => parseAlphaPercent(colorOf(pos)), 100).isMixed, - }; - } - default: - return { hex: false, alpha: false }; - } - })(); - const getCounterColorDisplay = (target: 'fill') => { const key = batchCounterColorState === 'active' ? 'fillActive' : 'fillIdle'; @@ -846,208 +788,37 @@ export const BatchKeyLikePanel: React.FC = ({
- {/* 배치 편집용 로컬 ColorPicker */} - - {batchPickerFor ? ( - { - if (openNoteSurface) { - if (typeof color !== 'string') return; - if ( - openNoteSurface === 'border' && - batchNotePaint.states.border.format !== 'gradient' - ) { - batchNotePaint.previewBorderSolid(color); - return; - } - batchNotePaint.activeState.handlePickerColorChange( - color, - false, - ); - return; - } - handleBatchPickerColorChange(color); - }} - onColorChangeComplete={(color) => { - if (openNoteSurface) { - if (typeof color !== 'string') return; - if ( - openNoteSurface === 'border' && - batchNotePaint.states.border.format !== 'gradient' - ) { - batchNotePaint.commitBorderSolid(color); - return; - } - batchNotePaint.activeState.handlePickerColorChange( - color, - true, - ); - return; - } - if ( - commitCounterFill && - batchPickerFor === 'fill' && - typeof color === 'string' - ) { - handleBatchFillPickerColorChangeComplete( - color, - commitCounterFill, - ); - return; - } - if ( - batchPickerFor === 'fill' && - counterFillTargets.length === 0 - ) { - return; - } - handleBatchPickerColorChangeComplete(color); - }} - onClose={() => setBatchPickerFor(null)} - interactiveRefs={batchColorPickerInteractiveRefs} - solidOnly={true} - stateMode={ - batchPickerFor === 'fill' && selectedKeyElements.length > 0 - ? batchCounterColorState - : undefined - } - onStateModeChange={ - batchPickerFor === 'fill' && selectedKeyElements.length > 0 - ? setBatchCounterColorState - : undefined - } - onInputCancel={(_target, restoredColor) => { - if (openNoteSurface) { - if (typeof restoredColor !== 'string') return; - const state = batchNotePaint.states[openNoteSurface]; - if ( - openNoteSurface === 'border' && - state.format !== 'gradient' - ) { - batchNotePaint.previewBorderSolid(restoredColor); - } else { - state.handlePickerColorChange(restoredColor, false); - } - editGestureController.cancel(); - return; - } - if (batchPickerFor === 'fill') { - const state = - batchCounterColorState === 'active' ? 'active' : 'idle'; - setBatchLocalColors((prev) => ({ - ...prev, - [state === 'active' ? 'fillActive' : 'fillIdle']: - batchCounterSettings.fill[state], - })); - } - }} - hexMixed={batchPickerMixed.hex} - opacityPercentMixed={batchPickerMixed.alpha} - headerSlot={ - openNoteSurface - ? batchNotePaint.activeState.headerSlot - : undefined - } - footerSlot={ - openNoteSurface - ? batchNotePaint.activeState.footerSlot - : undefined - } - gradientSpec={ - openNoteSurface - ? batchNotePaint.activeState.paletteGradientSpec - : undefined - } - onGradientSpecSelect={ - openNoteSurface - ? batchNotePaint.activeState.handleGradientSpecSelect - : undefined - } - {...((openNoteSurface === 'note' || openNoteSurface === 'glow') && - batchNotePaint.states[openNoteSurface].format !== 'gradient' - ? { - // 단색 형식의 색 알파는 저장 시 hex 변환으로 버려지므로 항상 숨긴다. - // 그라데이션 형식은 스톱 알파만 편집하므로 조절기를 두지 않는다 - hideColorAlpha: true, - } - : {})} - {...((openNoteSurface === 'note' || openNoteSurface === 'glow') && - batchNotePaint.states[openNoteSurface].format !== 'gradient' && - !batchNotePaint.anyPresented[openNoteSurface] - ? { - // 전부 단색인 선택에서만 투명도 조절기가 알파를 대신한다 - opacityPercent: - openNoteSurface === 'note' - ? batchNotePaint.noteOpacity - : batchNotePaint.glowOpacity, - onOpacityPercentChange: (value: number) => { - if (openNoteSurface === 'note') { - batchNotePaint.setNoteOpacity(value); - previewNotePaint?.({ - property: 'notePaint', - value: { opacity: value }, - }); - return; - } - batchNotePaint.setGlowOpacity(value); - previewNotePaint?.({ - property: 'noteGlowPaint', - value: { opacity: value }, - }); - }, - onOpacityPercentChangeComplete: (value: number) => { - const surface = openNoteSurface; - if (surface === 'note') { - batchNotePaint.setNoteOpacity(value); - } else { - batchNotePaint.setGlowOpacity(value); - } - // 단색 형식은 기존 배치 규약대로 {opacity} 단독 커밋 유지 - commitNotePaint?.({ - property: - surface === 'note' ? 'notePaint' : 'noteGlowPaint', - value: { opacity: value }, - }); - }, - onOpacityPercentCancel: () => { - // Escape는 게스처를 통째로 되돌린다. 로컬 대표값도 canonical에서 - // 다시 읽어야 입력이 blur 뒤 옛 preview 값으로 재동기화되지 않는다 - editGestureController.cancel(); - if (openNoteSurface === 'note') { - batchNotePaint.setNoteOpacity( - getMixedValueCanonical((pos) => pos.noteOpacity, 80) - .value, - ); - return; - } - batchNotePaint.setGlowOpacity( - getMixedValueCanonical((pos) => pos.noteGlowOpacity, 70) - .value, - ); - }, - opacityPercentLabel: - openNoteSurface === 'note' - ? t('keySetting.noteOpacity') || '노트 투명도' - : t('keySetting.noteGlowOpacity') || '글로우 투명도', - } - : {})} - /> - ) : null} - + {/* 다중 선택용 ImagePicker */} Date: Sun, 30 Aug 2026 16:26:48 +0900 Subject: [PATCH 127/245] =?UTF-8?q?test:=20=ED=94=8C=EB=9F=AC=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=9D=B8=EC=8A=A4=ED=84=B4=EC=8A=A4=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EB=AA=A8=EB=8B=AC=20=EA=B3=84=EC=95=BD=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/defineElement.modalHandlers.test.ts | 252 +++++++++++++++++- 1 file changed, 251 insertions(+), 1 deletion(-) diff --git a/src/renderer/plugins/runtime/api/defineElement.modalHandlers.test.ts b/src/renderer/plugins/runtime/api/defineElement.modalHandlers.test.ts index 133cbc65b..e92618803 100644 --- a/src/renderer/plugins/runtime/api/defineElement.modalHandlers.test.ts +++ b/src/renderer/plugins/runtime/api/defineElement.modalHandlers.test.ts @@ -1,6 +1,10 @@ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createCheckbox } from '@utils/plugin/pluginComponents'; +import { + createCheckbox, + createDropdown, + createInput, +} from '@utils/plugin/pluginComponents'; import { clearComponentHandlers } from '@utils/plugin/pluginUtils'; import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; import { registerPluginInstancesEditSessionFlush } from '../displayElement/instancesCommitQueue'; @@ -201,4 +205,250 @@ describe('defineElement modal handler lifecycle', () => { flush.mock.invocationCallOrder[2], ); }); + + it('설정 타입별 preview·commit과 visibility·picker·cancel 계약을 보존한다', async () => { + const flush = vi.fn(); + cleanups.push(registerPluginInstancesEditSessionFlush('plugin-a', flush)); + const element = { + fullId: 'plugin-a::one', + definitionId: 'plugin-a', + pluginId: 'plugin-a', + settings: { + enabled: false, + nickname: 'short', + amount: 2, + mode: 'one', + color: '#111111', + }, + state: {}, + position: { x: 0, y: 0 }, + html: '', + } as unknown as PluginDisplayElementInternal; + usePluginDisplayElementStore.setState({ elements: [element] }); + + let menuItem: + | { onClick: (context: unknown) => unknown | Promise } + | undefined; + let settleDialog: ((confirmed: boolean) => void) | undefined; + const dialogRoot = document.createElement('div'); + document.body.appendChild(dialogRoot); + cleanups.push(() => dialogRoot.remove()); + const custom = vi.fn( + (html: string) => + new Promise((resolve) => { + dialogRoot.innerHTML = html; + settleDialog = resolve; + }), + ); + const addElement = vi.fn(); + const update = vi.fn(); + const pickColor = vi.fn(); + const normalizationError = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + createDefineElement({ + pluginId: 'plugin-a', + api: { + settings: { get: vi.fn().mockResolvedValue({ language: 'en' }) }, + ui: { + dialog: { custom }, + components: { + checkbox: createCheckbox, + input: createInput, + dropdown: createDropdown, + }, + pickColor, + displayElement: { update, add: addElement }, + contextMenu: { + addGridMenuItem: vi.fn( + (item: { onClick: (context: unknown) => unknown }) => { + menuItem = item; + return 'menu-1'; + }, + ), + removeMenuItem: vi.fn(), + }, + }, + }, + registerCleanup: (cleanup: () => void) => cleanups.push(cleanup), + wrapFunctionWithContext: (fn) => fn, + isReloading: () => false, + waitForReloadEnd: vi.fn().mockResolvedValue(undefined), + } as never)({ + name: 'Example', + settingsUI: 'modal', + settings: { + appearance: { type: 'section', label: 'Appearance' }, + enabled: { type: 'boolean', default: false, label: 'Enabled' }, + nickname: { + type: 'string', + default: '', + label: 'Nickname', + visible: (settings) => settings.enabled === true, + }, + amount: { + type: 'number', + default: 0, + label: 'Amount', + min: 0, + max: 10, + step: 1, + }, + mode: { + type: 'select', + default: 'one', + label: 'Mode', + options: [ + { label: 'One', value: 'one' }, + { label: 'Two', value: 'two' }, + ], + }, + color: { type: 'color', default: '#000000', label: 'Color' }, + broken: { + type: 'string', + default: 'broken', + label: 'Broken', + visible: () => { + throw new Error('visibility failed'); + }, + }, + }, + template: () => '', + }); + + await menuItem?.onClick({ position: { dx: 0, dy: 0 } }); + const addConfig = addElement.mock.calls[0]?.[0] as { + onClick?: (event: Event) => unknown; + }; + update.mockClear(); + flush.mockClear(); + const opening = addConfig.onClick?.({ + currentTarget: { getAttribute: () => element.fullId }, + } as unknown as Event) as Promise; + await vi.waitFor(() => expect(custom).toHaveBeenCalledOnce()); + + expect(custom).toHaveBeenCalledWith( + expect.stringContaining( + 'data-settings-section="plugin-element-plugin-a-plugin-a%3A%3Aone-0"', + ), + { + showCancel: true, + confirmText: 'Apply', + cancelText: 'Cancel', + }, + ); + const entries = Array.from( + dialogRoot.querySelectorAll('[data-settings-entry]'), + ); + expect(entries).toHaveLength(6); + expect(entries[1]?.style.display).toBe('none'); + expect(entries[5]?.style.display).toBe('none'); + + const querySelectorAll = vi.spyOn(document, 'querySelectorAll'); + const checkbox = entries[0]?.querySelector( + 'input[type="checkbox"]', + ); + const checkboxHandler = entries[0] + ?.querySelector('[data-plugin-handler-change]') + ?.getAttribute('data-plugin-handler-change'); + window[checkboxHandler as `__dmn_handler_${string}`]?.({ + target: { checked: true }, + } as unknown as Event); + + expect(update).toHaveBeenLastCalledWith(element.fullId, { + settings: expect.objectContaining({ enabled: true }), + }); + expect(entries[1]?.style.display).toBe(''); + expect(querySelectorAll.mock.calls.map(([selector]) => selector)).toEqual([ + '[data-settings-section]', + '[data-settings-entry]', + '[data-settings-entry]', + '[data-settings-entry]', + '[data-settings-entry]', + '[data-settings-entry]', + '[data-settings-entry]', + '[data-settings-empty]', + ]); + expect(checkbox).not.toBeNull(); + querySelectorAll.mockRestore(); + + const numberInput = entries[2]?.querySelector( + 'input[type="number"]', + ); + const numberHandler = numberInput?.getAttribute( + 'data-plugin-handler-change', + ); + window[numberHandler as `__dmn_handler_${string}`]?.({ + target: { value: '99' }, + } as unknown as Event); + expect(update).toHaveBeenLastCalledWith(element.fullId, { + settings: expect.objectContaining({ amount: 10 }), + }); + + const dropdown = entries[3]?.querySelector( + '[data-plugin-handler-change]', + ); + const dropdownHandler = dropdown?.getAttribute( + 'data-plugin-handler-change', + ); + window[dropdownHandler as `__dmn_handler_${string}`]?.({ + target: { + getAttribute: (name: string) => + name === 'data-selected' ? 'two' : null, + }, + } as unknown as Event); + expect(update).toHaveBeenLastCalledWith(element.fullId, { + settings: expect.objectContaining({ mode: 'two' }), + }); + + const colorButton = entries[4]?.querySelector( + '[data-plugin-handler]', + ); + const colorHandler = colorButton?.getAttribute('data-plugin-handler'); + handlerRegistry.get(colorHandler || '')?.({ + target: colorButton, + } as unknown as Event); + expect(pickColor).toHaveBeenCalledWith( + expect.objectContaining({ + initialColor: '#111111', + id: 'plugin-plugin-a-plugin-a::one-color', + referenceElement: colorButton, + }), + ); + const pickerOptions = pickColor.mock.calls[0]?.[0] as { + onColorChange: (color: string) => void; + onColorChangeComplete: (color: string) => void; + onClose: () => void; + }; + const updateCountBeforePreview = update.mock.calls.length; + pickerOptions.onColorChange('#222222'); + expect( + colorButton?.style.getPropertyValue('--dmn-color-swatch-color'), + ).toBe('#222222'); + expect(update).toHaveBeenCalledTimes(updateCountBeforePreview); + pickerOptions.onColorChangeComplete('#333333'); + expect(update).toHaveBeenLastCalledWith(element.fullId, { + settings: expect.objectContaining({ color: '#333333' }), + }); + expect(colorButton?.classList.contains('shadow-focus-ring')).toBe(true); + pickerOptions.onClose(); + expect(colorButton?.classList.contains('shadow-focus-ring')).toBe(false); + + expect(normalizationError).toHaveBeenCalledTimes(1); + settleDialog?.(false); + await opening; + + expect(update).toHaveBeenLastCalledWith(element.fullId, { + settings: { + enabled: false, + nickname: 'short', + amount: 2, + mode: 'one', + color: '#111111', + broken: 'broken', + }, + }); + expect(flush).toHaveBeenCalledOnce(); + }); }); From f32a3cf41bfa98163e0bc793af8d0446649f1f82 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 16:26:52 +0900 Subject: [PATCH 128/245] =?UTF-8?q?refactor:=20=ED=94=8C=EB=9F=AC=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=9D=B8=EC=8A=A4=ED=84=B4=EC=8A=A4=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EB=AA=A8=EB=8B=AC=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plugins/runtime/api/defineElement.ts | 331 +--------------- .../runtime/api/instanceSettingsDialog.ts | 360 ++++++++++++++++++ 2 files changed, 371 insertions(+), 320 deletions(-) create mode 100644 src/renderer/plugins/runtime/api/instanceSettingsDialog.ts diff --git a/src/renderer/plugins/runtime/api/defineElement.ts b/src/renderer/plugins/runtime/api/defineElement.ts index 40fae2ff6..34b9a0f69 100644 --- a/src/renderer/plugins/runtime/api/defineElement.ts +++ b/src/renderer/plugins/runtime/api/defineElement.ts @@ -44,18 +44,7 @@ import { type PluginRestoreReadiness, } from '../displayElement/instanceLifecycle'; import { - SECTION_WRAPPER_CLASS, - SECTION_LABEL_CLASS, - SECTION_CARD_CLASS, - FORM_ROW_CLASS, - FORM_LABEL_CLASS, -} from '@utils/cardRecipes'; -import { handlerRegistry } from '../handlers'; -import { createPluginDialogHandlerScope } from './pluginDialogHandlers'; -import { - coerceSettingValue, getDefaultSettings, - normalizeSettingsSections, omitLayoutSettingValues, } from '../settingsSections'; import type { @@ -68,6 +57,7 @@ import type { } from '@src/types/plugin/api'; import type { SettingsState } from '@src/types/settings/settings'; import { trackEditorWrite } from '@src/renderer/editor/runtime/editorWriteBarrier'; +import { createInstanceSettingsDialogFactory } from './instanceSettingsDialog'; export interface SavedInstance { // 영구 인스턴스 ID - backfill 전 구데이터는 없을 수 있음 @@ -122,7 +112,11 @@ export const createDefineElement = (deps: DefineElementDependencies) => { isReloading, waitForReloadEnd, } = deps; - const visibilityErrorKeys = new Set(); + const createOpenInstanceSettings = createInstanceSettingsDialogFactory({ + pluginId, + api, + wrapFunctionWithContext, + }); return (definition: PluginDefinition) => { const defId = pluginId; @@ -571,314 +565,11 @@ export const createDefineElement = (deps: DefineElementDependencies) => { const useModalSettings = definition.settingsUI === 'modal'; - const openInstanceSettings = async (instanceId: string) => { - const element = usePluginDisplayElementStore - .getState() - .elements.find((el) => el.fullId === instanceId); - - if (!element) { - console.warn( - `[Plugin ${pluginId}] Cannot find element ${instanceId} for settings`, - ); - return; - } - - const currentSettings: Record = omitLayoutSettingValues( - definition.settings, - { - ...defaultSettings, - ...(element.settings || {}), - }, - ); - const originalSettings = { ...currentSettings }; - - const reportNormalizationError = ( - key: string, - error: unknown, - kind: 'visibility' | 'unsupported-type', - ) => { - if (visibilityErrorKeys.has(key)) return; - visibilityErrorKeys.add(key); - const message = - kind === 'unsupported-type' - ? `Unsupported setting type for "${key}"` - : `Failed to evaluate visibility for setting "${key}"`; - console.error(`[Plugin ${pluginId}] ${message}:`, error); - }; - const getNormalizedSections = () => - normalizeSettingsSections( - definition.settings, - currentSettings, - reportNormalizationError, - ); - const modalScope = `plugin-element-${encodeURIComponent( - pluginId, - )}-${encodeURIComponent(instanceId)}`; - const findModalElement = (attribute: string, value: string) => - Array.from( - document.querySelectorAll(`[${attribute}]`), - ).find((element) => element.getAttribute(attribute) === value); - const updateVisibility = () => { - const sections = getNormalizedSections(); - sections.forEach((section, sectionIndex) => { - const sectionElement = findModalElement( - 'data-settings-section', - `${modalScope}-${sectionIndex}`, - ); - if (sectionElement) { - sectionElement.style.display = section.renderVisible ? '' : 'none'; - } - section.entries.forEach((entry, entryIndex) => { - const entryElement = findModalElement( - 'data-settings-entry', - `${modalScope}-${sectionIndex}-${entryIndex}`, - ); - if (entryElement) { - entryElement.style.display = entry.renderVisible ? '' : 'none'; - } - }); - }); - const emptyElement = findModalElement( - 'data-settings-empty', - modalScope, - ); - if (emptyElement) { - emptyElement.style.display = sections.some( - (section) => section.renderVisible, - ) - ? 'none' - : ''; - } - }; - const commitSettingValue = async ( - key: string, - newValue: string | number | boolean, - ) => { - currentSettings[key] = newValue; - api.ui.displayElement.update(instanceId, { - settings: { ...currentSettings }, - }); - updateVisibility(); - }; - - const modalHandlers = createPluginDialogHandlerScope(); - let htmlContent = ''; - try { - const normalizedSections = getNormalizedSections(); - // 패널(renderPluginSettingsForm)과 동일한 섹션 카드 구조·토큰 - section이 - // 없어도 암시적 카드 하나로 렌더 (모달-패널 외형 통합, 2026-07-12 결정) - htmlContent = '
'; - - for (const [sectionIndex, section] of normalizedSections.entries()) { - htmlContent += `
`; - if (section.label) { - const sectionLabel = translate( - section.label, - undefined, - section.label, - ); - htmlContent += `

${sectionLabel}

`; - } - htmlContent += `
`; - for (const [entryIndex, entry] of section.entries.entries()) { - const { key, schema } = entry; - const entryAttributes = `data-settings-entry="${modalScope}-${sectionIndex}-${entryIndex}" style="${ - entry.renderVisible ? '' : 'display:none' - }"`; - { - const value = - currentSettings[key] !== undefined - ? currentSettings[key] - : schema.default; - let componentHtml = ''; - const labelText = translate( - schema.label, - undefined, - schema.label, - ); - const placeholderText = - typeof schema.placeholder === 'string' - ? translate(schema.placeholder, undefined, schema.placeholder) - : schema.placeholder; - - const wrappedChange = wrapFunctionWithContext((newValue) => { - // DOM 문자열을 스키마 타입으로 복원, 복원 불가면 커밋 스킵 - const coerced = coerceSettingValue(schema, newValue); - if (coerced === null) return; - return commitSettingValue(key, coerced); - }); - - if (schema.type === 'boolean') { - componentHtml = modalHandlers.capture(() => - api.ui.components.checkbox({ - checked: !!value, - onChange: wrappedChange as unknown as ( - checked: boolean, - ) => void | Promise, - }), - ); - } else if (schema.type === 'color') { - const handleColorClick = (e: Event) => { - const target = (e.target as HTMLElement).closest('button'); - if (!target) return; - - const pickerId = `plugin-${pluginId}-${instanceId}-${key}`; - - if ( - window.__dmn_showColorPicker && - window.__dmn_getColorPickerState - ) { - const state = window.__dmn_getColorPickerState(); - if (state?.isOpen && state.id === pickerId) { - window.__dmn_showColorPicker({ - initialColor: state.color, - id: pickerId, - }); - return; - } - } - - target.classList.add('shadow-focus-ring'); - - api.ui.pickColor({ - initialColor: String(currentSettings[key] ?? ''), - id: pickerId, - referenceElement: target as HTMLElement, - onColorChange: (newColor) => { - // 스와치(버튼 자체) 미리보기 업데이트 - target.style.setProperty( - '--dmn-color-swatch-color', - newColor, - ); - }, - onColorChangeComplete: (newColor) => { - wrappedChange(newColor); - }, - onClose: () => { - target.classList.remove('shadow-focus-ring'); - }, - }); - }; - - const handlerId = modalHandlers.trackRegistryHandler( - handlerRegistry.register(pluginId, handleColorClick), - ); - - // 패널 ColorInput과 동일한 스와치 단독 버튼 - componentHtml = ` - - `; - } else if (schema.type === 'string' || schema.type === 'number') { - const strVal = String(value); - let inputWidth: number; - - if (schema.type === 'number') { - inputWidth = 60; - } else { - if (strVal.length <= 4) inputWidth = 60; - else if (strVal.length <= 10) inputWidth = 100; - else inputWidth = 200; - } - - componentHtml = modalHandlers.capture(() => - api.ui.components.input({ - type: - schema.type === 'string' - ? 'text' - : (schema.type as 'number'), - value: value as string | number, - onChange: wrappedChange as unknown as ( - value: string, - ) => void | Promise, - min: schema.min, - max: schema.max, - step: schema.step, - placeholder: placeholderText, - width: inputWidth, - }), - ); - } else if (schema.type === 'select') { - const translatedOptions = (schema.options || []).map( - (option: { label: string; value: string }) => ({ - ...option, - label: translate(option.label, undefined, option.label), - }), - ); - componentHtml = modalHandlers.capture(() => - api.ui.components.dropdown({ - options: translatedOptions, - selected: value as string, - onChange: wrappedChange as unknown as ( - value: string, - ) => void | Promise, - }), - ); - } - - htmlContent += ` -
-

${labelText}

- ${componentHtml} -
- `; - } - } - htmlContent += '
'; - } - - const noSettingsText = await api.settings - .get() - .then((s) => { - const locale = s.language || 'ko'; - return locale === 'en' - ? 'No settings available.' - : '설정할 항목이 없습니다.'; - }) - .catch(() => '설정할 항목이 없습니다.'); - htmlContent += `
${noSettingsText}
`; - - htmlContent += '
'; - - const [saveText, cancelText] = await api.settings - .get() - .then((s) => { - const locale = s.language || 'ko'; - return locale === 'en' ? ['Apply', 'Cancel'] : ['저장', '취소']; - }) - .catch(() => ['저장', '취소']); - - const confirmed = await api.ui.dialog.custom(htmlContent, { - showCancel: true, - confirmText: saveText, - cancelText: cancelText, - }); - - if (!confirmed) { - api.ui.displayElement.update(instanceId, { - settings: originalSettings, - }); - } - // 모달 정산은 확정 경계 - 확정·취소 revert 모두 즉시 커밋 - flushPluginInstancesEditSession(pluginId); - } finally { - modalHandlers.dispose(); - } - }; + const openInstanceSettings = createOpenInstanceSettings({ + definition, + defaultSettings, + translate, + }); const handleElementClick = (e: Event) => { if (!useModalSettings) return; diff --git a/src/renderer/plugins/runtime/api/instanceSettingsDialog.ts b/src/renderer/plugins/runtime/api/instanceSettingsDialog.ts new file mode 100644 index 000000000..06f8c1af3 --- /dev/null +++ b/src/renderer/plugins/runtime/api/instanceSettingsDialog.ts @@ -0,0 +1,360 @@ +import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; +import { flushPluginInstancesEditSession } from '../displayElement/instancesCommitQueue'; +import { + SECTION_WRAPPER_CLASS, + SECTION_LABEL_CLASS, + SECTION_CARD_CLASS, + FORM_ROW_CLASS, + FORM_LABEL_CLASS, +} from '@utils/cardRecipes'; +import { handlerRegistry } from '../handlers'; +import { + coerceSettingValue, + normalizeSettingsSections, + omitLayoutSettingValues, +} from '../settingsSections'; +import type { DMNoteAPI, PluginDefinition } from '@src/types/plugin/api'; +import { createPluginDialogHandlerScope } from './pluginDialogHandlers'; + +type Translate = ( + key?: string, + params?: Record, + fallback?: string, +) => string; + +interface CreateInstanceSettingsDialogFactoryOptions { + pluginId: string; + api: DMNoteAPI; + wrapFunctionWithContext: ( + fn: (...args: unknown[]) => unknown, + ) => (...args: unknown[]) => unknown; +} + +interface CreateOpenInstanceSettingsOptions { + definition: PluginDefinition; + defaultSettings: Record; + translate: Translate; +} + +export const createInstanceSettingsDialogFactory = ({ + pluginId, + api, + wrapFunctionWithContext, +}: CreateInstanceSettingsDialogFactoryOptions) => { + const visibilityErrorKeys = new Set(); + + return ({ + definition, + defaultSettings, + translate, + }: CreateOpenInstanceSettingsOptions) => { + return async (instanceId: string) => { + const element = usePluginDisplayElementStore + .getState() + .elements.find((el) => el.fullId === instanceId); + + if (!element) { + console.warn( + `[Plugin ${pluginId}] Cannot find element ${instanceId} for settings`, + ); + return; + } + + const currentSettings: Record = omitLayoutSettingValues( + definition.settings, + { + ...defaultSettings, + ...(element.settings || {}), + }, + ); + const originalSettings = { ...currentSettings }; + + const reportNormalizationError = ( + key: string, + error: unknown, + kind: 'visibility' | 'unsupported-type', + ) => { + if (visibilityErrorKeys.has(key)) return; + visibilityErrorKeys.add(key); + const message = + kind === 'unsupported-type' + ? `Unsupported setting type for "${key}"` + : `Failed to evaluate visibility for setting "${key}"`; + console.error(`[Plugin ${pluginId}] ${message}:`, error); + }; + const getNormalizedSections = () => + normalizeSettingsSections( + definition.settings, + currentSettings, + reportNormalizationError, + ); + const modalScope = `plugin-element-${encodeURIComponent( + pluginId, + )}-${encodeURIComponent(instanceId)}`; + const findModalElement = (attribute: string, value: string) => + Array.from( + document.querySelectorAll(`[${attribute}]`), + ).find((element) => element.getAttribute(attribute) === value); + const updateVisibility = () => { + const sections = getNormalizedSections(); + sections.forEach((section, sectionIndex) => { + const sectionElement = findModalElement( + 'data-settings-section', + `${modalScope}-${sectionIndex}`, + ); + if (sectionElement) { + sectionElement.style.display = section.renderVisible ? '' : 'none'; + } + section.entries.forEach((entry, entryIndex) => { + const entryElement = findModalElement( + 'data-settings-entry', + `${modalScope}-${sectionIndex}-${entryIndex}`, + ); + if (entryElement) { + entryElement.style.display = entry.renderVisible ? '' : 'none'; + } + }); + }); + const emptyElement = findModalElement( + 'data-settings-empty', + modalScope, + ); + if (emptyElement) { + emptyElement.style.display = sections.some( + (section) => section.renderVisible, + ) + ? 'none' + : ''; + } + }; + const commitSettingValue = async ( + key: string, + newValue: string | number | boolean, + ) => { + currentSettings[key] = newValue; + api.ui.displayElement.update(instanceId, { + settings: { ...currentSettings }, + }); + updateVisibility(); + }; + + const modalHandlers = createPluginDialogHandlerScope(); + let htmlContent = ''; + try { + const normalizedSections = getNormalizedSections(); + // 패널(renderPluginSettingsForm)과 동일한 섹션 카드 구조·토큰 - section이 + // 없어도 암시적 카드 하나로 렌더 (모달-패널 외형 통합, 2026-07-12 결정) + htmlContent = '
'; + + for (const [sectionIndex, section] of normalizedSections.entries()) { + htmlContent += `
`; + if (section.label) { + const sectionLabel = translate( + section.label, + undefined, + section.label, + ); + htmlContent += `

${sectionLabel}

`; + } + htmlContent += `
`; + for (const [entryIndex, entry] of section.entries.entries()) { + const { key, schema } = entry; + const entryAttributes = `data-settings-entry="${modalScope}-${sectionIndex}-${entryIndex}" style="${ + entry.renderVisible ? '' : 'display:none' + }"`; + { + const value = + currentSettings[key] !== undefined + ? currentSettings[key] + : schema.default; + let componentHtml = ''; + const labelText = translate( + schema.label, + undefined, + schema.label, + ); + const placeholderText = + typeof schema.placeholder === 'string' + ? translate(schema.placeholder, undefined, schema.placeholder) + : schema.placeholder; + + const wrappedChange = wrapFunctionWithContext((newValue) => { + // DOM 문자열을 스키마 타입으로 복원, 복원 불가면 커밋 스킵 + const coerced = coerceSettingValue(schema, newValue); + if (coerced === null) return; + return commitSettingValue(key, coerced); + }); + + if (schema.type === 'boolean') { + componentHtml = modalHandlers.capture(() => + api.ui.components.checkbox({ + checked: !!value, + onChange: wrappedChange as unknown as ( + checked: boolean, + ) => void | Promise, + }), + ); + } else if (schema.type === 'color') { + const handleColorClick = (e: Event) => { + const target = (e.target as HTMLElement).closest('button'); + if (!target) return; + + const pickerId = `plugin-${pluginId}-${instanceId}-${key}`; + + if ( + window.__dmn_showColorPicker && + window.__dmn_getColorPickerState + ) { + const state = window.__dmn_getColorPickerState(); + if (state?.isOpen && state.id === pickerId) { + window.__dmn_showColorPicker({ + initialColor: state.color, + id: pickerId, + }); + return; + } + } + + target.classList.add('shadow-focus-ring'); + + api.ui.pickColor({ + initialColor: String(currentSettings[key] ?? ''), + id: pickerId, + referenceElement: target as HTMLElement, + onColorChange: (newColor) => { + // 스와치(버튼 자체) 미리보기 업데이트 + target.style.setProperty( + '--dmn-color-swatch-color', + newColor, + ); + }, + onColorChangeComplete: (newColor) => { + wrappedChange(newColor); + }, + onClose: () => { + target.classList.remove('shadow-focus-ring'); + }, + }); + }; + + const handlerId = modalHandlers.trackRegistryHandler( + handlerRegistry.register(pluginId, handleColorClick), + ); + + // 패널 ColorInput과 동일한 스와치 단독 버튼 + componentHtml = ` + + `; + } else if (schema.type === 'string' || schema.type === 'number') { + const strVal = String(value); + let inputWidth: number; + + if (schema.type === 'number') { + inputWidth = 60; + } else { + if (strVal.length <= 4) inputWidth = 60; + else if (strVal.length <= 10) inputWidth = 100; + else inputWidth = 200; + } + + componentHtml = modalHandlers.capture(() => + api.ui.components.input({ + type: + schema.type === 'string' + ? 'text' + : (schema.type as 'number'), + value: value as string | number, + onChange: wrappedChange as unknown as ( + value: string, + ) => void | Promise, + min: schema.min, + max: schema.max, + step: schema.step, + placeholder: placeholderText, + width: inputWidth, + }), + ); + } else if (schema.type === 'select') { + const translatedOptions = (schema.options || []).map( + (option: { label: string; value: string }) => ({ + ...option, + label: translate(option.label, undefined, option.label), + }), + ); + componentHtml = modalHandlers.capture(() => + api.ui.components.dropdown({ + options: translatedOptions, + selected: value as string, + onChange: wrappedChange as unknown as ( + value: string, + ) => void | Promise, + }), + ); + } + + htmlContent += ` +
+

${labelText}

+ ${componentHtml} +
+ `; + } + } + htmlContent += '
'; + } + + const noSettingsText = await api.settings + .get() + .then((s) => { + const locale = s.language || 'ko'; + return locale === 'en' + ? 'No settings available.' + : '설정할 항목이 없습니다.'; + }) + .catch(() => '설정할 항목이 없습니다.'); + htmlContent += `
${noSettingsText}
`; + + htmlContent += '
'; + + const [saveText, cancelText] = await api.settings + .get() + .then((s) => { + const locale = s.language || 'ko'; + return locale === 'en' ? ['Apply', 'Cancel'] : ['저장', '취소']; + }) + .catch(() => ['저장', '취소']); + + const confirmed = await api.ui.dialog.custom(htmlContent, { + showCancel: true, + confirmText: saveText, + cancelText: cancelText, + }); + + if (!confirmed) { + api.ui.displayElement.update(instanceId, { + settings: originalSettings, + }); + } + // 모달 정산은 확정 경계 - 확정·취소 revert 모두 즉시 커밋 + flushPluginInstancesEditSession(pluginId); + } finally { + modalHandlers.dispose(); + } + }; + }; +}; From 57a37b14f0993b7fd81dcca8767a1e2713c99171 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 16:27:15 +0900 Subject: [PATCH 129/245] =?UTF-8?q?test:=20=EC=B9=B4=EC=9A=B4=ED=84=B0=20?= =?UTF-8?q?=EB=AA=A8=EC=85=98=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CounterAnimationEditorModal.test.tsx | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.test.tsx b/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.test.tsx index 2628ca487..157311da2 100644 --- a/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.test.tsx +++ b/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.test.tsx @@ -5,6 +5,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import CounterAnimationEditorModal from './CounterAnimationEditorModal'; +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const preview = vi.hoisted(() => ({ + countDisplayProps: null as Record | null, +})); + vi.mock('@components/main/Modal/FullSurfaceModalLayout', () => ({ default: ({ children }: { children: React.ReactNode }) => <>{children}, })); @@ -16,7 +22,10 @@ vi.mock('@components/main/Grid/PropertiesPanel/PropertyInputs', () => ({ NumberInput: () => null, })); vi.mock('@components/overlay/counters/CountDisplay', () => ({ - default: () => null, + default: (props: Record) => { + preview.countDisplayProps = props; + return ; + }, })); const pointerEvent = (type: string, clientX: number, clientY: number) => { @@ -39,6 +48,7 @@ describe('CounterAnimationEditorModal 베지어 드래그', () => { let resizeCallback: ResizeObserverCallback; beforeEach(() => { + preview.countDisplayProps = null; callbacks = new Map(); let nextId = 1; vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { @@ -117,6 +127,42 @@ describe('CounterAnimationEditorModal 베지어 드래그', () => { expect(svg.getAttribute('preserveAspectRatio')).toBe('xMidYMid meet'); }); + it('미리보기는 기본 fallback을 전달하고 press·release 순서로 count와 active를 갱신한다', () => { + const key = host.querySelector('[data-key-element="true"]')!; + const scope = host.querySelector( + '[data-dmn-user-css-scope=""]', + )!; + const stage = scope.parentElement!; + + expect(preview.countDisplayProps).toMatchObject({ + count: 0, + active: false, + globalKey: 'preview', + animationEnabled: true, + animationBezier: [0.25, 0.46, 0.45, 0.94], + animationScale: 1.1, + animationDurationMs: 300, + }); + expect(key.getAttribute('data-state')).toBe('inactive'); + expect(key.getAttribute('data-key-element')).toBe('true'); + expect(key.hasAttribute('data-key-image')).toBe(false); + expect(stage.textContent).toContain('counterSetting.pressToPreview'); + + act(() => stage.dispatchEvent(pointerEvent('pointerdown', 100, 100))); + expect(preview.countDisplayProps).toMatchObject({ + count: 1, + active: true, + }); + expect(key.getAttribute('data-state')).toBe('active'); + + act(() => window.dispatchEvent(pointerEvent('pointerup', 100, 100))); + expect(preview.countDisplayProps).toMatchObject({ + count: 1, + active: false, + }); + expect(key.getAttribute('data-state')).toBe('inactive'); + }); + it('가로형 캔버스 실측 뒤에는 같은 비율의 풀블리드 viewBox를 쓴다', () => { const svg = host.querySelector( '[data-counter-bezier-editor="true"]', From bf66ca753fa9a3ba105459cade1fb95d065486e0 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 16:27:20 +0900 Subject: [PATCH 130/245] =?UTF-8?q?refactor:=20=EC=B9=B4=EC=9A=B4=ED=84=B0?= =?UTF-8?q?=20=EB=AA=A8=EC=85=98=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=EB=AC=B4=EB=8C=80=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../editors/CounterAnimationEditorModal.tsx | 271 +----------------- .../editors/CounterAnimationPreviewStage.tsx | 268 +++++++++++++++++ 2 files changed, 282 insertions(+), 257 deletions(-) create mode 100644 src/renderer/components/main/Modal/content/editors/CounterAnimationPreviewStage.tsx diff --git a/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.tsx b/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.tsx index c5d121f43..586179aec 100644 --- a/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.tsx +++ b/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.tsx @@ -11,22 +11,12 @@ import { TextInput, NumberInput, } from '@components/main/Grid/PropertiesPanel/PropertyInputs'; -import CountDisplay from '@components/overlay/counters/CountDisplay'; import { COUNTER_BEZIER_PRESETS, clampCounterBezier, findBezierPresetId, } from '@utils/cubicBezier'; -import { - DEFAULT_ELEMENT_FONT, - DEFAULT_ELEMENT_ACTIVE_FONT, - DEFAULT_COUNTER_FONT_SIZE, - DEFAULT_COUNTER_FONT_WEIGHT, -} from '@utils/core/elementDefaults'; -import { - computeCounterAnimationPreviewKeyStyles, - type CounterAnimationKeyVisual, -} from '@utils/core/counterAnimationPreview'; +import type { CounterAnimationKeyVisual } from '@utils/core/counterAnimationPreview'; import { createRafLatestScheduler, type ContinuousInputStrategy, @@ -46,6 +36,7 @@ import { parseCounterNumber as parseNumber, resolveCounterEditorViewDimensions as viewDims, } from './counterAnimationEditorModel'; +import CounterAnimationPreviewStage from './CounterAnimationPreviewStage'; type EditorMode = 'create' | 'edit'; @@ -979,252 +970,18 @@ const CounterAnimationEditorModal = ({ - {/* 미리보기 — 풀하이트 무대, 라벨 없이 스테이지 안 힌트만 */} -
-
- {/* 그리드 — 커브 캔버스와 동일 팔레트 */} -
-
- {(() => { - const PREVIEW_MAX_W = 200; - const PREVIEW_MAX_H = 160; - - const placement = counterSettings?.placement ?? 'inside'; - const align = counterSettings?.align ?? 'top'; - const alignMode = counterSettings?.alignMode ?? 'center'; - const gap = counterSettings?.gap ?? 4; - const isInside = placement === 'inside'; - const isHorizontal = align === 'left' || align === 'right'; - const isBetween = alignMode === 'between'; - - const keyW = keyVisual?.width ?? 60; - const keyH = keyVisual?.height ?? 60; - const counterExtra = - (counterSettings?.fontSize ?? DEFAULT_COUNTER_FONT_SIZE) + - gap; - - let totalW = keyW; - let totalH = keyH; - if (!isInside) { - if (align === 'left' || align === 'right') - totalW += counterExtra; - else totalH += counterExtra; - } - - const fitScale = Math.min( - PREVIEW_MAX_W / totalW, - PREVIEW_MAX_H / totalH, - 1, - ); - const keyActive = previewActive && !keyVisual?.isStat; - const { - keyStyle: computedKeyStyle, - borderRingStyle, - imageStyle, - textStyle, - currentImageSrc, - hasCurrentImage, - imageMode, - imageReplaces, - isTransparent, - labelText, - useInline, - } = computeCounterAnimationPreviewKeyStyles({ - keyVisual, - active: keyActive, - width: keyW, - height: keyH, - }); - - const labelEl = ( - - {labelText} - - ); - - const counterEl = ( - - ); - - const keyBoxStyle: React.CSSProperties = { - ...computedKeyStyle, - transform: 'none', - display: isTransparent ? 'none' : undefined, - zIndex: undefined, - cursor: undefined, - }; - - const outsideStyle: React.CSSProperties | undefined = - !isInside - ? { - position: 'absolute', - pointerEvents: 'none', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - ...(align === 'top' && { - bottom: '100%', - left: '50%', - transform: 'translateX(-50%)', - paddingBottom: `${gap}px`, - }), - ...(align === 'bottom' && { - top: '100%', - left: '50%', - transform: 'translateX(-50%)', - paddingTop: `${gap}px`, - }), - ...(align === 'left' && { - right: '100%', - top: '50%', - transform: 'translateY(-50%)', - paddingRight: `${gap}px`, - }), - ...(align === 'right' && { - left: '100%', - top: '50%', - transform: 'translateY(-50%)', - paddingLeft: `${gap}px`, - }), - } - : undefined; - - return ( -
-
- {borderRingStyle && ( -
- {!isInside && outsideStyle && ( -
- {counterEl} -
- )} -
- ); - })()} -
- {/* 하단 안내 — 스크림 없이 흐린 캡션만 (풀하이트 스테이지라 키와 충돌 없음) */} -
- - {t('counterSetting.pressToPreview') || '눌러서 미리보기'} - -
-
-
+
{/* 파라미터 데크 — 하단 풀폭 항상 한 줄, 이름 입력이 남는 폭 흡수 */} diff --git a/src/renderer/components/main/Modal/content/editors/CounterAnimationPreviewStage.tsx b/src/renderer/components/main/Modal/content/editors/CounterAnimationPreviewStage.tsx new file mode 100644 index 000000000..ff26afb95 --- /dev/null +++ b/src/renderer/components/main/Modal/content/editors/CounterAnimationPreviewStage.tsx @@ -0,0 +1,268 @@ +import React from 'react'; +import CountDisplay from '@components/overlay/counters/CountDisplay'; +import type { + CounterAnimationBezier, + KeyCounterSettings, +} from '@src/types/key/keys'; +import { + DEFAULT_ELEMENT_ACTIVE_FONT, + DEFAULT_ELEMENT_FONT, + DEFAULT_COUNTER_FONT_SIZE, + DEFAULT_COUNTER_FONT_WEIGHT, +} from '@utils/core/elementDefaults'; +import { + computeCounterAnimationPreviewKeyStyles, + type CounterAnimationKeyVisual, +} from '@utils/core/counterAnimationPreview'; + +interface CounterAnimationPreviewStageProps { + counterSettings?: KeyCounterSettings; + keyVisual?: CounterAnimationKeyVisual; + animationBezier: CounterAnimationBezier; + animationScale: number; + animationDurationMs: number; + count: number; + active: boolean; + gridMinorColor: string; + onPointerDown: (event: React.PointerEvent) => void; + t: (key: string) => string; +} + +const CounterAnimationPreviewStage = ({ + counterSettings, + keyVisual, + animationBezier, + animationScale, + animationDurationMs, + count, + active, + gridMinorColor, + onPointerDown, + t, +}: CounterAnimationPreviewStageProps) => { + const PREVIEW_MAX_W = 200; + const PREVIEW_MAX_H = 160; + + const placement = counterSettings?.placement ?? 'inside'; + const align = counterSettings?.align ?? 'top'; + const alignMode = counterSettings?.alignMode ?? 'center'; + const gap = counterSettings?.gap ?? 4; + const isInside = placement === 'inside'; + const isHorizontal = align === 'left' || align === 'right'; + const isBetween = alignMode === 'between'; + + const keyW = keyVisual?.width ?? 60; + const keyH = keyVisual?.height ?? 60; + const counterExtra = + (counterSettings?.fontSize ?? DEFAULT_COUNTER_FONT_SIZE) + gap; + + let totalW = keyW; + let totalH = keyH; + if (!isInside) { + if (align === 'left' || align === 'right') totalW += counterExtra; + else totalH += counterExtra; + } + + const fitScale = Math.min(PREVIEW_MAX_W / totalW, PREVIEW_MAX_H / totalH, 1); + const keyActive = active && !keyVisual?.isStat; + const { + keyStyle: computedKeyStyle, + borderRingStyle, + imageStyle, + textStyle, + currentImageSrc, + hasCurrentImage, + imageMode, + imageReplaces, + isTransparent, + labelText, + useInline, + } = computeCounterAnimationPreviewKeyStyles({ + keyVisual, + active: keyActive, + width: keyW, + height: keyH, + }); + + const labelEl = ( + + {labelText} + + ); + + const counterEl = ( + + ); + + const keyBoxStyle: React.CSSProperties = { + ...computedKeyStyle, + transform: 'none', + display: isTransparent ? 'none' : undefined, + zIndex: undefined, + cursor: undefined, + }; + + const outsideStyle: React.CSSProperties | undefined = !isInside + ? { + position: 'absolute', + pointerEvents: 'none', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + ...(align === 'top' && { + bottom: '100%', + left: '50%', + transform: 'translateX(-50%)', + paddingBottom: `${gap}px`, + }), + ...(align === 'bottom' && { + top: '100%', + left: '50%', + transform: 'translateX(-50%)', + paddingTop: `${gap}px`, + }), + ...(align === 'left' && { + right: '100%', + top: '50%', + transform: 'translateY(-50%)', + paddingRight: `${gap}px`, + }), + ...(align === 'right' && { + left: '100%', + top: '50%', + transform: 'translateY(-50%)', + paddingLeft: `${gap}px`, + }), + } + : undefined; + + return ( +
+
+ {/* 그리드 — 커브 캔버스와 동일 팔레트 */} +
+
+
+
+ {borderRingStyle && ( +
+ {!isInside && outsideStyle && ( +
+ {counterEl} +
+ )} +
+
+ {/* 하단 안내 — 스크림 없이 흐린 캡션만 */} +
+ + {t('counterSetting.pressToPreview') || '눌러서 미리보기'} + +
+
+
+ ); +}; + +export default CounterAnimationPreviewStage; From 722b5b921c6da922b21b1208911d4de445960b86 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 16:33:29 +0900 Subject: [PATCH 131/245] =?UTF-8?q?refactor:=20=EC=95=B1=20=EB=84=A4?= =?UTF-8?q?=EC=9D=B4=ED=8B=B0=EB=B8=8C=20=EC=B0=BD=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/state/app_state.rs | 349 +----------------- .../src/state/app_state/native_window.rs | 343 +++++++++++++++++ 2 files changed, 350 insertions(+), 342 deletions(-) create mode 100644 src-tauri/src/state/app_state/native_window.rs diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs index 05377b335..211d1ae03 100644 --- a/src-tauri/src/state/app_state.rs +++ b/src-tauri/src/state/app_state.rs @@ -31,6 +31,7 @@ mod css_runtime; mod frontend_lifecycle; mod keyboard_runtime; mod lifecycle_runtime; +mod native_window; mod overlay_runtime; mod panel_runtime; mod window_geometry; @@ -52,6 +53,12 @@ use lifecycle_runtime::{ attach_main_window_close_handler, dispatch_remove_tray_icon, execute_frontend_lifecycle, remove_tray_icon, tray_menu_labels, }; +#[cfg(target_os = "macos")] +use native_window::apply_macos_overlay_fullscreen_behavior; +pub(crate) use native_window::fade_overlay_window; +use native_window::{apply_overlay_frame, hide_overlay_window, show_overlay_window}; +#[cfg(target_os = "windows")] +use native_window::{disable_system_context_menu, raise_panel_window_without_activation}; #[cfg(test)] use window_geometry::{ @@ -117,9 +124,6 @@ const MIN_OVERLAY_DIMENSION: f64 = 100.0; // 넓은 배치에 트랙 높이를 크게 잡으면 이전 상한 2000에서 조용히 잘렸음 const MAX_OVERLAY_DIMENSION: f64 = 4096.0; -#[cfg(target_os = "macos")] -const OVERLAY_FRAME_APPLY_TIMEOUT_MS: u64 = 250; - fn clamp_overlay_dimension(value: f64) -> f64 { value .clamp(MIN_OVERLAY_DIMENSION, MAX_OVERLAY_DIMENSION) @@ -1510,344 +1514,5 @@ mod output_backend_tests { } } -/// 오버레이 창 알파를 페이드 - 트랙 예약 토글처럼 창 프레임과 콘텐츠가 함께 바뀌는 전환을 가린다 -/// 창 프레임 변경과 웹뷰 리페인트는 프로세스 경계라 같은 프레임에 커밋될 수 없음 -/// 반환값은 페이드 적용 여부 - false면 호출부가 가림 없이 즉시 전환해야 한다 -pub(crate) fn fade_overlay_window(app: &AppHandle, alpha: f64, duration_ms: u64) -> Result { - let Some(window) = app.get_webview_window(OVERLAY_LABEL) else { - // OBS 모드 등 창 미존재 시 무시 - return Ok(false); - }; - #[cfg(target_os = "macos")] - { - let alpha = alpha.clamp(0.0, 1.0); - let duration = (duration_ms as f64 / 1000.0).max(0.0); - let target = window.clone(); - app.run_on_main_thread(move || { - use objc::{class, msg_send, sel, sel_impl}; - - match target.ns_window() { - Ok(ns_window) => unsafe { - let ns_window = ns_window as *mut objc::runtime::Object; - if duration <= 0.0 { - let _: () = msg_send![ns_window, setAlphaValue: alpha]; - } else { - let _: () = msg_send![class!(NSAnimationContext), beginGrouping]; - let ctx: *mut objc::runtime::Object = - msg_send![class!(NSAnimationContext), currentContext]; - let _: () = msg_send![ctx, setDuration: duration]; - let animator: *mut objc::runtime::Object = msg_send![ns_window, animator]; - let _: () = msg_send![animator, setAlphaValue: alpha]; - let _: () = msg_send![class!(NSAnimationContext), endGrouping]; - } - }, - Err(err) => log::warn!("overlay fade: failed to get NSWindow handle: {err}"), - } - })?; - Ok(true) - } - #[cfg(not(target_os = "macos"))] - { - // 네이티브 창 알파 미지원 - false를 돌려 렌더러가 콘텐츠 페이드로 대체한다 - let _ = (alpha, duration_ms, window); - Ok(false) - } -} - -/// 오버레이 표시 시 알파를 1로 복원 - 페이드 도중 종료된 전환이 남긴 투명 상태의 마지막 방어선 -#[cfg(target_os = "macos")] -fn reset_overlay_alpha(window: &WebviewWindow) { - let app = window.app_handle().clone(); - let target = window.clone(); - let _ = app.run_on_main_thread(move || { - use objc::{msg_send, sel, sel_impl}; - - if let Ok(ns_window) = target.ns_window() { - let ns_window = ns_window as *mut objc::runtime::Object; - unsafe { - let _: () = msg_send![ns_window, setAlphaValue: 1.0f64]; - } - } - }); -} - -/// 오버레이 크기와 위치를 한 번의 네이티브 호출로 적용 -/// set_size와 set_position을 나눠 부르면 창 이동이 두 트랜잭션으로 쪼개져 중간 프레임이 보인다 -fn apply_overlay_frame( - window: &WebviewWindow, - x: f64, - y: f64, - width: f64, - height: f64, - scale_factor: f64, -) -> Result { - let requested = OverlayBounds { - x, - y, - width, - height, - }; - #[cfg(target_os = "macos")] - { - use objc::{class, msg_send, sel, sel_impl}; - - let _ = scale_factor; - // 메인 스레드에서 큐잉 후 대기하면 교착하므로 직접 실행 - let on_main: bool = unsafe { msg_send![class!(NSThread), isMainThread] }; - if on_main { - return Ok(apply_overlay_frame_macos(window, x, y, width, height).unwrap_or(requested)); - } - - let (tx, rx) = std::sync::mpsc::channel(); - let target = window.clone(); - window.app_handle().run_on_main_thread(move || { - let _ = tx.send(apply_overlay_frame_macos(&target, x, y, width, height)); - })?; - Ok(rx - .recv_timeout(Duration::from_millis(OVERLAY_FRAME_APPLY_TIMEOUT_MS)) - .ok() - .flatten() - .unwrap_or(requested)) - } - #[cfg(target_os = "windows")] - { - use windows::Win32::UI::WindowsAndMessaging::{SetWindowPos, SWP_NOACTIVATE, SWP_NOZORDER}; - - let hwnd = window.hwnd()?; - let px = (x * scale_factor).round() as i32; - let py = (y * scale_factor).round() as i32; - let pw = (width * scale_factor).round() as i32; - let ph = (height * scale_factor).round() as i32; - unsafe { - SetWindowPos(hwnd, None, px, py, pw, ph, SWP_NOZORDER | SWP_NOACTIVATE)?; - } - Ok(requested) - } - #[cfg(all(not(target_os = "windows"), not(target_os = "macos")))] - { - let _ = scale_factor; - window.set_size(LogicalSize::new(width, height))?; - window.set_position(LogicalPosition::new(x, y))?; - Ok(requested) - } -} - -/// tao 좌표(주 모니터 좌상단 원점)를 AppKit 좌표(주 모니터 좌하단 원점)로 변환해 setFrame 적용 -#[cfg(target_os = "macos")] -fn apply_overlay_frame_macos( - window: &WebviewWindow, - x: f64, - y: f64, - width: f64, - height: f64, -) -> Option { - use cocoa::foundation::{NSPoint, NSRect, NSSize}; - use objc::{class, msg_send, sel, sel_impl}; - - let fallback = || { - let _ = window.set_size(LogicalSize::new(width, height)); - let _ = window.set_position(LogicalPosition::new(x, y)); - }; - - match window.ns_window() { - Ok(ns_window) => unsafe { - let ns_window = ns_window as *mut objc::runtime::Object; - - let screens: *mut objc::runtime::Object = msg_send![class!(NSScreen), screens]; - let count: usize = msg_send![screens, count]; - if count == 0 { - fallback(); - return None; - } - let primary: *mut objc::runtime::Object = msg_send![screens, objectAtIndex: 0usize]; - let screen_frame: NSRect = msg_send![primary, frame]; - - let flipped_y = screen_frame.size.height - (y + height); - let frame = NSRect::new(NSPoint::new(x, flipped_y), NSSize::new(width, height)); - let _: () = msg_send![ns_window, setFrame: frame display: true]; - - // AppKit이 창을 화면 안으로 되밀 수 있어 실제 반영된 프레임을 읽는다 - let applied: NSRect = msg_send![ns_window, frame]; - Some(OverlayBounds { - x: applied.origin.x, - y: screen_frame.size.height - (applied.origin.y + applied.size.height), - width: applied.size.width, - height: applied.size.height, - }) - }, - Err(err) => { - log::warn!("overlay frame: failed to get NSWindow handle: {err}"); - fallback(); - None - } - } -} - -fn show_overlay_window(window: &WebviewWindow, _always_on_top: bool) -> Result<()> { - #[cfg(target_os = "windows")] - { - use windows::Win32::UI::WindowsAndMessaging::{ShowWindow, SW_SHOWNOACTIVATE}; - - let hwnd = window.hwnd()?; - unsafe { - let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE); - } - // tao 내부 VISIBLE 플래그 동기화 — raw ShowWindow는 tao 상태를 갱신하지 않아, - // 이후 set_always_on_top/set_ignore_cursor_events 호출 시 tao가 창을 숨겨버림 - window.show()?; - Ok(()) - } - #[cfg(target_os = "macos")] - { - reset_overlay_alpha(window); - // 오버레이 표시 시 key/main 윈도우 전환 방지 - let _ = window.set_focusable(false); - apply_macos_overlay_fullscreen_behavior(window, _always_on_top); - window.show()?; - apply_macos_overlay_fullscreen_behavior(window, _always_on_top); - Ok(()) - } - #[cfg(all(not(target_os = "windows"), not(target_os = "macos")))] - { - window.show()?; - Ok(()) - } -} - -fn hide_overlay_window(window: &WebviewWindow) -> Result<()> { - // tao API 사용으로 내부 VISIBLE 플래그와 실제 창 상태 일치 유지 - window.hide()?; - Ok(()) -} - -/// 분리 창을 포커스 이동 없이 메인 위로 올린다. -/// -/// 빌더의 focused(false)가 tao에 MARKER_DONT_FOCUS를 남기는데, 이 플래그를 내리는 코드는 -/// apply_diff가 값으로 받은 복사본만 바꾸므로 창 수명 내내 살아 있다. 그래서 show()는 항상 -/// SW_SHOWNOACTIVATE로 나가고, 그건 활성 창 위로 올려주지 않아 패널이 메인 뒤에 깔린다. -/// 순서를 올리는 유일한 경로였던 set_focus는 드래그 tear-off에서 쓸 수 없다 - -/// 메인 웹뷰가 kill-focus를 받으면 mousedown 암시적 캡처가 풀려 드래그 세션이 끊긴다. -/// -/// TOPMOST 밴드는 쓰지 않는다 - 한 프레임이라도 올라가면 always-on-top 오버레이 위로 나온다 -#[cfg(target_os = "windows")] -fn raise_panel_window_without_activation(window: &WebviewWindow) { - use windows::Win32::UI::WindowsAndMessaging::{ - SetWindowPos, HWND_TOP, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOOWNERZORDER, SWP_NOSIZE, - }; - - let hwnd = match window.hwnd() { - Ok(hwnd) => hwnd, - Err(err) => { - log::warn!("failed to get panel HWND for z-order raise: {err}"); - return; - } - }; - if let Err(err) = unsafe { - SetWindowPos( - hwnd, - Some(HWND_TOP), - 0, - 0, - 0, - 0, - SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_NOOWNERZORDER, - ) - } { - log::warn!("failed to raise panel window above main: {err}"); - } -} - -/// macOS 전체화면 Space에서도 오버레이가 보이도록 NSWindow 동작을 보정 -/// 어떤 스레드에서 호출되든 안전하게 메인 스레드에서 AppKit API를 실행 -#[cfg(target_os = "macos")] -fn apply_macos_overlay_fullscreen_behavior(window: &WebviewWindow, always_on_top: bool) { - let app = window.app_handle().clone(); - let window = window.clone(); - let _ = app.run_on_main_thread(move || { - apply_macos_overlay_fullscreen_behavior_inner(&window, always_on_top); - }); -} - -#[cfg(target_os = "macos")] -fn apply_macos_overlay_fullscreen_behavior_inner(window: &WebviewWindow, always_on_top: bool) { - use objc::{msg_send, sel, sel_impl}; - - match window.ns_window() { - Ok(ns_window) => unsafe { - let ns_window = ns_window as *mut objc::runtime::Object; - - // 전체화면 Space 표시를 위한 레벨 및 컬렉션 동작 설정 - // NSStatusWindowLevel (25) 수준 적용 - let target_level: i64 = if always_on_top { 25 } else { 0 }; - let _: () = msg_send![ns_window, setLevel: target_level]; - - let behavior: u64 = (1 << 0) | (1 << 8); // canJoinAllSpaces | fullScreenAuxiliary - let _: () = msg_send![ns_window, setCollectionBehavior: behavior]; - - let _: () = msg_send![ns_window, setHidesOnDeactivate: false]; - let _: () = msg_send![ns_window, orderFrontRegardless]; - - log::info!("macOS overlay: level={target_level}, behavior={behavior}"); - }, - Err(err) => { - log::warn!("macOS overlay: failed to get NSWindow handle: {err}"); - } - } -} - -/// 드래그 가능한 영역에서 시스템 컨텍스트 메뉴(이전 크기, 이동, 최소화 등)가 표시되지 않도록 설정 -/// WM_INITMENU 메시지를 후킹하여 메뉴가 초기화될 때 창을 비활성화했다 활성화하여 메뉴를 취소시키는 방식 -/// (Electron의 hookWindowMessage 방식과 동일) -#[cfg(target_os = "windows")] -fn disable_system_context_menu(window: &WebviewWindow) -> Result<()> { - use windows::Win32::{ - Foundation::{HWND, LPARAM, LRESULT, WPARAM}, - UI::{ - Shell::{DefSubclassProc, SetWindowSubclass}, - WindowsAndMessaging::{GetSystemMenu, WM_INITMENU}, - }, - }; - - // EnableWindow는 user32.dll에서 직접 호출 - #[link(name = "user32")] - extern "system" { - fn EnableWindow(hwnd: isize, enable: i32) -> i32; - } - - const SUBCLASS_ID: usize = 1; - - unsafe extern "system" fn subclass_proc( - hwnd: HWND, - msg: u32, - wparam: WPARAM, - lparam: LPARAM, - _uid_subclass: usize, - _dw_ref_data: usize, - ) -> LRESULT { - if msg == WM_INITMENU { - let system_menu = GetSystemMenu(hwnd, false); - let is_system_menu = !system_menu.0.is_null() && (system_menu.0 as usize == wparam.0); - if is_system_menu { - // 시스템 메뉴만 차단하고, 앱이 띄우는 커스텀 메뉴(Menu.popup)는 허용 - EnableWindow(hwnd.0 as isize, 0); // FALSE - EnableWindow(hwnd.0 as isize, 1); // TRUE - return LRESULT(0); - } - } - DefSubclassProc(hwnd, msg, wparam, lparam) - } - - let hwnd = window.hwnd()?; - let hwnd_win = HWND(hwnd.0); - - unsafe { - SetWindowSubclass(hwnd_win, Some(subclass_proc), SUBCLASS_ID, 0) - .ok() - .map_err(|e| anyhow!("SetWindowSubclass failed: {e}"))?; - } - - Ok(()) -} - #[cfg(test)] mod tests; diff --git a/src-tauri/src/state/app_state/native_window.rs b/src-tauri/src/state/app_state/native_window.rs new file mode 100644 index 000000000..065af039d --- /dev/null +++ b/src-tauri/src/state/app_state/native_window.rs @@ -0,0 +1,343 @@ +use super::*; + +#[cfg(target_os = "macos")] +const OVERLAY_FRAME_APPLY_TIMEOUT_MS: u64 = 250; + +/// 오버레이 창 알파를 페이드 - 트랙 예약 토글처럼 창 프레임과 콘텐츠가 함께 바뀌는 전환을 가린다 +/// 창 프레임 변경과 웹뷰 리페인트는 프로세스 경계라 같은 프레임에 커밋될 수 없음 +/// 반환값은 페이드 적용 여부 - false면 호출부가 가림 없이 즉시 전환해야 한다 +pub(crate) fn fade_overlay_window(app: &AppHandle, alpha: f64, duration_ms: u64) -> Result { + let Some(window) = app.get_webview_window(OVERLAY_LABEL) else { + // OBS 모드 등 창 미존재 시 무시 + return Ok(false); + }; + #[cfg(target_os = "macos")] + { + let alpha = alpha.clamp(0.0, 1.0); + let duration = (duration_ms as f64 / 1000.0).max(0.0); + let target = window.clone(); + app.run_on_main_thread(move || { + use objc::{class, msg_send, sel, sel_impl}; + + match target.ns_window() { + Ok(ns_window) => unsafe { + let ns_window = ns_window as *mut objc::runtime::Object; + if duration <= 0.0 { + let _: () = msg_send![ns_window, setAlphaValue: alpha]; + } else { + let _: () = msg_send![class!(NSAnimationContext), beginGrouping]; + let ctx: *mut objc::runtime::Object = + msg_send![class!(NSAnimationContext), currentContext]; + let _: () = msg_send![ctx, setDuration: duration]; + let animator: *mut objc::runtime::Object = msg_send![ns_window, animator]; + let _: () = msg_send![animator, setAlphaValue: alpha]; + let _: () = msg_send![class!(NSAnimationContext), endGrouping]; + } + }, + Err(err) => log::warn!("overlay fade: failed to get NSWindow handle: {err}"), + } + })?; + Ok(true) + } + #[cfg(not(target_os = "macos"))] + { + // 네이티브 창 알파 미지원 - false를 돌려 렌더러가 콘텐츠 페이드로 대체한다 + let _ = (alpha, duration_ms, window); + Ok(false) + } +} + +/// 오버레이 표시 시 알파를 1로 복원 - 페이드 도중 종료된 전환이 남긴 투명 상태의 마지막 방어선 +#[cfg(target_os = "macos")] +fn reset_overlay_alpha(window: &WebviewWindow) { + let app = window.app_handle().clone(); + let target = window.clone(); + let _ = app.run_on_main_thread(move || { + use objc::{msg_send, sel, sel_impl}; + + if let Ok(ns_window) = target.ns_window() { + let ns_window = ns_window as *mut objc::runtime::Object; + unsafe { + let _: () = msg_send![ns_window, setAlphaValue: 1.0f64]; + } + } + }); +} + +/// 오버레이 크기와 위치를 한 번의 네이티브 호출로 적용 +/// set_size와 set_position을 나눠 부르면 창 이동이 두 트랜잭션으로 쪼개져 중간 프레임이 보인다 +pub(super) fn apply_overlay_frame( + window: &WebviewWindow, + x: f64, + y: f64, + width: f64, + height: f64, + scale_factor: f64, +) -> Result { + let requested = OverlayBounds { + x, + y, + width, + height, + }; + #[cfg(target_os = "macos")] + { + use objc::{class, msg_send, sel, sel_impl}; + + let _ = scale_factor; + // 메인 스레드에서 큐잉 후 대기하면 교착하므로 직접 실행 + let on_main: bool = unsafe { msg_send![class!(NSThread), isMainThread] }; + if on_main { + return Ok(apply_overlay_frame_macos(window, x, y, width, height).unwrap_or(requested)); + } + + let (tx, rx) = std::sync::mpsc::channel(); + let target = window.clone(); + window.app_handle().run_on_main_thread(move || { + let _ = tx.send(apply_overlay_frame_macos(&target, x, y, width, height)); + })?; + Ok(rx + .recv_timeout(Duration::from_millis(OVERLAY_FRAME_APPLY_TIMEOUT_MS)) + .ok() + .flatten() + .unwrap_or(requested)) + } + #[cfg(target_os = "windows")] + { + use windows::Win32::UI::WindowsAndMessaging::{SetWindowPos, SWP_NOACTIVATE, SWP_NOZORDER}; + + let hwnd = window.hwnd()?; + let px = (x * scale_factor).round() as i32; + let py = (y * scale_factor).round() as i32; + let pw = (width * scale_factor).round() as i32; + let ph = (height * scale_factor).round() as i32; + unsafe { + SetWindowPos(hwnd, None, px, py, pw, ph, SWP_NOZORDER | SWP_NOACTIVATE)?; + } + Ok(requested) + } + #[cfg(all(not(target_os = "windows"), not(target_os = "macos")))] + { + let _ = scale_factor; + window.set_size(LogicalSize::new(width, height))?; + window.set_position(LogicalPosition::new(x, y))?; + Ok(requested) + } +} + +/// tao 좌표(주 모니터 좌상단 원점)를 AppKit 좌표(주 모니터 좌하단 원점)로 변환해 setFrame 적용 +#[cfg(target_os = "macos")] +fn apply_overlay_frame_macos( + window: &WebviewWindow, + x: f64, + y: f64, + width: f64, + height: f64, +) -> Option { + use cocoa::foundation::{NSPoint, NSRect, NSSize}; + use objc::{class, msg_send, sel, sel_impl}; + + let fallback = || { + let _ = window.set_size(LogicalSize::new(width, height)); + let _ = window.set_position(LogicalPosition::new(x, y)); + }; + + match window.ns_window() { + Ok(ns_window) => unsafe { + let ns_window = ns_window as *mut objc::runtime::Object; + + let screens: *mut objc::runtime::Object = msg_send![class!(NSScreen), screens]; + let count: usize = msg_send![screens, count]; + if count == 0 { + fallback(); + return None; + } + let primary: *mut objc::runtime::Object = msg_send![screens, objectAtIndex: 0usize]; + let screen_frame: NSRect = msg_send![primary, frame]; + + let flipped_y = screen_frame.size.height - (y + height); + let frame = NSRect::new(NSPoint::new(x, flipped_y), NSSize::new(width, height)); + let _: () = msg_send![ns_window, setFrame: frame display: true]; + + // AppKit이 창을 화면 안으로 되밀 수 있어 실제 반영된 프레임을 읽는다 + let applied: NSRect = msg_send![ns_window, frame]; + Some(OverlayBounds { + x: applied.origin.x, + y: screen_frame.size.height - (applied.origin.y + applied.size.height), + width: applied.size.width, + height: applied.size.height, + }) + }, + Err(err) => { + log::warn!("overlay frame: failed to get NSWindow handle: {err}"); + fallback(); + None + } + } +} + +pub(super) fn show_overlay_window(window: &WebviewWindow, _always_on_top: bool) -> Result<()> { + #[cfg(target_os = "windows")] + { + use windows::Win32::UI::WindowsAndMessaging::{ShowWindow, SW_SHOWNOACTIVATE}; + + let hwnd = window.hwnd()?; + unsafe { + let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE); + } + // tao 내부 VISIBLE 플래그 동기화 — raw ShowWindow는 tao 상태를 갱신하지 않아, + // 이후 set_always_on_top/set_ignore_cursor_events 호출 시 tao가 창을 숨겨버림 + window.show()?; + Ok(()) + } + #[cfg(target_os = "macos")] + { + reset_overlay_alpha(window); + // 오버레이 표시 시 key/main 윈도우 전환 방지 + let _ = window.set_focusable(false); + apply_macos_overlay_fullscreen_behavior(window, _always_on_top); + window.show()?; + apply_macos_overlay_fullscreen_behavior(window, _always_on_top); + Ok(()) + } + #[cfg(all(not(target_os = "windows"), not(target_os = "macos")))] + { + window.show()?; + Ok(()) + } +} + +pub(super) fn hide_overlay_window(window: &WebviewWindow) -> Result<()> { + // tao API 사용으로 내부 VISIBLE 플래그와 실제 창 상태 일치 유지 + window.hide()?; + Ok(()) +} + +/// 분리 창을 포커스 이동 없이 메인 위로 올린다. +/// +/// 빌더의 focused(false)가 tao에 MARKER_DONT_FOCUS를 남기는데, 이 플래그를 내리는 코드는 +/// apply_diff가 값으로 받은 복사본만 바꾸므로 창 수명 내내 살아 있다. 그래서 show()는 항상 +/// SW_SHOWNOACTIVATE로 나가고, 그건 활성 창 위로 올려주지 않아 패널이 메인 뒤에 깔린다. +/// 순서를 올리는 유일한 경로였던 set_focus는 드래그 tear-off에서 쓸 수 없다 - +/// 메인 웹뷰가 kill-focus를 받으면 mousedown 암시적 캡처가 풀려 드래그 세션이 끊긴다. +/// +/// TOPMOST 밴드는 쓰지 않는다 - 한 프레임이라도 올라가면 always-on-top 오버레이 위로 나온다 +#[cfg(target_os = "windows")] +pub(super) fn raise_panel_window_without_activation(window: &WebviewWindow) { + use windows::Win32::UI::WindowsAndMessaging::{ + SetWindowPos, HWND_TOP, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOOWNERZORDER, SWP_NOSIZE, + }; + + let hwnd = match window.hwnd() { + Ok(hwnd) => hwnd, + Err(err) => { + log::warn!("failed to get panel HWND for z-order raise: {err}"); + return; + } + }; + if let Err(err) = unsafe { + SetWindowPos( + hwnd, + Some(HWND_TOP), + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_NOOWNERZORDER, + ) + } { + log::warn!("failed to raise panel window above main: {err}"); + } +} + +/// macOS 전체화면 Space에서도 오버레이가 보이도록 NSWindow 동작을 보정 +/// 어떤 스레드에서 호출되든 안전하게 메인 스레드에서 AppKit API를 실행 +#[cfg(target_os = "macos")] +pub(super) fn apply_macos_overlay_fullscreen_behavior(window: &WebviewWindow, always_on_top: bool) { + let app = window.app_handle().clone(); + let window = window.clone(); + let _ = app.run_on_main_thread(move || { + apply_macos_overlay_fullscreen_behavior_inner(&window, always_on_top); + }); +} + +#[cfg(target_os = "macos")] +fn apply_macos_overlay_fullscreen_behavior_inner(window: &WebviewWindow, always_on_top: bool) { + use objc::{msg_send, sel, sel_impl}; + + match window.ns_window() { + Ok(ns_window) => unsafe { + let ns_window = ns_window as *mut objc::runtime::Object; + + // 전체화면 Space 표시를 위한 레벨 및 컬렉션 동작 설정 + // NSStatusWindowLevel (25) 수준 적용 + let target_level: i64 = if always_on_top { 25 } else { 0 }; + let _: () = msg_send![ns_window, setLevel: target_level]; + + let behavior: u64 = (1 << 0) | (1 << 8); // canJoinAllSpaces | fullScreenAuxiliary + let _: () = msg_send![ns_window, setCollectionBehavior: behavior]; + + let _: () = msg_send![ns_window, setHidesOnDeactivate: false]; + let _: () = msg_send![ns_window, orderFrontRegardless]; + + log::info!("macOS overlay: level={target_level}, behavior={behavior}"); + }, + Err(err) => { + log::warn!("macOS overlay: failed to get NSWindow handle: {err}"); + } + } +} + +/// 드래그 가능한 영역에서 시스템 컨텍스트 메뉴(이전 크기, 이동, 최소화 등)가 표시되지 않도록 설정 +/// WM_INITMENU 메시지를 후킹하여 메뉴가 초기화될 때 창을 비활성화했다 활성화하여 메뉴를 취소시키는 방식 +/// (Electron의 hookWindowMessage 방식과 동일) +#[cfg(target_os = "windows")] +pub(super) fn disable_system_context_menu(window: &WebviewWindow) -> Result<()> { + use windows::Win32::{ + Foundation::{HWND, LPARAM, LRESULT, WPARAM}, + UI::{ + Shell::{DefSubclassProc, SetWindowSubclass}, + WindowsAndMessaging::{GetSystemMenu, WM_INITMENU}, + }, + }; + + // EnableWindow는 user32.dll에서 직접 호출 + #[link(name = "user32")] + extern "system" { + fn EnableWindow(hwnd: isize, enable: i32) -> i32; + } + + const SUBCLASS_ID: usize = 1; + + unsafe extern "system" fn subclass_proc( + hwnd: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, + _uid_subclass: usize, + _dw_ref_data: usize, + ) -> LRESULT { + if msg == WM_INITMENU { + let system_menu = GetSystemMenu(hwnd, false); + let is_system_menu = !system_menu.0.is_null() && (system_menu.0 as usize == wparam.0); + if is_system_menu { + // 시스템 메뉴만 차단하고, 앱이 띄우는 커스텀 메뉴(Menu.popup)는 허용 + EnableWindow(hwnd.0 as isize, 0); // FALSE + EnableWindow(hwnd.0 as isize, 1); // TRUE + return LRESULT(0); + } + } + DefSubclassProc(hwnd, msg, wparam, lparam) + } + + let hwnd = window.hwnd()?; + let hwnd_win = HWND(hwnd.0); + + unsafe { + SetWindowSubclass(hwnd_win, Some(subclass_proc), SUBCLASS_ID, 0) + .ok() + .map_err(|e| anyhow!("SetWindowSubclass failed: {e}"))?; + } + + Ok(()) +} From 7d4f2c4e1e8bf5b630edc443467982d25140100c Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 16:34:19 +0900 Subject: [PATCH 132/245] =?UTF-8?q?test:=20=EC=B9=B4=EC=9A=B4=ED=84=B0=20?= =?UTF-8?q?=EB=9F=B0=ED=83=80=EC=9E=84=20=EC=A2=85=EB=A3=8C=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=84=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hooks/app/appCounterRuntime.test.ts | 112 ++++++++++++++++++ ...seAppBootstrap.counterDelayTimers.test.tsx | 52 ++++++++ 2 files changed, 164 insertions(+) create mode 100644 src/renderer/hooks/app/appCounterRuntime.test.ts diff --git a/src/renderer/hooks/app/appCounterRuntime.test.ts b/src/renderer/hooks/app/appCounterRuntime.test.ts new file mode 100644 index 000000000..48b106cbe --- /dev/null +++ b/src/renderer/hooks/app/appCounterRuntime.test.ts @@ -0,0 +1,112 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CanonicalBootstrapPayload } from '@src/types/app'; + +const mocks = vi.hoisted(() => ({ + applyCounterCacheSnapshot: vi.fn(), + applyCounterSnapshot: vi.fn(), + setCachedKeyCounter: vi.fn(), + setKeyCounter: vi.fn(), +})); + +vi.mock('@api/pluginDisplayElements', () => ({ + getUndoRedoInProgress: vi.fn(() => false), +})); +vi.mock('@stores/signals/keyCounterCache', () => ({ + applyCounterCacheSnapshot: mocks.applyCounterCacheSnapshot, + setCachedKeyCounter: mocks.setCachedKeyCounter, +})); +vi.mock('@stores/signals/keyCounterSignals', () => ({ + applyCounterSnapshot: mocks.applyCounterSnapshot, + setKeyCounter: mocks.setKeyCounter, +})); +vi.mock('@stores/data/useKeyStore', () => ({ + useKeyStore: { + getState: () => ({ selectedKeyType: '4key' }), + }, +})); +vi.mock('@stores/useSettingsStore', () => ({ + useSettingsStore: { + getState: () => ({ + noteSettings: { keyDisplayDelayMs: 30000 }, + tabNoteOverrides: {}, + }), + }, +})); + +import { createAppCounterRuntime } from './appCounterRuntime'; + +describe('appCounterRuntime 재동기화 수명주기', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('in-flight load 중 dispose하면 snapshot·editor sync·지연 반영 없이 promise를 종료한다', async () => { + let resolveBootstrap!: (bootstrap: CanonicalBootstrapPayload) => void; + const bootstrapPromise = new Promise( + (resolve) => { + resolveBootstrap = resolve; + }, + ); + const loadBootstrap = vi.fn(() => bootstrapPromise); + const applySnapshot = vi.fn(); + const syncEditor = vi.fn(async () => undefined); + const runtime = createAppCounterRuntime(true); + runtime.markInitialApplied(); + + const resyncPromise = runtime.runResync({ + loadBootstrap, + applySnapshot, + syncEditor, + }); + runtime.handleCounterChanged({ + mode: '4key', + key: 'KeyK', + count: 7, + sessionId: 'session-a', + revision: 1, + }); + expect(vi.getTimerCount()).toBe(1); + + runtime.dispose(); + expect(vi.getTimerCount()).toBe(0); + resolveBootstrap({} as CanonicalBootstrapPayload); + + await expect(resyncPromise).resolves.toBeUndefined(); + vi.runAllTimers(); + expect(applySnapshot).not.toHaveBeenCalled(); + expect(syncEditor).not.toHaveBeenCalled(); + expect(mocks.applyCounterSnapshot).not.toHaveBeenCalled(); + expect(mocks.setKeyCounter).not.toHaveBeenCalled(); + + await runtime.runResync({ loadBootstrap, applySnapshot, syncEditor }); + expect(loadBootstrap).toHaveBeenCalledTimes(1); + }); + + it('초기 gate 전 queued resync를 gate 개방 뒤 한 번만 실행한다', async () => { + const loadBootstrap = vi + .fn<() => Promise>() + .mockResolvedValue({} as CanonicalBootstrapPayload); + const applySnapshot = vi.fn(); + const syncEditor = vi.fn(async () => undefined); + const runtime = createAppCounterRuntime(true); + const options = { loadBootstrap, applySnapshot, syncEditor }; + + await runtime.runResync(options); + await runtime.runResync(options); + await runtime.runResync(options); + + expect(loadBootstrap).not.toHaveBeenCalled(); + expect(runtime.markInitialApplied()).toBe(true); + await runtime.runResync(options); + + expect(loadBootstrap).toHaveBeenCalledTimes(1); + expect(applySnapshot).toHaveBeenCalledTimes(1); + expect(syncEditor).toHaveBeenCalledTimes(1); + runtime.dispose(); + }); +}); diff --git a/src/renderer/hooks/app/useAppBootstrap.counterDelayTimers.test.tsx b/src/renderer/hooks/app/useAppBootstrap.counterDelayTimers.test.tsx index 1e0fe5b6b..5d0186647 100644 --- a/src/renderer/hooks/app/useAppBootstrap.counterDelayTimers.test.tsx +++ b/src/renderer/hooks/app/useAppBootstrap.counterDelayTimers.test.tsx @@ -707,6 +707,58 @@ describe('카운터 지연 타이머', () => { expect(getKeyCounterSignal('4key', 'KeyK').value).toBe(2); }); + it('초기 gate 전 queued resync는 bootstrap 실패 뒤 한 번만 실행해 복구한다', async () => { + act(() => root.unmount()); + mounted = false; + + let rejectInitial!: (reason?: unknown) => void; + const initialBootstrap = new Promise( + (_resolve, reject) => { + rejectInitial = reject; + }, + ); + const failure = new Error('initial bootstrap failed'); + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + mocks.bootstrap.mockReset(); + mocks.bootstrap + .mockReturnValueOnce(initialBootstrap) + .mockResolvedValueOnce(makeBootstrap({ '4key': { KeyK: 6 } }, 30000, 20)); + mocks.resyncListener = null; + mocks.keyState = { + ...mocks.keyState, + isBootstrapped: false, + }; + setKeyCounter('4key', 'KeyK', 0); + + root = createRoot(container); + mounted = true; + act(() => { + root.render(); + }); + expect(mocks.bootstrap).toHaveBeenCalledTimes(1); + + act(() => { + mocks.resyncListener?.(); + mocks.resyncListener?.(); + mocks.resyncListener?.(); + }); + expect(mocks.bootstrap).toHaveBeenCalledTimes(1); + + await act(async () => { + rejectInitial(failure); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(consoleError).toHaveBeenCalledWith('초기 부트스트랩 실패', failure); + expect(mocks.bootstrap).toHaveBeenCalledTimes(2); + expect(mocks.keyState.isBootstrapped).toBe(true); + expect(getKeyCounterSignal('4key', 'KeyK').value).toBe(6); + }); + it('같은 세션에서 최신 revision보다 오래된 delta를 무시한다', () => { emitCounter(5, '4key', 'KeyK', 5); emitCounter(4, '4key', 'KeyK', 4); From ef3fe378d0359d4a4fa4badf68bbafaef9e5b63c Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 16:34:19 +0900 Subject: [PATCH 133/245] =?UTF-8?q?test:=20=EB=85=B8=EB=B8=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20=ED=85=8C=EB=91=90=EB=A6=AC=20=EC=BB=A8=ED=85=8D?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EA=B3=84=EC=95=BD=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../single/singleGeometryBindings.test.tsx | 144 +++++++++++++----- 1 file changed, 105 insertions(+), 39 deletions(-) diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/singleGeometryBindings.test.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/singleGeometryBindings.test.tsx index 9b7eafb90..dde49b0a1 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/singleGeometryBindings.test.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/singleGeometryBindings.test.tsx @@ -1,6 +1,22 @@ import React, { act, createRef } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ColorModeValue, ColorPair } from '@src/types/color'; +import type { + GradientCanvasAnchor, + GradientPreviewState, + GradientPreviewSurface, +} from '@stores/grid/useGradientEditStore'; + +interface CapturedGradientOptions { + pair: ColorPair; + contextKey?: string; + canvasAnchor?: GradientCanvasAnchor; + canvasSurface?: GradientPreviewSurface; + canvasState?: GradientPreviewState; + onPreview?: (value: ColorModeValue) => void; + onCommit: (value: ColorModeValue) => void; +} const captured = vi.hoisted(() => ({ numbers: new Map< @@ -127,7 +143,8 @@ const captured = vi.hoisted(() => ({ }, ) => void; }>, - swatches: [] as Array<{ onClick: () => void }>, + swatches: [] as Array<{ onClick: () => void; image?: string }>, + gradientOptions: [] as CapturedGradientOptions[], shadows: [] as Array<{ showActiveState?: boolean; onChange: ( @@ -237,7 +254,7 @@ vi.mock('@components/main/Modal/content/pickers/FontPicker', () => ({ }, })); vi.mock('@components/main/Modal/content/pickers/ColorSwatch', () => ({ - ColorSwatchButton: (props: { onClick: () => void }) => { + ColorSwatchButton: (props: { onClick: () => void; image?: string }) => { captured.swatches.push(props); return null; }, @@ -270,43 +287,21 @@ vi.mock('@hooks/useKeySlotCapture', () => ({ }), })); vi.mock('@hooks/pickers/useGradientColorState', () => ({ - useGradientColorState: ({ - onPreview, - onCommit, - }: { - onPreview?: ( - value: - | { mode: 'solid'; color: string } - | { - mode: 'gradient'; - spec: { - angle: number; - stops: Array<{ color: string; pos: number }>; - }; - }, - ) => void; - onCommit: ( - value: - | { mode: 'solid'; color: string } - | { - mode: 'gradient'; - spec: { - angle: number; - stops: Array<{ color: string; pos: number }>; - }; - }, - ) => void; - }) => ({ - pickerColor: '#ffffff', - handlePickerColorChange: (color: string, commit: boolean) => - commit - ? onCommit({ mode: 'solid', color }) - : onPreview?.({ mode: 'solid', color }), - handleGradientSpecSelect: (spec: { - angle: number; - stops: Array<{ color: string; pos: number }>; - }) => onCommit({ mode: 'gradient', spec }), - }), + useGradientColorState: (options: CapturedGradientOptions) => { + captured.gradientOptions.push(options); + const { onPreview, onCommit } = options; + return { + pickerColor: '#ffffff', + handlePickerColorChange: (color: string, commit: boolean) => + commit + ? onCommit({ mode: 'solid', color }) + : onPreview?.({ mode: 'solid', color }), + handleGradientSpecSelect: (spec: { + angle: number; + stops: Array<{ color: string; pos: number }>; + }) => onCommit({ mode: 'gradient', spec }), + }; + }, })); vi.mock('@utils/core/axisEventBus', () => ({ axisEventBus: { subscribe: () => vi.fn() }, @@ -321,7 +316,12 @@ import { } from './SingleSelectionPanel'; import { createDefaultKeyPosition } from '@src/renderer/editor/model/keys'; import { isEditorElementPropertyPatchV1 } from '@src/types/editor'; +import { gradientToCss } from '@src/types/color'; import type { ImageFit } from '@src/types/key/keys'; +import { + DEFAULT_ELEMENT_ACTIVE_BORDER_GRADIENT, + DEFAULT_ELEMENT_BORDER_GRADIENT, +} from '@utils/core/elementDefaults'; type CompatProps = React.ComponentProps & Record; @@ -386,6 +386,7 @@ describe('single geometry input bindings', () => { captured.color = null; captured.colorInputs.length = 0; captured.swatches.length = 0; + captured.gradientOptions.length = 0; captured.shadows.length = 0; captured.nav.activePageKey = null; captured.nav.renderPageKey = null; @@ -813,6 +814,71 @@ describe('single geometry input bindings', () => { expect(legacy).not.toHaveBeenCalled(); }); + it('knob replace 이미지에서도 기본 border gradient와 native picker 문맥을 보존한다', () => { + const position = { + ...createDefaultKeyPosition(), + axisId: 'HIDA:test', + sensitivity: 1, + reverse: false, + inactiveImage: '/images/knob.png', + imageMode: 'replace' as const, + borderColor: undefined, + borderGradient: undefined, + borderWidth: undefined, + }; + act(() => { + root.render( + ()} + renameValue="" + setRenameValue={vi.fn()} + renameCancelledRef={{ current: false }} + handleRenameCommit={vi.fn()} + handleRenameCancel={vi.fn()} + handleRenameStart={vi.fn()} + handleKnobUpdate={vi.fn()} + singleScrollRefFor={() => vi.fn()} + panelElement={null} + useCustomCSS={false} + t={(key) => key} + />, + ); + }); + + expect(captured.swatches[1]?.image).toBe( + gradientToCss(DEFAULT_ELEMENT_BORDER_GRADIENT), + ); + + act(() => captured.swatches[1]?.onClick()); + const borderPicker = captured.gradientOptions.at(-1); + expect(borderPicker?.pair.gradient).toEqual( + DEFAULT_ELEMENT_BORDER_GRADIENT, + ); + expect(borderPicker).toMatchObject({ + contextKey: `knob:4key:${position.id}:borderColor:idle`, + canvasAnchor: { kind: 'knob', id: position.id }, + canvasSurface: 'border', + canvasState: 'idle', + }); + + act(() => captured.color?.onStateModeChange?.('active')); + const activeBorderPicker = captured.gradientOptions.at(-1); + expect(activeBorderPicker?.pair.gradient).toEqual( + DEFAULT_ELEMENT_ACTIVE_BORDER_GRADIENT, + ); + expect(activeBorderPicker).toMatchObject({ + contextKey: `knob:4key:${position.id}:borderColor:active`, + canvasAnchor: { kind: 'knob', id: position.id }, + canvasSurface: 'border', + canvasState: 'active', + }); + }); + it.each([ ['key', true], ['stat', false], From 0a09cf6c28abbb2e00bf51e1443cc3b3da17243a Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 16:37:17 +0900 Subject: [PATCH 134/245] =?UTF-8?q?test:=20=EC=83=89=EC=83=81=20=ED=94=BC?= =?UTF-8?q?=EC=BB=A4=20=EC=9E=85=EB=A0=A5=20=EC=84=B8=EC=85=98=20=EA=B3=84?= =?UTF-8?q?=EC=95=BD=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pickers/ColorPicker.percentInput.test.tsx | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/src/renderer/components/main/Modal/content/pickers/ColorPicker.percentInput.test.tsx b/src/renderer/components/main/Modal/content/pickers/ColorPicker.percentInput.test.tsx index e03ce1d24..008645509 100644 --- a/src/renderer/components/main/Modal/content/pickers/ColorPicker.percentInput.test.tsx +++ b/src/renderer/components/main/Modal/content/pickers/ColorPicker.percentInput.test.tsx @@ -776,6 +776,103 @@ describe('ColorPicker 불투명도 % 입력', () => { }); }); + it('그라데이션 hex Escape는 시작 gradient를 cancel 채널로 넘기고 draft를 복원한다', () => { + const onColorChange = vi.fn(); + const onColorChangeComplete = vi.fn(); + const onInputCancel = vi.fn(); + act(() => + root.render( + , + ), + ); + const top = hexInputs().find((input) => input.placeholder === 'Top')!; + + act(() => top.focus()); + act(() => setInputValue(top, '00FF00')); + onColorChange.mockClear(); + act(() => pressKey(top, 'Escape')); + + expect(top.value).toBe('FF0000'); + expect(onInputCancel).toHaveBeenCalledWith('top', { + type: 'gradient', + top: '#FF0000', + bottom: '#0000FF', + }); + expect(onColorChange).not.toHaveBeenCalled(); + expect(onColorChangeComplete).not.toHaveBeenCalled(); + }); + + it('그라데이션의 유효 preview 뒤 invalid blur는 시작 gradient로 복원하고 확정하지 않는다', () => { + const onColorChange = vi.fn(); + const onColorChangeComplete = vi.fn(); + act(() => + root.render( + , + ), + ); + const top = hexInputs().find((input) => input.placeholder === 'Top')!; + + act(() => top.focus()); + act(() => setInputValue(top, '00FF00')); + act(() => setInputValue(top, '00F')); + act(() => top.blur()); + + expect(top.value).toBe('FF0000'); + expect(onColorChange).toHaveBeenLastCalledWith({ + type: 'gradient', + top: '#FF0000', + bottom: '#0000FF', + }); + expect(onColorChangeComplete).not.toHaveBeenCalled(); + }); + + it('편집 중이 아닐 때 외부 solid color 변경을 hex 입력에 동기화한다', () => { + const onColorChange = vi.fn(); + const onColorChangeComplete = vi.fn(); + const render = (color: string) => + root.render( + , + ); + + act(() => render('#FF0000')); + expect(hexInputs()[0].value).toBe('FF0000'); + + act(() => render('#00FF00')); + expect(hexInputs()[0].value).toBe('00FF00'); + expect(onColorChange).not.toHaveBeenCalled(); + expect(onColorChangeComplete).not.toHaveBeenCalled(); + }); + it('non-solid 8자리 hex는 alpha를 버리지 않고 preview와 complete에 보존한다', () => { const onColorChange = vi.fn(); const onColorChangeComplete = vi.fn(); From 1ca122f1d454d4cc2678efd51c7446ab90bc2db5 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 16:37:17 +0900 Subject: [PATCH 135/245] =?UTF-8?q?refactor:=20=EC=83=89=EC=83=81=20?= =?UTF-8?q?=ED=94=BC=EC=BB=A4=20=EC=9E=85=EB=A0=A5=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Modal/content/pickers/ColorPicker.tsx | 541 ++-------------- .../pickers/useColorPickerInputSession.ts | 578 ++++++++++++++++++ 2 files changed, 636 insertions(+), 483 deletions(-) create mode 100644 src/renderer/components/main/Modal/content/pickers/useColorPickerInputSession.ts diff --git a/src/renderer/components/main/Modal/content/pickers/ColorPicker.tsx b/src/renderer/components/main/Modal/content/pickers/ColorPicker.tsx index cdab50488..4f1e0b004 100644 --- a/src/renderer/components/main/Modal/content/pickers/ColorPicker.tsx +++ b/src/renderer/components/main/Modal/content/pickers/ColorPicker.tsx @@ -1,6 +1,4 @@ -/* eslint-disable react-hooks/set-state-in-effect */ -import React, { useEffect, useRef, useState } from 'react'; -import { flushSync } from 'react-dom'; +import React, { useRef, useState } from 'react'; import { useTranslation } from '@contexts/useTranslation'; import { SaturationArea, @@ -39,13 +37,15 @@ import { StateSwitch, type PaletteValue, } from './ColorPickerControls'; - -type ColorValue = string | GradientColor; +import { + useColorPickerInputSession, + type ColorPickerOpacityTarget as OpacityTarget, + type ColorPickerValue as ColorValue, +} from './useColorPickerInputSession'; // normalizeColorInput 기본색과 동일 const DEFAULT_PICKER_COLOR: ColorObject = parseHexColor('#561ecb') ?? hsvToColorObject({ h: 0, s: 0, v: 100, a: 1 }); -type OpacityTarget = 'solid' | 'top' | 'bottom'; interface ResolvedOpacity { solid: number; @@ -302,487 +302,62 @@ const ColorPickerWrapper = ({ } }; - // onClose를 래핑하여 팔레트 저장 후 호출 - const handleClose = () => { - const ownerDocument = referenceRef.current?.ownerDocument ?? document; - const active = ownerDocument.activeElement; - if ( - active?.matches('input, textarea') && - active.closest('[role="dialog"]') - ) { - flushSync(() => (active as HTMLElement).blur()); - } - saveCurrentColorToPalette(); - onClose?.(); - }; - // undo/redo 반영은 진행 중 드래그를 프리미티브가 커밋 없이 끊는다 - complete가 // 오지 않으므로 여기서 드래그 잠금을 풀어 아래 동기화가 canonical을 따르게 const historyTick = useCommittedApplyStore((state) => state.historyTick); - const historyTickRef = useRef(historyTick); - useEffect(() => { - if (historyTickRef.current !== historyTick) { - historyTickRef.current = historyTick; - isDraggingRef.current = false; - } - // 드래그 중에는 외부 color prop 동기화 건너뜀 - if (isDraggingRef.current) { - return; - } - - const wasGradient = isGradientColor(prevColorRef.current); - const isGradientNow = isGradientColor(color); - - // 사용자가 수동으로 모드를 전환한 직후에는 prop 기반 모드 전환 무시 - if (userSwitchedModeRef.current) { - userSwitchedModeRef.current = false; - } else { - setMode(isGradientNow ? MODES.gradient : MODES.solid); - } - - if (isGradientNow) { - const topHex = color.top.replace('#', '').toUpperCase(); - const bottomHex = color.bottom.replace('#', '').toUpperCase(); - setGradientTop(topHex); - setGradientBottom(bottomHex); - hasSeededGradientFromSolidRef.current = true; - - const targetHex = gradientSelected === 'bottom' ? bottomHex : topHex; - const parsedTarget = parseHexColor(targetHex); - if (parsedTarget) { - // 같은 색이면 유지 — hex 왕복으로 hue(360°, s=0 등)가 소실되지 않도록 - setSelectedColor((prev) => - prev.hex === parsedTarget.hex ? prev : parsedTarget, - ); - } - - if (!wasGradient) { - setGradientSelected('top'); - } else if (gradientSelected !== 'top' && gradientSelected !== 'bottom') { - setGradientSelected('top'); - } - } else if (typeof color === 'string') { - const normalized = normalizeColorInput(color); - const parsed = toColorObject(normalized); - if (parsed) { - // 같은 색이면 hsv 유지(hex 왕복의 hue 소실 방지)하되 alpha는 병합 — - // alpha만 바뀐 외부 변경이 슬라이더 노브에 반영되도록 - setSelectedColor((prev) => { - if (prev.hex !== parsed.hex) return parsed; - const nextAlpha = parsed.rgb.a ?? 1; - if (prev.rgb.a === nextAlpha) return prev; - return { - ...prev, - rgb: { ...prev.rgb, a: nextAlpha }, - hsv: { ...prev.hsv, a: nextAlpha }, - }; - }); - // RGBA에서 alpha 추출하여 설정 - const newAlpha = extractAlphaFromColor(color); - setAlpha(newAlpha); - - if ( - !suppressGradientResetRef.current && - !hasSeededGradientFromSolidRef.current - ) { - setGradientTop(parsed.hex.replace('#', '')); - setGradientBottom('FFFFFF'); - } - } - setGradientSelected('top'); - // 한 번만 억제 플래그를 사용 - suppressGradientResetRef.current = false; - } - - prevColorRef.current = color; - }, [color, gradientSelected, setSelectedColor, historyTick]); - - const [inputValue, setInputValue] = useState(() => - selectedColor.hex - .replace('#', '') - .toUpperCase() - .slice(0, solidOnly ? 6 : 8), - ); + const { + inputValue, + setAlphaWithSync, + handleChange, + handleChangeComplete, + startSolidInputEdit, + handleInputChange, + commitSolidInput, + cancelSolidInput, + startAlphaEdit, + previewAlphaPercent, + commitAlphaPercent, + cancelAlphaPercent, + startGradientInputEdit, + handleGradientInputChange, + commitGradientInput, + cancelGradientInput, + selectGradient, + handleModeSwitch, + flushActiveInput, + } = useColorPickerInputSession({ + referenceRef, + color, + solidOnly, + hexMixed, + mode, + setMode, + selectedColor, + setSelectedColor, + alpha, + setAlpha, + gradientTop, + setGradientTop, + gradientBottom, + setGradientBottom, + gradientSelected, + setGradientSelected, + suppressGradientResetRef, + isDraggingRef, + hasSeededGradientFromSolidRef, + userSwitchedModeRef, + prevColorRef, + historyTick, + onColorChange, + onColorChangeComplete, + onInputCancel, + onOpacityPercentCancel, + }); - // 이번 편집에서 hex를 실제로 쳤는지. Mixed 상태에서 손대지 않은 blur가 - // 대표값을 선택 전체에 확정해 항목별 값을 지우는 일을 막는다 - const hexDirtyRef = useRef(false); - const solidInputEditRef = useRef<{ - inputValue: string; - color: ColorValue; - selectedColor: ColorObject; - alpha: number; - previewed: boolean; - } | null>(null); - const gradientInputEditRef = useRef<{ - side: GradientSide; - top: string; - bottom: string; - color: GradientColor; - selectedColor: ColorObject; - dirty: boolean; - previewed: boolean; - } | null>(null); - - useEffect(() => { - if (solidInputEditRef.current) return; - setInputValue( - selectedColor.hex - .replace('#', '') - .toUpperCase() - .slice(0, solidOnly ? 6 : 8), - ); - }, [selectedColor.hex, solidOnly]); - - // solidOnly 모드에서 Alpha 값 변경 반영 - useEffect 제거하여 무한 루프 방지 - // Alpha 슬라이더는 onChangeComplete에서 처리 - - const buildRgbaFromHexAndAlpha = (hex: string, nextAlpha: number): string => { - const r = parseInt(hex.slice(1, 3), 16); - const g = parseInt(hex.slice(3, 5), 16); - const b = parseInt(hex.slice(5, 7), 16); - return `rgba(${r}, ${g}, ${b}, ${nextAlpha})`; - }; - - const setAlphaWithSync = (nextAlpha: number, isComplete: boolean = false) => { - const clamped = Math.min(Math.max(Number(nextAlpha) || 0, 0), 1); - setAlpha(clamped); - setSelectedColor((prev: ColorObject) => { - if (!prev) return prev; - return { - ...prev, - rgb: { ...prev.rgb, a: clamped }, - hsv: { ...prev.hsv, a: clamped }, - }; - }); - - if (!solidOnly) return; - const rgbaValue = buildRgbaFromHexAndAlpha(selectedColor.hex, clamped); - onColorChange?.(rgbaValue); - if (isComplete) { - onColorChangeComplete?.(rgbaValue); - } - }; - - const applyColor = ( - next: string | Partial, - isComplete: boolean = false, - ) => { - const parsed = toColorObject(next); - if (!parsed) return; - setSelectedColor(parsed); - if (!solidOnly && mode === MODES.gradient) { - // 그라디언트 모드에서 Saturation/Hue 편집 시 선택된 stop 업데이트 - const newHex = parsed.hex.replace('#', '').toUpperCase(); - - if (gradientSelected === 'top') { - setGradientTop(newHex); - const gradient = buildGradient(parsed.hex, `#${gradientBottom}`); - onColorChange?.(gradient); - if (isComplete) onColorChangeComplete?.(gradient); - } else { - setGradientBottom(newHex); - const gradient = buildGradient(`#${gradientTop}`, parsed.hex); - onColorChange?.(gradient); - if (isComplete) onColorChangeComplete?.(gradient); - } - return; - } - if (solidOnly) { - // solidOnly 모드에서는 현재 alpha 값을 유지 - const rgbaValue = `rgba(${parseInt( - parsed.hex.slice(1, 3), - 16, - )}, ${parseInt(parsed.hex.slice(3, 5), 16)}, ${parseInt( - parsed.hex.slice(5, 7), - 16, - )}, ${alpha})`; - onColorChange?.(rgbaValue); - if (isComplete) onColorChangeComplete?.(rgbaValue); - } else { - onColorChange?.(parsed.hex); - if (isComplete) onColorChangeComplete?.(parsed.hex); - } - }; - - const handleChange = (nextColor: ColorObject) => { - isDraggingRef.current = true; - applyColor(nextColor, false); - }; - - const handleChangeComplete = (nextColor: ColorObject) => { - applyColor(nextColor, true); - isDraggingRef.current = false; - }; - - const validHexDraft = (value: string, allowAlpha: boolean): boolean => - value.length === 6 || (allowAlpha && value.length === 8); - - const solidOutput = (parsed: ColorObject, draft: string): string => - solidOnly - ? buildRgbaFromHexAndAlpha(parsed.hex, alpha) - : draft.length === 8 - ? `#${draft}` - : parsed.hex; - - const startSolidInputEdit = () => { - const baseColor = solidOnly - ? buildRgbaFromHexAndAlpha(selectedColor.hex, alpha) - : inputValue.length === 8 - ? `#${inputValue}` - : selectedColor.hex; - solidInputEditRef.current = { - inputValue, - color: baseColor, - selectedColor, - alpha, - previewed: false, - }; - hexDirtyRef.current = false; - // Mixed 필드는 빈 칸에서 시작한다. 대표값을 띄우면 공통값처럼 읽힌다 - if (hexMixed) setInputValue(''); - }; - - const handleInputChange = (raw: string) => { - const sanitized = raw - .replace(/[^0-9a-fA-F]/g, '') - .slice(0, solidOnly ? 6 : 8) - .toUpperCase(); - if (!solidInputEditRef.current) startSolidInputEdit(); - hexDirtyRef.current = true; - setInputValue(sanitized); - if (!validHexDraft(sanitized, !solidOnly)) return; - const parsed = parseHexColor(sanitized); - if (!parsed) return; - const nextSelected = solidOnly - ? { - ...parsed, - rgb: { ...parsed.rgb, a: alpha }, - hsv: { ...parsed.hsv, a: alpha }, - } - : parsed; - setSelectedColor(nextSelected); - if (solidInputEditRef.current) { - solidInputEditRef.current.previewed = true; - } - onColorChange?.(solidOutput(parsed, sanitized)); - }; - - const restoreSolidInput = () => { - const edit = solidInputEditRef.current; - if (!edit) return; - setInputValue(edit.inputValue); - setSelectedColor(edit.selectedColor); - setAlpha(edit.alpha); - hexDirtyRef.current = false; - solidInputEditRef.current = null; - if (!edit.previewed) return; - if (onInputCancel) { - onInputCancel('solid', edit.color); - } else if (!hexMixed) { - onColorChange?.(edit.color); - } - }; - - const commitSolidInput = () => { - if (!hexDirtyRef.current) { - const edit = solidInputEditRef.current; - if (edit) setInputValue(edit.inputValue); - solidInputEditRef.current = null; - return; - } - if (!validHexDraft(inputValue, !solidOnly)) { - restoreSolidInput(); - return; - } - const parsed = parseHexColor(inputValue); - if (!parsed) { - restoreSolidInput(); - return; - } - hexDirtyRef.current = false; - solidInputEditRef.current = null; - onColorChangeComplete?.(solidOutput(parsed, inputValue)); - }; - - const cancelSolidInput = (): boolean => { - if (!hexDirtyRef.current) return false; - restoreSolidInput(); - return true; - }; - - // 입력은 NumberInput이 0~100으로 재운 값만 넘긴다 - const previewAlphaPercent = (percent: number) => { - setAlphaWithSync(percent / 100, false); - }; - - const commitAlphaPercent = (percent: number) => { - setAlphaWithSync(percent / 100, true); - }; - - // Escape 원복 기준. Mixed에서는 NumberInput이 대표값을 다시 발행하지 않으므로 - // 편집 전 alpha를 여기서 기억해 두었다가 되돌린다 - const alphaEditBaseRef = useRef(alpha); - - const startAlphaEdit = () => { - alphaEditBaseRef.current = alpha; - }; - - const cancelAlphaPercent = () => { - const base = alphaEditBaseRef.current; - const restoredColor = buildRgbaFromHexAndAlpha(selectedColor.hex, base); - if (onInputCancel) { - setAlpha(base); - setSelectedColor((prev) => ({ - ...prev, - rgb: { ...prev.rgb, a: base }, - hsv: { ...prev.hsv, a: base }, - })); - onInputCancel('solid', restoredColor); - return; - } - if (onOpacityPercentCancel) { - // 호출부가 게스처를 가지면 preview를 내지 않고 조용히 되돌린 뒤 맡긴다. - // 대표값 preview는 선택 전체를 평탄화한다 - setAlpha(base); - setSelectedColor((prev) => ({ - ...prev, - rgb: { ...prev.rgb, a: base }, - hsv: { ...prev.hsv, a: base }, - })); - onOpacityPercentCancel('solid'); - return; - } - setAlphaWithSync(base, false); - }; - - const commitGradient = () => { - const parsedTop = parseHexColor(gradientTop); - const parsedBottom = parseHexColor(gradientBottom); - if (!parsedTop || !parsedBottom) { - return; - } - setSelectedColor(parsedTop); - const gradient = buildGradient(`#${gradientTop}`, `#${gradientBottom}`); - onColorChange?.(gradient); - onColorChangeComplete?.(gradient); - }; - - const startGradientInputEdit = (side: GradientSide) => { - const selectedValue = side === 'top' ? gradientTop : gradientBottom; - const parsed = parseHexColor(selectedValue) ?? selectedColor; - setGradientSelected(side); - setSelectedColor(parsed); - gradientInputEditRef.current = { - side, - top: gradientTop, - bottom: gradientBottom, - color: buildGradient(`#${gradientTop}`, `#${gradientBottom}`), - selectedColor: parsed, - dirty: false, - previewed: false, - }; - }; - - const handleGradientInputChange = (side: GradientSide, raw: string) => { - const sanitized = raw - .replace(/[^0-9a-fA-F]/g, '') - .slice(0, 8) - .toUpperCase(); - if (gradientInputEditRef.current?.side !== side) { - startGradientInputEdit(side); - } - const edit = gradientInputEditRef.current; - if (edit) edit.dirty = true; - if (side === 'top') setGradientTop(sanitized); - else setGradientBottom(sanitized); - if (!validHexDraft(sanitized, true)) return; - const parsed = parseHexColor(sanitized); - const other = parseHexColor(side === 'top' ? gradientBottom : gradientTop); - if (!parsed || !other) return; - setSelectedColor(parsed); - if (edit) edit.previewed = true; - onColorChange?.( - side === 'top' - ? buildGradient(`#${sanitized}`, `#${gradientBottom}`) - : buildGradient(`#${gradientTop}`, `#${sanitized}`), - ); - }; - - const restoreGradientInput = () => { - const edit = gradientInputEditRef.current; - if (!edit) return; - setGradientTop(edit.top); - setGradientBottom(edit.bottom); - setSelectedColor(edit.selectedColor); - setGradientSelected(edit.side); - gradientInputEditRef.current = null; - if (!edit.previewed) return; - if (onInputCancel) onInputCancel(edit.side, edit.color); - else onColorChange?.(edit.color); - }; - - const commitGradientInput = (side: GradientSide) => { - const edit = gradientInputEditRef.current; - if (!edit?.dirty) { - gradientInputEditRef.current = null; - return; - } - if ( - !validHexDraft(gradientTop, true) || - !validHexDraft(gradientBottom, true) - ) { - restoreGradientInput(); - return; - } - const parsedTop = parseHexColor(gradientTop); - const parsedBottom = parseHexColor(gradientBottom); - if (!parsedTop || !parsedBottom) { - restoreGradientInput(); - return; - } - setGradientSelected(side); - setSelectedColor(side === 'top' ? parsedTop : parsedBottom); - gradientInputEditRef.current = null; - onColorChangeComplete?.( - buildGradient(`#${gradientTop}`, `#${gradientBottom}`), - ); - }; - - const cancelGradientInput = (side: GradientSide): boolean => { - const edit = gradientInputEditRef.current; - if (!edit || edit.side !== side || !edit.dirty) return false; - restoreGradientInput(); - return true; - }; - - const selectGradient = (side: GradientSide) => { - setGradientSelected(side); - const hex = `#${side === 'top' ? gradientTop : gradientBottom}`; - const parsed = parseHexColor(hex); - if (parsed) setSelectedColor(parsed); - }; - - const handleModeSwitch = (nextMode: string) => { - if (nextMode === mode) return; - userSwitchedModeRef.current = true; - setMode(nextMode); - if (nextMode === MODES.solid) { - // 그라디언트 -> 솔리드 전환 시, 부모로 전달되는 단색 변경에 따라 - // 내부 그라디언트 상태가 초기화되지 않도록 억제 플래그 설정 - suppressGradientResetRef.current = true; - const parsed = parseHexColor(gradientTop || inputValue); - if (parsed) { - setSelectedColor(parsed); - onColorChange?.(parsed.hex); - onColorChangeComplete?.(parsed.hex); - } - } else { - // 그라디언트 모드에 진입함을 표시(이후부터는 시드 금지) - hasSeededGradientFromSolidRef.current = true; - setGradientSelected('top'); - commitGradient(); - } + const handleClose = () => { + flushActiveInput(); + saveCurrentColorToPalette(); + onClose?.(); }; const showStateSwitch = diff --git a/src/renderer/components/main/Modal/content/pickers/useColorPickerInputSession.ts b/src/renderer/components/main/Modal/content/pickers/useColorPickerInputSession.ts new file mode 100644 index 000000000..b429de03b --- /dev/null +++ b/src/renderer/components/main/Modal/content/pickers/useColorPickerInputSession.ts @@ -0,0 +1,578 @@ +import { useEffect, useRef, useState } from 'react'; +import type React from 'react'; +import { flushSync } from 'react-dom'; +import { + MODES, + buildGradient, + isGradientColor, + normalizeColorInput, + parseHexColor, + toColorObject, + type ColorObject, + type GradientColor, +} from '@utils/color/colorUtils'; +import type { GradientSide } from './ColorPickerInputs'; + +export type ColorPickerValue = string | GradientColor; +export type ColorPickerOpacityTarget = 'solid' | 'top' | 'bottom'; + +interface UseColorPickerInputSessionOptions { + referenceRef: React.RefObject; + color: ColorPickerValue; + solidOnly: boolean; + hexMixed: boolean; + mode: string; + setMode: React.Dispatch>; + selectedColor: ColorObject; + setSelectedColor: React.Dispatch>; + alpha: number; + setAlpha: React.Dispatch>; + gradientTop: string; + setGradientTop: React.Dispatch>; + gradientBottom: string; + setGradientBottom: React.Dispatch>; + gradientSelected: GradientSide; + setGradientSelected: React.Dispatch>; + suppressGradientResetRef: React.MutableRefObject; + isDraggingRef: React.MutableRefObject; + hasSeededGradientFromSolidRef: React.MutableRefObject; + userSwitchedModeRef: React.MutableRefObject; + prevColorRef: React.MutableRefObject; + historyTick: number; + onColorChange?: (color: ColorPickerValue) => void; + onColorChangeComplete?: (color: ColorPickerValue) => void; + onInputCancel?: ( + target: ColorPickerOpacityTarget, + restoredColor: ColorPickerValue, + ) => void; + onOpacityPercentCancel?: (target: ColorPickerOpacityTarget) => void; +} + +export const useColorPickerInputSession = ({ + referenceRef, + color, + solidOnly, + hexMixed, + mode, + setMode, + selectedColor, + setSelectedColor, + alpha, + setAlpha, + gradientTop, + setGradientTop, + gradientBottom, + setGradientBottom, + gradientSelected, + setGradientSelected, + suppressGradientResetRef, + isDraggingRef, + hasSeededGradientFromSolidRef, + userSwitchedModeRef, + prevColorRef, + historyTick, + onColorChange, + onColorChangeComplete, + onInputCancel, + onOpacityPercentCancel, +}: UseColorPickerInputSessionOptions) => { + const historyTickRef = useRef(historyTick); + useEffect(() => { + if (historyTickRef.current !== historyTick) { + historyTickRef.current = historyTick; + isDraggingRef.current = false; + } + // 드래그 중 외부 color prop 동기화 제외 + if (isDraggingRef.current) { + return; + } + + const wasGradient = isGradientColor(prevColorRef.current); + const isGradientNow = isGradientColor(color); + + if (userSwitchedModeRef.current) { + userSwitchedModeRef.current = false; + } else { + setMode(isGradientNow ? MODES.gradient : MODES.solid); + } + + if (isGradientNow) { + const topHex = color.top.replace('#', '').toUpperCase(); + const bottomHex = color.bottom.replace('#', '').toUpperCase(); + setGradientTop(topHex); + setGradientBottom(bottomHex); + hasSeededGradientFromSolidRef.current = true; + + const targetHex = gradientSelected === 'bottom' ? bottomHex : topHex; + const parsedTarget = parseHexColor(targetHex); + if (parsedTarget) { + // 동일 색상의 HSV 보존 + setSelectedColor((prev) => + prev.hex === parsedTarget.hex ? prev : parsedTarget, + ); + } + + if (!wasGradient) { + setGradientSelected('top'); + } else if (gradientSelected !== 'top' && gradientSelected !== 'bottom') { + setGradientSelected('top'); + } + } else if (typeof color === 'string') { + const normalized = normalizeColorInput(color); + const parsed = toColorObject(normalized); + if (parsed) { + // 동일 색상의 HSV와 외부 alpha 병합 + setSelectedColor((prev) => { + if (prev.hex !== parsed.hex) return parsed; + const nextAlpha = parsed.rgb.a ?? 1; + if (prev.rgb.a === nextAlpha) return prev; + return { + ...prev, + rgb: { ...prev.rgb, a: nextAlpha }, + hsv: { ...prev.hsv, a: nextAlpha }, + }; + }); + const newAlpha = extractAlphaFromColor(color); + setAlpha(newAlpha); + + if ( + !suppressGradientResetRef.current && + !hasSeededGradientFromSolidRef.current + ) { + setGradientTop(parsed.hex.replace('#', '')); + setGradientBottom('FFFFFF'); + } + } + setGradientSelected('top'); + suppressGradientResetRef.current = false; + } + + prevColorRef.current = color; + // 부모 setter/ref는 안정 참조이며 canonical 신호만 재동기화 경계를 구성 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [color, gradientSelected, setSelectedColor, historyTick]); + + const [inputValue, setInputValue] = useState(() => + selectedColor.hex + .replace('#', '') + .toUpperCase() + .slice(0, solidOnly ? 6 : 8), + ); + + const hexDirtyRef = useRef(false); + const solidInputEditRef = useRef<{ + inputValue: string; + color: ColorPickerValue; + selectedColor: ColorObject; + alpha: number; + previewed: boolean; + } | null>(null); + const gradientInputEditRef = useRef<{ + side: GradientSide; + top: string; + bottom: string; + color: GradientColor; + selectedColor: ColorObject; + dirty: boolean; + previewed: boolean; + } | null>(null); + + useEffect(() => { + if (solidInputEditRef.current) return; + setInputValue( + selectedColor.hex + .replace('#', '') + .toUpperCase() + .slice(0, solidOnly ? 6 : 8), + ); + }, [selectedColor.hex, solidOnly]); + + const buildRgbaFromHexAndAlpha = (hex: string, nextAlpha: number): string => { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `rgba(${r}, ${g}, ${b}, ${nextAlpha})`; + }; + + const setAlphaWithSync = (nextAlpha: number, isComplete: boolean = false) => { + const clamped = Math.min(Math.max(Number(nextAlpha) || 0, 0), 1); + setAlpha(clamped); + setSelectedColor((prev: ColorObject) => { + if (!prev) return prev; + return { + ...prev, + rgb: { ...prev.rgb, a: clamped }, + hsv: { ...prev.hsv, a: clamped }, + }; + }); + + if (!solidOnly) return; + const rgbaValue = buildRgbaFromHexAndAlpha(selectedColor.hex, clamped); + onColorChange?.(rgbaValue); + if (isComplete) { + onColorChangeComplete?.(rgbaValue); + } + }; + + const applyColor = ( + next: string | Partial, + isComplete: boolean = false, + ) => { + const parsed = toColorObject(next); + if (!parsed) return; + setSelectedColor(parsed); + if (!solidOnly && mode === MODES.gradient) { + const newHex = parsed.hex.replace('#', '').toUpperCase(); + + if (gradientSelected === 'top') { + setGradientTop(newHex); + const gradient = buildGradient(parsed.hex, `#${gradientBottom}`); + onColorChange?.(gradient); + if (isComplete) onColorChangeComplete?.(gradient); + } else { + setGradientBottom(newHex); + const gradient = buildGradient(`#${gradientTop}`, parsed.hex); + onColorChange?.(gradient); + if (isComplete) onColorChangeComplete?.(gradient); + } + return; + } + if (solidOnly) { + const rgbaValue = `rgba(${parseInt( + parsed.hex.slice(1, 3), + 16, + )}, ${parseInt(parsed.hex.slice(3, 5), 16)}, ${parseInt( + parsed.hex.slice(5, 7), + 16, + )}, ${alpha})`; + onColorChange?.(rgbaValue); + if (isComplete) onColorChangeComplete?.(rgbaValue); + } else { + onColorChange?.(parsed.hex); + if (isComplete) onColorChangeComplete?.(parsed.hex); + } + }; + + const handleChange = (nextColor: ColorObject) => { + isDraggingRef.current = true; + applyColor(nextColor, false); + }; + + const handleChangeComplete = (nextColor: ColorObject) => { + applyColor(nextColor, true); + isDraggingRef.current = false; + }; + + const validHexDraft = (value: string, allowAlpha: boolean): boolean => + value.length === 6 || (allowAlpha && value.length === 8); + + const solidOutput = (parsed: ColorObject, draft: string): string => + solidOnly + ? buildRgbaFromHexAndAlpha(parsed.hex, alpha) + : draft.length === 8 + ? `#${draft}` + : parsed.hex; + + const startSolidInputEdit = () => { + const baseColor = solidOnly + ? buildRgbaFromHexAndAlpha(selectedColor.hex, alpha) + : inputValue.length === 8 + ? `#${inputValue}` + : selectedColor.hex; + solidInputEditRef.current = { + inputValue, + color: baseColor, + selectedColor, + alpha, + previewed: false, + }; + hexDirtyRef.current = false; + if (hexMixed) setInputValue(''); + }; + + const handleInputChange = (raw: string) => { + const sanitized = raw + .replace(/[^0-9a-fA-F]/g, '') + .slice(0, solidOnly ? 6 : 8) + .toUpperCase(); + if (!solidInputEditRef.current) startSolidInputEdit(); + hexDirtyRef.current = true; + setInputValue(sanitized); + if (!validHexDraft(sanitized, !solidOnly)) return; + const parsed = parseHexColor(sanitized); + if (!parsed) return; + const nextSelected = solidOnly + ? { + ...parsed, + rgb: { ...parsed.rgb, a: alpha }, + hsv: { ...parsed.hsv, a: alpha }, + } + : parsed; + setSelectedColor(nextSelected); + if (solidInputEditRef.current) { + solidInputEditRef.current.previewed = true; + } + onColorChange?.(solidOutput(parsed, sanitized)); + }; + + const restoreSolidInput = () => { + const edit = solidInputEditRef.current; + if (!edit) return; + setInputValue(edit.inputValue); + setSelectedColor(edit.selectedColor); + setAlpha(edit.alpha); + hexDirtyRef.current = false; + solidInputEditRef.current = null; + if (!edit.previewed) return; + if (onInputCancel) { + onInputCancel('solid', edit.color); + } else if (!hexMixed) { + onColorChange?.(edit.color); + } + }; + + const commitSolidInput = () => { + if (!hexDirtyRef.current) { + const edit = solidInputEditRef.current; + if (edit) setInputValue(edit.inputValue); + solidInputEditRef.current = null; + return; + } + if (!validHexDraft(inputValue, !solidOnly)) { + restoreSolidInput(); + return; + } + const parsed = parseHexColor(inputValue); + if (!parsed) { + restoreSolidInput(); + return; + } + hexDirtyRef.current = false; + solidInputEditRef.current = null; + onColorChangeComplete?.(solidOutput(parsed, inputValue)); + }; + + const cancelSolidInput = (): boolean => { + if (!hexDirtyRef.current) return false; + restoreSolidInput(); + return true; + }; + + const previewAlphaPercent = (percent: number) => { + setAlphaWithSync(percent / 100, false); + }; + + const commitAlphaPercent = (percent: number) => { + setAlphaWithSync(percent / 100, true); + }; + + const alphaEditBaseRef = useRef(alpha); + + const startAlphaEdit = () => { + alphaEditBaseRef.current = alpha; + }; + + const cancelAlphaPercent = () => { + const base = alphaEditBaseRef.current; + const restoredColor = buildRgbaFromHexAndAlpha(selectedColor.hex, base); + if (onInputCancel) { + setAlpha(base); + setSelectedColor((prev) => ({ + ...prev, + rgb: { ...prev.rgb, a: base }, + hsv: { ...prev.hsv, a: base }, + })); + onInputCancel('solid', restoredColor); + return; + } + if (onOpacityPercentCancel) { + setAlpha(base); + setSelectedColor((prev) => ({ + ...prev, + rgb: { ...prev.rgb, a: base }, + hsv: { ...prev.hsv, a: base }, + })); + onOpacityPercentCancel('solid'); + return; + } + setAlphaWithSync(base, false); + }; + + const commitGradient = () => { + const parsedTop = parseHexColor(gradientTop); + const parsedBottom = parseHexColor(gradientBottom); + if (!parsedTop || !parsedBottom) { + return; + } + setSelectedColor(parsedTop); + const gradient = buildGradient(`#${gradientTop}`, `#${gradientBottom}`); + onColorChange?.(gradient); + onColorChangeComplete?.(gradient); + }; + + const startGradientInputEdit = (side: GradientSide) => { + const selectedValue = side === 'top' ? gradientTop : gradientBottom; + const parsed = parseHexColor(selectedValue) ?? selectedColor; + setGradientSelected(side); + setSelectedColor(parsed); + gradientInputEditRef.current = { + side, + top: gradientTop, + bottom: gradientBottom, + color: buildGradient(`#${gradientTop}`, `#${gradientBottom}`), + selectedColor: parsed, + dirty: false, + previewed: false, + }; + }; + + const handleGradientInputChange = (side: GradientSide, raw: string) => { + const sanitized = raw + .replace(/[^0-9a-fA-F]/g, '') + .slice(0, 8) + .toUpperCase(); + if (gradientInputEditRef.current?.side !== side) { + startGradientInputEdit(side); + } + const edit = gradientInputEditRef.current; + if (edit) edit.dirty = true; + if (side === 'top') setGradientTop(sanitized); + else setGradientBottom(sanitized); + if (!validHexDraft(sanitized, true)) return; + const parsed = parseHexColor(sanitized); + const other = parseHexColor(side === 'top' ? gradientBottom : gradientTop); + if (!parsed || !other) return; + setSelectedColor(parsed); + if (edit) edit.previewed = true; + onColorChange?.( + side === 'top' + ? buildGradient(`#${sanitized}`, `#${gradientBottom}`) + : buildGradient(`#${gradientTop}`, `#${sanitized}`), + ); + }; + + const restoreGradientInput = () => { + const edit = gradientInputEditRef.current; + if (!edit) return; + setGradientTop(edit.top); + setGradientBottom(edit.bottom); + setSelectedColor(edit.selectedColor); + setGradientSelected(edit.side); + gradientInputEditRef.current = null; + if (!edit.previewed) return; + if (onInputCancel) onInputCancel(edit.side, edit.color); + else onColorChange?.(edit.color); + }; + + const commitGradientInput = (side: GradientSide) => { + const edit = gradientInputEditRef.current; + if (!edit?.dirty) { + gradientInputEditRef.current = null; + return; + } + if ( + !validHexDraft(gradientTop, true) || + !validHexDraft(gradientBottom, true) + ) { + restoreGradientInput(); + return; + } + const parsedTop = parseHexColor(gradientTop); + const parsedBottom = parseHexColor(gradientBottom); + if (!parsedTop || !parsedBottom) { + restoreGradientInput(); + return; + } + setGradientSelected(side); + setSelectedColor(side === 'top' ? parsedTop : parsedBottom); + gradientInputEditRef.current = null; + onColorChangeComplete?.( + buildGradient(`#${gradientTop}`, `#${gradientBottom}`), + ); + }; + + const cancelGradientInput = (side: GradientSide): boolean => { + const edit = gradientInputEditRef.current; + if (!edit || edit.side !== side || !edit.dirty) return false; + restoreGradientInput(); + return true; + }; + + const selectGradient = (side: GradientSide) => { + setGradientSelected(side); + const hex = `#${side === 'top' ? gradientTop : gradientBottom}`; + const parsed = parseHexColor(hex); + if (parsed) setSelectedColor(parsed); + }; + + const handleModeSwitch = (nextMode: string) => { + if (nextMode === mode) return; + userSwitchedModeRef.current = true; + setMode(nextMode); + if (nextMode === MODES.solid) { + suppressGradientResetRef.current = true; + const parsed = parseHexColor(gradientTop || inputValue); + if (parsed) { + setSelectedColor(parsed); + onColorChange?.(parsed.hex); + onColorChangeComplete?.(parsed.hex); + } + } else { + hasSeededGradientFromSolidRef.current = true; + setGradientSelected('top'); + commitGradient(); + } + }; + + const flushActiveInput = () => { + const ownerDocument = referenceRef.current?.ownerDocument ?? document; + const active = ownerDocument.activeElement; + if ( + active?.matches('input, textarea') && + active.closest('[role="dialog"]') + ) { + flushSync(() => (active as HTMLElement).blur()); + } + }; + + return { + inputValue, + setAlphaWithSync, + handleChange, + handleChangeComplete, + startSolidInputEdit, + handleInputChange, + commitSolidInput, + cancelSolidInput, + startAlphaEdit, + previewAlphaPercent, + commitAlphaPercent, + cancelAlphaPercent, + startGradientInputEdit, + handleGradientInputChange, + commitGradientInput, + cancelGradientInput, + selectGradient, + handleModeSwitch, + flushActiveInput, + }; +}; + +const extractAlphaFromColor = (colorValue: ColorPickerValue): number => { + if (typeof colorValue === 'string') { + if (colorValue.startsWith('rgba(')) { + const match = colorValue.match( + /rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)/, + ); + if (match) { + return parseFloat(match[4]); + } + } + const normalized = normalizeColorInput(colorValue); + const parsed = parseHexColor(normalized); + if (parsed && parsed.rgb.a !== undefined) { + return parsed.rgb.a; + } + } + return 1; +}; From 7027484f53e1d99872c31f5cba1f9baaa422ba7b Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 16:41:17 +0900 Subject: [PATCH 136/245] =?UTF-8?q?refactor:=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=ED=94=8C=EB=9F=AC=EA=B7=B8=EC=9D=B8=20=EC=83=9D=EB=AA=85?= =?UTF-8?q?=EC=A3=BC=EA=B8=B0=20=EC=A0=9C=EC=96=B4=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/components/main/Settings.tsx | 329 ++--------------- .../settingsPluginLifecycleController.test.ts | 260 +++++++++++++ .../main/settingsPluginLifecycleController.ts | 346 ++++++++++++++++++ 3 files changed, 632 insertions(+), 303 deletions(-) create mode 100644 src/renderer/components/main/settingsPluginLifecycleController.test.ts create mode 100644 src/renderer/components/main/settingsPluginLifecycleController.ts diff --git a/src/renderer/components/main/Settings.tsx b/src/renderer/components/main/Settings.tsx index 8c4f81cf0..af7c7af30 100644 --- a/src/renderer/components/main/Settings.tsx +++ b/src/renderer/components/main/Settings.tsx @@ -25,39 +25,25 @@ import ShortcutsPanelContent from '@components/main/SettingsPanel/ShortcutsPanel import PluginsPanelContent from '@components/main/SettingsPanel/PluginsPanelContent'; import CssPanelContent from '@components/main/SettingsPanel/CssPanelContent'; import KeySoundOutputSettings from '@components/main/SettingsPanel/KeySoundOutputSettings'; +import { createSettingsPluginLifecycleController } from '@components/main/settingsPluginLifecycleController'; +import type { PluginToDelete } from '@components/main/settingsPluginLifecycleController'; import { FILL_DISABLED_CLASS, FILL_INTERACTIVE_CLASS, } from '@components/main/SettingsPanel/panelChrome'; import { applyCounterSnapshot } from '@stores/signals/keyCounterSignals'; -import { - currentPluginHealthRevision, - waitForPluginInjection, -} from '@stores/plugin/usePluginHealthStore'; -import { - extractPluginId, - getPluginDisplayName, -} from '@utils/plugin/pluginUtils'; -import { classifyPluginAddResult } from '@utils/plugin/pluginAddResult'; +import { getPluginDisplayName } from '@utils/plugin/pluginUtils'; import { isMac } from '@utils/core/platform'; import { useUpdateCheck } from '@hooks/app/useUpdateCheck'; import type { OverlayResizeAnchor } from '@src/types/settings/settings'; import type { ShortcutsState } from '@src/types/settings/shortcuts'; import type { SupportedLocale } from '@contexts/I18nContextDef'; -import type { - JsLoadResult, - JsReloadResult, - JsRemoveResult, - JsPluginUpdateResult, - KeysResetAllResponse, -} from '@src/types/plugin/api'; -import type { JsPlugin } from '@src/types/plugin/js'; +import type { KeysResetAllResponse } from '@src/types/plugin/api'; import type { KeyCounters } from '@src/types/key/keys'; import { settingsApi } from '@api/modules/settingsApi'; import { overlayApi } from '@api/modules/overlayApi'; import { cssApi } from '@api/modules/cssApi'; import { jsApi } from '@api/modules/jsApi'; -import { pluginApi } from '@api/modules/pluginApi'; import { keysApi } from '@api/modules/keysApi'; import { appApi, windowApi } from '@api/modules/appApi'; import { obsApi } from '@api/modules/obsApi'; @@ -79,17 +65,6 @@ interface SettingsProps { ) => void; } -interface PluginError { - path?: string; - error: string; -} - -interface PluginToDelete { - id: string; - name: string; - namespace: string; -} - const Settings = ({ showAlert, showConfirm, @@ -311,280 +286,28 @@ const Settings = ({ } }; - const formatPluginErrors = (errors: PluginError[] = []): string => - errors.map((item) => `${item.path ?? 'unknown'}: ${item.error}`).join('\n'); - - // 파일을 읽는 데 성공해도 브라우저가 평가하지 못하면 실패다. - // 주입 결과가 정산될 때까지 기다렸다가 실제로 죽은 플러그인을 오류로 합류시킨다 - const collectInjectionErrors = async ( - candidates: JsPlugin[], - revision: number, - ): Promise => { - const injected: JsPlugin[] = candidates.filter( - (plugin) => plugin.enabled && plugin.content, - ); - if (!injected.length) return []; - - const { outcome, health } = await waitForPluginInjection( - revision, - injected.map((plugin) => plugin.id), - ); - - // 전역 JS가 꺼져 있으면 주입 대상이 아니다. 실패로 셀 일이 아니다 - if (outcome === 'skipped') return []; - - // 주입이 아예 못 돌았으면 결과가 비어 있다. 이걸 '오류 없음'으로 읽으면 - // 실행되지 않은 플러그인을 성공으로 표시하게 된다 - if (outcome !== 'settled') { - return injected.map((plugin) => ({ - path: plugin.path ?? plugin.name, - error: t('settings.jsNotApplied'), - })); - } - - return injected - .filter((plugin) => health[plugin.id]?.status === 'failed') - .map((plugin) => ({ - path: plugin.path ?? plugin.name, - // 빈 메시지(throw '')는 nullish가 아니라 그대로 렌더되므로 || - error: health[plugin.id]?.message || t('settings.jsRuntimeError'), - })); - }; - - const canReloadPlugins: boolean = jsPlugins.some( - (plugin: JsPlugin) => plugin.path, - ); - - const handleReloadPlugins = async (): Promise => { - if (reloadingPluginsRef.current) return; - if (jsPlugins.length === 0) { - showAlert?.(t('settings.jsReloadNoPlugins')); - return; - } - const startTime: number = performance.now(); - reloadingPluginsRef.current = true; - setIsReloadingPlugins(true); - try { - // 요청 전에 회차를 잡는다 - 응답보다 주입 정산이 먼저 끝나도 놓치지 않는다 - const healthRevision: number = currentPluginHealthRevision(); - const result: JsReloadResult = await jsApi.reload(); - const updated: JsPlugin[] = result.updated ?? []; - const injectionErrors: PluginError[] = await collectInjectionErrors( - updated, - healthRevision, - ); - const errors: PluginError[] = [ - ...(result.errors ?? []), - ...injectionErrors, - ]; - - const succeeded: number = updated.length - injectionErrors.length; - - if (errors.length && succeeded) { - showAlert?.( - `${t('settings.jsReloadPartial', { - count: succeeded, - })}\n${formatPluginErrors(errors)}`, - ); - } else if (errors.length) { - showAlert?.( - `${t('settings.jsReloadFailed')}\n${formatPluginErrors(errors)}`, - ); - } else if (succeeded) { - showAlert?.(t('settings.jsReloadSuccess', { count: succeeded })); - } else { - showAlert?.(t('settings.jsReloadNoChanges')); - } - } catch (error) { - console.error('Failed to reload JS plugins', error); - showAlert?.(`${t('settings.jsReloadFailed')}${error}`); - } finally { - reloadingPluginsRef.current = false; - const elapsed: number = performance.now() - startTime; - const MIN_SPINNER_MS = 250; - if (elapsed < MIN_SPINNER_MS) { - setTimeout( - () => setIsReloadingPlugins(false), - MIN_SPINNER_MS - elapsed, - ); - } else { - setIsReloadingPlugins(false); - } - } - }; - - const handleAddPlugins = async (): Promise => { - if (addingPluginsRef.current) return; - addingPluginsRef.current = true; - setIsAddingPlugins(true); - try { - const healthRevision: number = currentPluginHealthRevision(); - const result: JsLoadResult = await jsApi.load(); - if (!result) return; - const added: JsPlugin[] = result.added ?? []; - const injectionErrors: PluginError[] = await collectInjectionErrors( - added, - healthRevision, - ); - const errors: PluginError[] = [ - ...(result.errors ?? []), - ...injectionErrors, - ]; - const alertKind = classifyPluginAddResult(added.length, errors.length); - - if (alertKind === 'partial') { - showAlert?.( - `${t('settings.jsAddPartial', { - count: added.length, - })}\n${formatPluginErrors(errors)}`, - ); - } else if (alertKind === 'failed') { - showAlert?.( - `${t('settings.jsAddFailed')}\n${formatPluginErrors(errors)}`, - ); - } else if (alertKind === 'success') { - showAlert?.(t('settings.jsAddSuccess', { count: added.length })); - } - } catch (error) { - console.error('Failed to add JS plugins', error); - showAlert?.(`${t('settings.jsAddFailed')}${error}`); - } finally { - addingPluginsRef.current = false; - setIsAddingPlugins(false); - } - }; - - const handlePluginToggle = async ( - pluginId: string, - nextState: boolean, - ): Promise => { - if (pendingPluginRef.current) return; - pendingPluginRef.current = pluginId; - setPendingPluginId(pluginId); - try { - const result: JsPluginUpdateResult = await jsApi.setPluginEnabled( - pluginId, - nextState, - ); - if (!result.success) { - showAlert?.(t('settings.jsPluginToggleFailed')); - } - } catch (error) { - console.error('Failed to toggle JS plugin', error); - showAlert?.(t('settings.jsPluginToggleFailed')); - } finally { - pendingPluginRef.current = null; - setPendingPluginId(null); - } - }; - - const handlePluginRemove = async (pluginId: string): Promise => { - const plugin: JsPlugin | undefined = jsPlugins.find( - (candidate: JsPlugin) => candidate.id === pluginId, - ); - if (!plugin) return; - if (removingPluginRef.current || pendingPluginRef.current) return; - removingPluginRef.current = pluginId; - setPendingPluginId(pluginId); - - try { - // 실제 플러그인 네임스페이스 추출 (@id 또는 파일명 기반) - const pluginNamespace: string = extractPluginId( - plugin.content, - plugin.name, - ); - const pluginStorageNamespace = `${pluginNamespace}/`; - - // 네임스페이스를 prefix로 사용하는 데이터가 있는지 확인 - // 백엔드에서 자동으로 "plugin_data_" 를 붙이므로 순수 네임스페이스만 전달 - const hasData: boolean = await pluginApi.storage.hasData( - pluginStorageNamespace, - ); - console.warn( - '[PluginRemove] namespace=', - pluginNamespace, - 'hasData=', - hasData, - ); - - if (hasData) { - setPluginToDelete({ - id: pluginId, - name: plugin.name, - namespace: pluginNamespace, - }); - setDataDeleteModalOpen(true); - } else { - removingPluginRef.current = null; - setPendingPluginId(null); - await removePluginOnly(pluginId); - } - } catch (error) { - console.error('Failed to check plugin data', error); - showAlert?.(t('settings.jsPluginRemoveFailed')); - } finally { - removingPluginRef.current = null; - setPendingPluginId(null); - } - }; - - const removePluginOnly = async (pluginId: string): Promise => { - if (removingPluginRef.current) return; - removingPluginRef.current = pluginId; - setPendingPluginId(pluginId); - try { - const result: JsRemoveResult = await jsApi.remove(pluginId); - if (!result.success) { - showAlert?.(t('settings.jsPluginRemoveFailed')); - } - } catch (error) { - console.error('Failed to remove JS plugin', error); - showAlert?.(t('settings.jsPluginRemoveFailed')); - } finally { - removingPluginRef.current = null; - setPendingPluginId(null); - setDataDeleteModalOpen(false); - setPluginToDelete(null); - } - }; - - const removePluginWithData = async (pluginId: string): Promise => { - if (removingPluginRef.current) return; - removingPluginRef.current = pluginId; - setPendingPluginId(pluginId); - try { - const plugin: JsPlugin | undefined = jsPlugins.find( - (p: JsPlugin) => p.id === pluginId, - ); - if (!plugin) { - throw new Error('Plugin not found'); - } - - // 실제 네임스페이스를 다시 추출 - const pluginNamespace: string = extractPluginId( - plugin.content, - plugin.name, - ); - const pluginStorageNamespace = `${pluginNamespace}/`; - - // 1) 먼저 플러그인 제거 → 클린업이 실행되며 일부 플러그인은 저장을 시도할 수 있음 - const result: JsRemoveResult = await jsApi.remove(pluginId); - if (!result.success) { - showAlert?.(t('settings.jsPluginRemoveFailed')); - } - - // 2) 그 다음 스토리지 정리 → 클린업 중 재생성된 값까지 함께 제거 - await pluginApi.storage.clearByPrefix(pluginStorageNamespace); - } catch (error) { - console.error('Failed to remove JS plugin with data', error); - showAlert?.(t('settings.jsPluginRemoveFailed')); - } finally { - removingPluginRef.current = null; - setPendingPluginId(null); - setDataDeleteModalOpen(false); - setPluginToDelete(null); - } - }; + const { + canReloadPlugins, + handleReloadPlugins, + handleAddPlugins, + handlePluginToggle, + handlePluginRemove, + removePluginOnly, + removePluginWithData, + } = createSettingsPluginLifecycleController({ + t, + showAlert, + jsPlugins, + setPluginToDelete, + setDataDeleteModalOpen, + setIsReloadingPlugins, + setIsAddingPlugins, + setPendingPluginId, + reloadingPluginsRef, + addingPluginsRef, + pendingPluginRef, + removingPluginRef, + }); const actionButtonClass = (enabled: boolean): string => 'inline-flex items-center h-[23px] px-[10px] rounded-md text-body transition-colors duration-fast ' + diff --git a/src/renderer/components/main/settingsPluginLifecycleController.test.ts b/src/renderer/components/main/settingsPluginLifecycleController.test.ts new file mode 100644 index 000000000..59eb2039e --- /dev/null +++ b/src/renderer/components/main/settingsPluginLifecycleController.test.ts @@ -0,0 +1,260 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { JsPlugin } from '@src/types/plugin/js'; + +const mocks = vi.hoisted(() => ({ + currentPluginHealthRevision: vi.fn(), + waitForPluginInjection: vi.fn(), + load: vi.fn(), + reload: vi.fn(), + remove: vi.fn(), + setPluginEnabled: vi.fn(), + hasData: vi.fn(), + clearByPrefix: vi.fn(), +})); + +vi.mock('@stores/plugin/usePluginHealthStore', () => ({ + currentPluginHealthRevision: mocks.currentPluginHealthRevision, + waitForPluginInjection: mocks.waitForPluginInjection, +})); + +vi.mock('@api/modules/jsApi', () => ({ + jsApi: { + load: mocks.load, + reload: mocks.reload, + remove: mocks.remove, + setPluginEnabled: mocks.setPluginEnabled, + }, +})); + +vi.mock('@api/modules/pluginApi', () => ({ + pluginApi: { + storage: { + hasData: mocks.hasData, + clearByPrefix: mocks.clearByPrefix, + }, + }, +})); + +import { createSettingsPluginLifecycleController } from './settingsPluginLifecycleController'; + +const makePlugin = (overrides: Partial = {}): JsPlugin => ({ + id: 'alpha-id', + name: 'Alpha.js', + path: '/plugins/Alpha.js', + content: '// @id alpha\nrun()', + enabled: true, + ...overrides, +}); + +const translate = ( + key: string, + params?: Record, +): string => (params ? `${key}:${JSON.stringify(params)}` : key); + +const createHarness = (jsPlugins: JsPlugin[] = []) => { + const showAlert = vi.fn(); + const setPluginToDelete = vi.fn(); + const setDataDeleteModalOpen = vi.fn(); + const setIsReloadingPlugins = vi.fn(); + const setIsAddingPlugins = vi.fn(); + const setPendingPluginId = vi.fn(); + const reloadingPluginsRef = { current: false }; + const addingPluginsRef = { current: false }; + const pendingPluginRef = { current: null as string | null }; + const removingPluginRef = { current: null as string | null }; + + const controller = createSettingsPluginLifecycleController({ + t: translate, + showAlert, + jsPlugins, + setPluginToDelete, + setDataDeleteModalOpen, + setIsReloadingPlugins, + setIsAddingPlugins, + setPendingPluginId, + reloadingPluginsRef, + addingPluginsRef, + pendingPluginRef, + removingPluginRef, + }); + + return { + ...controller, + showAlert, + setPluginToDelete, + setDataDeleteModalOpen, + setIsReloadingPlugins, + setIsAddingPlugins, + setPendingPluginId, + reloadingPluginsRef, + addingPluginsRef, + pendingPluginRef, + removingPluginRef, + }; +}; + +describe('Settings 플러그인 생명주기 controller', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.currentPluginHealthRevision.mockReturnValue(7); + mocks.waitForPluginInjection.mockResolvedValue({ + outcome: 'settled', + health: {}, + }); + mocks.load.mockResolvedValue({ success: true, added: [] }); + mocks.reload.mockResolvedValue({ updated: [] }); + mocks.remove.mockResolvedValue({ success: true }); + mocks.setPluginEnabled.mockResolvedValue({ success: true }); + mocks.hasData.mockResolvedValue(false); + mocks.clearByPrefix.mockResolvedValue(0); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it('추가 시 요청 전 revision을 잡고 주입 대상만 기다린 뒤 오류를 합산한다', async () => { + const alpha = makePlugin(); + const disabled = makePlugin({ + id: 'disabled-id', + name: 'Disabled.js', + enabled: false, + }); + const empty = makePlugin({ + id: 'empty-id', + name: 'Empty.js', + content: '', + }); + const order: string[] = []; + mocks.currentPluginHealthRevision.mockImplementation(() => { + order.push('revision'); + return 7; + }); + mocks.load.mockImplementation(async () => { + order.push('load'); + return { + success: true, + added: [alpha, disabled, empty], + errors: [{ path: '/bad.js', error: 'read failed' }], + }; + }); + mocks.waitForPluginInjection.mockImplementation(async () => { + order.push('injection'); + return { + outcome: 'settled' as const, + health: { 'alpha-id': { status: 'failed' as const, message: '' } }, + }; + }); + const harness = createHarness(); + + await harness.handleAddPlugins(); + + expect(order).toEqual(['revision', 'load', 'injection']); + expect(mocks.waitForPluginInjection).toHaveBeenCalledWith(7, ['alpha-id']); + expect(harness.showAlert).toHaveBeenCalledWith( + 'settings.jsAddPartial:{"count":3}\n/bad.js: read failed\n/plugins/Alpha.js: settings.jsRuntimeError', + ); + expect(harness.setIsAddingPlugins.mock.calls).toEqual([[true], [false]]); + expect(harness.addingPluginsRef.current).toBe(false); + }); + + it('리로드 주입이 정산되지 않으면 적용 실패로 세고 spinner 최소 시간 계약을 유지한다', async () => { + vi.spyOn(performance, 'now') + .mockReturnValueOnce(10) + .mockReturnValueOnce(310); + const alpha = makePlugin(); + mocks.reload.mockResolvedValue({ updated: [alpha] }); + mocks.waitForPluginInjection.mockResolvedValue({ + outcome: 'timeout', + health: {}, + }); + const harness = createHarness([alpha]); + + await harness.handleReloadPlugins(); + + expect(mocks.currentPluginHealthRevision).toHaveBeenCalledBefore( + mocks.reload, + ); + expect(mocks.waitForPluginInjection).toHaveBeenCalledWith(7, ['alpha-id']); + expect(harness.showAlert).toHaveBeenCalledWith( + 'settings.jsReloadFailed\n/plugins/Alpha.js: settings.jsNotApplied', + ); + expect(harness.setIsReloadingPlugins.mock.calls).toEqual([[true], [false]]); + expect(harness.reloadingPluginsRef.current).toBe(false); + }); + + it('toggle은 pending 동안 중복 요청을 막고 실패를 알린 뒤 pending을 정산한다', async () => { + mocks.setPluginEnabled.mockResolvedValue({ success: false }); + const harness = createHarness(); + + const first = harness.handlePluginToggle('alpha-id', false); + await harness.handlePluginToggle('beta-id', true); + await first; + + expect(mocks.setPluginEnabled).toHaveBeenCalledTimes(1); + expect(mocks.setPluginEnabled).toHaveBeenCalledWith('alpha-id', false); + expect(harness.showAlert).toHaveBeenCalledWith( + 'settings.jsPluginToggleFailed', + ); + expect(harness.setPendingPluginId.mock.calls).toEqual([ + ['alpha-id'], + [null], + ]); + expect(harness.pendingPluginRef.current).toBeNull(); + }); + + it('저장 데이터가 있으면 제거하지 않고 실제 namespace와 확인 모달 대상을 남긴다', async () => { + mocks.hasData.mockResolvedValue(true); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const alpha = makePlugin(); + const harness = createHarness([alpha]); + + await harness.handlePluginRemove(alpha.id); + + expect(mocks.hasData).toHaveBeenCalledWith('alpha/'); + expect(mocks.remove).not.toHaveBeenCalled(); + expect(harness.setPluginToDelete).toHaveBeenCalledWith({ + id: 'alpha-id', + name: 'Alpha.js', + namespace: 'alpha', + }); + expect(harness.setDataDeleteModalOpen).toHaveBeenCalledWith(true); + expect(harness.removingPluginRef.current).toBeNull(); + expect(warn).toHaveBeenCalledWith( + '[PluginRemove] namespace=', + 'alpha', + 'hasData=', + true, + ); + }); + + it('데이터 포함 제거는 remove 정산 뒤 prefix를 지우고 실패여도 모달 상태를 닫는다', async () => { + const order: string[] = []; + mocks.remove.mockImplementation(async () => { + order.push('remove'); + return { success: false }; + }); + mocks.clearByPrefix.mockImplementation(async () => { + order.push('clear'); + return 2; + }); + const alpha = makePlugin(); + const harness = createHarness([alpha]); + + await harness.removePluginWithData(alpha.id); + + expect(order).toEqual(['remove', 'clear']); + expect(mocks.clearByPrefix).toHaveBeenCalledWith('alpha/'); + expect(harness.showAlert).toHaveBeenCalledWith( + 'settings.jsPluginRemoveFailed', + ); + expect(harness.setPendingPluginId.mock.calls).toEqual([ + ['alpha-id'], + [null], + ]); + expect(harness.setDataDeleteModalOpen).toHaveBeenCalledWith(false); + expect(harness.setPluginToDelete).toHaveBeenCalledWith(null); + }); +}); diff --git a/src/renderer/components/main/settingsPluginLifecycleController.ts b/src/renderer/components/main/settingsPluginLifecycleController.ts new file mode 100644 index 000000000..2a88c430a --- /dev/null +++ b/src/renderer/components/main/settingsPluginLifecycleController.ts @@ -0,0 +1,346 @@ +import { + currentPluginHealthRevision, + waitForPluginInjection, +} from '@stores/plugin/usePluginHealthStore'; +import { extractPluginId } from '@utils/plugin/pluginUtils'; +import { classifyPluginAddResult } from '@utils/plugin/pluginAddResult'; +import { jsApi } from '@api/modules/jsApi'; +import { pluginApi } from '@api/modules/pluginApi'; +import type { I18nContextValue } from '@contexts/I18nContextDef'; +import type { + JsLoadResult, + JsReloadResult, + JsRemoveResult, + JsPluginUpdateResult, +} from '@src/types/plugin/api'; +import type { JsPlugin } from '@src/types/plugin/js'; + +interface PluginError { + path?: string; + error: string; +} + +export interface PluginToDelete { + id: string; + name: string; + namespace: string; +} + +interface MutableValue { + current: T; +} + +interface SettingsPluginLifecycleControllerOptions { + t: I18nContextValue['t']; + showAlert: (msg: string, confirmText?: string) => void; + jsPlugins: JsPlugin[]; + setPluginToDelete: (plugin: PluginToDelete | null) => void; + setDataDeleteModalOpen: (open: boolean) => void; + setIsReloadingPlugins: (reloading: boolean) => void; + setIsAddingPlugins: (adding: boolean) => void; + setPendingPluginId: (pluginId: string | null) => void; + reloadingPluginsRef: MutableValue; + addingPluginsRef: MutableValue; + pendingPluginRef: MutableValue; + removingPluginRef: MutableValue; +} + +export const createSettingsPluginLifecycleController = ({ + t, + showAlert, + jsPlugins, + setPluginToDelete, + setDataDeleteModalOpen, + setIsReloadingPlugins, + setIsAddingPlugins, + setPendingPluginId, + reloadingPluginsRef, + addingPluginsRef, + pendingPluginRef, + removingPluginRef, +}: SettingsPluginLifecycleControllerOptions) => { + const formatPluginErrors = (errors: PluginError[] = []): string => + errors.map((item) => `${item.path ?? 'unknown'}: ${item.error}`).join('\n'); + + // 파일을 읽는 데 성공해도 브라우저가 평가하지 못하면 실패다. + // 주입 결과가 정산될 때까지 기다렸다가 실제로 죽은 플러그인을 오류로 합류시킨다 + const collectInjectionErrors = async ( + candidates: JsPlugin[], + revision: number, + ): Promise => { + const injected: JsPlugin[] = candidates.filter( + (plugin) => plugin.enabled && plugin.content, + ); + if (!injected.length) return []; + + const { outcome, health } = await waitForPluginInjection( + revision, + injected.map((plugin) => plugin.id), + ); + + // 전역 JS가 꺼져 있으면 주입 대상이 아니다. 실패로 셀 일이 아니다 + if (outcome === 'skipped') return []; + + // 주입이 아예 못 돌았으면 결과가 비어 있다. 이걸 '오류 없음'으로 읽으면 + // 실행되지 않은 플러그인을 성공으로 표시하게 된다 + if (outcome !== 'settled') { + return injected.map((plugin) => ({ + path: plugin.path ?? plugin.name, + error: t('settings.jsNotApplied'), + })); + } + + return injected + .filter((plugin) => health[plugin.id]?.status === 'failed') + .map((plugin) => ({ + path: plugin.path ?? plugin.name, + // 빈 메시지(throw '')는 nullish가 아니라 그대로 렌더되므로 || + error: health[plugin.id]?.message || t('settings.jsRuntimeError'), + })); + }; + + const canReloadPlugins: boolean = jsPlugins.some( + (plugin: JsPlugin) => plugin.path, + ); + + const handleReloadPlugins = async (): Promise => { + if (reloadingPluginsRef.current) return; + if (jsPlugins.length === 0) { + showAlert?.(t('settings.jsReloadNoPlugins')); + return; + } + const startTime: number = performance.now(); + reloadingPluginsRef.current = true; + setIsReloadingPlugins(true); + try { + // 요청 전에 회차를 잡는다 - 응답보다 주입 정산이 먼저 끝나도 놓치지 않는다 + const healthRevision: number = currentPluginHealthRevision(); + const result: JsReloadResult = await jsApi.reload(); + const updated: JsPlugin[] = result.updated ?? []; + const injectionErrors: PluginError[] = await collectInjectionErrors( + updated, + healthRevision, + ); + const errors: PluginError[] = [ + ...(result.errors ?? []), + ...injectionErrors, + ]; + + const succeeded: number = updated.length - injectionErrors.length; + + if (errors.length && succeeded) { + showAlert?.( + `${t('settings.jsReloadPartial', { + count: succeeded, + })}\n${formatPluginErrors(errors)}`, + ); + } else if (errors.length) { + showAlert?.( + `${t('settings.jsReloadFailed')}\n${formatPluginErrors(errors)}`, + ); + } else if (succeeded) { + showAlert?.(t('settings.jsReloadSuccess', { count: succeeded })); + } else { + showAlert?.(t('settings.jsReloadNoChanges')); + } + } catch (error) { + console.error('Failed to reload JS plugins', error); + showAlert?.(`${t('settings.jsReloadFailed')}${error}`); + } finally { + reloadingPluginsRef.current = false; + const elapsed: number = performance.now() - startTime; + const MIN_SPINNER_MS = 250; + if (elapsed < MIN_SPINNER_MS) { + setTimeout( + () => setIsReloadingPlugins(false), + MIN_SPINNER_MS - elapsed, + ); + } else { + setIsReloadingPlugins(false); + } + } + }; + + const handleAddPlugins = async (): Promise => { + if (addingPluginsRef.current) return; + addingPluginsRef.current = true; + setIsAddingPlugins(true); + try { + const healthRevision: number = currentPluginHealthRevision(); + const result: JsLoadResult = await jsApi.load(); + if (!result) return; + const added: JsPlugin[] = result.added ?? []; + const injectionErrors: PluginError[] = await collectInjectionErrors( + added, + healthRevision, + ); + const errors: PluginError[] = [ + ...(result.errors ?? []), + ...injectionErrors, + ]; + const alertKind = classifyPluginAddResult(added.length, errors.length); + + if (alertKind === 'partial') { + showAlert?.( + `${t('settings.jsAddPartial', { + count: added.length, + })}\n${formatPluginErrors(errors)}`, + ); + } else if (alertKind === 'failed') { + showAlert?.( + `${t('settings.jsAddFailed')}\n${formatPluginErrors(errors)}`, + ); + } else if (alertKind === 'success') { + showAlert?.(t('settings.jsAddSuccess', { count: added.length })); + } + } catch (error) { + console.error('Failed to add JS plugins', error); + showAlert?.(`${t('settings.jsAddFailed')}${error}`); + } finally { + addingPluginsRef.current = false; + setIsAddingPlugins(false); + } + }; + + const handlePluginToggle = async ( + pluginId: string, + nextState: boolean, + ): Promise => { + if (pendingPluginRef.current) return; + pendingPluginRef.current = pluginId; + setPendingPluginId(pluginId); + try { + const result: JsPluginUpdateResult = await jsApi.setPluginEnabled( + pluginId, + nextState, + ); + if (!result.success) { + showAlert?.(t('settings.jsPluginToggleFailed')); + } + } catch (error) { + console.error('Failed to toggle JS plugin', error); + showAlert?.(t('settings.jsPluginToggleFailed')); + } finally { + pendingPluginRef.current = null; + setPendingPluginId(null); + } + }; + + const handlePluginRemove = async (pluginId: string): Promise => { + const plugin: JsPlugin | undefined = jsPlugins.find( + (candidate: JsPlugin) => candidate.id === pluginId, + ); + if (!plugin) return; + if (removingPluginRef.current || pendingPluginRef.current) return; + removingPluginRef.current = pluginId; + setPendingPluginId(pluginId); + + try { + // 실제 플러그인 네임스페이스 추출 (@id 또는 파일명 기반) + const pluginNamespace: string = extractPluginId( + plugin.content, + plugin.name, + ); + const pluginStorageNamespace = `${pluginNamespace}/`; + + // 네임스페이스를 prefix로 사용하는 데이터가 있는지 확인 + // 백엔드에서 자동으로 "plugin_data_" 를 붙이므로 순수 네임스페이스만 전달 + const hasData: boolean = await pluginApi.storage.hasData( + pluginStorageNamespace, + ); + console.warn( + '[PluginRemove] namespace=', + pluginNamespace, + 'hasData=', + hasData, + ); + + if (hasData) { + setPluginToDelete({ + id: pluginId, + name: plugin.name, + namespace: pluginNamespace, + }); + setDataDeleteModalOpen(true); + } else { + removingPluginRef.current = null; + setPendingPluginId(null); + await removePluginOnly(pluginId); + } + } catch (error) { + console.error('Failed to check plugin data', error); + showAlert?.(t('settings.jsPluginRemoveFailed')); + } finally { + removingPluginRef.current = null; + setPendingPluginId(null); + } + }; + + const removePluginOnly = async (pluginId: string): Promise => { + if (removingPluginRef.current) return; + removingPluginRef.current = pluginId; + setPendingPluginId(pluginId); + try { + const result: JsRemoveResult = await jsApi.remove(pluginId); + if (!result.success) { + showAlert?.(t('settings.jsPluginRemoveFailed')); + } + } catch (error) { + console.error('Failed to remove JS plugin', error); + showAlert?.(t('settings.jsPluginRemoveFailed')); + } finally { + removingPluginRef.current = null; + setPendingPluginId(null); + setDataDeleteModalOpen(false); + setPluginToDelete(null); + } + }; + + const removePluginWithData = async (pluginId: string): Promise => { + if (removingPluginRef.current) return; + removingPluginRef.current = pluginId; + setPendingPluginId(pluginId); + try { + const plugin: JsPlugin | undefined = jsPlugins.find( + (p: JsPlugin) => p.id === pluginId, + ); + if (!plugin) { + throw new Error('Plugin not found'); + } + + // 실제 네임스페이스를 다시 추출 + const pluginNamespace: string = extractPluginId( + plugin.content, + plugin.name, + ); + const pluginStorageNamespace = `${pluginNamespace}/`; + + // 1) 먼저 플러그인 제거 → 클린업이 실행되며 일부 플러그인은 저장을 시도할 수 있음 + const result: JsRemoveResult = await jsApi.remove(pluginId); + if (!result.success) { + showAlert?.(t('settings.jsPluginRemoveFailed')); + } + + // 2) 그 다음 스토리지 정리 → 클린업 중 재생성된 값까지 함께 제거 + await pluginApi.storage.clearByPrefix(pluginStorageNamespace); + } catch (error) { + console.error('Failed to remove JS plugin with data', error); + showAlert?.(t('settings.jsPluginRemoveFailed')); + } finally { + removingPluginRef.current = null; + setPendingPluginId(null); + setDataDeleteModalOpen(false); + setPluginToDelete(null); + } + }; + + return { + canReloadPlugins, + handleReloadPlugins, + handleAddPlugins, + handlePluginToggle, + handlePluginRemove, + removePluginOnly, + removePluginWithData, + }; +}; From b94395e3e3c857b1be9c211364462c3cccc8f802 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 16:44:12 +0900 Subject: [PATCH 137/245] =?UTF-8?q?refactor:=20=EC=97=90=EB=94=94=ED=84=B0?= =?UTF-8?q?=20=EC=9D=98=EB=AF=B8=20=ED=8C=A8=EC=B9=98=20=ED=88=AC=EC=98=81?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../runtime/semanticElementPatchProjection.ts | 525 ++++++++++++++++++ .../editor/runtime/semanticOpsProjection.ts | 511 +---------------- 2 files changed, 527 insertions(+), 509 deletions(-) create mode 100644 src/renderer/editor/runtime/semanticElementPatchProjection.ts diff --git a/src/renderer/editor/runtime/semanticElementPatchProjection.ts b/src/renderer/editor/runtime/semanticElementPatchProjection.ts new file mode 100644 index 000000000..f05e82bf6 --- /dev/null +++ b/src/renderer/editor/runtime/semanticElementPatchProjection.ts @@ -0,0 +1,525 @@ +import { + inheritedPaintMaterialization, + paintPropertyFields, +} from '@src/types/color'; +import { isEditorShadowPropertyPatchV1 } from '@src/types/editor'; +import type { + CanonicalEditorDocumentV1, + EditorElementTypeV1, + EditorField, + EditorOpResultV1, + EditorOpV1, +} from '@src/types/editor'; +import { + isCounterFillPropertyPatchV1, + projectCounterFillPatch, +} from '@src/types/key/counterFill'; +import { + applyImageTransformLeaf, + type ImageTransform, +} from '@src/types/key/imageLayer'; +import { + isNotePaintPropertyPatchV1, + mirrorBodyPaintToGlow, + projectNotePaintPatch, +} from '@src/types/key/notePaint'; +import { projectElementShadowPatch } from '@src/types/key/shadows'; +import { + DEFAULT_ELEMENT_ACTIVE_SHADOW_SPEC, + DEFAULT_ELEMENT_SHADOW_SPEC, +} from '@utils/core/elementDefaults'; +import { + implicitCounterFontBold, + implicitElementFontBold, +} from '@utils/core/fontWeights'; + +type PatchElementOp = Extract; + +export const applySemanticElementPatch = ( + next: CanonicalEditorDocumentV1, + op: PatchElementOp, + result: EditorOpResultV1 | undefined, + SEMANTIC_POSITION_FIELDS: Record, +): void => { + if (result?.status === 'noChange') return; + const field = SEMANTIC_POSITION_FIELDS[op.elementType]; + const record = next[field] as Record< + string, + Array & { id: string }> + >; + for (const [mode, positions] of Object.entries(record)) { + const index = positions.findIndex((position) => position.id === op.id); + if (index < 0) continue; + next[field] = { + ...record, + [mode]: positions.map((position, positionIndex) => { + if (positionIndex !== index) return position; + if (op.patch.property === 'layerName') { + const updated = { ...position }; + if (op.patch.value === null) delete updated.layerName; + else updated.layerName = op.patch.value; + return updated; + } + if (op.patch.property === 'graphType') { + return { ...position, graphType: op.patch.value }; + } + if (op.patch.property === 'graphColor') { + return { ...position, graphColor: op.patch.value }; + } + if (op.patch.property === 'showAvgLine') { + return { ...position, showAvgLine: op.patch.value }; + } + if (op.patch.property === 'graphAnimationEnabled') { + return { + ...position, + graphAnimationEnabled: op.patch.value, + }; + } + if (op.patch.property === 'graphSpeed') { + return { ...position, graphSpeed: op.patch.value }; + } + if (op.patch.property === 'reverse') { + return { ...position, reverse: op.patch.value }; + } + if (op.patch.property === 'sensitivity') { + return { ...position, sensitivity: op.patch.value }; + } + if (op.patch.property === 'axisId') { + return { ...position, axisId: op.patch.value }; + } + if (op.patch.property === 'soundEnabled') { + return { ...position, soundEnabled: op.patch.value }; + } + if (op.patch.property === 'soundVolume') { + return { ...position, soundVolume: op.patch.value }; + } + if (op.patch.property === 'soundPath') { + return { ...position, soundPath: op.patch.value }; + } + if (op.patch.property === 'inactiveImage') { + return { ...position, inactiveImage: op.patch.value }; + } + if (op.patch.property === 'activeImage') { + return { ...position, activeImage: op.patch.value }; + } + if (op.patch.property === 'idleTransparent') { + return { + ...position, + idleTransparent: op.patch.value, + }; + } + if (op.patch.property === 'activeTransparent') { + return { + ...position, + activeTransparent: op.patch.value, + }; + } + if (op.patch.property === 'idleImageFit') { + return { ...position, idleImageFit: op.patch.value }; + } + if (op.patch.property === 'activeImageFit') { + return { ...position, activeImageFit: op.patch.value }; + } + if (op.patch.property === 'imageMode') { + // replace는 기본값이라 sparse 저장 - 백엔드와 동일 + const { imageMode: _imageMode, ...rest } = position; + return op.patch.value === 'replace' + ? rest + : { ...position, imageMode: op.patch.value }; + } + if ( + op.patch.property === 'idleImageTransform' || + op.patch.property === 'activeImageTransform' + ) { + const field = op.patch.property; + if (op.patch.value === null) { + const { [field]: _dropped, ...rest } = position; + return rest; + } + return { + ...position, + [field]: applyImageTransformLeaf( + position[field] as ImageTransform | undefined, + op.patch.value, + ), + }; + } + if (op.patch.property === 'counterEnabled') { + const counter = position.counter as + | Record + | undefined; + return { + ...position, + counter: { ...counter, enabled: op.patch.value }, + }; + } + if (op.patch.property === 'counterAnimationEnabled') { + const counter = position.counter as + | Record + | undefined; + const animation = (counter?.animation ?? {}) as Record< + string, + unknown + >; + return { + ...position, + counter: { + ...counter, + animation: { + ...animation, + enabled: op.patch.value, + }, + }, + }; + } + if (op.patch.property === 'counterPlacement') { + const counter = position.counter as + | Record + | undefined; + return { + ...position, + counter: { ...counter, placement: op.patch.value }, + }; + } + if (op.patch.property === 'counterAlign') { + const counter = position.counter as + | Record + | undefined; + return { + ...position, + counter: { ...counter, align: op.patch.value }, + }; + } + if (op.patch.property === 'counterAlignMode') { + const counter = position.counter as + | Record + | undefined; + return { + ...position, + counter: { ...counter, alignMode: op.patch.value }, + }; + } + if (op.patch.property === 'counterGap') { + const counter = position.counter as + | Record + | undefined; + return { + ...position, + counter: { ...counter, gap: op.patch.value }, + }; + } + if (op.patch.property === 'counterFontSize') { + const counter = position.counter as + | Record + | undefined; + return { + ...position, + counter: { ...counter, fontSize: op.patch.value }, + }; + } + if (op.patch.property === 'counterFontWeight') { + const counter = position.counter as + | Record + | undefined; + // 백엔드와 같은 암묵 Bold 고정 (fontWeights.implicitCounterFontBold) + return { + ...position, + counter: { + ...counter, + fontWeight: op.patch.value, + ...(typeof counter?.fontBold !== 'boolean' + ? { fontBold: implicitCounterFontBold(counter?.fontWeight) } + : {}), + }, + }; + } + if (op.patch.property === 'counterFontBold') { + const counter = position.counter as + | Record + | undefined; + return { + ...position, + counter: { ...counter, fontBold: op.patch.value }, + }; + } + if (op.patch.property === 'counterFontItalic') { + const counter = position.counter as + | Record + | undefined; + return { + ...position, + counter: { ...counter, fontItalic: op.patch.value }, + }; + } + if (op.patch.property === 'counterFontUnderline') { + const counter = position.counter as + | Record + | undefined; + return { + ...position, + counter: { + ...counter, + fontUnderline: op.patch.value, + }, + }; + } + if (op.patch.property === 'counterFontStrikethrough') { + const counter = position.counter as + | Record + | undefined; + return { + ...position, + counter: { + ...counter, + fontStrikethrough: op.patch.value, + }, + }; + } + if (op.patch.property === 'counterFontFamily') { + const counter = position.counter as + | Record + | undefined; + return { + ...position, + counter: { + ...counter, + fontFamily: op.patch.value, + }, + }; + } + if (op.patch.property === 'counterAnimationPreset') { + const counter = position.counter as + | Record + | undefined; + const animation = (counter?.animation ?? {}) as Record< + string, + unknown + >; + const intent = op.patch.value; + return { + ...position, + counter: { + ...counter, + animation: { + ...animation, + ...('applyPresetId' in intent + ? { presetId: intent.presetId } + : {}), + ...('bezier' in intent ? { bezier: [...intent.bezier] } : {}), + ...('scale' in intent ? { scale: intent.scale } : {}), + ...('durationMs' in intent + ? { durationMs: intent.durationMs } + : {}), + }, + }, + }; + } + if (op.patch.property === 'useInlineStyles') { + return { + ...position, + useInlineStyles: op.patch.value, + }; + } + if (op.patch.property === 'fontWeight') { + // 백엔드와 같은 암묵 Bold 고정 (fontWeights.implicitElementFontBold) + return { + ...position, + fontWeight: op.patch.value, + ...(position.fontBold == null + ? { fontBold: implicitElementFontBold(position.fontWeight) } + : {}), + }; + } + if (op.patch.property === 'fontBold') { + return { ...position, fontBold: op.patch.value }; + } + if (op.patch.property === 'fontItalic') { + return { ...position, fontItalic: op.patch.value }; + } + if (op.patch.property === 'fontUnderline') { + return { ...position, fontUnderline: op.patch.value }; + } + if (op.patch.property === 'fontStrikethrough') { + return { + ...position, + fontStrikethrough: op.patch.value, + }; + } + if (op.patch.property === 'fontFamily') { + return { ...position, fontFamily: op.patch.value }; + } + if ( + op.patch.property === 'backgroundPaint' || + op.patch.property === 'activeBackgroundPaint' || + op.patch.property === 'borderPaint' || + op.patch.property === 'activeBorderPaint' || + op.patch.property === 'fontPaint' || + op.patch.property === 'activeFontPaint' + ) { + const field = op.patch.property; + const paint = op.patch.value; + const { + active, + surface, + colorField, + gradientField, + activeColorField, + activeGradientField, + } = paintPropertyFields(field); + // 물질화 대상 - active 쌍을 가진 요소 (font는 키만) + const materializes = + surface === 'font' + ? op.elementType === 'key' + : op.elementType === 'key' || op.elementType === 'knob'; + const preservation: Record = {}; + if (!active && materializes) { + const inherited = inheritedPaintMaterialization( + { + color: + typeof position[colorField] === 'string' + ? (position[colorField] as string) + : undefined, + gradient: position[gradientField] as never, + }, + { + color: + typeof position[activeColorField] === 'string' + ? (position[activeColorField] as string) + : undefined, + gradient: position[activeGradientField] as never, + }, + ); + if (inherited) { + if (inherited.color != null) { + preservation[activeColorField] = inherited.color; + } + if (inherited.gradient) { + preservation[activeGradientField] = inherited.gradient; + } + } + } + return { + ...position, + ...preservation, + [colorField]: paint.color, + [gradientField]: paint.gradient ?? undefined, + }; + } + if (isEditorShadowPropertyPatchV1(op.patch)) { + return { + ...position, + ...projectElementShadowPatch({ + position: position as never, + elementType: op.elementType as 'key' | 'stat' | 'knob', + patch: op.patch, + defaultShadow: DEFAULT_ELEMENT_SHADOW_SPEC, + defaultActiveShadow: DEFAULT_ELEMENT_ACTIVE_SHADOW_SPEC, + }), + }; + } + if (isCounterFillPropertyPatchV1(op.patch)) { + return { + ...position, + ...projectCounterFillPatch(position as never, op.patch), + }; + } + if (isNotePaintPropertyPatchV1(op.patch)) { + // position 전달 - {opacity} 단독의 sibling shadow 재계산 (§9-5) + return { + ...position, + ...projectNotePaintPatch(op.patch, position as never), + }; + } + if (op.patch.property === 'displayText') { + return { ...position, displayText: op.patch.value }; + } + if (op.patch.property === 'className') { + return { ...position, className: op.patch.value }; + } + if (op.patch.property === 'borderWidth') { + return { ...position, borderWidth: op.patch.value }; + } + if (op.patch.property === 'borderRadius') { + return { ...position, borderRadius: op.patch.value }; + } + if (op.patch.property === 'fontSize') { + return { ...position, fontSize: op.patch.value }; + } + if (op.patch.property === 'noteGlowSize') { + return { ...position, noteGlowSize: op.patch.value }; + } + if (op.patch.property === 'noteOffsetX') { + // null 보존 - 백엔드가 이 필드를 null로 직렬화하므로 undefined로 + // 바꾸면 의미가 같은데도 getChangedEditorFields가 변경으로 잡는다 + return { ...position, noteOffsetX: op.patch.value }; + } + if (op.patch.property === 'noteOffsetY') { + // null 보존 - 백엔드가 이 필드를 null로 직렬화하므로 undefined로 + // 바꾸면 의미가 같은데도 getChangedEditorFields가 변경으로 잡는다 + return { ...position, noteOffsetY: op.patch.value }; + } + if (op.patch.property === 'noteWidth') { + // null 보존 - 백엔드가 이 필드를 null로 직렬화하므로 undefined로 + // 바꾸면 의미가 같은데도 getChangedEditorFields가 변경으로 잡는다 + return { ...position, noteWidth: op.patch.value }; + } + if (op.patch.property === 'noteBorderWidth') { + return { + ...position, + noteBorderWidth: op.patch.value, + }; + } + if (op.patch.property === 'noteBorderRadius') { + return { + ...position, + noteBorderRadius: op.patch.value, + }; + } + if (op.patch.property === 'noteEffectEnabled') { + return { + ...position, + noteEffectEnabled: op.patch.value, + }; + } + if (op.patch.property === 'noteAutoYCorrection') { + return { + ...position, + noteAutoYCorrection: op.patch.value, + }; + } + if (op.patch.property === 'noteGlowEnabled') { + return { + ...position, + noteGlowEnabled: op.patch.value, + }; + } + if (op.patch.property === 'noteGlowSyncPaint') { + // 켜는 순간 본체 페인트를 글로우로 복사 (Rust 적용기와 동일) + const next = { ...position, noteGlowSyncPaint: op.patch.value }; + return op.patch.value + ? { ...next, ...mirrorBodyPaintToGlow(next as never) } + : next; + } + if (op.patch.property === 'noteAlignment') { + return { ...position, noteAlignment: op.patch.value }; + } + if (op.patch.property === 'noteBorderSide') { + return { ...position, noteBorderSide: op.patch.value }; + } + if (op.patch.property === 'statType') { + return { ...position, statType: op.patch.value }; + } + if (op.patch.property === 'hidden') { + return { ...position, hidden: op.patch.value }; + } + // 속성 arm 누락을 컴파일 시점에 잡는다. Rust 적용부는 exhaustive + // match라 누락이 컴파일 오류지만 이 체인은 폴백으로 흘렀다 + const unhandled: never = op.patch; + void unhandled; + return position; + }), + } as never; + break; + } + return; +}; diff --git a/src/renderer/editor/runtime/semanticOpsProjection.ts b/src/renderer/editor/runtime/semanticOpsProjection.ts index b1bf89953..d42a84507 100644 --- a/src/renderer/editor/runtime/semanticOpsProjection.ts +++ b/src/renderer/editor/runtime/semanticOpsProjection.ts @@ -3,30 +3,8 @@ import { projectLayerGroupRename, projectStableElementGroups, } from '@utils/layerGroupUtils'; +import { applySemanticElementPatch } from './semanticElementPatchProjection'; import { currentPluginGroupMembers } from './pluginGroupMembers'; -import { - inheritedPaintMaterialization, - paintPropertyFields, -} from '@src/types/color'; -import { projectElementShadowPatch } from '@src/types/key/shadows'; -import { - applyImageTransformLeaf, - type ImageTransform, -} from '@src/types/key/imageLayer'; -import { - isNotePaintPropertyPatchV1, - mirrorBodyPaintToGlow, - projectNotePaintPatch, -} from '@src/types/key/notePaint'; -import { - isCounterFillPropertyPatchV1, - projectCounterFillPatch, -} from '@src/types/key/counterFill'; -import { - DEFAULT_ELEMENT_ACTIVE_SHADOW_SPEC, - DEFAULT_ELEMENT_SHADOW_SPEC, -} from '@utils/core/elementDefaults'; -import { isEditorShadowPropertyPatchV1 } from '@src/types/editor'; import type { CanonicalEditorDocumentV1, EditorElementTypeV1, @@ -34,10 +12,6 @@ import type { EditorOpResultV1, EditorOpV1, } from '@src/types/editor'; -import { - implicitCounterFontBold, - implicitElementFontBold, -} from '@utils/core/fontWeights'; const clone = (value: T): T => structuredClone(value); @@ -316,488 +290,7 @@ export const applySemanticOps = ( return; } if (op.kind === 'patchElement') { - if (result?.status === 'noChange') return; - const field = SEMANTIC_POSITION_FIELDS[op.elementType]; - const record = next[field] as Record< - string, - Array & { id: string }> - >; - for (const [mode, positions] of Object.entries(record)) { - const index = positions.findIndex((position) => position.id === op.id); - if (index < 0) continue; - next[field] = { - ...record, - [mode]: positions.map((position, positionIndex) => { - if (positionIndex !== index) return position; - if (op.patch.property === 'layerName') { - const updated = { ...position }; - if (op.patch.value === null) delete updated.layerName; - else updated.layerName = op.patch.value; - return updated; - } - if (op.patch.property === 'graphType') { - return { ...position, graphType: op.patch.value }; - } - if (op.patch.property === 'graphColor') { - return { ...position, graphColor: op.patch.value }; - } - if (op.patch.property === 'showAvgLine') { - return { ...position, showAvgLine: op.patch.value }; - } - if (op.patch.property === 'graphAnimationEnabled') { - return { - ...position, - graphAnimationEnabled: op.patch.value, - }; - } - if (op.patch.property === 'graphSpeed') { - return { ...position, graphSpeed: op.patch.value }; - } - if (op.patch.property === 'reverse') { - return { ...position, reverse: op.patch.value }; - } - if (op.patch.property === 'sensitivity') { - return { ...position, sensitivity: op.patch.value }; - } - if (op.patch.property === 'axisId') { - return { ...position, axisId: op.patch.value }; - } - if (op.patch.property === 'soundEnabled') { - return { ...position, soundEnabled: op.patch.value }; - } - if (op.patch.property === 'soundVolume') { - return { ...position, soundVolume: op.patch.value }; - } - if (op.patch.property === 'soundPath') { - return { ...position, soundPath: op.patch.value }; - } - if (op.patch.property === 'inactiveImage') { - return { ...position, inactiveImage: op.patch.value }; - } - if (op.patch.property === 'activeImage') { - return { ...position, activeImage: op.patch.value }; - } - if (op.patch.property === 'idleTransparent') { - return { - ...position, - idleTransparent: op.patch.value, - }; - } - if (op.patch.property === 'activeTransparent') { - return { - ...position, - activeTransparent: op.patch.value, - }; - } - if (op.patch.property === 'idleImageFit') { - return { ...position, idleImageFit: op.patch.value }; - } - if (op.patch.property === 'activeImageFit') { - return { ...position, activeImageFit: op.patch.value }; - } - if (op.patch.property === 'imageMode') { - // replace는 기본값이라 sparse 저장 - 백엔드와 동일 - const { imageMode: _imageMode, ...rest } = position; - return op.patch.value === 'replace' - ? rest - : { ...position, imageMode: op.patch.value }; - } - if ( - op.patch.property === 'idleImageTransform' || - op.patch.property === 'activeImageTransform' - ) { - const field = op.patch.property; - if (op.patch.value === null) { - const { [field]: _dropped, ...rest } = position; - return rest; - } - return { - ...position, - [field]: applyImageTransformLeaf( - position[field] as ImageTransform | undefined, - op.patch.value, - ), - }; - } - if (op.patch.property === 'counterEnabled') { - const counter = position.counter as - | Record - | undefined; - return { - ...position, - counter: { ...counter, enabled: op.patch.value }, - }; - } - if (op.patch.property === 'counterAnimationEnabled') { - const counter = position.counter as - | Record - | undefined; - const animation = (counter?.animation ?? {}) as Record< - string, - unknown - >; - return { - ...position, - counter: { - ...counter, - animation: { - ...animation, - enabled: op.patch.value, - }, - }, - }; - } - if (op.patch.property === 'counterPlacement') { - const counter = position.counter as - | Record - | undefined; - return { - ...position, - counter: { ...counter, placement: op.patch.value }, - }; - } - if (op.patch.property === 'counterAlign') { - const counter = position.counter as - | Record - | undefined; - return { - ...position, - counter: { ...counter, align: op.patch.value }, - }; - } - if (op.patch.property === 'counterAlignMode') { - const counter = position.counter as - | Record - | undefined; - return { - ...position, - counter: { ...counter, alignMode: op.patch.value }, - }; - } - if (op.patch.property === 'counterGap') { - const counter = position.counter as - | Record - | undefined; - return { - ...position, - counter: { ...counter, gap: op.patch.value }, - }; - } - if (op.patch.property === 'counterFontSize') { - const counter = position.counter as - | Record - | undefined; - return { - ...position, - counter: { ...counter, fontSize: op.patch.value }, - }; - } - if (op.patch.property === 'counterFontWeight') { - const counter = position.counter as - | Record - | undefined; - // 백엔드와 같은 암묵 Bold 고정 (fontWeights.implicitCounterFontBold) - return { - ...position, - counter: { - ...counter, - fontWeight: op.patch.value, - ...(typeof counter?.fontBold !== 'boolean' - ? { fontBold: implicitCounterFontBold(counter?.fontWeight) } - : {}), - }, - }; - } - if (op.patch.property === 'counterFontBold') { - const counter = position.counter as - | Record - | undefined; - return { - ...position, - counter: { ...counter, fontBold: op.patch.value }, - }; - } - if (op.patch.property === 'counterFontItalic') { - const counter = position.counter as - | Record - | undefined; - return { - ...position, - counter: { ...counter, fontItalic: op.patch.value }, - }; - } - if (op.patch.property === 'counterFontUnderline') { - const counter = position.counter as - | Record - | undefined; - return { - ...position, - counter: { - ...counter, - fontUnderline: op.patch.value, - }, - }; - } - if (op.patch.property === 'counterFontStrikethrough') { - const counter = position.counter as - | Record - | undefined; - return { - ...position, - counter: { - ...counter, - fontStrikethrough: op.patch.value, - }, - }; - } - if (op.patch.property === 'counterFontFamily') { - const counter = position.counter as - | Record - | undefined; - return { - ...position, - counter: { - ...counter, - fontFamily: op.patch.value, - }, - }; - } - if (op.patch.property === 'counterAnimationPreset') { - const counter = position.counter as - | Record - | undefined; - const animation = (counter?.animation ?? {}) as Record< - string, - unknown - >; - const intent = op.patch.value; - return { - ...position, - counter: { - ...counter, - animation: { - ...animation, - ...('applyPresetId' in intent - ? { presetId: intent.presetId } - : {}), - ...('bezier' in intent - ? { bezier: [...intent.bezier] } - : {}), - ...('scale' in intent ? { scale: intent.scale } : {}), - ...('durationMs' in intent - ? { durationMs: intent.durationMs } - : {}), - }, - }, - }; - } - if (op.patch.property === 'useInlineStyles') { - return { - ...position, - useInlineStyles: op.patch.value, - }; - } - if (op.patch.property === 'fontWeight') { - // 백엔드와 같은 암묵 Bold 고정 (fontWeights.implicitElementFontBold) - return { - ...position, - fontWeight: op.patch.value, - ...(position.fontBold == null - ? { fontBold: implicitElementFontBold(position.fontWeight) } - : {}), - }; - } - if (op.patch.property === 'fontBold') { - return { ...position, fontBold: op.patch.value }; - } - if (op.patch.property === 'fontItalic') { - return { ...position, fontItalic: op.patch.value }; - } - if (op.patch.property === 'fontUnderline') { - return { ...position, fontUnderline: op.patch.value }; - } - if (op.patch.property === 'fontStrikethrough') { - return { - ...position, - fontStrikethrough: op.patch.value, - }; - } - if (op.patch.property === 'fontFamily') { - return { ...position, fontFamily: op.patch.value }; - } - if ( - op.patch.property === 'backgroundPaint' || - op.patch.property === 'activeBackgroundPaint' || - op.patch.property === 'borderPaint' || - op.patch.property === 'activeBorderPaint' || - op.patch.property === 'fontPaint' || - op.patch.property === 'activeFontPaint' - ) { - const field = op.patch.property; - const paint = op.patch.value; - const { - active, - surface, - colorField, - gradientField, - activeColorField, - activeGradientField, - } = paintPropertyFields(field); - // 물질화 대상 - active 쌍을 가진 요소 (font는 키만) - const materializes = - surface === 'font' - ? op.elementType === 'key' - : op.elementType === 'key' || op.elementType === 'knob'; - const preservation: Record = {}; - if (!active && materializes) { - const inherited = inheritedPaintMaterialization( - { - color: - typeof position[colorField] === 'string' - ? (position[colorField] as string) - : undefined, - gradient: position[gradientField] as never, - }, - { - color: - typeof position[activeColorField] === 'string' - ? (position[activeColorField] as string) - : undefined, - gradient: position[activeGradientField] as never, - }, - ); - if (inherited) { - if (inherited.color != null) { - preservation[activeColorField] = inherited.color; - } - if (inherited.gradient) { - preservation[activeGradientField] = inherited.gradient; - } - } - } - return { - ...position, - ...preservation, - [colorField]: paint.color, - [gradientField]: paint.gradient ?? undefined, - }; - } - if (isEditorShadowPropertyPatchV1(op.patch)) { - return { - ...position, - ...projectElementShadowPatch({ - position: position as never, - elementType: op.elementType as 'key' | 'stat' | 'knob', - patch: op.patch, - defaultShadow: DEFAULT_ELEMENT_SHADOW_SPEC, - defaultActiveShadow: DEFAULT_ELEMENT_ACTIVE_SHADOW_SPEC, - }), - }; - } - if (isCounterFillPropertyPatchV1(op.patch)) { - return { - ...position, - ...projectCounterFillPatch(position as never, op.patch), - }; - } - if (isNotePaintPropertyPatchV1(op.patch)) { - // position 전달 - {opacity} 단독의 sibling shadow 재계산 (§9-5) - return { - ...position, - ...projectNotePaintPatch(op.patch, position as never), - }; - } - if (op.patch.property === 'displayText') { - return { ...position, displayText: op.patch.value }; - } - if (op.patch.property === 'className') { - return { ...position, className: op.patch.value }; - } - if (op.patch.property === 'borderWidth') { - return { ...position, borderWidth: op.patch.value }; - } - if (op.patch.property === 'borderRadius') { - return { ...position, borderRadius: op.patch.value }; - } - if (op.patch.property === 'fontSize') { - return { ...position, fontSize: op.patch.value }; - } - if (op.patch.property === 'noteGlowSize') { - return { ...position, noteGlowSize: op.patch.value }; - } - if (op.patch.property === 'noteOffsetX') { - // null 보존 - 백엔드가 이 필드를 null로 직렬화하므로 undefined로 - // 바꾸면 의미가 같은데도 getChangedEditorFields가 변경으로 잡는다 - return { ...position, noteOffsetX: op.patch.value }; - } - if (op.patch.property === 'noteOffsetY') { - // null 보존 - 백엔드가 이 필드를 null로 직렬화하므로 undefined로 - // 바꾸면 의미가 같은데도 getChangedEditorFields가 변경으로 잡는다 - return { ...position, noteOffsetY: op.patch.value }; - } - if (op.patch.property === 'noteWidth') { - // null 보존 - 백엔드가 이 필드를 null로 직렬화하므로 undefined로 - // 바꾸면 의미가 같은데도 getChangedEditorFields가 변경으로 잡는다 - return { ...position, noteWidth: op.patch.value }; - } - if (op.patch.property === 'noteBorderWidth') { - return { - ...position, - noteBorderWidth: op.patch.value, - }; - } - if (op.patch.property === 'noteBorderRadius') { - return { - ...position, - noteBorderRadius: op.patch.value, - }; - } - if (op.patch.property === 'noteEffectEnabled') { - return { - ...position, - noteEffectEnabled: op.patch.value, - }; - } - if (op.patch.property === 'noteAutoYCorrection') { - return { - ...position, - noteAutoYCorrection: op.patch.value, - }; - } - if (op.patch.property === 'noteGlowEnabled') { - return { - ...position, - noteGlowEnabled: op.patch.value, - }; - } - if (op.patch.property === 'noteGlowSyncPaint') { - // 켜는 순간 본체 페인트를 글로우로 복사 (Rust 적용기와 동일) - const next = { ...position, noteGlowSyncPaint: op.patch.value }; - return op.patch.value - ? { ...next, ...mirrorBodyPaintToGlow(next as never) } - : next; - } - if (op.patch.property === 'noteAlignment') { - return { ...position, noteAlignment: op.patch.value }; - } - if (op.patch.property === 'noteBorderSide') { - return { ...position, noteBorderSide: op.patch.value }; - } - if (op.patch.property === 'statType') { - return { ...position, statType: op.patch.value }; - } - if (op.patch.property === 'hidden') { - return { ...position, hidden: op.patch.value }; - } - // 속성 arm 누락을 컴파일 시점에 잡는다. Rust 적용부는 exhaustive - // match라 누락이 컴파일 오류지만 이 체인은 폴백으로 흘렀다 - const unhandled: never = op.patch; - void unhandled; - return position; - }), - } as never; - break; - } + applySemanticElementPatch(next, op, result, SEMANTIC_POSITION_FIELDS); return; } if (op.kind === 'setKeySlot') { From e7e31ea7b8ca1994b3076c6709c67584fd9af503 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 16:45:34 +0900 Subject: [PATCH 138/245] =?UTF-8?q?refactor:=20=EC=97=90=EB=94=94=ED=84=B0?= =?UTF-8?q?=20=EC=86=8D=EC=84=B1=20=ED=8C=A8=EC=B9=98=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/state/editor_ops/transition.rs | 777 +----------------- .../editor_ops/transition/element_patch.rs | 777 ++++++++++++++++++ 2 files changed, 781 insertions(+), 773 deletions(-) create mode 100644 src-tauri/src/state/editor_ops/transition/element_patch.rs diff --git a/src-tauri/src/state/editor_ops/transition.rs b/src-tauri/src/state/editor_ops/transition.rs index 8a5e0c4bc..4e2c937ea 100644 --- a/src-tauri/src/state/editor_ops/transition.rs +++ b/src-tauri/src/state/editor_ops/transition.rs @@ -1,5 +1,7 @@ use super::*; +mod element_patch; + // editor 단독 commit: 플러그인 그룹 참조를 store 인스턴스 decode로 파생 pub(crate) fn prepare_editor_ops_transition( current_store: &AppStoreData, @@ -511,779 +513,8 @@ pub(crate) fn prepare_editor_ops_transition_with_plugin_refs( }); continue; }; - let changed = match patch { - EditorElementPropertyPatchV1::Hidden(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.hidden == *patch { - false - } else { - position.hidden = *patch; - true - } - } - EditorElementPropertyPatchV1::LayerName(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.layer_name == *patch { - false - } else { - position.layer_name.clone_from(patch); - true - } - } - EditorElementPropertyPatchV1::GraphType(patch) => { - let graph = candidate - .graph_positions - .get_mut(&location.mode) - .and_then(|positions| positions.get_mut(location.index)) - .ok_or_else(|| { - EditorCommitError::validation( - "ELEMENT_LOCATOR_INVALID", - "graph property target no longer matches its stable ID", - ) - })?; - if graph.graph_type == *patch { - false - } else { - graph.graph_type = patch.clone(); - true - } - } - EditorElementPropertyPatchV1::GraphColor(patch) => { - let graph = candidate - .graph_positions - .get_mut(&location.mode) - .and_then(|positions| positions.get_mut(location.index)) - .ok_or_else(|| { - EditorCommitError::validation( - "ELEMENT_LOCATOR_INVALID", - "graph property target no longer matches its stable ID", - ) - })?; - if graph.graph_color == *patch { - false - } else { - graph.graph_color.clone_from(patch); - true - } - } - EditorElementPropertyPatchV1::ShowAvgLine(patch) => { - let graph = candidate - .graph_positions - .get_mut(&location.mode) - .and_then(|positions| positions.get_mut(location.index)) - .ok_or_else(|| { - EditorCommitError::validation( - "ELEMENT_LOCATOR_INVALID", - "graph property target no longer matches its stable ID", - ) - })?; - if graph.show_avg_line == *patch { - false - } else { - graph.show_avg_line = *patch; - true - } - } - EditorElementPropertyPatchV1::GraphAnimationEnabled(patch) => { - let graph = candidate - .graph_positions - .get_mut(&location.mode) - .and_then(|positions| positions.get_mut(location.index)) - .ok_or_else(|| { - EditorCommitError::validation( - "ELEMENT_LOCATOR_INVALID", - "graph property target no longer matches its stable ID", - ) - })?; - if graph.position.graph_animation_enabled == Some(*patch) { - false - } else { - graph.position.graph_animation_enabled = Some(*patch); - true - } - } - EditorElementPropertyPatchV1::GraphSpeed(patch) => { - let graph = candidate - .graph_positions - .get_mut(&location.mode) - .and_then(|positions| positions.get_mut(location.index)) - .ok_or_else(|| { - EditorCommitError::validation( - "ELEMENT_LOCATOR_INVALID", - "graph property target no longer matches its stable ID", - ) - })?; - if graph.graph_speed == *patch { - false - } else { - graph.graph_speed = *patch; - true - } - } - EditorElementPropertyPatchV1::Reverse(patch) => { - let knob = candidate - .knob_positions - .get_mut(&location.mode) - .and_then(|positions| positions.get_mut(location.index)) - .ok_or_else(|| { - EditorCommitError::validation( - "ELEMENT_LOCATOR_INVALID", - "knob property target no longer matches its stable ID", - ) - })?; - if knob.reverse == *patch { - false - } else { - knob.reverse = *patch; - true - } - } - EditorElementPropertyPatchV1::Sensitivity(patch) => { - let knob = candidate - .knob_positions - .get_mut(&location.mode) - .and_then(|positions| positions.get_mut(location.index)) - .ok_or_else(|| { - EditorCommitError::validation( - "ELEMENT_LOCATOR_INVALID", - "knob property target no longer matches its stable ID", - ) - })?; - if knob.sensitivity == *patch { - false - } else { - knob.sensitivity = *patch; - true - } - } - EditorElementPropertyPatchV1::AxisId(patch) => { - let knob = candidate - .knob_positions - .get_mut(&location.mode) - .and_then(|positions| positions.get_mut(location.index)) - .ok_or_else(|| { - EditorCommitError::validation( - "ELEMENT_LOCATOR_INVALID", - "knob property target no longer matches its stable ID", - ) - })?; - if knob.axis_id == *patch { - false - } else { - knob.axis_id.clone_from(patch); - true - } - } - EditorElementPropertyPatchV1::UseInlineStyles(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.use_inline_styles == Some(*patch) { - false - } else { - position.use_inline_styles = Some(*patch); - true - } - } - EditorElementPropertyPatchV1::FontWeight(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.font_weight == Some(*patch) { - false - } else { - // Bold 미확정 요소는 굵기 변경 전 암묵 상태를 고정 - (700, None)이 - // 새로 생기면 재시작 마이그레이션이 레거시 700으로 오인한다 - if position.font_bold.is_none() { - position.font_bold = - Some(matches!(position.font_weight, None | Some(700))); - } - position.font_weight = Some(*patch); - true - } - } - EditorElementPropertyPatchV1::FontBold(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.font_bold == Some(*patch) { - false - } else { - position.font_bold = Some(*patch); - true - } - } - EditorElementPropertyPatchV1::FontItalic(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.font_italic == Some(*patch) { - false - } else { - position.font_italic = Some(*patch); - true - } - } - EditorElementPropertyPatchV1::FontUnderline(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.font_underline == Some(*patch) { - false - } else { - position.font_underline = Some(*patch); - true - } - } - EditorElementPropertyPatchV1::FontStrikethrough(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.font_strikethrough == Some(*patch) { - false - } else { - position.font_strikethrough = Some(*patch); - true - } - } - EditorElementPropertyPatchV1::FontFamily(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.font_family.as_deref() == Some(patch.as_str()) { - false - } else { - position.font_family = Some(patch.clone()); - true - } - } - EditorElementPropertyPatchV1::DisplayText(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.display_text.as_deref() == Some(patch.as_str()) { - false - } else { - position.display_text = Some(patch.clone()); - true - } - } - EditorElementPropertyPatchV1::ClassName(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.class_name.as_deref() == Some(patch.as_str()) { - false - } else { - position.class_name = Some(patch.clone()); - true - } - } - EditorElementPropertyPatchV1::FontPaint(patch) => { - let position = position_at_mut(&mut candidate, location)?; - let preserved = matches!(location.element_type, EditorElementTypeV1::Key) - && preserve_active_paint_fallback(position, PaintSurface::Font); - apply_font_paint_descriptor( - &mut position.font_color, - &mut position.font_gradient, - patch, - ) || preserved - } - EditorElementPropertyPatchV1::ActiveFontPaint(patch) => { - let position = position_at_mut(&mut candidate, location)?; - apply_font_paint_descriptor( - &mut position.active_font_color, - &mut position.active_font_gradient, - patch, - ) - } - EditorElementPropertyPatchV1::Shadow(patch) => { - let position = position_at_mut(&mut candidate, location)?; - patch_shadow(position, location.element_type, false, patch) - } - EditorElementPropertyPatchV1::ActiveShadow(patch) => { - let position = position_at_mut(&mut candidate, location)?; - patch_shadow(position, location.element_type, true, patch) - } - EditorElementPropertyPatchV1::ShadowEnabled(patch) => { - let position = position_at_mut(&mut candidate, location)?; - patch_shadow_enabled(position, location.element_type, *patch) - } - EditorElementPropertyPatchV1::BackgroundPaint(patch) => { - let position = position_at_mut(&mut candidate, location)?; - let preserved = - matches!( - location.element_type, - EditorElementTypeV1::Key | EditorElementTypeV1::Knob - ) && preserve_active_paint_fallback(position, PaintSurface::Background); - apply_paint_descriptor( - &mut position.background_color, - &mut position.background_gradient, - patch, - ) || preserved - } - EditorElementPropertyPatchV1::ActiveBackgroundPaint(patch) => { - let position = position_at_mut(&mut candidate, location)?; - apply_paint_descriptor( - &mut position.active_background_color, - &mut position.active_background_gradient, - patch, - ) - } - EditorElementPropertyPatchV1::BorderPaint(patch) => { - let position = position_at_mut(&mut candidate, location)?; - let preserved = - matches!( - location.element_type, - EditorElementTypeV1::Key | EditorElementTypeV1::Knob - ) && preserve_active_paint_fallback(position, PaintSurface::Border); - apply_paint_descriptor( - &mut position.border_color, - &mut position.border_gradient, - patch, - ) || preserved - } - EditorElementPropertyPatchV1::ActiveBorderPaint(patch) => { - let position = position_at_mut(&mut candidate, location)?; - apply_paint_descriptor( - &mut position.active_border_color, - &mut position.active_border_gradient, - patch, - ) - } - EditorElementPropertyPatchV1::BorderWidth(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.border_width == Some(*patch) { - false - } else { - position.border_width = Some(*patch); - true - } - } - EditorElementPropertyPatchV1::BorderRadius(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.border_radius == Some(*patch) { - false - } else { - position.border_radius = Some(*patch); - true - } - } - EditorElementPropertyPatchV1::FontSize(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.font_size == Some(*patch) { - false - } else { - position.font_size = Some(*patch); - true - } - } - EditorElementPropertyPatchV1::InactiveImage(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.inactive_image.as_deref() == Some(patch.as_str()) { - false - } else { - position.inactive_image = Some(patch.clone()); - true - } - } - EditorElementPropertyPatchV1::ActiveImage(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.active_image.as_deref() == Some(patch.as_str()) { - false - } else { - position.active_image = Some(patch.clone()); - true - } - } - EditorElementPropertyPatchV1::IdleTransparent(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.idle_transparent == *patch { - false - } else { - position.idle_transparent = *patch; - true - } - } - EditorElementPropertyPatchV1::ActiveTransparent(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.active_transparent == *patch { - false - } else { - position.active_transparent = *patch; - true - } - } - EditorElementPropertyPatchV1::IdleImageFit(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.idle_image_fit.as_ref() == Some(patch) { - false - } else { - position.idle_image_fit = Some(patch.clone()); - true - } - } - EditorElementPropertyPatchV1::ActiveImageFit(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.active_image_fit.as_ref() == Some(patch) { - false - } else { - position.active_image_fit = Some(patch.clone()); - true - } - } - EditorElementPropertyPatchV1::ImageMode(patch) => { - let position = position_at_mut(&mut candidate, location)?; - let next = match patch { - ImageMode::Replace => None, - ImageMode::Overlay => Some(ImageMode::Overlay), - }; - if position.image_mode == next { - false - } else { - position.image_mode = next; - true - } - } - EditorElementPropertyPatchV1::IdleImageTransform(patch) => { - let position = position_at_mut(&mut candidate, location)?; - patch_image_transform(&mut position.idle_image_transform, patch) - } - EditorElementPropertyPatchV1::ActiveImageTransform(patch) => { - let position = position_at_mut(&mut candidate, location)?; - patch_image_transform(&mut position.active_image_transform, patch) - } - EditorElementPropertyPatchV1::SoundPath(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.sound_path.as_deref() == Some(patch.as_str()) { - false - } else { - position.sound_path = Some(patch.clone()); - true - } - } - EditorElementPropertyPatchV1::SoundEnabled(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.sound_enabled == Some(*patch) { - false - } else { - position.sound_enabled = Some(*patch); - true - } - } - EditorElementPropertyPatchV1::SoundVolume(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.sound_volume == Some(*patch) { - false - } else { - position.sound_volume = Some(*patch); - true - } - } - EditorElementPropertyPatchV1::CounterEnabled(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.counter.enabled == *patch { - false - } else { - position.counter.enabled = *patch; - true - } - } - EditorElementPropertyPatchV1::CounterAnimationEnabled(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.counter.animation.enabled == *patch { - false - } else { - position.counter.animation.enabled = *patch; - true - } - } - EditorElementPropertyPatchV1::CounterPlacement(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.counter.placement == *patch { - false - } else { - position.counter.placement = patch.clone(); - true - } - } - EditorElementPropertyPatchV1::CounterAlign(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.counter.align == *patch { - false - } else { - position.counter.align = patch.clone(); - true - } - } - EditorElementPropertyPatchV1::CounterAlignMode(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.counter.align_mode == *patch { - false - } else { - position.counter.align_mode = patch.clone(); - true - } - } - EditorElementPropertyPatchV1::CounterGap(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.counter.gap == *patch { - false - } else { - position.counter.gap = *patch; - true - } - } - EditorElementPropertyPatchV1::CounterFontSize(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.counter.font_size == *patch { - false - } else { - position.counter.font_size = *patch; - true - } - } - EditorElementPropertyPatchV1::CounterFontWeight(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.counter.font_weight == *patch { - false - } else { - if position.counter.font_bold.is_none() { - position.counter.font_bold = - Some(position.counter.font_weight == 700); - } - position.counter.font_weight = *patch; - true - } - } - EditorElementPropertyPatchV1::CounterFontBold(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.counter.font_bold == Some(*patch) { - false - } else { - position.counter.font_bold = Some(*patch); - true - } - } - EditorElementPropertyPatchV1::CounterFontItalic(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.counter.font_italic == *patch { - false - } else { - position.counter.font_italic = *patch; - true - } - } - EditorElementPropertyPatchV1::CounterFontUnderline(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.counter.font_underline == *patch { - false - } else { - position.counter.font_underline = *patch; - true - } - } - EditorElementPropertyPatchV1::CounterFontStrikethrough(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.counter.font_strikethrough == *patch { - false - } else { - position.counter.font_strikethrough = *patch; - true - } - } - EditorElementPropertyPatchV1::CounterFontFamily(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.counter.font_family.as_deref() == Some(patch.as_str()) { - false - } else { - position.counter.font_family = Some(patch.clone()); - true - } - } - EditorElementPropertyPatchV1::CounterFillIdle(patch) => { - let position = position_at_mut(&mut candidate, location)?; - patch_counter_fill(position, false, patch) - } - EditorElementPropertyPatchV1::CounterFillActive(patch) => { - let position = position_at_mut(&mut candidate, location)?; - patch_counter_fill(position, true, patch) - } - EditorElementPropertyPatchV1::CounterAnimationPreset(patch) => { - let position = position_at_mut(&mut candidate, location)?; - let animation = &mut position.counter.animation; - let mut changed = false; - if patch.apply_preset_id == Some(true) - && animation.preset_id.as_deref() != Some(patch.preset_id.as_str()) - { - animation.preset_id = Some(patch.preset_id.clone()); - changed = true; - } - if let Some(bezier) = patch.bezier { - if animation.bezier != bezier { - animation.bezier = bezier; - changed = true; - } - } - if let Some(scale) = patch.scale { - if animation.scale != scale { - animation.scale = scale; - changed = true; - } - } - if let Some(duration_ms) = patch.duration_ms { - if animation.duration_ms != duration_ms { - animation.duration_ms = duration_ms; - changed = true; - } - } - changed - } - EditorElementPropertyPatchV1::StatType(patch) => { - let stat = candidate - .stat_positions - .get_mut(&location.mode) - .and_then(|positions| positions.get_mut(location.index)) - .ok_or_else(|| { - EditorCommitError::validation( - "ELEMENT_LOCATOR_INVALID", - "stat property target no longer matches its stable ID", - ) - })?; - if stat.stat_type == *patch { - false - } else { - stat.stat_type = patch.clone(); - true - } - } - EditorElementPropertyPatchV1::NoteEffectEnabled(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.note_effect_enabled == *patch { - false - } else { - position.note_effect_enabled = *patch; - true - } - } - EditorElementPropertyPatchV1::NoteGlowEnabled(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.note_glow_enabled == *patch { - false - } else { - position.note_glow_enabled = *patch; - true - } - } - EditorElementPropertyPatchV1::NoteGlowSyncPaint(patch) => { - let position = position_at_mut(&mut candidate, location)?; - let mut changed = position.note_glow_sync_paint != *patch; - position.note_glow_sync_paint = *patch; - if *patch { - changed |= position.mirror_note_body_to_glow(); - } - changed - } - EditorElementPropertyPatchV1::NoteGlowSize(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.note_glow_size == *patch { - false - } else { - position.note_glow_size = *patch; - true - } - } - EditorElementPropertyPatchV1::NotePaint(patch) => { - let position = position_at_mut(&mut candidate, location)?; - let mut changed = patch_note_paint(position, false, patch); - if position.note_glow_sync_paint { - changed |= position.mirror_note_body_to_glow(); - } - changed - } - EditorElementPropertyPatchV1::NoteGlowPaint(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.note_glow_sync_paint { - return Err(EditorCommitError::validation( - "NOTE_GLOW_PAINT_SYNC_LOCKED", - format!( - "editor op {op_index} note glow paint cannot be edited while paint sync is enabled" - ), - )); - } - patch_note_paint(position, true, patch) - } - EditorElementPropertyPatchV1::NoteBorderPaint(patch) => { - let position = position_at_mut(&mut candidate, location)?; - let next_gradient = patch - .gradient - .as_ref() - .map(EditorPaintGradientV1::to_gradient_spec); - let changed = position.note_border_color.as_deref() - != Some(patch.color.as_str()) - || position.note_border_opacity != patch.opacity - || position.note_border_gradient != next_gradient; - position.note_border_color = Some(patch.color.clone()); - position.note_border_opacity = patch.opacity; - position.note_border_gradient = next_gradient; - changed - } - EditorElementPropertyPatchV1::NoteOffsetX(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.note_offset_x == *patch { - false - } else { - position.note_offset_x = *patch; - true - } - } - EditorElementPropertyPatchV1::NoteOffsetY(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.note_offset_y == *patch { - false - } else { - position.note_offset_y = *patch; - true - } - } - EditorElementPropertyPatchV1::NoteWidth(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.note_width == *patch { - false - } else { - position.note_width = *patch; - true - } - } - EditorElementPropertyPatchV1::NoteBorderWidth(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.note_border_width == Some(*patch) { - false - } else { - position.note_border_width = Some(*patch); - true - } - } - EditorElementPropertyPatchV1::NoteBorderRadius(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.note_border_radius == Some(*patch) { - false - } else { - position.note_border_radius = Some(*patch); - true - } - } - EditorElementPropertyPatchV1::NoteAutoYCorrection(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.note_auto_y_correction == *patch { - false - } else { - position.note_auto_y_correction = *patch; - true - } - } - EditorElementPropertyPatchV1::NoteAlignment(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.note_alignment == *patch { - false - } else { - position.note_alignment = patch.clone(); - true - } - } - EditorElementPropertyPatchV1::NoteBorderSide(patch) => { - let position = position_at_mut(&mut candidate, location)?; - let note_border_side = patch.as_str(); - if position.note_border_side.as_deref() == Some(note_border_side) { - false - } else { - position.note_border_side = Some(note_border_side.to_string()); - true - } - } - }; + let changed = + element_patch::apply_element_patch(&mut candidate, location, patch, op_index)?; op_results.push(EditorOpResultV1 { status: if changed { EditorOpResultStatusV1::Applied diff --git a/src-tauri/src/state/editor_ops/transition/element_patch.rs b/src-tauri/src/state/editor_ops/transition/element_patch.rs new file mode 100644 index 000000000..bd1ee74d4 --- /dev/null +++ b/src-tauri/src/state/editor_ops/transition/element_patch.rs @@ -0,0 +1,777 @@ +use super::super::*; + +pub(super) fn apply_element_patch( + candidate: &mut EditorDocumentV1, + location: &ElementLocation, + patch: &EditorElementPropertyPatchV1, + op_index: usize, +) -> Result { + Ok(match patch { + EditorElementPropertyPatchV1::Hidden(patch) => { + let position = position_at_mut(candidate, location)?; + if position.hidden == *patch { + false + } else { + position.hidden = *patch; + true + } + } + EditorElementPropertyPatchV1::LayerName(patch) => { + let position = position_at_mut(candidate, location)?; + if position.layer_name == *patch { + false + } else { + position.layer_name.clone_from(patch); + true + } + } + EditorElementPropertyPatchV1::GraphType(patch) => { + let graph = candidate + .graph_positions + .get_mut(&location.mode) + .and_then(|positions| positions.get_mut(location.index)) + .ok_or_else(|| { + EditorCommitError::validation( + "ELEMENT_LOCATOR_INVALID", + "graph property target no longer matches its stable ID", + ) + })?; + if graph.graph_type == *patch { + false + } else { + graph.graph_type = patch.clone(); + true + } + } + EditorElementPropertyPatchV1::GraphColor(patch) => { + let graph = candidate + .graph_positions + .get_mut(&location.mode) + .and_then(|positions| positions.get_mut(location.index)) + .ok_or_else(|| { + EditorCommitError::validation( + "ELEMENT_LOCATOR_INVALID", + "graph property target no longer matches its stable ID", + ) + })?; + if graph.graph_color == *patch { + false + } else { + graph.graph_color.clone_from(patch); + true + } + } + EditorElementPropertyPatchV1::ShowAvgLine(patch) => { + let graph = candidate + .graph_positions + .get_mut(&location.mode) + .and_then(|positions| positions.get_mut(location.index)) + .ok_or_else(|| { + EditorCommitError::validation( + "ELEMENT_LOCATOR_INVALID", + "graph property target no longer matches its stable ID", + ) + })?; + if graph.show_avg_line == *patch { + false + } else { + graph.show_avg_line = *patch; + true + } + } + EditorElementPropertyPatchV1::GraphAnimationEnabled(patch) => { + let graph = candidate + .graph_positions + .get_mut(&location.mode) + .and_then(|positions| positions.get_mut(location.index)) + .ok_or_else(|| { + EditorCommitError::validation( + "ELEMENT_LOCATOR_INVALID", + "graph property target no longer matches its stable ID", + ) + })?; + if graph.position.graph_animation_enabled == Some(*patch) { + false + } else { + graph.position.graph_animation_enabled = Some(*patch); + true + } + } + EditorElementPropertyPatchV1::GraphSpeed(patch) => { + let graph = candidate + .graph_positions + .get_mut(&location.mode) + .and_then(|positions| positions.get_mut(location.index)) + .ok_or_else(|| { + EditorCommitError::validation( + "ELEMENT_LOCATOR_INVALID", + "graph property target no longer matches its stable ID", + ) + })?; + if graph.graph_speed == *patch { + false + } else { + graph.graph_speed = *patch; + true + } + } + EditorElementPropertyPatchV1::Reverse(patch) => { + let knob = candidate + .knob_positions + .get_mut(&location.mode) + .and_then(|positions| positions.get_mut(location.index)) + .ok_or_else(|| { + EditorCommitError::validation( + "ELEMENT_LOCATOR_INVALID", + "knob property target no longer matches its stable ID", + ) + })?; + if knob.reverse == *patch { + false + } else { + knob.reverse = *patch; + true + } + } + EditorElementPropertyPatchV1::Sensitivity(patch) => { + let knob = candidate + .knob_positions + .get_mut(&location.mode) + .and_then(|positions| positions.get_mut(location.index)) + .ok_or_else(|| { + EditorCommitError::validation( + "ELEMENT_LOCATOR_INVALID", + "knob property target no longer matches its stable ID", + ) + })?; + if knob.sensitivity == *patch { + false + } else { + knob.sensitivity = *patch; + true + } + } + EditorElementPropertyPatchV1::AxisId(patch) => { + let knob = candidate + .knob_positions + .get_mut(&location.mode) + .and_then(|positions| positions.get_mut(location.index)) + .ok_or_else(|| { + EditorCommitError::validation( + "ELEMENT_LOCATOR_INVALID", + "knob property target no longer matches its stable ID", + ) + })?; + if knob.axis_id == *patch { + false + } else { + knob.axis_id.clone_from(patch); + true + } + } + EditorElementPropertyPatchV1::UseInlineStyles(patch) => { + let position = position_at_mut(candidate, location)?; + if position.use_inline_styles == Some(*patch) { + false + } else { + position.use_inline_styles = Some(*patch); + true + } + } + EditorElementPropertyPatchV1::FontWeight(patch) => { + let position = position_at_mut(candidate, location)?; + if position.font_weight == Some(*patch) { + false + } else { + // Bold 미확정 요소는 굵기 변경 전 암묵 상태를 고정 - (700, None)이 + // 새로 생기면 재시작 마이그레이션이 레거시 700으로 오인한다 + if position.font_bold.is_none() { + position.font_bold = Some(matches!(position.font_weight, None | Some(700))); + } + position.font_weight = Some(*patch); + true + } + } + EditorElementPropertyPatchV1::FontBold(patch) => { + let position = position_at_mut(candidate, location)?; + if position.font_bold == Some(*patch) { + false + } else { + position.font_bold = Some(*patch); + true + } + } + EditorElementPropertyPatchV1::FontItalic(patch) => { + let position = position_at_mut(candidate, location)?; + if position.font_italic == Some(*patch) { + false + } else { + position.font_italic = Some(*patch); + true + } + } + EditorElementPropertyPatchV1::FontUnderline(patch) => { + let position = position_at_mut(candidate, location)?; + if position.font_underline == Some(*patch) { + false + } else { + position.font_underline = Some(*patch); + true + } + } + EditorElementPropertyPatchV1::FontStrikethrough(patch) => { + let position = position_at_mut(candidate, location)?; + if position.font_strikethrough == Some(*patch) { + false + } else { + position.font_strikethrough = Some(*patch); + true + } + } + EditorElementPropertyPatchV1::FontFamily(patch) => { + let position = position_at_mut(candidate, location)?; + if position.font_family.as_deref() == Some(patch.as_str()) { + false + } else { + position.font_family = Some(patch.clone()); + true + } + } + EditorElementPropertyPatchV1::DisplayText(patch) => { + let position = position_at_mut(candidate, location)?; + if position.display_text.as_deref() == Some(patch.as_str()) { + false + } else { + position.display_text = Some(patch.clone()); + true + } + } + EditorElementPropertyPatchV1::ClassName(patch) => { + let position = position_at_mut(candidate, location)?; + if position.class_name.as_deref() == Some(patch.as_str()) { + false + } else { + position.class_name = Some(patch.clone()); + true + } + } + EditorElementPropertyPatchV1::FontPaint(patch) => { + let position = position_at_mut(candidate, location)?; + let preserved = matches!(location.element_type, EditorElementTypeV1::Key) + && preserve_active_paint_fallback(position, PaintSurface::Font); + apply_font_paint_descriptor( + &mut position.font_color, + &mut position.font_gradient, + patch, + ) || preserved + } + EditorElementPropertyPatchV1::ActiveFontPaint(patch) => { + let position = position_at_mut(candidate, location)?; + apply_font_paint_descriptor( + &mut position.active_font_color, + &mut position.active_font_gradient, + patch, + ) + } + EditorElementPropertyPatchV1::Shadow(patch) => { + let position = position_at_mut(candidate, location)?; + patch_shadow(position, location.element_type, false, patch) + } + EditorElementPropertyPatchV1::ActiveShadow(patch) => { + let position = position_at_mut(candidate, location)?; + patch_shadow(position, location.element_type, true, patch) + } + EditorElementPropertyPatchV1::ShadowEnabled(patch) => { + let position = position_at_mut(candidate, location)?; + patch_shadow_enabled(position, location.element_type, *patch) + } + EditorElementPropertyPatchV1::BackgroundPaint(patch) => { + let position = position_at_mut(candidate, location)?; + let preserved = matches!( + location.element_type, + EditorElementTypeV1::Key | EditorElementTypeV1::Knob + ) && preserve_active_paint_fallback(position, PaintSurface::Background); + apply_paint_descriptor( + &mut position.background_color, + &mut position.background_gradient, + patch, + ) || preserved + } + EditorElementPropertyPatchV1::ActiveBackgroundPaint(patch) => { + let position = position_at_mut(candidate, location)?; + apply_paint_descriptor( + &mut position.active_background_color, + &mut position.active_background_gradient, + patch, + ) + } + EditorElementPropertyPatchV1::BorderPaint(patch) => { + let position = position_at_mut(candidate, location)?; + let preserved = matches!( + location.element_type, + EditorElementTypeV1::Key | EditorElementTypeV1::Knob + ) && preserve_active_paint_fallback(position, PaintSurface::Border); + apply_paint_descriptor( + &mut position.border_color, + &mut position.border_gradient, + patch, + ) || preserved + } + EditorElementPropertyPatchV1::ActiveBorderPaint(patch) => { + let position = position_at_mut(candidate, location)?; + apply_paint_descriptor( + &mut position.active_border_color, + &mut position.active_border_gradient, + patch, + ) + } + EditorElementPropertyPatchV1::BorderWidth(patch) => { + let position = position_at_mut(candidate, location)?; + if position.border_width == Some(*patch) { + false + } else { + position.border_width = Some(*patch); + true + } + } + EditorElementPropertyPatchV1::BorderRadius(patch) => { + let position = position_at_mut(candidate, location)?; + if position.border_radius == Some(*patch) { + false + } else { + position.border_radius = Some(*patch); + true + } + } + EditorElementPropertyPatchV1::FontSize(patch) => { + let position = position_at_mut(candidate, location)?; + if position.font_size == Some(*patch) { + false + } else { + position.font_size = Some(*patch); + true + } + } + EditorElementPropertyPatchV1::InactiveImage(patch) => { + let position = position_at_mut(candidate, location)?; + if position.inactive_image.as_deref() == Some(patch.as_str()) { + false + } else { + position.inactive_image = Some(patch.clone()); + true + } + } + EditorElementPropertyPatchV1::ActiveImage(patch) => { + let position = position_at_mut(candidate, location)?; + if position.active_image.as_deref() == Some(patch.as_str()) { + false + } else { + position.active_image = Some(patch.clone()); + true + } + } + EditorElementPropertyPatchV1::IdleTransparent(patch) => { + let position = position_at_mut(candidate, location)?; + if position.idle_transparent == *patch { + false + } else { + position.idle_transparent = *patch; + true + } + } + EditorElementPropertyPatchV1::ActiveTransparent(patch) => { + let position = position_at_mut(candidate, location)?; + if position.active_transparent == *patch { + false + } else { + position.active_transparent = *patch; + true + } + } + EditorElementPropertyPatchV1::IdleImageFit(patch) => { + let position = position_at_mut(candidate, location)?; + if position.idle_image_fit.as_ref() == Some(patch) { + false + } else { + position.idle_image_fit = Some(patch.clone()); + true + } + } + EditorElementPropertyPatchV1::ActiveImageFit(patch) => { + let position = position_at_mut(candidate, location)?; + if position.active_image_fit.as_ref() == Some(patch) { + false + } else { + position.active_image_fit = Some(patch.clone()); + true + } + } + EditorElementPropertyPatchV1::ImageMode(patch) => { + let position = position_at_mut(candidate, location)?; + let next = match patch { + ImageMode::Replace => None, + ImageMode::Overlay => Some(ImageMode::Overlay), + }; + if position.image_mode == next { + false + } else { + position.image_mode = next; + true + } + } + EditorElementPropertyPatchV1::IdleImageTransform(patch) => { + let position = position_at_mut(candidate, location)?; + patch_image_transform(&mut position.idle_image_transform, patch) + } + EditorElementPropertyPatchV1::ActiveImageTransform(patch) => { + let position = position_at_mut(candidate, location)?; + patch_image_transform(&mut position.active_image_transform, patch) + } + EditorElementPropertyPatchV1::SoundPath(patch) => { + let position = position_at_mut(candidate, location)?; + if position.sound_path.as_deref() == Some(patch.as_str()) { + false + } else { + position.sound_path = Some(patch.clone()); + true + } + } + EditorElementPropertyPatchV1::SoundEnabled(patch) => { + let position = position_at_mut(candidate, location)?; + if position.sound_enabled == Some(*patch) { + false + } else { + position.sound_enabled = Some(*patch); + true + } + } + EditorElementPropertyPatchV1::SoundVolume(patch) => { + let position = position_at_mut(candidate, location)?; + if position.sound_volume == Some(*patch) { + false + } else { + position.sound_volume = Some(*patch); + true + } + } + EditorElementPropertyPatchV1::CounterEnabled(patch) => { + let position = position_at_mut(candidate, location)?; + if position.counter.enabled == *patch { + false + } else { + position.counter.enabled = *patch; + true + } + } + EditorElementPropertyPatchV1::CounterAnimationEnabled(patch) => { + let position = position_at_mut(candidate, location)?; + if position.counter.animation.enabled == *patch { + false + } else { + position.counter.animation.enabled = *patch; + true + } + } + EditorElementPropertyPatchV1::CounterPlacement(patch) => { + let position = position_at_mut(candidate, location)?; + if position.counter.placement == *patch { + false + } else { + position.counter.placement = patch.clone(); + true + } + } + EditorElementPropertyPatchV1::CounterAlign(patch) => { + let position = position_at_mut(candidate, location)?; + if position.counter.align == *patch { + false + } else { + position.counter.align = patch.clone(); + true + } + } + EditorElementPropertyPatchV1::CounterAlignMode(patch) => { + let position = position_at_mut(candidate, location)?; + if position.counter.align_mode == *patch { + false + } else { + position.counter.align_mode = patch.clone(); + true + } + } + EditorElementPropertyPatchV1::CounterGap(patch) => { + let position = position_at_mut(candidate, location)?; + if position.counter.gap == *patch { + false + } else { + position.counter.gap = *patch; + true + } + } + EditorElementPropertyPatchV1::CounterFontSize(patch) => { + let position = position_at_mut(candidate, location)?; + if position.counter.font_size == *patch { + false + } else { + position.counter.font_size = *patch; + true + } + } + EditorElementPropertyPatchV1::CounterFontWeight(patch) => { + let position = position_at_mut(candidate, location)?; + if position.counter.font_weight == *patch { + false + } else { + if position.counter.font_bold.is_none() { + position.counter.font_bold = Some(position.counter.font_weight == 700); + } + position.counter.font_weight = *patch; + true + } + } + EditorElementPropertyPatchV1::CounterFontBold(patch) => { + let position = position_at_mut(candidate, location)?; + if position.counter.font_bold == Some(*patch) { + false + } else { + position.counter.font_bold = Some(*patch); + true + } + } + EditorElementPropertyPatchV1::CounterFontItalic(patch) => { + let position = position_at_mut(candidate, location)?; + if position.counter.font_italic == *patch { + false + } else { + position.counter.font_italic = *patch; + true + } + } + EditorElementPropertyPatchV1::CounterFontUnderline(patch) => { + let position = position_at_mut(candidate, location)?; + if position.counter.font_underline == *patch { + false + } else { + position.counter.font_underline = *patch; + true + } + } + EditorElementPropertyPatchV1::CounterFontStrikethrough(patch) => { + let position = position_at_mut(candidate, location)?; + if position.counter.font_strikethrough == *patch { + false + } else { + position.counter.font_strikethrough = *patch; + true + } + } + EditorElementPropertyPatchV1::CounterFontFamily(patch) => { + let position = position_at_mut(candidate, location)?; + if position.counter.font_family.as_deref() == Some(patch.as_str()) { + false + } else { + position.counter.font_family = Some(patch.clone()); + true + } + } + EditorElementPropertyPatchV1::CounterFillIdle(patch) => { + let position = position_at_mut(candidate, location)?; + patch_counter_fill(position, false, patch) + } + EditorElementPropertyPatchV1::CounterFillActive(patch) => { + let position = position_at_mut(candidate, location)?; + patch_counter_fill(position, true, patch) + } + EditorElementPropertyPatchV1::CounterAnimationPreset(patch) => { + let position = position_at_mut(candidate, location)?; + let animation = &mut position.counter.animation; + let mut changed = false; + if patch.apply_preset_id == Some(true) + && animation.preset_id.as_deref() != Some(patch.preset_id.as_str()) + { + animation.preset_id = Some(patch.preset_id.clone()); + changed = true; + } + if let Some(bezier) = patch.bezier { + if animation.bezier != bezier { + animation.bezier = bezier; + changed = true; + } + } + if let Some(scale) = patch.scale { + if animation.scale != scale { + animation.scale = scale; + changed = true; + } + } + if let Some(duration_ms) = patch.duration_ms { + if animation.duration_ms != duration_ms { + animation.duration_ms = duration_ms; + changed = true; + } + } + changed + } + EditorElementPropertyPatchV1::StatType(patch) => { + let stat = candidate + .stat_positions + .get_mut(&location.mode) + .and_then(|positions| positions.get_mut(location.index)) + .ok_or_else(|| { + EditorCommitError::validation( + "ELEMENT_LOCATOR_INVALID", + "stat property target no longer matches its stable ID", + ) + })?; + if stat.stat_type == *patch { + false + } else { + stat.stat_type = patch.clone(); + true + } + } + EditorElementPropertyPatchV1::NoteEffectEnabled(patch) => { + let position = position_at_mut(candidate, location)?; + if position.note_effect_enabled == *patch { + false + } else { + position.note_effect_enabled = *patch; + true + } + } + EditorElementPropertyPatchV1::NoteGlowEnabled(patch) => { + let position = position_at_mut(candidate, location)?; + if position.note_glow_enabled == *patch { + false + } else { + position.note_glow_enabled = *patch; + true + } + } + EditorElementPropertyPatchV1::NoteGlowSyncPaint(patch) => { + let position = position_at_mut(candidate, location)?; + let mut changed = position.note_glow_sync_paint != *patch; + position.note_glow_sync_paint = *patch; + if *patch { + changed |= position.mirror_note_body_to_glow(); + } + changed + } + EditorElementPropertyPatchV1::NoteGlowSize(patch) => { + let position = position_at_mut(candidate, location)?; + if position.note_glow_size == *patch { + false + } else { + position.note_glow_size = *patch; + true + } + } + EditorElementPropertyPatchV1::NotePaint(patch) => { + let position = position_at_mut(candidate, location)?; + let mut changed = patch_note_paint(position, false, patch); + if position.note_glow_sync_paint { + changed |= position.mirror_note_body_to_glow(); + } + changed + } + EditorElementPropertyPatchV1::NoteGlowPaint(patch) => { + let position = position_at_mut(candidate, location)?; + if position.note_glow_sync_paint { + return Err(EditorCommitError::validation( + "NOTE_GLOW_PAINT_SYNC_LOCKED", + format!( + "editor op {op_index} note glow paint cannot be edited while paint sync is enabled" + ), + )); + } + patch_note_paint(position, true, patch) + } + EditorElementPropertyPatchV1::NoteBorderPaint(patch) => { + let position = position_at_mut(candidate, location)?; + let next_gradient = patch + .gradient + .as_ref() + .map(EditorPaintGradientV1::to_gradient_spec); + let changed = position.note_border_color.as_deref() != Some(patch.color.as_str()) + || position.note_border_opacity != patch.opacity + || position.note_border_gradient != next_gradient; + position.note_border_color = Some(patch.color.clone()); + position.note_border_opacity = patch.opacity; + position.note_border_gradient = next_gradient; + changed + } + EditorElementPropertyPatchV1::NoteOffsetX(patch) => { + let position = position_at_mut(candidate, location)?; + if position.note_offset_x == *patch { + false + } else { + position.note_offset_x = *patch; + true + } + } + EditorElementPropertyPatchV1::NoteOffsetY(patch) => { + let position = position_at_mut(candidate, location)?; + if position.note_offset_y == *patch { + false + } else { + position.note_offset_y = *patch; + true + } + } + EditorElementPropertyPatchV1::NoteWidth(patch) => { + let position = position_at_mut(candidate, location)?; + if position.note_width == *patch { + false + } else { + position.note_width = *patch; + true + } + } + EditorElementPropertyPatchV1::NoteBorderWidth(patch) => { + let position = position_at_mut(candidate, location)?; + if position.note_border_width == Some(*patch) { + false + } else { + position.note_border_width = Some(*patch); + true + } + } + EditorElementPropertyPatchV1::NoteBorderRadius(patch) => { + let position = position_at_mut(candidate, location)?; + if position.note_border_radius == Some(*patch) { + false + } else { + position.note_border_radius = Some(*patch); + true + } + } + EditorElementPropertyPatchV1::NoteAutoYCorrection(patch) => { + let position = position_at_mut(candidate, location)?; + if position.note_auto_y_correction == *patch { + false + } else { + position.note_auto_y_correction = *patch; + true + } + } + EditorElementPropertyPatchV1::NoteAlignment(patch) => { + let position = position_at_mut(candidate, location)?; + if position.note_alignment == *patch { + false + } else { + position.note_alignment = patch.clone(); + true + } + } + EditorElementPropertyPatchV1::NoteBorderSide(patch) => { + let position = position_at_mut(candidate, location)?; + let note_border_side = patch.as_str(); + if position.note_border_side.as_deref() == Some(note_border_side) { + false + } else { + position.note_border_side = Some(note_border_side.to_string()); + true + } + } + }) +} From dc6f45147e95b792e2fb7e9b02f2dd5fb3384e80 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 16:45:41 +0900 Subject: [PATCH 139/245] =?UTF-8?q?refactor:=20=ED=9E=88=EC=8A=A4=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/state/history.rs | 650 +-------------------------- src-tauri/src/state/history/tests.rs | 647 ++++++++++++++++++++++++++ 2 files changed, 648 insertions(+), 649 deletions(-) create mode 100644 src-tauri/src/state/history/tests.rs diff --git a/src-tauri/src/state/history.rs b/src-tauri/src/state/history.rs index 96ddcdf3b..c54c02ab8 100644 --- a/src-tauri/src/state/history.rs +++ b/src-tauri/src/state/history.rs @@ -1253,652 +1253,4 @@ fn snapshot_contains_plugin_elements(snapshot: &HistorySnapshot, plugin_id: Opti } #[cfg(test)] -mod tests { - use super::*; - use crate::{ - keyboard::KeyboardManager, - models::{KeySlot, EDITOR_SCHEMA_VERSION}, - }; - use std::{ - collections::HashMap, - sync::{mpsc, Arc}, - thread, - time::{Duration, Instant}, - }; - - fn patch(text: &str) -> EditorPatchV1 { - EditorPatchV1 { - schema_version: EDITOR_SCHEMA_VERSION, - keys: Some(std::collections::HashMap::from([( - "mode".to_string(), - vec![text.into()], - )])), - ..EditorPatchV1::default() - } - } - - fn plugin_snapshot(plugin_id: &str) -> PluginElementsHistorySnapshot { - PluginElementsHistorySnapshot { - plugin_id: plugin_id.to_string(), - instances: None, - } - } - - #[test] - fn preset_history_target_match_ignores_live_counter_drift() { - let mut store = AppStoreData::default(); - let target = PresetFullHistorySnapshot::from_store(&store); - store.key_counters.insert( - "counter-only-mode".to_string(), - HashMap::from([("counter-only-key".to_string(), 1)]), - ); - - assert_ne!(target, PresetFullHistorySnapshot::from_store(&store)); - assert!(target.matches_store(&store)); - } - - #[test] - fn shared_gesture_merges_editor_and_plugins_into_one_compound_entry() { - let mut history = HistoryService::default(); - let gesture_id = uuid::Uuid::new_v4().to_string(); - - let editor = history - .prepare_entry( - vec![EditorField::Keys], - patch("before"), - Some(gesture_id.clone()), - ) - .unwrap(); - history.apply_record_plan(editor); - for plugin_id in ["plugin-a", "plugin-b"] { - let plugin = history - .prepare_plugin_elements_entry(plugin_snapshot(plugin_id), Some(gesture_id.clone())) - .unwrap(); - history.apply_record_plan(plugin); - } - - assert_eq!(history.past.len(), 1); - let entry = history.past.back().unwrap(); - assert_eq!(entry.scope, HistoryScope::Compound); - let HistorySnapshot::Compound { snapshots } = &entry.before else { - panic!("shared gesture must produce a compound snapshot"); - }; - assert_eq!(snapshots.len(), 3); - assert!(matches!(snapshots[0], HistorySnapshot::Editor { .. })); - assert!(matches!( - &snapshots[1], - HistorySnapshot::PluginElements(snapshot) if snapshot.plugin_id == "plugin-a" - )); - assert!(matches!( - &snapshots[2], - HistorySnapshot::PluginElements(snapshot) if snapshot.plugin_id == "plugin-b" - )); - assert_eq!(history.history_revision(), 1); - } - - #[test] - fn delayed_shared_gesture_merges_in_place_without_reordering_later_entry() { - let mut history = HistoryService::default(); - let gesture_id = uuid::Uuid::new_v4().to_string(); - let editor = history - .prepare_entry( - vec![EditorField::Keys], - patch("before"), - Some(gesture_id.clone()), - ) - .unwrap(); - history.apply_record_plan(editor); - - let later = history - .prepare_plugin_elements_entry(plugin_snapshot("later-plugin"), None) - .unwrap(); - history.apply_record_plan(later); - let delayed = history - .prepare_plugin_elements_entry(plugin_snapshot("shared-plugin"), Some(gesture_id)) - .unwrap(); - history.apply_record_plan(delayed); - - assert_eq!(history.past.len(), 2); - assert!(matches!( - history.past.front().map(|entry| &entry.before), - Some(HistorySnapshot::Compound { snapshots }) if snapshots.len() == 2 - )); - assert!(matches!( - history.past.back().map(|entry| &entry.before), - Some(HistorySnapshot::PluginElements(snapshot)) - if snapshot.plugin_id == "later-plugin" - )); - assert_eq!(history.history_revision(), 2); - } - - #[test] - fn repeated_editor_gesture_does_not_merge_across_an_intervening_entry() { - let mut history = HistoryService::default(); - let gesture_id = uuid::Uuid::new_v4().to_string(); - let first = history - .prepare_entry( - vec![EditorField::Keys], - patch("first"), - Some(gesture_id.clone()), - ) - .unwrap(); - history.apply_record_plan(first); - let intervening = history - .prepare_plugin_elements_entry(plugin_snapshot("later-plugin"), None) - .unwrap(); - history.apply_record_plan(intervening); - - let repeated = history - .prepare_entry(vec![EditorField::Keys], patch("repeated"), Some(gesture_id)) - .unwrap(); - assert!(matches!(repeated, HistoryRecordPlan::Entry(_))); - history.apply_record_plan(repeated); - - assert_eq!(history.past.len(), 3); - assert!(matches!( - history.past.back().map(|entry| &entry.before), - Some(HistorySnapshot::Editor { before, .. }) - if before.keys.as_ref().unwrap()["mode"] == [KeySlot::from("repeated")] - )); - } - - #[test] - fn merged_editor_gesture_aliases_absorb_only_the_top_plugin_entry() { - let mut history = HistoryService::default(); - let first_gesture = uuid::Uuid::new_v4().to_string(); - let second_gesture = uuid::Uuid::new_v4().to_string(); - for (plugin_id, gesture_id) in [ - ("plugin-a", first_gesture.clone()), - ("plugin-b", second_gesture.clone()), - ] { - let plugin = history - .prepare_plugin_elements_entry(plugin_snapshot(plugin_id), Some(gesture_id)) - .unwrap(); - history.apply_record_plan(plugin); - } - - let editor = history - .prepare_entry_with_gesture_ids( - vec![EditorField::Keys], - patch("before"), - None, - vec![first_gesture.clone(), second_gesture.clone()], - ) - .unwrap(); - history.apply_record_plan(editor); - - assert_eq!(history.past.len(), 2); - let first_entry = history.past.front().unwrap(); - assert!(matches!( - &first_entry.before, - HistorySnapshot::PluginElements(snapshot) if snapshot.plugin_id == "plugin-a" - )); - assert_eq!(first_entry.gesture_ids, vec![first_gesture.clone()]); - - let top = history.past.back().unwrap(); - assert_eq!(top.gesture_ids, vec![first_gesture, second_gesture]); - let HistorySnapshot::Compound { snapshots } = &top.before else { - panic!("top history entry must be compound"); - }; - assert!(matches!( - snapshots.as_slice(), - [ - HistorySnapshot::PluginElements(plugin), - HistorySnapshot::Editor { before, .. } - ] if plugin.plugin_id == "plugin-b" - && before.keys.as_ref().unwrap()["mode"] == [KeySlot::from("before")] - )); - } - - #[test] - fn multi_alias_editor_stays_above_a_later_editor_entry() { - let mut history = HistoryService::default(); - let first_gesture = uuid::Uuid::new_v4().to_string(); - let second_gesture = uuid::Uuid::new_v4().to_string(); - for (plugin_id, gesture_id) in [ - ("plugin-a", first_gesture.clone()), - ("plugin-b", second_gesture.clone()), - ] { - let plugin = history - .prepare_plugin_elements_entry(plugin_snapshot(plugin_id), Some(gesture_id)) - .unwrap(); - history.apply_record_plan(plugin); - } - - let intervening_gesture = uuid::Uuid::new_v4().to_string(); - let intervening_editor = history - .prepare_entry( - vec![EditorField::Keys], - patch("before-intervening-editor"), - Some(intervening_gesture.clone()), - ) - .unwrap(); - history.apply_record_plan(intervening_editor); - - let latest_editor = history - .prepare_entry_with_gesture_ids( - vec![EditorField::Keys], - patch("after-intervening-editor"), - None, - vec![first_gesture.clone(), second_gesture.clone()], - ) - .unwrap(); - assert!(matches!(latest_editor, HistoryRecordPlan::Entry(_))); - history.apply_record_plan(latest_editor); - - assert_eq!(history.past.len(), 4); - let mut undo_order = history.past.iter().rev(); - let latest = undo_order.next().unwrap(); - assert_eq!(latest.gesture_ids, vec![first_gesture, second_gesture]); - assert!(matches!( - &latest.before, - HistorySnapshot::Editor { before, .. } - if before.keys.as_ref().unwrap()["mode"] == [KeySlot::from("after-intervening-editor")] - )); - - let intervening = undo_order.next().unwrap(); - assert_eq!(intervening.gesture_ids, vec![intervening_gesture]); - assert!(matches!( - &intervening.before, - HistorySnapshot::Editor { before, .. } - if before.keys.as_ref().unwrap()["mode"] == [KeySlot::from("before-intervening-editor")] - )); - } - - #[test] - fn delayed_net_zero_merge_removes_only_its_original_entry() { - let mut history = HistoryService::default(); - let gesture_id = uuid::Uuid::new_v4().to_string(); - let original = plugin_snapshot("shared-plugin"); - let first = history - .prepare_plugin_elements_entry(original.clone(), Some(gesture_id.clone())) - .unwrap(); - history.apply_record_plan(first); - let later = history - .prepare_plugin_elements_entry(plugin_snapshot("later-plugin"), None) - .unwrap(); - history.apply_record_plan(later); - - let back_to_original = history - .prepare_plugin_elements_entry(original.clone(), Some(gesture_id)) - .unwrap(); - history.apply_plugin_elements_record_plan(back_to_original, &original); - - assert_eq!(history.past.len(), 1); - assert!(matches!( - history.past.back().map(|entry| &entry.before), - Some(HistorySnapshot::PluginElements(snapshot)) - if snapshot.plugin_id == "later-plugin" - )); - } - - #[test] - fn gesture_entry_merges_with_a_prior_same_gesture_plugin_entry() { - let mut history = HistoryService::default(); - let gesture_id = uuid::Uuid::new_v4().to_string(); - let plugin = history - .prepare_plugin_elements_entry(plugin_snapshot("plugin-a"), Some(gesture_id.clone())) - .unwrap(); - history.apply_record_plan(plugin); - - let gesture = history - .prepare_gesture_entry( - vec![HistorySnapshot::Editor { - changed_fields: vec![EditorField::Keys], - before: Box::new(patch("before")), - key_counters: None, - }], - gesture_id.clone(), - ) - .unwrap(); - assert!(matches!(gesture, HistoryRecordPlan::Merge { .. })); - history.apply_record_plan(gesture); - - assert_eq!(history.past.len(), 1); - let entry = history.past.back().unwrap(); - assert_eq!(entry.scope, HistoryScope::Compound); - assert_eq!(entry.gesture_ids, vec![gesture_id]); - let HistorySnapshot::Compound { snapshots } = &entry.before else { - panic!("same gesture must merge into one compound entry"); - }; - assert!(matches!( - snapshots.as_slice(), - [ - HistorySnapshot::PluginElements(plugin), - HistorySnapshot::Editor { .. } - ] if plugin.plugin_id == "plugin-a" - )); - } - - #[test] - fn repeated_gesture_commits_merge_and_keep_the_first_editor_before() { - let mut history = HistoryService::default(); - let gesture_id = uuid::Uuid::new_v4().to_string(); - for (text, count) in [("first", 12), ("second", 3)] { - let plan = history - .prepare_gesture_entry( - vec![ - HistorySnapshot::Editor { - changed_fields: vec![EditorField::Keys], - before: Box::new(patch(text)), - key_counters: Some(HashMap::from([( - "mode".to_string(), - HashMap::from([(text.to_string(), count)]), - )])), - }, - HistorySnapshot::PluginElements(plugin_snapshot("plugin-a")), - ], - gesture_id.clone(), - ) - .unwrap(); - history.apply_record_plan(plan); - } - - assert_eq!(history.past.len(), 1); - let HistorySnapshot::Compound { snapshots } = &history.past.back().unwrap().before else { - panic!("repeated gesture commits must stay one compound entry"); - }; - assert!(matches!( - snapshots.as_slice(), - [ - HistorySnapshot::Editor { - before, - key_counters: Some(key_counters), - .. - }, - HistorySnapshot::PluginElements(plugin) - ] if before.keys.as_ref().unwrap()["mode"] == [KeySlot::from("first")] - && key_counters["mode"]["first"] == 12 - && plugin.plugin_id == "plugin-a" - )); - } - - #[test] - fn gesture_entry_does_not_merge_across_different_gestures() { - let mut history = HistoryService::default(); - let plugin = history - .prepare_plugin_elements_entry( - plugin_snapshot("plugin-a"), - Some(uuid::Uuid::new_v4().to_string()), - ) - .unwrap(); - history.apply_record_plan(plugin); - - let gesture = history - .prepare_gesture_entry( - vec![HistorySnapshot::Editor { - changed_fields: vec![EditorField::Keys], - before: Box::new(patch("before")), - key_counters: None, - }], - uuid::Uuid::new_v4().to_string(), - ) - .unwrap(); - assert!(matches!(gesture, HistoryRecordPlan::Entry(_))); - history.apply_record_plan(gesture); - - assert_eq!(history.past.len(), 2); - } - - #[test] - fn oversized_gesture_merge_falls_back_to_a_separate_entry() { - let gesture_id = uuid::Uuid::new_v4().to_string(); - let editor_snapshots = vec![HistorySnapshot::Editor { - changed_fields: vec![EditorField::Keys], - before: Box::new(patch("before")), - key_counters: None, - }]; - let mut probe = HistoryService::default(); - let plugin = probe - .prepare_plugin_elements_entry(plugin_snapshot("plugin-a"), Some(gesture_id.clone())) - .unwrap(); - probe.apply_record_plan(plugin); - let merged = probe - .prepare_gesture_entry(editor_snapshots.clone(), gesture_id.clone()) - .unwrap(); - let HistoryRecordPlan::Merge { entry: merged, .. } = merged else { - panic!("same gesture must prepare a compound merge"); - }; - let limit = merged.size_bytes - 1; - - let mut history = HistoryService::with_limits(limit, 32 * 1024 * 1024, 50); - let plugin = history - .prepare_plugin_elements_entry(plugin_snapshot("plugin-a"), Some(gesture_id.clone())) - .unwrap(); - history.apply_record_plan(plugin); - let fallback = history - .prepare_gesture_entry(editor_snapshots, gesture_id) - .unwrap(); - assert!(matches!(fallback, HistoryRecordPlan::Entry(_))); - history.apply_record_plan(fallback); - - assert_eq!(history.past.len(), 2); - } - - #[test] - fn compound_entry_counts_as_one_budget_slot() { - let mut history = HistoryService::with_limits(8 * 1024 * 1024, 32 * 1024 * 1024, 1); - let gesture_id = uuid::Uuid::new_v4().to_string(); - let editor = history - .prepare_entry( - vec![EditorField::Keys], - patch("before"), - Some(gesture_id.clone()), - ) - .unwrap(); - history.apply_record_plan(editor); - let plugin = history - .prepare_plugin_elements_entry(plugin_snapshot("plugin-a"), Some(gesture_id)) - .unwrap(); - history.apply_record_plan(plugin); - - assert_eq!(history.past.len(), 1); - assert!(history.status(false).can_undo); - } - - #[test] - fn compound_merge_honors_combined_entry_size_limit() { - let gesture_id = uuid::Uuid::new_v4().to_string(); - let mut probe = HistoryService::default(); - let editor = probe - .prepare_entry( - vec![EditorField::Keys], - patch("before"), - Some(gesture_id.clone()), - ) - .unwrap(); - probe.apply_record_plan(editor); - let compound = probe - .prepare_plugin_elements_entry(plugin_snapshot("plugin-a"), Some(gesture_id.clone())) - .unwrap(); - let HistoryRecordPlan::Merge { - entry: compound, .. - } = compound - else { - panic!("shared gesture must prepare a compound merge"); - }; - let limit = compound.size_bytes - 1; - - let mut history = HistoryService::with_limits(limit, 32 * 1024 * 1024, 50); - let editor = history - .prepare_entry( - vec![EditorField::Keys], - patch("before"), - Some(gesture_id.clone()), - ) - .unwrap(); - assert!(matches!(editor, HistoryRecordPlan::Entry(_))); - history.apply_record_plan(editor); - let oversized = history - .prepare_plugin_elements_entry(plugin_snapshot("plugin-a"), Some(gesture_id)) - .unwrap(); - assert!(matches!(oversized, HistoryRecordPlan::Truncate)); - } - - #[test] - fn oversized_entry_truncates_both_stacks_atomically() { - let mut history = HistoryService::with_limits(200, 1_000, 50); - let first = history - .prepare_entry(vec![EditorField::Keys], patch("first"), None) - .unwrap(); - history.apply_record_plan(first); - assert!(history.status(false).can_undo); - - let oversized = history - .prepare_entry(vec![EditorField::Keys], patch(&"x".repeat(512)), None) - .unwrap(); - assert!(matches!(oversized, HistoryRecordPlan::Truncate)); - history.apply_record_plan(oversized); - - let status = history.status(false); - assert!(!status.can_undo); - assert!(!status.can_redo); - assert_eq!(status.truncated.unwrap().reason, HISTORY_ENTRY_TOO_LARGE); - assert_eq!(status.history_revision, 2); - } - - #[test] - fn total_budget_and_count_evict_least_recent_entries() { - let sample = HistoryService::with_limits(1_000, 10_000, 50) - .prepare_entry(vec![EditorField::Keys], patch("sample"), None) - .unwrap(); - let HistoryRecordPlan::Entry(sample) = sample else { - panic!("sample entry must fit"); - }; - let mut history = HistoryService::with_limits(1_000, sample.size_bytes * 2 + 1, 2); - - for value in ["one", "two", "three"] { - let plan = history - .prepare_entry(vec![EditorField::Keys], patch(value), None) - .unwrap(); - history.apply_record_plan(plan); - } - - assert_eq!(history.past.len(), 2); - assert_eq!( - history.past.front().unwrap().before, - HistorySnapshot::Editor { - changed_fields: vec![EditorField::Keys], - before: Box::new(patch("two")), - key_counters: None, - } - ); - assert_eq!( - history.past.back().unwrap().before, - HistorySnapshot::Editor { - changed_fields: vec![EditorField::Keys], - before: Box::new(patch("three")), - key_counters: None, - } - ); - } - - #[test] - fn status_sequence_orders_reversed_busy_delivery_at_same_revision() { - let mut history = HistoryService::default(); - let busy = history.issue_status(true); - let idle = history.issue_status(false); - - assert_eq!(busy.history_revision, idle.history_revision); - assert!(busy.busy); - assert!(!idle.busy); - assert!(idle.status_seq > busy.status_seq); - - let mut accepted = idle.clone(); - if busy.status_seq > accepted.status_seq { - accepted = busy; - } - assert!(!accepted.busy); - assert_eq!(accepted.status_seq, idle.status_seq); - } - - #[test] - fn admission_gate_rejects_busy_and_stale_generations() { - let gate = Arc::new(HistoryAdmissionGate::default()); - let stale = gate.try_admit().unwrap(); - let lease = gate.close("operation-a").unwrap(); - assert_eq!(gate.owner().as_deref(), Some("operation-a")); - assert_eq!(gate.try_admit().unwrap_err(), HISTORY_IN_PROGRESS); - drop(lease); - - assert_eq!(gate.revalidate(stale).unwrap_err(), HISTORY_IN_PROGRESS); - assert!(gate.try_admit().is_ok()); - } - - #[test] - fn canceled_barrier_wakes_a_drain_waiter() { - let gate = Arc::new(HistoryAdmissionGate::default()); - let admission = gate.admit_mutation().unwrap(); - let barrier = gate.begin_close("operation-canceled").unwrap(); - let waiter = barrier.waiter(); - let (result_tx, result_rx) = mpsc::channel(); - let wait_thread = thread::spawn(move || { - result_tx.send(waiter.wait_for_drain()).unwrap(); - }); - - drop(barrier); - assert_eq!( - result_rx.recv_timeout(Duration::from_secs(2)).unwrap(), - Err(HISTORY_IN_PROGRESS.to_string()) - ); - drop(admission); - wait_thread.join().unwrap(); - } - - #[test] - fn barrier_drains_admitted_runtime_publication_before_restore_mapping() { - let gate = Arc::new(HistoryAdmissionGate::default()); - let keyboard = KeyboardManager::new( - HashMap::from([("mode".to_string(), vec!["initial".into()])]), - "mode", - ); - let admission = gate.admit_mutation().unwrap(); - admission.revalidate_for(&gate).unwrap(); - let stale_keyboard = keyboard.clone(); - let (store_committed_tx, store_committed_rx) = mpsc::channel(); - let (publish_tx, publish_rx) = mpsc::channel(); - let mutation = thread::spawn(move || { - store_committed_tx.send(()).unwrap(); - publish_rx.recv().unwrap(); - stale_keyboard.update_mappings_and_set_mode( - HashMap::from([("mode".to_string(), vec!["stale".into()])]), - "mode", - ); - drop(admission); - }); - store_committed_rx - .recv_timeout(Duration::from_secs(2)) - .unwrap(); - - let barrier_gate = Arc::clone(&gate); - let restored_keyboard = keyboard.clone(); - let (restored_tx, restored_rx) = mpsc::channel(); - let barrier = thread::spawn(move || { - let lease = barrier_gate.close("history-operation").unwrap(); - restored_keyboard.update_mappings_and_set_mode( - HashMap::from([("mode".to_string(), vec!["restored".into()])]), - "mode", - ); - restored_tx.send(()).unwrap(); - drop(lease); - }); - - let deadline = Instant::now() + Duration::from_secs(2); - while !gate.is_closed() { - assert!(Instant::now() < deadline, "history gate did not close"); - thread::yield_now(); - } - assert!(matches!( - restored_rx.try_recv(), - Err(mpsc::TryRecvError::Empty) - )); - - publish_tx.send(()).unwrap(); - mutation.join().unwrap(); - restored_rx.recv_timeout(Duration::from_secs(2)).unwrap(); - barrier.join().unwrap(); - - assert!(keyboard.register_key_down("mode", "restored")); - assert!(!keyboard.register_key_down("mode", "stale")); - } -} +mod tests; diff --git a/src-tauri/src/state/history/tests.rs b/src-tauri/src/state/history/tests.rs new file mode 100644 index 000000000..1aec21017 --- /dev/null +++ b/src-tauri/src/state/history/tests.rs @@ -0,0 +1,647 @@ +use super::*; +use crate::{ + keyboard::KeyboardManager, + models::{KeySlot, EDITOR_SCHEMA_VERSION}, +}; +use std::{ + collections::HashMap, + sync::{mpsc, Arc}, + thread, + time::{Duration, Instant}, +}; + +fn patch(text: &str) -> EditorPatchV1 { + EditorPatchV1 { + schema_version: EDITOR_SCHEMA_VERSION, + keys: Some(std::collections::HashMap::from([( + "mode".to_string(), + vec![text.into()], + )])), + ..EditorPatchV1::default() + } +} + +fn plugin_snapshot(plugin_id: &str) -> PluginElementsHistorySnapshot { + PluginElementsHistorySnapshot { + plugin_id: plugin_id.to_string(), + instances: None, + } +} + +#[test] +fn preset_history_target_match_ignores_live_counter_drift() { + let mut store = AppStoreData::default(); + let target = PresetFullHistorySnapshot::from_store(&store); + store.key_counters.insert( + "counter-only-mode".to_string(), + HashMap::from([("counter-only-key".to_string(), 1)]), + ); + + assert_ne!(target, PresetFullHistorySnapshot::from_store(&store)); + assert!(target.matches_store(&store)); +} + +#[test] +fn shared_gesture_merges_editor_and_plugins_into_one_compound_entry() { + let mut history = HistoryService::default(); + let gesture_id = uuid::Uuid::new_v4().to_string(); + + let editor = history + .prepare_entry( + vec![EditorField::Keys], + patch("before"), + Some(gesture_id.clone()), + ) + .unwrap(); + history.apply_record_plan(editor); + for plugin_id in ["plugin-a", "plugin-b"] { + let plugin = history + .prepare_plugin_elements_entry(plugin_snapshot(plugin_id), Some(gesture_id.clone())) + .unwrap(); + history.apply_record_plan(plugin); + } + + assert_eq!(history.past.len(), 1); + let entry = history.past.back().unwrap(); + assert_eq!(entry.scope, HistoryScope::Compound); + let HistorySnapshot::Compound { snapshots } = &entry.before else { + panic!("shared gesture must produce a compound snapshot"); + }; + assert_eq!(snapshots.len(), 3); + assert!(matches!(snapshots[0], HistorySnapshot::Editor { .. })); + assert!(matches!( + &snapshots[1], + HistorySnapshot::PluginElements(snapshot) if snapshot.plugin_id == "plugin-a" + )); + assert!(matches!( + &snapshots[2], + HistorySnapshot::PluginElements(snapshot) if snapshot.plugin_id == "plugin-b" + )); + assert_eq!(history.history_revision(), 1); +} + +#[test] +fn delayed_shared_gesture_merges_in_place_without_reordering_later_entry() { + let mut history = HistoryService::default(); + let gesture_id = uuid::Uuid::new_v4().to_string(); + let editor = history + .prepare_entry( + vec![EditorField::Keys], + patch("before"), + Some(gesture_id.clone()), + ) + .unwrap(); + history.apply_record_plan(editor); + + let later = history + .prepare_plugin_elements_entry(plugin_snapshot("later-plugin"), None) + .unwrap(); + history.apply_record_plan(later); + let delayed = history + .prepare_plugin_elements_entry(plugin_snapshot("shared-plugin"), Some(gesture_id)) + .unwrap(); + history.apply_record_plan(delayed); + + assert_eq!(history.past.len(), 2); + assert!(matches!( + history.past.front().map(|entry| &entry.before), + Some(HistorySnapshot::Compound { snapshots }) if snapshots.len() == 2 + )); + assert!(matches!( + history.past.back().map(|entry| &entry.before), + Some(HistorySnapshot::PluginElements(snapshot)) + if snapshot.plugin_id == "later-plugin" + )); + assert_eq!(history.history_revision(), 2); +} + +#[test] +fn repeated_editor_gesture_does_not_merge_across_an_intervening_entry() { + let mut history = HistoryService::default(); + let gesture_id = uuid::Uuid::new_v4().to_string(); + let first = history + .prepare_entry( + vec![EditorField::Keys], + patch("first"), + Some(gesture_id.clone()), + ) + .unwrap(); + history.apply_record_plan(first); + let intervening = history + .prepare_plugin_elements_entry(plugin_snapshot("later-plugin"), None) + .unwrap(); + history.apply_record_plan(intervening); + + let repeated = history + .prepare_entry(vec![EditorField::Keys], patch("repeated"), Some(gesture_id)) + .unwrap(); + assert!(matches!(repeated, HistoryRecordPlan::Entry(_))); + history.apply_record_plan(repeated); + + assert_eq!(history.past.len(), 3); + assert!(matches!( + history.past.back().map(|entry| &entry.before), + Some(HistorySnapshot::Editor { before, .. }) + if before.keys.as_ref().unwrap()["mode"] == [KeySlot::from("repeated")] + )); +} + +#[test] +fn merged_editor_gesture_aliases_absorb_only_the_top_plugin_entry() { + let mut history = HistoryService::default(); + let first_gesture = uuid::Uuid::new_v4().to_string(); + let second_gesture = uuid::Uuid::new_v4().to_string(); + for (plugin_id, gesture_id) in [ + ("plugin-a", first_gesture.clone()), + ("plugin-b", second_gesture.clone()), + ] { + let plugin = history + .prepare_plugin_elements_entry(plugin_snapshot(plugin_id), Some(gesture_id)) + .unwrap(); + history.apply_record_plan(plugin); + } + + let editor = history + .prepare_entry_with_gesture_ids( + vec![EditorField::Keys], + patch("before"), + None, + vec![first_gesture.clone(), second_gesture.clone()], + ) + .unwrap(); + history.apply_record_plan(editor); + + assert_eq!(history.past.len(), 2); + let first_entry = history.past.front().unwrap(); + assert!(matches!( + &first_entry.before, + HistorySnapshot::PluginElements(snapshot) if snapshot.plugin_id == "plugin-a" + )); + assert_eq!(first_entry.gesture_ids, vec![first_gesture.clone()]); + + let top = history.past.back().unwrap(); + assert_eq!(top.gesture_ids, vec![first_gesture, second_gesture]); + let HistorySnapshot::Compound { snapshots } = &top.before else { + panic!("top history entry must be compound"); + }; + assert!(matches!( + snapshots.as_slice(), + [ + HistorySnapshot::PluginElements(plugin), + HistorySnapshot::Editor { before, .. } + ] if plugin.plugin_id == "plugin-b" + && before.keys.as_ref().unwrap()["mode"] == [KeySlot::from("before")] + )); +} + +#[test] +fn multi_alias_editor_stays_above_a_later_editor_entry() { + let mut history = HistoryService::default(); + let first_gesture = uuid::Uuid::new_v4().to_string(); + let second_gesture = uuid::Uuid::new_v4().to_string(); + for (plugin_id, gesture_id) in [ + ("plugin-a", first_gesture.clone()), + ("plugin-b", second_gesture.clone()), + ] { + let plugin = history + .prepare_plugin_elements_entry(plugin_snapshot(plugin_id), Some(gesture_id)) + .unwrap(); + history.apply_record_plan(plugin); + } + + let intervening_gesture = uuid::Uuid::new_v4().to_string(); + let intervening_editor = history + .prepare_entry( + vec![EditorField::Keys], + patch("before-intervening-editor"), + Some(intervening_gesture.clone()), + ) + .unwrap(); + history.apply_record_plan(intervening_editor); + + let latest_editor = history + .prepare_entry_with_gesture_ids( + vec![EditorField::Keys], + patch("after-intervening-editor"), + None, + vec![first_gesture.clone(), second_gesture.clone()], + ) + .unwrap(); + assert!(matches!(latest_editor, HistoryRecordPlan::Entry(_))); + history.apply_record_plan(latest_editor); + + assert_eq!(history.past.len(), 4); + let mut undo_order = history.past.iter().rev(); + let latest = undo_order.next().unwrap(); + assert_eq!(latest.gesture_ids, vec![first_gesture, second_gesture]); + assert!(matches!( + &latest.before, + HistorySnapshot::Editor { before, .. } + if before.keys.as_ref().unwrap()["mode"] == [KeySlot::from("after-intervening-editor")] + )); + + let intervening = undo_order.next().unwrap(); + assert_eq!(intervening.gesture_ids, vec![intervening_gesture]); + assert!(matches!( + &intervening.before, + HistorySnapshot::Editor { before, .. } + if before.keys.as_ref().unwrap()["mode"] == [KeySlot::from("before-intervening-editor")] + )); +} + +#[test] +fn delayed_net_zero_merge_removes_only_its_original_entry() { + let mut history = HistoryService::default(); + let gesture_id = uuid::Uuid::new_v4().to_string(); + let original = plugin_snapshot("shared-plugin"); + let first = history + .prepare_plugin_elements_entry(original.clone(), Some(gesture_id.clone())) + .unwrap(); + history.apply_record_plan(first); + let later = history + .prepare_plugin_elements_entry(plugin_snapshot("later-plugin"), None) + .unwrap(); + history.apply_record_plan(later); + + let back_to_original = history + .prepare_plugin_elements_entry(original.clone(), Some(gesture_id)) + .unwrap(); + history.apply_plugin_elements_record_plan(back_to_original, &original); + + assert_eq!(history.past.len(), 1); + assert!(matches!( + history.past.back().map(|entry| &entry.before), + Some(HistorySnapshot::PluginElements(snapshot)) + if snapshot.plugin_id == "later-plugin" + )); +} + +#[test] +fn gesture_entry_merges_with_a_prior_same_gesture_plugin_entry() { + let mut history = HistoryService::default(); + let gesture_id = uuid::Uuid::new_v4().to_string(); + let plugin = history + .prepare_plugin_elements_entry(plugin_snapshot("plugin-a"), Some(gesture_id.clone())) + .unwrap(); + history.apply_record_plan(plugin); + + let gesture = history + .prepare_gesture_entry( + vec![HistorySnapshot::Editor { + changed_fields: vec![EditorField::Keys], + before: Box::new(patch("before")), + key_counters: None, + }], + gesture_id.clone(), + ) + .unwrap(); + assert!(matches!(gesture, HistoryRecordPlan::Merge { .. })); + history.apply_record_plan(gesture); + + assert_eq!(history.past.len(), 1); + let entry = history.past.back().unwrap(); + assert_eq!(entry.scope, HistoryScope::Compound); + assert_eq!(entry.gesture_ids, vec![gesture_id]); + let HistorySnapshot::Compound { snapshots } = &entry.before else { + panic!("same gesture must merge into one compound entry"); + }; + assert!(matches!( + snapshots.as_slice(), + [ + HistorySnapshot::PluginElements(plugin), + HistorySnapshot::Editor { .. } + ] if plugin.plugin_id == "plugin-a" + )); +} + +#[test] +fn repeated_gesture_commits_merge_and_keep_the_first_editor_before() { + let mut history = HistoryService::default(); + let gesture_id = uuid::Uuid::new_v4().to_string(); + for (text, count) in [("first", 12), ("second", 3)] { + let plan = history + .prepare_gesture_entry( + vec![ + HistorySnapshot::Editor { + changed_fields: vec![EditorField::Keys], + before: Box::new(patch(text)), + key_counters: Some(HashMap::from([( + "mode".to_string(), + HashMap::from([(text.to_string(), count)]), + )])), + }, + HistorySnapshot::PluginElements(plugin_snapshot("plugin-a")), + ], + gesture_id.clone(), + ) + .unwrap(); + history.apply_record_plan(plan); + } + + assert_eq!(history.past.len(), 1); + let HistorySnapshot::Compound { snapshots } = &history.past.back().unwrap().before else { + panic!("repeated gesture commits must stay one compound entry"); + }; + assert!(matches!( + snapshots.as_slice(), + [ + HistorySnapshot::Editor { + before, + key_counters: Some(key_counters), + .. + }, + HistorySnapshot::PluginElements(plugin) + ] if before.keys.as_ref().unwrap()["mode"] == [KeySlot::from("first")] + && key_counters["mode"]["first"] == 12 + && plugin.plugin_id == "plugin-a" + )); +} + +#[test] +fn gesture_entry_does_not_merge_across_different_gestures() { + let mut history = HistoryService::default(); + let plugin = history + .prepare_plugin_elements_entry( + plugin_snapshot("plugin-a"), + Some(uuid::Uuid::new_v4().to_string()), + ) + .unwrap(); + history.apply_record_plan(plugin); + + let gesture = history + .prepare_gesture_entry( + vec![HistorySnapshot::Editor { + changed_fields: vec![EditorField::Keys], + before: Box::new(patch("before")), + key_counters: None, + }], + uuid::Uuid::new_v4().to_string(), + ) + .unwrap(); + assert!(matches!(gesture, HistoryRecordPlan::Entry(_))); + history.apply_record_plan(gesture); + + assert_eq!(history.past.len(), 2); +} + +#[test] +fn oversized_gesture_merge_falls_back_to_a_separate_entry() { + let gesture_id = uuid::Uuid::new_v4().to_string(); + let editor_snapshots = vec![HistorySnapshot::Editor { + changed_fields: vec![EditorField::Keys], + before: Box::new(patch("before")), + key_counters: None, + }]; + let mut probe = HistoryService::default(); + let plugin = probe + .prepare_plugin_elements_entry(plugin_snapshot("plugin-a"), Some(gesture_id.clone())) + .unwrap(); + probe.apply_record_plan(plugin); + let merged = probe + .prepare_gesture_entry(editor_snapshots.clone(), gesture_id.clone()) + .unwrap(); + let HistoryRecordPlan::Merge { entry: merged, .. } = merged else { + panic!("same gesture must prepare a compound merge"); + }; + let limit = merged.size_bytes - 1; + + let mut history = HistoryService::with_limits(limit, 32 * 1024 * 1024, 50); + let plugin = history + .prepare_plugin_elements_entry(plugin_snapshot("plugin-a"), Some(gesture_id.clone())) + .unwrap(); + history.apply_record_plan(plugin); + let fallback = history + .prepare_gesture_entry(editor_snapshots, gesture_id) + .unwrap(); + assert!(matches!(fallback, HistoryRecordPlan::Entry(_))); + history.apply_record_plan(fallback); + + assert_eq!(history.past.len(), 2); +} + +#[test] +fn compound_entry_counts_as_one_budget_slot() { + let mut history = HistoryService::with_limits(8 * 1024 * 1024, 32 * 1024 * 1024, 1); + let gesture_id = uuid::Uuid::new_v4().to_string(); + let editor = history + .prepare_entry( + vec![EditorField::Keys], + patch("before"), + Some(gesture_id.clone()), + ) + .unwrap(); + history.apply_record_plan(editor); + let plugin = history + .prepare_plugin_elements_entry(plugin_snapshot("plugin-a"), Some(gesture_id)) + .unwrap(); + history.apply_record_plan(plugin); + + assert_eq!(history.past.len(), 1); + assert!(history.status(false).can_undo); +} + +#[test] +fn compound_merge_honors_combined_entry_size_limit() { + let gesture_id = uuid::Uuid::new_v4().to_string(); + let mut probe = HistoryService::default(); + let editor = probe + .prepare_entry( + vec![EditorField::Keys], + patch("before"), + Some(gesture_id.clone()), + ) + .unwrap(); + probe.apply_record_plan(editor); + let compound = probe + .prepare_plugin_elements_entry(plugin_snapshot("plugin-a"), Some(gesture_id.clone())) + .unwrap(); + let HistoryRecordPlan::Merge { + entry: compound, .. + } = compound + else { + panic!("shared gesture must prepare a compound merge"); + }; + let limit = compound.size_bytes - 1; + + let mut history = HistoryService::with_limits(limit, 32 * 1024 * 1024, 50); + let editor = history + .prepare_entry( + vec![EditorField::Keys], + patch("before"), + Some(gesture_id.clone()), + ) + .unwrap(); + assert!(matches!(editor, HistoryRecordPlan::Entry(_))); + history.apply_record_plan(editor); + let oversized = history + .prepare_plugin_elements_entry(plugin_snapshot("plugin-a"), Some(gesture_id)) + .unwrap(); + assert!(matches!(oversized, HistoryRecordPlan::Truncate)); +} + +#[test] +fn oversized_entry_truncates_both_stacks_atomically() { + let mut history = HistoryService::with_limits(200, 1_000, 50); + let first = history + .prepare_entry(vec![EditorField::Keys], patch("first"), None) + .unwrap(); + history.apply_record_plan(first); + assert!(history.status(false).can_undo); + + let oversized = history + .prepare_entry(vec![EditorField::Keys], patch(&"x".repeat(512)), None) + .unwrap(); + assert!(matches!(oversized, HistoryRecordPlan::Truncate)); + history.apply_record_plan(oversized); + + let status = history.status(false); + assert!(!status.can_undo); + assert!(!status.can_redo); + assert_eq!(status.truncated.unwrap().reason, HISTORY_ENTRY_TOO_LARGE); + assert_eq!(status.history_revision, 2); +} + +#[test] +fn total_budget_and_count_evict_least_recent_entries() { + let sample = HistoryService::with_limits(1_000, 10_000, 50) + .prepare_entry(vec![EditorField::Keys], patch("sample"), None) + .unwrap(); + let HistoryRecordPlan::Entry(sample) = sample else { + panic!("sample entry must fit"); + }; + let mut history = HistoryService::with_limits(1_000, sample.size_bytes * 2 + 1, 2); + + for value in ["one", "two", "three"] { + let plan = history + .prepare_entry(vec![EditorField::Keys], patch(value), None) + .unwrap(); + history.apply_record_plan(plan); + } + + assert_eq!(history.past.len(), 2); + assert_eq!( + history.past.front().unwrap().before, + HistorySnapshot::Editor { + changed_fields: vec![EditorField::Keys], + before: Box::new(patch("two")), + key_counters: None, + } + ); + assert_eq!( + history.past.back().unwrap().before, + HistorySnapshot::Editor { + changed_fields: vec![EditorField::Keys], + before: Box::new(patch("three")), + key_counters: None, + } + ); +} + +#[test] +fn status_sequence_orders_reversed_busy_delivery_at_same_revision() { + let mut history = HistoryService::default(); + let busy = history.issue_status(true); + let idle = history.issue_status(false); + + assert_eq!(busy.history_revision, idle.history_revision); + assert!(busy.busy); + assert!(!idle.busy); + assert!(idle.status_seq > busy.status_seq); + + let mut accepted = idle.clone(); + if busy.status_seq > accepted.status_seq { + accepted = busy; + } + assert!(!accepted.busy); + assert_eq!(accepted.status_seq, idle.status_seq); +} + +#[test] +fn admission_gate_rejects_busy_and_stale_generations() { + let gate = Arc::new(HistoryAdmissionGate::default()); + let stale = gate.try_admit().unwrap(); + let lease = gate.close("operation-a").unwrap(); + assert_eq!(gate.owner().as_deref(), Some("operation-a")); + assert_eq!(gate.try_admit().unwrap_err(), HISTORY_IN_PROGRESS); + drop(lease); + + assert_eq!(gate.revalidate(stale).unwrap_err(), HISTORY_IN_PROGRESS); + assert!(gate.try_admit().is_ok()); +} + +#[test] +fn canceled_barrier_wakes_a_drain_waiter() { + let gate = Arc::new(HistoryAdmissionGate::default()); + let admission = gate.admit_mutation().unwrap(); + let barrier = gate.begin_close("operation-canceled").unwrap(); + let waiter = barrier.waiter(); + let (result_tx, result_rx) = mpsc::channel(); + let wait_thread = thread::spawn(move || { + result_tx.send(waiter.wait_for_drain()).unwrap(); + }); + + drop(barrier); + assert_eq!( + result_rx.recv_timeout(Duration::from_secs(2)).unwrap(), + Err(HISTORY_IN_PROGRESS.to_string()) + ); + drop(admission); + wait_thread.join().unwrap(); +} + +#[test] +fn barrier_drains_admitted_runtime_publication_before_restore_mapping() { + let gate = Arc::new(HistoryAdmissionGate::default()); + let keyboard = KeyboardManager::new( + HashMap::from([("mode".to_string(), vec!["initial".into()])]), + "mode", + ); + let admission = gate.admit_mutation().unwrap(); + admission.revalidate_for(&gate).unwrap(); + let stale_keyboard = keyboard.clone(); + let (store_committed_tx, store_committed_rx) = mpsc::channel(); + let (publish_tx, publish_rx) = mpsc::channel(); + let mutation = thread::spawn(move || { + store_committed_tx.send(()).unwrap(); + publish_rx.recv().unwrap(); + stale_keyboard.update_mappings_and_set_mode( + HashMap::from([("mode".to_string(), vec!["stale".into()])]), + "mode", + ); + drop(admission); + }); + store_committed_rx + .recv_timeout(Duration::from_secs(2)) + .unwrap(); + + let barrier_gate = Arc::clone(&gate); + let restored_keyboard = keyboard.clone(); + let (restored_tx, restored_rx) = mpsc::channel(); + let barrier = thread::spawn(move || { + let lease = barrier_gate.close("history-operation").unwrap(); + restored_keyboard.update_mappings_and_set_mode( + HashMap::from([("mode".to_string(), vec!["restored".into()])]), + "mode", + ); + restored_tx.send(()).unwrap(); + drop(lease); + }); + + let deadline = Instant::now() + Duration::from_secs(2); + while !gate.is_closed() { + assert!(Instant::now() < deadline, "history gate did not close"); + thread::yield_now(); + } + assert!(matches!( + restored_rx.try_recv(), + Err(mpsc::TryRecvError::Empty) + )); + + publish_tx.send(()).unwrap(); + mutation.join().unwrap(); + restored_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + barrier.join().unwrap(); + + assert!(keyboard.register_key_down("mode", "restored")); + assert!(!keyboard.register_key_down("mode", "stale")); +} From 994f2e41d7aaf809145da73b0d0dae888dd74a6e Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 16:47:25 +0900 Subject: [PATCH 140/245] =?UTF-8?q?refactor:=20OBS=20=EB=B8=8C=EB=A6=AC?= =?UTF-8?q?=EC=A7=80=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/services/obs_bridge.rs | 652 +-------------------- src-tauri/src/services/obs_bridge/tests.rs | 649 ++++++++++++++++++++ 2 files changed, 650 insertions(+), 651 deletions(-) create mode 100644 src-tauri/src/services/obs_bridge/tests.rs diff --git a/src-tauri/src/services/obs_bridge.rs b/src-tauri/src/services/obs_bridge.rs index 5567833d0..40817c7ae 100644 --- a/src-tauri/src/services/obs_bridge.rs +++ b/src-tauri/src/services/obs_bridge.rs @@ -1322,654 +1322,4 @@ fn broadcast_to_envelope(broadcast: &ObsBroadcast, seq: u64) -> Value { } #[cfg(test)] -mod tests { - use super::*; - use tokio_tungstenite::{connect_async, MaybeTlsStream}; - - type TestWebSocket = WebSocketStream>; - - async fn receive_envelope(ws: &mut TestWebSocket, expected_type: &str) -> ObsEnvelope { - tokio::time::timeout(Duration::from_secs(2), async { - loop { - match ws.next().await { - Some(Ok(Message::Text(text))) => { - let envelope = serde_json::from_str::(&text) - .expect("OBS envelope 파싱 실패"); - if envelope.msg_type == expected_type { - return envelope; - } - } - Some(Ok(_)) => {} - Some(Err(error)) => panic!("WS 메시지 수신 실패: {error}"), - None => panic!("WS 연결이 예기치 않게 종료됨"), - } - } - }) - .await - .expect("WS 메시지 수신 타임아웃") - } - - async fn connect_authenticated(port: u16, token: &str) -> TestWebSocket { - let (mut ws, _) = connect_async(format!("ws://127.0.0.1:{port}")) - .await - .expect("WS 연결 실패"); - let hello = make_envelope( - "hello", - 0, - serde_json::json!({ "token": token, "protocol": OBS_PROTOCOL_VERSION }), - ); - ws.send(Message::Text(hello.to_string())) - .await - .expect("hello 전송 실패"); - - receive_envelope(&mut ws, "hello_ack").await; - receive_envelope(&mut ws, "snapshot").await; - ws - } - - async fn assert_no_tauri_event(ws: &mut TestWebSocket) { - let result = tokio::time::timeout(Duration::from_millis(100), async { - loop { - match ws.next().await { - Some(Ok(Message::Text(text))) => { - let envelope = serde_json::from_str::(&text) - .expect("OBS envelope 파싱 실패"); - if envelope.msg_type == "tauri_event" { - return true; - } - } - Some(Ok(_)) => {} - Some(Err(_)) | None => return false, - } - } - }) - .await; - - assert!( - matches!(result, Err(_) | Ok(false)), - "예상하지 않은 tauri_event 중복 수신" - ); - } - - #[test] - fn websocket_allowlist_uses_exact_matching() { - assert_eq!(ALLOWED_WS_COMMANDS.len(), 32); - assert!(is_allowed_command("app_bootstrap")); - assert!(is_allowed_command("editor_get")); - assert!(!is_allowed_command("css_history_get")); - assert!(is_allowed_command("sound_list")); - assert!(is_allowed_command("sound_load_original")); - assert!(!is_allowed_command("plugin_storage_clear")); - assert!(!is_allowed_command("plugin_storage_clear_by_prefix")); - assert!(!is_allowed_command("editor_commit")); - assert!(!is_allowed_command("settings_update")); - assert!(!is_allowed_command("keys_update")); - assert!(!is_allowed_command("keys_update_with_positions")); - assert!(!is_allowed_command("plugin_rpc_send")); - assert!(!is_allowed_command("plugin_rpc_respond")); - assert!(!is_allowed_command("plugin_instances_commit")); - assert!(!is_allowed_command("plugin_instances_reconcile")); - assert!(!is_allowed_command("plugin_authority_reset")); - assert!(!is_allowed_command("app_bootstrap_extra")); - assert!(!is_allowed_command("plugin:window|close")); - assert_eq!(build_allowed_list().len(), ALLOWED_WS_COMMANDS.len()); - } - - #[test] - fn bridge_messages_targeting_main_are_not_forwarded_to_obs() { - let broadcast = serde_json::json!({ "type": "PING", "data": null }); - assert!(is_forwarded_to_obs("plugin-bridge:message", &broadcast)); - let to_overlay = serde_json::json!({ "type": "PING", "target": "overlay" }); - assert!(is_forwarded_to_obs("plugin-bridge:message", &to_overlay)); - let to_main = serde_json::json!({ "type": "PING", "target": "main" }); - assert!(!is_forwarded_to_obs("plugin-bridge:message", &to_main)); - assert!(!is_forwarded_to_obs("app:close-requested", &broadcast)); - } - - #[test] - fn public_overlay_events_are_forwarded_to_obs_clients() { - assert_eq!(FORWARDED_EVENTS.len(), 32); - for event in [ - "customTabs:changed", - "overlay:resized", - "counterAnimation:changed", - "preset:snapshot", - ] { - assert!(FORWARDED_EVENTS.contains(&event), "missing event: {event}"); - } - } - - #[tokio::test] - async fn publish_is_noop_while_server_is_stopped() { - let bridge = ObsBridgeService::new("test"); - let mut receiver = bridge.broadcast_tx.subscribe(); - - bridge.publish("settings:changed", serde_json::json!({ "enabled": true })); - - assert!( - tokio::time::timeout(Duration::from_millis(50), receiver.recv()) - .await - .is_err() - ); - } - - #[tokio::test] - async fn publish_with_no_receivers_keeps_running_server_healthy() { - let bridge = Arc::new(ObsBridgeService::new("test")); - let port = bridge - .start(0, "token".to_string()) - .await - .expect("OBS bridge 시작 실패"); - - assert_ne!(port, 0); - assert_eq!(bridge.broadcast_tx.receiver_count(), 0); - bridge.publish("settings:changed", serde_json::json!({ "enabled": true })); - assert!(bridge.is_running()); - - bridge.stop(); - } - - #[tokio::test] - async fn publish_forwards_supported_events_to_authenticated_client() { - let bridge = Arc::new(ObsBridgeService::new("test")); - let port = bridge - .start(0, "token".to_string()) - .await - .expect("OBS bridge 시작 실패"); - let mut ws = connect_authenticated(port, "token").await; - - for (event_name, data) in [ - ("settings:changed", serde_json::json!({ "theme": "dark" })), - ("overlay:lock", serde_json::json!({ "locked": true })), - ("css:content", serde_json::json!({ "content": "body {}" })), - ] { - bridge.publish(event_name, data.clone()); - let event = receive_envelope(&mut ws, "tauri_event").await; - assert_eq!( - event.payload, - serde_json::json!({ "event": event_name, "data": data }) - ); - } - - bridge.stop(); - } - - #[tokio::test] - async fn publish_ignores_events_outside_forwarded_allowlist() { - let bridge = Arc::new(ObsBridgeService::new("test")); - let port = bridge - .start(0, "token".to_string()) - .await - .expect("OBS bridge 시작 실패"); - let mut ws = connect_authenticated(port, "token").await; - - bridge.publish("history:status", serde_json::json!({ "canUndo": true })); - bridge.publish("settings:changed", serde_json::json!({ "marker": true })); - - let event = receive_envelope(&mut ws, "tauri_event").await; - assert_eq!( - event.payload, - serde_json::json!({ - "event": "settings:changed", - "data": { "marker": true } - }) - ); - assert_no_tauri_event(&mut ws).await; - - bridge.stop(); - } - - #[tokio::test] - async fn publish_reaches_multiple_authenticated_clients_once_each() { - let bridge = Arc::new(ObsBridgeService::new("test")); - let port = bridge - .start(0, "token".to_string()) - .await - .expect("OBS bridge 시작 실패"); - let mut first = connect_authenticated(port, "token").await; - let mut second = connect_authenticated(port, "token").await; - - bridge.publish("overlay:lock", serde_json::json!({ "locked": false })); - - for ws in [&mut first, &mut second] { - let event = receive_envelope(ws, "tauri_event").await; - assert_eq!( - event.payload, - serde_json::json!({ - "event": "overlay:lock", - "data": { "locked": false } - }) - ); - assert_no_tauri_event(ws).await; - } - - bridge.stop(); - } - - #[tokio::test] - async fn publish_after_stop_start_has_no_missing_or_duplicate_event() { - let bridge = Arc::new(ObsBridgeService::new("test")); - let first_port = bridge - .start(0, "token".to_string()) - .await - .expect("OBS bridge 시작 실패"); - let mut first = connect_authenticated(first_port, "token").await; - - bridge.publish("settings:changed", serde_json::json!({ "cycle": 1 })); - let first_event = receive_envelope(&mut first, "tauri_event").await; - assert_eq!(first_event.payload["data"]["cycle"], 1); - - bridge.stop(); - drop(first); - - let second_port = bridge - .start(0, "token".to_string()) - .await - .expect("OBS bridge 재시작 실패"); - let mut second = connect_authenticated(second_port, "token").await; - - bridge.publish("settings:changed", serde_json::json!({ "cycle": 2 })); - let second_event = receive_envelope(&mut second, "tauri_event").await; - assert_eq!(second_event.payload["data"]["cycle"], 2); - assert_no_tauri_event(&mut second).await; - - bridge.stop(); - } - - #[tokio::test] - async fn lagged_publish_burst_recovers_with_latest_snapshot() { - let bridge = Arc::new(ObsBridgeService::new("test")); - let expected_snapshot = serde_json::json!({ "revision": 300 }); - bridge.update_snapshot(expected_snapshot.clone()); - let port = bridge - .start(0, "token".to_string()) - .await - .expect("OBS bridge 시작 실패"); - let mut ws = connect_authenticated(port, "token").await; - - for revision in 0..300 { - bridge.publish( - "settings:changed", - serde_json::json!({ "revision": revision }), - ); - } - - let snapshot = receive_envelope(&mut ws, "snapshot").await; - assert_eq!(snapshot.payload, expected_snapshot); - - bridge.stop(); - } - - #[test] - fn host_header_allows_only_ip_literals_and_localhost() { - for allowed in [ - "localhost", - "LOCALHOST:34891", - "127.0.0.1", - "127.0.0.1:34891", - "192.168.0.10:80", - "::1", - "[::1]", - "[2001:db8::1]:34891", - ] { - assert!(is_allowed_host_header(allowed), "{allowed}"); - } - - for rejected in [ - "", - "example.com", - "example.com:34891", - "localhost.example.com", - "localhost:invalid", - "[localhost]:34891", - "127.0.0.1:99999", - ] { - assert!(!is_allowed_host_header(rejected), "{rejected}"); - } - } - - #[test] - fn http_host_validation_rejects_missing_or_duplicate_headers() { - assert!(has_allowed_http_host( - "GET / HTTP/1.1\r\nHost: localhost:34891\r\n\r\n" - )); - assert!(!has_allowed_http_host("GET / HTTP/1.1\r\n\r\n")); - assert!(!has_allowed_http_host( - "GET / HTTP/1.1\r\nHost: localhost\r\nHost: example.com\r\n\r\n" - )); - } - - #[test] - fn websocket_origin_allows_absence_or_local_machine_hosts_only() { - let no_origin = WsRequest::builder() - .header(header::HOST, "127.0.0.1:34891") - .body(()) - .unwrap(); - assert!(validate_websocket_request(&no_origin).is_ok()); - - let local_origin = WsRequest::builder() - .header(header::HOST, "localhost:34891") - .header(header::ORIGIN, "http://[::1]:3400") - .body(()) - .unwrap(); - assert!(validate_websocket_request(&local_origin).is_ok()); - - // 이 머신에 할당된 LAN IP는 허용 (LAN 클라이언트가 접속에 쓰는 페이지 origin) - if let Ok(lan_ip) = local_ip_address::local_ip() { - let lan_origin = WsRequest::builder() - .header(header::HOST, format!("{lan_ip}:34891")) - .header(header::ORIGIN, format!("http://{lan_ip}:34891")) - .body(()) - .unwrap(); - assert!(validate_websocket_request(&lan_origin).is_ok()); - } - - let domain_origin = WsRequest::builder() - .header(header::HOST, "127.0.0.1:34891") - .header(header::ORIGIN, "https://example.com") - .body(()) - .unwrap(); - assert_eq!( - validate_websocket_request(&domain_origin), - Err("Invalid Origin header") - ); - - // 이 머신의 인터페이스가 아닌 임의 숫자 IP는 거부 (TEST-NET-3) - let foreign_ip_origin = WsRequest::builder() - .header(header::HOST, "127.0.0.1:34891") - .header(header::ORIGIN, "http://203.0.113.7") - .body(()) - .unwrap(); - assert_eq!( - validate_websocket_request(&foreign_ip_origin), - Err("Invalid Origin header") - ); - } - - async fn http_get(host: &str, port: u16, path: &str) -> String { - let mut stream = TcpStream::connect((host, port)) - .await - .expect("TCP 연결 실패"); - let request = - format!("GET {path} HTTP/1.1\r\nHost: {host}:{port}\r\nConnection: close\r\n\r\n"); - stream - .write_all(request.as_bytes()) - .await - .expect("요청 전송 실패"); - let mut response = Vec::new(); - stream - .read_to_end(&mut response) - .await - .expect("응답 수신 실패"); - String::from_utf8_lossy(&response).into_owned() - } - - #[test] - fn local_machine_ip_rejects_foreign_addresses() { - assert!(is_local_machine_ip(&"127.0.0.1".parse().unwrap())); - assert!(is_local_machine_ip(&"::1".parse().unwrap())); - // TEST-NET-3 — 로컬 인터페이스에 할당될 일 없는 주소 - assert!(!is_local_machine_ip(&"203.0.113.7".parse().unwrap())); - if let Ok(lan_ip) = local_ip_address::local_ip() { - assert!(is_local_machine_ip(&lan_ip)); - } - } - - // dev 리다이렉트(token 쿼리 포함)는 이 머신 자신의 peer에만 허용 — - // 같은 PC가 자기 LAN IP로 접속하는 URL 복사 경로는 유지되어야 함 - #[tokio::test] - async fn dev_redirect_serves_only_local_machine_peers() { - let bridge = Arc::new(ObsBridgeService::new("test")); - bridge.set_dev_url("http://localhost:3400".to_string()); - let port = bridge - .start(0, "dev-token".to_string()) - .await - .expect("OBS bridge 시작 실패"); - - // loopback peer → 기존 dev 부트스트랩(302 + token) 유지 - let response = http_get("127.0.0.1", port, "/").await; - assert!(response.starts_with("HTTP/1.1 302"), "{response}"); - assert!(response.contains("token=dev-token"), "{response}"); - - // 같은 머신의 LAN IP 경유 — peer도 로컬 인터페이스 IP이므로 유지되어야 함 - if let Ok(lan_ip) = local_ip_address::local_ip() { - let response = http_get(&lan_ip.to_string(), port, "/").await; - assert!( - response.starts_with("HTTP/1.1 302"), - "같은 PC의 LAN IP 접속이 깨지면 안 됨: {response}" - ); - } - - bridge.stop(); - } - - // Game Bar 엔드포인트 제거 검증 — 어떤 경로도 무인증 HTTP 응답으로 토큰을 내주면 안 됨 - #[tokio::test] - async fn gamebar_paths_no_longer_leak_token() { - let bridge = Arc::new(ObsBridgeService::new("test")); - let port = bridge - .start(0, "secret-token".to_string()) - .await - .expect("OBS bridge 시작 실패"); - - for path in ["/gamebar", "/gamebar/bootstrap.json"] { - let mut stream = TcpStream::connect(("127.0.0.1", port)) - .await - .expect("TCP 연결 실패"); - let request = - format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n"); - stream - .write_all(request.as_bytes()) - .await - .expect("요청 전송 실패"); - let mut response = Vec::new(); - stream - .read_to_end(&mut response) - .await - .expect("응답 수신 실패"); - let response = String::from_utf8_lossy(&response); - assert!( - !response.contains("secret-token"), - "{path} 응답에 토큰 노출: {response}" - ); - } - - bridge.stop(); - } - - // dev 모드에서도 /media는 Vite 리다이렉트가 아니라 브릿지가 직접 처리해야 함 - #[tokio::test] - async fn media_requests_bypass_dev_redirect() { - let bridge = Arc::new(ObsBridgeService::new("test")); - bridge.set_dev_url("http://localhost:3400".to_string()); - let port = bridge - .start(0, "media-token".to_string()) - .await - .expect("OBS bridge 시작 실패"); - - let mut stream = TcpStream::connect(("127.0.0.1", port)) - .await - .expect("TCP 연결 실패"); - stream - .write_all( - b"GET /media/abc?token=wrong HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n", - ) - .await - .expect("요청 전송 실패"); - let mut response = Vec::new(); - stream - .read_to_end(&mut response) - .await - .expect("응답 수신 실패"); - let response = String::from_utf8_lossy(&response); - assert!( - response.starts_with("HTTP/1.1 403"), - "302 리다이렉트가 아닌 미디어 핸들러 403이어야 함: {response}" - ); - - bridge.stop(); - } - - #[tokio::test] - async fn protocol_mismatch_is_rejected_before_auth() { - let bridge = Arc::new(ObsBridgeService::new("test")); - let port = bridge - .start(0, "token".to_string()) - .await - .expect("OBS bridge 시작 실패"); - - let (mut ws, _) = connect_async(format!("ws://127.0.0.1:{port}")) - .await - .expect("WS 연결 실패"); - // 올바른 토큰이라도 프로토콜 버전이 다르면 거부되어야 함 - let hello = serde_json::json!({ - "v": 999, - "type": "hello", - "seq": 0, - "payload": { "token": "token", "protocol": 999 }, - }); - ws.send(Message::Text(hello.to_string())) - .await - .expect("hello 전송 실패"); - - let error = receive_envelope(&mut ws, "error").await; - assert_eq!( - error.payload.get("code").and_then(Value::as_str), - Some("PROTOCOL_MISMATCH") - ); - - bridge.stop(); - } - - // v1 번들은 KeySlot union 와이어 형식을 소비할 수 없으므로 handshake에서 결정적으로 거부 - #[tokio::test] - async fn legacy_protocol_v1_hello_is_rejected() { - let bridge = Arc::new(ObsBridgeService::new("test")); - let port = bridge - .start(0, "token".to_string()) - .await - .expect("OBS bridge 시작 실패"); - - let (mut ws, _) = connect_async(format!("ws://127.0.0.1:{port}")) - .await - .expect("WS 연결 실패"); - let hello = serde_json::json!({ - "v": 1, - "type": "hello", - "seq": 0, - "payload": { "token": "token", "protocol": 1 }, - }); - ws.send(Message::Text(hello.to_string())) - .await - .expect("hello 전송 실패"); - - let error = receive_envelope(&mut ws, "error").await; - assert_eq!( - error.payload.get("code").and_then(Value::as_str), - Some("PROTOCOL_MISMATCH") - ); - - bridge.stop(); - } - - #[test] - fn binding_listens_on_all_interfaces_for_lan_access() { - assert!(bind_address(34891).ip().is_unspecified()); - assert_eq!(bind_address(34891).port(), 34891); - } - - // 같은 네트워크의 다른 PC 접속은 제품 계약 — LAN IP 경유 인증 접속이 실제로 성립해야 함 - #[tokio::test] - async fn lan_address_accepts_authenticated_clients() { - let Ok(lan_ip) = local_ip_address::local_ip() else { - eprintln!("LAN IP 조회 불가 환경, 테스트 스킵"); - return; - }; - - let bridge = Arc::new(ObsBridgeService::new("test")); - bridge.update_snapshot(serde_json::json!({ "source": "lan" })); - let port = bridge - .start(0, "lan-token".to_string()) - .await - .expect("OBS bridge 시작 실패"); - - assert_eq!(bridge.status().local_ip, Some(lan_ip.to_string())); - - let (mut ws, _) = connect_async(format!("ws://{lan_ip}:{port}")) - .await - .expect("LAN IP로 WS 연결 실패"); - let hello = make_envelope( - "hello", - 0, - serde_json::json!({ "token": "lan-token", "protocol": OBS_PROTOCOL_VERSION }), - ); - ws.send(Message::Text(hello.to_string())) - .await - .expect("hello 전송 실패"); - receive_envelope(&mut ws, "hello_ack").await; - let snapshot = receive_envelope(&mut ws, "snapshot").await; - assert_eq!(snapshot.payload, serde_json::json!({ "source": "lan" })); - - bridge.stop(); - } - - #[tokio::test] - async fn token_rotation_disconnects_existing_sessions_and_keeps_server_running() { - let bridge = Arc::new(ObsBridgeService::new("test")); - bridge.update_snapshot(serde_json::json!({ "source": "initial" })); - let port = bridge - .start(0, "old-token".to_string()) - .await - .expect("OBS bridge 시작 실패"); - let mut old_ws = connect_authenticated(port, "old-token").await; - - bridge.set_token("new-token".to_string()); - assert!(bridge.is_running()); - assert_eq!(bridge.status().port, port); - - let resync = make_envelope("resync_request", 1, Value::Null); - let _ = old_ws.send(Message::Text(resync.to_string())).await; - bridge.publish( - "settings:changed", - serde_json::json!({ "authenticated": false }), - ); - - tokio::time::timeout(Duration::from_secs(2), async { - loop { - match old_ws.next().await { - Some(Ok(Message::Text(text))) => { - let envelope: ObsEnvelope = - serde_json::from_str(&text).expect("OBS envelope 파싱 실패"); - assert_ne!(envelope.msg_type, "snapshot"); - if envelope.msg_type == "tauri_event" { - assert_ne!( - envelope.payload.get("event").and_then(Value::as_str), - Some("settings:changed") - ); - } - } - Some(Ok(Message::Close(_))) | Some(Err(_)) | None => break, - Some(Ok(_)) => {} - } - } - }) - .await - .expect("구 토큰 세션이 종료되지 않음"); - - let mut new_ws = connect_authenticated(port, "new-token").await; - bridge.publish( - "settings:changed", - serde_json::json!({ "authenticated": true }), - ); - let event = receive_envelope(&mut new_ws, "tauri_event").await; - assert_eq!( - event.payload, - serde_json::json!({ - "event": "settings:changed", - "data": { "authenticated": true } - }) - ); - - bridge.stop(); - } -} +mod tests; diff --git a/src-tauri/src/services/obs_bridge/tests.rs b/src-tauri/src/services/obs_bridge/tests.rs new file mode 100644 index 000000000..7100391d6 --- /dev/null +++ b/src-tauri/src/services/obs_bridge/tests.rs @@ -0,0 +1,649 @@ +use super::*; +use tokio_tungstenite::{connect_async, MaybeTlsStream}; + +type TestWebSocket = WebSocketStream>; + +async fn receive_envelope(ws: &mut TestWebSocket, expected_type: &str) -> ObsEnvelope { + tokio::time::timeout(Duration::from_secs(2), async { + loop { + match ws.next().await { + Some(Ok(Message::Text(text))) => { + let envelope = + serde_json::from_str::(&text).expect("OBS envelope 파싱 실패"); + if envelope.msg_type == expected_type { + return envelope; + } + } + Some(Ok(_)) => {} + Some(Err(error)) => panic!("WS 메시지 수신 실패: {error}"), + None => panic!("WS 연결이 예기치 않게 종료됨"), + } + } + }) + .await + .expect("WS 메시지 수신 타임아웃") +} + +async fn connect_authenticated(port: u16, token: &str) -> TestWebSocket { + let (mut ws, _) = connect_async(format!("ws://127.0.0.1:{port}")) + .await + .expect("WS 연결 실패"); + let hello = make_envelope( + "hello", + 0, + serde_json::json!({ "token": token, "protocol": OBS_PROTOCOL_VERSION }), + ); + ws.send(Message::Text(hello.to_string())) + .await + .expect("hello 전송 실패"); + + receive_envelope(&mut ws, "hello_ack").await; + receive_envelope(&mut ws, "snapshot").await; + ws +} + +async fn assert_no_tauri_event(ws: &mut TestWebSocket) { + let result = tokio::time::timeout(Duration::from_millis(100), async { + loop { + match ws.next().await { + Some(Ok(Message::Text(text))) => { + let envelope = + serde_json::from_str::(&text).expect("OBS envelope 파싱 실패"); + if envelope.msg_type == "tauri_event" { + return true; + } + } + Some(Ok(_)) => {} + Some(Err(_)) | None => return false, + } + } + }) + .await; + + assert!( + matches!(result, Err(_) | Ok(false)), + "예상하지 않은 tauri_event 중복 수신" + ); +} + +#[test] +fn websocket_allowlist_uses_exact_matching() { + assert_eq!(ALLOWED_WS_COMMANDS.len(), 32); + assert!(is_allowed_command("app_bootstrap")); + assert!(is_allowed_command("editor_get")); + assert!(!is_allowed_command("css_history_get")); + assert!(is_allowed_command("sound_list")); + assert!(is_allowed_command("sound_load_original")); + assert!(!is_allowed_command("plugin_storage_clear")); + assert!(!is_allowed_command("plugin_storage_clear_by_prefix")); + assert!(!is_allowed_command("editor_commit")); + assert!(!is_allowed_command("settings_update")); + assert!(!is_allowed_command("keys_update")); + assert!(!is_allowed_command("keys_update_with_positions")); + assert!(!is_allowed_command("plugin_rpc_send")); + assert!(!is_allowed_command("plugin_rpc_respond")); + assert!(!is_allowed_command("plugin_instances_commit")); + assert!(!is_allowed_command("plugin_instances_reconcile")); + assert!(!is_allowed_command("plugin_authority_reset")); + assert!(!is_allowed_command("app_bootstrap_extra")); + assert!(!is_allowed_command("plugin:window|close")); + assert_eq!(build_allowed_list().len(), ALLOWED_WS_COMMANDS.len()); +} + +#[test] +fn bridge_messages_targeting_main_are_not_forwarded_to_obs() { + let broadcast = serde_json::json!({ "type": "PING", "data": null }); + assert!(is_forwarded_to_obs("plugin-bridge:message", &broadcast)); + let to_overlay = serde_json::json!({ "type": "PING", "target": "overlay" }); + assert!(is_forwarded_to_obs("plugin-bridge:message", &to_overlay)); + let to_main = serde_json::json!({ "type": "PING", "target": "main" }); + assert!(!is_forwarded_to_obs("plugin-bridge:message", &to_main)); + assert!(!is_forwarded_to_obs("app:close-requested", &broadcast)); +} + +#[test] +fn public_overlay_events_are_forwarded_to_obs_clients() { + assert_eq!(FORWARDED_EVENTS.len(), 32); + for event in [ + "customTabs:changed", + "overlay:resized", + "counterAnimation:changed", + "preset:snapshot", + ] { + assert!(FORWARDED_EVENTS.contains(&event), "missing event: {event}"); + } +} + +#[tokio::test] +async fn publish_is_noop_while_server_is_stopped() { + let bridge = ObsBridgeService::new("test"); + let mut receiver = bridge.broadcast_tx.subscribe(); + + bridge.publish("settings:changed", serde_json::json!({ "enabled": true })); + + assert!( + tokio::time::timeout(Duration::from_millis(50), receiver.recv()) + .await + .is_err() + ); +} + +#[tokio::test] +async fn publish_with_no_receivers_keeps_running_server_healthy() { + let bridge = Arc::new(ObsBridgeService::new("test")); + let port = bridge + .start(0, "token".to_string()) + .await + .expect("OBS bridge 시작 실패"); + + assert_ne!(port, 0); + assert_eq!(bridge.broadcast_tx.receiver_count(), 0); + bridge.publish("settings:changed", serde_json::json!({ "enabled": true })); + assert!(bridge.is_running()); + + bridge.stop(); +} + +#[tokio::test] +async fn publish_forwards_supported_events_to_authenticated_client() { + let bridge = Arc::new(ObsBridgeService::new("test")); + let port = bridge + .start(0, "token".to_string()) + .await + .expect("OBS bridge 시작 실패"); + let mut ws = connect_authenticated(port, "token").await; + + for (event_name, data) in [ + ("settings:changed", serde_json::json!({ "theme": "dark" })), + ("overlay:lock", serde_json::json!({ "locked": true })), + ("css:content", serde_json::json!({ "content": "body {}" })), + ] { + bridge.publish(event_name, data.clone()); + let event = receive_envelope(&mut ws, "tauri_event").await; + assert_eq!( + event.payload, + serde_json::json!({ "event": event_name, "data": data }) + ); + } + + bridge.stop(); +} + +#[tokio::test] +async fn publish_ignores_events_outside_forwarded_allowlist() { + let bridge = Arc::new(ObsBridgeService::new("test")); + let port = bridge + .start(0, "token".to_string()) + .await + .expect("OBS bridge 시작 실패"); + let mut ws = connect_authenticated(port, "token").await; + + bridge.publish("history:status", serde_json::json!({ "canUndo": true })); + bridge.publish("settings:changed", serde_json::json!({ "marker": true })); + + let event = receive_envelope(&mut ws, "tauri_event").await; + assert_eq!( + event.payload, + serde_json::json!({ + "event": "settings:changed", + "data": { "marker": true } + }) + ); + assert_no_tauri_event(&mut ws).await; + + bridge.stop(); +} + +#[tokio::test] +async fn publish_reaches_multiple_authenticated_clients_once_each() { + let bridge = Arc::new(ObsBridgeService::new("test")); + let port = bridge + .start(0, "token".to_string()) + .await + .expect("OBS bridge 시작 실패"); + let mut first = connect_authenticated(port, "token").await; + let mut second = connect_authenticated(port, "token").await; + + bridge.publish("overlay:lock", serde_json::json!({ "locked": false })); + + for ws in [&mut first, &mut second] { + let event = receive_envelope(ws, "tauri_event").await; + assert_eq!( + event.payload, + serde_json::json!({ + "event": "overlay:lock", + "data": { "locked": false } + }) + ); + assert_no_tauri_event(ws).await; + } + + bridge.stop(); +} + +#[tokio::test] +async fn publish_after_stop_start_has_no_missing_or_duplicate_event() { + let bridge = Arc::new(ObsBridgeService::new("test")); + let first_port = bridge + .start(0, "token".to_string()) + .await + .expect("OBS bridge 시작 실패"); + let mut first = connect_authenticated(first_port, "token").await; + + bridge.publish("settings:changed", serde_json::json!({ "cycle": 1 })); + let first_event = receive_envelope(&mut first, "tauri_event").await; + assert_eq!(first_event.payload["data"]["cycle"], 1); + + bridge.stop(); + drop(first); + + let second_port = bridge + .start(0, "token".to_string()) + .await + .expect("OBS bridge 재시작 실패"); + let mut second = connect_authenticated(second_port, "token").await; + + bridge.publish("settings:changed", serde_json::json!({ "cycle": 2 })); + let second_event = receive_envelope(&mut second, "tauri_event").await; + assert_eq!(second_event.payload["data"]["cycle"], 2); + assert_no_tauri_event(&mut second).await; + + bridge.stop(); +} + +#[tokio::test] +async fn lagged_publish_burst_recovers_with_latest_snapshot() { + let bridge = Arc::new(ObsBridgeService::new("test")); + let expected_snapshot = serde_json::json!({ "revision": 300 }); + bridge.update_snapshot(expected_snapshot.clone()); + let port = bridge + .start(0, "token".to_string()) + .await + .expect("OBS bridge 시작 실패"); + let mut ws = connect_authenticated(port, "token").await; + + for revision in 0..300 { + bridge.publish( + "settings:changed", + serde_json::json!({ "revision": revision }), + ); + } + + let snapshot = receive_envelope(&mut ws, "snapshot").await; + assert_eq!(snapshot.payload, expected_snapshot); + + bridge.stop(); +} + +#[test] +fn host_header_allows_only_ip_literals_and_localhost() { + for allowed in [ + "localhost", + "LOCALHOST:34891", + "127.0.0.1", + "127.0.0.1:34891", + "192.168.0.10:80", + "::1", + "[::1]", + "[2001:db8::1]:34891", + ] { + assert!(is_allowed_host_header(allowed), "{allowed}"); + } + + for rejected in [ + "", + "example.com", + "example.com:34891", + "localhost.example.com", + "localhost:invalid", + "[localhost]:34891", + "127.0.0.1:99999", + ] { + assert!(!is_allowed_host_header(rejected), "{rejected}"); + } +} + +#[test] +fn http_host_validation_rejects_missing_or_duplicate_headers() { + assert!(has_allowed_http_host( + "GET / HTTP/1.1\r\nHost: localhost:34891\r\n\r\n" + )); + assert!(!has_allowed_http_host("GET / HTTP/1.1\r\n\r\n")); + assert!(!has_allowed_http_host( + "GET / HTTP/1.1\r\nHost: localhost\r\nHost: example.com\r\n\r\n" + )); +} + +#[test] +fn websocket_origin_allows_absence_or_local_machine_hosts_only() { + let no_origin = WsRequest::builder() + .header(header::HOST, "127.0.0.1:34891") + .body(()) + .unwrap(); + assert!(validate_websocket_request(&no_origin).is_ok()); + + let local_origin = WsRequest::builder() + .header(header::HOST, "localhost:34891") + .header(header::ORIGIN, "http://[::1]:3400") + .body(()) + .unwrap(); + assert!(validate_websocket_request(&local_origin).is_ok()); + + // 이 머신에 할당된 LAN IP는 허용 (LAN 클라이언트가 접속에 쓰는 페이지 origin) + if let Ok(lan_ip) = local_ip_address::local_ip() { + let lan_origin = WsRequest::builder() + .header(header::HOST, format!("{lan_ip}:34891")) + .header(header::ORIGIN, format!("http://{lan_ip}:34891")) + .body(()) + .unwrap(); + assert!(validate_websocket_request(&lan_origin).is_ok()); + } + + let domain_origin = WsRequest::builder() + .header(header::HOST, "127.0.0.1:34891") + .header(header::ORIGIN, "https://example.com") + .body(()) + .unwrap(); + assert_eq!( + validate_websocket_request(&domain_origin), + Err("Invalid Origin header") + ); + + // 이 머신의 인터페이스가 아닌 임의 숫자 IP는 거부 (TEST-NET-3) + let foreign_ip_origin = WsRequest::builder() + .header(header::HOST, "127.0.0.1:34891") + .header(header::ORIGIN, "http://203.0.113.7") + .body(()) + .unwrap(); + assert_eq!( + validate_websocket_request(&foreign_ip_origin), + Err("Invalid Origin header") + ); +} + +async fn http_get(host: &str, port: u16, path: &str) -> String { + let mut stream = TcpStream::connect((host, port)) + .await + .expect("TCP 연결 실패"); + let request = + format!("GET {path} HTTP/1.1\r\nHost: {host}:{port}\r\nConnection: close\r\n\r\n"); + stream + .write_all(request.as_bytes()) + .await + .expect("요청 전송 실패"); + let mut response = Vec::new(); + stream + .read_to_end(&mut response) + .await + .expect("응답 수신 실패"); + String::from_utf8_lossy(&response).into_owned() +} + +#[test] +fn local_machine_ip_rejects_foreign_addresses() { + assert!(is_local_machine_ip(&"127.0.0.1".parse().unwrap())); + assert!(is_local_machine_ip(&"::1".parse().unwrap())); + // TEST-NET-3 — 로컬 인터페이스에 할당될 일 없는 주소 + assert!(!is_local_machine_ip(&"203.0.113.7".parse().unwrap())); + if let Ok(lan_ip) = local_ip_address::local_ip() { + assert!(is_local_machine_ip(&lan_ip)); + } +} + +// dev 리다이렉트(token 쿼리 포함)는 이 머신 자신의 peer에만 허용 — +// 같은 PC가 자기 LAN IP로 접속하는 URL 복사 경로는 유지되어야 함 +#[tokio::test] +async fn dev_redirect_serves_only_local_machine_peers() { + let bridge = Arc::new(ObsBridgeService::new("test")); + bridge.set_dev_url("http://localhost:3400".to_string()); + let port = bridge + .start(0, "dev-token".to_string()) + .await + .expect("OBS bridge 시작 실패"); + + // loopback peer → 기존 dev 부트스트랩(302 + token) 유지 + let response = http_get("127.0.0.1", port, "/").await; + assert!(response.starts_with("HTTP/1.1 302"), "{response}"); + assert!(response.contains("token=dev-token"), "{response}"); + + // 같은 머신의 LAN IP 경유 — peer도 로컬 인터페이스 IP이므로 유지되어야 함 + if let Ok(lan_ip) = local_ip_address::local_ip() { + let response = http_get(&lan_ip.to_string(), port, "/").await; + assert!( + response.starts_with("HTTP/1.1 302"), + "같은 PC의 LAN IP 접속이 깨지면 안 됨: {response}" + ); + } + + bridge.stop(); +} + +// Game Bar 엔드포인트 제거 검증 — 어떤 경로도 무인증 HTTP 응답으로 토큰을 내주면 안 됨 +#[tokio::test] +async fn gamebar_paths_no_longer_leak_token() { + let bridge = Arc::new(ObsBridgeService::new("test")); + let port = bridge + .start(0, "secret-token".to_string()) + .await + .expect("OBS bridge 시작 실패"); + + for path in ["/gamebar", "/gamebar/bootstrap.json"] { + let mut stream = TcpStream::connect(("127.0.0.1", port)) + .await + .expect("TCP 연결 실패"); + let request = + format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n"); + stream + .write_all(request.as_bytes()) + .await + .expect("요청 전송 실패"); + let mut response = Vec::new(); + stream + .read_to_end(&mut response) + .await + .expect("응답 수신 실패"); + let response = String::from_utf8_lossy(&response); + assert!( + !response.contains("secret-token"), + "{path} 응답에 토큰 노출: {response}" + ); + } + + bridge.stop(); +} + +// dev 모드에서도 /media는 Vite 리다이렉트가 아니라 브릿지가 직접 처리해야 함 +#[tokio::test] +async fn media_requests_bypass_dev_redirect() { + let bridge = Arc::new(ObsBridgeService::new("test")); + bridge.set_dev_url("http://localhost:3400".to_string()); + let port = bridge + .start(0, "media-token".to_string()) + .await + .expect("OBS bridge 시작 실패"); + + let mut stream = TcpStream::connect(("127.0.0.1", port)) + .await + .expect("TCP 연결 실패"); + stream + .write_all( + b"GET /media/abc?token=wrong HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n", + ) + .await + .expect("요청 전송 실패"); + let mut response = Vec::new(); + stream + .read_to_end(&mut response) + .await + .expect("응답 수신 실패"); + let response = String::from_utf8_lossy(&response); + assert!( + response.starts_with("HTTP/1.1 403"), + "302 리다이렉트가 아닌 미디어 핸들러 403이어야 함: {response}" + ); + + bridge.stop(); +} + +#[tokio::test] +async fn protocol_mismatch_is_rejected_before_auth() { + let bridge = Arc::new(ObsBridgeService::new("test")); + let port = bridge + .start(0, "token".to_string()) + .await + .expect("OBS bridge 시작 실패"); + + let (mut ws, _) = connect_async(format!("ws://127.0.0.1:{port}")) + .await + .expect("WS 연결 실패"); + // 올바른 토큰이라도 프로토콜 버전이 다르면 거부되어야 함 + let hello = serde_json::json!({ + "v": 999, + "type": "hello", + "seq": 0, + "payload": { "token": "token", "protocol": 999 }, + }); + ws.send(Message::Text(hello.to_string())) + .await + .expect("hello 전송 실패"); + + let error = receive_envelope(&mut ws, "error").await; + assert_eq!( + error.payload.get("code").and_then(Value::as_str), + Some("PROTOCOL_MISMATCH") + ); + + bridge.stop(); +} + +// v1 번들은 KeySlot union 와이어 형식을 소비할 수 없으므로 handshake에서 결정적으로 거부 +#[tokio::test] +async fn legacy_protocol_v1_hello_is_rejected() { + let bridge = Arc::new(ObsBridgeService::new("test")); + let port = bridge + .start(0, "token".to_string()) + .await + .expect("OBS bridge 시작 실패"); + + let (mut ws, _) = connect_async(format!("ws://127.0.0.1:{port}")) + .await + .expect("WS 연결 실패"); + let hello = serde_json::json!({ + "v": 1, + "type": "hello", + "seq": 0, + "payload": { "token": "token", "protocol": 1 }, + }); + ws.send(Message::Text(hello.to_string())) + .await + .expect("hello 전송 실패"); + + let error = receive_envelope(&mut ws, "error").await; + assert_eq!( + error.payload.get("code").and_then(Value::as_str), + Some("PROTOCOL_MISMATCH") + ); + + bridge.stop(); +} + +#[test] +fn binding_listens_on_all_interfaces_for_lan_access() { + assert!(bind_address(34891).ip().is_unspecified()); + assert_eq!(bind_address(34891).port(), 34891); +} + +// 같은 네트워크의 다른 PC 접속은 제품 계약 — LAN IP 경유 인증 접속이 실제로 성립해야 함 +#[tokio::test] +async fn lan_address_accepts_authenticated_clients() { + let Ok(lan_ip) = local_ip_address::local_ip() else { + eprintln!("LAN IP 조회 불가 환경, 테스트 스킵"); + return; + }; + + let bridge = Arc::new(ObsBridgeService::new("test")); + bridge.update_snapshot(serde_json::json!({ "source": "lan" })); + let port = bridge + .start(0, "lan-token".to_string()) + .await + .expect("OBS bridge 시작 실패"); + + assert_eq!(bridge.status().local_ip, Some(lan_ip.to_string())); + + let (mut ws, _) = connect_async(format!("ws://{lan_ip}:{port}")) + .await + .expect("LAN IP로 WS 연결 실패"); + let hello = make_envelope( + "hello", + 0, + serde_json::json!({ "token": "lan-token", "protocol": OBS_PROTOCOL_VERSION }), + ); + ws.send(Message::Text(hello.to_string())) + .await + .expect("hello 전송 실패"); + receive_envelope(&mut ws, "hello_ack").await; + let snapshot = receive_envelope(&mut ws, "snapshot").await; + assert_eq!(snapshot.payload, serde_json::json!({ "source": "lan" })); + + bridge.stop(); +} + +#[tokio::test] +async fn token_rotation_disconnects_existing_sessions_and_keeps_server_running() { + let bridge = Arc::new(ObsBridgeService::new("test")); + bridge.update_snapshot(serde_json::json!({ "source": "initial" })); + let port = bridge + .start(0, "old-token".to_string()) + .await + .expect("OBS bridge 시작 실패"); + let mut old_ws = connect_authenticated(port, "old-token").await; + + bridge.set_token("new-token".to_string()); + assert!(bridge.is_running()); + assert_eq!(bridge.status().port, port); + + let resync = make_envelope("resync_request", 1, Value::Null); + let _ = old_ws.send(Message::Text(resync.to_string())).await; + bridge.publish( + "settings:changed", + serde_json::json!({ "authenticated": false }), + ); + + tokio::time::timeout(Duration::from_secs(2), async { + loop { + match old_ws.next().await { + Some(Ok(Message::Text(text))) => { + let envelope: ObsEnvelope = + serde_json::from_str(&text).expect("OBS envelope 파싱 실패"); + assert_ne!(envelope.msg_type, "snapshot"); + if envelope.msg_type == "tauri_event" { + assert_ne!( + envelope.payload.get("event").and_then(Value::as_str), + Some("settings:changed") + ); + } + } + Some(Ok(Message::Close(_))) | Some(Err(_)) | None => break, + Some(Ok(_)) => {} + } + } + }) + .await + .expect("구 토큰 세션이 종료되지 않음"); + + let mut new_ws = connect_authenticated(port, "new-token").await; + bridge.publish( + "settings:changed", + serde_json::json!({ "authenticated": true }), + ); + let event = receive_envelope(&mut new_ws, "tauri_event").await; + assert_eq!( + event.payload, + serde_json::json!({ + "event": "settings:changed", + "data": { "authenticated": true } + }) + ); + + bridge.stop(); +} From a7812f8ad52a5223c70593dc20383713f55cd835 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 16:48:57 +0900 Subject: [PATCH 141/245] =?UTF-8?q?refactor:=20=ED=82=A4=20=EB=AA=85?= =?UTF-8?q?=EB=A0=B9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/keys/keys.rs | 891 +--------------------- src-tauri/src/commands/keys/keys/tests.rs | 880 +++++++++++++++++++++ 2 files changed, 881 insertions(+), 890 deletions(-) create mode 100644 src-tauri/src/commands/keys/keys/tests.rs diff --git a/src-tauri/src/commands/keys/keys.rs b/src-tauri/src/commands/keys/keys.rs index 83db13b6d..0718e7942 100644 --- a/src-tauri/src/commands/keys/keys.rs +++ b/src-tauri/src/commands/keys/keys.rs @@ -884,893 +884,4 @@ pub fn raw_input_unsubscribe(state: State<'_, AppState>) -> CmdResult anyhow::Result<()> { - Ok(()) - } - - fn emit_key_counter( - &self, - _mode: &str, - _key: &str, - _count: u32, - _session_id: &str, - _revision: u64, - ) -> anyhow::Result<()> { - Ok(()) - } - } - - fn populated_custom_tab_store() -> AppStoreData { - let position = default_positions() - .values() - .next() - .and_then(|positions| positions.first()) - .cloned() - .expect("default position fixture"); - let mut store = AppStoreData { - custom_tabs: vec![ - CustomTab { - id: "custom-before".to_string(), - name: "Before".to_string(), - }, - CustomTab { - id: TARGET_TAB.to_string(), - name: "Target".to_string(), - }, - ], - selected_key_type: TARGET_TAB.to_string(), - ..AppStoreData::default() - }; - store - .keys - .insert(TARGET_TAB.to_string(), vec![KeySlot::from("KeyD")]); - store - .key_positions - .insert(TARGET_TAB.to_string(), vec![position.clone()]); - store.stat_positions.insert( - TARGET_TAB.to_string(), - vec![StatPosition { - stat_type: StatType::Kps, - position: position.clone(), - }], - ); - store.graph_positions.insert( - TARGET_TAB.to_string(), - vec![GraphPosition { - stat_type: GraphStatType::Kps, - graph_type: GraphType::Line, - graph_speed: 1, - graph_color: "#ffffff".to_string(), - show_avg_line: true, - position: position.clone(), - }], - ); - store.knob_positions.insert( - TARGET_TAB.to_string(), - vec![KnobPosition { - axis_id: "axis".to_string(), - sensitivity: 1.0, - reverse: false, - position, - }], - ); - store.layer_groups.insert( - TARGET_TAB.to_string(), - vec![LayerGroupDef { - id: "group".to_string(), - name: "Group".to_string(), - }], - ); - store - .tab_css_overrides - .insert(TARGET_TAB.to_string(), TabCss::default()); - store - .tab_note_overrides - .insert(TARGET_TAB.to_string(), TabNoteSettings::default()); - store.key_counters.insert( - TARGET_TAB.to_string(), - [("KeyD".to_string(), 7)].into_iter().collect(), - ); - store - } - - #[test] - fn deleting_selected_custom_tab_clears_all_tab_scoped_data() { - let mut store = populated_custom_tab_store(); - let plan = plan_custom_tab_delete(&store, TARGET_TAB).expect("delete plan"); - - assert_eq!(plan.next_selected, "custom-before"); - delete_custom_tab_data(&mut store, TARGET_TAB, &plan); - - assert!(!store.custom_tabs.iter().any(|tab| tab.id == TARGET_TAB)); - assert!(!store.keys.contains_key(TARGET_TAB)); - assert!(!store.key_positions.contains_key(TARGET_TAB)); - assert!(!store.stat_positions.contains_key(TARGET_TAB)); - assert!(!store.graph_positions.contains_key(TARGET_TAB)); - assert!(!store.knob_positions.contains_key(TARGET_TAB)); - assert!(!store.layer_groups.contains_key(TARGET_TAB)); - assert!(!store.tab_css_overrides.contains_key(TARGET_TAB)); - assert!(!store.tab_note_overrides.contains_key(TARGET_TAB)); - assert!(!store.key_counters.contains_key(TARGET_TAB)); - assert_eq!(store.selected_key_type, "custom-before"); - } - - #[test] - fn reset_all_clears_knob_positions_and_zeroes_default_counters() { - let mut store = populated_custom_tab_store(); - reset_all_editor_data( - &mut store, - default_keys(), - default_positions(), - default_stat_positions(), - ); - - assert!(store.knob_positions.is_empty()); - assert!(store.custom_tabs.is_empty()); - assert_eq!(store.selected_key_type, "4key"); - assert_eq!(store.stat_positions.len(), default_stat_positions().len()); - assert!(store - .key_counters - .values() - .flat_map(|mode| mode.values()) - .all(|count| *count == 0)); - } - - #[test] - fn reset_all_issues_a_fresh_globally_unique_id_generation_each_time() { - let mut store = populated_custom_tab_store(); - reset_all_editor_data( - &mut store, - default_keys(), - default_positions(), - default_stat_positions(), - ); - let first = store - .key_positions - .values() - .flatten() - .map(|position| position.id.clone()) - .collect::>(); - let first_count = store.key_positions.values().map(Vec::len).sum::(); - - reset_all_editor_data( - &mut store, - default_keys(), - default_positions(), - default_stat_positions(), - ); - let second = store - .key_positions - .values() - .flatten() - .map(|position| position.id.clone()) - .collect::>(); - - assert_eq!(first.len(), first_count); - assert_eq!(second.len(), first_count); - assert!(first.is_disjoint(&second)); - assert!(second - .iter() - .all(|id| crate::state::native_element_id::is_valid_element_id(id))); - } - - #[test] - fn reset_all_migrates_default_data_url_images_immediately() { - let dir = std::env::temp_dir().join(format!( - "dmnote-reset-all-default-images-{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&dir).unwrap(); - let mut store = populated_custom_tab_store(); - - reset_all_editor_data_with_images( - &mut store, - default_keys(), - default_positions(), - default_stat_positions(), - &dir, - ); - - let positions = store - .key_positions - .values() - .flatten() - .chain( - store - .stat_positions - .values() - .flatten() - .map(|stat| &stat.position), - ) - .chain( - store - .graph_positions - .values() - .flatten() - .map(|graph| &graph.position), - ) - .chain( - store - .knob_positions - .values() - .flatten() - .map(|knob| &knob.position), - ) - .collect::>(); - let image_paths = positions - .iter() - .flat_map(|position| [&position.active_image, &position.inactive_image]) - .flatten() - .filter(|image| !image.is_empty()) - .collect::>(); - assert!(!image_paths.is_empty()); - assert!(image_paths.iter().all(|image| !image.starts_with("data:"))); - assert!(image_paths - .iter() - .all(|image| std::path::Path::new(image.as_str()).is_file())); - assert!(store.stat_positions.values().flatten().all(|stat| { - crate::state::native_element_id::is_valid_element_id(&stat.position.id) - })); - - std::fs::remove_dir_all(dir).unwrap(); - } - - #[test] - fn custom_mode_reset_is_supported_and_preserves_tab_identity() { - let mut store = populated_custom_tab_store(); - let tabs_before = store.custom_tabs.clone(); - let kind = reset_mode_kind(&store, TARGET_TAB); - - assert_eq!(kind, Some(ModeResetKind::Custom)); - reset_mode_data(&mut store, TARGET_TAB, kind.unwrap()); - - assert_eq!(store.custom_tabs, tabs_before); - assert!(store.keys[TARGET_TAB].is_empty()); - assert!(store.key_positions[TARGET_TAB].is_empty()); - assert!(store.stat_positions[TARGET_TAB].is_empty()); - assert!(store.graph_positions[TARGET_TAB].is_empty()); - assert!(store.knob_positions[TARGET_TAB].is_empty()); - assert!(!store.layer_groups.contains_key(TARGET_TAB)); - assert!(!store.tab_css_overrides.contains_key(TARGET_TAB)); - assert!(!store.tab_note_overrides.contains_key(TARGET_TAB)); - assert!(store.key_counters[TARGET_TAB].is_empty()); - } - - #[test] - fn default_mode_reset_clears_knob_positions() { - let mut store = AppStoreData::default(); - store.knob_positions.insert( - "4key".to_string(), - populated_custom_tab_store().knob_positions[TARGET_TAB].clone(), - ); - - reset_mode_data(&mut store, "4key", ModeResetKind::Default); - - assert!(store.knob_positions["4key"].is_empty()); - assert_eq!( - store.stat_positions["4key"].len(), - default_stat_positions()["4key"].len() - ); - } - - #[test] - fn ghost_mode_request_leaves_store_keyboard_and_events_unchanged() { - let mut store = AppStoreData { - selected_key_type: "8key".to_string(), - ..AppStoreData::default() - }; - store - .keys - .insert("ghost-mode".to_string(), vec![KeySlot::from("KeyA")]); - let keyboard = KeyboardManager::new(store.keys.clone(), "8key"); - let commit_calls = Cell::new(0); - let emit_calls = Cell::new(0); - - let response = set_mode_with( - &store, - "ghost-mode".to_string(), - |candidate| { - commit_calls.set(commit_calls.get() + 1); - Ok(candidate) - }, - |effective| { - keyboard.set_mode(effective.to_string()); - emit_calls.set(emit_calls.get() + 1); - Ok(()) - }, - ) - .unwrap(); - - assert!(!response.success); - assert_eq!(response.mode, "8key"); - assert_eq!(store.selected_key_type, "8key"); - assert_eq!(keyboard.current_mode(), "8key"); - assert_eq!(commit_calls.get(), 0); - assert_eq!(emit_calls.get(), 0); - } - - #[test] - fn absent_mode_request_remains_a_no_op() { - let store = AppStoreData { - selected_key_type: "8key".to_string(), - ..AppStoreData::default() - }; - let keyboard = KeyboardManager::new(store.keys.clone(), "8key"); - let commit_calls = Cell::new(0); - let emit_calls = Cell::new(0); - - let response = set_mode_with( - &store, - "missing-mode".to_string(), - |candidate| { - commit_calls.set(commit_calls.get() + 1); - Ok(candidate) - }, - |effective| { - keyboard.set_mode(effective.to_string()); - emit_calls.set(emit_calls.get() + 1); - Ok(()) - }, - ) - .unwrap(); - - assert!(!response.success); - assert_eq!(response.mode, "8key"); - assert_eq!(store.selected_key_type, "8key"); - assert_eq!(keyboard.current_mode(), "8key"); - assert_eq!(commit_calls.get(), 0); - assert_eq!(emit_calls.get(), 0); - } - - #[test] - fn selection_after_concurrent_delete_uses_locked_store_state() { - let stale_snapshot = populated_custom_tab_store(); - assert!(super::is_selectable_mode(&stale_snapshot, TARGET_TAB)); - - let mut locked_store = stale_snapshot; - let delete_plan = plan_custom_tab_delete(&locked_store, TARGET_TAB).unwrap(); - delete_custom_tab_data(&mut locked_store, TARGET_TAB, &delete_plan); - let selected_after_delete = locked_store.selected_key_type.clone(); - - let (success, selected) = select_mode_if_available(&mut locked_store, TARGET_TAB); - - assert!(!success); - assert_eq!(selected, selected_after_delete); - assert_eq!(locked_store.selected_key_type, selected_after_delete); - assert!(!locked_store.keys.contains_key(TARGET_TAB)); - } - - #[test] - fn reset_mode_with_changed_keys_preserves_other_live_mode_counters() { - let directory = tempfile::tempdir().unwrap(); - let store = AppStore::initialize_for_test(directory.path()).unwrap(); - store - .update(|data| data.key_counter_enabled = true) - .unwrap(); - let customized = store - .commit_legacy_editor_transaction( - EditorCommitOrigin::LegacyAdapter("reset-test-setup".to_string()), - &[EditorField::Keys, EditorField::KeyPositions], - |data| { - data.keys.get_mut("4key").unwrap()[0] = KeySlot::from("QA RESET KEY"); - Ok(()) - }, - ) - .unwrap(); - drop(customized); - let state = AppState::initialize(store).unwrap(); - let emitter = NoopCounterEmitter; - let reset_mode = "4key"; - let reset_key = state.store.snapshot().keys[reset_mode][0].canonical(); - let preserved_mode = "5key"; - let preserved_key = state.store.snapshot().keys[preserved_mode][0].canonical(); - for expected in 1..=3 { - assert_eq!( - state.increment_key_counter_and_emit(&emitter, reset_mode, &reset_key), - Some(expected) - ); - } - for expected in 1..=7 { - assert_eq!( - state.increment_key_counter_and_emit(&emitter, preserved_mode, &preserved_key), - Some(expected) - ); - } - assert_eq!( - state.store.snapshot().key_counters[preserved_mode][&preserved_key], - 0 - ); - - let admission = state.store.admit_editor_mutation().unwrap(); - let (transaction, key_runtime_applied) = state - .commit_legacy_editor_reset_preserving_runtime_counters( - &emitter, - EditorCommitOrigin::LegacyAdapter("keys_reset_mode".to_string()), - &[ - EditorField::Keys, - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - EditorField::LayerGroups, - ], - PluginInstancesResetScope::Mode(reset_mode.to_string()), - admission, - |data| { - reset_mode_data_with_images( - data, - reset_mode, - ModeResetKind::Default, - directory.path(), - ); - Ok(()) - }, - ) - .unwrap(); - assert!(key_runtime_applied); - assert!(transaction - .change - .result - .changed_fields - .contains(&EditorField::Keys)); - - assert_eq!( - state.snapshot_key_counters()[preserved_mode][&preserved_key], - 7 - ); - assert!(state.snapshot_key_counters()[reset_mode] - .values() - .all(|count| *count == 0)); - assert_eq!( - state.store.snapshot().key_counters[preserved_mode][&preserved_key], - 7 - ); - state.shutdown(); - } - - #[test] - fn reset_mode_with_default_keys_applies_counter_only_reset_to_runtime() { - let directory = tempfile::tempdir().unwrap(); - let store = AppStore::initialize_for_test(directory.path()).unwrap(); - store - .update(|data| data.key_counter_enabled = true) - .unwrap(); - let state = AppState::initialize(store).unwrap(); - let emitter = NoopCounterEmitter; - let mode = "4key"; - let key = state.store.snapshot().keys[mode][0].canonical(); - for expected in 1..=7 { - assert_eq!( - state.increment_key_counter_and_emit(&emitter, mode, &key), - Some(expected) - ); - } - assert_eq!(state.store.snapshot().key_counters[mode][&key], 0); - let generation_before = state.store.runtime_publication_generation(); - - let admission = state.store.admit_editor_mutation().unwrap(); - let (transaction, key_runtime_applied) = state - .commit_legacy_editor_reset_preserving_runtime_counters( - &emitter, - EditorCommitOrigin::LegacyAdapter("keys_reset_mode".to_string()), - &[ - EditorField::Keys, - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - EditorField::LayerGroups, - ], - PluginInstancesResetScope::Mode(mode.to_string()), - admission, - |data| { - reset_mode_data_with_images( - data, - mode, - ModeResetKind::Default, - directory.path(), - ); - Ok(()) - }, - ) - .unwrap(); - assert!(key_runtime_applied); - assert!(!transaction - .change - .result - .changed_fields - .contains(&EditorField::Keys)); - assert!(transaction.change.runtime_publication_generation > generation_before); - - assert_eq!(state.snapshot_key_counters()[mode][&key], 0); - assert_eq!(state.store.snapshot().key_counters[mode][&key], 0); - state.shutdown(); - } - - #[test] - fn reset_mode_replays_queued_increment_for_unchanged_mapping() { - let directory = tempfile::tempdir().unwrap(); - let store = AppStore::initialize_for_test(directory.path()).unwrap(); - store - .update(|data| data.key_counter_enabled = true) - .unwrap(); - let state = AppState::initialize(store).unwrap(); - let emitter = NoopCounterEmitter; - let reset_mode = "4key"; - let reset_key = state.store.snapshot().keys[reset_mode][0].canonical(); - let preserved_mode = "5key"; - let preserved_key = state.store.snapshot().keys[preserved_mode][0].canonical(); - for expected in 1..=3 { - assert_eq!( - state.increment_key_counter_and_emit(&emitter, reset_mode, &reset_key), - Some(expected) - ); - } - for expected in 1..=7 { - assert_eq!( - state.increment_key_counter_and_emit(&emitter, preserved_mode, &preserved_key), - Some(expected) - ); - } - - let admission = state.store.admit_editor_mutation().unwrap(); - let (_, key_runtime_applied) = state - .commit_legacy_editor_reset_preserving_runtime_counters( - &emitter, - EditorCommitOrigin::LegacyAdapter("keys_reset_mode".to_string()), - &[ - EditorField::Keys, - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - EditorField::LayerGroups, - ], - PluginInstancesResetScope::Mode(reset_mode.to_string()), - admission, - |data| { - assert_eq!( - state.increment_key_counter_and_emit(&emitter, reset_mode, &reset_key,), - None - ); - reset_mode_data_with_images( - data, - reset_mode, - ModeResetKind::Default, - directory.path(), - ); - Ok(()) - }, - ) - .unwrap(); - - assert!(key_runtime_applied); - assert_eq!(state.snapshot_key_counters()[reset_mode][&reset_key], 1); - assert_eq!( - state.snapshot_key_counters()[preserved_mode][&preserved_key], - 7 - ); - assert_eq!( - state.store.snapshot().key_counters[reset_mode][&reset_key], - 0 - ); - assert_eq!( - state.store.snapshot().key_counters[preserved_mode][&preserved_key], - 7 - ); - state.shutdown(); - } - - #[test] - fn reset_mode_drops_queued_increment_for_replaced_key() { - let directory = tempfile::tempdir().unwrap(); - let store = AppStore::initialize_for_test(directory.path()).unwrap(); - store - .update(|data| data.key_counter_enabled = true) - .unwrap(); - let mode = "4key"; - let replaced_key = "QA REPLACED KEY"; - let setup = store - .commit_legacy_editor_transaction( - EditorCommitOrigin::LegacyAdapter("reset-queue-test-setup".to_string()), - &[EditorField::Keys, EditorField::KeyPositions], - |data| { - data.keys.get_mut(mode).unwrap()[0] = KeySlot::from(replaced_key); - Ok(()) - }, - ) - .unwrap(); - drop(setup); - let state = AppState::initialize(store).unwrap(); - let emitter = NoopCounterEmitter; - - let admission = state.store.admit_editor_mutation().unwrap(); - let (_, key_runtime_applied) = state - .commit_legacy_editor_reset_preserving_runtime_counters( - &emitter, - EditorCommitOrigin::LegacyAdapter("keys_reset_mode".to_string()), - &[ - EditorField::Keys, - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - EditorField::LayerGroups, - ], - PluginInstancesResetScope::Mode(mode.to_string()), - admission, - |data| { - assert_eq!( - state.increment_key_counter_and_emit(&emitter, mode, replaced_key), - None - ); - reset_mode_data_with_images( - data, - mode, - ModeResetKind::Default, - directory.path(), - ); - Ok(()) - }, - ) - .unwrap(); - - assert!(key_runtime_applied); - assert!(!state.snapshot_key_counters()[mode].contains_key(replaced_key)); - assert!(!state.store.snapshot().key_counters[mode].contains_key(replaced_key)); - state.shutdown(); - drop(state); - let reloaded = AppStore::initialize_for_test(directory.path()).unwrap(); - assert!(!reloaded.snapshot().key_counters[mode].contains_key(replaced_key)); - reloaded.flush_and_shutdown().unwrap(); - } - - #[test] - fn reset_all_drops_queued_increment_for_removed_custom_mode() { - let directory = tempfile::tempdir().unwrap(); - let store = AppStore::initialize_for_test(directory.path()).unwrap(); - store - .update(|data| data.key_counter_enabled = true) - .unwrap(); - let mode = "qa-removed-custom-mode"; - let key = "QA REMOVED KEY"; - let mut position = default_positions()["4key"][0].clone(); - position.id = uuid::Uuid::new_v4().to_string(); - let setup = store - .commit_legacy_editor_transaction( - EditorCommitOrigin::LegacyAdapter("reset-queue-test-setup".to_string()), - &[EditorField::Keys, EditorField::KeyPositions], - |data| { - data.custom_tabs.push(CustomTab { - id: mode.to_string(), - name: "Removed during reset".to_string(), - }); - data.keys.insert(mode.to_string(), vec![KeySlot::from(key)]); - data.key_positions.insert(mode.to_string(), vec![position]); - data.selected_key_type = mode.to_string(); - Ok(()) - }, - ) - .unwrap(); - drop(setup); - let state = AppState::initialize(store).unwrap(); - let emitter = NoopCounterEmitter; - - let admission = state.store.admit_editor_mutation().unwrap(); - let (_, key_runtime_applied) = state - .commit_legacy_editor_reset_preserving_runtime_counters( - &emitter, - EditorCommitOrigin::LegacyAdapter("keys_reset_all".to_string()), - &[ - EditorField::Keys, - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - EditorField::LayerGroups, - ], - PluginInstancesResetScope::All, - admission, - |data| { - assert_eq!( - state.increment_key_counter_and_emit(&emitter, mode, key), - None - ); - reset_all_editor_data_with_images( - data, - default_keys(), - default_positions(), - default_stat_positions(), - directory.path(), - ); - Ok(()) - }, - ) - .unwrap(); - - assert!(key_runtime_applied); - assert!(!state.snapshot_key_counters().contains_key(mode)); - assert!(!state.store.snapshot().key_counters.contains_key(mode)); - state.shutdown(); - drop(state); - let reloaded = AppStore::initialize_for_test(directory.path()).unwrap(); - assert!(!reloaded.snapshot().key_counters.contains_key(mode)); - reloaded.flush_and_shutdown().unwrap(); - } - - #[test] - fn reset_all_with_default_keys_applies_counter_only_reset_to_runtime() { - let directory = tempfile::tempdir().unwrap(); - let store = AppStore::initialize_for_test(directory.path()).unwrap(); - store - .update(|data| data.key_counter_enabled = true) - .unwrap(); - let state = AppState::initialize(store).unwrap(); - let emitter = NoopCounterEmitter; - let mode = "4key"; - let key = state.store.snapshot().keys[mode][0].canonical(); - for expected in 1..=7 { - assert_eq!( - state.increment_key_counter_and_emit(&emitter, mode, &key), - Some(expected) - ); - } - assert_eq!(state.store.snapshot().key_counters[mode][&key], 0); - let generation_before = state.store.runtime_publication_generation(); - - let admission = state.store.admit_editor_mutation().unwrap(); - let (transaction, key_runtime_applied) = state - .commit_legacy_editor_reset_preserving_runtime_counters( - &emitter, - EditorCommitOrigin::LegacyAdapter("keys_reset_all".to_string()), - &[ - EditorField::Keys, - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - EditorField::LayerGroups, - ], - PluginInstancesResetScope::All, - admission, - |data| { - reset_all_editor_data_with_images( - data, - default_keys(), - default_positions(), - default_stat_positions(), - directory.path(), - ); - Ok(()) - }, - ) - .unwrap(); - assert!(key_runtime_applied); - assert!(!transaction - .change - .result - .changed_fields - .contains(&EditorField::Keys)); - assert!(transaction.change.runtime_publication_generation > generation_before); - - assert_eq!(state.snapshot_key_counters()[mode][&key], 0); - assert!(state - .store - .snapshot() - .key_counters - .values() - .flat_map(|counters| counters.values()) - .all(|count| *count == 0)); - state.shutdown(); - } - - #[test] - fn reset_all_with_changed_keys_zeroes_every_live_counter() { - let directory = tempfile::tempdir().unwrap(); - let store = AppStore::initialize_for_test(directory.path()).unwrap(); - store - .update(|data| data.key_counter_enabled = true) - .unwrap(); - let customized = store - .commit_legacy_editor_transaction( - EditorCommitOrigin::LegacyAdapter("reset-all-test-setup".to_string()), - &[EditorField::Keys, EditorField::KeyPositions], - |data| { - data.keys.get_mut("4key").unwrap()[0] = KeySlot::from("QA RESET ALL KEY"); - Ok(()) - }, - ) - .unwrap(); - drop(customized); - let state = AppState::initialize(store).unwrap(); - let emitter = NoopCounterEmitter; - for mode in ["4key", "5key"] { - let key = state.store.snapshot().keys[mode][0].canonical(); - for expected in 1..=7 { - assert_eq!( - state.increment_key_counter_and_emit(&emitter, mode, &key), - Some(expected) - ); - } - } - - let admission = state.store.admit_editor_mutation().unwrap(); - let (transaction, key_runtime_applied) = state - .commit_legacy_editor_reset_preserving_runtime_counters( - &emitter, - EditorCommitOrigin::LegacyAdapter("keys_reset_all".to_string()), - &[ - EditorField::Keys, - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - EditorField::LayerGroups, - ], - PluginInstancesResetScope::All, - admission, - |data| { - reset_all_editor_data_with_images( - data, - default_keys(), - default_positions(), - default_stat_positions(), - directory.path(), - ); - Ok(()) - }, - ) - .unwrap(); - - assert!(key_runtime_applied); - assert!(transaction - .change - .result - .changed_fields - .contains(&EditorField::Keys)); - assert_eq!(transaction.change.document.keys, *default_keys()); - assert!(state - .snapshot_key_counters() - .values() - .flat_map(|counters| counters.values()) - .all(|count| *count == 0)); - assert!(state - .store - .snapshot() - .key_counters - .values() - .flat_map(|counters| counters.values()) - .all(|count| *count == 0)); - state.shutdown(); - } -} +mod tests; diff --git a/src-tauri/src/commands/keys/keys/tests.rs b/src-tauri/src/commands/keys/keys/tests.rs new file mode 100644 index 000000000..9985d00cc --- /dev/null +++ b/src-tauri/src/commands/keys/keys/tests.rs @@ -0,0 +1,880 @@ +use super::{ + delete_custom_tab_data, plan_custom_tab_delete, reset_all_editor_data, + reset_all_editor_data_with_images, reset_mode_data, reset_mode_data_with_images, + reset_mode_kind, select_mode_if_available, set_mode_with, ModeResetKind, +}; +use crate::{ + defaults::{default_keys, default_positions, default_stat_positions}, + keyboard::KeyboardManager, + models::{ + AppStoreData, CustomTab, EditorCommitOrigin, EditorField, GraphPosition, GraphStatType, + GraphType, KeyCounters, KeySlot, KnobPosition, LayerGroupDef, StatPosition, StatType, + TabCss, TabNoteSettings, + }, + state::{ + app_state::KeyCounterEventEmitter, store::PluginInstancesResetScope, AppState, AppStore, + }, +}; +use std::{cell::Cell, collections::HashSet}; + +const TARGET_TAB: &str = "custom-target"; + +struct NoopCounterEmitter; + +impl KeyCounterEventEmitter for NoopCounterEmitter { + fn emit_key_counters( + &self, + _counters: &KeyCounters, + _session_id: &str, + _revision: u64, + ) -> anyhow::Result<()> { + Ok(()) + } + + fn emit_key_counter( + &self, + _mode: &str, + _key: &str, + _count: u32, + _session_id: &str, + _revision: u64, + ) -> anyhow::Result<()> { + Ok(()) + } +} + +fn populated_custom_tab_store() -> AppStoreData { + let position = default_positions() + .values() + .next() + .and_then(|positions| positions.first()) + .cloned() + .expect("default position fixture"); + let mut store = AppStoreData { + custom_tabs: vec![ + CustomTab { + id: "custom-before".to_string(), + name: "Before".to_string(), + }, + CustomTab { + id: TARGET_TAB.to_string(), + name: "Target".to_string(), + }, + ], + selected_key_type: TARGET_TAB.to_string(), + ..AppStoreData::default() + }; + store + .keys + .insert(TARGET_TAB.to_string(), vec![KeySlot::from("KeyD")]); + store + .key_positions + .insert(TARGET_TAB.to_string(), vec![position.clone()]); + store.stat_positions.insert( + TARGET_TAB.to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: position.clone(), + }], + ); + store.graph_positions.insert( + TARGET_TAB.to_string(), + vec![GraphPosition { + stat_type: GraphStatType::Kps, + graph_type: GraphType::Line, + graph_speed: 1, + graph_color: "#ffffff".to_string(), + show_avg_line: true, + position: position.clone(), + }], + ); + store.knob_positions.insert( + TARGET_TAB.to_string(), + vec![KnobPosition { + axis_id: "axis".to_string(), + sensitivity: 1.0, + reverse: false, + position, + }], + ); + store.layer_groups.insert( + TARGET_TAB.to_string(), + vec![LayerGroupDef { + id: "group".to_string(), + name: "Group".to_string(), + }], + ); + store + .tab_css_overrides + .insert(TARGET_TAB.to_string(), TabCss::default()); + store + .tab_note_overrides + .insert(TARGET_TAB.to_string(), TabNoteSettings::default()); + store.key_counters.insert( + TARGET_TAB.to_string(), + [("KeyD".to_string(), 7)].into_iter().collect(), + ); + store +} + +#[test] +fn deleting_selected_custom_tab_clears_all_tab_scoped_data() { + let mut store = populated_custom_tab_store(); + let plan = plan_custom_tab_delete(&store, TARGET_TAB).expect("delete plan"); + + assert_eq!(plan.next_selected, "custom-before"); + delete_custom_tab_data(&mut store, TARGET_TAB, &plan); + + assert!(!store.custom_tabs.iter().any(|tab| tab.id == TARGET_TAB)); + assert!(!store.keys.contains_key(TARGET_TAB)); + assert!(!store.key_positions.contains_key(TARGET_TAB)); + assert!(!store.stat_positions.contains_key(TARGET_TAB)); + assert!(!store.graph_positions.contains_key(TARGET_TAB)); + assert!(!store.knob_positions.contains_key(TARGET_TAB)); + assert!(!store.layer_groups.contains_key(TARGET_TAB)); + assert!(!store.tab_css_overrides.contains_key(TARGET_TAB)); + assert!(!store.tab_note_overrides.contains_key(TARGET_TAB)); + assert!(!store.key_counters.contains_key(TARGET_TAB)); + assert_eq!(store.selected_key_type, "custom-before"); +} + +#[test] +fn reset_all_clears_knob_positions_and_zeroes_default_counters() { + let mut store = populated_custom_tab_store(); + reset_all_editor_data( + &mut store, + default_keys(), + default_positions(), + default_stat_positions(), + ); + + assert!(store.knob_positions.is_empty()); + assert!(store.custom_tabs.is_empty()); + assert_eq!(store.selected_key_type, "4key"); + assert_eq!(store.stat_positions.len(), default_stat_positions().len()); + assert!(store + .key_counters + .values() + .flat_map(|mode| mode.values()) + .all(|count| *count == 0)); +} + +#[test] +fn reset_all_issues_a_fresh_globally_unique_id_generation_each_time() { + let mut store = populated_custom_tab_store(); + reset_all_editor_data( + &mut store, + default_keys(), + default_positions(), + default_stat_positions(), + ); + let first = store + .key_positions + .values() + .flatten() + .map(|position| position.id.clone()) + .collect::>(); + let first_count = store.key_positions.values().map(Vec::len).sum::(); + + reset_all_editor_data( + &mut store, + default_keys(), + default_positions(), + default_stat_positions(), + ); + let second = store + .key_positions + .values() + .flatten() + .map(|position| position.id.clone()) + .collect::>(); + + assert_eq!(first.len(), first_count); + assert_eq!(second.len(), first_count); + assert!(first.is_disjoint(&second)); + assert!(second + .iter() + .all(|id| crate::state::native_element_id::is_valid_element_id(id))); +} + +#[test] +fn reset_all_migrates_default_data_url_images_immediately() { + let dir = std::env::temp_dir().join(format!( + "dmnote-reset-all-default-images-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&dir).unwrap(); + let mut store = populated_custom_tab_store(); + + reset_all_editor_data_with_images( + &mut store, + default_keys(), + default_positions(), + default_stat_positions(), + &dir, + ); + + let positions = store + .key_positions + .values() + .flatten() + .chain( + store + .stat_positions + .values() + .flatten() + .map(|stat| &stat.position), + ) + .chain( + store + .graph_positions + .values() + .flatten() + .map(|graph| &graph.position), + ) + .chain( + store + .knob_positions + .values() + .flatten() + .map(|knob| &knob.position), + ) + .collect::>(); + let image_paths = positions + .iter() + .flat_map(|position| [&position.active_image, &position.inactive_image]) + .flatten() + .filter(|image| !image.is_empty()) + .collect::>(); + assert!(!image_paths.is_empty()); + assert!(image_paths.iter().all(|image| !image.starts_with("data:"))); + assert!(image_paths + .iter() + .all(|image| std::path::Path::new(image.as_str()).is_file())); + assert!(store + .stat_positions + .values() + .flatten() + .all(|stat| { crate::state::native_element_id::is_valid_element_id(&stat.position.id) })); + + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn custom_mode_reset_is_supported_and_preserves_tab_identity() { + let mut store = populated_custom_tab_store(); + let tabs_before = store.custom_tabs.clone(); + let kind = reset_mode_kind(&store, TARGET_TAB); + + assert_eq!(kind, Some(ModeResetKind::Custom)); + reset_mode_data(&mut store, TARGET_TAB, kind.unwrap()); + + assert_eq!(store.custom_tabs, tabs_before); + assert!(store.keys[TARGET_TAB].is_empty()); + assert!(store.key_positions[TARGET_TAB].is_empty()); + assert!(store.stat_positions[TARGET_TAB].is_empty()); + assert!(store.graph_positions[TARGET_TAB].is_empty()); + assert!(store.knob_positions[TARGET_TAB].is_empty()); + assert!(!store.layer_groups.contains_key(TARGET_TAB)); + assert!(!store.tab_css_overrides.contains_key(TARGET_TAB)); + assert!(!store.tab_note_overrides.contains_key(TARGET_TAB)); + assert!(store.key_counters[TARGET_TAB].is_empty()); +} + +#[test] +fn default_mode_reset_clears_knob_positions() { + let mut store = AppStoreData::default(); + store.knob_positions.insert( + "4key".to_string(), + populated_custom_tab_store().knob_positions[TARGET_TAB].clone(), + ); + + reset_mode_data(&mut store, "4key", ModeResetKind::Default); + + assert!(store.knob_positions["4key"].is_empty()); + assert_eq!( + store.stat_positions["4key"].len(), + default_stat_positions()["4key"].len() + ); +} + +#[test] +fn ghost_mode_request_leaves_store_keyboard_and_events_unchanged() { + let mut store = AppStoreData { + selected_key_type: "8key".to_string(), + ..AppStoreData::default() + }; + store + .keys + .insert("ghost-mode".to_string(), vec![KeySlot::from("KeyA")]); + let keyboard = KeyboardManager::new(store.keys.clone(), "8key"); + let commit_calls = Cell::new(0); + let emit_calls = Cell::new(0); + + let response = set_mode_with( + &store, + "ghost-mode".to_string(), + |candidate| { + commit_calls.set(commit_calls.get() + 1); + Ok(candidate) + }, + |effective| { + keyboard.set_mode(effective.to_string()); + emit_calls.set(emit_calls.get() + 1); + Ok(()) + }, + ) + .unwrap(); + + assert!(!response.success); + assert_eq!(response.mode, "8key"); + assert_eq!(store.selected_key_type, "8key"); + assert_eq!(keyboard.current_mode(), "8key"); + assert_eq!(commit_calls.get(), 0); + assert_eq!(emit_calls.get(), 0); +} + +#[test] +fn absent_mode_request_remains_a_no_op() { + let store = AppStoreData { + selected_key_type: "8key".to_string(), + ..AppStoreData::default() + }; + let keyboard = KeyboardManager::new(store.keys.clone(), "8key"); + let commit_calls = Cell::new(0); + let emit_calls = Cell::new(0); + + let response = set_mode_with( + &store, + "missing-mode".to_string(), + |candidate| { + commit_calls.set(commit_calls.get() + 1); + Ok(candidate) + }, + |effective| { + keyboard.set_mode(effective.to_string()); + emit_calls.set(emit_calls.get() + 1); + Ok(()) + }, + ) + .unwrap(); + + assert!(!response.success); + assert_eq!(response.mode, "8key"); + assert_eq!(store.selected_key_type, "8key"); + assert_eq!(keyboard.current_mode(), "8key"); + assert_eq!(commit_calls.get(), 0); + assert_eq!(emit_calls.get(), 0); +} + +#[test] +fn selection_after_concurrent_delete_uses_locked_store_state() { + let stale_snapshot = populated_custom_tab_store(); + assert!(super::is_selectable_mode(&stale_snapshot, TARGET_TAB)); + + let mut locked_store = stale_snapshot; + let delete_plan = plan_custom_tab_delete(&locked_store, TARGET_TAB).unwrap(); + delete_custom_tab_data(&mut locked_store, TARGET_TAB, &delete_plan); + let selected_after_delete = locked_store.selected_key_type.clone(); + + let (success, selected) = select_mode_if_available(&mut locked_store, TARGET_TAB); + + assert!(!success); + assert_eq!(selected, selected_after_delete); + assert_eq!(locked_store.selected_key_type, selected_after_delete); + assert!(!locked_store.keys.contains_key(TARGET_TAB)); +} + +#[test] +fn reset_mode_with_changed_keys_preserves_other_live_mode_counters() { + let directory = tempfile::tempdir().unwrap(); + let store = AppStore::initialize_for_test(directory.path()).unwrap(); + store + .update(|data| data.key_counter_enabled = true) + .unwrap(); + let customized = store + .commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("reset-test-setup".to_string()), + &[EditorField::Keys, EditorField::KeyPositions], + |data| { + data.keys.get_mut("4key").unwrap()[0] = KeySlot::from("QA RESET KEY"); + Ok(()) + }, + ) + .unwrap(); + drop(customized); + let state = AppState::initialize(store).unwrap(); + let emitter = NoopCounterEmitter; + let reset_mode = "4key"; + let reset_key = state.store.snapshot().keys[reset_mode][0].canonical(); + let preserved_mode = "5key"; + let preserved_key = state.store.snapshot().keys[preserved_mode][0].canonical(); + for expected in 1..=3 { + assert_eq!( + state.increment_key_counter_and_emit(&emitter, reset_mode, &reset_key), + Some(expected) + ); + } + for expected in 1..=7 { + assert_eq!( + state.increment_key_counter_and_emit(&emitter, preserved_mode, &preserved_key), + Some(expected) + ); + } + assert_eq!( + state.store.snapshot().key_counters[preserved_mode][&preserved_key], + 0 + ); + + let admission = state.store.admit_editor_mutation().unwrap(); + let (transaction, key_runtime_applied) = state + .commit_legacy_editor_reset_preserving_runtime_counters( + &emitter, + EditorCommitOrigin::LegacyAdapter("keys_reset_mode".to_string()), + &[ + EditorField::Keys, + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + EditorField::LayerGroups, + ], + PluginInstancesResetScope::Mode(reset_mode.to_string()), + admission, + |data| { + reset_mode_data_with_images( + data, + reset_mode, + ModeResetKind::Default, + directory.path(), + ); + Ok(()) + }, + ) + .unwrap(); + assert!(key_runtime_applied); + assert!(transaction + .change + .result + .changed_fields + .contains(&EditorField::Keys)); + + assert_eq!( + state.snapshot_key_counters()[preserved_mode][&preserved_key], + 7 + ); + assert!(state.snapshot_key_counters()[reset_mode] + .values() + .all(|count| *count == 0)); + assert_eq!( + state.store.snapshot().key_counters[preserved_mode][&preserved_key], + 7 + ); + state.shutdown(); +} + +#[test] +fn reset_mode_with_default_keys_applies_counter_only_reset_to_runtime() { + let directory = tempfile::tempdir().unwrap(); + let store = AppStore::initialize_for_test(directory.path()).unwrap(); + store + .update(|data| data.key_counter_enabled = true) + .unwrap(); + let state = AppState::initialize(store).unwrap(); + let emitter = NoopCounterEmitter; + let mode = "4key"; + let key = state.store.snapshot().keys[mode][0].canonical(); + for expected in 1..=7 { + assert_eq!( + state.increment_key_counter_and_emit(&emitter, mode, &key), + Some(expected) + ); + } + assert_eq!(state.store.snapshot().key_counters[mode][&key], 0); + let generation_before = state.store.runtime_publication_generation(); + + let admission = state.store.admit_editor_mutation().unwrap(); + let (transaction, key_runtime_applied) = state + .commit_legacy_editor_reset_preserving_runtime_counters( + &emitter, + EditorCommitOrigin::LegacyAdapter("keys_reset_mode".to_string()), + &[ + EditorField::Keys, + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + EditorField::LayerGroups, + ], + PluginInstancesResetScope::Mode(mode.to_string()), + admission, + |data| { + reset_mode_data_with_images(data, mode, ModeResetKind::Default, directory.path()); + Ok(()) + }, + ) + .unwrap(); + assert!(key_runtime_applied); + assert!(!transaction + .change + .result + .changed_fields + .contains(&EditorField::Keys)); + assert!(transaction.change.runtime_publication_generation > generation_before); + + assert_eq!(state.snapshot_key_counters()[mode][&key], 0); + assert_eq!(state.store.snapshot().key_counters[mode][&key], 0); + state.shutdown(); +} + +#[test] +fn reset_mode_replays_queued_increment_for_unchanged_mapping() { + let directory = tempfile::tempdir().unwrap(); + let store = AppStore::initialize_for_test(directory.path()).unwrap(); + store + .update(|data| data.key_counter_enabled = true) + .unwrap(); + let state = AppState::initialize(store).unwrap(); + let emitter = NoopCounterEmitter; + let reset_mode = "4key"; + let reset_key = state.store.snapshot().keys[reset_mode][0].canonical(); + let preserved_mode = "5key"; + let preserved_key = state.store.snapshot().keys[preserved_mode][0].canonical(); + for expected in 1..=3 { + assert_eq!( + state.increment_key_counter_and_emit(&emitter, reset_mode, &reset_key), + Some(expected) + ); + } + for expected in 1..=7 { + assert_eq!( + state.increment_key_counter_and_emit(&emitter, preserved_mode, &preserved_key), + Some(expected) + ); + } + + let admission = state.store.admit_editor_mutation().unwrap(); + let (_, key_runtime_applied) = state + .commit_legacy_editor_reset_preserving_runtime_counters( + &emitter, + EditorCommitOrigin::LegacyAdapter("keys_reset_mode".to_string()), + &[ + EditorField::Keys, + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + EditorField::LayerGroups, + ], + PluginInstancesResetScope::Mode(reset_mode.to_string()), + admission, + |data| { + assert_eq!( + state.increment_key_counter_and_emit(&emitter, reset_mode, &reset_key,), + None + ); + reset_mode_data_with_images( + data, + reset_mode, + ModeResetKind::Default, + directory.path(), + ); + Ok(()) + }, + ) + .unwrap(); + + assert!(key_runtime_applied); + assert_eq!(state.snapshot_key_counters()[reset_mode][&reset_key], 1); + assert_eq!( + state.snapshot_key_counters()[preserved_mode][&preserved_key], + 7 + ); + assert_eq!( + state.store.snapshot().key_counters[reset_mode][&reset_key], + 0 + ); + assert_eq!( + state.store.snapshot().key_counters[preserved_mode][&preserved_key], + 7 + ); + state.shutdown(); +} + +#[test] +fn reset_mode_drops_queued_increment_for_replaced_key() { + let directory = tempfile::tempdir().unwrap(); + let store = AppStore::initialize_for_test(directory.path()).unwrap(); + store + .update(|data| data.key_counter_enabled = true) + .unwrap(); + let mode = "4key"; + let replaced_key = "QA REPLACED KEY"; + let setup = store + .commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("reset-queue-test-setup".to_string()), + &[EditorField::Keys, EditorField::KeyPositions], + |data| { + data.keys.get_mut(mode).unwrap()[0] = KeySlot::from(replaced_key); + Ok(()) + }, + ) + .unwrap(); + drop(setup); + let state = AppState::initialize(store).unwrap(); + let emitter = NoopCounterEmitter; + + let admission = state.store.admit_editor_mutation().unwrap(); + let (_, key_runtime_applied) = state + .commit_legacy_editor_reset_preserving_runtime_counters( + &emitter, + EditorCommitOrigin::LegacyAdapter("keys_reset_mode".to_string()), + &[ + EditorField::Keys, + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + EditorField::LayerGroups, + ], + PluginInstancesResetScope::Mode(mode.to_string()), + admission, + |data| { + assert_eq!( + state.increment_key_counter_and_emit(&emitter, mode, replaced_key), + None + ); + reset_mode_data_with_images(data, mode, ModeResetKind::Default, directory.path()); + Ok(()) + }, + ) + .unwrap(); + + assert!(key_runtime_applied); + assert!(!state.snapshot_key_counters()[mode].contains_key(replaced_key)); + assert!(!state.store.snapshot().key_counters[mode].contains_key(replaced_key)); + state.shutdown(); + drop(state); + let reloaded = AppStore::initialize_for_test(directory.path()).unwrap(); + assert!(!reloaded.snapshot().key_counters[mode].contains_key(replaced_key)); + reloaded.flush_and_shutdown().unwrap(); +} + +#[test] +fn reset_all_drops_queued_increment_for_removed_custom_mode() { + let directory = tempfile::tempdir().unwrap(); + let store = AppStore::initialize_for_test(directory.path()).unwrap(); + store + .update(|data| data.key_counter_enabled = true) + .unwrap(); + let mode = "qa-removed-custom-mode"; + let key = "QA REMOVED KEY"; + let mut position = default_positions()["4key"][0].clone(); + position.id = uuid::Uuid::new_v4().to_string(); + let setup = store + .commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("reset-queue-test-setup".to_string()), + &[EditorField::Keys, EditorField::KeyPositions], + |data| { + data.custom_tabs.push(CustomTab { + id: mode.to_string(), + name: "Removed during reset".to_string(), + }); + data.keys.insert(mode.to_string(), vec![KeySlot::from(key)]); + data.key_positions.insert(mode.to_string(), vec![position]); + data.selected_key_type = mode.to_string(); + Ok(()) + }, + ) + .unwrap(); + drop(setup); + let state = AppState::initialize(store).unwrap(); + let emitter = NoopCounterEmitter; + + let admission = state.store.admit_editor_mutation().unwrap(); + let (_, key_runtime_applied) = state + .commit_legacy_editor_reset_preserving_runtime_counters( + &emitter, + EditorCommitOrigin::LegacyAdapter("keys_reset_all".to_string()), + &[ + EditorField::Keys, + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + EditorField::LayerGroups, + ], + PluginInstancesResetScope::All, + admission, + |data| { + assert_eq!( + state.increment_key_counter_and_emit(&emitter, mode, key), + None + ); + reset_all_editor_data_with_images( + data, + default_keys(), + default_positions(), + default_stat_positions(), + directory.path(), + ); + Ok(()) + }, + ) + .unwrap(); + + assert!(key_runtime_applied); + assert!(!state.snapshot_key_counters().contains_key(mode)); + assert!(!state.store.snapshot().key_counters.contains_key(mode)); + state.shutdown(); + drop(state); + let reloaded = AppStore::initialize_for_test(directory.path()).unwrap(); + assert!(!reloaded.snapshot().key_counters.contains_key(mode)); + reloaded.flush_and_shutdown().unwrap(); +} + +#[test] +fn reset_all_with_default_keys_applies_counter_only_reset_to_runtime() { + let directory = tempfile::tempdir().unwrap(); + let store = AppStore::initialize_for_test(directory.path()).unwrap(); + store + .update(|data| data.key_counter_enabled = true) + .unwrap(); + let state = AppState::initialize(store).unwrap(); + let emitter = NoopCounterEmitter; + let mode = "4key"; + let key = state.store.snapshot().keys[mode][0].canonical(); + for expected in 1..=7 { + assert_eq!( + state.increment_key_counter_and_emit(&emitter, mode, &key), + Some(expected) + ); + } + assert_eq!(state.store.snapshot().key_counters[mode][&key], 0); + let generation_before = state.store.runtime_publication_generation(); + + let admission = state.store.admit_editor_mutation().unwrap(); + let (transaction, key_runtime_applied) = state + .commit_legacy_editor_reset_preserving_runtime_counters( + &emitter, + EditorCommitOrigin::LegacyAdapter("keys_reset_all".to_string()), + &[ + EditorField::Keys, + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + EditorField::LayerGroups, + ], + PluginInstancesResetScope::All, + admission, + |data| { + reset_all_editor_data_with_images( + data, + default_keys(), + default_positions(), + default_stat_positions(), + directory.path(), + ); + Ok(()) + }, + ) + .unwrap(); + assert!(key_runtime_applied); + assert!(!transaction + .change + .result + .changed_fields + .contains(&EditorField::Keys)); + assert!(transaction.change.runtime_publication_generation > generation_before); + + assert_eq!(state.snapshot_key_counters()[mode][&key], 0); + assert!(state + .store + .snapshot() + .key_counters + .values() + .flat_map(|counters| counters.values()) + .all(|count| *count == 0)); + state.shutdown(); +} + +#[test] +fn reset_all_with_changed_keys_zeroes_every_live_counter() { + let directory = tempfile::tempdir().unwrap(); + let store = AppStore::initialize_for_test(directory.path()).unwrap(); + store + .update(|data| data.key_counter_enabled = true) + .unwrap(); + let customized = store + .commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("reset-all-test-setup".to_string()), + &[EditorField::Keys, EditorField::KeyPositions], + |data| { + data.keys.get_mut("4key").unwrap()[0] = KeySlot::from("QA RESET ALL KEY"); + Ok(()) + }, + ) + .unwrap(); + drop(customized); + let state = AppState::initialize(store).unwrap(); + let emitter = NoopCounterEmitter; + for mode in ["4key", "5key"] { + let key = state.store.snapshot().keys[mode][0].canonical(); + for expected in 1..=7 { + assert_eq!( + state.increment_key_counter_and_emit(&emitter, mode, &key), + Some(expected) + ); + } + } + + let admission = state.store.admit_editor_mutation().unwrap(); + let (transaction, key_runtime_applied) = state + .commit_legacy_editor_reset_preserving_runtime_counters( + &emitter, + EditorCommitOrigin::LegacyAdapter("keys_reset_all".to_string()), + &[ + EditorField::Keys, + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + EditorField::LayerGroups, + ], + PluginInstancesResetScope::All, + admission, + |data| { + reset_all_editor_data_with_images( + data, + default_keys(), + default_positions(), + default_stat_positions(), + directory.path(), + ); + Ok(()) + }, + ) + .unwrap(); + + assert!(key_runtime_applied); + assert!(transaction + .change + .result + .changed_fields + .contains(&EditorField::Keys)); + assert_eq!(transaction.change.document.keys, *default_keys()); + assert!(state + .snapshot_key_counters() + .values() + .flat_map(|counters| counters.values()) + .all(|count| *count == 0)); + assert!(state + .store + .snapshot() + .key_counters + .values() + .flat_map(|counters| counters.values()) + .all(|count| *count == 0)); + state.shutdown(); +} From faeea8504bb71c37eab3d07545daff7c082fcff6 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 16:51:48 +0900 Subject: [PATCH 142/245] =?UTF-8?q?refactor:=20=EB=84=A4=EC=9D=B4=ED=8B=B0?= =?UTF-8?q?=EB=B8=8C=20=EC=9A=94=EC=86=8C=20ID=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=AA=A8=EB=93=88=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/state/native_element_id.rs | 729 +----------------- .../src/state/native_element_id/tests.rs | 718 +++++++++++++++++ 2 files changed, 719 insertions(+), 728 deletions(-) create mode 100644 src-tauri/src/state/native_element_id/tests.rs diff --git a/src-tauri/src/state/native_element_id.rs b/src-tauri/src/state/native_element_id.rs index 18cedea76..9b6e7ea80 100644 --- a/src-tauri/src/state/native_element_id.rs +++ b/src-tauri/src/state/native_element_id.rs @@ -967,731 +967,4 @@ pub(crate) fn prepare_commit_patch_element_ids( } #[cfg(test)] -mod tests { - use std::collections::{HashMap, HashSet}; - - use crate::models::{ - AppStoreData, EditorPatchV1, GraphPosition, GraphStatType, GraphType, KeyPosition, - KnobPosition, SlotMatch, StatPosition, StatType, - }; - - use super::*; - - fn position(dx: f64) -> KeyPosition { - KeyPosition { - dx, - ..KeyPosition::default() - } - } - - fn stat_position(stat_type: StatType, dx: f64) -> StatPosition { - StatPosition { - stat_type, - position: position(dx), - } - } - - fn store_with_all_collections() -> AppStoreData { - let mut store = AppStoreData { - key_positions: HashMap::from([( - "mode".to_string(), - vec![position(1.0), position(2.0)], - )]), - stat_positions: HashMap::from([( - "mode".to_string(), - vec![StatPosition { - stat_type: StatType::Kps, - position: position(3.0), - }], - )]), - graph_positions: HashMap::from([( - "mode".to_string(), - vec![GraphPosition { - stat_type: GraphStatType::KpsAvg, - graph_type: GraphType::Line, - graph_speed: 100, - graph_color: "#123456".to_string(), - show_avg_line: true, - position: position(4.0), - }], - )]), - knob_positions: HashMap::from([( - "mode".to_string(), - vec![KnobPosition { - axis_id: "axis".to_string(), - sensitivity: 1.0, - reverse: false, - position: position(5.0), - }], - )]), - ..AppStoreData::default() - }; - rekey_store_element_ids(&mut store); - store - } - - fn all_ids(store: &AppStoreData) -> Vec { - let document = EditorDocumentV1::from_store(store); - let mut ids = Vec::new(); - for positions in document.key_positions.values() { - ids.extend(positions.iter().map(|position| position.id.clone())); - } - for positions in document.stat_positions.values() { - ids.extend( - positions - .iter() - .map(|position| position.position.id.clone()), - ); - } - for positions in document.graph_positions.values() { - ids.extend( - positions - .iter() - .map(|position| position.position.id.clone()), - ); - } - for positions in document.knob_positions.values() { - ids.extend( - positions - .iter() - .map(|position| position.position.id.clone()), - ); - } - ids - } - - fn validation_code(error: EditorCommitError) -> String { - error - .details - .and_then(|details| details.validation_code) - .unwrap() - } - - #[test] - fn backfill_replaces_only_missing_invalid_and_duplicate_ids() { - let mut store = store_with_all_collections(); - let kept_id = store.key_positions["mode"][0].id.clone(); - store.key_positions.get_mut("mode").unwrap()[0].active_image = - Some("/images/kept.png".to_string()); - store.key_positions.get_mut("mode").unwrap()[0].sound_path = - Some("/sounds/kept.wav".to_string()); - store.key_positions.get_mut("mode").unwrap()[1].id = kept_id.clone(); - store.stat_positions.get_mut("mode").unwrap()[0].position.id = "not-a-uuid".to_string(); - store.graph_positions.get_mut("mode").unwrap()[0] - .position - .id - .clear(); - - let outcome = backfill_store_element_ids(&mut store); - - assert_eq!( - outcome, - BackfillOutcome { - changed: true, - repaired: true - } - ); - assert_eq!(store.key_positions["mode"][0].id, kept_id); - assert_eq!( - store.key_positions["mode"][0].active_image.as_deref(), - Some("/images/kept.png") - ); - assert_eq!( - store.key_positions["mode"][0].sound_path.as_deref(), - Some("/sounds/kept.wav") - ); - let ids = all_ids(&store); - assert!(ids.iter().all(|id| is_valid_element_id(id))); - assert_eq!(ids.iter().collect::>().len(), ids.len()); - } - - #[test] - fn full_and_mode_rekey_create_fresh_globally_unique_generations() { - let mut store = store_with_all_collections(); - let first = all_ids(&store).into_iter().collect::>(); - rekey_store_element_ids(&mut store); - let second = all_ids(&store).into_iter().collect::>(); - rekey_mode_element_ids(&mut store, "mode"); - let third = all_ids(&store).into_iter().collect::>(); - - assert!(first.is_disjoint(&second)); - assert!(second.is_disjoint(&third)); - assert_eq!(third.len(), 5); - } - - #[test] - fn v2_requires_valid_ids_and_checks_merged_global_uniqueness() { - let store = store_with_all_collections(); - let mut valid = EditorPatchV1 { - schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, - key_positions: Some(store.key_positions.clone()), - ..EditorPatchV1::default() - }; - prepare_commit_patch_element_ids(&store, &mut valid).unwrap(); - - let mut missing = valid.clone(); - missing - .key_positions - .as_mut() - .unwrap() - .get_mut("mode") - .unwrap()[0] - .id - .clear(); - assert_eq!( - validation_code(prepare_commit_patch_element_ids(&store, &mut missing).unwrap_err()), - MISSING_ELEMENT_ID - ); - - for invalid_id in [Uuid::nil().to_string(), "not-a-uuid".to_string()] { - let mut invalid = valid.clone(); - invalid - .key_positions - .as_mut() - .unwrap() - .get_mut("mode") - .unwrap()[0] - .id = invalid_id; - assert_eq!( - validation_code( - prepare_commit_patch_element_ids(&store, &mut invalid).unwrap_err() - ), - INVALID_ELEMENT_ID - ); - } - - let mut merged_duplicate = valid; - merged_duplicate - .key_positions - .as_mut() - .unwrap() - .get_mut("mode") - .unwrap()[0] - .id = store.stat_positions["mode"][0].position.id.clone(); - assert_eq!( - validation_code( - prepare_commit_patch_element_ids(&store, &mut merged_duplicate).unwrap_err() - ), - DUPLICATE_ELEMENT_ID - ); - } - - #[test] - fn v1_preserves_explicit_current_ids_and_rekeys_stale_ids() { - let store = store_with_all_collections(); - let current_id = store.key_positions["mode"][0].id.clone(); - let stale_id = Uuid::new_v4().to_string(); - let mut positions = store.key_positions.clone(); - positions.get_mut("mode").unwrap()[0].dx = 99.0; - positions.get_mut("mode").unwrap()[1].id = stale_id.clone(); - let mut patch = EditorPatchV1 { - key_positions: Some(positions), - ..EditorPatchV1::default() - }; - - prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); - - let positions = &patch.key_positions.unwrap()["mode"]; - assert_eq!(positions[0].id, current_id); - assert_ne!(positions[1].id, stale_id); - assert!(is_valid_element_id(&positions[1].id)); - } - - #[test] - fn v1_idless_reorder_append_and_tie_groups_are_deterministic() { - let mut store = AppStoreData { - key_positions: HashMap::from([( - "mode".to_string(), - vec![position(1.0), position(1.0), position(2.0)], - )]), - ..AppStoreData::default() - }; - rekey_store_element_ids(&mut store); - let original_ids = store.key_positions["mode"] - .iter() - .map(|position| position.id.clone()) - .collect::>(); - let mut candidate = vec![position(1.0), position(1.0), position(2.0), position(3.0)]; - candidate.swap(0, 2); - let mut first = EditorPatchV1 { - key_positions: Some(HashMap::from([("mode".to_string(), candidate.clone())])), - ..EditorPatchV1::default() - }; - - prepare_commit_patch_element_ids(&store, &mut first).unwrap(); - let assigned = first.key_positions.as_ref().unwrap()["mode"] - .iter() - .map(|position| position.id.clone()) - .collect::>(); - assert_eq!(assigned[0], original_ids[2]); - assert_eq!(assigned[1], original_ids[0]); - assert_eq!(assigned[2], original_ids[1]); - assert!(!original_ids.contains(&assigned[3])); - - let mut canonical = store.clone(); - canonical.key_positions = first.key_positions.unwrap(); - let mut repeated = EditorPatchV1 { - key_positions: Some(HashMap::from([("mode".to_string(), candidate)])), - ..EditorPatchV1::default() - }; - prepare_commit_patch_element_ids(&canonical, &mut repeated).unwrap(); - let repeated_ids = repeated.key_positions.unwrap()["mode"] - .iter() - .map(|position| position.id.clone()) - .collect::>(); - assert_eq!(repeated_ids, assigned); - } - - #[test] - fn v1_mixed_attribute_edit_reorder_and_append_succeeds() { - let mut store = AppStoreData { - key_positions: HashMap::from([( - "mode".to_string(), - vec![position(1.0), position(2.0)], - )]), - ..AppStoreData::default() - }; - rekey_store_element_ids(&mut store); - let first_id = store.key_positions["mode"][0].id.clone(); - let second_id = store.key_positions["mode"][1].id.clone(); - let mut edited = store.key_positions["mode"][1].clone(); - edited.dx = 20.0; - let mut patch = EditorPatchV1 { - key_positions: Some(HashMap::from([( - "mode".to_string(), - vec![edited, position(1.0), position(3.0)], - )])), - ..EditorPatchV1::default() - }; - - prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); - - let positions = &patch.key_positions.unwrap()["mode"]; - assert_eq!(positions[0].id, second_id); - assert_eq!(positions[1].id, first_id); - assert!(is_valid_element_id(&positions[2].id)); - assert_ne!(positions[2].id, first_id); - assert_ne!(positions[2].id, second_id); - } - - #[test] - fn v1_idless_positions_only_value_change_keeps_ids_by_index() { - let store = store_with_all_collections(); - let original = store.key_positions["mode"] - .iter() - .map(|position| position.id.clone()) - .collect::>(); - let mut patch = EditorPatchV1 { - key_positions: Some(HashMap::from([( - "mode".to_string(), - vec![position(50.0), position(2.0)], - )])), - ..EditorPatchV1::default() - }; - - prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); - - // 같은 길이 positions 단독 패치는 index가 신원 - 값이 바뀐 요소도 ID를 - // 유지해 id 순서가 보존되고 paired id-order 검사와 충돌하지 않는다 - let positions = &patch.key_positions.unwrap()["mode"]; - assert_eq!(positions[0].id, original[0]); - assert_eq!(positions[1].id, original[1]); - } - - #[test] - fn v1_idless_positions_only_swap_is_treated_as_in_place_edits() { - let store = store_with_all_collections(); - let original = store.key_positions["mode"] - .iter() - .map(|position| position.id.clone()) - .collect::>(); - let mut patch = EditorPatchV1 { - key_positions: Some(HashMap::from([( - "mode".to_string(), - vec![position(2.0), position(1.0)], - )])), - ..EditorPatchV1::default() - }; - - prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); - - // 같은 길이 값 스왑은 두 건의 제자리 값 편집으로 해석 - keys 미동반 - // 패치가 신원 재배열로 번지지 않는다 - let positions = &patch.key_positions.unwrap()["mode"]; - assert_eq!(positions[0].id, original[0]); - assert_eq!(positions[1].id, original[1]); - } - - #[test] - fn v1_idless_equal_length_delete_and_append_does_not_reuse_the_deleted_id() { - let mut store = AppStoreData { - stat_positions: HashMap::from([( - "mode".to_string(), - vec![ - stat_position(StatType::Kps, 1.0), - stat_position(StatType::Kps, 2.0), - stat_position(StatType::Kps, 3.0), - ], - )]), - ..AppStoreData::default() - }; - rekey_store_element_ids(&mut store); - let ids = store.stat_positions["mode"] - .iter() - .map(|element| element.position.id.clone()) - .collect::>(); - // 가운데를 지우고 새 요소를 덧붙인다 - 길이는 그대로다 - let mut patch = EditorPatchV1 { - stat_positions: Some(HashMap::from([( - "mode".to_string(), - vec![ - stat_position(StatType::Kps, 1.0), - stat_position(StatType::Kps, 3.0), - stat_position(StatType::Kps, 9.0), - ], - )])), - ..EditorPatchV1::default() - }; - - prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); - - // 살아남은 요소는 값으로 자기 신원을 지키고, 신규 요소는 삭제된 - // 요소의 신원을 물려받지 않는다 - let positions = &patch.stat_positions.unwrap()["mode"]; - assert_eq!(positions[0].position.id, ids[0]); - assert_eq!(positions[1].position.id, ids[2]); - assert!(!ids.contains(&positions[2].position.id)); - assert!(is_valid_element_id(&positions[2].position.id)); - } - - #[test] - fn v1_paired_value_swap_keeps_ids_with_their_slots() { - let store = keyed_store( - vec![KeySlot::from("A"), KeySlot::from("B")], - vec![position(1.0), position(2.0)], - ); - let id_a = store.key_positions["mode"][0].id.clone(); - let id_b = store.key_positions["mode"][1].id.clone(); - let mut patch = paired_patch( - vec![KeySlot::from("A"), KeySlot::from("B")], - vec![position(2.0), position(1.0)], - ); - - prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); - - // 슬롯이 그대로면 좌표 맞바꿈은 제자리 값 편집이다 - keys 미동반 - // 패치와 같은 결과를 낸다 - let positions = &patch.key_positions.unwrap()["mode"]; - assert_eq!(positions[0].id, id_a); - assert_eq!(positions[1].id, id_b); - } - - #[test] - fn v1_idless_patch_prefers_same_mode_and_never_steals_across_modes() { - let mut store = AppStoreData { - stat_positions: HashMap::from([ - ("modeA".to_string(), Vec::new()), - ( - "modeB".to_string(), - vec![StatPosition { - stat_type: StatType::Kps, - position: position(10.0), - }], - ), - ]), - ..AppStoreData::default() - }; - rekey_store_element_ids(&mut store); - let id_b = store.stat_positions["modeB"][0].position.id.clone(); - let mut patch = EditorPatchV1 { - stat_positions: Some(HashMap::from([ - ( - "modeA".to_string(), - vec![StatPosition { - stat_type: StatType::Kps, - position: position(10.0), - }], - ), - ( - "modeB".to_string(), - vec![StatPosition { - stat_type: StatType::Kps, - position: position(10.0), - }], - ), - ])), - ..EditorPatchV1::default() - }; - - prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); - - // 레이아웃을 다른 모드로 복사해도 원본 모드가 자기 ID를 지킨다 - let positions = patch.stat_positions.unwrap(); - assert_eq!(positions["modeB"][0].position.id, id_b); - assert_ne!(positions["modeA"][0].position.id, id_b); - assert!(is_valid_element_id(&positions["modeA"][0].position.id)); - } - - fn keyed_store(slots: Vec, positions: Vec) -> AppStoreData { - let mut store = AppStoreData { - keys: HashMap::from([("mode".to_string(), slots)]), - key_positions: HashMap::from([("mode".to_string(), positions)]), - ..AppStoreData::default() - }; - rekey_store_element_ids(&mut store); - store - } - - fn paired_patch(slots: Vec, positions: Vec) -> EditorPatchV1 { - EditorPatchV1 { - keys: Some(HashMap::from([("mode".to_string(), slots)])), - key_positions: Some(HashMap::from([("mode".to_string(), positions)])), - ..EditorPatchV1::default() - } - } - - #[test] - fn v1_paired_reorder_moves_ids_with_their_key_slots() { - let store = keyed_store( - vec![KeySlot::from("A"), KeySlot::from("B")], - vec![position(1.0), position(1.0)], - ); - let id_a = store.key_positions["mode"][0].id.clone(); - let id_b = store.key_positions["mode"][1].id.clone(); - let mut patch = paired_patch( - vec![KeySlot::from("B"), KeySlot::from("A")], - vec![position(1.0), position(1.0)], - ); - - prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); - - let positions = &patch.key_positions.unwrap()["mode"]; - assert_eq!(positions[0].id, id_b); - assert_eq!(positions[1].id, id_a); - } - - #[test] - fn v1_paired_rebind_falls_back_to_value_inheritance() { - let store = keyed_store( - vec![KeySlot::from("A"), KeySlot::from("B")], - vec![position(1.0), position(2.0)], - ); - let id_a = store.key_positions["mode"][0].id.clone(); - let id_b = store.key_positions["mode"][1].id.clone(); - let mut patch = paired_patch( - vec![KeySlot::from("A"), KeySlot::from("C")], - vec![position(1.0), position(2.0)], - ); - - prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); - - let positions = &patch.key_positions.unwrap()["mode"]; - assert_eq!(positions[0].id, id_a); - assert_eq!(positions[1].id, id_b); - } - - #[test] - fn v1_paired_slot_match_wins_over_earlier_value_steal() { - let store = keyed_store( - vec![KeySlot::from("A"), KeySlot::from("B")], - vec![position(1.0), position(1.0)], - ); - let id_a = store.key_positions["mode"][0].id.clone(); - let id_b = store.key_positions["mode"][1].id.clone(); - let mut patch = paired_patch( - vec![KeySlot::from("C"), KeySlot::from("A")], - vec![position(1.0), position(1.0)], - ); - - prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); - - // 슬롯 정확 일치(A)가 먼저 소진되고, 새 슬롯 C는 남은 값 승계를 받는다 - let positions = &patch.key_positions.unwrap()["mode"]; - assert_eq!(positions[1].id, id_a); - assert_eq!(positions[0].id, id_b); - } - - #[test] - fn v1_paired_slot_keeps_id_when_only_the_position_value_changes() { - let store = keyed_store( - vec![KeySlot::from("A"), KeySlot::from("B")], - vec![position(1.0), position(2.0)], - ); - let id_a = store.key_positions["mode"][0].id.clone(); - let id_b = store.key_positions["mode"][1].id.clone(); - let mut patch = paired_patch( - vec![KeySlot::from("A"), KeySlot::from("B")], - vec![position(50.0), position(2.0)], - ); - - prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); - - // 슬롯이 그대로면 위치 값이 바뀌어도 신원이 유지된다 - 이동은 신원 - // 회전이 아니다 - let positions = &patch.key_positions.unwrap()["mode"]; - assert_eq!(positions[0].id, id_a); - assert_eq!(positions[1].id, id_b); - } - - #[test] - fn v1_paired_duplicate_slots_keep_their_own_ids_by_index() { - let store = keyed_store( - vec![KeySlot::from("A"), KeySlot::from("A")], - vec![position(1.0), position(2.0)], - ); - let first = store.key_positions["mode"][0].id.clone(); - let second = store.key_positions["mode"][1].id.clone(); - let mut patch = paired_patch( - vec![KeySlot::from("A"), KeySlot::from("A")], - vec![position(10.0), position(20.0)], - ); - - prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); - - // 슬롯이 겹쳐도 index 정렬로 각자 자기 ID를 지킨다 - let positions = &patch.key_positions.unwrap()["mode"]; - assert_eq!(positions[0].id, first); - assert_eq!(positions[1].id, second); - } - - #[test] - fn v1_paired_rebind_never_steals_ids_from_other_modes() { - let mut store = AppStoreData { - keys: HashMap::from([ - ("modeA".to_string(), vec![KeySlot::from("X")]), - ("modeB".to_string(), vec![KeySlot::from("X")]), - ]), - key_positions: HashMap::from([ - ("modeA".to_string(), vec![position(1.0)]), - ("modeB".to_string(), vec![position(1.0)]), - ]), - ..AppStoreData::default() - }; - rekey_store_element_ids(&mut store); - let id_a = store.key_positions["modeA"][0].id.clone(); - let id_b = store.key_positions["modeB"][0].id.clone(); - let mut patch = EditorPatchV1 { - keys: Some(HashMap::from([ - ("modeA".to_string(), vec![KeySlot::from("Y")]), - ("modeB".to_string(), vec![KeySlot::from("X")]), - ])), - key_positions: Some(HashMap::from([ - ("modeA".to_string(), vec![position(1.0)]), - ("modeB".to_string(), vec![position(1.0)]), - ])), - ..EditorPatchV1::default() - }; - - prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); - - // modeB는 변경이 없으므로 자기 ID를 지키고, modeA 재바인딩은 - // 같은 모드 값 폴백으로 자기 ID를 유지한다 - let positions = patch.key_positions.unwrap(); - assert_eq!(positions["modeB"][0].id, id_b); - assert_eq!(positions["modeA"][0].id, id_a); - } - - #[test] - fn v1_paired_slot_matching_is_structural_not_canonical() { - let single = KeySlot::from("A+B"); - let multi = KeySlot::Multi { - keys: vec!["A".to_string(), "B".to_string()], - match_mode: SlotMatch::All, - }; - assert_eq!(single.canonical(), multi.canonical()); - let store = keyed_store( - vec![single.clone(), multi.clone()], - vec![position(1.0), position(1.0)], - ); - let id_single = store.key_positions["mode"][0].id.clone(); - let id_multi = store.key_positions["mode"][1].id.clone(); - let mut patch = paired_patch(vec![multi, single], vec![position(1.0), position(1.0)]); - - prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); - - // canonical이 같아도 구조가 다르면 각자의 슬롯을 따라간다 - let positions = &patch.key_positions.unwrap()["mode"]; - assert_eq!(positions[0].id, id_multi); - assert_eq!(positions[1].id, id_single); - } - - #[test] - fn v1_stale_snapshot_id_never_revives_after_deletion() { - let mut store = AppStoreData { - key_positions: HashMap::from([("mode".to_string(), vec![position(1.0)])]), - ..AppStoreData::default() - }; - rekey_store_element_ids(&mut store); - let deleted_id = store.key_positions["mode"][0].id.clone(); - let stale_element = store.key_positions["mode"][0].clone(); - store.key_positions.get_mut("mode").unwrap().clear(); - let mut patch = EditorPatchV1 { - key_positions: Some(HashMap::from([("mode".to_string(), vec![stale_element])])), - ..EditorPatchV1::default() - }; - - prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); - - let replacement_id = &patch.key_positions.unwrap()["mode"][0].id; - assert_ne!(replacement_id, &deleted_id); - assert!(is_valid_element_id(replacement_id)); - } - - #[test] - fn v1_rejects_nil_and_non_uuid_supplied_ids() { - let store = store_with_all_collections(); - for invalid_id in [Uuid::nil().to_string(), "not-a-uuid".to_string()] { - let mut positions = store.key_positions.clone(); - positions.get_mut("mode").unwrap()[0].id = invalid_id; - let mut patch = EditorPatchV1 { - key_positions: Some(positions), - ..EditorPatchV1::default() - }; - assert_eq!( - validation_code(prepare_commit_patch_element_ids(&store, &mut patch).unwrap_err()), - INVALID_ELEMENT_ID - ); - } - } - - #[test] - fn v1_duplicating_a_read_element_rekeys_the_copy_instead_of_failing() { - let store = store_with_all_collections(); - let original = store.key_positions["mode"][0].id.clone(); - // 플러그인의 관용 패턴: 읽어온 위치 객체를 펼쳐 복사해 배열에 덧붙인다 - let mut positions = store.key_positions.clone(); - let mut copy = positions["mode"][0].clone(); - copy.dx = 300.0; - positions.get_mut("mode").unwrap().push(copy); - let mut patch = EditorPatchV1 { - key_positions: Some(positions), - ..EditorPatchV1::default() - }; - - prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); - - let positions = &patch.key_positions.unwrap()["mode"]; - assert_eq!(positions[0].id, original); - assert_ne!(positions[2].id, original); - assert!(is_valid_element_id(&positions[2].id)); - } - - #[test] - fn v1_supplied_id_from_another_collection_is_rekeyed() { - let store = store_with_all_collections(); - let stat_id = store.stat_positions["mode"][0].position.id.clone(); - let mut positions = store.key_positions.clone(); - positions.get_mut("mode").unwrap()[0].id = stat_id.clone(); - let mut patch = EditorPatchV1 { - key_positions: Some(positions), - ..EditorPatchV1::default() - }; - - prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); - - // 다른 컬렉션의 신원을 키 위치로 이식할 수 없다 - let positions = &patch.key_positions.unwrap()["mode"]; - assert_ne!(positions[0].id, stat_id); - assert!(is_valid_element_id(&positions[0].id)); - } -} +mod tests; diff --git a/src-tauri/src/state/native_element_id/tests.rs b/src-tauri/src/state/native_element_id/tests.rs new file mode 100644 index 000000000..50b1f1577 --- /dev/null +++ b/src-tauri/src/state/native_element_id/tests.rs @@ -0,0 +1,718 @@ +use std::collections::{HashMap, HashSet}; + +use crate::models::{ + AppStoreData, EditorPatchV1, GraphPosition, GraphStatType, GraphType, KeyPosition, + KnobPosition, SlotMatch, StatPosition, StatType, +}; + +use super::*; + +fn position(dx: f64) -> KeyPosition { + KeyPosition { + dx, + ..KeyPosition::default() + } +} + +fn stat_position(stat_type: StatType, dx: f64) -> StatPosition { + StatPosition { + stat_type, + position: position(dx), + } +} + +fn store_with_all_collections() -> AppStoreData { + let mut store = AppStoreData { + key_positions: HashMap::from([("mode".to_string(), vec![position(1.0), position(2.0)])]), + stat_positions: HashMap::from([( + "mode".to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: position(3.0), + }], + )]), + graph_positions: HashMap::from([( + "mode".to_string(), + vec![GraphPosition { + stat_type: GraphStatType::KpsAvg, + graph_type: GraphType::Line, + graph_speed: 100, + graph_color: "#123456".to_string(), + show_avg_line: true, + position: position(4.0), + }], + )]), + knob_positions: HashMap::from([( + "mode".to_string(), + vec![KnobPosition { + axis_id: "axis".to_string(), + sensitivity: 1.0, + reverse: false, + position: position(5.0), + }], + )]), + ..AppStoreData::default() + }; + rekey_store_element_ids(&mut store); + store +} + +fn all_ids(store: &AppStoreData) -> Vec { + let document = EditorDocumentV1::from_store(store); + let mut ids = Vec::new(); + for positions in document.key_positions.values() { + ids.extend(positions.iter().map(|position| position.id.clone())); + } + for positions in document.stat_positions.values() { + ids.extend( + positions + .iter() + .map(|position| position.position.id.clone()), + ); + } + for positions in document.graph_positions.values() { + ids.extend( + positions + .iter() + .map(|position| position.position.id.clone()), + ); + } + for positions in document.knob_positions.values() { + ids.extend( + positions + .iter() + .map(|position| position.position.id.clone()), + ); + } + ids +} + +fn validation_code(error: EditorCommitError) -> String { + error + .details + .and_then(|details| details.validation_code) + .unwrap() +} + +#[test] +fn backfill_replaces_only_missing_invalid_and_duplicate_ids() { + let mut store = store_with_all_collections(); + let kept_id = store.key_positions["mode"][0].id.clone(); + store.key_positions.get_mut("mode").unwrap()[0].active_image = + Some("/images/kept.png".to_string()); + store.key_positions.get_mut("mode").unwrap()[0].sound_path = + Some("/sounds/kept.wav".to_string()); + store.key_positions.get_mut("mode").unwrap()[1].id = kept_id.clone(); + store.stat_positions.get_mut("mode").unwrap()[0].position.id = "not-a-uuid".to_string(); + store.graph_positions.get_mut("mode").unwrap()[0] + .position + .id + .clear(); + + let outcome = backfill_store_element_ids(&mut store); + + assert_eq!( + outcome, + BackfillOutcome { + changed: true, + repaired: true + } + ); + assert_eq!(store.key_positions["mode"][0].id, kept_id); + assert_eq!( + store.key_positions["mode"][0].active_image.as_deref(), + Some("/images/kept.png") + ); + assert_eq!( + store.key_positions["mode"][0].sound_path.as_deref(), + Some("/sounds/kept.wav") + ); + let ids = all_ids(&store); + assert!(ids.iter().all(|id| is_valid_element_id(id))); + assert_eq!(ids.iter().collect::>().len(), ids.len()); +} + +#[test] +fn full_and_mode_rekey_create_fresh_globally_unique_generations() { + let mut store = store_with_all_collections(); + let first = all_ids(&store).into_iter().collect::>(); + rekey_store_element_ids(&mut store); + let second = all_ids(&store).into_iter().collect::>(); + rekey_mode_element_ids(&mut store, "mode"); + let third = all_ids(&store).into_iter().collect::>(); + + assert!(first.is_disjoint(&second)); + assert!(second.is_disjoint(&third)); + assert_eq!(third.len(), 5); +} + +#[test] +fn v2_requires_valid_ids_and_checks_merged_global_uniqueness() { + let store = store_with_all_collections(); + let mut valid = EditorPatchV1 { + schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, + key_positions: Some(store.key_positions.clone()), + ..EditorPatchV1::default() + }; + prepare_commit_patch_element_ids(&store, &mut valid).unwrap(); + + let mut missing = valid.clone(); + missing + .key_positions + .as_mut() + .unwrap() + .get_mut("mode") + .unwrap()[0] + .id + .clear(); + assert_eq!( + validation_code(prepare_commit_patch_element_ids(&store, &mut missing).unwrap_err()), + MISSING_ELEMENT_ID + ); + + for invalid_id in [Uuid::nil().to_string(), "not-a-uuid".to_string()] { + let mut invalid = valid.clone(); + invalid + .key_positions + .as_mut() + .unwrap() + .get_mut("mode") + .unwrap()[0] + .id = invalid_id; + assert_eq!( + validation_code(prepare_commit_patch_element_ids(&store, &mut invalid).unwrap_err()), + INVALID_ELEMENT_ID + ); + } + + let mut merged_duplicate = valid; + merged_duplicate + .key_positions + .as_mut() + .unwrap() + .get_mut("mode") + .unwrap()[0] + .id = store.stat_positions["mode"][0].position.id.clone(); + assert_eq!( + validation_code( + prepare_commit_patch_element_ids(&store, &mut merged_duplicate).unwrap_err() + ), + DUPLICATE_ELEMENT_ID + ); +} + +#[test] +fn v1_preserves_explicit_current_ids_and_rekeys_stale_ids() { + let store = store_with_all_collections(); + let current_id = store.key_positions["mode"][0].id.clone(); + let stale_id = Uuid::new_v4().to_string(); + let mut positions = store.key_positions.clone(); + positions.get_mut("mode").unwrap()[0].dx = 99.0; + positions.get_mut("mode").unwrap()[1].id = stale_id.clone(); + let mut patch = EditorPatchV1 { + key_positions: Some(positions), + ..EditorPatchV1::default() + }; + + prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); + + let positions = &patch.key_positions.unwrap()["mode"]; + assert_eq!(positions[0].id, current_id); + assert_ne!(positions[1].id, stale_id); + assert!(is_valid_element_id(&positions[1].id)); +} + +#[test] +fn v1_idless_reorder_append_and_tie_groups_are_deterministic() { + let mut store = AppStoreData { + key_positions: HashMap::from([( + "mode".to_string(), + vec![position(1.0), position(1.0), position(2.0)], + )]), + ..AppStoreData::default() + }; + rekey_store_element_ids(&mut store); + let original_ids = store.key_positions["mode"] + .iter() + .map(|position| position.id.clone()) + .collect::>(); + let mut candidate = vec![position(1.0), position(1.0), position(2.0), position(3.0)]; + candidate.swap(0, 2); + let mut first = EditorPatchV1 { + key_positions: Some(HashMap::from([("mode".to_string(), candidate.clone())])), + ..EditorPatchV1::default() + }; + + prepare_commit_patch_element_ids(&store, &mut first).unwrap(); + let assigned = first.key_positions.as_ref().unwrap()["mode"] + .iter() + .map(|position| position.id.clone()) + .collect::>(); + assert_eq!(assigned[0], original_ids[2]); + assert_eq!(assigned[1], original_ids[0]); + assert_eq!(assigned[2], original_ids[1]); + assert!(!original_ids.contains(&assigned[3])); + + let mut canonical = store.clone(); + canonical.key_positions = first.key_positions.unwrap(); + let mut repeated = EditorPatchV1 { + key_positions: Some(HashMap::from([("mode".to_string(), candidate)])), + ..EditorPatchV1::default() + }; + prepare_commit_patch_element_ids(&canonical, &mut repeated).unwrap(); + let repeated_ids = repeated.key_positions.unwrap()["mode"] + .iter() + .map(|position| position.id.clone()) + .collect::>(); + assert_eq!(repeated_ids, assigned); +} + +#[test] +fn v1_mixed_attribute_edit_reorder_and_append_succeeds() { + let mut store = AppStoreData { + key_positions: HashMap::from([("mode".to_string(), vec![position(1.0), position(2.0)])]), + ..AppStoreData::default() + }; + rekey_store_element_ids(&mut store); + let first_id = store.key_positions["mode"][0].id.clone(); + let second_id = store.key_positions["mode"][1].id.clone(); + let mut edited = store.key_positions["mode"][1].clone(); + edited.dx = 20.0; + let mut patch = EditorPatchV1 { + key_positions: Some(HashMap::from([( + "mode".to_string(), + vec![edited, position(1.0), position(3.0)], + )])), + ..EditorPatchV1::default() + }; + + prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); + + let positions = &patch.key_positions.unwrap()["mode"]; + assert_eq!(positions[0].id, second_id); + assert_eq!(positions[1].id, first_id); + assert!(is_valid_element_id(&positions[2].id)); + assert_ne!(positions[2].id, first_id); + assert_ne!(positions[2].id, second_id); +} + +#[test] +fn v1_idless_positions_only_value_change_keeps_ids_by_index() { + let store = store_with_all_collections(); + let original = store.key_positions["mode"] + .iter() + .map(|position| position.id.clone()) + .collect::>(); + let mut patch = EditorPatchV1 { + key_positions: Some(HashMap::from([( + "mode".to_string(), + vec![position(50.0), position(2.0)], + )])), + ..EditorPatchV1::default() + }; + + prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); + + // 같은 길이 positions 단독 패치는 index가 신원 - 값이 바뀐 요소도 ID를 + // 유지해 id 순서가 보존되고 paired id-order 검사와 충돌하지 않는다 + let positions = &patch.key_positions.unwrap()["mode"]; + assert_eq!(positions[0].id, original[0]); + assert_eq!(positions[1].id, original[1]); +} + +#[test] +fn v1_idless_positions_only_swap_is_treated_as_in_place_edits() { + let store = store_with_all_collections(); + let original = store.key_positions["mode"] + .iter() + .map(|position| position.id.clone()) + .collect::>(); + let mut patch = EditorPatchV1 { + key_positions: Some(HashMap::from([( + "mode".to_string(), + vec![position(2.0), position(1.0)], + )])), + ..EditorPatchV1::default() + }; + + prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); + + // 같은 길이 값 스왑은 두 건의 제자리 값 편집으로 해석 - keys 미동반 + // 패치가 신원 재배열로 번지지 않는다 + let positions = &patch.key_positions.unwrap()["mode"]; + assert_eq!(positions[0].id, original[0]); + assert_eq!(positions[1].id, original[1]); +} + +#[test] +fn v1_idless_equal_length_delete_and_append_does_not_reuse_the_deleted_id() { + let mut store = AppStoreData { + stat_positions: HashMap::from([( + "mode".to_string(), + vec![ + stat_position(StatType::Kps, 1.0), + stat_position(StatType::Kps, 2.0), + stat_position(StatType::Kps, 3.0), + ], + )]), + ..AppStoreData::default() + }; + rekey_store_element_ids(&mut store); + let ids = store.stat_positions["mode"] + .iter() + .map(|element| element.position.id.clone()) + .collect::>(); + // 가운데를 지우고 새 요소를 덧붙인다 - 길이는 그대로다 + let mut patch = EditorPatchV1 { + stat_positions: Some(HashMap::from([( + "mode".to_string(), + vec![ + stat_position(StatType::Kps, 1.0), + stat_position(StatType::Kps, 3.0), + stat_position(StatType::Kps, 9.0), + ], + )])), + ..EditorPatchV1::default() + }; + + prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); + + // 살아남은 요소는 값으로 자기 신원을 지키고, 신규 요소는 삭제된 + // 요소의 신원을 물려받지 않는다 + let positions = &patch.stat_positions.unwrap()["mode"]; + assert_eq!(positions[0].position.id, ids[0]); + assert_eq!(positions[1].position.id, ids[2]); + assert!(!ids.contains(&positions[2].position.id)); + assert!(is_valid_element_id(&positions[2].position.id)); +} + +#[test] +fn v1_paired_value_swap_keeps_ids_with_their_slots() { + let store = keyed_store( + vec![KeySlot::from("A"), KeySlot::from("B")], + vec![position(1.0), position(2.0)], + ); + let id_a = store.key_positions["mode"][0].id.clone(); + let id_b = store.key_positions["mode"][1].id.clone(); + let mut patch = paired_patch( + vec![KeySlot::from("A"), KeySlot::from("B")], + vec![position(2.0), position(1.0)], + ); + + prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); + + // 슬롯이 그대로면 좌표 맞바꿈은 제자리 값 편집이다 - keys 미동반 + // 패치와 같은 결과를 낸다 + let positions = &patch.key_positions.unwrap()["mode"]; + assert_eq!(positions[0].id, id_a); + assert_eq!(positions[1].id, id_b); +} + +#[test] +fn v1_idless_patch_prefers_same_mode_and_never_steals_across_modes() { + let mut store = AppStoreData { + stat_positions: HashMap::from([ + ("modeA".to_string(), Vec::new()), + ( + "modeB".to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: position(10.0), + }], + ), + ]), + ..AppStoreData::default() + }; + rekey_store_element_ids(&mut store); + let id_b = store.stat_positions["modeB"][0].position.id.clone(); + let mut patch = EditorPatchV1 { + stat_positions: Some(HashMap::from([ + ( + "modeA".to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: position(10.0), + }], + ), + ( + "modeB".to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: position(10.0), + }], + ), + ])), + ..EditorPatchV1::default() + }; + + prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); + + // 레이아웃을 다른 모드로 복사해도 원본 모드가 자기 ID를 지킨다 + let positions = patch.stat_positions.unwrap(); + assert_eq!(positions["modeB"][0].position.id, id_b); + assert_ne!(positions["modeA"][0].position.id, id_b); + assert!(is_valid_element_id(&positions["modeA"][0].position.id)); +} + +fn keyed_store(slots: Vec, positions: Vec) -> AppStoreData { + let mut store = AppStoreData { + keys: HashMap::from([("mode".to_string(), slots)]), + key_positions: HashMap::from([("mode".to_string(), positions)]), + ..AppStoreData::default() + }; + rekey_store_element_ids(&mut store); + store +} + +fn paired_patch(slots: Vec, positions: Vec) -> EditorPatchV1 { + EditorPatchV1 { + keys: Some(HashMap::from([("mode".to_string(), slots)])), + key_positions: Some(HashMap::from([("mode".to_string(), positions)])), + ..EditorPatchV1::default() + } +} + +#[test] +fn v1_paired_reorder_moves_ids_with_their_key_slots() { + let store = keyed_store( + vec![KeySlot::from("A"), KeySlot::from("B")], + vec![position(1.0), position(1.0)], + ); + let id_a = store.key_positions["mode"][0].id.clone(); + let id_b = store.key_positions["mode"][1].id.clone(); + let mut patch = paired_patch( + vec![KeySlot::from("B"), KeySlot::from("A")], + vec![position(1.0), position(1.0)], + ); + + prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); + + let positions = &patch.key_positions.unwrap()["mode"]; + assert_eq!(positions[0].id, id_b); + assert_eq!(positions[1].id, id_a); +} + +#[test] +fn v1_paired_rebind_falls_back_to_value_inheritance() { + let store = keyed_store( + vec![KeySlot::from("A"), KeySlot::from("B")], + vec![position(1.0), position(2.0)], + ); + let id_a = store.key_positions["mode"][0].id.clone(); + let id_b = store.key_positions["mode"][1].id.clone(); + let mut patch = paired_patch( + vec![KeySlot::from("A"), KeySlot::from("C")], + vec![position(1.0), position(2.0)], + ); + + prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); + + let positions = &patch.key_positions.unwrap()["mode"]; + assert_eq!(positions[0].id, id_a); + assert_eq!(positions[1].id, id_b); +} + +#[test] +fn v1_paired_slot_match_wins_over_earlier_value_steal() { + let store = keyed_store( + vec![KeySlot::from("A"), KeySlot::from("B")], + vec![position(1.0), position(1.0)], + ); + let id_a = store.key_positions["mode"][0].id.clone(); + let id_b = store.key_positions["mode"][1].id.clone(); + let mut patch = paired_patch( + vec![KeySlot::from("C"), KeySlot::from("A")], + vec![position(1.0), position(1.0)], + ); + + prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); + + // 슬롯 정확 일치(A)가 먼저 소진되고, 새 슬롯 C는 남은 값 승계를 받는다 + let positions = &patch.key_positions.unwrap()["mode"]; + assert_eq!(positions[1].id, id_a); + assert_eq!(positions[0].id, id_b); +} + +#[test] +fn v1_paired_slot_keeps_id_when_only_the_position_value_changes() { + let store = keyed_store( + vec![KeySlot::from("A"), KeySlot::from("B")], + vec![position(1.0), position(2.0)], + ); + let id_a = store.key_positions["mode"][0].id.clone(); + let id_b = store.key_positions["mode"][1].id.clone(); + let mut patch = paired_patch( + vec![KeySlot::from("A"), KeySlot::from("B")], + vec![position(50.0), position(2.0)], + ); + + prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); + + // 슬롯이 그대로면 위치 값이 바뀌어도 신원이 유지된다 - 이동은 신원 + // 회전이 아니다 + let positions = &patch.key_positions.unwrap()["mode"]; + assert_eq!(positions[0].id, id_a); + assert_eq!(positions[1].id, id_b); +} + +#[test] +fn v1_paired_duplicate_slots_keep_their_own_ids_by_index() { + let store = keyed_store( + vec![KeySlot::from("A"), KeySlot::from("A")], + vec![position(1.0), position(2.0)], + ); + let first = store.key_positions["mode"][0].id.clone(); + let second = store.key_positions["mode"][1].id.clone(); + let mut patch = paired_patch( + vec![KeySlot::from("A"), KeySlot::from("A")], + vec![position(10.0), position(20.0)], + ); + + prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); + + // 슬롯이 겹쳐도 index 정렬로 각자 자기 ID를 지킨다 + let positions = &patch.key_positions.unwrap()["mode"]; + assert_eq!(positions[0].id, first); + assert_eq!(positions[1].id, second); +} + +#[test] +fn v1_paired_rebind_never_steals_ids_from_other_modes() { + let mut store = AppStoreData { + keys: HashMap::from([ + ("modeA".to_string(), vec![KeySlot::from("X")]), + ("modeB".to_string(), vec![KeySlot::from("X")]), + ]), + key_positions: HashMap::from([ + ("modeA".to_string(), vec![position(1.0)]), + ("modeB".to_string(), vec![position(1.0)]), + ]), + ..AppStoreData::default() + }; + rekey_store_element_ids(&mut store); + let id_a = store.key_positions["modeA"][0].id.clone(); + let id_b = store.key_positions["modeB"][0].id.clone(); + let mut patch = EditorPatchV1 { + keys: Some(HashMap::from([ + ("modeA".to_string(), vec![KeySlot::from("Y")]), + ("modeB".to_string(), vec![KeySlot::from("X")]), + ])), + key_positions: Some(HashMap::from([ + ("modeA".to_string(), vec![position(1.0)]), + ("modeB".to_string(), vec![position(1.0)]), + ])), + ..EditorPatchV1::default() + }; + + prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); + + // modeB는 변경이 없으므로 자기 ID를 지키고, modeA 재바인딩은 + // 같은 모드 값 폴백으로 자기 ID를 유지한다 + let positions = patch.key_positions.unwrap(); + assert_eq!(positions["modeB"][0].id, id_b); + assert_eq!(positions["modeA"][0].id, id_a); +} + +#[test] +fn v1_paired_slot_matching_is_structural_not_canonical() { + let single = KeySlot::from("A+B"); + let multi = KeySlot::Multi { + keys: vec!["A".to_string(), "B".to_string()], + match_mode: SlotMatch::All, + }; + assert_eq!(single.canonical(), multi.canonical()); + let store = keyed_store( + vec![single.clone(), multi.clone()], + vec![position(1.0), position(1.0)], + ); + let id_single = store.key_positions["mode"][0].id.clone(); + let id_multi = store.key_positions["mode"][1].id.clone(); + let mut patch = paired_patch(vec![multi, single], vec![position(1.0), position(1.0)]); + + prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); + + // canonical이 같아도 구조가 다르면 각자의 슬롯을 따라간다 + let positions = &patch.key_positions.unwrap()["mode"]; + assert_eq!(positions[0].id, id_multi); + assert_eq!(positions[1].id, id_single); +} + +#[test] +fn v1_stale_snapshot_id_never_revives_after_deletion() { + let mut store = AppStoreData { + key_positions: HashMap::from([("mode".to_string(), vec![position(1.0)])]), + ..AppStoreData::default() + }; + rekey_store_element_ids(&mut store); + let deleted_id = store.key_positions["mode"][0].id.clone(); + let stale_element = store.key_positions["mode"][0].clone(); + store.key_positions.get_mut("mode").unwrap().clear(); + let mut patch = EditorPatchV1 { + key_positions: Some(HashMap::from([("mode".to_string(), vec![stale_element])])), + ..EditorPatchV1::default() + }; + + prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); + + let replacement_id = &patch.key_positions.unwrap()["mode"][0].id; + assert_ne!(replacement_id, &deleted_id); + assert!(is_valid_element_id(replacement_id)); +} + +#[test] +fn v1_rejects_nil_and_non_uuid_supplied_ids() { + let store = store_with_all_collections(); + for invalid_id in [Uuid::nil().to_string(), "not-a-uuid".to_string()] { + let mut positions = store.key_positions.clone(); + positions.get_mut("mode").unwrap()[0].id = invalid_id; + let mut patch = EditorPatchV1 { + key_positions: Some(positions), + ..EditorPatchV1::default() + }; + assert_eq!( + validation_code(prepare_commit_patch_element_ids(&store, &mut patch).unwrap_err()), + INVALID_ELEMENT_ID + ); + } +} + +#[test] +fn v1_duplicating_a_read_element_rekeys_the_copy_instead_of_failing() { + let store = store_with_all_collections(); + let original = store.key_positions["mode"][0].id.clone(); + // 플러그인의 관용 패턴: 읽어온 위치 객체를 펼쳐 복사해 배열에 덧붙인다 + let mut positions = store.key_positions.clone(); + let mut copy = positions["mode"][0].clone(); + copy.dx = 300.0; + positions.get_mut("mode").unwrap().push(copy); + let mut patch = EditorPatchV1 { + key_positions: Some(positions), + ..EditorPatchV1::default() + }; + + prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); + + let positions = &patch.key_positions.unwrap()["mode"]; + assert_eq!(positions[0].id, original); + assert_ne!(positions[2].id, original); + assert!(is_valid_element_id(&positions[2].id)); +} + +#[test] +fn v1_supplied_id_from_another_collection_is_rekeyed() { + let store = store_with_all_collections(); + let stat_id = store.stat_positions["mode"][0].position.id.clone(); + let mut positions = store.key_positions.clone(); + positions.get_mut("mode").unwrap()[0].id = stat_id.clone(); + let mut patch = EditorPatchV1 { + key_positions: Some(positions), + ..EditorPatchV1::default() + }; + + prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); + + // 다른 컬렉션의 신원을 키 위치로 이식할 수 없다 + let positions = &patch.key_positions.unwrap()["mode"]; + assert_ne!(positions[0].id, stat_id); + assert!(is_valid_element_id(&positions[0].id)); +} From 4dfe3ef106e5940851c24a126a0584c6819a710a Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 16:51:48 +0900 Subject: [PATCH 143/245] =?UTF-8?q?refactor:=20=EC=97=90=EB=94=94=ED=84=B0?= =?UTF-8?q?=20=EC=9A=94=EC=B2=AD=20=EA=B2=80=EC=A6=9D=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/state/editor.rs | 867 +------------------------- src-tauri/src/state/editor/request.rs | 867 ++++++++++++++++++++++++++ 2 files changed, 881 insertions(+), 853 deletions(-) create mode 100644 src-tauri/src/state/editor/request.rs diff --git a/src-tauri/src/state/editor.rs b/src-tauri/src/state/editor.rs index 2de3629aa..89d198079 100644 --- a/src-tauri/src/state/editor.rs +++ b/src-tauri/src/state/editor.rs @@ -1,21 +1,28 @@ use std::collections::{BTreeSet, HashMap, HashSet}; -use serde::Serialize; -use serde_json::Value; -use sha2::{Digest, Sha256}; use uuid::Uuid; use crate::{ defaults::default_keys, errors::EditorCommitError, models::{ - AppStoreData, CustomTab, EditorBoundsV1, EditorCommitRequest, EditorDocumentV1, - EditorElementTypeV1, EditorField, ElementShadowSpec, GraphPosition, KeyCounters, - KeyMappings, KeyPosition, KeySlot, KnobPosition, StatPosition, - EDITOR_COMMIT_SCHEMA_VERSION_V2, EDITOR_OPS_VERSION, EDITOR_SCHEMA_VERSION, + AppStoreData, EditorBoundsV1, EditorDocumentV1, EditorElementTypeV1, EditorField, + ElementShadowSpec, GraphPosition, KeyCounters, KeyMappings, KeyPosition, KeySlot, + KnobPosition, StatPosition, EDITOR_SCHEMA_VERSION, }, }; +#[cfg(test)] +use crate::models::EDITOR_OPS_VERSION; + +mod request; + +pub(crate) use request::{ + canonical_request_fingerprint, decode_editor_commit_request, decode_exact_frozen_insert, + next_revision, request_fingerprint, request_payload_size, validate_history_restore_metadata, + validate_request_envelope, validate_revision, RequestFingerprint, +}; + // JS Number.MAX_SAFE_INTEGER - 프론트와 오가는 모든 u64 리비전의 공통 wire 상한 pub(crate) const MAX_SAFE_WIRE_REVISION: u64 = 9_007_199_254_740_991; pub(crate) const MUTATION_ACK_CAPACITY: usize = 32; @@ -55,31 +62,6 @@ pub(crate) fn is_valid_group_id_shape(group_id: &str) -> bool { !group_id.is_empty() && group_id.len() <= MAX_GROUP_ID_BYTES } -pub(crate) type RequestFingerprint = [u8; 32]; - -const EDITOR_COMMIT_REQUEST_KEYS: &[&str] = &[ - "baseRevision", - "mutationId", - "multiKey", - "gestureId", - "gestureIds", - "changes", - "opsVersion", - "ops", -]; - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct FingerprintPayload<'a> { - base_revision: u64, - multi_key: bool, - gesture_id: Option<&'a str>, - gesture_ids: &'a [String], - changes: &'a Option, - ops_version: &'a Option, - ops: &'a Option>, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] enum NativeElementKind { Key, @@ -182,827 +164,6 @@ impl Ord for ValidationViolation { } } -pub(crate) fn decode_editor_commit_request( - value: Value, -) -> Result { - let Some(object) = value.as_object() else { - return Err(EditorCommitError::validation( - "INVALID_REQUEST_PAYLOAD", - "editor request must be an object", - )); - }; - - if let Some(key) = object - .keys() - .find(|key| !EDITOR_COMMIT_REQUEST_KEYS.contains(&key.as_str())) - { - return Err(EditorCommitError::validation( - "INVALID_REQUEST_PAYLOAD", - format!("editor request contains unknown key '{key}'"), - )); - } - - if ["changes", "opsVersion", "ops"] - .into_iter() - .any(|key| object.get(key).is_some_and(Value::is_null)) - { - return Err(EditorCommitError::validation( - "INVALID_EDITOR_MUTATION", - "editor mutation fields cannot be null", - )); - } - - let has_changes = object.contains_key("changes"); - let has_ops_version = object.contains_key("opsVersion"); - let has_ops = object.contains_key("ops"); - let has_patch_mutation = has_changes && !has_ops_version && !has_ops; - let has_ops_mutation = !has_changes && has_ops_version && has_ops; - if !has_patch_mutation && !has_ops_mutation { - return Err(EditorCommitError::validation( - "INVALID_EDITOR_MUTATION", - "editor request must contain changes or the opsVersion and ops pair", - )); - } - - let has_frozen_insert = value - .get("ops") - .and_then(Value::as_array) - .is_some_and(|ops| { - ops.iter() - .any(|op| op.get("kind").and_then(Value::as_str) == Some("insertFrozenElements")) - }); - if !has_frozen_insert { - return serde_json::from_value(value).map_err(|error| { - EditorCommitError::validation( - "INVALID_REQUEST_PAYLOAD", - format!("invalid editor request: {error}"), - ) - }); - } - - decode_exact_frozen_insert(value, "editor") -} - -pub(crate) fn decode_exact_frozen_insert( - value: Value, - label: &str, -) -> Result -where - T: serde::de::DeserializeOwned + Serialize, -{ - let raw_value = value.clone(); - let request: T = serde_json::from_value(value).map_err(|error| { - EditorCommitError::validation( - "INVALID_REQUEST_PAYLOAD", - format!("invalid {label} request: {error}"), - ) - })?; - let canonical = serde_json::to_value(&request).map_err(|error| { - EditorCommitError::validation( - "INVALID_REQUEST_PAYLOAD", - format!("could not inspect decoded {label} request: {error}"), - ) - })?; - if let Some(path) = first_unknown_json_key(&raw_value, &canonical, label) { - return Err(EditorCommitError::validation( - "INVALID_REQUEST_PAYLOAD", - format!("{label} request contains unknown key '{path}'"), - )); - } - Ok(request) -} - -fn carries_value(value: &Value) -> bool { - match value { - Value::Null => false, - Value::Array(items) => !items.is_empty(), - _ => true, - } -} - -fn first_unknown_json_key(raw: &Value, canonical: &Value, path: &str) -> Option { - match (raw, canonical) { - (Value::Object(raw), Value::Object(canonical)) => raw.iter().find_map(|(key, value)| { - let child_path = format!("{path}.{key}"); - canonical.get(key).map_or_else( - // None·빈 배열은 재직렬화에서 생략된다(skip_serializing_if). - // 프론트가 명시한 null·[]은 같은 뜻이고 실린 값이 없으므로 - // 미지의 키로 보지 않는다 - 값이 있는 키만 거절 - || carries_value(value).then(|| child_path.clone()), - |canonical| first_unknown_json_key(value, canonical, &child_path), - ) - }), - (Value::Array(raw), Value::Array(canonical)) => { - if raw.len() != canonical.len() { - return Some(path.to_string()); - } - raw.iter() - .zip(canonical) - .enumerate() - .find_map(|(index, (value, canonical))| { - first_unknown_json_key(value, canonical, &format!("{path}[{index}]")) - }) - } - (Value::Object(_) | Value::Array(_), _) => Some(path.to_string()), - _ => None, - } -} - -pub(crate) fn validate_request_envelope( - request: &EditorCommitRequest, -) -> Result<(), EditorCommitError> { - validate_revision(request.base_revision)?; - - match (&request.changes, request.ops_version, &request.ops) { - (Some(changes), None, None) => { - if !matches!( - changes.schema_version, - EDITOR_SCHEMA_VERSION | EDITOR_COMMIT_SCHEMA_VERSION_V2 - ) { - return Err(EditorCommitError::validation( - "UNSUPPORTED_SCHEMA_VERSION", - format!( - "unsupported editor schema version {}", - changes.schema_version - ), - )); - } - } - (None, Some(version), Some(ops)) => { - if version != EDITOR_OPS_VERSION { - return Err(EditorCommitError::validation( - "UNSUPPORTED_OPS_VERSION", - format!("unsupported editor ops version {version}"), - )); - } - if ops.is_empty() { - return Err(EditorCommitError::validation( - "EMPTY_EDITOR_OPS", - "editor ops must contain at least one operation", - )); - } - if ops.len() > MAX_EDITOR_OPS { - return Err(EditorCommitError::validation( - "TOO_MANY_EDITOR_OPS", - format!("editor op count exceeds {MAX_EDITOR_OPS}"), - )); - } - - let frozen_insert_count = ops - .iter() - .filter(|op| matches!(op, crate::models::EditorOpV1::InsertFrozenElements { .. })) - .count(); - if frozen_insert_count > 0 && (ops.len() != 1 || frozen_insert_count != 1) { - return Err(EditorCommitError::validation( - "INVALID_FROZEN_INSERT_BATCH", - "insertFrozenElements must be the only editor op", - )); - } - - let reorder_count = ops - .iter() - .filter(|op| matches!(op, crate::models::EditorOpV1::ReorderElements { .. })) - .count(); - if reorder_count > 0 && (ops.len() != 1 || reorder_count != 1) { - return Err(EditorCommitError::validation( - "INVALID_REORDER_BATCH", - "reorderElements must be the only editor op", - )); - } - - let group_structural_count = ops - .iter() - .filter(|op| { - matches!( - op, - crate::models::EditorOpV1::SetElementGroups { .. } - | crate::models::EditorOpV1::RenameLayerGroup { .. } - ) - }) - .count(); - if group_structural_count > 0 && (ops.len() != 1 || group_structural_count != 1) { - return Err(EditorCommitError::validation( - "INVALID_GROUP_STRUCTURAL_BATCH", - "group structural operations must be the only editor op", - )); - } - - let mut ids = HashSet::with_capacity(ops.len()); - for op in ops { - let Some(id) = op.target_id() else { - match op { - crate::models::EditorOpV1::InsertFrozenElements { .. } => { - validate_frozen_insert_envelope(op)?; - } - crate::models::EditorOpV1::ReorderElements { .. } => { - validate_reorder_envelope(op)?; - } - crate::models::EditorOpV1::SetElementGroups { .. } - | crate::models::EditorOpV1::RenameLayerGroup { .. } => { - validate_group_structural_envelope(op)?; - } - _ => {} - } - continue; - }; - if let crate::models::EditorOpV1::SetKeySlot { slot, .. } = op { - validate_key_slot(slot, "INVALID_KEY_SLOT", "setKeySlot")?; - } - if !crate::state::native_element_id::is_valid_element_id(id) { - return Err(EditorCommitError::validation( - crate::state::native_element_id::INVALID_ELEMENT_ID, - format!("editor op target '{id}' has an invalid ID"), - )); - } - if !ids.insert(id) { - return Err(EditorCommitError::validation( - "DUPLICATE_EDITOR_OP_TARGET", - format!("editor op target '{id}' appears more than once"), - )); - } - } - } - _ => { - return Err(EditorCommitError::validation( - "INVALID_EDITOR_MUTATION", - "editor request must contain exactly one of changes or ops", - )); - } - } - - if request.mutation_id.len() > MAX_MUTATION_ID_BYTES - || Uuid::parse_str(&request.mutation_id).is_err() - { - return Err(EditorCommitError::validation( - "INVALID_MUTATION_ID", - "mutationId must be a UUID no longer than 64 bytes", - )); - } - - if request.gesture_ids.len() > MAX_GESTURE_IDS - || request - .gesture_ids - .iter() - .chain(request.gesture_id.iter()) - .collect::>() - .len() - > MAX_GESTURE_IDS - { - return Err(EditorCommitError::too_many_gesture_ids(MAX_GESTURE_IDS)); - } - - if request - .gesture_id - .iter() - .chain(request.gesture_ids.iter()) - .any(|gesture_id| !is_valid_gesture_id(gesture_id)) - { - return Err(EditorCommitError::invalid_gesture_id()); - } - - Ok(()) -} - -fn validate_frozen_insert_envelope( - op: &crate::models::EditorOpV1, -) -> Result<(), EditorCommitError> { - use crate::models::EditorOpV1; - - let EditorOpV1::InsertFrozenElements { - mode, - elements, - groups, - z_updates, - } = op - else { - return Ok(()); - }; - - if mode.is_empty() || mode.len() > MAX_MODE_ID_BYTES { - return Err(EditorCommitError::validation( - "INVALID_MODE_ID", - "insertFrozenElements mode must be non-empty and within the mode ID limit", - )); - } - if elements.is_empty() && z_updates.is_empty() { - return Err(EditorCommitError::validation( - "EMPTY_FROZEN_INSERT_BATCH", - "insertFrozenElements must contain elements or zUpdates", - )); - } - for (label, count) in [ - ("elements", elements.len()), - ("groups", groups.len()), - ("zUpdates", z_updates.len()), - ] { - if count > MAX_RENDER_ITEMS { - return Err(EditorCommitError::validation( - "FROZEN_INSERT_BATCH_TOO_LARGE", - format!("insertFrozenElements {label} count exceeds {MAX_RENDER_ITEMS}"), - )); - } - } - let mut inserted_ids = HashSet::with_capacity(elements.len()); - for element in elements { - if let crate::models::EditorFrozenElementV1::Key { slot, .. } = element { - validate_frozen_key_slot(slot)?; - } - let id = element.id(); - if !crate::state::native_element_id::is_valid_element_id(id) { - return Err(EditorCommitError::validation( - crate::state::native_element_id::INVALID_ELEMENT_ID, - format!("insertFrozenElements element '{id}' has an invalid ID"), - )); - } - if !inserted_ids.insert(id) { - return Err(EditorCommitError::validation( - "DUPLICATE_FROZEN_INSERT_ID", - format!("insertFrozenElements element '{id}' appears more than once"), - )); - } - } - - let mut group_ids = HashSet::with_capacity(groups.len()); - for group in groups { - if !is_valid_group_id_shape(&group.id) { - return Err(EditorCommitError::validation( - "INVALID_GROUP_ID", - "insertFrozenElements group ID is empty or exceeds its limit", - )); - } - if !group_ids.insert(group.id.as_str()) { - return Err(EditorCommitError::validation( - "DUPLICATE_GROUP_ID", - format!( - "insertFrozenElements group '{}' appears more than once", - group.id - ), - )); - } - } - - let mut z_ids = HashSet::with_capacity(z_updates.len()); - for update in z_updates { - if !crate::state::native_element_id::is_valid_element_id(&update.id) { - return Err(EditorCommitError::validation( - crate::state::native_element_id::INVALID_ELEMENT_ID, - format!( - "insertFrozenElements z target '{}' has an invalid ID", - update.id - ), - )); - } - if inserted_ids.contains(update.id.as_str()) { - return Err(EditorCommitError::validation( - "FROZEN_INSERT_Z_TARGET_OVERLAP", - format!( - "insertFrozenElements z target '{}' is also inserted", - update.id - ), - )); - } - if !z_ids.insert(update.id.as_str()) { - return Err(EditorCommitError::validation( - "DUPLICATE_FROZEN_Z_TARGET", - format!( - "insertFrozenElements z target '{}' appears more than once", - update.id - ), - )); - } - } - Ok(()) -} - -fn validate_reorder_envelope(op: &crate::models::EditorOpV1) -> Result<(), EditorCommitError> { - use crate::models::EditorOpV1; - - let EditorOpV1::ReorderElements { - mode, - complete_mode_order, - z_updates, - group_updates, - } = op - else { - return Ok(()); - }; - - if mode.is_empty() || mode.len() > MAX_MODE_ID_BYTES { - return Err(EditorCommitError::validation( - "INVALID_MODE_ID", - "reorderElements mode must be non-empty and within the mode ID limit", - )); - } - if z_updates.is_empty() { - return Err(EditorCommitError::validation( - "EMPTY_REORDER_BATCH", - "reorderElements must contain zUpdates", - )); - } - for (label, count) in [ - ("zUpdates", z_updates.len()), - ("groupUpdates", group_updates.len()), - ] { - if count > MAX_RENDER_ITEMS { - return Err(EditorCommitError::validation( - "REORDER_BATCH_TOO_LARGE", - format!("reorderElements {label} count exceeds {MAX_RENDER_ITEMS}"), - )); - } - } - if !complete_mode_order && !group_updates.is_empty() { - return Err(EditorCommitError::validation( - "INVALID_PARTIAL_REORDER_GROUP_UPDATE", - "partial reorderElements cannot contain groupUpdates", - )); - } - - let mut z_targets = HashMap::with_capacity(z_updates.len()); - for update in z_updates { - if !crate::state::native_element_id::is_valid_element_id(&update.id) { - return Err(EditorCommitError::validation( - crate::state::native_element_id::INVALID_ELEMENT_ID, - format!("reorderElements z target '{}' has an invalid ID", update.id), - )); - } - if z_targets - .insert(update.id.as_str(), update.element_type) - .is_some() - { - return Err(EditorCommitError::validation( - "DUPLICATE_REORDER_Z_TARGET", - format!( - "reorderElements z target '{}' appears more than once", - update.id - ), - )); - } - } - - let mut group_targets = HashSet::with_capacity(group_updates.len()); - for update in group_updates { - if !crate::state::native_element_id::is_valid_element_id(&update.id) { - return Err(EditorCommitError::validation( - crate::state::native_element_id::INVALID_ELEMENT_ID, - format!( - "reorderElements group target '{}' has an invalid ID", - update.id - ), - )); - } - if !group_targets.insert(update.id.as_str()) { - return Err(EditorCommitError::validation( - "DUPLICATE_REORDER_GROUP_TARGET", - format!( - "reorderElements group target '{}' appears more than once", - update.id - ), - )); - } - let Some(z_type) = z_targets.get(update.id.as_str()) else { - return Err(EditorCommitError::validation( - "REORDER_GROUP_TARGET_NOT_IN_ORDER", - format!( - "reorderElements group target '{}' has no matching z target", - update.id - ), - )); - }; - if *z_type != update.element_type { - return Err(EditorCommitError::validation( - "REORDER_TARGET_TYPE_CONFLICT", - format!( - "reorderElements target '{}' uses conflicting element types", - update.id - ), - )); - } - if update - .group_id - .as_ref() - .is_some_and(|group_id| !is_valid_group_id_shape(group_id)) - { - return Err(EditorCommitError::validation( - "INVALID_REORDER_GROUP_ID", - "reorderElements group ID is empty or exceeds its limit", - )); - } - } - Ok(()) -} - -fn validate_group_structural_envelope( - op: &crate::models::EditorOpV1, -) -> Result<(), EditorCommitError> { - use crate::models::{EditorOpV1, EditorTargetGroupV1}; - - let validate_mode = |mode: &str| { - if mode.is_empty() || mode.len() > MAX_MODE_ID_BYTES { - Err(EditorCommitError::validation( - "INVALID_MODE_ID", - "group structural mode must be non-empty and within the mode ID limit", - )) - } else { - Ok(()) - } - }; - let validate_group_id = |group_id: &str| { - if !is_valid_group_id_shape(group_id) { - Err(EditorCommitError::validation( - "INVALID_GROUP_ID", - "group ID must be non-empty and within its limit", - )) - } else { - Ok(()) - } - }; - let validate_group_name = |name: &str| { - if name.is_empty() || name.len() > MAX_GROUP_NAME_BYTES { - Err(EditorCommitError::validation( - "INVALID_GROUP_NAME", - "group name must be non-empty and within its limit", - )) - } else { - Ok(()) - } - }; - - match op { - EditorOpV1::SetElementGroups { - mode, - targets, - target_group, - } => { - validate_mode(mode)?; - // 빈 targets 허용 - plugin-only 그룹 편집은 그룹 def 생성·정리를 - // native 대상 없이 editor op에 실어야 한다 (플러그인 소속은 동반 - // plugin_changes가 운반) - if targets.len() > MAX_RENDER_ITEMS { - return Err(EditorCommitError::validation( - "INVALID_ELEMENT_GROUP_TARGET_COUNT", - format!( - "setElementGroups targets must contain at most {MAX_RENDER_ITEMS} entries" - ), - )); - } - let mut target_ids = HashSet::with_capacity(targets.len()); - for target in targets { - if !crate::state::native_element_id::is_valid_element_id(&target.id) { - return Err(EditorCommitError::validation( - crate::state::native_element_id::INVALID_ELEMENT_ID, - format!("setElementGroups target '{}' has an invalid ID", target.id), - )); - } - if !target_ids.insert(target.id.as_str()) { - return Err(EditorCommitError::validation( - "DUPLICATE_ELEMENT_GROUP_TARGET", - format!( - "setElementGroups target '{}' appears more than once", - target.id - ), - )); - } - } - match target_group { - Some(EditorTargetGroupV1::Existing { id }) => validate_group_id(id)?, - Some(EditorTargetGroupV1::Create { id, name }) => { - validate_group_id(id)?; - validate_group_name(name)?; - } - None => {} - } - } - EditorOpV1::RenameLayerGroup { - mode, - group_id, - name, - } => { - validate_mode(mode)?; - validate_group_id(group_id)?; - validate_group_name(name)?; - } - _ => {} - } - Ok(()) -} - -fn validate_frozen_key_slot( - slot: &crate::models::EditorFrozenKeySlotV1, -) -> Result<(), EditorCommitError> { - validate_key_slot(slot, "INVALID_FROZEN_KEY_SLOT", "insertFrozenElements") -} - -fn validate_key_slot( - slot: &crate::models::EditorFrozenKeySlotV1, - code: &'static str, - operation: &'static str, -) -> Result<(), EditorCommitError> { - let crate::models::EditorFrozenKeySlotV1::Multi(slot) = slot else { - return Ok(()); - }; - let mut members = HashSet::with_capacity(slot.keys.len()); - if !(2..=crate::models::MAX_SLOT_KEYS).contains(&slot.keys.len()) - || slot.keys.iter().any(|member| { - member.is_empty() - || member.contains('+') - || member.contains('|') - || !members.insert(member.as_str()) - }) - { - return Err(EditorCommitError::validation( - code, - format!("{operation} key slot is not canonical"), - )); - } - Ok(()) -} - -pub(crate) fn validate_revision(revision: u64) -> Result<(), EditorCommitError> { - if revision > MAX_SAFE_WIRE_REVISION { - return Err(EditorCommitError::validation( - "REVISION_OUT_OF_RANGE", - "editor revision exceeds JavaScript's safe integer range", - )); - } - Ok(()) -} - -pub(crate) fn next_revision(current: u64) -> Result { - validate_revision(current)?; - current - .checked_add(1) - .filter(|revision| *revision <= MAX_SAFE_WIRE_REVISION) - .ok_or_else(|| { - EditorCommitError::validation( - "REVISION_OUT_OF_RANGE", - "editor revision cannot be incremented safely", - ) - }) -} - -pub(crate) fn validate_history_restore_metadata( - document: &EditorDocumentV1, - custom_tabs: &[CustomTab], - selected_key_type: &str, -) -> Result<(), EditorCommitError> { - if custom_tabs.len() > MAX_CUSTOM_TABS { - return Err(EditorCommitError::validation( - "TOO_MANY_CUSTOM_TABS", - format!("custom tab count exceeds {MAX_CUSTOM_TABS}"), - )); - } - - let built_in_modes = default_keys(); - let mut ids = HashSet::with_capacity(custom_tabs.len()); - let mut names = HashSet::with_capacity(custom_tabs.len()); - for tab in custom_tabs { - let id = tab.id.trim(); - let name = tab.name.trim(); - if id.is_empty() || id.len() > MAX_MODE_ID_BYTES { - return Err(EditorCommitError::validation( - "INVALID_CUSTOM_TAB_ID", - "custom tab id is empty or too long", - )); - } - if name.is_empty() || name.len() > MAX_GROUP_NAME_BYTES { - return Err(EditorCommitError::validation( - "INVALID_CUSTOM_TAB_NAME", - "custom tab name is empty or too long", - )); - } - if built_in_modes.contains_key(id) || !ids.insert(id.to_string()) { - return Err(EditorCommitError::validation( - "DUPLICATE_CUSTOM_TAB_ID", - format!("custom tab id '{id}' is duplicated or reserved"), - )); - } - if !names.insert(name.to_string()) { - return Err(EditorCommitError::validation( - "DUPLICATE_CUSTOM_TAB_NAME", - format!("custom tab name '{name}' is duplicated"), - )); - } - if !document.keys.contains_key(id) || !document.key_positions.contains_key(id) { - return Err(EditorCommitError::validation( - "CUSTOM_TAB_DOCUMENT_MISSING", - format!("custom tab '{id}' has no paired editor collections"), - )); - } - } - - for mode in document.keys.keys() { - if !built_in_modes.contains_key(mode) && !ids.contains(mode) { - return Err(EditorCommitError::validation( - "CUSTOM_TAB_METADATA_MISSING", - format!("editor mode '{mode}' has no custom tab metadata"), - )); - } - } - - let selected_is_valid = - built_in_modes.contains_key(selected_key_type) || ids.contains(selected_key_type); - if !selected_is_valid || !document.keys.contains_key(selected_key_type) { - return Err(EditorCommitError::validation( - "INVALID_SELECTED_MODE", - format!("selected mode '{selected_key_type}' is not restorable"), - )); - } - - Ok(()) -} - -pub(crate) fn request_fingerprint( - request: &EditorCommitRequest, -) -> Result { - canonical_request_fingerprint(&FingerprintPayload { - base_revision: request.base_revision, - multi_key: request.multi_key, - gesture_id: request.gesture_id.as_deref(), - gesture_ids: &request.gesture_ids, - changes: &request.changes, - ops_version: &request.ops_version, - ops: &request.ops, - }) -} - -pub(crate) fn canonical_request_fingerprint( - payload: &impl Serialize, -) -> Result { - let value = serde_json::to_value(payload).map_err(|error| { - EditorCommitError::validation( - "INVALID_REQUEST_PAYLOAD", - format!("failed to serialize editor request: {error}"), - ) - })?; - - let mut canonical = Vec::new(); - write_canonical_json(&value, &mut canonical).map_err(|error| { - EditorCommitError::validation( - "INVALID_REQUEST_PAYLOAD", - format!("failed to canonicalize editor request: {error}"), - ) - })?; - - Ok(Sha256::digest(canonical).into()) -} - -pub(crate) fn request_payload_size( - request: &EditorCommitRequest, -) -> Result { - let compact_size = serde_json::to_vec(request) - .map_err(|error| { - EditorCommitError::validation( - "INVALID_REQUEST_PAYLOAD", - format!("failed to serialize editor request: {error}"), - ) - })? - .len(); - if compact_size > MAX_REQUEST_BYTES { - return Err(EditorCommitError::validation( - "REQUEST_TOO_LARGE", - format!("editor request exceeds the {MAX_REQUEST_BYTES} byte limit"), - )); - } - if compact_size >= REQUEST_WARNING_BYTES { - log::warn!("[Editor] Large editor request: {compact_size} compact bytes"); - } - Ok(compact_size) -} - -fn write_canonical_json(value: &Value, output: &mut Vec) -> serde_json::Result<()> { - match value { - Value::Null => output.extend_from_slice(b"null"), - Value::Bool(value) => output.extend_from_slice(if *value { b"true" } else { b"false" }), - Value::Number(value) => output.extend_from_slice(value.to_string().as_bytes()), - Value::String(value) => output.extend_from_slice(serde_json::to_string(value)?.as_bytes()), - Value::Array(values) => { - output.push(b'['); - for (index, value) in values.iter().enumerate() { - if index > 0 { - output.push(b','); - } - write_canonical_json(value, output)?; - } - output.push(b']'); - } - Value::Object(values) => { - output.push(b'{'); - let mut entries = values.iter().collect::>(); - entries.sort_unstable_by_key(|(key, _)| *key); - for (index, (key, value)) in entries.into_iter().enumerate() { - if index > 0 { - output.push(b','); - } - output.extend_from_slice(serde_json::to_string(key)?.as_bytes()); - output.push(b':'); - write_canonical_json(value, output)?; - } - output.push(b'}'); - } - } - Ok(()) -} - pub(crate) fn validate_paired_update( current: &EditorDocumentV1, candidate: &EditorDocumentV1, diff --git a/src-tauri/src/state/editor/request.rs b/src-tauri/src/state/editor/request.rs new file mode 100644 index 000000000..95dc4121b --- /dev/null +++ b/src-tauri/src/state/editor/request.rs @@ -0,0 +1,867 @@ +use std::collections::{HashMap, HashSet}; + +use serde::Serialize; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::{ + defaults::default_keys, + errors::EditorCommitError, + models::{ + CustomTab, EditorCommitRequest, EditorDocumentV1, EDITOR_COMMIT_SCHEMA_VERSION_V2, + EDITOR_OPS_VERSION, EDITOR_SCHEMA_VERSION, + }, +}; + +use super::{ + is_valid_gesture_id, is_valid_group_id_shape, MAX_CUSTOM_TABS, MAX_EDITOR_OPS, MAX_GESTURE_IDS, + MAX_GROUP_NAME_BYTES, MAX_MODE_ID_BYTES, MAX_MUTATION_ID_BYTES, MAX_RENDER_ITEMS, + MAX_REQUEST_BYTES, MAX_SAFE_WIRE_REVISION, REQUEST_WARNING_BYTES, +}; + +pub(crate) type RequestFingerprint = [u8; 32]; + +const EDITOR_COMMIT_REQUEST_KEYS: &[&str] = &[ + "baseRevision", + "mutationId", + "multiKey", + "gestureId", + "gestureIds", + "changes", + "opsVersion", + "ops", +]; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct FingerprintPayload<'a> { + base_revision: u64, + multi_key: bool, + gesture_id: Option<&'a str>, + gesture_ids: &'a [String], + changes: &'a Option, + ops_version: &'a Option, + ops: &'a Option>, +} + +pub(crate) fn decode_editor_commit_request( + value: Value, +) -> Result { + let Some(object) = value.as_object() else { + return Err(EditorCommitError::validation( + "INVALID_REQUEST_PAYLOAD", + "editor request must be an object", + )); + }; + + if let Some(key) = object + .keys() + .find(|key| !EDITOR_COMMIT_REQUEST_KEYS.contains(&key.as_str())) + { + return Err(EditorCommitError::validation( + "INVALID_REQUEST_PAYLOAD", + format!("editor request contains unknown key '{key}'"), + )); + } + + if ["changes", "opsVersion", "ops"] + .into_iter() + .any(|key| object.get(key).is_some_and(Value::is_null)) + { + return Err(EditorCommitError::validation( + "INVALID_EDITOR_MUTATION", + "editor mutation fields cannot be null", + )); + } + + let has_changes = object.contains_key("changes"); + let has_ops_version = object.contains_key("opsVersion"); + let has_ops = object.contains_key("ops"); + let has_patch_mutation = has_changes && !has_ops_version && !has_ops; + let has_ops_mutation = !has_changes && has_ops_version && has_ops; + if !has_patch_mutation && !has_ops_mutation { + return Err(EditorCommitError::validation( + "INVALID_EDITOR_MUTATION", + "editor request must contain changes or the opsVersion and ops pair", + )); + } + + let has_frozen_insert = value + .get("ops") + .and_then(Value::as_array) + .is_some_and(|ops| { + ops.iter() + .any(|op| op.get("kind").and_then(Value::as_str) == Some("insertFrozenElements")) + }); + if !has_frozen_insert { + return serde_json::from_value(value).map_err(|error| { + EditorCommitError::validation( + "INVALID_REQUEST_PAYLOAD", + format!("invalid editor request: {error}"), + ) + }); + } + + decode_exact_frozen_insert(value, "editor") +} + +pub(crate) fn decode_exact_frozen_insert( + value: Value, + label: &str, +) -> Result +where + T: serde::de::DeserializeOwned + Serialize, +{ + let raw_value = value.clone(); + let request: T = serde_json::from_value(value).map_err(|error| { + EditorCommitError::validation( + "INVALID_REQUEST_PAYLOAD", + format!("invalid {label} request: {error}"), + ) + })?; + let canonical = serde_json::to_value(&request).map_err(|error| { + EditorCommitError::validation( + "INVALID_REQUEST_PAYLOAD", + format!("could not inspect decoded {label} request: {error}"), + ) + })?; + if let Some(path) = first_unknown_json_key(&raw_value, &canonical, label) { + return Err(EditorCommitError::validation( + "INVALID_REQUEST_PAYLOAD", + format!("{label} request contains unknown key '{path}'"), + )); + } + Ok(request) +} + +fn carries_value(value: &Value) -> bool { + match value { + Value::Null => false, + Value::Array(items) => !items.is_empty(), + _ => true, + } +} + +fn first_unknown_json_key(raw: &Value, canonical: &Value, path: &str) -> Option { + match (raw, canonical) { + (Value::Object(raw), Value::Object(canonical)) => raw.iter().find_map(|(key, value)| { + let child_path = format!("{path}.{key}"); + canonical.get(key).map_or_else( + // None·빈 배열은 재직렬화에서 생략된다(skip_serializing_if). + // 프론트가 명시한 null·[]은 같은 뜻이고 실린 값이 없으므로 + // 미지의 키로 보지 않는다 - 값이 있는 키만 거절 + || carries_value(value).then(|| child_path.clone()), + |canonical| first_unknown_json_key(value, canonical, &child_path), + ) + }), + (Value::Array(raw), Value::Array(canonical)) => { + if raw.len() != canonical.len() { + return Some(path.to_string()); + } + raw.iter() + .zip(canonical) + .enumerate() + .find_map(|(index, (value, canonical))| { + first_unknown_json_key(value, canonical, &format!("{path}[{index}]")) + }) + } + (Value::Object(_) | Value::Array(_), _) => Some(path.to_string()), + _ => None, + } +} + +pub(crate) fn validate_request_envelope( + request: &EditorCommitRequest, +) -> Result<(), EditorCommitError> { + validate_revision(request.base_revision)?; + + match (&request.changes, request.ops_version, &request.ops) { + (Some(changes), None, None) => { + if !matches!( + changes.schema_version, + EDITOR_SCHEMA_VERSION | EDITOR_COMMIT_SCHEMA_VERSION_V2 + ) { + return Err(EditorCommitError::validation( + "UNSUPPORTED_SCHEMA_VERSION", + format!( + "unsupported editor schema version {}", + changes.schema_version + ), + )); + } + } + (None, Some(version), Some(ops)) => { + if version != EDITOR_OPS_VERSION { + return Err(EditorCommitError::validation( + "UNSUPPORTED_OPS_VERSION", + format!("unsupported editor ops version {version}"), + )); + } + if ops.is_empty() { + return Err(EditorCommitError::validation( + "EMPTY_EDITOR_OPS", + "editor ops must contain at least one operation", + )); + } + if ops.len() > MAX_EDITOR_OPS { + return Err(EditorCommitError::validation( + "TOO_MANY_EDITOR_OPS", + format!("editor op count exceeds {MAX_EDITOR_OPS}"), + )); + } + + let frozen_insert_count = ops + .iter() + .filter(|op| matches!(op, crate::models::EditorOpV1::InsertFrozenElements { .. })) + .count(); + if frozen_insert_count > 0 && (ops.len() != 1 || frozen_insert_count != 1) { + return Err(EditorCommitError::validation( + "INVALID_FROZEN_INSERT_BATCH", + "insertFrozenElements must be the only editor op", + )); + } + + let reorder_count = ops + .iter() + .filter(|op| matches!(op, crate::models::EditorOpV1::ReorderElements { .. })) + .count(); + if reorder_count > 0 && (ops.len() != 1 || reorder_count != 1) { + return Err(EditorCommitError::validation( + "INVALID_REORDER_BATCH", + "reorderElements must be the only editor op", + )); + } + + let group_structural_count = ops + .iter() + .filter(|op| { + matches!( + op, + crate::models::EditorOpV1::SetElementGroups { .. } + | crate::models::EditorOpV1::RenameLayerGroup { .. } + ) + }) + .count(); + if group_structural_count > 0 && (ops.len() != 1 || group_structural_count != 1) { + return Err(EditorCommitError::validation( + "INVALID_GROUP_STRUCTURAL_BATCH", + "group structural operations must be the only editor op", + )); + } + + let mut ids = HashSet::with_capacity(ops.len()); + for op in ops { + let Some(id) = op.target_id() else { + match op { + crate::models::EditorOpV1::InsertFrozenElements { .. } => { + validate_frozen_insert_envelope(op)?; + } + crate::models::EditorOpV1::ReorderElements { .. } => { + validate_reorder_envelope(op)?; + } + crate::models::EditorOpV1::SetElementGroups { .. } + | crate::models::EditorOpV1::RenameLayerGroup { .. } => { + validate_group_structural_envelope(op)?; + } + _ => {} + } + continue; + }; + if let crate::models::EditorOpV1::SetKeySlot { slot, .. } = op { + validate_key_slot(slot, "INVALID_KEY_SLOT", "setKeySlot")?; + } + if !crate::state::native_element_id::is_valid_element_id(id) { + return Err(EditorCommitError::validation( + crate::state::native_element_id::INVALID_ELEMENT_ID, + format!("editor op target '{id}' has an invalid ID"), + )); + } + if !ids.insert(id) { + return Err(EditorCommitError::validation( + "DUPLICATE_EDITOR_OP_TARGET", + format!("editor op target '{id}' appears more than once"), + )); + } + } + } + _ => { + return Err(EditorCommitError::validation( + "INVALID_EDITOR_MUTATION", + "editor request must contain exactly one of changes or ops", + )); + } + } + + if request.mutation_id.len() > MAX_MUTATION_ID_BYTES + || Uuid::parse_str(&request.mutation_id).is_err() + { + return Err(EditorCommitError::validation( + "INVALID_MUTATION_ID", + "mutationId must be a UUID no longer than 64 bytes", + )); + } + + if request.gesture_ids.len() > MAX_GESTURE_IDS + || request + .gesture_ids + .iter() + .chain(request.gesture_id.iter()) + .collect::>() + .len() + > MAX_GESTURE_IDS + { + return Err(EditorCommitError::too_many_gesture_ids(MAX_GESTURE_IDS)); + } + + if request + .gesture_id + .iter() + .chain(request.gesture_ids.iter()) + .any(|gesture_id| !is_valid_gesture_id(gesture_id)) + { + return Err(EditorCommitError::invalid_gesture_id()); + } + + Ok(()) +} + +fn validate_frozen_insert_envelope( + op: &crate::models::EditorOpV1, +) -> Result<(), EditorCommitError> { + use crate::models::EditorOpV1; + + let EditorOpV1::InsertFrozenElements { + mode, + elements, + groups, + z_updates, + } = op + else { + return Ok(()); + }; + + if mode.is_empty() || mode.len() > MAX_MODE_ID_BYTES { + return Err(EditorCommitError::validation( + "INVALID_MODE_ID", + "insertFrozenElements mode must be non-empty and within the mode ID limit", + )); + } + if elements.is_empty() && z_updates.is_empty() { + return Err(EditorCommitError::validation( + "EMPTY_FROZEN_INSERT_BATCH", + "insertFrozenElements must contain elements or zUpdates", + )); + } + for (label, count) in [ + ("elements", elements.len()), + ("groups", groups.len()), + ("zUpdates", z_updates.len()), + ] { + if count > MAX_RENDER_ITEMS { + return Err(EditorCommitError::validation( + "FROZEN_INSERT_BATCH_TOO_LARGE", + format!("insertFrozenElements {label} count exceeds {MAX_RENDER_ITEMS}"), + )); + } + } + let mut inserted_ids = HashSet::with_capacity(elements.len()); + for element in elements { + if let crate::models::EditorFrozenElementV1::Key { slot, .. } = element { + validate_frozen_key_slot(slot)?; + } + let id = element.id(); + if !crate::state::native_element_id::is_valid_element_id(id) { + return Err(EditorCommitError::validation( + crate::state::native_element_id::INVALID_ELEMENT_ID, + format!("insertFrozenElements element '{id}' has an invalid ID"), + )); + } + if !inserted_ids.insert(id) { + return Err(EditorCommitError::validation( + "DUPLICATE_FROZEN_INSERT_ID", + format!("insertFrozenElements element '{id}' appears more than once"), + )); + } + } + + let mut group_ids = HashSet::with_capacity(groups.len()); + for group in groups { + if !is_valid_group_id_shape(&group.id) { + return Err(EditorCommitError::validation( + "INVALID_GROUP_ID", + "insertFrozenElements group ID is empty or exceeds its limit", + )); + } + if !group_ids.insert(group.id.as_str()) { + return Err(EditorCommitError::validation( + "DUPLICATE_GROUP_ID", + format!( + "insertFrozenElements group '{}' appears more than once", + group.id + ), + )); + } + } + + let mut z_ids = HashSet::with_capacity(z_updates.len()); + for update in z_updates { + if !crate::state::native_element_id::is_valid_element_id(&update.id) { + return Err(EditorCommitError::validation( + crate::state::native_element_id::INVALID_ELEMENT_ID, + format!( + "insertFrozenElements z target '{}' has an invalid ID", + update.id + ), + )); + } + if inserted_ids.contains(update.id.as_str()) { + return Err(EditorCommitError::validation( + "FROZEN_INSERT_Z_TARGET_OVERLAP", + format!( + "insertFrozenElements z target '{}' is also inserted", + update.id + ), + )); + } + if !z_ids.insert(update.id.as_str()) { + return Err(EditorCommitError::validation( + "DUPLICATE_FROZEN_Z_TARGET", + format!( + "insertFrozenElements z target '{}' appears more than once", + update.id + ), + )); + } + } + Ok(()) +} + +fn validate_reorder_envelope(op: &crate::models::EditorOpV1) -> Result<(), EditorCommitError> { + use crate::models::EditorOpV1; + + let EditorOpV1::ReorderElements { + mode, + complete_mode_order, + z_updates, + group_updates, + } = op + else { + return Ok(()); + }; + + if mode.is_empty() || mode.len() > MAX_MODE_ID_BYTES { + return Err(EditorCommitError::validation( + "INVALID_MODE_ID", + "reorderElements mode must be non-empty and within the mode ID limit", + )); + } + if z_updates.is_empty() { + return Err(EditorCommitError::validation( + "EMPTY_REORDER_BATCH", + "reorderElements must contain zUpdates", + )); + } + for (label, count) in [ + ("zUpdates", z_updates.len()), + ("groupUpdates", group_updates.len()), + ] { + if count > MAX_RENDER_ITEMS { + return Err(EditorCommitError::validation( + "REORDER_BATCH_TOO_LARGE", + format!("reorderElements {label} count exceeds {MAX_RENDER_ITEMS}"), + )); + } + } + if !complete_mode_order && !group_updates.is_empty() { + return Err(EditorCommitError::validation( + "INVALID_PARTIAL_REORDER_GROUP_UPDATE", + "partial reorderElements cannot contain groupUpdates", + )); + } + + let mut z_targets = HashMap::with_capacity(z_updates.len()); + for update in z_updates { + if !crate::state::native_element_id::is_valid_element_id(&update.id) { + return Err(EditorCommitError::validation( + crate::state::native_element_id::INVALID_ELEMENT_ID, + format!("reorderElements z target '{}' has an invalid ID", update.id), + )); + } + if z_targets + .insert(update.id.as_str(), update.element_type) + .is_some() + { + return Err(EditorCommitError::validation( + "DUPLICATE_REORDER_Z_TARGET", + format!( + "reorderElements z target '{}' appears more than once", + update.id + ), + )); + } + } + + let mut group_targets = HashSet::with_capacity(group_updates.len()); + for update in group_updates { + if !crate::state::native_element_id::is_valid_element_id(&update.id) { + return Err(EditorCommitError::validation( + crate::state::native_element_id::INVALID_ELEMENT_ID, + format!( + "reorderElements group target '{}' has an invalid ID", + update.id + ), + )); + } + if !group_targets.insert(update.id.as_str()) { + return Err(EditorCommitError::validation( + "DUPLICATE_REORDER_GROUP_TARGET", + format!( + "reorderElements group target '{}' appears more than once", + update.id + ), + )); + } + let Some(z_type) = z_targets.get(update.id.as_str()) else { + return Err(EditorCommitError::validation( + "REORDER_GROUP_TARGET_NOT_IN_ORDER", + format!( + "reorderElements group target '{}' has no matching z target", + update.id + ), + )); + }; + if *z_type != update.element_type { + return Err(EditorCommitError::validation( + "REORDER_TARGET_TYPE_CONFLICT", + format!( + "reorderElements target '{}' uses conflicting element types", + update.id + ), + )); + } + if update + .group_id + .as_ref() + .is_some_and(|group_id| !is_valid_group_id_shape(group_id)) + { + return Err(EditorCommitError::validation( + "INVALID_REORDER_GROUP_ID", + "reorderElements group ID is empty or exceeds its limit", + )); + } + } + Ok(()) +} + +fn validate_group_structural_envelope( + op: &crate::models::EditorOpV1, +) -> Result<(), EditorCommitError> { + use crate::models::{EditorOpV1, EditorTargetGroupV1}; + + let validate_mode = |mode: &str| { + if mode.is_empty() || mode.len() > MAX_MODE_ID_BYTES { + Err(EditorCommitError::validation( + "INVALID_MODE_ID", + "group structural mode must be non-empty and within the mode ID limit", + )) + } else { + Ok(()) + } + }; + let validate_group_id = |group_id: &str| { + if !is_valid_group_id_shape(group_id) { + Err(EditorCommitError::validation( + "INVALID_GROUP_ID", + "group ID must be non-empty and within its limit", + )) + } else { + Ok(()) + } + }; + let validate_group_name = |name: &str| { + if name.is_empty() || name.len() > MAX_GROUP_NAME_BYTES { + Err(EditorCommitError::validation( + "INVALID_GROUP_NAME", + "group name must be non-empty and within its limit", + )) + } else { + Ok(()) + } + }; + + match op { + EditorOpV1::SetElementGroups { + mode, + targets, + target_group, + } => { + validate_mode(mode)?; + // 빈 targets 허용 - plugin-only 그룹 편집은 그룹 def 생성·정리를 + // native 대상 없이 editor op에 실어야 한다 (플러그인 소속은 동반 + // plugin_changes가 운반) + if targets.len() > MAX_RENDER_ITEMS { + return Err(EditorCommitError::validation( + "INVALID_ELEMENT_GROUP_TARGET_COUNT", + format!( + "setElementGroups targets must contain at most {MAX_RENDER_ITEMS} entries" + ), + )); + } + let mut target_ids = HashSet::with_capacity(targets.len()); + for target in targets { + if !crate::state::native_element_id::is_valid_element_id(&target.id) { + return Err(EditorCommitError::validation( + crate::state::native_element_id::INVALID_ELEMENT_ID, + format!("setElementGroups target '{}' has an invalid ID", target.id), + )); + } + if !target_ids.insert(target.id.as_str()) { + return Err(EditorCommitError::validation( + "DUPLICATE_ELEMENT_GROUP_TARGET", + format!( + "setElementGroups target '{}' appears more than once", + target.id + ), + )); + } + } + match target_group { + Some(EditorTargetGroupV1::Existing { id }) => validate_group_id(id)?, + Some(EditorTargetGroupV1::Create { id, name }) => { + validate_group_id(id)?; + validate_group_name(name)?; + } + None => {} + } + } + EditorOpV1::RenameLayerGroup { + mode, + group_id, + name, + } => { + validate_mode(mode)?; + validate_group_id(group_id)?; + validate_group_name(name)?; + } + _ => {} + } + Ok(()) +} + +fn validate_frozen_key_slot( + slot: &crate::models::EditorFrozenKeySlotV1, +) -> Result<(), EditorCommitError> { + validate_key_slot(slot, "INVALID_FROZEN_KEY_SLOT", "insertFrozenElements") +} + +fn validate_key_slot( + slot: &crate::models::EditorFrozenKeySlotV1, + code: &'static str, + operation: &'static str, +) -> Result<(), EditorCommitError> { + let crate::models::EditorFrozenKeySlotV1::Multi(slot) = slot else { + return Ok(()); + }; + let mut members = HashSet::with_capacity(slot.keys.len()); + if !(2..=crate::models::MAX_SLOT_KEYS).contains(&slot.keys.len()) + || slot.keys.iter().any(|member| { + member.is_empty() + || member.contains('+') + || member.contains('|') + || !members.insert(member.as_str()) + }) + { + return Err(EditorCommitError::validation( + code, + format!("{operation} key slot is not canonical"), + )); + } + Ok(()) +} + +pub(crate) fn validate_revision(revision: u64) -> Result<(), EditorCommitError> { + if revision > MAX_SAFE_WIRE_REVISION { + return Err(EditorCommitError::validation( + "REVISION_OUT_OF_RANGE", + "editor revision exceeds JavaScript's safe integer range", + )); + } + Ok(()) +} + +pub(crate) fn next_revision(current: u64) -> Result { + validate_revision(current)?; + current + .checked_add(1) + .filter(|revision| *revision <= MAX_SAFE_WIRE_REVISION) + .ok_or_else(|| { + EditorCommitError::validation( + "REVISION_OUT_OF_RANGE", + "editor revision cannot be incremented safely", + ) + }) +} + +pub(crate) fn validate_history_restore_metadata( + document: &EditorDocumentV1, + custom_tabs: &[CustomTab], + selected_key_type: &str, +) -> Result<(), EditorCommitError> { + if custom_tabs.len() > MAX_CUSTOM_TABS { + return Err(EditorCommitError::validation( + "TOO_MANY_CUSTOM_TABS", + format!("custom tab count exceeds {MAX_CUSTOM_TABS}"), + )); + } + + let built_in_modes = default_keys(); + let mut ids = HashSet::with_capacity(custom_tabs.len()); + let mut names = HashSet::with_capacity(custom_tabs.len()); + for tab in custom_tabs { + let id = tab.id.trim(); + let name = tab.name.trim(); + if id.is_empty() || id.len() > MAX_MODE_ID_BYTES { + return Err(EditorCommitError::validation( + "INVALID_CUSTOM_TAB_ID", + "custom tab id is empty or too long", + )); + } + if name.is_empty() || name.len() > MAX_GROUP_NAME_BYTES { + return Err(EditorCommitError::validation( + "INVALID_CUSTOM_TAB_NAME", + "custom tab name is empty or too long", + )); + } + if built_in_modes.contains_key(id) || !ids.insert(id.to_string()) { + return Err(EditorCommitError::validation( + "DUPLICATE_CUSTOM_TAB_ID", + format!("custom tab id '{id}' is duplicated or reserved"), + )); + } + if !names.insert(name.to_string()) { + return Err(EditorCommitError::validation( + "DUPLICATE_CUSTOM_TAB_NAME", + format!("custom tab name '{name}' is duplicated"), + )); + } + if !document.keys.contains_key(id) || !document.key_positions.contains_key(id) { + return Err(EditorCommitError::validation( + "CUSTOM_TAB_DOCUMENT_MISSING", + format!("custom tab '{id}' has no paired editor collections"), + )); + } + } + + for mode in document.keys.keys() { + if !built_in_modes.contains_key(mode) && !ids.contains(mode) { + return Err(EditorCommitError::validation( + "CUSTOM_TAB_METADATA_MISSING", + format!("editor mode '{mode}' has no custom tab metadata"), + )); + } + } + + let selected_is_valid = + built_in_modes.contains_key(selected_key_type) || ids.contains(selected_key_type); + if !selected_is_valid || !document.keys.contains_key(selected_key_type) { + return Err(EditorCommitError::validation( + "INVALID_SELECTED_MODE", + format!("selected mode '{selected_key_type}' is not restorable"), + )); + } + + Ok(()) +} + +pub(crate) fn request_fingerprint( + request: &EditorCommitRequest, +) -> Result { + canonical_request_fingerprint(&FingerprintPayload { + base_revision: request.base_revision, + multi_key: request.multi_key, + gesture_id: request.gesture_id.as_deref(), + gesture_ids: &request.gesture_ids, + changes: &request.changes, + ops_version: &request.ops_version, + ops: &request.ops, + }) +} + +pub(crate) fn canonical_request_fingerprint( + payload: &impl Serialize, +) -> Result { + let value = serde_json::to_value(payload).map_err(|error| { + EditorCommitError::validation( + "INVALID_REQUEST_PAYLOAD", + format!("failed to serialize editor request: {error}"), + ) + })?; + + let mut canonical = Vec::new(); + write_canonical_json(&value, &mut canonical).map_err(|error| { + EditorCommitError::validation( + "INVALID_REQUEST_PAYLOAD", + format!("failed to canonicalize editor request: {error}"), + ) + })?; + + Ok(Sha256::digest(canonical).into()) +} + +pub(crate) fn request_payload_size( + request: &EditorCommitRequest, +) -> Result { + let compact_size = serde_json::to_vec(request) + .map_err(|error| { + EditorCommitError::validation( + "INVALID_REQUEST_PAYLOAD", + format!("failed to serialize editor request: {error}"), + ) + })? + .len(); + if compact_size > MAX_REQUEST_BYTES { + return Err(EditorCommitError::validation( + "REQUEST_TOO_LARGE", + format!("editor request exceeds the {MAX_REQUEST_BYTES} byte limit"), + )); + } + if compact_size >= REQUEST_WARNING_BYTES { + log::warn!("[Editor] Large editor request: {compact_size} compact bytes"); + } + Ok(compact_size) +} + +fn write_canonical_json(value: &Value, output: &mut Vec) -> serde_json::Result<()> { + match value { + Value::Null => output.extend_from_slice(b"null"), + Value::Bool(value) => output.extend_from_slice(if *value { b"true" } else { b"false" }), + Value::Number(value) => output.extend_from_slice(value.to_string().as_bytes()), + Value::String(value) => output.extend_from_slice(serde_json::to_string(value)?.as_bytes()), + Value::Array(values) => { + output.push(b'['); + for (index, value) in values.iter().enumerate() { + if index > 0 { + output.push(b','); + } + write_canonical_json(value, output)?; + } + output.push(b']'); + } + Value::Object(values) => { + output.push(b'{'); + let mut entries = values.iter().collect::>(); + entries.sort_unstable_by_key(|(key, _)| *key); + for (index, (key, value)) in entries.into_iter().enumerate() { + if index > 0 { + output.push(b','); + } + output.extend_from_slice(serde_json::to_string(key)?.as_bytes()); + output.push(b':'); + write_canonical_json(value, output)?; + } + output.push(b'}'); + } + } + Ok(()) +} From 9ba4861a6e751875bd7bb4aa0d3621b49889ba9c Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 16:52:05 +0900 Subject: [PATCH 144/245] =?UTF-8?q?refactor:=20=ED=8E=B8=EC=A7=91=20?= =?UTF-8?q?=EC=A0=9C=EC=8A=A4=EC=B2=98=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/state/gesture.rs | 1082 +------------------------- src-tauri/src/state/gesture/tests.rs | 1071 +++++++++++++++++++++++++ 2 files changed, 1072 insertions(+), 1081 deletions(-) create mode 100644 src-tauri/src/state/gesture/tests.rs diff --git a/src-tauri/src/state/gesture.rs b/src-tauri/src/state/gesture.rs index b63f89c71..f17306d2a 100644 --- a/src-tauri/src/state/gesture.rs +++ b/src-tauri/src/state/gesture.rs @@ -151,1084 +151,4 @@ pub(crate) fn validate_gesture_commit_request( } #[cfg(test)] -mod tests { - use super::*; - use crate::models::{ - EditorBoundsV1, EditorElementGroupTargetV1, EditorElementPropertyPatchV1, - EditorElementTypeV1, EditorFrozenKeySlotV1, EditorGroupUpdateV1, EditorOpV1, EditorPatchV1, - EditorTargetGroupV1, EditorZUpdateV1, GesturePluginInstancesChange, EDITOR_OPS_VERSION, - }; - - fn gesture_request(plugin_ids: &[String]) -> GestureCommitRequest { - GestureCommitRequest { - gesture_id: uuid::Uuid::new_v4().to_string(), - mutation_id: uuid::Uuid::new_v4().to_string(), - editor_base_revision: 0, - plugin_base_revision: 0, - observed_history_epoch: None, - authority_generation: 1, - editor_changes: None, - editor_ops_version: None, - editor_ops: None, - plugin_changes: plugin_ids - .iter() - .map(|plugin_id| GesturePluginInstancesChange { - plugin_id: plugin_id.clone(), - instances: Vec::new(), - }) - .collect(), - } - } - - fn validation_code(error: EditorCommitError) -> Option { - error.details.and_then(|details| details.validation_code) - } - - fn set_bounds_op() -> EditorOpV1 { - EditorOpV1::SetBounds { - element_type: EditorElementTypeV1::Key, - id: uuid::Uuid::new_v4().to_string(), - bounds: EditorBoundsV1 { - dx: 1.0, - dy: 2.0, - width: 3.0, - height: 4.0, - }, - } - } - - fn frozen_insert_op() -> EditorOpV1 { - EditorOpV1::InsertFrozenElements { - mode: "4key".to_string(), - elements: vec![crate::models::EditorFrozenElementV1::Key { - slot: crate::models::EditorFrozenKeySlotV1::Single("FROZEN".to_string()), - position: crate::models::KeyPosition { - id: uuid::Uuid::new_v4().to_string(), - ..crate::models::KeyPosition::default() - }, - }], - groups: Vec::new(), - z_updates: Vec::new(), - } - } - - fn reorder_op() -> EditorOpV1 { - let id = uuid::Uuid::new_v4().to_string(); - EditorOpV1::ReorderElements { - mode: "4key".to_string(), - complete_mode_order: true, - z_updates: vec![EditorZUpdateV1 { - element_type: EditorElementTypeV1::Key, - id: id.clone(), - z_index: 1, - }], - group_updates: vec![EditorGroupUpdateV1 { - element_type: EditorElementTypeV1::Key, - id, - group_id: None, - }], - } - } - - fn property_op() -> EditorOpV1 { - EditorOpV1::PatchElement { - element_type: EditorElementTypeV1::Key, - id: uuid::Uuid::new_v4().to_string(), - patch: EditorElementPropertyPatchV1::Hidden(true), - } - } - - #[test] - fn gesture_allows_plugin_only_patch_or_ops_but_not_both_editor_mutations() { - let plugin_ids = ["plugin-a".to_string()]; - let plugin_only = gesture_request(&plugin_ids); - validate_gesture_commit_request(&plugin_only).unwrap(); - let mut invalid_plugin_only = plugin_only.clone(); - invalid_plugin_only.gesture_id = "not-a-uuid".to_string(); - assert_eq!( - validate_gesture_commit_request(&invalid_plugin_only) - .unwrap_err() - .error_code, - crate::errors::EditorCommitErrorCode::InvalidGestureId - ); - - let mut patch = plugin_only.clone(); - patch.editor_changes = Some(EditorPatchV1::default()); - validate_gesture_commit_request(&patch).unwrap(); - - let mut ops = plugin_only.clone(); - ops.editor_ops_version = Some(EDITOR_OPS_VERSION); - ops.editor_ops = Some(vec![set_bounds_op()]); - validate_gesture_commit_request(&ops).unwrap(); - - let mut both = ops.clone(); - both.editor_changes = Some(EditorPatchV1::default()); - assert_eq!( - validation_code(validate_gesture_commit_request(&both).unwrap_err()).as_deref(), - Some("INVALID_GESTURE_EDITOR_MUTATION") - ); - - for invalid in [ - GestureCommitRequest { - editor_ops: None, - ..ops.clone() - }, - GestureCommitRequest { - editor_ops_version: None, - ..ops - }, - ] { - assert_eq!( - validation_code(validate_gesture_commit_request(&invalid).unwrap_err()).as_deref(), - Some("INVALID_GESTURE_EDITOR_MUTATION") - ); - } - } - - #[test] - fn gesture_requires_at_least_one_plugin_change() { - let error = validate_gesture_commit_request(&gesture_request(&[])).unwrap_err(); - - assert_eq!( - validation_code(error).as_deref(), - Some("INVALID_GESTURE_PLUGIN_COUNT") - ); - } - - #[test] - fn gesture_frozen_insert_wire_rejects_unknown_nested_keys() { - let mut request = gesture_request(&["plugin-a".to_string()]); - request.editor_ops_version = Some(EDITOR_OPS_VERSION); - request.editor_ops = Some(vec![frozen_insert_op()]); - let mut wire = serde_json::to_value(request).unwrap(); - wire["editorOps"][0]["elements"][0]["position"]["unexpected"] = serde_json::json!(true); - - let error = decode_gesture_commit_request(wire).unwrap_err(); - assert_eq!( - validation_code(error).as_deref(), - Some("INVALID_REQUEST_PAYLOAD") - ); - } - - #[test] - fn gesture_reorder_wire_rejects_unknown_and_missing_nested_keys() { - let mut request = gesture_request(&["plugin-a".to_string()]); - request.editor_ops_version = Some(EDITOR_OPS_VERSION); - request.editor_ops = Some(vec![reorder_op()]); - let valid = serde_json::to_value(request).unwrap(); - - let mut unknown_z = valid.clone(); - unknown_z["editorOps"][0]["zUpdates"][0]["unexpected"] = serde_json::json!(true); - let mut unknown_group = valid.clone(); - unknown_group["editorOps"][0]["groupUpdates"][0]["unexpected"] = serde_json::json!(true); - let mut missing_group_id = valid; - missing_group_id["editorOps"][0]["groupUpdates"][0] - .as_object_mut() - .unwrap() - .remove("groupId"); - - for wire in [unknown_z, unknown_group, missing_group_id] { - let error = decode_gesture_commit_request(wire).unwrap_err(); - assert_eq!( - validation_code(error).as_deref(), - Some("INVALID_REQUEST_PAYLOAD") - ); - } - } - - #[test] - fn gesture_group_structural_wire_is_exact_tagged_and_required_nullable() { - let target = EditorElementGroupTargetV1 { - element_type: EditorElementTypeV1::Key, - id: uuid::Uuid::new_v4().to_string(), - }; - let mut request = gesture_request(&["plugin-a".to_string()]); - request.editor_ops_version = Some(EDITOR_OPS_VERSION); - request.editor_ops = Some(vec![EditorOpV1::SetElementGroups { - mode: "4key".to_string(), - targets: vec![target.clone()], - target_group: Some(EditorTargetGroupV1::Create { - id: " legacy group ".to_string(), - name: " Raw Name ".to_string(), - }), - }]); - let create = serde_json::to_value(request).unwrap(); - assert_eq!( - create["editorOps"][0]["targetGroup"], - serde_json::json!({ - "kind": "create", - "id": " legacy group ", - "name": " Raw Name ", - }) - ); - let decoded = decode_gesture_commit_request(create.clone()).unwrap(); - validate_gesture_commit_request(&decoded).unwrap(); - - let mut ungroup_request = gesture_request(&["plugin-a".to_string()]); - ungroup_request.editor_ops_version = Some(EDITOR_OPS_VERSION); - ungroup_request.editor_ops = Some(vec![EditorOpV1::SetElementGroups { - mode: "4key".to_string(), - targets: vec![target], - target_group: None, - }]); - let ungroup = serde_json::to_value(ungroup_request).unwrap(); - assert!(ungroup["editorOps"][0]["targetGroup"].is_null()); - let decoded = decode_gesture_commit_request(ungroup.clone()).unwrap(); - validate_gesture_commit_request(&decoded).unwrap(); - - let mut rename_request = gesture_request(&["plugin-a".to_string()]); - rename_request.editor_ops_version = Some(EDITOR_OPS_VERSION); - rename_request.editor_ops = Some(vec![EditorOpV1::RenameLayerGroup { - mode: "4key".to_string(), - group_id: "legacy-group".to_string(), - name: "Name".to_string(), - }]); - let rename = serde_json::to_value(rename_request).unwrap(); - assert_eq!(rename["editorOps"][0]["groupId"], "legacy-group"); - let decoded = decode_gesture_commit_request(rename.clone()).unwrap(); - validate_gesture_commit_request(&decoded).unwrap(); - - let mut missing_target_group = ungroup.clone(); - missing_target_group["editorOps"][0] - .as_object_mut() - .unwrap() - .remove("targetGroup"); - let mut untagged = create.clone(); - untagged["editorOps"][0]["targetGroup"] = serde_json::json!({ - "id": "group", - "name": "Group", - }); - let mut combined = create.clone(); - combined["editorOps"][0]["targetGroup"] = serde_json::json!({ - "kind": "existing", - "id": "group", - "name": "No rename", - }); - let mut target_extra = create.clone(); - target_extra["editorOps"][0]["targets"][0]["unexpected"] = serde_json::json!(true); - let mut group_extra = create; - group_extra["editorOps"][0]["targetGroup"]["unexpected"] = serde_json::json!(true); - let mut rename_extra = rename; - rename_extra["editorOps"][0]["unexpected"] = serde_json::json!(true); - - for invalid in [ - missing_target_group, - untagged, - combined, - target_extra, - group_extra, - rename_extra, - ] { - let error = decode_gesture_commit_request(invalid).unwrap_err(); - assert_eq!( - validation_code(error).as_deref(), - Some("INVALID_REQUEST_PAYLOAD") - ); - } - } - - #[test] - fn gesture_property_and_key_slot_wires_reject_unknown_nested_keys() { - let mut property = gesture_request(&["plugin-a".to_string()]); - property.editor_ops_version = Some(EDITOR_OPS_VERSION); - property.editor_ops = Some(vec![property_op()]); - let mut property_wire = serde_json::to_value(property).unwrap(); - property_wire["editorOps"][0]["patch"]["width"] = serde_json::json!(1); - let error = decode_gesture_commit_request(property_wire).unwrap_err(); - assert_eq!( - validation_code(error).as_deref(), - Some("INVALID_REQUEST_PAYLOAD") - ); - - let mut layer_name = gesture_request(&["plugin-a".to_string()]); - layer_name.editor_ops_version = Some(EDITOR_OPS_VERSION); - layer_name.editor_ops = Some(vec![EditorOpV1::PatchElement { - element_type: EditorElementTypeV1::Graph, - id: uuid::Uuid::new_v4().to_string(), - patch: EditorElementPropertyPatchV1::LayerName(None), - }]); - let layer_name_wire = serde_json::to_value(layer_name).unwrap(); - assert_eq!( - layer_name_wire["editorOps"][0]["patch"], - serde_json::json!({ "property": "layerName", "value": null }) - ); - decode_gesture_commit_request(layer_name_wire.clone()).unwrap(); - - let mut graph_type = gesture_request(&["plugin-a".to_string()]); - graph_type.editor_ops_version = Some(EDITOR_OPS_VERSION); - graph_type.editor_ops = Some(vec![EditorOpV1::PatchElement { - element_type: EditorElementTypeV1::Graph, - id: uuid::Uuid::new_v4().to_string(), - patch: EditorElementPropertyPatchV1::GraphType(crate::models::GraphType::Bar), - }]); - let graph_type_wire = serde_json::to_value(graph_type).unwrap(); - assert_eq!( - graph_type_wire["editorOps"][0]["patch"], - serde_json::json!({ "property": "graphType", "value": "bar" }) - ); - decode_gesture_commit_request(graph_type_wire.clone()).unwrap(); - - let mut graph_color = gesture_request(&["plugin-a".to_string()]); - graph_color.editor_ops_version = Some(EDITOR_OPS_VERSION); - graph_color.editor_ops = Some(vec![EditorOpV1::PatchElement { - element_type: EditorElementTypeV1::Graph, - id: uuid::Uuid::new_v4().to_string(), - patch: EditorElementPropertyPatchV1::GraphColor("not-normalized".to_string()), - }]); - let graph_color_wire = serde_json::to_value(graph_color).unwrap(); - assert_eq!( - graph_color_wire["editorOps"][0]["patch"], - serde_json::json!({ "property": "graphColor", "value": "not-normalized" }) - ); - decode_gesture_commit_request(graph_color_wire.clone()).unwrap(); - - let mut paint = gesture_request(&["plugin-a".to_string()]); - paint.editor_ops_version = Some(EDITOR_OPS_VERSION); - paint.editor_ops = Some(vec![EditorOpV1::PatchElement { - element_type: EditorElementTypeV1::Knob, - id: uuid::Uuid::new_v4().to_string(), - patch: EditorElementPropertyPatchV1::ActiveBorderPaint( - crate::models::EditorPaintDescriptorV1 { - color: "active".to_string(), - gradient: None, - }, - ), - }]); - let paint_wire = serde_json::to_value(paint).unwrap(); - assert_eq!( - paint_wire["editorOps"][0]["patch"], - serde_json::json!({ "property": "activeBorderPaint", "value": { "color": "active", "gradient": null } }) - ); - decode_gesture_commit_request(paint_wire.clone()).unwrap(); - - let literal_properties = [ - ( - EditorElementTypeV1::Graph, - EditorElementPropertyPatchV1::ShowAvgLine(true), - serde_json::json!({ "property": "showAvgLine", "value": true }), - ), - ( - EditorElementTypeV1::Graph, - EditorElementPropertyPatchV1::GraphAnimationEnabled(false), - serde_json::json!({ "property": "graphAnimationEnabled", "value": false }), - ), - ( - EditorElementTypeV1::Graph, - EditorElementPropertyPatchV1::GraphSpeed(0), - serde_json::json!({ "property": "graphSpeed", "value": 0 }), - ), - ( - EditorElementTypeV1::Knob, - EditorElementPropertyPatchV1::Reverse(true), - serde_json::json!({ "property": "reverse", "value": true }), - ), - ( - EditorElementTypeV1::Knob, - EditorElementPropertyPatchV1::Sensitivity(-7.25), - serde_json::json!({ "property": "sensitivity", "value": -7.25 }), - ), - ( - EditorElementTypeV1::Knob, - EditorElementPropertyPatchV1::AxisId(" HIDA:raw ".to_string()), - serde_json::json!({ "property": "axisId", "value": " HIDA:raw " }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::UseInlineStyles(false), - serde_json::json!({ "property": "useInlineStyles", "value": false }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::FontWeight(400), - serde_json::json!({ "property": "fontWeight", "value": 400 }), - ), - ( - EditorElementTypeV1::Stat, - EditorElementPropertyPatchV1::FontItalic(false), - serde_json::json!({ "property": "fontItalic", "value": false }), - ), - ( - EditorElementTypeV1::Graph, - EditorElementPropertyPatchV1::FontUnderline(true), - serde_json::json!({ "property": "fontUnderline", "value": true }), - ), - ( - EditorElementTypeV1::Knob, - EditorElementPropertyPatchV1::FontStrikethrough(false), - serde_json::json!({ "property": "fontStrikethrough", "value": false }), - ), - ( - EditorElementTypeV1::Graph, - EditorElementPropertyPatchV1::FontFamily(" raw-font ".to_string()), - serde_json::json!({ "property": "fontFamily", "value": " raw-font " }), - ), - ( - EditorElementTypeV1::Knob, - EditorElementPropertyPatchV1::DisplayText(" raw display ".to_string()), - serde_json::json!({ "property": "displayText", "value": " raw display " }), - ), - ( - EditorElementTypeV1::Graph, - EditorElementPropertyPatchV1::ClassName(" raw class ".to_string()), - serde_json::json!({ "property": "className", "value": " raw class " }), - ), - ( - EditorElementTypeV1::Stat, - EditorElementPropertyPatchV1::FontPaint(crate::models::EditorPaintDescriptorV1 { - color: String::new(), - gradient: None, - }), - serde_json::json!({ "property": "fontPaint", "value": { "color": "", "gradient": null } }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::ActiveFontPaint( - crate::models::EditorPaintDescriptorV1 { - color: " raw active font ".to_string(), - gradient: None, - }, - ), - serde_json::json!({ "property": "activeFontPaint", "value": { "color": " raw active font ", "gradient": null } }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::Shadow(crate::models::EditorShadowLeafPatchV1::Blur( - 8.5, - )), - serde_json::json!({ "property": "shadow", "value": { "leaf": "blur", "value": 8.5 } }), - ), - ( - EditorElementTypeV1::Knob, - EditorElementPropertyPatchV1::ActiveShadow( - crate::models::EditorShadowLeafPatchV1::OffsetY(-3.25), - ), - serde_json::json!({ "property": "activeShadow", "value": { "leaf": "offsetY", "value": -3.25 } }), - ), - ( - EditorElementTypeV1::Stat, - EditorElementPropertyPatchV1::ShadowEnabled(true), - serde_json::json!({ "property": "shadowEnabled", "value": true }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::BorderWidth(0.5), - serde_json::json!({ "property": "borderWidth", "value": 0.5 }), - ), - ( - EditorElementTypeV1::Knob, - EditorElementPropertyPatchV1::BorderRadius(999.0), - serde_json::json!({ "property": "borderRadius", "value": 999.0 }), - ), - ( - EditorElementTypeV1::Stat, - EditorElementPropertyPatchV1::FontSize(8.5), - serde_json::json!({ "property": "fontSize", "value": 8.5 }), - ), - ( - EditorElementTypeV1::Stat, - EditorElementPropertyPatchV1::InactiveImage(String::new()), - serde_json::json!({ "property": "inactiveImage", "value": "" }), - ), - ( - EditorElementTypeV1::Knob, - EditorElementPropertyPatchV1::ActiveImage(" active.png ".to_string()), - serde_json::json!({ "property": "activeImage", "value": " active.png " }), - ), - ( - EditorElementTypeV1::Stat, - EditorElementPropertyPatchV1::IdleTransparent(true), - serde_json::json!({ "property": "idleTransparent", "value": true }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::ActiveTransparent(false), - serde_json::json!({ "property": "activeTransparent", "value": false }), - ), - ( - EditorElementTypeV1::Stat, - EditorElementPropertyPatchV1::IdleImageFit(crate::models::ImageFit::Fill), - serde_json::json!({ "property": "idleImageFit", "value": "fill" }), - ), - ( - EditorElementTypeV1::Knob, - EditorElementPropertyPatchV1::ActiveImageFit(crate::models::ImageFit::Cover), - serde_json::json!({ "property": "activeImageFit", "value": "cover" }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::ImageMode(crate::models::ImageMode::Overlay), - serde_json::json!({ "property": "imageMode", "value": "overlay" }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::IdleImageTransform(Some( - crate::models::ImageTransformLeafPatchV1::OffsetX(-125.5), - )), - serde_json::json!({ "property": "idleImageTransform", "value": { "leaf": "offsetX", "value": -125.5 } }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::ActiveImageTransform(None), - serde_json::json!({ "property": "activeImageTransform", "value": null }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::SoundPath(String::new()), - serde_json::json!({ "property": "soundPath", "value": "" }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::SoundEnabled(false), - serde_json::json!({ "property": "soundEnabled", "value": false }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::SoundVolume(137.5), - serde_json::json!({ "property": "soundVolume", "value": 137.5 }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::CounterEnabled(true), - serde_json::json!({ "property": "counterEnabled", "value": true }), - ), - ( - EditorElementTypeV1::Stat, - EditorElementPropertyPatchV1::CounterAnimationEnabled(false), - serde_json::json!({ "property": "counterAnimationEnabled", "value": false }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::CounterPlacement( - crate::models::KeyCounterPlacement::Outside, - ), - serde_json::json!({ "property": "counterPlacement", "value": "outside" }), - ), - ( - EditorElementTypeV1::Stat, - EditorElementPropertyPatchV1::CounterAlign(crate::models::KeyCounterAlign::Right), - serde_json::json!({ "property": "counterAlign", "value": "right" }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::CounterAlignMode( - crate::models::KeyCounterAlignMode::Between, - ), - serde_json::json!({ "property": "counterAlignMode", "value": "between" }), - ), - ( - EditorElementTypeV1::Stat, - EditorElementPropertyPatchV1::CounterGap(u32::MAX), - serde_json::json!({ "property": "counterGap", "value": u32::MAX }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::CounterFontSize(72), - serde_json::json!({ "property": "counterFontSize", "value": 72 }), - ), - ( - EditorElementTypeV1::Stat, - EditorElementPropertyPatchV1::CounterFontWeight(900), - serde_json::json!({ "property": "counterFontWeight", "value": 900 }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::CounterFontItalic(true), - serde_json::json!({ "property": "counterFontItalic", "value": true }), - ), - ( - EditorElementTypeV1::Stat, - EditorElementPropertyPatchV1::CounterFontUnderline(true), - serde_json::json!({ "property": "counterFontUnderline", "value": true }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::CounterFontStrikethrough(true), - serde_json::json!({ "property": "counterFontStrikethrough", "value": true }), - ), - ( - EditorElementTypeV1::Stat, - EditorElementPropertyPatchV1::CounterFontFamily(" raw-counter-font ".to_string()), - serde_json::json!({ "property": "counterFontFamily", "value": " raw-counter-font " }), - ), - ( - EditorElementTypeV1::Stat, - EditorElementPropertyPatchV1::CounterFillIdle( - crate::models::EditorCounterFillIntentV1::Solid( - crate::models::EditorCounterFillSolidIntentV1 { - color: String::new(), - }, - ), - ), - serde_json::json!({ "property": "counterFillIdle", "value": { "color": "" } }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::CounterFillActive( - crate::models::EditorCounterFillIntentV1::Gradient( - crate::models::EditorCounterFillGradientIntentV1 { - color: "rgba(170,187,204,1)".to_string(), - gradient: crate::models::EditorPaintGradientV1 { - angle: 180.0, - stops: vec![ - crate::models::EditorPaintGradientStopV1 { - color: "#ABC".to_string(), - pos: 0.0, - }, - crate::models::EditorPaintGradientStopV1 { - color: "transparent".to_string(), - pos: 1.0, - }, - ], - }, - }, - ), - ), - serde_json::json!({ "property": "counterFillActive", "value": { "color": "rgba(170,187,204,1)", "gradient": { "angle": 180.0, "stops": [ { "color": "#ABC", "pos": 0.0 }, { "color": "transparent", "pos": 1.0 } ] } } }), - ), - ( - EditorElementTypeV1::Stat, - EditorElementPropertyPatchV1::CounterAnimationPreset( - crate::models::EditorCounterAnimationPresetIntentV1 { - preset_id: "builtin-ease-out".to_string(), - apply_preset_id: None, - bezier: None, - scale: None, - duration_ms: None, - }, - ), - serde_json::json!({ "property": "counterAnimationPreset", "value": { "presetId": "builtin-ease-out" } }), - ), - ( - EditorElementTypeV1::Stat, - EditorElementPropertyPatchV1::StatType(crate::models::StatType::Total), - serde_json::json!({ "property": "statType", "value": "total" }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::NoteEffectEnabled(false), - serde_json::json!({ "property": "noteEffectEnabled", "value": false }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::NoteGlowEnabled(true), - serde_json::json!({ "property": "noteGlowEnabled", "value": true }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::NoteGlowSyncPaint(true), - serde_json::json!({ "property": "noteGlowSyncPaint", "value": true }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::NoteGlowSize(19.75), - serde_json::json!({ "property": "noteGlowSize", "value": 19.75 }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::NotePaint( - crate::models::EditorNotePaintIntentV1::Color( - crate::models::EditorNotePaintColorIntentV1 { - color: crate::models::EditorNoteColorV1::Solid(String::new()), - }, - ), - ), - serde_json::json!({ "property": "notePaint", "value": { "color": "" } }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::NoteGlowPaint( - crate::models::EditorNotePaintIntentV1::Opacity( - crate::models::EditorNotePaintOpacityIntentV1 { opacity: 70 }, - ), - ), - serde_json::json!({ "property": "noteGlowPaint", "value": { "opacity": 70 } }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::NoteBorderPaint( - crate::models::EditorNoteBorderPaintV1 { - color: "#112233".to_string(), - opacity: 0, - gradient: None, - }, - ), - serde_json::json!({ "property": "noteBorderPaint", "value": { "color": "#112233", "opacity": 0 } }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::NoteOffsetX(Some(0.0)), - serde_json::json!({ "property": "noteOffsetX", "value": 0.0 }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::NoteOffsetY(None), - serde_json::json!({ "property": "noteOffsetY", "value": null }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::NoteWidth(Some(32.5)), - serde_json::json!({ "property": "noteWidth", "value": 32.5 }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::NoteBorderWidth(2.5), - serde_json::json!({ "property": "noteBorderWidth", "value": 2.5 }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::NoteBorderRadius(4.0), - serde_json::json!({ "property": "noteBorderRadius", "value": 4.0 }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::NoteAutoYCorrection(false), - serde_json::json!({ "property": "noteAutoYCorrection", "value": false }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::NoteAlignment(crate::models::NoteAlignment::Left), - serde_json::json!({ "property": "noteAlignment", "value": "left" }), - ), - ( - EditorElementTypeV1::Key, - EditorElementPropertyPatchV1::NoteBorderSide( - crate::models::EditorNoteBorderSideV1::Horizontal, - ), - serde_json::json!({ "property": "noteBorderSide", "value": "horizontal" }), - ), - ]; - for (element_type, patch, expected) in literal_properties { - let mut request = gesture_request(&["plugin-a".to_string()]); - request.editor_ops_version = Some(EDITOR_OPS_VERSION); - request.editor_ops = Some(vec![EditorOpV1::PatchElement { - element_type, - id: uuid::Uuid::new_v4().to_string(), - patch, - }]); - let wire = serde_json::to_value(request).unwrap(); - assert_eq!(wire["editorOps"][0]["patch"], expected); - decode_gesture_commit_request(wire).unwrap(); - } - - let mut invalid_axis_id = graph_color_wire.clone(); - invalid_axis_id["editorOps"][0]["patch"] = - serde_json::json!({ "property": "axisId", "value": false }); - let error = decode_gesture_commit_request(invalid_axis_id).unwrap_err(); - assert_eq!( - validation_code(error).as_deref(), - Some("INVALID_REQUEST_PAYLOAD") - ); - for patch in [ - serde_json::json!({ "axisId": "axis", "hidden": true }), - serde_json::json!({ "axisId": "axis", "unexpected": true }), - ] { - let mut invalid_axis_id = graph_color_wire.clone(); - invalid_axis_id["editorOps"][0]["patch"] = patch; - let error = decode_gesture_commit_request(invalid_axis_id).unwrap_err(); - assert_eq!( - validation_code(error).as_deref(), - Some("INVALID_REQUEST_PAYLOAD") - ); - } - - let mut invalid_graph_color = graph_color_wire; - invalid_graph_color["editorOps"][0]["patch"]["graphColor"] = serde_json::json!(false); - let error = decode_gesture_commit_request(invalid_graph_color).unwrap_err(); - assert_eq!( - validation_code(error).as_deref(), - Some("INVALID_REQUEST_PAYLOAD") - ); - - let mut invalid_font_family = layer_name_wire.clone(); - invalid_font_family["editorOps"][0]["patch"] = - serde_json::json!({ "property": "fontFamily", "value": null }); - let error = decode_gesture_commit_request(invalid_font_family).unwrap_err(); - assert_eq!( - validation_code(error).as_deref(), - Some("INVALID_REQUEST_PAYLOAD") - ); - - for patch in [ - serde_json::json!({ "property": "displayText", "value": null }), - serde_json::json!({ "property": "displayText", "value": 1 }), - serde_json::json!({ "displayText": "text", "hidden": true }), - serde_json::json!({ "displayText": "text", "unexpected": true }), - ] { - let mut invalid_display_text = layer_name_wire.clone(); - invalid_display_text["editorOps"][0]["patch"] = patch; - let error = decode_gesture_commit_request(invalid_display_text).unwrap_err(); - assert_eq!( - validation_code(error).as_deref(), - Some("INVALID_REQUEST_PAYLOAD") - ); - } - - for patch in [ - serde_json::json!({ "property": "className", "value": null }), - serde_json::json!({ "property": "className", "value": 1 }), - serde_json::json!({ "className": "class", "hidden": true }), - serde_json::json!({ "className": "class", "unexpected": true }), - ] { - let mut invalid_class_name = layer_name_wire.clone(); - invalid_class_name["editorOps"][0]["patch"] = patch; - let error = decode_gesture_commit_request(invalid_class_name).unwrap_err(); - assert_eq!( - validation_code(error).as_deref(), - Some("INVALID_REQUEST_PAYLOAD") - ); - } - - for patch in [ - serde_json::json!({ "property": "fontPaint", "value": null }), - serde_json::json!({ "property": "fontPaint", "value": { "color": "idle" } }), - serde_json::json!({ "property": "activeFontPaint", "value": 1 }), - serde_json::json!({ "fontPaint": { "color": "idle", "gradient": null }, "activeFontPaint": { "color": "active", "gradient": null } }), - serde_json::json!({ "property": "fontPaint", "value": { "color": "idle", "gradient": null, "unexpected": true } }), - ] { - let mut invalid_font_color = layer_name_wire.clone(); - invalid_font_color["editorOps"][0]["patch"] = patch; - let error = decode_gesture_commit_request(invalid_font_color).unwrap_err(); - assert_eq!( - validation_code(error).as_deref(), - Some("INVALID_REQUEST_PAYLOAD") - ); - } - - for patch in [ - serde_json::json!({ "property": "shadow", "value": {} }), - serde_json::json!({ "property": "shadow", "value": { "offsetX": 1, "blur": 2 } }), - serde_json::json!({ "property": "shadow", "value": { "color": "shadow", "unexpected": true } }), - serde_json::json!({ "property": "activeShadow", "value": null }), - serde_json::json!({ "property": "activeShadow", "value": { "leaf": "offsetY", "value": "1" } }), - serde_json::json!({ "property": "shadowEnabled", "value": "true" }), - serde_json::json!({ "shadow": { "blur": 1 }, "hidden": false }), - ] { - let mut invalid_shadow = layer_name_wire.clone(); - invalid_shadow["editorOps"][0]["patch"] = patch; - let error = decode_gesture_commit_request(invalid_shadow).unwrap_err(); - assert_eq!( - validation_code(error).as_deref(), - Some("INVALID_REQUEST_PAYLOAD") - ); - } - - for patch in [ - serde_json::json!({ "property": "activeBorderPaint", "value": {} }), - serde_json::json!({ "property": "activeBorderPaint", "value": { "color": "active" } }), - serde_json::json!({ "property": "activeBorderPaint", "value": { "color": "active", "gradient": null, "unexpected": true } }), - serde_json::json!({ "property": "activeBorderPaint", "value": { "color": "active", "gradient": { "stops": [{ "color": "active", "pos": 0 }, { "color": "tail", "pos": 1 }] } } }), - serde_json::json!({ "property": "activeBorderPaint", "value": { "color": "active", "gradient": { "angle": 90 } } }), - serde_json::json!({ "property": "activeBorderPaint", "value": { "color": "active", "gradient": { "angle": 90, "stops": [{ "color": "active", "pos": 0, "unexpected": true }, { "color": "tail", "pos": 1 }] } } }), - serde_json::json!({ "activeBorderPaint": { "color": "active", "gradient": null }, "hidden": false }), - ] { - let mut invalid_paint = paint_wire.clone(); - invalid_paint["editorOps"][0]["patch"] = patch; - let error = decode_gesture_commit_request(invalid_paint).unwrap_err(); - assert_eq!( - validation_code(error).as_deref(), - Some("INVALID_REQUEST_PAYLOAD") - ); - } - - for patch in [ - serde_json::json!({ "property": "borderWidth", "value": null }), - serde_json::json!({ "property": "borderWidth", "value": "1" }), - serde_json::json!({ "borderWidth": 1, "fontSize": 14 }), - serde_json::json!({ "borderWidth": 1, "unexpected": true }), - serde_json::json!({ "property": "borderRadius", "value": null }), - serde_json::json!({ "property": "borderRadius", "value": "1" }), - serde_json::json!({ "borderRadius": 1, "hidden": false }), - serde_json::json!({ "property": "fontSize", "value": null }), - serde_json::json!({ "property": "fontSize", "value": "14" }), - serde_json::json!({ "fontSize": 14, "unexpected": true }), - ] { - let mut invalid_numeric_style = layer_name_wire.clone(); - invalid_numeric_style["editorOps"][0]["patch"] = patch; - let error = decode_gesture_commit_request(invalid_numeric_style).unwrap_err(); - assert_eq!( - validation_code(error).as_deref(), - Some("INVALID_REQUEST_PAYLOAD") - ); - } - - for patch in [ - serde_json::json!({ "property": "inactiveImage", "value": null }), - serde_json::json!({ "inactiveImage": "path", "hidden": false }), - serde_json::json!({ "inactiveImage": "path", "unexpected": true }), - serde_json::json!({ "property": "activeImage", "value": null }), - serde_json::json!({ "activeImage": "path", "hidden": false }), - serde_json::json!({ "activeImage": "path", "unexpected": true }), - serde_json::json!({ "property": "idleTransparent", "value": null }), - serde_json::json!({ "property": "idleTransparent", "value": "true" }), - serde_json::json!({ "idleTransparent": true, "activeTransparent": false }), - serde_json::json!({ "property": "activeTransparent", "value": 1 }), - serde_json::json!({ "activeTransparent": false, "unexpected": true }), - serde_json::json!({ "property": "idleImageFit", "value": "stretch" }), - serde_json::json!({ "property": "idleImageFit", "value": null }), - serde_json::json!({ "idleImageFit": "cover", "activeImageFit": "contain" }), - serde_json::json!({ "property": "activeImageFit", "value": 1 }), - serde_json::json!({ "activeImageFit": "none", "unexpected": true }), - serde_json::json!({ "property": "imageMode", "value": "invalid" }), - serde_json::json!({ "property": "idleImageTransform", "value": { "leaf": "offsetX", "value": "1" } }), - serde_json::json!({ "property": "idleImageTransform", "value": { "leaf": "offsetX", "value": 1, "unexpected": true } }), - serde_json::json!({ "property": "activeImageTransform", "value": { "leaf": "unknown", "value": 1 } }), - serde_json::json!({ "property": "soundPath", "value": null }), - serde_json::json!({ "property": "soundPath", "value": 1 }), - serde_json::json!({ "soundPath": "path", "soundEnabled": true }), - serde_json::json!({ "soundPath": "path", "unexpected": true }), - serde_json::json!({ "property": "soundEnabled", "value": null }), - serde_json::json!({ "property": "soundEnabled", "value": "true" }), - serde_json::json!({ "soundEnabled": true, "unexpected": true }), - serde_json::json!({ "property": "soundVolume", "value": null }), - serde_json::json!({ "property": "soundVolume", "value": "100" }), - serde_json::json!({ "soundVolume": 100, "soundEnabled": true }), - serde_json::json!({ "soundVolume": 100, "unexpected": true }), - serde_json::json!({ "property": "counterEnabled", "value": 1 }), - serde_json::json!({ "property": "counterAnimationEnabled", "value": null }), - serde_json::json!({ "counterAnimationEnabled": true, "hidden": false }), - serde_json::json!({ "property": "counterPlacement", "value": "center" }), - serde_json::json!({ "property": "counterAlign", "value": "center" }), - serde_json::json!({ "property": "counterAlignMode", "value": "outside" }), - serde_json::json!({ "property": "counterGap", "value": -1 }), - serde_json::json!({ "property": "counterGap", "value": 1.5 }), - serde_json::json!({ "property": "counterGap", "value": 4_294_967_296_u64 }), - serde_json::json!({ "counterPlacement": "inside", "counterAlign": "top" }), - serde_json::json!({ "property": "counterFontSize", "value": -1 }), - serde_json::json!({ "property": "counterFontWeight", "value": "400" }), - serde_json::json!({ "property": "counterFontItalic", "value": null }), - serde_json::json!({ "property": "counterFontUnderline", "value": 1 }), - serde_json::json!({ "property": "counterFontStrikethrough", "value": "false" }), - serde_json::json!({ "counterFontSize": 16, "counterFontWeight": 400 }), - serde_json::json!({ "counterFontSize": 16, "unexpected": true }), - serde_json::json!({ "property": "counterFontFamily", "value": null }), - serde_json::json!({ "property": "counterFontFamily", "value": 1 }), - serde_json::json!({ "counterFontFamily": "font", "counterFontItalic": true }), - serde_json::json!({ "counterFontFamily": "font", "unexpected": true }), - serde_json::json!({ "property": "counterFillIdle", "value": {} }), - serde_json::json!({ "property": "counterFillIdle", "value": { "color": false } }), - serde_json::json!({ "property": "counterFillIdle", "value": { "color": "solid", "gradient": null } }), - serde_json::json!({ "property": "counterFillActive", "value": { "color": "first", "gradient": { "angle": 45 } } }), - serde_json::json!({ "property": "counterFillActive", "value": { "color": "first", "gradient": { "angle": 45, "stops": [{ "color": "first", "pos": 0, "unexpected": true }, { "color": "last", "pos": 1 }] } } }), - serde_json::json!({ "property": "counterFillIdle", "value": { "color": "solid", "unexpected": true } }), - serde_json::json!({ "counterFillActive": { "color": "active" }, "hidden": false }), - serde_json::json!({ "property": "counterAnimationPreset", "value": null }), - serde_json::json!({ "property": "counterAnimationPreset", "value": {} }), - serde_json::json!({ "property": "counterAnimationPreset", "value": { "presetId": "preset", "enabled": false } }), - serde_json::json!({ "property": "counterAnimationPreset", "value": { "presetId": "preset", "applyPresetId": false } }), - serde_json::json!({ "property": "counterAnimationPreset", "value": { "presetId": "preset", "scale": "1.2" } }), - serde_json::json!({ "counterAnimationPreset": { "presetId": "preset" }, "hidden": true }), - serde_json::json!({ "property": "noteGlowSize", "value": null }), - serde_json::json!({ "property": "noteGlowSize", "value": "20" }), - serde_json::json!({ "property": "noteGlowSyncPaint", "value": null }), - serde_json::json!({ "noteGlowSize": 20, "noteGlowEnabled": true }), - serde_json::json!({ "noteGlowSize": 20, "unexpected": true }), - serde_json::json!({ "property": "notePaint", "value": {} }), - serde_json::json!({ "property": "notePaint", "value": { "color": { "top": "a", "bottom": "b" } } }), - serde_json::json!({ "property": "notePaint", "value": { "color": { "type": "gradient", "top": "a", "bottom": "b", "unexpected": true } } }), - serde_json::json!({ "property": "notePaint", "value": { "opacity": 50, "opacityBottom": 60 } }), - serde_json::json!({ "property": "noteGlowPaint", "value": { "color": "x", "opacity": 70 } }), - serde_json::json!({ "property": "noteBorderPaint", "value": { "color": "#FFFFFF" } }), - serde_json::json!({ "property": "noteBorderPaint", "value": { "color": "#FFFFFF", "opacity": "100" } }), - serde_json::json!({ "notePaint": { "color": "x" }, "unexpected": true }), - serde_json::json!({ "property": "noteOffsetX", "value": "0" }), - serde_json::json!({ "noteOffsetX": null, "noteOffsetY": null }), - serde_json::json!({ "noteOffsetY": null, "unexpected": true }), - serde_json::json!({ "property": "noteWidth", "value": "20" }), - serde_json::json!({ "noteWidth": null, "hidden": true }), - serde_json::json!({ "property": "noteBorderWidth", "value": null }), - serde_json::json!({ "property": "noteBorderWidth", "value": "1" }), - serde_json::json!({ "property": "noteBorderRadius", "value": null }), - serde_json::json!({ "noteBorderRadius": 4, "unexpected": true }), - ] { - let mut invalid_image = layer_name_wire.clone(); - invalid_image["editorOps"][0]["patch"] = patch; - let error = decode_gesture_commit_request(invalid_image).unwrap_err(); - assert_eq!( - validation_code(error).as_deref(), - Some("INVALID_REQUEST_PAYLOAD") - ); - } - - let mut invalid_stat_type = layer_name_wire.clone(); - invalid_stat_type["editorOps"][0]["patch"] = - serde_json::json!({ "property": "statType", "value": "invalid" }); - let error = decode_gesture_commit_request(invalid_stat_type).unwrap_err(); - assert_eq!( - validation_code(error).as_deref(), - Some("INVALID_REQUEST_PAYLOAD") - ); - - let mut request = gesture_request(&["plugin-a".to_string()]); - request.editor_ops_version = Some(EDITOR_OPS_VERSION); - request.editor_ops = Some(vec![EditorOpV1::PatchElement { - element_type: EditorElementTypeV1::Stat, - id: uuid::Uuid::new_v4().to_string(), - patch: EditorElementPropertyPatchV1::FontItalic(false), - }]); - let mut invalid_font = serde_json::to_value(request).unwrap(); - invalid_font["editorOps"][0]["patch"] = - serde_json::json!({ "fontItalic": false, "fontWeight": 400 }); - let error = decode_gesture_commit_request(invalid_font).unwrap_err(); - assert_eq!( - validation_code(error).as_deref(), - Some("INVALID_REQUEST_PAYLOAD") - ); - - let mut invalid_graph_type = graph_type_wire; - invalid_graph_type["editorOps"][0]["patch"]["graphType"] = serde_json::json!("area"); - let error = decode_gesture_commit_request(invalid_graph_type).unwrap_err(); - assert_eq!( - validation_code(error).as_deref(), - Some("INVALID_REQUEST_PAYLOAD") - ); - - let mut multiple_properties = layer_name_wire; - multiple_properties["editorOps"][0]["patch"]["hidden"] = serde_json::json!(true); - let error = decode_gesture_commit_request(multiple_properties).unwrap_err(); - assert_eq!( - validation_code(error).as_deref(), - Some("INVALID_REQUEST_PAYLOAD") - ); - - let mut slot = gesture_request(&["plugin-a".to_string()]); - slot.editor_ops_version = Some(EDITOR_OPS_VERSION); - slot.editor_ops = Some(vec![EditorOpV1::SetKeySlot { - id: uuid::Uuid::new_v4().to_string(), - slot: EditorFrozenKeySlotV1::Multi(crate::models::EditorFrozenMultiKeySlotV1 { - keys: vec!["A".to_string(), "B".to_string()], - match_mode: crate::models::SlotMatch::All, - }), - }]); - let mut slot_wire = serde_json::to_value(slot).unwrap(); - slot_wire["editorOps"][0]["slot"]["unexpected"] = serde_json::json!(true); - let error = decode_gesture_commit_request(slot_wire).unwrap_err(); - assert_eq!( - validation_code(error).as_deref(), - Some("INVALID_REQUEST_PAYLOAD") - ); - } - - #[test] - fn gesture_rejects_more_than_sixty_four_plugin_changes() { - let plugin_ids = (0..65) - .map(|index| format!("plugin-{index}")) - .collect::>(); - let error = validate_gesture_commit_request(&gesture_request(&plugin_ids)).unwrap_err(); - - assert_eq!( - validation_code(error).as_deref(), - Some("INVALID_GESTURE_PLUGIN_COUNT") - ); - } - - #[test] - fn gesture_rejects_duplicate_plugin_changes() { - let plugin_ids = vec!["plugin-a".to_string(), "plugin-a".to_string()]; - let error = validate_gesture_commit_request(&gesture_request(&plugin_ids)).unwrap_err(); - - assert_eq!( - validation_code(error).as_deref(), - Some("DUPLICATE_GESTURE_PLUGIN") - ); - } -} +mod tests; diff --git a/src-tauri/src/state/gesture/tests.rs b/src-tauri/src/state/gesture/tests.rs new file mode 100644 index 000000000..129d9eede --- /dev/null +++ b/src-tauri/src/state/gesture/tests.rs @@ -0,0 +1,1071 @@ +use super::*; +use crate::models::{ + EditorBoundsV1, EditorElementGroupTargetV1, EditorElementPropertyPatchV1, EditorElementTypeV1, + EditorFrozenKeySlotV1, EditorGroupUpdateV1, EditorOpV1, EditorPatchV1, EditorTargetGroupV1, + EditorZUpdateV1, GesturePluginInstancesChange, EDITOR_OPS_VERSION, +}; + +fn gesture_request(plugin_ids: &[String]) -> GestureCommitRequest { + GestureCommitRequest { + gesture_id: uuid::Uuid::new_v4().to_string(), + mutation_id: uuid::Uuid::new_v4().to_string(), + editor_base_revision: 0, + plugin_base_revision: 0, + observed_history_epoch: None, + authority_generation: 1, + editor_changes: None, + editor_ops_version: None, + editor_ops: None, + plugin_changes: plugin_ids + .iter() + .map(|plugin_id| GesturePluginInstancesChange { + plugin_id: plugin_id.clone(), + instances: Vec::new(), + }) + .collect(), + } +} + +fn validation_code(error: EditorCommitError) -> Option { + error.details.and_then(|details| details.validation_code) +} + +fn set_bounds_op() -> EditorOpV1 { + EditorOpV1::SetBounds { + element_type: EditorElementTypeV1::Key, + id: uuid::Uuid::new_v4().to_string(), + bounds: EditorBoundsV1 { + dx: 1.0, + dy: 2.0, + width: 3.0, + height: 4.0, + }, + } +} + +fn frozen_insert_op() -> EditorOpV1 { + EditorOpV1::InsertFrozenElements { + mode: "4key".to_string(), + elements: vec![crate::models::EditorFrozenElementV1::Key { + slot: crate::models::EditorFrozenKeySlotV1::Single("FROZEN".to_string()), + position: crate::models::KeyPosition { + id: uuid::Uuid::new_v4().to_string(), + ..crate::models::KeyPosition::default() + }, + }], + groups: Vec::new(), + z_updates: Vec::new(), + } +} + +fn reorder_op() -> EditorOpV1 { + let id = uuid::Uuid::new_v4().to_string(); + EditorOpV1::ReorderElements { + mode: "4key".to_string(), + complete_mode_order: true, + z_updates: vec![EditorZUpdateV1 { + element_type: EditorElementTypeV1::Key, + id: id.clone(), + z_index: 1, + }], + group_updates: vec![EditorGroupUpdateV1 { + element_type: EditorElementTypeV1::Key, + id, + group_id: None, + }], + } +} + +fn property_op() -> EditorOpV1 { + EditorOpV1::PatchElement { + element_type: EditorElementTypeV1::Key, + id: uuid::Uuid::new_v4().to_string(), + patch: EditorElementPropertyPatchV1::Hidden(true), + } +} + +#[test] +fn gesture_allows_plugin_only_patch_or_ops_but_not_both_editor_mutations() { + let plugin_ids = ["plugin-a".to_string()]; + let plugin_only = gesture_request(&plugin_ids); + validate_gesture_commit_request(&plugin_only).unwrap(); + let mut invalid_plugin_only = plugin_only.clone(); + invalid_plugin_only.gesture_id = "not-a-uuid".to_string(); + assert_eq!( + validate_gesture_commit_request(&invalid_plugin_only) + .unwrap_err() + .error_code, + crate::errors::EditorCommitErrorCode::InvalidGestureId + ); + + let mut patch = plugin_only.clone(); + patch.editor_changes = Some(EditorPatchV1::default()); + validate_gesture_commit_request(&patch).unwrap(); + + let mut ops = plugin_only.clone(); + ops.editor_ops_version = Some(EDITOR_OPS_VERSION); + ops.editor_ops = Some(vec![set_bounds_op()]); + validate_gesture_commit_request(&ops).unwrap(); + + let mut both = ops.clone(); + both.editor_changes = Some(EditorPatchV1::default()); + assert_eq!( + validation_code(validate_gesture_commit_request(&both).unwrap_err()).as_deref(), + Some("INVALID_GESTURE_EDITOR_MUTATION") + ); + + for invalid in [ + GestureCommitRequest { + editor_ops: None, + ..ops.clone() + }, + GestureCommitRequest { + editor_ops_version: None, + ..ops + }, + ] { + assert_eq!( + validation_code(validate_gesture_commit_request(&invalid).unwrap_err()).as_deref(), + Some("INVALID_GESTURE_EDITOR_MUTATION") + ); + } +} + +#[test] +fn gesture_requires_at_least_one_plugin_change() { + let error = validate_gesture_commit_request(&gesture_request(&[])).unwrap_err(); + + assert_eq!( + validation_code(error).as_deref(), + Some("INVALID_GESTURE_PLUGIN_COUNT") + ); +} + +#[test] +fn gesture_frozen_insert_wire_rejects_unknown_nested_keys() { + let mut request = gesture_request(&["plugin-a".to_string()]); + request.editor_ops_version = Some(EDITOR_OPS_VERSION); + request.editor_ops = Some(vec![frozen_insert_op()]); + let mut wire = serde_json::to_value(request).unwrap(); + wire["editorOps"][0]["elements"][0]["position"]["unexpected"] = serde_json::json!(true); + + let error = decode_gesture_commit_request(wire).unwrap_err(); + assert_eq!( + validation_code(error).as_deref(), + Some("INVALID_REQUEST_PAYLOAD") + ); +} + +#[test] +fn gesture_reorder_wire_rejects_unknown_and_missing_nested_keys() { + let mut request = gesture_request(&["plugin-a".to_string()]); + request.editor_ops_version = Some(EDITOR_OPS_VERSION); + request.editor_ops = Some(vec![reorder_op()]); + let valid = serde_json::to_value(request).unwrap(); + + let mut unknown_z = valid.clone(); + unknown_z["editorOps"][0]["zUpdates"][0]["unexpected"] = serde_json::json!(true); + let mut unknown_group = valid.clone(); + unknown_group["editorOps"][0]["groupUpdates"][0]["unexpected"] = serde_json::json!(true); + let mut missing_group_id = valid; + missing_group_id["editorOps"][0]["groupUpdates"][0] + .as_object_mut() + .unwrap() + .remove("groupId"); + + for wire in [unknown_z, unknown_group, missing_group_id] { + let error = decode_gesture_commit_request(wire).unwrap_err(); + assert_eq!( + validation_code(error).as_deref(), + Some("INVALID_REQUEST_PAYLOAD") + ); + } +} + +#[test] +fn gesture_group_structural_wire_is_exact_tagged_and_required_nullable() { + let target = EditorElementGroupTargetV1 { + element_type: EditorElementTypeV1::Key, + id: uuid::Uuid::new_v4().to_string(), + }; + let mut request = gesture_request(&["plugin-a".to_string()]); + request.editor_ops_version = Some(EDITOR_OPS_VERSION); + request.editor_ops = Some(vec![EditorOpV1::SetElementGroups { + mode: "4key".to_string(), + targets: vec![target.clone()], + target_group: Some(EditorTargetGroupV1::Create { + id: " legacy group ".to_string(), + name: " Raw Name ".to_string(), + }), + }]); + let create = serde_json::to_value(request).unwrap(); + assert_eq!( + create["editorOps"][0]["targetGroup"], + serde_json::json!({ + "kind": "create", + "id": " legacy group ", + "name": " Raw Name ", + }) + ); + let decoded = decode_gesture_commit_request(create.clone()).unwrap(); + validate_gesture_commit_request(&decoded).unwrap(); + + let mut ungroup_request = gesture_request(&["plugin-a".to_string()]); + ungroup_request.editor_ops_version = Some(EDITOR_OPS_VERSION); + ungroup_request.editor_ops = Some(vec![EditorOpV1::SetElementGroups { + mode: "4key".to_string(), + targets: vec![target], + target_group: None, + }]); + let ungroup = serde_json::to_value(ungroup_request).unwrap(); + assert!(ungroup["editorOps"][0]["targetGroup"].is_null()); + let decoded = decode_gesture_commit_request(ungroup.clone()).unwrap(); + validate_gesture_commit_request(&decoded).unwrap(); + + let mut rename_request = gesture_request(&["plugin-a".to_string()]); + rename_request.editor_ops_version = Some(EDITOR_OPS_VERSION); + rename_request.editor_ops = Some(vec![EditorOpV1::RenameLayerGroup { + mode: "4key".to_string(), + group_id: "legacy-group".to_string(), + name: "Name".to_string(), + }]); + let rename = serde_json::to_value(rename_request).unwrap(); + assert_eq!(rename["editorOps"][0]["groupId"], "legacy-group"); + let decoded = decode_gesture_commit_request(rename.clone()).unwrap(); + validate_gesture_commit_request(&decoded).unwrap(); + + let mut missing_target_group = ungroup.clone(); + missing_target_group["editorOps"][0] + .as_object_mut() + .unwrap() + .remove("targetGroup"); + let mut untagged = create.clone(); + untagged["editorOps"][0]["targetGroup"] = serde_json::json!({ + "id": "group", + "name": "Group", + }); + let mut combined = create.clone(); + combined["editorOps"][0]["targetGroup"] = serde_json::json!({ + "kind": "existing", + "id": "group", + "name": "No rename", + }); + let mut target_extra = create.clone(); + target_extra["editorOps"][0]["targets"][0]["unexpected"] = serde_json::json!(true); + let mut group_extra = create; + group_extra["editorOps"][0]["targetGroup"]["unexpected"] = serde_json::json!(true); + let mut rename_extra = rename; + rename_extra["editorOps"][0]["unexpected"] = serde_json::json!(true); + + for invalid in [ + missing_target_group, + untagged, + combined, + target_extra, + group_extra, + rename_extra, + ] { + let error = decode_gesture_commit_request(invalid).unwrap_err(); + assert_eq!( + validation_code(error).as_deref(), + Some("INVALID_REQUEST_PAYLOAD") + ); + } +} + +#[test] +fn gesture_property_and_key_slot_wires_reject_unknown_nested_keys() { + let mut property = gesture_request(&["plugin-a".to_string()]); + property.editor_ops_version = Some(EDITOR_OPS_VERSION); + property.editor_ops = Some(vec![property_op()]); + let mut property_wire = serde_json::to_value(property).unwrap(); + property_wire["editorOps"][0]["patch"]["width"] = serde_json::json!(1); + let error = decode_gesture_commit_request(property_wire).unwrap_err(); + assert_eq!( + validation_code(error).as_deref(), + Some("INVALID_REQUEST_PAYLOAD") + ); + + let mut layer_name = gesture_request(&["plugin-a".to_string()]); + layer_name.editor_ops_version = Some(EDITOR_OPS_VERSION); + layer_name.editor_ops = Some(vec![EditorOpV1::PatchElement { + element_type: EditorElementTypeV1::Graph, + id: uuid::Uuid::new_v4().to_string(), + patch: EditorElementPropertyPatchV1::LayerName(None), + }]); + let layer_name_wire = serde_json::to_value(layer_name).unwrap(); + assert_eq!( + layer_name_wire["editorOps"][0]["patch"], + serde_json::json!({ "property": "layerName", "value": null }) + ); + decode_gesture_commit_request(layer_name_wire.clone()).unwrap(); + + let mut graph_type = gesture_request(&["plugin-a".to_string()]); + graph_type.editor_ops_version = Some(EDITOR_OPS_VERSION); + graph_type.editor_ops = Some(vec![EditorOpV1::PatchElement { + element_type: EditorElementTypeV1::Graph, + id: uuid::Uuid::new_v4().to_string(), + patch: EditorElementPropertyPatchV1::GraphType(crate::models::GraphType::Bar), + }]); + let graph_type_wire = serde_json::to_value(graph_type).unwrap(); + assert_eq!( + graph_type_wire["editorOps"][0]["patch"], + serde_json::json!({ "property": "graphType", "value": "bar" }) + ); + decode_gesture_commit_request(graph_type_wire.clone()).unwrap(); + + let mut graph_color = gesture_request(&["plugin-a".to_string()]); + graph_color.editor_ops_version = Some(EDITOR_OPS_VERSION); + graph_color.editor_ops = Some(vec![EditorOpV1::PatchElement { + element_type: EditorElementTypeV1::Graph, + id: uuid::Uuid::new_v4().to_string(), + patch: EditorElementPropertyPatchV1::GraphColor("not-normalized".to_string()), + }]); + let graph_color_wire = serde_json::to_value(graph_color).unwrap(); + assert_eq!( + graph_color_wire["editorOps"][0]["patch"], + serde_json::json!({ "property": "graphColor", "value": "not-normalized" }) + ); + decode_gesture_commit_request(graph_color_wire.clone()).unwrap(); + + let mut paint = gesture_request(&["plugin-a".to_string()]); + paint.editor_ops_version = Some(EDITOR_OPS_VERSION); + paint.editor_ops = Some(vec![EditorOpV1::PatchElement { + element_type: EditorElementTypeV1::Knob, + id: uuid::Uuid::new_v4().to_string(), + patch: EditorElementPropertyPatchV1::ActiveBorderPaint( + crate::models::EditorPaintDescriptorV1 { + color: "active".to_string(), + gradient: None, + }, + ), + }]); + let paint_wire = serde_json::to_value(paint).unwrap(); + assert_eq!( + paint_wire["editorOps"][0]["patch"], + serde_json::json!({ "property": "activeBorderPaint", "value": { "color": "active", "gradient": null } }) + ); + decode_gesture_commit_request(paint_wire.clone()).unwrap(); + + let literal_properties = [ + ( + EditorElementTypeV1::Graph, + EditorElementPropertyPatchV1::ShowAvgLine(true), + serde_json::json!({ "property": "showAvgLine", "value": true }), + ), + ( + EditorElementTypeV1::Graph, + EditorElementPropertyPatchV1::GraphAnimationEnabled(false), + serde_json::json!({ "property": "graphAnimationEnabled", "value": false }), + ), + ( + EditorElementTypeV1::Graph, + EditorElementPropertyPatchV1::GraphSpeed(0), + serde_json::json!({ "property": "graphSpeed", "value": 0 }), + ), + ( + EditorElementTypeV1::Knob, + EditorElementPropertyPatchV1::Reverse(true), + serde_json::json!({ "property": "reverse", "value": true }), + ), + ( + EditorElementTypeV1::Knob, + EditorElementPropertyPatchV1::Sensitivity(-7.25), + serde_json::json!({ "property": "sensitivity", "value": -7.25 }), + ), + ( + EditorElementTypeV1::Knob, + EditorElementPropertyPatchV1::AxisId(" HIDA:raw ".to_string()), + serde_json::json!({ "property": "axisId", "value": " HIDA:raw " }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::UseInlineStyles(false), + serde_json::json!({ "property": "useInlineStyles", "value": false }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::FontWeight(400), + serde_json::json!({ "property": "fontWeight", "value": 400 }), + ), + ( + EditorElementTypeV1::Stat, + EditorElementPropertyPatchV1::FontItalic(false), + serde_json::json!({ "property": "fontItalic", "value": false }), + ), + ( + EditorElementTypeV1::Graph, + EditorElementPropertyPatchV1::FontUnderline(true), + serde_json::json!({ "property": "fontUnderline", "value": true }), + ), + ( + EditorElementTypeV1::Knob, + EditorElementPropertyPatchV1::FontStrikethrough(false), + serde_json::json!({ "property": "fontStrikethrough", "value": false }), + ), + ( + EditorElementTypeV1::Graph, + EditorElementPropertyPatchV1::FontFamily(" raw-font ".to_string()), + serde_json::json!({ "property": "fontFamily", "value": " raw-font " }), + ), + ( + EditorElementTypeV1::Knob, + EditorElementPropertyPatchV1::DisplayText(" raw display ".to_string()), + serde_json::json!({ "property": "displayText", "value": " raw display " }), + ), + ( + EditorElementTypeV1::Graph, + EditorElementPropertyPatchV1::ClassName(" raw class ".to_string()), + serde_json::json!({ "property": "className", "value": " raw class " }), + ), + ( + EditorElementTypeV1::Stat, + EditorElementPropertyPatchV1::FontPaint(crate::models::EditorPaintDescriptorV1 { + color: String::new(), + gradient: None, + }), + serde_json::json!({ "property": "fontPaint", "value": { "color": "", "gradient": null } }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::ActiveFontPaint(crate::models::EditorPaintDescriptorV1 { + color: " raw active font ".to_string(), + gradient: None, + }), + serde_json::json!({ "property": "activeFontPaint", "value": { "color": " raw active font ", "gradient": null } }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::Shadow(crate::models::EditorShadowLeafPatchV1::Blur(8.5)), + serde_json::json!({ "property": "shadow", "value": { "leaf": "blur", "value": 8.5 } }), + ), + ( + EditorElementTypeV1::Knob, + EditorElementPropertyPatchV1::ActiveShadow( + crate::models::EditorShadowLeafPatchV1::OffsetY(-3.25), + ), + serde_json::json!({ "property": "activeShadow", "value": { "leaf": "offsetY", "value": -3.25 } }), + ), + ( + EditorElementTypeV1::Stat, + EditorElementPropertyPatchV1::ShadowEnabled(true), + serde_json::json!({ "property": "shadowEnabled", "value": true }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::BorderWidth(0.5), + serde_json::json!({ "property": "borderWidth", "value": 0.5 }), + ), + ( + EditorElementTypeV1::Knob, + EditorElementPropertyPatchV1::BorderRadius(999.0), + serde_json::json!({ "property": "borderRadius", "value": 999.0 }), + ), + ( + EditorElementTypeV1::Stat, + EditorElementPropertyPatchV1::FontSize(8.5), + serde_json::json!({ "property": "fontSize", "value": 8.5 }), + ), + ( + EditorElementTypeV1::Stat, + EditorElementPropertyPatchV1::InactiveImage(String::new()), + serde_json::json!({ "property": "inactiveImage", "value": "" }), + ), + ( + EditorElementTypeV1::Knob, + EditorElementPropertyPatchV1::ActiveImage(" active.png ".to_string()), + serde_json::json!({ "property": "activeImage", "value": " active.png " }), + ), + ( + EditorElementTypeV1::Stat, + EditorElementPropertyPatchV1::IdleTransparent(true), + serde_json::json!({ "property": "idleTransparent", "value": true }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::ActiveTransparent(false), + serde_json::json!({ "property": "activeTransparent", "value": false }), + ), + ( + EditorElementTypeV1::Stat, + EditorElementPropertyPatchV1::IdleImageFit(crate::models::ImageFit::Fill), + serde_json::json!({ "property": "idleImageFit", "value": "fill" }), + ), + ( + EditorElementTypeV1::Knob, + EditorElementPropertyPatchV1::ActiveImageFit(crate::models::ImageFit::Cover), + serde_json::json!({ "property": "activeImageFit", "value": "cover" }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::ImageMode(crate::models::ImageMode::Overlay), + serde_json::json!({ "property": "imageMode", "value": "overlay" }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::IdleImageTransform(Some( + crate::models::ImageTransformLeafPatchV1::OffsetX(-125.5), + )), + serde_json::json!({ "property": "idleImageTransform", "value": { "leaf": "offsetX", "value": -125.5 } }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::ActiveImageTransform(None), + serde_json::json!({ "property": "activeImageTransform", "value": null }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::SoundPath(String::new()), + serde_json::json!({ "property": "soundPath", "value": "" }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::SoundEnabled(false), + serde_json::json!({ "property": "soundEnabled", "value": false }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::SoundVolume(137.5), + serde_json::json!({ "property": "soundVolume", "value": 137.5 }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::CounterEnabled(true), + serde_json::json!({ "property": "counterEnabled", "value": true }), + ), + ( + EditorElementTypeV1::Stat, + EditorElementPropertyPatchV1::CounterAnimationEnabled(false), + serde_json::json!({ "property": "counterAnimationEnabled", "value": false }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::CounterPlacement( + crate::models::KeyCounterPlacement::Outside, + ), + serde_json::json!({ "property": "counterPlacement", "value": "outside" }), + ), + ( + EditorElementTypeV1::Stat, + EditorElementPropertyPatchV1::CounterAlign(crate::models::KeyCounterAlign::Right), + serde_json::json!({ "property": "counterAlign", "value": "right" }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::CounterAlignMode( + crate::models::KeyCounterAlignMode::Between, + ), + serde_json::json!({ "property": "counterAlignMode", "value": "between" }), + ), + ( + EditorElementTypeV1::Stat, + EditorElementPropertyPatchV1::CounterGap(u32::MAX), + serde_json::json!({ "property": "counterGap", "value": u32::MAX }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::CounterFontSize(72), + serde_json::json!({ "property": "counterFontSize", "value": 72 }), + ), + ( + EditorElementTypeV1::Stat, + EditorElementPropertyPatchV1::CounterFontWeight(900), + serde_json::json!({ "property": "counterFontWeight", "value": 900 }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::CounterFontItalic(true), + serde_json::json!({ "property": "counterFontItalic", "value": true }), + ), + ( + EditorElementTypeV1::Stat, + EditorElementPropertyPatchV1::CounterFontUnderline(true), + serde_json::json!({ "property": "counterFontUnderline", "value": true }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::CounterFontStrikethrough(true), + serde_json::json!({ "property": "counterFontStrikethrough", "value": true }), + ), + ( + EditorElementTypeV1::Stat, + EditorElementPropertyPatchV1::CounterFontFamily(" raw-counter-font ".to_string()), + serde_json::json!({ "property": "counterFontFamily", "value": " raw-counter-font " }), + ), + ( + EditorElementTypeV1::Stat, + EditorElementPropertyPatchV1::CounterFillIdle( + crate::models::EditorCounterFillIntentV1::Solid( + crate::models::EditorCounterFillSolidIntentV1 { + color: String::new(), + }, + ), + ), + serde_json::json!({ "property": "counterFillIdle", "value": { "color": "" } }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::CounterFillActive( + crate::models::EditorCounterFillIntentV1::Gradient( + crate::models::EditorCounterFillGradientIntentV1 { + color: "rgba(170,187,204,1)".to_string(), + gradient: crate::models::EditorPaintGradientV1 { + angle: 180.0, + stops: vec![ + crate::models::EditorPaintGradientStopV1 { + color: "#ABC".to_string(), + pos: 0.0, + }, + crate::models::EditorPaintGradientStopV1 { + color: "transparent".to_string(), + pos: 1.0, + }, + ], + }, + }, + ), + ), + serde_json::json!({ "property": "counterFillActive", "value": { "color": "rgba(170,187,204,1)", "gradient": { "angle": 180.0, "stops": [ { "color": "#ABC", "pos": 0.0 }, { "color": "transparent", "pos": 1.0 } ] } } }), + ), + ( + EditorElementTypeV1::Stat, + EditorElementPropertyPatchV1::CounterAnimationPreset( + crate::models::EditorCounterAnimationPresetIntentV1 { + preset_id: "builtin-ease-out".to_string(), + apply_preset_id: None, + bezier: None, + scale: None, + duration_ms: None, + }, + ), + serde_json::json!({ "property": "counterAnimationPreset", "value": { "presetId": "builtin-ease-out" } }), + ), + ( + EditorElementTypeV1::Stat, + EditorElementPropertyPatchV1::StatType(crate::models::StatType::Total), + serde_json::json!({ "property": "statType", "value": "total" }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::NoteEffectEnabled(false), + serde_json::json!({ "property": "noteEffectEnabled", "value": false }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::NoteGlowEnabled(true), + serde_json::json!({ "property": "noteGlowEnabled", "value": true }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::NoteGlowSyncPaint(true), + serde_json::json!({ "property": "noteGlowSyncPaint", "value": true }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::NoteGlowSize(19.75), + serde_json::json!({ "property": "noteGlowSize", "value": 19.75 }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::NotePaint(crate::models::EditorNotePaintIntentV1::Color( + crate::models::EditorNotePaintColorIntentV1 { + color: crate::models::EditorNoteColorV1::Solid(String::new()), + }, + )), + serde_json::json!({ "property": "notePaint", "value": { "color": "" } }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::NoteGlowPaint( + crate::models::EditorNotePaintIntentV1::Opacity( + crate::models::EditorNotePaintOpacityIntentV1 { opacity: 70 }, + ), + ), + serde_json::json!({ "property": "noteGlowPaint", "value": { "opacity": 70 } }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::NoteBorderPaint(crate::models::EditorNoteBorderPaintV1 { + color: "#112233".to_string(), + opacity: 0, + gradient: None, + }), + serde_json::json!({ "property": "noteBorderPaint", "value": { "color": "#112233", "opacity": 0 } }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::NoteOffsetX(Some(0.0)), + serde_json::json!({ "property": "noteOffsetX", "value": 0.0 }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::NoteOffsetY(None), + serde_json::json!({ "property": "noteOffsetY", "value": null }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::NoteWidth(Some(32.5)), + serde_json::json!({ "property": "noteWidth", "value": 32.5 }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::NoteBorderWidth(2.5), + serde_json::json!({ "property": "noteBorderWidth", "value": 2.5 }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::NoteBorderRadius(4.0), + serde_json::json!({ "property": "noteBorderRadius", "value": 4.0 }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::NoteAutoYCorrection(false), + serde_json::json!({ "property": "noteAutoYCorrection", "value": false }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::NoteAlignment(crate::models::NoteAlignment::Left), + serde_json::json!({ "property": "noteAlignment", "value": "left" }), + ), + ( + EditorElementTypeV1::Key, + EditorElementPropertyPatchV1::NoteBorderSide( + crate::models::EditorNoteBorderSideV1::Horizontal, + ), + serde_json::json!({ "property": "noteBorderSide", "value": "horizontal" }), + ), + ]; + for (element_type, patch, expected) in literal_properties { + let mut request = gesture_request(&["plugin-a".to_string()]); + request.editor_ops_version = Some(EDITOR_OPS_VERSION); + request.editor_ops = Some(vec![EditorOpV1::PatchElement { + element_type, + id: uuid::Uuid::new_v4().to_string(), + patch, + }]); + let wire = serde_json::to_value(request).unwrap(); + assert_eq!(wire["editorOps"][0]["patch"], expected); + decode_gesture_commit_request(wire).unwrap(); + } + + let mut invalid_axis_id = graph_color_wire.clone(); + invalid_axis_id["editorOps"][0]["patch"] = + serde_json::json!({ "property": "axisId", "value": false }); + let error = decode_gesture_commit_request(invalid_axis_id).unwrap_err(); + assert_eq!( + validation_code(error).as_deref(), + Some("INVALID_REQUEST_PAYLOAD") + ); + for patch in [ + serde_json::json!({ "axisId": "axis", "hidden": true }), + serde_json::json!({ "axisId": "axis", "unexpected": true }), + ] { + let mut invalid_axis_id = graph_color_wire.clone(); + invalid_axis_id["editorOps"][0]["patch"] = patch; + let error = decode_gesture_commit_request(invalid_axis_id).unwrap_err(); + assert_eq!( + validation_code(error).as_deref(), + Some("INVALID_REQUEST_PAYLOAD") + ); + } + + let mut invalid_graph_color = graph_color_wire; + invalid_graph_color["editorOps"][0]["patch"]["graphColor"] = serde_json::json!(false); + let error = decode_gesture_commit_request(invalid_graph_color).unwrap_err(); + assert_eq!( + validation_code(error).as_deref(), + Some("INVALID_REQUEST_PAYLOAD") + ); + + let mut invalid_font_family = layer_name_wire.clone(); + invalid_font_family["editorOps"][0]["patch"] = + serde_json::json!({ "property": "fontFamily", "value": null }); + let error = decode_gesture_commit_request(invalid_font_family).unwrap_err(); + assert_eq!( + validation_code(error).as_deref(), + Some("INVALID_REQUEST_PAYLOAD") + ); + + for patch in [ + serde_json::json!({ "property": "displayText", "value": null }), + serde_json::json!({ "property": "displayText", "value": 1 }), + serde_json::json!({ "displayText": "text", "hidden": true }), + serde_json::json!({ "displayText": "text", "unexpected": true }), + ] { + let mut invalid_display_text = layer_name_wire.clone(); + invalid_display_text["editorOps"][0]["patch"] = patch; + let error = decode_gesture_commit_request(invalid_display_text).unwrap_err(); + assert_eq!( + validation_code(error).as_deref(), + Some("INVALID_REQUEST_PAYLOAD") + ); + } + + for patch in [ + serde_json::json!({ "property": "className", "value": null }), + serde_json::json!({ "property": "className", "value": 1 }), + serde_json::json!({ "className": "class", "hidden": true }), + serde_json::json!({ "className": "class", "unexpected": true }), + ] { + let mut invalid_class_name = layer_name_wire.clone(); + invalid_class_name["editorOps"][0]["patch"] = patch; + let error = decode_gesture_commit_request(invalid_class_name).unwrap_err(); + assert_eq!( + validation_code(error).as_deref(), + Some("INVALID_REQUEST_PAYLOAD") + ); + } + + for patch in [ + serde_json::json!({ "property": "fontPaint", "value": null }), + serde_json::json!({ "property": "fontPaint", "value": { "color": "idle" } }), + serde_json::json!({ "property": "activeFontPaint", "value": 1 }), + serde_json::json!({ "fontPaint": { "color": "idle", "gradient": null }, "activeFontPaint": { "color": "active", "gradient": null } }), + serde_json::json!({ "property": "fontPaint", "value": { "color": "idle", "gradient": null, "unexpected": true } }), + ] { + let mut invalid_font_color = layer_name_wire.clone(); + invalid_font_color["editorOps"][0]["patch"] = patch; + let error = decode_gesture_commit_request(invalid_font_color).unwrap_err(); + assert_eq!( + validation_code(error).as_deref(), + Some("INVALID_REQUEST_PAYLOAD") + ); + } + + for patch in [ + serde_json::json!({ "property": "shadow", "value": {} }), + serde_json::json!({ "property": "shadow", "value": { "offsetX": 1, "blur": 2 } }), + serde_json::json!({ "property": "shadow", "value": { "color": "shadow", "unexpected": true } }), + serde_json::json!({ "property": "activeShadow", "value": null }), + serde_json::json!({ "property": "activeShadow", "value": { "leaf": "offsetY", "value": "1" } }), + serde_json::json!({ "property": "shadowEnabled", "value": "true" }), + serde_json::json!({ "shadow": { "blur": 1 }, "hidden": false }), + ] { + let mut invalid_shadow = layer_name_wire.clone(); + invalid_shadow["editorOps"][0]["patch"] = patch; + let error = decode_gesture_commit_request(invalid_shadow).unwrap_err(); + assert_eq!( + validation_code(error).as_deref(), + Some("INVALID_REQUEST_PAYLOAD") + ); + } + + for patch in [ + serde_json::json!({ "property": "activeBorderPaint", "value": {} }), + serde_json::json!({ "property": "activeBorderPaint", "value": { "color": "active" } }), + serde_json::json!({ "property": "activeBorderPaint", "value": { "color": "active", "gradient": null, "unexpected": true } }), + serde_json::json!({ "property": "activeBorderPaint", "value": { "color": "active", "gradient": { "stops": [{ "color": "active", "pos": 0 }, { "color": "tail", "pos": 1 }] } } }), + serde_json::json!({ "property": "activeBorderPaint", "value": { "color": "active", "gradient": { "angle": 90 } } }), + serde_json::json!({ "property": "activeBorderPaint", "value": { "color": "active", "gradient": { "angle": 90, "stops": [{ "color": "active", "pos": 0, "unexpected": true }, { "color": "tail", "pos": 1 }] } } }), + serde_json::json!({ "activeBorderPaint": { "color": "active", "gradient": null }, "hidden": false }), + ] { + let mut invalid_paint = paint_wire.clone(); + invalid_paint["editorOps"][0]["patch"] = patch; + let error = decode_gesture_commit_request(invalid_paint).unwrap_err(); + assert_eq!( + validation_code(error).as_deref(), + Some("INVALID_REQUEST_PAYLOAD") + ); + } + + for patch in [ + serde_json::json!({ "property": "borderWidth", "value": null }), + serde_json::json!({ "property": "borderWidth", "value": "1" }), + serde_json::json!({ "borderWidth": 1, "fontSize": 14 }), + serde_json::json!({ "borderWidth": 1, "unexpected": true }), + serde_json::json!({ "property": "borderRadius", "value": null }), + serde_json::json!({ "property": "borderRadius", "value": "1" }), + serde_json::json!({ "borderRadius": 1, "hidden": false }), + serde_json::json!({ "property": "fontSize", "value": null }), + serde_json::json!({ "property": "fontSize", "value": "14" }), + serde_json::json!({ "fontSize": 14, "unexpected": true }), + ] { + let mut invalid_numeric_style = layer_name_wire.clone(); + invalid_numeric_style["editorOps"][0]["patch"] = patch; + let error = decode_gesture_commit_request(invalid_numeric_style).unwrap_err(); + assert_eq!( + validation_code(error).as_deref(), + Some("INVALID_REQUEST_PAYLOAD") + ); + } + + for patch in [ + serde_json::json!({ "property": "inactiveImage", "value": null }), + serde_json::json!({ "inactiveImage": "path", "hidden": false }), + serde_json::json!({ "inactiveImage": "path", "unexpected": true }), + serde_json::json!({ "property": "activeImage", "value": null }), + serde_json::json!({ "activeImage": "path", "hidden": false }), + serde_json::json!({ "activeImage": "path", "unexpected": true }), + serde_json::json!({ "property": "idleTransparent", "value": null }), + serde_json::json!({ "property": "idleTransparent", "value": "true" }), + serde_json::json!({ "idleTransparent": true, "activeTransparent": false }), + serde_json::json!({ "property": "activeTransparent", "value": 1 }), + serde_json::json!({ "activeTransparent": false, "unexpected": true }), + serde_json::json!({ "property": "idleImageFit", "value": "stretch" }), + serde_json::json!({ "property": "idleImageFit", "value": null }), + serde_json::json!({ "idleImageFit": "cover", "activeImageFit": "contain" }), + serde_json::json!({ "property": "activeImageFit", "value": 1 }), + serde_json::json!({ "activeImageFit": "none", "unexpected": true }), + serde_json::json!({ "property": "imageMode", "value": "invalid" }), + serde_json::json!({ "property": "idleImageTransform", "value": { "leaf": "offsetX", "value": "1" } }), + serde_json::json!({ "property": "idleImageTransform", "value": { "leaf": "offsetX", "value": 1, "unexpected": true } }), + serde_json::json!({ "property": "activeImageTransform", "value": { "leaf": "unknown", "value": 1 } }), + serde_json::json!({ "property": "soundPath", "value": null }), + serde_json::json!({ "property": "soundPath", "value": 1 }), + serde_json::json!({ "soundPath": "path", "soundEnabled": true }), + serde_json::json!({ "soundPath": "path", "unexpected": true }), + serde_json::json!({ "property": "soundEnabled", "value": null }), + serde_json::json!({ "property": "soundEnabled", "value": "true" }), + serde_json::json!({ "soundEnabled": true, "unexpected": true }), + serde_json::json!({ "property": "soundVolume", "value": null }), + serde_json::json!({ "property": "soundVolume", "value": "100" }), + serde_json::json!({ "soundVolume": 100, "soundEnabled": true }), + serde_json::json!({ "soundVolume": 100, "unexpected": true }), + serde_json::json!({ "property": "counterEnabled", "value": 1 }), + serde_json::json!({ "property": "counterAnimationEnabled", "value": null }), + serde_json::json!({ "counterAnimationEnabled": true, "hidden": false }), + serde_json::json!({ "property": "counterPlacement", "value": "center" }), + serde_json::json!({ "property": "counterAlign", "value": "center" }), + serde_json::json!({ "property": "counterAlignMode", "value": "outside" }), + serde_json::json!({ "property": "counterGap", "value": -1 }), + serde_json::json!({ "property": "counterGap", "value": 1.5 }), + serde_json::json!({ "property": "counterGap", "value": 4_294_967_296_u64 }), + serde_json::json!({ "counterPlacement": "inside", "counterAlign": "top" }), + serde_json::json!({ "property": "counterFontSize", "value": -1 }), + serde_json::json!({ "property": "counterFontWeight", "value": "400" }), + serde_json::json!({ "property": "counterFontItalic", "value": null }), + serde_json::json!({ "property": "counterFontUnderline", "value": 1 }), + serde_json::json!({ "property": "counterFontStrikethrough", "value": "false" }), + serde_json::json!({ "counterFontSize": 16, "counterFontWeight": 400 }), + serde_json::json!({ "counterFontSize": 16, "unexpected": true }), + serde_json::json!({ "property": "counterFontFamily", "value": null }), + serde_json::json!({ "property": "counterFontFamily", "value": 1 }), + serde_json::json!({ "counterFontFamily": "font", "counterFontItalic": true }), + serde_json::json!({ "counterFontFamily": "font", "unexpected": true }), + serde_json::json!({ "property": "counterFillIdle", "value": {} }), + serde_json::json!({ "property": "counterFillIdle", "value": { "color": false } }), + serde_json::json!({ "property": "counterFillIdle", "value": { "color": "solid", "gradient": null } }), + serde_json::json!({ "property": "counterFillActive", "value": { "color": "first", "gradient": { "angle": 45 } } }), + serde_json::json!({ "property": "counterFillActive", "value": { "color": "first", "gradient": { "angle": 45, "stops": [{ "color": "first", "pos": 0, "unexpected": true }, { "color": "last", "pos": 1 }] } } }), + serde_json::json!({ "property": "counterFillIdle", "value": { "color": "solid", "unexpected": true } }), + serde_json::json!({ "counterFillActive": { "color": "active" }, "hidden": false }), + serde_json::json!({ "property": "counterAnimationPreset", "value": null }), + serde_json::json!({ "property": "counterAnimationPreset", "value": {} }), + serde_json::json!({ "property": "counterAnimationPreset", "value": { "presetId": "preset", "enabled": false } }), + serde_json::json!({ "property": "counterAnimationPreset", "value": { "presetId": "preset", "applyPresetId": false } }), + serde_json::json!({ "property": "counterAnimationPreset", "value": { "presetId": "preset", "scale": "1.2" } }), + serde_json::json!({ "counterAnimationPreset": { "presetId": "preset" }, "hidden": true }), + serde_json::json!({ "property": "noteGlowSize", "value": null }), + serde_json::json!({ "property": "noteGlowSize", "value": "20" }), + serde_json::json!({ "property": "noteGlowSyncPaint", "value": null }), + serde_json::json!({ "noteGlowSize": 20, "noteGlowEnabled": true }), + serde_json::json!({ "noteGlowSize": 20, "unexpected": true }), + serde_json::json!({ "property": "notePaint", "value": {} }), + serde_json::json!({ "property": "notePaint", "value": { "color": { "top": "a", "bottom": "b" } } }), + serde_json::json!({ "property": "notePaint", "value": { "color": { "type": "gradient", "top": "a", "bottom": "b", "unexpected": true } } }), + serde_json::json!({ "property": "notePaint", "value": { "opacity": 50, "opacityBottom": 60 } }), + serde_json::json!({ "property": "noteGlowPaint", "value": { "color": "x", "opacity": 70 } }), + serde_json::json!({ "property": "noteBorderPaint", "value": { "color": "#FFFFFF" } }), + serde_json::json!({ "property": "noteBorderPaint", "value": { "color": "#FFFFFF", "opacity": "100" } }), + serde_json::json!({ "notePaint": { "color": "x" }, "unexpected": true }), + serde_json::json!({ "property": "noteOffsetX", "value": "0" }), + serde_json::json!({ "noteOffsetX": null, "noteOffsetY": null }), + serde_json::json!({ "noteOffsetY": null, "unexpected": true }), + serde_json::json!({ "property": "noteWidth", "value": "20" }), + serde_json::json!({ "noteWidth": null, "hidden": true }), + serde_json::json!({ "property": "noteBorderWidth", "value": null }), + serde_json::json!({ "property": "noteBorderWidth", "value": "1" }), + serde_json::json!({ "property": "noteBorderRadius", "value": null }), + serde_json::json!({ "noteBorderRadius": 4, "unexpected": true }), + ] { + let mut invalid_image = layer_name_wire.clone(); + invalid_image["editorOps"][0]["patch"] = patch; + let error = decode_gesture_commit_request(invalid_image).unwrap_err(); + assert_eq!( + validation_code(error).as_deref(), + Some("INVALID_REQUEST_PAYLOAD") + ); + } + + let mut invalid_stat_type = layer_name_wire.clone(); + invalid_stat_type["editorOps"][0]["patch"] = + serde_json::json!({ "property": "statType", "value": "invalid" }); + let error = decode_gesture_commit_request(invalid_stat_type).unwrap_err(); + assert_eq!( + validation_code(error).as_deref(), + Some("INVALID_REQUEST_PAYLOAD") + ); + + let mut request = gesture_request(&["plugin-a".to_string()]); + request.editor_ops_version = Some(EDITOR_OPS_VERSION); + request.editor_ops = Some(vec![EditorOpV1::PatchElement { + element_type: EditorElementTypeV1::Stat, + id: uuid::Uuid::new_v4().to_string(), + patch: EditorElementPropertyPatchV1::FontItalic(false), + }]); + let mut invalid_font = serde_json::to_value(request).unwrap(); + invalid_font["editorOps"][0]["patch"] = + serde_json::json!({ "fontItalic": false, "fontWeight": 400 }); + let error = decode_gesture_commit_request(invalid_font).unwrap_err(); + assert_eq!( + validation_code(error).as_deref(), + Some("INVALID_REQUEST_PAYLOAD") + ); + + let mut invalid_graph_type = graph_type_wire; + invalid_graph_type["editorOps"][0]["patch"]["graphType"] = serde_json::json!("area"); + let error = decode_gesture_commit_request(invalid_graph_type).unwrap_err(); + assert_eq!( + validation_code(error).as_deref(), + Some("INVALID_REQUEST_PAYLOAD") + ); + + let mut multiple_properties = layer_name_wire; + multiple_properties["editorOps"][0]["patch"]["hidden"] = serde_json::json!(true); + let error = decode_gesture_commit_request(multiple_properties).unwrap_err(); + assert_eq!( + validation_code(error).as_deref(), + Some("INVALID_REQUEST_PAYLOAD") + ); + + let mut slot = gesture_request(&["plugin-a".to_string()]); + slot.editor_ops_version = Some(EDITOR_OPS_VERSION); + slot.editor_ops = Some(vec![EditorOpV1::SetKeySlot { + id: uuid::Uuid::new_v4().to_string(), + slot: EditorFrozenKeySlotV1::Multi(crate::models::EditorFrozenMultiKeySlotV1 { + keys: vec!["A".to_string(), "B".to_string()], + match_mode: crate::models::SlotMatch::All, + }), + }]); + let mut slot_wire = serde_json::to_value(slot).unwrap(); + slot_wire["editorOps"][0]["slot"]["unexpected"] = serde_json::json!(true); + let error = decode_gesture_commit_request(slot_wire).unwrap_err(); + assert_eq!( + validation_code(error).as_deref(), + Some("INVALID_REQUEST_PAYLOAD") + ); +} + +#[test] +fn gesture_rejects_more_than_sixty_four_plugin_changes() { + let plugin_ids = (0..65) + .map(|index| format!("plugin-{index}")) + .collect::>(); + let error = validate_gesture_commit_request(&gesture_request(&plugin_ids)).unwrap_err(); + + assert_eq!( + validation_code(error).as_deref(), + Some("INVALID_GESTURE_PLUGIN_COUNT") + ); +} + +#[test] +fn gesture_rejects_duplicate_plugin_changes() { + let plugin_ids = vec!["plugin-a".to_string(), "plugin-a".to_string()]; + let error = validate_gesture_commit_request(&gesture_request(&plugin_ids)).unwrap_err(); + + assert_eq!( + validation_code(error).as_deref(), + Some("DUPLICATE_GESTURE_PLUGIN") + ); +} From 606ad79e3538c39159c8d80d68a27986ea97e0a2 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 16:57:28 +0900 Subject: [PATCH 145/245] =?UTF-8?q?refactor:=20=EC=B9=B4=EC=9A=B4=ED=84=B0?= =?UTF-8?q?=20=EB=AA=A8=EB=8D=B8=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/models/counter.rs | 377 +++++++++++++++++++++++++++++++ src-tauri/src/models/mod.rs | 382 +------------------------------- 2 files changed, 386 insertions(+), 373 deletions(-) create mode 100644 src-tauri/src/models/counter.rs diff --git a/src-tauri/src/models/counter.rs b/src-tauri/src/models/counter.rs new file mode 100644 index 000000000..b73c33a4c --- /dev/null +++ b/src-tauri/src/models/counter.rs @@ -0,0 +1,377 @@ +use serde::{Deserialize, Serialize}; + +use super::GradientSpec; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "kebab-case")] +#[derive(Default)] +pub enum KeyCounterPlacement { + #[default] + Inside, + Outside, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "kebab-case")] +#[derive(Default)] +pub enum KeyCounterAlign { + Top, + // align 필드 부재 시에도 새 기본 배치와 일치하도록 serde 기본값 겸용 + #[default] + Bottom, + Left, + Right, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "kebab-case")] +#[derive(Default)] +pub enum KeyCounterAlignMode { + #[default] + Center, + Between, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct KeyCounterColor { + pub idle: String, + pub active: String, +} + +impl Default for KeyCounterColor { + fn default() -> Self { + Self { + // 렌더러 기본 키 텍스트 색과 일치 (utils/core/elementDefaults.ts) + idle: "rgba(237, 238, 242, 0.78)".to_string(), + active: "rgba(20, 20, 24, 0.9)".to_string(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum CounterAnimationSource { + Builtin, + User, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CounterAnimationPreset { + pub id: String, + pub name: String, + #[serde(rename = "source")] + pub source: CounterAnimationSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label_key: Option, + pub bezier: [f64; 4], + pub scale: f64, + pub duration_ms: u32, +} + +impl CounterAnimationPreset { + pub fn normalize(&mut self) { + let mut animation = KeyCounterAnimationSettings { + enabled: true, + preset_id: None, + bezier: self.bezier, + scale: self.scale, + duration_ms: self.duration_ms, + }; + animation.normalize(); + self.bezier = animation.bezier; + self.scale = animation.scale; + self.duration_ms = animation.duration_ms; + self.name = self.name.trim().to_string(); + } + + pub fn is_valid_user_entry(&self) -> bool { + self.source == CounterAnimationSource::User + && !self.id.trim().is_empty() + && !self.name.trim().is_empty() + } +} + +fn builtin_counter_animation_presets() -> Vec { + vec![ + CounterAnimationPreset { + id: "builtin-ease-out".to_string(), + name: "Default".to_string(), + source: CounterAnimationSource::Builtin, + label_key: None, + bezier: [0.25, 0.46, 0.45, 0.94], + scale: 1.1, + duration_ms: 300, + }, + CounterAnimationPreset { + id: "builtin-linear".to_string(), + name: "Linear".to_string(), + source: CounterAnimationSource::Builtin, + label_key: None, + bezier: [0.0, 0.0, 1.0, 1.0], + scale: 1.1, + duration_ms: 300, + }, + CounterAnimationPreset { + id: "builtin-ease-out-css".to_string(), + name: "Ease Out".to_string(), + source: CounterAnimationSource::Builtin, + label_key: None, + bezier: [0.0, 0.0, 0.58, 1.0], + scale: 1.1, + duration_ms: 300, + }, + CounterAnimationPreset { + id: "builtin-ease-in".to_string(), + name: "Ease In".to_string(), + source: CounterAnimationSource::Builtin, + label_key: None, + bezier: [0.42, 0.0, 1.0, 1.0], + scale: 1.1, + duration_ms: 300, + }, + CounterAnimationPreset { + id: "builtin-ease-in-out".to_string(), + name: "Ease In-Out".to_string(), + source: CounterAnimationSource::Builtin, + label_key: None, + bezier: [0.42, 0.0, 0.58, 1.0], + scale: 1.1, + duration_ms: 300, + }, + CounterAnimationPreset { + id: "builtin-overshoot".to_string(), + name: "Overshoot".to_string(), + source: CounterAnimationSource::Builtin, + label_key: None, + bezier: [0.34, 1.56, 0.64, 1.0], + scale: 1.15, + duration_ms: 360, + }, + ] +} + +pub fn default_counter_animation_preset_id() -> &'static str { + "builtin-ease-out" +} + +pub fn default_counter_animation_builtin_presets() -> Vec { + builtin_counter_animation_presets() +} + +pub fn find_builtin_counter_animation_preset_by_id(id: &str) -> Option { + builtin_counter_animation_presets() + .into_iter() + .find(|preset| preset.id == id) +} + +pub fn infer_builtin_counter_animation_preset_id( + bezier: [f64; 4], + scale: f64, + duration_ms: u32, +) -> Option { + const BEZIER_EPSILON: f64 = 0.001; + const SCALE_EPSILON: f64 = 0.001; + + builtin_counter_animation_presets() + .into_iter() + .find(|preset| { + let bezier_matches = preset + .bezier + .iter() + .zip(bezier.iter()) + .all(|(a, b)| (*a - *b).abs() <= BEZIER_EPSILON); + let scale_matches = (preset.scale - scale).abs() <= SCALE_EPSILON; + let duration_matches = preset.duration_ms == duration_ms; + bezier_matches && scale_matches && duration_matches + }) + .map(|preset| preset.id) +} + +pub fn normalize_user_counter_animation_presets( + presets: Vec, +) -> Vec { + let mut seen_ids = std::collections::HashSet::new(); + let mut normalized: Vec = presets + .into_iter() + .filter_map(|mut preset| { + preset.normalize(); + if !preset.is_valid_user_entry() { + return None; + } + if !seen_ids.insert(preset.id.clone()) { + return None; + } + preset.source = CounterAnimationSource::User; + preset.label_key = None; + Some(preset) + }) + .collect(); + + normalized.sort_by_key(|a| a.name.to_lowercase()); + normalized +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct KeyCounterAnimationSettings { + #[serde(default = "default_counter_animation_enabled")] + pub enabled: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preset_id: Option, + #[serde(default = "default_counter_animation_bezier")] + pub bezier: [f64; 4], + #[serde(default = "default_counter_animation_scale")] + pub scale: f64, + #[serde(default = "default_counter_animation_duration_ms")] + pub duration_ms: u32, +} + +impl Default for KeyCounterAnimationSettings { + fn default() -> Self { + Self { + enabled: default_counter_animation_enabled(), + preset_id: Some(default_counter_animation_preset_id().to_string()), + bezier: default_counter_animation_bezier(), + scale: default_counter_animation_scale(), + duration_ms: default_counter_animation_duration_ms(), + } + } +} + +impl KeyCounterAnimationSettings { + pub fn normalize(&mut self) { + let fallback = default_counter_animation_bezier(); + for i in [0, 2] { + let value = self.bezier[i]; + self.bezier[i] = if value.is_finite() { + value.clamp(0.0, 1.0) + } else { + fallback[i] + }; + } + for i in [1, 3] { + let value = self.bezier[i]; + self.bezier[i] = if value.is_finite() { + value.clamp(-2.0, 2.0) + } else { + fallback[i] + }; + } + + self.scale = if self.scale.is_finite() { + self.scale + } else { + default_counter_animation_scale() + }; + self.duration_ms = self.duration_ms.clamp(1, 5000); + + self.preset_id = self + .preset_id + .as_ref() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + + if self.preset_id.is_none() { + self.preset_id = infer_builtin_counter_animation_preset_id( + self.bezier, + self.scale, + self.duration_ms, + ); + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct KeyCounterSettings { + #[serde(default = "default_counter_enabled")] + pub enabled: bool, + #[serde(default)] + pub placement: KeyCounterPlacement, + #[serde(default)] + pub align: KeyCounterAlign, + #[serde(default)] + pub align_mode: KeyCounterAlignMode, + #[serde(default)] + pub fill: KeyCounterColor, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fill_idle_gradient: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fill_active_gradient: Option, + #[serde(default = "default_gap")] + pub gap: u32, + #[serde(default = "default_counter_font_size")] + pub font_size: u32, + #[serde(default = "default_counter_font_weight")] + pub font_weight: u32, + /// 선택 굵기에 +300을 적용하는 Bold 토글 - None은 직렬화하지 않는다 + /// (IPC에서 null로 나가면 프론트 스키마가 거부해 설정 전체가 기본값으로 떨어진다) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub font_bold: Option, + /// 카운터 글꼴 패밀리 (커스텀 폰트 이름) + #[serde(default)] + pub font_family: Option, + #[serde(default)] + pub font_italic: bool, + #[serde(default)] + pub font_underline: bool, + #[serde(default)] + pub font_strikethrough: bool, + #[serde(default)] + pub animation: KeyCounterAnimationSettings, +} + +impl Default for KeyCounterSettings { + fn default() -> Self { + Self { + enabled: true, + placement: KeyCounterPlacement::Inside, + align: KeyCounterAlign::Bottom, + align_mode: KeyCounterAlignMode::Center, + fill: KeyCounterColor::default(), + fill_idle_gradient: None, + fill_active_gradient: None, + gap: default_gap(), + font_size: default_counter_font_size(), + font_weight: default_counter_font_weight(), + font_bold: Some(false), + font_family: None, + font_italic: false, + font_underline: false, + font_strikethrough: false, + animation: KeyCounterAnimationSettings::default(), + } + } +} + +fn default_counter_animation_enabled() -> bool { + false +} + +fn default_counter_animation_bezier() -> [f64; 4] { + [0.25, 0.46, 0.45, 0.94] +} + +fn default_counter_animation_scale() -> f64 { + 1.1 +} + +fn default_counter_animation_duration_ms() -> u32 { + 300 +} + +pub(super) fn default_gap() -> u32 { + 4 +} +pub(super) fn default_counter_font_size() -> u32 { + 11 +} +pub(super) fn default_counter_font_weight() -> u32 { + 500 +} + +fn default_counter_enabled() -> bool { + true +} diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index f4a81e7c9..aa3cbc55c 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -4,11 +4,20 @@ pub mod obs; pub mod plugin; mod bootstrap; +mod counter; mod key_position_normalization; mod settings; mod shortcuts; pub use bootstrap::*; +pub use counter::{ + default_counter_animation_builtin_presets, default_counter_animation_preset_id, + find_builtin_counter_animation_preset_by_id, infer_builtin_counter_animation_preset_id, + normalize_user_counter_animation_presets, CounterAnimationPreset, CounterAnimationSource, + KeyCounterAlign, KeyCounterAlignMode, KeyCounterAnimationSettings, KeyCounterColor, + KeyCounterPlacement, KeyCounterSettings, +}; +use counter::{default_counter_font_size, default_counter_font_weight, default_gap}; pub use editor::*; pub use gesture::*; pub use plugin::*; @@ -894,379 +903,6 @@ pub struct KnobPosition { pub position: KeyPosition, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "kebab-case")] -#[derive(Default)] -pub enum KeyCounterPlacement { - #[default] - Inside, - Outside, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "kebab-case")] -#[derive(Default)] -pub enum KeyCounterAlign { - Top, - // align 필드 부재 시에도 새 기본 배치와 일치하도록 serde 기본값 겸용 - #[default] - Bottom, - Left, - Right, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "kebab-case")] -#[derive(Default)] -pub enum KeyCounterAlignMode { - #[default] - Center, - Between, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct KeyCounterColor { - pub idle: String, - pub active: String, -} - -impl Default for KeyCounterColor { - fn default() -> Self { - Self { - // 렌더러 기본 키 텍스트 색과 일치 (utils/core/elementDefaults.ts) - idle: "rgba(237, 238, 242, 0.78)".to_string(), - active: "rgba(20, 20, 24, 0.9)".to_string(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum CounterAnimationSource { - Builtin, - User, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CounterAnimationPreset { - pub id: String, - pub name: String, - #[serde(rename = "source")] - pub source: CounterAnimationSource, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub label_key: Option, - pub bezier: [f64; 4], - pub scale: f64, - pub duration_ms: u32, -} - -impl CounterAnimationPreset { - pub fn normalize(&mut self) { - let mut animation = KeyCounterAnimationSettings { - enabled: true, - preset_id: None, - bezier: self.bezier, - scale: self.scale, - duration_ms: self.duration_ms, - }; - animation.normalize(); - self.bezier = animation.bezier; - self.scale = animation.scale; - self.duration_ms = animation.duration_ms; - self.name = self.name.trim().to_string(); - } - - pub fn is_valid_user_entry(&self) -> bool { - self.source == CounterAnimationSource::User - && !self.id.trim().is_empty() - && !self.name.trim().is_empty() - } -} - -fn builtin_counter_animation_presets() -> Vec { - vec![ - CounterAnimationPreset { - id: "builtin-ease-out".to_string(), - name: "Default".to_string(), - source: CounterAnimationSource::Builtin, - label_key: None, - bezier: [0.25, 0.46, 0.45, 0.94], - scale: 1.1, - duration_ms: 300, - }, - CounterAnimationPreset { - id: "builtin-linear".to_string(), - name: "Linear".to_string(), - source: CounterAnimationSource::Builtin, - label_key: None, - bezier: [0.0, 0.0, 1.0, 1.0], - scale: 1.1, - duration_ms: 300, - }, - CounterAnimationPreset { - id: "builtin-ease-out-css".to_string(), - name: "Ease Out".to_string(), - source: CounterAnimationSource::Builtin, - label_key: None, - bezier: [0.0, 0.0, 0.58, 1.0], - scale: 1.1, - duration_ms: 300, - }, - CounterAnimationPreset { - id: "builtin-ease-in".to_string(), - name: "Ease In".to_string(), - source: CounterAnimationSource::Builtin, - label_key: None, - bezier: [0.42, 0.0, 1.0, 1.0], - scale: 1.1, - duration_ms: 300, - }, - CounterAnimationPreset { - id: "builtin-ease-in-out".to_string(), - name: "Ease In-Out".to_string(), - source: CounterAnimationSource::Builtin, - label_key: None, - bezier: [0.42, 0.0, 0.58, 1.0], - scale: 1.1, - duration_ms: 300, - }, - CounterAnimationPreset { - id: "builtin-overshoot".to_string(), - name: "Overshoot".to_string(), - source: CounterAnimationSource::Builtin, - label_key: None, - bezier: [0.34, 1.56, 0.64, 1.0], - scale: 1.15, - duration_ms: 360, - }, - ] -} - -pub fn default_counter_animation_preset_id() -> &'static str { - "builtin-ease-out" -} - -pub fn default_counter_animation_builtin_presets() -> Vec { - builtin_counter_animation_presets() -} - -pub fn find_builtin_counter_animation_preset_by_id(id: &str) -> Option { - builtin_counter_animation_presets() - .into_iter() - .find(|preset| preset.id == id) -} - -pub fn infer_builtin_counter_animation_preset_id( - bezier: [f64; 4], - scale: f64, - duration_ms: u32, -) -> Option { - const BEZIER_EPSILON: f64 = 0.001; - const SCALE_EPSILON: f64 = 0.001; - - builtin_counter_animation_presets() - .into_iter() - .find(|preset| { - let bezier_matches = preset - .bezier - .iter() - .zip(bezier.iter()) - .all(|(a, b)| (*a - *b).abs() <= BEZIER_EPSILON); - let scale_matches = (preset.scale - scale).abs() <= SCALE_EPSILON; - let duration_matches = preset.duration_ms == duration_ms; - bezier_matches && scale_matches && duration_matches - }) - .map(|preset| preset.id) -} - -pub fn normalize_user_counter_animation_presets( - presets: Vec, -) -> Vec { - let mut seen_ids = std::collections::HashSet::new(); - let mut normalized: Vec = presets - .into_iter() - .filter_map(|mut preset| { - preset.normalize(); - if !preset.is_valid_user_entry() { - return None; - } - if !seen_ids.insert(preset.id.clone()) { - return None; - } - preset.source = CounterAnimationSource::User; - preset.label_key = None; - Some(preset) - }) - .collect(); - - normalized.sort_by_key(|a| a.name.to_lowercase()); - normalized -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct KeyCounterAnimationSettings { - #[serde(default = "default_counter_animation_enabled")] - pub enabled: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub preset_id: Option, - #[serde(default = "default_counter_animation_bezier")] - pub bezier: [f64; 4], - #[serde(default = "default_counter_animation_scale")] - pub scale: f64, - #[serde(default = "default_counter_animation_duration_ms")] - pub duration_ms: u32, -} - -impl Default for KeyCounterAnimationSettings { - fn default() -> Self { - Self { - enabled: default_counter_animation_enabled(), - preset_id: Some(default_counter_animation_preset_id().to_string()), - bezier: default_counter_animation_bezier(), - scale: default_counter_animation_scale(), - duration_ms: default_counter_animation_duration_ms(), - } - } -} - -impl KeyCounterAnimationSettings { - pub fn normalize(&mut self) { - let fallback = default_counter_animation_bezier(); - for i in [0, 2] { - let value = self.bezier[i]; - self.bezier[i] = if value.is_finite() { - value.clamp(0.0, 1.0) - } else { - fallback[i] - }; - } - for i in [1, 3] { - let value = self.bezier[i]; - self.bezier[i] = if value.is_finite() { - value.clamp(-2.0, 2.0) - } else { - fallback[i] - }; - } - - self.scale = if self.scale.is_finite() { - self.scale - } else { - default_counter_animation_scale() - }; - self.duration_ms = self.duration_ms.clamp(1, 5000); - - self.preset_id = self - .preset_id - .as_ref() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()); - - if self.preset_id.is_none() { - self.preset_id = infer_builtin_counter_animation_preset_id( - self.bezier, - self.scale, - self.duration_ms, - ); - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct KeyCounterSettings { - #[serde(default = "default_counter_enabled")] - pub enabled: bool, - #[serde(default)] - pub placement: KeyCounterPlacement, - #[serde(default)] - pub align: KeyCounterAlign, - #[serde(default)] - pub align_mode: KeyCounterAlignMode, - #[serde(default)] - pub fill: KeyCounterColor, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub fill_idle_gradient: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub fill_active_gradient: Option, - #[serde(default = "default_gap")] - pub gap: u32, - #[serde(default = "default_counter_font_size")] - pub font_size: u32, - #[serde(default = "default_counter_font_weight")] - pub font_weight: u32, - /// 선택 굵기에 +300을 적용하는 Bold 토글 - None은 직렬화하지 않는다 - /// (IPC에서 null로 나가면 프론트 스키마가 거부해 설정 전체가 기본값으로 떨어진다) - #[serde(default, skip_serializing_if = "Option::is_none")] - pub font_bold: Option, - /// 카운터 글꼴 패밀리 (커스텀 폰트 이름) - #[serde(default)] - pub font_family: Option, - #[serde(default)] - pub font_italic: bool, - #[serde(default)] - pub font_underline: bool, - #[serde(default)] - pub font_strikethrough: bool, - #[serde(default)] - pub animation: KeyCounterAnimationSettings, -} - -impl Default for KeyCounterSettings { - fn default() -> Self { - Self { - enabled: true, - placement: KeyCounterPlacement::Inside, - align: KeyCounterAlign::Bottom, - align_mode: KeyCounterAlignMode::Center, - fill: KeyCounterColor::default(), - fill_idle_gradient: None, - fill_active_gradient: None, - gap: default_gap(), - font_size: default_counter_font_size(), - font_weight: default_counter_font_weight(), - font_bold: Some(false), - font_family: None, - font_italic: false, - font_underline: false, - font_strikethrough: false, - animation: KeyCounterAnimationSettings::default(), - } - } -} - -fn default_counter_animation_enabled() -> bool { - false -} - -fn default_counter_animation_bezier() -> [f64; 4] { - [0.25, 0.46, 0.45, 0.94] -} - -fn default_counter_animation_scale() -> f64 { - 1.1 -} - -fn default_counter_animation_duration_ms() -> u32 { - 300 -} - -fn default_gap() -> u32 { - 4 -} -fn default_counter_font_size() -> u32 { - 11 -} -fn default_counter_font_weight() -> u32 { - 500 -} - -fn default_counter_enabled() -> bool { - true -} fn default_note_effect_enabled() -> bool { true } From 028912498c7172a45f90057937556a7fd157140b Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 16:57:44 +0900 Subject: [PATCH 146/245] =?UTF-8?q?refactor:=20=ED=94=8C=EB=9F=AC=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=83=81=ED=83=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/state/plugin.rs | 519 +--------------------------- src-tauri/src/state/plugin/tests.rs | 515 +++++++++++++++++++++++++++ 2 files changed, 516 insertions(+), 518 deletions(-) create mode 100644 src-tauri/src/state/plugin/tests.rs diff --git a/src-tauri/src/state/plugin.rs b/src-tauri/src/state/plugin.rs index 3bdf6603a..58fa60662 100644 --- a/src-tauri/src/state/plugin.rs +++ b/src-tauri/src/state/plugin.rs @@ -661,521 +661,4 @@ fn validate_compact_size( } #[cfg(test)] -mod tests { - use std::collections::BTreeMap; - - use super::*; - use crate::models::{ - PluginInstancesCommitRequest, PluginInstancesReconcileRequest, PluginPoint, - PluginSettingValue, - }; - use crate::state::editor::MAX_GROUP_ID_BYTES; - - fn saved_instance() -> SavedPluginInstance { - SavedPluginInstance { - instance_id: Some(uuid::Uuid::new_v4().to_string()), - position: PluginPoint { x: 1.0, y: 2.0 }, - settings: None, - measured_size: None, - tab_id: Some("4key".to_string()), - hidden: false, - z_index: None, - group_id: None, - } - } - - #[test] - fn authority_generation_advances_and_rejects_stale_leases() { - let authority = PluginRuntimeAuthority::default(); - assert_eq!( - authority.admit(0).unwrap_err(), - "AUTHORITY_UNAVAILABLE".to_string() - ); - let generation = authority.reset().unwrap().generation(); - assert_eq!(generation, 1); - assert!(authority.admit(1).is_ok()); - assert_eq!( - authority.admit(0).unwrap_err(), - "AUTHORITY_GENERATION_CHANGED".to_string() - ); - authority.mark_unavailable(); - assert_eq!( - authority.admit(1).unwrap_err(), - "AUTHORITY_UNAVAILABLE".to_string() - ); - } - - // lease가 잠금을 들면 번호표 turn 대기 중 reset·다른 admit이 막혀 교착한다 - #[test] - fn admission_does_not_hold_authority_lock() { - let authority = std::sync::Arc::new(PluginRuntimeAuthority::default()); - authority.reset().unwrap(); - let lease = authority.admit(1).unwrap(); - - let (done_tx, done_rx) = std::sync::mpsc::channel(); - let worker_authority = std::sync::Arc::clone(&authority); - let worker = std::thread::spawn(move || { - let reset = worker_authority.reset().unwrap(); - let other = worker_authority.admit(reset.generation()); - done_tx.send(other.is_ok()).unwrap(); - }); - - assert!(done_rx - .recv_timeout(std::time::Duration::from_secs(2)) - .expect("lease 보유 중에도 reset·admit이 진행돼야 한다")); - worker.join().unwrap(); - assert_eq!(lease.generation(), 1); - } - - #[test] - fn revalidate_rejects_after_reset_and_after_unavailable() { - let authority = PluginRuntimeAuthority::default(); - authority.reset().unwrap(); - let lease = authority.admit(1).unwrap(); - assert!(authority.revalidate(lease).is_ok()); - - authority.reset().unwrap(); - assert_eq!( - authority.revalidate(lease).unwrap_err(), - "AUTHORITY_GENERATION_CHANGED".to_string() - ); - - let fresh = authority.admit(2).unwrap(); - authority.mark_unavailable(); - assert_eq!( - authority.revalidate(fresh).unwrap_err(), - "AUTHORITY_UNAVAILABLE".to_string() - ); - } - - #[test] - fn saved_instance_wire_rejects_unknown_fields() { - let error = serde_json::from_value::(serde_json::json!({ - "position": { "x": 1.0, "y": 2.0 }, - "tabId": "4key", - "handler": "not allowed" - })) - .unwrap_err(); - assert!(error.to_string().contains("unknown field")); - - validate_saved_plugin_instances(&[saved_instance()]).unwrap(); - } - - #[test] - fn saved_instance_wire_defaults_legacy_visibility_and_z_index() { - let instance = serde_json::from_value::(serde_json::json!({ - "position": { "x": 1.0, "y": 2.0 }, - "tabId": "4key" - })) - .unwrap(); - - assert!(!instance.hidden); - assert_eq!(instance.z_index, None); - } - - #[test] - fn saved_instance_wire_rejects_invalid_z_indexes() { - for z_index in [1.5, f64::from(i32::MAX) + 1.0, f64::NAN] { - let instance = SavedPluginInstance { - z_index: Some(z_index), - ..saved_instance() - }; - - assert_eq!( - validate_saved_plugin_instances(&[instance]).unwrap_err(), - "INVALID_PLUGIN_INSTANCE_Z_INDEX:0" - ); - } - } - - #[test] - fn legacy_saved_instances_decode_without_identity_fields() { - // 구형 JSON에는 instanceId/groupId가 없다 - decode(validate 경유) 통과와 None 기본값 고정 - let legacy = serde_json::json!([ - { "position": { "x": 1.0, "y": 2.0 }, "tabId": "4key" }, - { "position": { "x": 3.0, "y": 4.0 }, "tabId": "4key" } - ]); - - let instances = decode_plugin_instances(Some(&legacy)).unwrap().unwrap(); - - assert_eq!(instances.len(), 2); - assert!(instances - .iter() - .all(|instance| instance.instance_id.is_none() && instance.group_id.is_none())); - } - - #[test] - fn saved_instance_identity_fields_survive_encode_decode_round_trip() { - let instance = SavedPluginInstance { - instance_id: Some(uuid::Uuid::new_v4().to_string()), - group_id: Some("layer-group".to_string()), - ..saved_instance() - }; - - let encoded = encode_plugin_instances(std::slice::from_ref(&instance)) - .unwrap() - .unwrap(); - let decoded = decode_plugin_instances(Some(&encoded)).unwrap().unwrap(); - - assert_eq!(decoded, vec![instance]); - } - - #[test] - fn saved_instance_wire_rejects_invalid_instance_ids() { - for invalid_id in ["not-a-uuid".to_string(), uuid::Uuid::nil().to_string()] { - let instance = SavedPluginInstance { - instance_id: Some(invalid_id), - ..saved_instance() - }; - - assert_eq!( - validate_saved_plugin_instances(&[instance]).unwrap_err(), - "INVALID_PLUGIN_INSTANCE_ID:0" - ); - } - } - - #[test] - fn decode_rejects_stored_instances_with_invalid_instance_id() { - // decode 경로가 validate를 경유하는지 고정 - let stored = serde_json::json!([{ - "position": { "x": 1.0, "y": 2.0 }, - "tabId": "4key", - "instanceId": "not-a-uuid" - }]); - - assert_eq!( - decode_plugin_instances(Some(&stored)).unwrap_err(), - "INVALID_PLUGIN_INSTANCE_ID:0" - ); - } - - #[test] - fn saved_instance_wire_rejects_invalid_group_ids() { - for invalid_group_id in [String::new(), "x".repeat(MAX_GROUP_ID_BYTES + 1)] { - let instance = SavedPluginInstance { - group_id: Some(invalid_group_id), - ..saved_instance() - }; - - assert_eq!( - validate_saved_plugin_instances(&[instance]).unwrap_err(), - "INVALID_PLUGIN_INSTANCE_GROUP_ID:0" - ); - } - - // 상한 경계 길이는 통과 - let boundary = SavedPluginInstance { - group_id: Some("x".repeat(MAX_GROUP_ID_BYTES)), - ..saved_instance() - }; - validate_saved_plugin_instances(&[boundary]).unwrap(); - } - - #[test] - fn decode_rejects_stored_instances_with_empty_group_id() { - // decode도 validate 공유 - 손상 group_id는 해당 플러그인만 격리 초기화 - let stored = serde_json::json!([{ - "position": { "x": 1.0, "y": 2.0 }, - "tabId": "4key", - "instanceId": uuid::Uuid::new_v4().to_string(), - "groupId": "" - }]); - - assert_eq!( - decode_plugin_instances(Some(&stored)).unwrap_err(), - "INVALID_PLUGIN_INSTANCE_GROUP_ID:0" - ); - } - - #[test] - fn normalize_tab_id_folds_missing_and_empty_to_default_mode() { - // 프론트 normalizePluginInstanceTabId(`tabId || '4key'`)와 동일 규칙 고정 - assert_eq!(normalize_plugin_instance_tab_id(None), "4key"); - assert_eq!(normalize_plugin_instance_tab_id(Some("")), "4key"); - assert_eq!(normalize_plugin_instance_tab_id(Some("8key")), "8key"); - } - - #[test] - fn group_refs_fold_empty_tab_id_into_default_mode() { - // 손상 store의 "" tabId도 4key 모드로 집계 - 모드 판정 드리프트 방지 - let mut refs = PluginGroupRefs::new(); - let instance = SavedPluginInstance { - tab_id: Some(String::new()), - group_id: Some("plugin-group".to_string()), - ..saved_instance() - }; - add_plugin_group_refs(&mut refs, &[instance]); - assert!(refs["4key"].contains("plugin-group")); - assert!(!refs.contains_key("")); - - // store 수집(관대 decode 경로)도 동일 판정 - let mut data = AppStoreData::default(); - data.plugin_data.insert( - plugin_instances_storage_key("demo"), - serde_json::json!([{ - "position": { "x": 1.0, "y": 2.0 }, - "tabId": "", - "groupId": "stored-group" - }]), - ); - let refs = plugin_group_refs_from_store(&data, &HashSet::new()); - assert!(refs["4key"].contains("stored-group")); - assert!(!refs.contains_key("")); - } - - #[test] - fn saved_instance_wire_rejects_duplicate_instance_ids_within_plugin() { - let shared = uuid::Uuid::new_v4().to_string(); - let instances = [ - SavedPluginInstance { - instance_id: Some(shared.clone()), - ..saved_instance() - }, - SavedPluginInstance { - instance_id: Some(shared), - ..saved_instance() - }, - ]; - - assert_eq!( - validate_saved_plugin_instances(&instances).unwrap_err(), - "DUPLICATE_PLUGIN_INSTANCE_ID:1" - ); - } - - #[test] - fn plugin_instances_request_enforces_compact_size_limit() { - let mut settings = BTreeMap::new(); - settings.insert( - "label".to_string(), - PluginSettingValue::String("x".repeat(MAX_SETTING_STRING_BYTES)), - ); - // ID 중복 거절에 먼저 걸리지 않게 인스턴스별 고유 ID 부여 - let instances = (0..129) - .map(|index| SavedPluginInstance { - instance_id: Some(format!("00000000-0000-4000-8000-{index:012x}")), - settings: Some(settings.clone()), - ..saved_instance() - }) - .collect::>(); - let request = PluginInstancesCommitRequest { - plugin_id: "demo".to_string(), - instances, - mutation_id: uuid::Uuid::new_v4().to_string(), - gesture_id: None, - observed_history_epoch: None, - expected_model_revision: None, - authority_generation: 1, - }; - - assert_eq!( - validate_plugin_instances_request(&request).unwrap_err(), - "PLUGIN_INSTANCES_REQUEST_TOO_LARGE" - ); - } - - #[test] - fn plugin_instances_request_shares_editor_gesture_id_rules() { - let mut request = PluginInstancesCommitRequest { - plugin_id: "demo".to_string(), - instances: vec![saved_instance()], - mutation_id: uuid::Uuid::new_v4().to_string(), - gesture_id: None, - observed_history_epoch: None, - expected_model_revision: None, - authority_generation: 1, - }; - - // 프론트 crypto.randomUUID() 발급분은 통과 - request.gesture_id = Some(uuid::Uuid::new_v4().to_string()); - validate_plugin_instances_request(&request).unwrap(); - - // 비UUID 형식 거절 - editor 경로와 동일 규칙 (길이 상한은 UUID 파싱이 - // 먼저 걸러 독립 관측 불가, 방어층으로만 존재) - for invalid_gesture_id in ["not-a-uuid".to_string(), "가".repeat(22)] { - request.gesture_id = Some(invalid_gesture_id); - assert_eq!( - validate_plugin_instances_request(&request).unwrap_err(), - "INVALID_PLUGIN_GESTURE_ID" - ); - } - } - - #[test] - fn plugin_storage_key_wire_bytes_are_stable() { - assert_eq!( - plugin_instances_storage_key("demo"), - "plugin_data_demo/instances" - ); - assert_eq!( - plugin_id_from_instances_storage_key("plugin_data_demo/instances"), - Some("demo") - ); - assert!(is_plugin_instances_storage_key( - "plugin_data_demo/instances" - )); - assert!(!is_plugin_instances_storage_key( - "plugin_data_invalid/id/instances" - )); - } - - #[test] - fn legacy_long_plugin_ids_remain_addressable() { - for length in [129, 255, 256, 1_024] { - let plugin_id = "a".repeat(length); - validate_plugin_id(&plugin_id).unwrap(); - let key = plugin_instances_storage_key(&plugin_id); - assert_eq!( - plugin_id_from_instances_storage_key(&key), - Some(plugin_id.as_str()) - ); - } - - for plugin_id in ["", "contains/slash", "한글"] { - assert_eq!( - validate_plugin_id(plugin_id).unwrap_err(), - "INVALID_PLUGIN_ID" - ); - assert_eq!( - plugin_id_from_instances_storage_key(&plugin_instances_storage_key(plugin_id)), - None - ); - } - } - - #[test] - fn existing_instance_namespace_is_canonical_without_copying() { - let key = "plugin_data_demo/instances".to_string(); - let value = serde_json::json!([{ - "position": { "x": 4.0, "y": 5.0 }, - "tabId": "4key", - "instanceId": uuid::Uuid::new_v4().to_string() - }]); - let mut data = AppStoreData::default(); - data.plugin_data.insert(key.clone(), value.clone()); - - assert!(!backfill_plugin_instance_ids(&mut data).changed); - assert_eq!(plugin_instances_storage_key("demo"), key); - assert_eq!(data.plugin_data[&key], value); - } - - #[test] - fn lenient_decode_keeps_valid_entries_and_skips_invalid_siblings() { - let instance = saved_instance(); - let valid = serde_json::to_value(&instance).unwrap(); - let stored = serde_json::json!([ - valid.clone(), - { "position": "broken" }, - valid - ]); - - let decoded = - decode_plugin_instances_lenient(Some(&stored), "plugin_data_demo/instances").unwrap(); - - assert_eq!(decoded, vec![instance]); - } - - #[test] - fn legacy_finite_metrics_are_readable_and_only_improving_commits_are_allowed() { - let mut legacy = saved_instance(); - legacy.position.x = 40_000.0; - let stored = serde_json::to_value(vec![legacy.clone()]).unwrap(); - assert_eq!( - decode_plugin_instances(Some(&stored)).unwrap().unwrap(), - vec![legacy.clone()] - ); - assert_eq!( - validate_saved_plugin_instances(&[legacy.clone()]).unwrap_err(), - "INVALID_PLUGIN_INSTANCE_POSITION:0" - ); - - validate_plugin_instances_transition(&[legacy.clone()], &[legacy.clone()]).unwrap(); - let mut improving = legacy.clone(); - improving.position.x = 39_000.0; - validate_plugin_instances_transition(&[legacy.clone()], &[improving]).unwrap(); - - let mut worsening = legacy.clone(); - worsening.position.x = 41_000.0; - assert_eq!( - validate_plugin_instances_transition(&[legacy], &[worsening]).unwrap_err(), - "INVALID_PLUGIN_INSTANCE_POSITION:0" - ); - let new_outlier = SavedPluginInstance { - position: PluginPoint { - x: 40_000.0, - y: 2.0, - }, - ..saved_instance() - }; - assert_eq!( - validate_plugin_instances_transition(&[], &[new_outlier]).unwrap_err(), - "INVALID_PLUGIN_INSTANCE_POSITION:0" - ); - } - - #[test] - fn plugin_instances_request_rejects_missing_instance_id() { - let request = PluginInstancesCommitRequest { - plugin_id: "demo".to_string(), - instances: vec![SavedPluginInstance { - instance_id: None, - ..saved_instance() - }], - mutation_id: uuid::Uuid::new_v4().to_string(), - gesture_id: None, - observed_history_epoch: None, - expected_model_revision: None, - authority_generation: 1, - }; - - assert_eq!( - validate_plugin_instances_request(&request).unwrap_err(), - "MISSING_PLUGIN_INSTANCE_ID:0" - ); - } - - #[test] - fn plugin_instances_reconcile_request_validates_tabs_and_wire_shape() { - let base = PluginInstancesReconcileRequest { - plugin_id: "demo".to_string(), - valid_tab_ids: vec!["4key".to_string()], - mutation_id: uuid::Uuid::new_v4().to_string(), - observed_history_epoch: None, - authority_generation: 1, - }; - validate_plugin_instances_reconcile_request(&base).unwrap(); - - let mut invalid_epoch = base.clone(); - invalid_epoch.observed_history_epoch = Some(MAX_SAFE_WIRE_REVISION + 1); - assert_eq!( - validate_plugin_instances_reconcile_request(&invalid_epoch).unwrap_err(), - "PLUGIN_REVISION_OUT_OF_RANGE" - ); - - let mut too_many = base.clone(); - too_many.valid_tab_ids = vec!["4key".to_string(); MAX_PLUGIN_RECONCILE_TAB_IDS + 1]; - assert_eq!( - validate_plugin_instances_reconcile_request(&too_many).unwrap_err(), - "TOO_MANY_PLUGIN_RECONCILE_TAB_IDS" - ); - - for invalid_tab_id in [String::new(), "x".repeat(MAX_TAB_ID_BYTES + 1)] { - let mut invalid = base.clone(); - invalid.valid_tab_ids = vec![invalid_tab_id]; - assert_eq!( - validate_plugin_instances_reconcile_request(&invalid).unwrap_err(), - "INVALID_PLUGIN_RECONCILE_TAB_ID:0" - ); - } - - let unknown = serde_json::json!({ - "pluginId": "demo", - "validTabIds": ["4key"], - "mutationId": uuid::Uuid::new_v4().to_string(), - "authorityGeneration": 1, - "unexpected": true, - }); - assert!(serde_json::from_value::(unknown).is_err()); - } -} +mod tests; diff --git a/src-tauri/src/state/plugin/tests.rs b/src-tauri/src/state/plugin/tests.rs new file mode 100644 index 000000000..a542a8938 --- /dev/null +++ b/src-tauri/src/state/plugin/tests.rs @@ -0,0 +1,515 @@ +use std::collections::BTreeMap; + +use super::*; +use crate::models::{ + PluginInstancesCommitRequest, PluginInstancesReconcileRequest, PluginPoint, PluginSettingValue, +}; +use crate::state::editor::MAX_GROUP_ID_BYTES; + +fn saved_instance() -> SavedPluginInstance { + SavedPluginInstance { + instance_id: Some(uuid::Uuid::new_v4().to_string()), + position: PluginPoint { x: 1.0, y: 2.0 }, + settings: None, + measured_size: None, + tab_id: Some("4key".to_string()), + hidden: false, + z_index: None, + group_id: None, + } +} + +#[test] +fn authority_generation_advances_and_rejects_stale_leases() { + let authority = PluginRuntimeAuthority::default(); + assert_eq!( + authority.admit(0).unwrap_err(), + "AUTHORITY_UNAVAILABLE".to_string() + ); + let generation = authority.reset().unwrap().generation(); + assert_eq!(generation, 1); + assert!(authority.admit(1).is_ok()); + assert_eq!( + authority.admit(0).unwrap_err(), + "AUTHORITY_GENERATION_CHANGED".to_string() + ); + authority.mark_unavailable(); + assert_eq!( + authority.admit(1).unwrap_err(), + "AUTHORITY_UNAVAILABLE".to_string() + ); +} + +// lease가 잠금을 들면 번호표 turn 대기 중 reset·다른 admit이 막혀 교착한다 +#[test] +fn admission_does_not_hold_authority_lock() { + let authority = std::sync::Arc::new(PluginRuntimeAuthority::default()); + authority.reset().unwrap(); + let lease = authority.admit(1).unwrap(); + + let (done_tx, done_rx) = std::sync::mpsc::channel(); + let worker_authority = std::sync::Arc::clone(&authority); + let worker = std::thread::spawn(move || { + let reset = worker_authority.reset().unwrap(); + let other = worker_authority.admit(reset.generation()); + done_tx.send(other.is_ok()).unwrap(); + }); + + assert!(done_rx + .recv_timeout(std::time::Duration::from_secs(2)) + .expect("lease 보유 중에도 reset·admit이 진행돼야 한다")); + worker.join().unwrap(); + assert_eq!(lease.generation(), 1); +} + +#[test] +fn revalidate_rejects_after_reset_and_after_unavailable() { + let authority = PluginRuntimeAuthority::default(); + authority.reset().unwrap(); + let lease = authority.admit(1).unwrap(); + assert!(authority.revalidate(lease).is_ok()); + + authority.reset().unwrap(); + assert_eq!( + authority.revalidate(lease).unwrap_err(), + "AUTHORITY_GENERATION_CHANGED".to_string() + ); + + let fresh = authority.admit(2).unwrap(); + authority.mark_unavailable(); + assert_eq!( + authority.revalidate(fresh).unwrap_err(), + "AUTHORITY_UNAVAILABLE".to_string() + ); +} + +#[test] +fn saved_instance_wire_rejects_unknown_fields() { + let error = serde_json::from_value::(serde_json::json!({ + "position": { "x": 1.0, "y": 2.0 }, + "tabId": "4key", + "handler": "not allowed" + })) + .unwrap_err(); + assert!(error.to_string().contains("unknown field")); + + validate_saved_plugin_instances(&[saved_instance()]).unwrap(); +} + +#[test] +fn saved_instance_wire_defaults_legacy_visibility_and_z_index() { + let instance = serde_json::from_value::(serde_json::json!({ + "position": { "x": 1.0, "y": 2.0 }, + "tabId": "4key" + })) + .unwrap(); + + assert!(!instance.hidden); + assert_eq!(instance.z_index, None); +} + +#[test] +fn saved_instance_wire_rejects_invalid_z_indexes() { + for z_index in [1.5, f64::from(i32::MAX) + 1.0, f64::NAN] { + let instance = SavedPluginInstance { + z_index: Some(z_index), + ..saved_instance() + }; + + assert_eq!( + validate_saved_plugin_instances(&[instance]).unwrap_err(), + "INVALID_PLUGIN_INSTANCE_Z_INDEX:0" + ); + } +} + +#[test] +fn legacy_saved_instances_decode_without_identity_fields() { + // 구형 JSON에는 instanceId/groupId가 없다 - decode(validate 경유) 통과와 None 기본값 고정 + let legacy = serde_json::json!([ + { "position": { "x": 1.0, "y": 2.0 }, "tabId": "4key" }, + { "position": { "x": 3.0, "y": 4.0 }, "tabId": "4key" } + ]); + + let instances = decode_plugin_instances(Some(&legacy)).unwrap().unwrap(); + + assert_eq!(instances.len(), 2); + assert!(instances + .iter() + .all(|instance| instance.instance_id.is_none() && instance.group_id.is_none())); +} + +#[test] +fn saved_instance_identity_fields_survive_encode_decode_round_trip() { + let instance = SavedPluginInstance { + instance_id: Some(uuid::Uuid::new_v4().to_string()), + group_id: Some("layer-group".to_string()), + ..saved_instance() + }; + + let encoded = encode_plugin_instances(std::slice::from_ref(&instance)) + .unwrap() + .unwrap(); + let decoded = decode_plugin_instances(Some(&encoded)).unwrap().unwrap(); + + assert_eq!(decoded, vec![instance]); +} + +#[test] +fn saved_instance_wire_rejects_invalid_instance_ids() { + for invalid_id in ["not-a-uuid".to_string(), uuid::Uuid::nil().to_string()] { + let instance = SavedPluginInstance { + instance_id: Some(invalid_id), + ..saved_instance() + }; + + assert_eq!( + validate_saved_plugin_instances(&[instance]).unwrap_err(), + "INVALID_PLUGIN_INSTANCE_ID:0" + ); + } +} + +#[test] +fn decode_rejects_stored_instances_with_invalid_instance_id() { + // decode 경로가 validate를 경유하는지 고정 + let stored = serde_json::json!([{ + "position": { "x": 1.0, "y": 2.0 }, + "tabId": "4key", + "instanceId": "not-a-uuid" + }]); + + assert_eq!( + decode_plugin_instances(Some(&stored)).unwrap_err(), + "INVALID_PLUGIN_INSTANCE_ID:0" + ); +} + +#[test] +fn saved_instance_wire_rejects_invalid_group_ids() { + for invalid_group_id in [String::new(), "x".repeat(MAX_GROUP_ID_BYTES + 1)] { + let instance = SavedPluginInstance { + group_id: Some(invalid_group_id), + ..saved_instance() + }; + + assert_eq!( + validate_saved_plugin_instances(&[instance]).unwrap_err(), + "INVALID_PLUGIN_INSTANCE_GROUP_ID:0" + ); + } + + // 상한 경계 길이는 통과 + let boundary = SavedPluginInstance { + group_id: Some("x".repeat(MAX_GROUP_ID_BYTES)), + ..saved_instance() + }; + validate_saved_plugin_instances(&[boundary]).unwrap(); +} + +#[test] +fn decode_rejects_stored_instances_with_empty_group_id() { + // decode도 validate 공유 - 손상 group_id는 해당 플러그인만 격리 초기화 + let stored = serde_json::json!([{ + "position": { "x": 1.0, "y": 2.0 }, + "tabId": "4key", + "instanceId": uuid::Uuid::new_v4().to_string(), + "groupId": "" + }]); + + assert_eq!( + decode_plugin_instances(Some(&stored)).unwrap_err(), + "INVALID_PLUGIN_INSTANCE_GROUP_ID:0" + ); +} + +#[test] +fn normalize_tab_id_folds_missing_and_empty_to_default_mode() { + // 프론트 normalizePluginInstanceTabId(`tabId || '4key'`)와 동일 규칙 고정 + assert_eq!(normalize_plugin_instance_tab_id(None), "4key"); + assert_eq!(normalize_plugin_instance_tab_id(Some("")), "4key"); + assert_eq!(normalize_plugin_instance_tab_id(Some("8key")), "8key"); +} + +#[test] +fn group_refs_fold_empty_tab_id_into_default_mode() { + // 손상 store의 "" tabId도 4key 모드로 집계 - 모드 판정 드리프트 방지 + let mut refs = PluginGroupRefs::new(); + let instance = SavedPluginInstance { + tab_id: Some(String::new()), + group_id: Some("plugin-group".to_string()), + ..saved_instance() + }; + add_plugin_group_refs(&mut refs, &[instance]); + assert!(refs["4key"].contains("plugin-group")); + assert!(!refs.contains_key("")); + + // store 수집(관대 decode 경로)도 동일 판정 + let mut data = AppStoreData::default(); + data.plugin_data.insert( + plugin_instances_storage_key("demo"), + serde_json::json!([{ + "position": { "x": 1.0, "y": 2.0 }, + "tabId": "", + "groupId": "stored-group" + }]), + ); + let refs = plugin_group_refs_from_store(&data, &HashSet::new()); + assert!(refs["4key"].contains("stored-group")); + assert!(!refs.contains_key("")); +} + +#[test] +fn saved_instance_wire_rejects_duplicate_instance_ids_within_plugin() { + let shared = uuid::Uuid::new_v4().to_string(); + let instances = [ + SavedPluginInstance { + instance_id: Some(shared.clone()), + ..saved_instance() + }, + SavedPluginInstance { + instance_id: Some(shared), + ..saved_instance() + }, + ]; + + assert_eq!( + validate_saved_plugin_instances(&instances).unwrap_err(), + "DUPLICATE_PLUGIN_INSTANCE_ID:1" + ); +} + +#[test] +fn plugin_instances_request_enforces_compact_size_limit() { + let mut settings = BTreeMap::new(); + settings.insert( + "label".to_string(), + PluginSettingValue::String("x".repeat(MAX_SETTING_STRING_BYTES)), + ); + // ID 중복 거절에 먼저 걸리지 않게 인스턴스별 고유 ID 부여 + let instances = (0..129) + .map(|index| SavedPluginInstance { + instance_id: Some(format!("00000000-0000-4000-8000-{index:012x}")), + settings: Some(settings.clone()), + ..saved_instance() + }) + .collect::>(); + let request = PluginInstancesCommitRequest { + plugin_id: "demo".to_string(), + instances, + mutation_id: uuid::Uuid::new_v4().to_string(), + gesture_id: None, + observed_history_epoch: None, + expected_model_revision: None, + authority_generation: 1, + }; + + assert_eq!( + validate_plugin_instances_request(&request).unwrap_err(), + "PLUGIN_INSTANCES_REQUEST_TOO_LARGE" + ); +} + +#[test] +fn plugin_instances_request_shares_editor_gesture_id_rules() { + let mut request = PluginInstancesCommitRequest { + plugin_id: "demo".to_string(), + instances: vec![saved_instance()], + mutation_id: uuid::Uuid::new_v4().to_string(), + gesture_id: None, + observed_history_epoch: None, + expected_model_revision: None, + authority_generation: 1, + }; + + // 프론트 crypto.randomUUID() 발급분은 통과 + request.gesture_id = Some(uuid::Uuid::new_v4().to_string()); + validate_plugin_instances_request(&request).unwrap(); + + // 비UUID 형식 거절 - editor 경로와 동일 규칙 (길이 상한은 UUID 파싱이 + // 먼저 걸러 독립 관측 불가, 방어층으로만 존재) + for invalid_gesture_id in ["not-a-uuid".to_string(), "가".repeat(22)] { + request.gesture_id = Some(invalid_gesture_id); + assert_eq!( + validate_plugin_instances_request(&request).unwrap_err(), + "INVALID_PLUGIN_GESTURE_ID" + ); + } +} + +#[test] +fn plugin_storage_key_wire_bytes_are_stable() { + assert_eq!( + plugin_instances_storage_key("demo"), + "plugin_data_demo/instances" + ); + assert_eq!( + plugin_id_from_instances_storage_key("plugin_data_demo/instances"), + Some("demo") + ); + assert!(is_plugin_instances_storage_key( + "plugin_data_demo/instances" + )); + assert!(!is_plugin_instances_storage_key( + "plugin_data_invalid/id/instances" + )); +} + +#[test] +fn legacy_long_plugin_ids_remain_addressable() { + for length in [129, 255, 256, 1_024] { + let plugin_id = "a".repeat(length); + validate_plugin_id(&plugin_id).unwrap(); + let key = plugin_instances_storage_key(&plugin_id); + assert_eq!( + plugin_id_from_instances_storage_key(&key), + Some(plugin_id.as_str()) + ); + } + + for plugin_id in ["", "contains/slash", "한글"] { + assert_eq!( + validate_plugin_id(plugin_id).unwrap_err(), + "INVALID_PLUGIN_ID" + ); + assert_eq!( + plugin_id_from_instances_storage_key(&plugin_instances_storage_key(plugin_id)), + None + ); + } +} + +#[test] +fn existing_instance_namespace_is_canonical_without_copying() { + let key = "plugin_data_demo/instances".to_string(); + let value = serde_json::json!([{ + "position": { "x": 4.0, "y": 5.0 }, + "tabId": "4key", + "instanceId": uuid::Uuid::new_v4().to_string() + }]); + let mut data = AppStoreData::default(); + data.plugin_data.insert(key.clone(), value.clone()); + + assert!(!backfill_plugin_instance_ids(&mut data).changed); + assert_eq!(plugin_instances_storage_key("demo"), key); + assert_eq!(data.plugin_data[&key], value); +} + +#[test] +fn lenient_decode_keeps_valid_entries_and_skips_invalid_siblings() { + let instance = saved_instance(); + let valid = serde_json::to_value(&instance).unwrap(); + let stored = serde_json::json!([ + valid.clone(), + { "position": "broken" }, + valid + ]); + + let decoded = + decode_plugin_instances_lenient(Some(&stored), "plugin_data_demo/instances").unwrap(); + + assert_eq!(decoded, vec![instance]); +} + +#[test] +fn legacy_finite_metrics_are_readable_and_only_improving_commits_are_allowed() { + let mut legacy = saved_instance(); + legacy.position.x = 40_000.0; + let stored = serde_json::to_value(vec![legacy.clone()]).unwrap(); + assert_eq!( + decode_plugin_instances(Some(&stored)).unwrap().unwrap(), + vec![legacy.clone()] + ); + assert_eq!( + validate_saved_plugin_instances(&[legacy.clone()]).unwrap_err(), + "INVALID_PLUGIN_INSTANCE_POSITION:0" + ); + + validate_plugin_instances_transition(&[legacy.clone()], &[legacy.clone()]).unwrap(); + let mut improving = legacy.clone(); + improving.position.x = 39_000.0; + validate_plugin_instances_transition(&[legacy.clone()], &[improving]).unwrap(); + + let mut worsening = legacy.clone(); + worsening.position.x = 41_000.0; + assert_eq!( + validate_plugin_instances_transition(&[legacy], &[worsening]).unwrap_err(), + "INVALID_PLUGIN_INSTANCE_POSITION:0" + ); + let new_outlier = SavedPluginInstance { + position: PluginPoint { + x: 40_000.0, + y: 2.0, + }, + ..saved_instance() + }; + assert_eq!( + validate_plugin_instances_transition(&[], &[new_outlier]).unwrap_err(), + "INVALID_PLUGIN_INSTANCE_POSITION:0" + ); +} + +#[test] +fn plugin_instances_request_rejects_missing_instance_id() { + let request = PluginInstancesCommitRequest { + plugin_id: "demo".to_string(), + instances: vec![SavedPluginInstance { + instance_id: None, + ..saved_instance() + }], + mutation_id: uuid::Uuid::new_v4().to_string(), + gesture_id: None, + observed_history_epoch: None, + expected_model_revision: None, + authority_generation: 1, + }; + + assert_eq!( + validate_plugin_instances_request(&request).unwrap_err(), + "MISSING_PLUGIN_INSTANCE_ID:0" + ); +} + +#[test] +fn plugin_instances_reconcile_request_validates_tabs_and_wire_shape() { + let base = PluginInstancesReconcileRequest { + plugin_id: "demo".to_string(), + valid_tab_ids: vec!["4key".to_string()], + mutation_id: uuid::Uuid::new_v4().to_string(), + observed_history_epoch: None, + authority_generation: 1, + }; + validate_plugin_instances_reconcile_request(&base).unwrap(); + + let mut invalid_epoch = base.clone(); + invalid_epoch.observed_history_epoch = Some(MAX_SAFE_WIRE_REVISION + 1); + assert_eq!( + validate_plugin_instances_reconcile_request(&invalid_epoch).unwrap_err(), + "PLUGIN_REVISION_OUT_OF_RANGE" + ); + + let mut too_many = base.clone(); + too_many.valid_tab_ids = vec!["4key".to_string(); MAX_PLUGIN_RECONCILE_TAB_IDS + 1]; + assert_eq!( + validate_plugin_instances_reconcile_request(&too_many).unwrap_err(), + "TOO_MANY_PLUGIN_RECONCILE_TAB_IDS" + ); + + for invalid_tab_id in [String::new(), "x".repeat(MAX_TAB_ID_BYTES + 1)] { + let mut invalid = base.clone(); + invalid.valid_tab_ids = vec![invalid_tab_id]; + assert_eq!( + validate_plugin_instances_reconcile_request(&invalid).unwrap_err(), + "INVALID_PLUGIN_RECONCILE_TAB_ID:0" + ); + } + + let unknown = serde_json::json!({ + "pluginId": "demo", + "validTabIds": ["4key"], + "mutationId": uuid::Uuid::new_v4().to_string(), + "authorityGeneration": 1, + "unexpected": true, + }); + assert!(serde_json::from_value::(unknown).is_err()); +} From c637f8f29fe789805a57964db1e212052f0de671 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 16:57:57 +0900 Subject: [PATCH 147/245] =?UTF-8?q?refactor:=20=EC=97=90=EB=94=94=ED=84=B0?= =?UTF-8?q?=20=EB=AA=A8=EB=8D=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/models/editor.rs | 617 +-------------------------- src-tauri/src/models/editor/tests.rs | 614 ++++++++++++++++++++++++++ 2 files changed, 615 insertions(+), 616 deletions(-) create mode 100644 src-tauri/src/models/editor/tests.rs diff --git a/src-tauri/src/models/editor.rs b/src-tauri/src/models/editor.rs index 67b78e346..5761d15f2 100644 --- a/src-tauri/src/models/editor.rs +++ b/src-tauri/src/models/editor.rs @@ -884,619 +884,4 @@ pub struct EditorTransactionResult { } #[cfg(test)] -mod tests { - use super::*; - - // TS canonical 배열과 공유하는 property 태그 fixture - const PROPERTY_TAG_FIXTURE: &str = - include_str!("../../../tests/fixtures/editor-property-tags.json"); - - fn paint(color: &str) -> EditorPaintDescriptorV1 { - EditorPaintDescriptorV1 { - color: color.to_string(), - gradient: None, - } - } - - // 75개 variant 전수: (patch, property 태그, value wire) 고정 표본 - fn property_patch_samples() -> Vec<( - EditorElementPropertyPatchV1, - &'static str, - serde_json::Value, - )> { - use serde_json::json; - use EditorElementPropertyPatchV1 as P; - vec![ - (P::Hidden(true), "hidden", json!(true)), - ( - P::LayerName(Some("layer".to_string())), - "layerName", - json!("layer"), - ), - (P::GraphType(GraphType::Bar), "graphType", json!("bar")), - ( - P::GraphColor("#123456".to_string()), - "graphColor", - json!("#123456"), - ), - (P::ShowAvgLine(false), "showAvgLine", json!(false)), - ( - P::GraphAnimationEnabled(true), - "graphAnimationEnabled", - json!(true), - ), - (P::GraphSpeed(7), "graphSpeed", json!(7)), - (P::Reverse(true), "reverse", json!(true)), - (P::Sensitivity(1.5), "sensitivity", json!(1.5)), - (P::AxisId("axis-1".to_string()), "axisId", json!("axis-1")), - (P::UseInlineStyles(true), "useInlineStyles", json!(true)), - (P::FontWeight(700), "fontWeight", json!(700)), - (P::FontBold(true), "fontBold", json!(true)), - (P::FontItalic(true), "fontItalic", json!(true)), - (P::FontUnderline(false), "fontUnderline", json!(false)), - (P::FontStrikethrough(true), "fontStrikethrough", json!(true)), - ( - P::FontFamily("font".to_string()), - "fontFamily", - json!("font"), - ), - ( - P::DisplayText("text".to_string()), - "displayText", - json!("text"), - ), - ( - P::ClassName("class".to_string()), - "className", - json!("class"), - ), - ( - P::FontPaint(paint("#111111")), - "fontPaint", - json!({ "color": "#111111", "gradient": null }), - ), - ( - P::ActiveFontPaint(paint("#222222")), - "activeFontPaint", - json!({ "color": "#222222", "gradient": null }), - ), - ( - P::Shadow(EditorShadowLeafPatchV1::Color( - "rgba(0, 0, 0, 0.2)".to_string(), - )), - "shadow", - json!({ "leaf": "color", "value": "rgba(0, 0, 0, 0.2)" }), - ), - ( - P::ActiveShadow(EditorShadowLeafPatchV1::OffsetY(-3.25)), - "activeShadow", - json!({ "leaf": "offsetY", "value": -3.25 }), - ), - (P::ShadowEnabled(false), "shadowEnabled", json!(false)), - ( - P::BackgroundPaint(paint("#010101")), - "backgroundPaint", - json!({ "color": "#010101", "gradient": null }), - ), - ( - P::ActiveBackgroundPaint(paint("#020202")), - "activeBackgroundPaint", - json!({ "color": "#020202", "gradient": null }), - ), - ( - P::BorderPaint(paint("#030303")), - "borderPaint", - json!({ "color": "#030303", "gradient": null }), - ), - ( - P::ActiveBorderPaint(paint("#040404")), - "activeBorderPaint", - json!({ "color": "#040404", "gradient": null }), - ), - (P::BorderWidth(2.5), "borderWidth", json!(2.5)), - (P::BorderRadius(8.5), "borderRadius", json!(8.5)), - (P::FontSize(12.5), "fontSize", json!(12.5)), - ( - P::InactiveImage("idle.png".to_string()), - "inactiveImage", - json!("idle.png"), - ), - ( - P::ActiveImage("active.png".to_string()), - "activeImage", - json!("active.png"), - ), - (P::IdleTransparent(true), "idleTransparent", json!(true)), - ( - P::ActiveTransparent(false), - "activeTransparent", - json!(false), - ), - ( - P::IdleImageFit(ImageFit::Contain), - "idleImageFit", - json!("contain"), - ), - ( - P::ActiveImageFit(ImageFit::Fill), - "activeImageFit", - json!("fill"), - ), - ( - P::ImageMode(ImageMode::Overlay), - "imageMode", - json!("overlay"), - ), - ( - P::IdleImageTransform(Some(ImageTransformLeafPatchV1::Rotation(-45.5))), - "idleImageTransform", - json!({ "leaf": "rotation", "value": -45.5 }), - ), - ( - P::ActiveImageTransform(None), - "activeImageTransform", - serde_json::Value::Null, - ), - ( - P::SoundPath("sound.wav".to_string()), - "soundPath", - json!("sound.wav"), - ), - (P::SoundEnabled(true), "soundEnabled", json!(true)), - (P::SoundVolume(80.5), "soundVolume", json!(80.5)), - (P::CounterEnabled(true), "counterEnabled", json!(true)), - ( - P::CounterAnimationEnabled(false), - "counterAnimationEnabled", - json!(false), - ), - ( - P::CounterPlacement(KeyCounterPlacement::Outside), - "counterPlacement", - json!("outside"), - ), - ( - P::CounterAlign(KeyCounterAlign::Top), - "counterAlign", - json!("top"), - ), - ( - P::CounterAlignMode(KeyCounterAlignMode::Between), - "counterAlignMode", - json!("between"), - ), - (P::CounterGap(4), "counterGap", json!(4)), - (P::CounterFontSize(16), "counterFontSize", json!(16)), - (P::CounterFontWeight(500), "counterFontWeight", json!(500)), - (P::CounterFontBold(true), "counterFontBold", json!(true)), - (P::CounterFontItalic(true), "counterFontItalic", json!(true)), - ( - P::CounterFontUnderline(false), - "counterFontUnderline", - json!(false), - ), - ( - P::CounterFontStrikethrough(true), - "counterFontStrikethrough", - json!(true), - ), - ( - P::CounterFontFamily("counter-font".to_string()), - "counterFontFamily", - json!("counter-font"), - ), - ( - P::CounterFillIdle(EditorCounterFillIntentV1::Solid( - EditorCounterFillSolidIntentV1 { - color: "#050505".to_string(), - }, - )), - "counterFillIdle", - json!({ "color": "#050505" }), - ), - ( - P::CounterFillActive(EditorCounterFillIntentV1::Solid( - EditorCounterFillSolidIntentV1 { - color: "#060606".to_string(), - }, - )), - "counterFillActive", - json!({ "color": "#060606" }), - ), - ( - P::CounterAnimationPreset(EditorCounterAnimationPresetIntentV1 { - preset_id: "preset".to_string(), - apply_preset_id: None, - bezier: None, - scale: None, - duration_ms: None, - }), - "counterAnimationPreset", - json!({ "presetId": "preset" }), - ), - (P::StatType(StatType::Total), "statType", json!("total")), - (P::NoteEffectEnabled(true), "noteEffectEnabled", json!(true)), - (P::NoteGlowEnabled(false), "noteGlowEnabled", json!(false)), - (P::NoteGlowSyncPaint(true), "noteGlowSyncPaint", json!(true)), - (P::NoteGlowSize(6.5), "noteGlowSize", json!(6.5)), - ( - P::NotePaint(EditorNotePaintIntentV1::Opacity( - EditorNotePaintOpacityIntentV1 { opacity: 40 }, - )), - "notePaint", - json!({ "opacity": 40 }), - ), - ( - P::NoteGlowPaint(EditorNotePaintIntentV1::Opacity( - EditorNotePaintOpacityIntentV1 { opacity: 60 }, - )), - "noteGlowPaint", - json!({ "opacity": 60 }), - ), - ( - P::NoteBorderPaint(EditorNoteBorderPaintV1 { - color: "#0a0b0c".to_string(), - opacity: 30, - gradient: None, - }), - "noteBorderPaint", - json!({ "color": "#0a0b0c", "opacity": 30 }), - ), - (P::NoteOffsetX(Some(11.5)), "noteOffsetX", json!(11.5)), - (P::NoteOffsetY(Some(-12.5)), "noteOffsetY", json!(-12.5)), - (P::NoteWidth(Some(13.5)), "noteWidth", json!(13.5)), - (P::NoteBorderWidth(1.5), "noteBorderWidth", json!(1.5)), - (P::NoteBorderRadius(9.5), "noteBorderRadius", json!(9.5)), - ( - P::NoteAutoYCorrection(true), - "noteAutoYCorrection", - json!(true), - ), - ( - P::NoteAlignment(NoteAlignment::Right), - "noteAlignment", - json!("right"), - ), - ( - P::NoteBorderSide(EditorNoteBorderSideV1::Vertical), - "noteBorderSide", - json!("vertical"), - ), - ] - } - - #[test] - fn property_patch_wire_pins_all_tag_value_pairs_and_roundtrips() { - let samples = property_patch_samples(); - assert_eq!(samples.len(), 75); - for (patch, tag, value) in samples { - let wire = serde_json::to_value(&patch).unwrap(); - assert_eq!( - wire, - serde_json::json!({ "property": tag, "value": value }), - "wire mismatch for {tag}" - ); - let decoded: EditorElementPropertyPatchV1 = serde_json::from_value(wire).unwrap(); - assert_eq!(decoded, patch, "roundtrip mismatch for {tag}"); - } - } - - #[test] - fn expanded_paint_values_keep_existing_property_tags_and_exact_keys() { - let gradient = serde_json::json!({ - "angle": 90, - "stops": [ - { "color": "#112233", "pos": 0 }, - { "color": "#445566", "pos": 1 } - ] - }); - let cases = [ - serde_json::json!({ - "property": "noteBorderPaint", - "value": { "color": "#112233", "opacity": 80 } - }), - serde_json::json!({ - "property": "noteBorderPaint", - "value": { "color": "#112233", "opacity": 80, "gradient": null } - }), - serde_json::json!({ - "property": "noteBorderPaint", - "value": { - "color": "#112233", - "opacity": 80, - "gradient": gradient.clone() - } - }), - serde_json::json!({ - "property": "notePaint", - "value": { - "color": "#112233", - "opacity": 80, - "gradient": null - } - }), - serde_json::json!({ - "property": "noteGlowPaint", - "value": { - "color": { - "type": "gradient", - "top": "#112233", - "bottom": "#445566" - }, - "opacity": 80, - "gradient": gradient - } - }), - ]; - - for wire in cases { - let patch: EditorElementPropertyPatchV1 = serde_json::from_value(wire.clone()).unwrap(); - assert_eq!( - serde_json::to_value(&patch).unwrap()["property"], - wire["property"] - ); - } - - for wire in [ - serde_json::json!({ - "property": "noteBorderPaint", - "value": { "color": "#112233", "opacity": 80, "extra": true } - }), - serde_json::json!({ - "property": "notePaint", - "value": { "color": "#112233", "opacity": 80 } - }), - serde_json::json!({ - "property": "noteGlowPaint", - "value": { - "color": "#112233", - "opacity": 80, - "gradient": null, - "extra": true - } - }), - ] { - assert!(serde_json::from_value::(wire).is_err()); - } - } - - #[derive(serde::Deserialize)] - #[serde(rename_all = "camelCase", deny_unknown_fields)] - struct PropertyTagFixture { - version: u16, - properties: Vec, - } - - fn property_tag_fixture() -> PropertyTagFixture { - serde_json::from_str(PROPERTY_TAG_FIXTURE).unwrap() - } - - #[test] - fn property_patch_tags_match_shared_fixture() { - let fixture = property_tag_fixture(); - // 양방향 anchor: Rust 상수만 승격되는 사고도 fixture 대조로 잡는다 - assert_eq!(fixture.version, EDITOR_OPS_VERSION); - let tags: Vec = property_patch_samples() - .iter() - .map(|(_, tag, _)| (*tag).to_string()) - .collect(); - assert_eq!(tags, fixture.properties); - } - - // serde가 알 수 없는 variant 오류에 나열하는 기대 목록에서 enum의 - // wire 태그 전수를 기계적으로 추출 - 수기 목록 없이 variant 추가를 감지 - fn wire_property_tags_from_serde() -> std::collections::BTreeSet { - let message = serde_json::from_value::(serde_json::json!({ - "property": "__unknown__", - "value": null, - })) - .unwrap_err() - .to_string(); - let (_, listed) = message - .split_once("expected one of ") - .expect("serde unknown-variant error must enumerate every variant"); - let tags: std::collections::BTreeSet = listed - .split(", ") - .map(|tag| tag.trim().trim_matches('`').to_string()) - .collect(); - assert!(!tags.is_empty()); - tags - } - - #[test] - fn sample_and_fixture_tags_cover_every_enum_variant() { - let enum_tags = wire_property_tags_from_serde(); - let sample_tags: std::collections::BTreeSet = property_patch_samples() - .iter() - .map(|(_, tag, _)| (*tag).to_string()) - .collect(); - let fixture_tags: std::collections::BTreeSet = - property_tag_fixture().properties.into_iter().collect(); - // variant 신규 추가 시 표본·fixture 갱신 누락을 단방향 드리프트 없이 차단 - assert_eq!(sample_tags, enum_tags); - assert_eq!(fixture_tags, enum_tags); - } - - #[test] - fn property_patch_rejects_untagged_extra_unknown_and_missing_fields() { - use serde_json::json; - let reject = |value: serde_json::Value| { - assert!( - serde_json::from_value::(value.clone()).is_err(), - "expected rejection: {value}" - ); - }; - // 옛 one-key 형식 - reject(json!({ "hidden": true })); - reject(json!({ "graphType": "bar" })); - // outer 추가 필드 - reject(json!({ "property": "hidden", "value": true, "extra": 1 })); - // 알 수 없는 property - reject(json!({ "property": "unknown", "value": 1 })); - // property 누락 - reject(json!({ "value": true })); - // 원시값 payload의 value 누락 - reject(json!({ "property": "hidden" })); - // 잘못된 값 타입 - reject(json!({ "property": "hidden", "value": "true" })); - // 중복 키 - for duplicated in [ - r#"{ "property": "hidden", "property": "hidden", "value": true }"#, - r#"{ "property": "hidden", "value": true, "value": false }"#, - ] { - assert!( - serde_json::from_str::(duplicated).is_err(), - "expected duplicate key rejection: {duplicated}" - ); - } - } - - #[test] - fn nullable_variants_require_an_explicit_value_key() { - use serde_json::json; - use EditorElementPropertyPatchV1 as P; - let cases = [ - ("layerName", P::LayerName(None)), - ("noteOffsetX", P::NoteOffsetX(None)), - ("noteOffsetY", P::NoteOffsetY(None)), - ("noteWidth", P::NoteWidth(None)), - ("idleImageTransform", P::IdleImageTransform(None)), - ("activeImageTransform", P::ActiveImageTransform(None)), - ]; - for (tag, expected_null) in cases { - // value 키 자체가 없으면 None으로 통과하지 않고 거부 - assert!( - serde_json::from_value::(json!({ "property": tag })) - .is_err(), - "expected missing value rejection for {tag}" - ); - let decoded: EditorElementPropertyPatchV1 = - serde_json::from_value(json!({ "property": tag, "value": null })).unwrap(); - assert_eq!(decoded, expected_null, "explicit null mismatch for {tag}"); - } - let decoded: EditorElementPropertyPatchV1 = serde_json::from_value(json!({ - "property": "layerName", - "value": "named" - })) - .unwrap(); - assert_eq!(decoded, P::LayerName(Some("named".to_string()))); - } - - #[test] - fn shadow_leaf_wire_requires_leaf_tag_and_exact_fields() { - use serde_json::json; - let decoded: EditorShadowLeafPatchV1 = - serde_json::from_value(json!({ "leaf": "blur", "value": 4.5 })).unwrap(); - assert_eq!(decoded, EditorShadowLeafPatchV1::Blur(4.5)); - for invalid in [ - // 옛 one-key 형식 - json!({ "blur": 4.5 }), - // leaf 누락 - json!({ "value": 4.5 }), - // leaf 오타 - json!({ "leaf": "blr", "value": 4.5 }), - // value 누락 - json!({ "leaf": "blur" }), - // 추가 필드 - json!({ "leaf": "blur", "value": 4.5, "extra": 1 }), - // 잘못된 값 타입 - json!({ "leaf": "offsetX", "value": "1" }), - ] { - assert!( - serde_json::from_value::(invalid.clone()).is_err(), - "expected shadow leaf rejection: {invalid}" - ); - } - } - - #[test] - fn image_transform_leaf_wire_requires_leaf_tag_and_exact_fields() { - use serde_json::json; - for (wire, expected) in [ - ( - json!({ "leaf": "offsetX", "value": -10.5 }), - ImageTransformLeafPatchV1::OffsetX(-10.5), - ), - ( - json!({ "leaf": "offsetY", "value": 20.5 }), - ImageTransformLeafPatchV1::OffsetY(20.5), - ), - ( - json!({ "leaf": "rotation", "value": 45.0 }), - ImageTransformLeafPatchV1::Rotation(45.0), - ), - ( - json!({ "leaf": "scale", "value": 1.25 }), - ImageTransformLeafPatchV1::Scale(1.25), - ), - ] { - let decoded: ImageTransformLeafPatchV1 = serde_json::from_value(wire.clone()).unwrap(); - assert_eq!(decoded, expected); - assert_eq!(serde_json::to_value(decoded).unwrap(), wire); - } - for invalid in [ - json!({ "scale": 1.0 }), - json!({ "value": 1.0 }), - json!({ "leaf": "zoom", "value": 1.0 }), - json!({ "leaf": "scale" }), - json!({ "leaf": "scale", "value": 1.0, "extra": true }), - json!({ "leaf": "offsetX", "value": "1" }), - ] { - assert!( - serde_json::from_value::(invalid.clone()).is_err(), - "expected image transform leaf rejection: {invalid}" - ); - } - } - - #[test] - fn editor_request_detects_every_direct_key_mapping_mutation() { - let request = - |changes: Option, ops: Option>| EditorCommitRequest { - base_revision: 0, - mutation_id: uuid::Uuid::new_v4().to_string(), - multi_key: false, - gesture_id: None, - gesture_ids: Vec::new(), - changes, - ops_version: ops.as_ref().map(|_| EDITOR_OPS_VERSION), - ops, - }; - - let key_patch = EditorPatchV1 { - keys: Some(KeyMappings::new()), - ..EditorPatchV1::default() - }; - assert!(request(Some(key_patch), None).may_change_keys()); - assert!(request( - None, - Some(vec![EditorOpV1::SetKeySlot { - id: uuid::Uuid::new_v4().to_string(), - slot: EditorFrozenKeySlotV1::Single("A".to_string()), - }]), - ) - .may_change_keys()); - assert!(request( - None, - Some(vec![EditorOpV1::DeleteElement { - element_type: EditorElementTypeV1::Key, - id: uuid::Uuid::new_v4().to_string(), - }]), - ) - .may_change_keys()); - assert!(!request( - None, - Some(vec![EditorOpV1::SetBounds { - element_type: EditorElementTypeV1::Key, - id: uuid::Uuid::new_v4().to_string(), - bounds: EditorBoundsV1 { - dx: 1.0, - dy: 2.0, - width: 60.0, - height: 60.0, - }, - }]), - ) - .may_change_keys()); - } -} +mod tests; diff --git a/src-tauri/src/models/editor/tests.rs b/src-tauri/src/models/editor/tests.rs new file mode 100644 index 000000000..76f972d7e --- /dev/null +++ b/src-tauri/src/models/editor/tests.rs @@ -0,0 +1,614 @@ +use super::*; + +// TS canonical 배열과 공유하는 property 태그 fixture +const PROPERTY_TAG_FIXTURE: &str = + include_str!("../../../../tests/fixtures/editor-property-tags.json"); + +fn paint(color: &str) -> EditorPaintDescriptorV1 { + EditorPaintDescriptorV1 { + color: color.to_string(), + gradient: None, + } +} + +// 75개 variant 전수: (patch, property 태그, value wire) 고정 표본 +fn property_patch_samples() -> Vec<( + EditorElementPropertyPatchV1, + &'static str, + serde_json::Value, +)> { + use serde_json::json; + use EditorElementPropertyPatchV1 as P; + vec![ + (P::Hidden(true), "hidden", json!(true)), + ( + P::LayerName(Some("layer".to_string())), + "layerName", + json!("layer"), + ), + (P::GraphType(GraphType::Bar), "graphType", json!("bar")), + ( + P::GraphColor("#123456".to_string()), + "graphColor", + json!("#123456"), + ), + (P::ShowAvgLine(false), "showAvgLine", json!(false)), + ( + P::GraphAnimationEnabled(true), + "graphAnimationEnabled", + json!(true), + ), + (P::GraphSpeed(7), "graphSpeed", json!(7)), + (P::Reverse(true), "reverse", json!(true)), + (P::Sensitivity(1.5), "sensitivity", json!(1.5)), + (P::AxisId("axis-1".to_string()), "axisId", json!("axis-1")), + (P::UseInlineStyles(true), "useInlineStyles", json!(true)), + (P::FontWeight(700), "fontWeight", json!(700)), + (P::FontBold(true), "fontBold", json!(true)), + (P::FontItalic(true), "fontItalic", json!(true)), + (P::FontUnderline(false), "fontUnderline", json!(false)), + (P::FontStrikethrough(true), "fontStrikethrough", json!(true)), + ( + P::FontFamily("font".to_string()), + "fontFamily", + json!("font"), + ), + ( + P::DisplayText("text".to_string()), + "displayText", + json!("text"), + ), + ( + P::ClassName("class".to_string()), + "className", + json!("class"), + ), + ( + P::FontPaint(paint("#111111")), + "fontPaint", + json!({ "color": "#111111", "gradient": null }), + ), + ( + P::ActiveFontPaint(paint("#222222")), + "activeFontPaint", + json!({ "color": "#222222", "gradient": null }), + ), + ( + P::Shadow(EditorShadowLeafPatchV1::Color( + "rgba(0, 0, 0, 0.2)".to_string(), + )), + "shadow", + json!({ "leaf": "color", "value": "rgba(0, 0, 0, 0.2)" }), + ), + ( + P::ActiveShadow(EditorShadowLeafPatchV1::OffsetY(-3.25)), + "activeShadow", + json!({ "leaf": "offsetY", "value": -3.25 }), + ), + (P::ShadowEnabled(false), "shadowEnabled", json!(false)), + ( + P::BackgroundPaint(paint("#010101")), + "backgroundPaint", + json!({ "color": "#010101", "gradient": null }), + ), + ( + P::ActiveBackgroundPaint(paint("#020202")), + "activeBackgroundPaint", + json!({ "color": "#020202", "gradient": null }), + ), + ( + P::BorderPaint(paint("#030303")), + "borderPaint", + json!({ "color": "#030303", "gradient": null }), + ), + ( + P::ActiveBorderPaint(paint("#040404")), + "activeBorderPaint", + json!({ "color": "#040404", "gradient": null }), + ), + (P::BorderWidth(2.5), "borderWidth", json!(2.5)), + (P::BorderRadius(8.5), "borderRadius", json!(8.5)), + (P::FontSize(12.5), "fontSize", json!(12.5)), + ( + P::InactiveImage("idle.png".to_string()), + "inactiveImage", + json!("idle.png"), + ), + ( + P::ActiveImage("active.png".to_string()), + "activeImage", + json!("active.png"), + ), + (P::IdleTransparent(true), "idleTransparent", json!(true)), + ( + P::ActiveTransparent(false), + "activeTransparent", + json!(false), + ), + ( + P::IdleImageFit(ImageFit::Contain), + "idleImageFit", + json!("contain"), + ), + ( + P::ActiveImageFit(ImageFit::Fill), + "activeImageFit", + json!("fill"), + ), + ( + P::ImageMode(ImageMode::Overlay), + "imageMode", + json!("overlay"), + ), + ( + P::IdleImageTransform(Some(ImageTransformLeafPatchV1::Rotation(-45.5))), + "idleImageTransform", + json!({ "leaf": "rotation", "value": -45.5 }), + ), + ( + P::ActiveImageTransform(None), + "activeImageTransform", + serde_json::Value::Null, + ), + ( + P::SoundPath("sound.wav".to_string()), + "soundPath", + json!("sound.wav"), + ), + (P::SoundEnabled(true), "soundEnabled", json!(true)), + (P::SoundVolume(80.5), "soundVolume", json!(80.5)), + (P::CounterEnabled(true), "counterEnabled", json!(true)), + ( + P::CounterAnimationEnabled(false), + "counterAnimationEnabled", + json!(false), + ), + ( + P::CounterPlacement(KeyCounterPlacement::Outside), + "counterPlacement", + json!("outside"), + ), + ( + P::CounterAlign(KeyCounterAlign::Top), + "counterAlign", + json!("top"), + ), + ( + P::CounterAlignMode(KeyCounterAlignMode::Between), + "counterAlignMode", + json!("between"), + ), + (P::CounterGap(4), "counterGap", json!(4)), + (P::CounterFontSize(16), "counterFontSize", json!(16)), + (P::CounterFontWeight(500), "counterFontWeight", json!(500)), + (P::CounterFontBold(true), "counterFontBold", json!(true)), + (P::CounterFontItalic(true), "counterFontItalic", json!(true)), + ( + P::CounterFontUnderline(false), + "counterFontUnderline", + json!(false), + ), + ( + P::CounterFontStrikethrough(true), + "counterFontStrikethrough", + json!(true), + ), + ( + P::CounterFontFamily("counter-font".to_string()), + "counterFontFamily", + json!("counter-font"), + ), + ( + P::CounterFillIdle(EditorCounterFillIntentV1::Solid( + EditorCounterFillSolidIntentV1 { + color: "#050505".to_string(), + }, + )), + "counterFillIdle", + json!({ "color": "#050505" }), + ), + ( + P::CounterFillActive(EditorCounterFillIntentV1::Solid( + EditorCounterFillSolidIntentV1 { + color: "#060606".to_string(), + }, + )), + "counterFillActive", + json!({ "color": "#060606" }), + ), + ( + P::CounterAnimationPreset(EditorCounterAnimationPresetIntentV1 { + preset_id: "preset".to_string(), + apply_preset_id: None, + bezier: None, + scale: None, + duration_ms: None, + }), + "counterAnimationPreset", + json!({ "presetId": "preset" }), + ), + (P::StatType(StatType::Total), "statType", json!("total")), + (P::NoteEffectEnabled(true), "noteEffectEnabled", json!(true)), + (P::NoteGlowEnabled(false), "noteGlowEnabled", json!(false)), + (P::NoteGlowSyncPaint(true), "noteGlowSyncPaint", json!(true)), + (P::NoteGlowSize(6.5), "noteGlowSize", json!(6.5)), + ( + P::NotePaint(EditorNotePaintIntentV1::Opacity( + EditorNotePaintOpacityIntentV1 { opacity: 40 }, + )), + "notePaint", + json!({ "opacity": 40 }), + ), + ( + P::NoteGlowPaint(EditorNotePaintIntentV1::Opacity( + EditorNotePaintOpacityIntentV1 { opacity: 60 }, + )), + "noteGlowPaint", + json!({ "opacity": 60 }), + ), + ( + P::NoteBorderPaint(EditorNoteBorderPaintV1 { + color: "#0a0b0c".to_string(), + opacity: 30, + gradient: None, + }), + "noteBorderPaint", + json!({ "color": "#0a0b0c", "opacity": 30 }), + ), + (P::NoteOffsetX(Some(11.5)), "noteOffsetX", json!(11.5)), + (P::NoteOffsetY(Some(-12.5)), "noteOffsetY", json!(-12.5)), + (P::NoteWidth(Some(13.5)), "noteWidth", json!(13.5)), + (P::NoteBorderWidth(1.5), "noteBorderWidth", json!(1.5)), + (P::NoteBorderRadius(9.5), "noteBorderRadius", json!(9.5)), + ( + P::NoteAutoYCorrection(true), + "noteAutoYCorrection", + json!(true), + ), + ( + P::NoteAlignment(NoteAlignment::Right), + "noteAlignment", + json!("right"), + ), + ( + P::NoteBorderSide(EditorNoteBorderSideV1::Vertical), + "noteBorderSide", + json!("vertical"), + ), + ] +} + +#[test] +fn property_patch_wire_pins_all_tag_value_pairs_and_roundtrips() { + let samples = property_patch_samples(); + assert_eq!(samples.len(), 75); + for (patch, tag, value) in samples { + let wire = serde_json::to_value(&patch).unwrap(); + assert_eq!( + wire, + serde_json::json!({ "property": tag, "value": value }), + "wire mismatch for {tag}" + ); + let decoded: EditorElementPropertyPatchV1 = serde_json::from_value(wire).unwrap(); + assert_eq!(decoded, patch, "roundtrip mismatch for {tag}"); + } +} + +#[test] +fn expanded_paint_values_keep_existing_property_tags_and_exact_keys() { + let gradient = serde_json::json!({ + "angle": 90, + "stops": [ + { "color": "#112233", "pos": 0 }, + { "color": "#445566", "pos": 1 } + ] + }); + let cases = [ + serde_json::json!({ + "property": "noteBorderPaint", + "value": { "color": "#112233", "opacity": 80 } + }), + serde_json::json!({ + "property": "noteBorderPaint", + "value": { "color": "#112233", "opacity": 80, "gradient": null } + }), + serde_json::json!({ + "property": "noteBorderPaint", + "value": { + "color": "#112233", + "opacity": 80, + "gradient": gradient.clone() + } + }), + serde_json::json!({ + "property": "notePaint", + "value": { + "color": "#112233", + "opacity": 80, + "gradient": null + } + }), + serde_json::json!({ + "property": "noteGlowPaint", + "value": { + "color": { + "type": "gradient", + "top": "#112233", + "bottom": "#445566" + }, + "opacity": 80, + "gradient": gradient + } + }), + ]; + + for wire in cases { + let patch: EditorElementPropertyPatchV1 = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!( + serde_json::to_value(&patch).unwrap()["property"], + wire["property"] + ); + } + + for wire in [ + serde_json::json!({ + "property": "noteBorderPaint", + "value": { "color": "#112233", "opacity": 80, "extra": true } + }), + serde_json::json!({ + "property": "notePaint", + "value": { "color": "#112233", "opacity": 80 } + }), + serde_json::json!({ + "property": "noteGlowPaint", + "value": { + "color": "#112233", + "opacity": 80, + "gradient": null, + "extra": true + } + }), + ] { + assert!(serde_json::from_value::(wire).is_err()); + } +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PropertyTagFixture { + version: u16, + properties: Vec, +} + +fn property_tag_fixture() -> PropertyTagFixture { + serde_json::from_str(PROPERTY_TAG_FIXTURE).unwrap() +} + +#[test] +fn property_patch_tags_match_shared_fixture() { + let fixture = property_tag_fixture(); + // 양방향 anchor: Rust 상수만 승격되는 사고도 fixture 대조로 잡는다 + assert_eq!(fixture.version, EDITOR_OPS_VERSION); + let tags: Vec = property_patch_samples() + .iter() + .map(|(_, tag, _)| (*tag).to_string()) + .collect(); + assert_eq!(tags, fixture.properties); +} + +// serde가 알 수 없는 variant 오류에 나열하는 기대 목록에서 enum의 +// wire 태그 전수를 기계적으로 추출 - 수기 목록 없이 variant 추가를 감지 +fn wire_property_tags_from_serde() -> std::collections::BTreeSet { + let message = serde_json::from_value::(serde_json::json!({ + "property": "__unknown__", + "value": null, + })) + .unwrap_err() + .to_string(); + let (_, listed) = message + .split_once("expected one of ") + .expect("serde unknown-variant error must enumerate every variant"); + let tags: std::collections::BTreeSet = listed + .split(", ") + .map(|tag| tag.trim().trim_matches('`').to_string()) + .collect(); + assert!(!tags.is_empty()); + tags +} + +#[test] +fn sample_and_fixture_tags_cover_every_enum_variant() { + let enum_tags = wire_property_tags_from_serde(); + let sample_tags: std::collections::BTreeSet = property_patch_samples() + .iter() + .map(|(_, tag, _)| (*tag).to_string()) + .collect(); + let fixture_tags: std::collections::BTreeSet = + property_tag_fixture().properties.into_iter().collect(); + // variant 신규 추가 시 표본·fixture 갱신 누락을 단방향 드리프트 없이 차단 + assert_eq!(sample_tags, enum_tags); + assert_eq!(fixture_tags, enum_tags); +} + +#[test] +fn property_patch_rejects_untagged_extra_unknown_and_missing_fields() { + use serde_json::json; + let reject = |value: serde_json::Value| { + assert!( + serde_json::from_value::(value.clone()).is_err(), + "expected rejection: {value}" + ); + }; + // 옛 one-key 형식 + reject(json!({ "hidden": true })); + reject(json!({ "graphType": "bar" })); + // outer 추가 필드 + reject(json!({ "property": "hidden", "value": true, "extra": 1 })); + // 알 수 없는 property + reject(json!({ "property": "unknown", "value": 1 })); + // property 누락 + reject(json!({ "value": true })); + // 원시값 payload의 value 누락 + reject(json!({ "property": "hidden" })); + // 잘못된 값 타입 + reject(json!({ "property": "hidden", "value": "true" })); + // 중복 키 + for duplicated in [ + r#"{ "property": "hidden", "property": "hidden", "value": true }"#, + r#"{ "property": "hidden", "value": true, "value": false }"#, + ] { + assert!( + serde_json::from_str::(duplicated).is_err(), + "expected duplicate key rejection: {duplicated}" + ); + } +} + +#[test] +fn nullable_variants_require_an_explicit_value_key() { + use serde_json::json; + use EditorElementPropertyPatchV1 as P; + let cases = [ + ("layerName", P::LayerName(None)), + ("noteOffsetX", P::NoteOffsetX(None)), + ("noteOffsetY", P::NoteOffsetY(None)), + ("noteWidth", P::NoteWidth(None)), + ("idleImageTransform", P::IdleImageTransform(None)), + ("activeImageTransform", P::ActiveImageTransform(None)), + ]; + for (tag, expected_null) in cases { + // value 키 자체가 없으면 None으로 통과하지 않고 거부 + assert!( + serde_json::from_value::(json!({ "property": tag })) + .is_err(), + "expected missing value rejection for {tag}" + ); + let decoded: EditorElementPropertyPatchV1 = + serde_json::from_value(json!({ "property": tag, "value": null })).unwrap(); + assert_eq!(decoded, expected_null, "explicit null mismatch for {tag}"); + } + let decoded: EditorElementPropertyPatchV1 = serde_json::from_value(json!({ + "property": "layerName", + "value": "named" + })) + .unwrap(); + assert_eq!(decoded, P::LayerName(Some("named".to_string()))); +} + +#[test] +fn shadow_leaf_wire_requires_leaf_tag_and_exact_fields() { + use serde_json::json; + let decoded: EditorShadowLeafPatchV1 = + serde_json::from_value(json!({ "leaf": "blur", "value": 4.5 })).unwrap(); + assert_eq!(decoded, EditorShadowLeafPatchV1::Blur(4.5)); + for invalid in [ + // 옛 one-key 형식 + json!({ "blur": 4.5 }), + // leaf 누락 + json!({ "value": 4.5 }), + // leaf 오타 + json!({ "leaf": "blr", "value": 4.5 }), + // value 누락 + json!({ "leaf": "blur" }), + // 추가 필드 + json!({ "leaf": "blur", "value": 4.5, "extra": 1 }), + // 잘못된 값 타입 + json!({ "leaf": "offsetX", "value": "1" }), + ] { + assert!( + serde_json::from_value::(invalid.clone()).is_err(), + "expected shadow leaf rejection: {invalid}" + ); + } +} + +#[test] +fn image_transform_leaf_wire_requires_leaf_tag_and_exact_fields() { + use serde_json::json; + for (wire, expected) in [ + ( + json!({ "leaf": "offsetX", "value": -10.5 }), + ImageTransformLeafPatchV1::OffsetX(-10.5), + ), + ( + json!({ "leaf": "offsetY", "value": 20.5 }), + ImageTransformLeafPatchV1::OffsetY(20.5), + ), + ( + json!({ "leaf": "rotation", "value": 45.0 }), + ImageTransformLeafPatchV1::Rotation(45.0), + ), + ( + json!({ "leaf": "scale", "value": 1.25 }), + ImageTransformLeafPatchV1::Scale(1.25), + ), + ] { + let decoded: ImageTransformLeafPatchV1 = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(decoded, expected); + assert_eq!(serde_json::to_value(decoded).unwrap(), wire); + } + for invalid in [ + json!({ "scale": 1.0 }), + json!({ "value": 1.0 }), + json!({ "leaf": "zoom", "value": 1.0 }), + json!({ "leaf": "scale" }), + json!({ "leaf": "scale", "value": 1.0, "extra": true }), + json!({ "leaf": "offsetX", "value": "1" }), + ] { + assert!( + serde_json::from_value::(invalid.clone()).is_err(), + "expected image transform leaf rejection: {invalid}" + ); + } +} + +#[test] +fn editor_request_detects_every_direct_key_mapping_mutation() { + let request = + |changes: Option, ops: Option>| EditorCommitRequest { + base_revision: 0, + mutation_id: uuid::Uuid::new_v4().to_string(), + multi_key: false, + gesture_id: None, + gesture_ids: Vec::new(), + changes, + ops_version: ops.as_ref().map(|_| EDITOR_OPS_VERSION), + ops, + }; + + let key_patch = EditorPatchV1 { + keys: Some(KeyMappings::new()), + ..EditorPatchV1::default() + }; + assert!(request(Some(key_patch), None).may_change_keys()); + assert!(request( + None, + Some(vec![EditorOpV1::SetKeySlot { + id: uuid::Uuid::new_v4().to_string(), + slot: EditorFrozenKeySlotV1::Single("A".to_string()), + }]), + ) + .may_change_keys()); + assert!(request( + None, + Some(vec![EditorOpV1::DeleteElement { + element_type: EditorElementTypeV1::Key, + id: uuid::Uuid::new_v4().to_string(), + }]), + ) + .may_change_keys()); + assert!(!request( + None, + Some(vec![EditorOpV1::SetBounds { + element_type: EditorElementTypeV1::Key, + id: uuid::Uuid::new_v4().to_string(), + bounds: EditorBoundsV1 { + dx: 1.0, + dy: 2.0, + width: 60.0, + height: 60.0, + }, + }]), + ) + .may_change_keys()); +} From ad990597bb018ecf0c9fe2123de881f3fc668bb1 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 17:01:12 +0900 Subject: [PATCH 148/245] =?UTF-8?q?refactor:=20CSS=20=EB=AA=85=EB=A0=B9=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=AA=A8=EB=93=88=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/editor/css.rs | 271 +-------------------- src-tauri/src/commands/editor/css/tests.rs | 265 ++++++++++++++++++++ 2 files changed, 266 insertions(+), 270 deletions(-) create mode 100644 src-tauri/src/commands/editor/css/tests.rs diff --git a/src-tauri/src/commands/editor/css.rs b/src-tauri/src/commands/editor/css.rs index f91df92cb..c5574ace5 100644 --- a/src-tauri/src/commands/editor/css.rs +++ b/src-tauri/src/commands/editor/css.rs @@ -1251,273 +1251,4 @@ fn css_tab_export_from_path( } #[cfg(test)] -mod tests { - use super::{ - ensure_css_extension, ensure_css_import_window, fetch_css_import, is_cloud_metadata_ip, - prepare_tab_css_for_set_with, read_css_import_body, replace_tab_css_override, - resolve_css_import_addrs, validate_css_import_url, write_tab_css_export, - MAX_CSS_IMPORT_BYTES, - }; - use crate::models::{AppStoreData, TabCss}; - use parking_lot::Mutex; - use std::{ - fs, - path::Path, - sync::{mpsc, Arc}, - thread, - }; - - fn test_directory(label: &str) -> std::path::PathBuf { - std::env::temp_dir().join(format!( - "dmnote-css-command-{label}-{}", - uuid::Uuid::new_v4() - )) - } - - #[test] - fn css_import_url_accepts_only_http_and_https() { - assert_eq!( - validate_css_import_url("https://example.com/theme.css") - .unwrap() - .scheme(), - "https" - ); - assert_eq!( - validate_css_import_url("http://example.com/theme.css") - .unwrap() - .scheme(), - "http" - ); - for invalid in ["file:///tmp/theme.css", "data:text/css,body{}", "theme.css"] { - assert!(validate_css_import_url(invalid).is_err(), "{invalid}"); - } - } - - #[test] - fn css_import_fetch_is_limited_to_the_main_window() { - ensure_css_import_window(super::super::MAIN_WINDOW_LABEL).unwrap(); - for label in ["overlay", "panel"] { - assert_eq!( - ensure_css_import_window(label).unwrap_err().to_string(), - "CSS import fetch is only available in the main window" - ); - } - } - - #[test] - fn css_import_blocks_only_explicit_metadata_targets() { - for allowed in [ - "http://127.0.0.1:5500/theme.css", - "http://10.0.0.1/theme.css", - "https://example.com/theme.css", - ] { - validate_css_import_url(allowed).unwrap(); - } - for blocked in [ - "http://169.254.169.254/latest/meta-data", - "http://169.254.170.2/v2/credentials", - "http://100.100.100.200/latest/meta-data", - "http://192.0.0.192/metadata", - "http://[fd00:ec2::254]/latest/meta-data", - "http://metadata.google.internal/computeMetadata/v1", - "http://metadata.azure.internal/metadata/instance", - ] { - assert!(validate_css_import_url(blocked).is_err(), "{blocked}"); - } - } - - #[test] - fn css_import_blocks_ipv4_mapped_metadata_literals_and_pins_resolved_addresses() { - assert!( - validate_css_import_url("http://[::ffff:169.254.169.254]/latest/meta-data").is_err() - ); - assert!(is_cloud_metadata_ip( - "::ffff:169.254.169.254".parse().unwrap() - )); - assert!(is_cloud_metadata_ip("fd00:ec2::254".parse().unwrap())); - assert!(!is_cloud_metadata_ip("::1".parse().unwrap())); - assert!(!is_cloud_metadata_ip("127.0.0.1".parse().unwrap())); - - let literal = url::Url::parse("http://127.0.0.1:5500/theme.css").unwrap(); - assert_eq!( - resolve_css_import_addrs(&literal).unwrap(), - vec!["127.0.0.1:5500".parse::().unwrap()] - ); - let mapped = url::Url::parse("http://[::ffff:169.254.169.254]/latest").unwrap(); - assert!(resolve_css_import_addrs(&mapped).is_err()); - } - - #[test] - fn css_import_follows_local_redirect_without_prompt() { - use std::io::{Read as _, Write as _}; - use std::net::TcpListener; - - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let address = listener.local_addr().unwrap(); - let server = thread::spawn(move || { - for request_index in 0..2 { - let (mut stream, _) = listener.accept().unwrap(); - let mut request = [0_u8; 2_048]; - let _ = stream.read(&mut request).unwrap(); - if request_index == 0 { - stream - .write_all( - b"HTTP/1.1 302 Found\r\nLocation: /theme.css\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", - ) - .unwrap(); - } else { - stream - .write_all( - b"HTTP/1.1 200 OK\r\nContent-Length: 22\r\nConnection: close\r\n\r\n.counter { color:red }", - ) - .unwrap(); - } - } - }); - let fetched = fetch_css_import(format!("http://{address}/start.css")).unwrap(); - - server.join().unwrap(); - assert_eq!(fetched.final_url, format!("http://{address}/theme.css")); - assert_eq!(fetched.text, ".counter { color:red }"); - } - - #[test] - fn css_import_blocks_metadata_redirect_before_requesting_it() { - use std::io::{Read as _, Write as _}; - use std::net::TcpListener; - - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let address = listener.local_addr().unwrap(); - let server = thread::spawn(move || { - let (mut stream, _) = listener.accept().unwrap(); - let mut request = [0_u8; 2_048]; - let _ = stream.read(&mut request).unwrap(); - stream - .write_all( - b"HTTP/1.1 302 Found\r\nLocation: http://169.254.169.254/latest/meta-data\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", - ) - .unwrap(); - }); - - let error = fetch_css_import(format!("http://{address}/start.css")).unwrap_err(); - - server.join().unwrap(); - assert!( - error.to_string().contains("error following redirect"), - "unexpected redirect error: {error}" - ); - } - - #[test] - fn css_import_body_enforces_the_one_mibibyte_limit() { - let accepted = vec![b'a'; MAX_CSS_IMPORT_BYTES]; - assert_eq!( - read_css_import_body(std::io::Cursor::new(accepted.clone())).unwrap(), - accepted - ); - - let rejected = vec![b'a'; MAX_CSS_IMPORT_BYTES + 1]; - assert_eq!( - read_css_import_body(std::io::Cursor::new(rejected)) - .unwrap_err() - .to_string(), - format!("CSS import response exceeds {MAX_CSS_IMPORT_BYTES} bytes") - ); - } - - #[test] - fn unauthorized_tab_set_preserves_content_without_persisting_path() { - let css = TabCss { - path: Some("/tmp/not-authorized.css".to_string()), - content: "preserved".to_string(), - enabled: true, - }; - - let prepared = prepare_tab_css_for_set_with(css, |_| false); - - assert_eq!(prepared.path, None); - assert_eq!(prepared.content, "preserved"); - assert!(prepared.enabled); - } - - #[test] - fn authorized_tab_set_persists_the_canonical_path() { - let root = test_directory("authorized"); - fs::create_dir_all(&root).unwrap(); - let path = root.join("theme.css"); - fs::write(&path, "body {}").unwrap(); - let css = TabCss { - path: Some(path.to_string_lossy().to_string()), - content: "preserved".to_string(), - enabled: true, - }; - - let prepared = prepare_tab_css_for_set_with(css, |_| true); - - let canonical = fs::canonicalize(&path) - .unwrap() - .to_string_lossy() - .to_string(); - assert_eq!(prepared.path.as_deref(), Some(canonical.as_str())); - assert_eq!(prepared.content, "preserved"); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn export_corrects_extension_and_atomically_replaces_existing_file() { - let root = test_directory("export"); - fs::create_dir_all(&root).unwrap(); - let selected = root.join("theme.txt"); - let export = ensure_css_extension(selected); - fs::write(&export, "old").unwrap(); - - write_tab_css_export(&export, "new").unwrap(); - - assert_eq!( - export.extension().and_then(|value| value.to_str()), - Some("css") - ); - assert_eq!(fs::read_to_string(&export).unwrap(), "new"); - assert_eq!(fs::read_dir(&root).unwrap().count(), 1); - assert!(Path::new(&export).is_file()); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn clear_waiting_on_activation_lock_wins_with_the_last_commit() { - let operation_lock = Arc::new(Mutex::new(())); - let store = Arc::new(Mutex::new(AppStoreData::default())); - let (locked_tx, locked_rx) = mpsc::channel(); - let (continue_tx, continue_rx) = mpsc::channel(); - - let activation_lock = operation_lock.clone(); - let activation_store = store.clone(); - let activate = thread::spawn(move || { - let _guard = activation_lock.lock(); - locked_tx.send(()).unwrap(); - continue_rx.recv().unwrap(); - replace_tab_css_override( - &mut activation_store.lock(), - "4key", - Some(TabCss { - path: Some("/tmp/theme.css".to_string()), - content: "active".to_string(), - enabled: true, - }), - ); - }); - locked_rx.recv().unwrap(); - - let clear_lock = operation_lock.clone(); - let clear_store = store.clone(); - let clear = thread::spawn(move || { - let _guard = clear_lock.lock(); - replace_tab_css_override(&mut clear_store.lock(), "4key", None); - }); - continue_tx.send(()).unwrap(); - activate.join().unwrap(); - clear.join().unwrap(); - - assert!(!store.lock().tab_css_overrides.contains_key("4key")); - } -} +mod tests; diff --git a/src-tauri/src/commands/editor/css/tests.rs b/src-tauri/src/commands/editor/css/tests.rs new file mode 100644 index 000000000..0a936d0d4 --- /dev/null +++ b/src-tauri/src/commands/editor/css/tests.rs @@ -0,0 +1,265 @@ +use super::{ + ensure_css_extension, ensure_css_import_window, fetch_css_import, is_cloud_metadata_ip, + prepare_tab_css_for_set_with, read_css_import_body, replace_tab_css_override, + resolve_css_import_addrs, validate_css_import_url, write_tab_css_export, MAX_CSS_IMPORT_BYTES, +}; +use crate::models::{AppStoreData, TabCss}; +use parking_lot::Mutex; +use std::{ + fs, + path::Path, + sync::{mpsc, Arc}, + thread, +}; + +fn test_directory(label: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!( + "dmnote-css-command-{label}-{}", + uuid::Uuid::new_v4() + )) +} + +#[test] +fn css_import_url_accepts_only_http_and_https() { + assert_eq!( + validate_css_import_url("https://example.com/theme.css") + .unwrap() + .scheme(), + "https" + ); + assert_eq!( + validate_css_import_url("http://example.com/theme.css") + .unwrap() + .scheme(), + "http" + ); + for invalid in ["file:///tmp/theme.css", "data:text/css,body{}", "theme.css"] { + assert!(validate_css_import_url(invalid).is_err(), "{invalid}"); + } +} + +#[test] +fn css_import_fetch_is_limited_to_the_main_window() { + ensure_css_import_window(super::super::MAIN_WINDOW_LABEL).unwrap(); + for label in ["overlay", "panel"] { + assert_eq!( + ensure_css_import_window(label).unwrap_err().to_string(), + "CSS import fetch is only available in the main window" + ); + } +} + +#[test] +fn css_import_blocks_only_explicit_metadata_targets() { + for allowed in [ + "http://127.0.0.1:5500/theme.css", + "http://10.0.0.1/theme.css", + "https://example.com/theme.css", + ] { + validate_css_import_url(allowed).unwrap(); + } + for blocked in [ + "http://169.254.169.254/latest/meta-data", + "http://169.254.170.2/v2/credentials", + "http://100.100.100.200/latest/meta-data", + "http://192.0.0.192/metadata", + "http://[fd00:ec2::254]/latest/meta-data", + "http://metadata.google.internal/computeMetadata/v1", + "http://metadata.azure.internal/metadata/instance", + ] { + assert!(validate_css_import_url(blocked).is_err(), "{blocked}"); + } +} + +#[test] +fn css_import_blocks_ipv4_mapped_metadata_literals_and_pins_resolved_addresses() { + assert!(validate_css_import_url("http://[::ffff:169.254.169.254]/latest/meta-data").is_err()); + assert!(is_cloud_metadata_ip( + "::ffff:169.254.169.254".parse().unwrap() + )); + assert!(is_cloud_metadata_ip("fd00:ec2::254".parse().unwrap())); + assert!(!is_cloud_metadata_ip("::1".parse().unwrap())); + assert!(!is_cloud_metadata_ip("127.0.0.1".parse().unwrap())); + + let literal = url::Url::parse("http://127.0.0.1:5500/theme.css").unwrap(); + assert_eq!( + resolve_css_import_addrs(&literal).unwrap(), + vec!["127.0.0.1:5500".parse::().unwrap()] + ); + let mapped = url::Url::parse("http://[::ffff:169.254.169.254]/latest").unwrap(); + assert!(resolve_css_import_addrs(&mapped).is_err()); +} + +#[test] +fn css_import_follows_local_redirect_without_prompt() { + use std::io::{Read as _, Write as _}; + use std::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + for request_index in 0..2 { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0_u8; 2_048]; + let _ = stream.read(&mut request).unwrap(); + if request_index == 0 { + stream + .write_all( + b"HTTP/1.1 302 Found\r\nLocation: /theme.css\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .unwrap(); + } else { + stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 22\r\nConnection: close\r\n\r\n.counter { color:red }", + ) + .unwrap(); + } + } + }); + let fetched = fetch_css_import(format!("http://{address}/start.css")).unwrap(); + + server.join().unwrap(); + assert_eq!(fetched.final_url, format!("http://{address}/theme.css")); + assert_eq!(fetched.text, ".counter { color:red }"); +} + +#[test] +fn css_import_blocks_metadata_redirect_before_requesting_it() { + use std::io::{Read as _, Write as _}; + use std::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0_u8; 2_048]; + let _ = stream.read(&mut request).unwrap(); + stream + .write_all( + b"HTTP/1.1 302 Found\r\nLocation: http://169.254.169.254/latest/meta-data\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .unwrap(); + }); + + let error = fetch_css_import(format!("http://{address}/start.css")).unwrap_err(); + + server.join().unwrap(); + assert!( + error.to_string().contains("error following redirect"), + "unexpected redirect error: {error}" + ); +} + +#[test] +fn css_import_body_enforces_the_one_mibibyte_limit() { + let accepted = vec![b'a'; MAX_CSS_IMPORT_BYTES]; + assert_eq!( + read_css_import_body(std::io::Cursor::new(accepted.clone())).unwrap(), + accepted + ); + + let rejected = vec![b'a'; MAX_CSS_IMPORT_BYTES + 1]; + assert_eq!( + read_css_import_body(std::io::Cursor::new(rejected)) + .unwrap_err() + .to_string(), + format!("CSS import response exceeds {MAX_CSS_IMPORT_BYTES} bytes") + ); +} + +#[test] +fn unauthorized_tab_set_preserves_content_without_persisting_path() { + let css = TabCss { + path: Some("/tmp/not-authorized.css".to_string()), + content: "preserved".to_string(), + enabled: true, + }; + + let prepared = prepare_tab_css_for_set_with(css, |_| false); + + assert_eq!(prepared.path, None); + assert_eq!(prepared.content, "preserved"); + assert!(prepared.enabled); +} + +#[test] +fn authorized_tab_set_persists_the_canonical_path() { + let root = test_directory("authorized"); + fs::create_dir_all(&root).unwrap(); + let path = root.join("theme.css"); + fs::write(&path, "body {}").unwrap(); + let css = TabCss { + path: Some(path.to_string_lossy().to_string()), + content: "preserved".to_string(), + enabled: true, + }; + + let prepared = prepare_tab_css_for_set_with(css, |_| true); + + let canonical = fs::canonicalize(&path) + .unwrap() + .to_string_lossy() + .to_string(); + assert_eq!(prepared.path.as_deref(), Some(canonical.as_str())); + assert_eq!(prepared.content, "preserved"); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn export_corrects_extension_and_atomically_replaces_existing_file() { + let root = test_directory("export"); + fs::create_dir_all(&root).unwrap(); + let selected = root.join("theme.txt"); + let export = ensure_css_extension(selected); + fs::write(&export, "old").unwrap(); + + write_tab_css_export(&export, "new").unwrap(); + + assert_eq!( + export.extension().and_then(|value| value.to_str()), + Some("css") + ); + assert_eq!(fs::read_to_string(&export).unwrap(), "new"); + assert_eq!(fs::read_dir(&root).unwrap().count(), 1); + assert!(Path::new(&export).is_file()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn clear_waiting_on_activation_lock_wins_with_the_last_commit() { + let operation_lock = Arc::new(Mutex::new(())); + let store = Arc::new(Mutex::new(AppStoreData::default())); + let (locked_tx, locked_rx) = mpsc::channel(); + let (continue_tx, continue_rx) = mpsc::channel(); + + let activation_lock = operation_lock.clone(); + let activation_store = store.clone(); + let activate = thread::spawn(move || { + let _guard = activation_lock.lock(); + locked_tx.send(()).unwrap(); + continue_rx.recv().unwrap(); + replace_tab_css_override( + &mut activation_store.lock(), + "4key", + Some(TabCss { + path: Some("/tmp/theme.css".to_string()), + content: "active".to_string(), + enabled: true, + }), + ); + }); + locked_rx.recv().unwrap(); + + let clear_lock = operation_lock.clone(); + let clear_store = store.clone(); + let clear = thread::spawn(move || { + let _guard = clear_lock.lock(); + replace_tab_css_override(&mut clear_store.lock(), "4key", None); + }); + continue_tx.send(()).unwrap(); + activate.join().unwrap(); + clear.join().unwrap(); + + assert!(!store.lock().tab_css_overrides.contains_key("4key")); +} From daa276abca519c101a43e7443be855a049367c3b Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 17:02:36 +0900 Subject: [PATCH 149/245] =?UTF-8?q?refactor:=20=EB=AF=B8=EB=A6=AC=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=EB=B8=8C=EB=A1=9C=EC=BB=A4=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=AA=A8=EB=93=88=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/services/preview_broker.rs | 522 +----------------- .../src/services/preview_broker/tests.rs | 515 +++++++++++++++++ 2 files changed, 516 insertions(+), 521 deletions(-) create mode 100644 src-tauri/src/services/preview_broker/tests.rs diff --git a/src-tauri/src/services/preview_broker.rs b/src-tauri/src/services/preview_broker.rs index 356bdc08f..057e64197 100644 --- a/src-tauri/src/services/preview_broker.rs +++ b/src-tauri/src/services/preview_broker.rs @@ -576,524 +576,4 @@ fn send_envelopes(recipients: &[Channel], envelopes: &[PreviewE } #[cfg(test)] -mod tests { - use std::sync::{ - atomic::{AtomicUsize, Ordering}, - Arc, - }; - - use tauri::ipc::InvokeResponseBody; - - use super::*; - - fn session_id() -> String { - Uuid::new_v4().to_string() - } - - fn request(session_id: &str, seq: u64) -> PreviewPublishRequest { - request_for_domain(session_id, seq, PreviewDomain::KeyPosition) - } - - fn request_for_domain( - session_id: &str, - seq: u64, - domain: PreviewDomain, - ) -> PreviewPublishRequest { - PreviewPublishRequest { - schema_version: PREVIEW_SCHEMA_VERSION, - session_id: session_id.to_string(), - seq, - kind: PreviewKind::Patch, - domain, - mode: "default".to_string(), - targets: vec![0], - patch: Map::from_iter([("width".to_string(), Value::from(80))]), - } - } - - fn envelope(label: &str, request: PreviewPublishRequest) -> PreviewEnvelope { - PreviewEnvelope { - schema_version: request.schema_version, - session_id: request.session_id, - seq: request.seq, - kind: request.kind, - source_label: label.to_string(), - domain: request.domain, - mode: request.mode, - targets: request.targets, - patch: request.patch, - } - } - - fn channel(counter: Arc) -> Channel { - Channel::new(move |_| { - counter.fetch_add(1, Ordering::SeqCst); - Ok(()) - }) - } - - fn recording_channel(messages: Arc>>) -> Channel { - Channel::new(move |body| { - let InvokeResponseBody::Json(json) = body else { - panic!("preview envelope must use a JSON channel body"); - }; - messages - .lock() - .push(serde_json::from_str(&json).expect("preview envelope is valid JSON")); - Ok(()) - }) - } - - fn subscribe(broker: &PreviewBroker, label: &str) -> Arc { - let counter = Arc::new(AtomicUsize::new(0)); - broker - .subscribe(label, channel(counter.clone())) - .expect("subscribe succeeds"); - counter - } - - #[test] - fn rejects_publish_from_non_owner() { - let broker = PreviewBroker::default(); - subscribe(&broker, "owner"); - subscribe(&broker, "intruder"); - let session_id = session_id(); - - broker - .publish("owner", request(&session_id, 1)) - .expect("owner claims session"); - - assert!(broker - .publish("intruder", request(&session_id, 2)) - .unwrap_err() - .contains("another window")); - } - - #[test] - fn publish_injects_source_label_and_defaults_to_patch() { - let broker = PreviewBroker::default(); - subscribe(&broker, "owner"); - let messages = Arc::new(Mutex::new(Vec::new())); - broker - .subscribe("observer", recording_channel(messages.clone())) - .expect("observer subscribes"); - let session_id = session_id(); - let request: PreviewPublishRequest = serde_json::from_value(serde_json::json!({ - "schemaVersion": PREVIEW_SCHEMA_VERSION, - "sessionId": session_id, - "seq": 1, - "domain": "keyPosition", - "mode": "default", - "targets": [0], - "patch": { "width": 80 } - })) - .expect("frontend request shape is accepted"); - - broker.publish("owner", request).expect("publish succeeds"); - - let messages = messages.lock(); - assert_eq!(messages.len(), 1); - assert_eq!(messages[0].source_label, "owner"); - assert_eq!(messages[0].kind, PreviewKind::Patch); - } - - #[test] - fn image_transform_preview_fields_round_trip_through_channel() { - let broker = PreviewBroker::default(); - subscribe(&broker, "owner"); - let messages = Arc::new(Mutex::new(Vec::new())); - broker - .subscribe("observer", recording_channel(messages.clone())) - .expect("observer subscribes"); - let session_id = session_id(); - let expected_patch = serde_json::json!({ - "idleImageTransform": { - "offsetX": 12.5, - "offsetY": -4.0, - "rotation": 25.0, - "scale": 1.35 - }, - "activeImageTransform": { - "offsetX": -8.0, - "offsetY": 6.25, - "rotation": -15.0, - "scale": 0.75 - } - }); - let request: PreviewPublishRequest = serde_json::from_value(serde_json::json!({ - "schemaVersion": PREVIEW_SCHEMA_VERSION, - "sessionId": session_id, - "seq": 1, - "domain": "keyPosition", - "mode": "default", - "targets": [0], - "patch": expected_patch - })) - .expect("nested image transform request is valid JSON"); - - broker - .publish("owner", request) - .expect("image transform preview publishes"); - - let messages = messages.lock(); - assert_eq!(messages.len(), 1); - assert_eq!( - messages[0].patch, - expected_patch.as_object().unwrap().clone() - ); - } - - #[test] - fn font_gradient_preview_fields_are_allowed_without_widening_the_allowlist() { - let session_id = session_id(); - let mut allowed = request(&session_id, 1); - allowed.patch = Map::from_iter([ - ( - "fontGradient".to_string(), - serde_json::json!({ - "angle": 45, - "stops": [ - { "color": "#112233", "pos": 0 }, - { "color": "#445566", "pos": 1 } - ] - }), - ), - ("activeFontGradient".to_string(), Value::Null), - ]); - validate_publish_request(&allowed).expect("font gradient preview fields are allowed"); - - let mut rejected = request(&session_id, 2); - rejected.patch = Map::from_iter([("fontPaint".to_string(), Value::Null)]); - assert_eq!( - validate_publish_request(&rejected).unwrap_err(), - "preview patch field 'fontPaint' is not allowed" - ); - } - - #[test] - fn note_glow_sync_paint_preview_field_is_allowed() { - let session_id = session_id(); - let mut allowed = request(&session_id, 1); - allowed.patch = Map::from_iter([("noteGlowSyncPaint".to_string(), Value::Bool(true))]); - - validate_publish_request(&allowed).expect("note glow paint sync preview field is allowed"); - } - - #[test] - fn malformed_gradient_preview_values_are_rejected_before_broadcast() { - let session_id = session_id(); - let cases: [(&str, Value); 6] = [ - // 객체 아님 - ("fontGradient", serde_json::json!("broken")), - // stops 누락 - ("activeFontGradient", serde_json::json!({ "angle": 45 })), - // angle이 숫자가 아님 - ( - "backgroundGradient", - serde_json::json!({ - "angle": "45", - "stops": [ - { "color": "#112233", "pos": 0 }, - { "color": "#445566", "pos": 1 } - ] - }), - ), - // stop 1개 - ( - "borderGradient", - serde_json::json!({ - "angle": 45, - "stops": [{ "color": "#112233", "pos": 0 }] - }), - ), - // 빈 stop 색 - ( - "fontGradient", - serde_json::json!({ - "angle": 45, - "stops": [ - { "color": " ", "pos": 0 }, - { "color": "#445566", "pos": 1 } - ] - }), - ), - // pos 범위 밖 - ( - "activeBorderGradient", - serde_json::json!({ - "angle": 45, - "stops": [ - { "color": "#112233", "pos": 2 }, - { "color": "#445566", "pos": 1 } - ] - }), - ), - ]; - for (seq, (field, value)) in cases.into_iter().enumerate() { - let mut rejected = request(&session_id, seq as u64 + 1); - rejected.patch = Map::from_iter([(field.to_string(), value)]); - let error = validate_publish_request(&rejected) - .expect_err("malformed gradient preview must be rejected"); - assert!( - error.contains(&format!("preview field '{field}'")), - "unexpected error for {field}: {error}" - ); - } - } - - #[test] - fn forwards_every_position_preview_domain() { - let broker = PreviewBroker::default(); - subscribe(&broker, "owner"); - let messages = Arc::new(Mutex::new(Vec::new())); - broker - .subscribe("observer", recording_channel(messages.clone())) - .expect("observer subscribes"); - let session_id = session_id(); - let domains = [ - PreviewDomain::KeyPosition, - PreviewDomain::StatPosition, - PreviewDomain::GraphPosition, - PreviewDomain::KnobPosition, - ]; - - for (index, domain) in domains.iter().copied().enumerate() { - broker - .publish( - "owner", - request_for_domain(&session_id, index as u64 + 1, domain), - ) - .expect("supported domain publishes"); - } - - let forwarded = messages - .lock() - .iter() - .map(|message| message.domain) - .collect::>(); - assert_eq!(forwarded, domains); - } - - #[test] - fn rejects_non_monotonic_sequence() { - let broker = PreviewBroker::default(); - subscribe(&broker, "owner"); - let session_id = session_id(); - broker - .publish("owner", request(&session_id, 2)) - .expect("first publish succeeds"); - - assert!(broker - .publish("owner", request(&session_id, 2)) - .unwrap_err() - .contains("monotonically")); - assert!(broker - .publish("owner", request(&session_id, 1)) - .unwrap_err() - .contains("monotonically")); - } - - #[test] - fn rejects_oversized_payload() { - let broker = PreviewBroker::default(); - subscribe(&broker, "owner"); - let session_id = session_id(); - let mut oversized = request(&session_id, 1); - oversized.patch.insert( - "displayText".to_string(), - Value::String("x".repeat(MAX_PREVIEW_BYTES)), - ); - - assert!(broker - .publish("owner", oversized) - .unwrap_err() - .contains("byte limit")); - } - - #[test] - fn rejects_patch_field_outside_allowlist() { - let broker = PreviewBroker::default(); - subscribe(&broker, "owner"); - let session_id = session_id(); - let mut invalid = request(&session_id, 1); - invalid - .patch - .insert("groupId".to_string(), Value::from("g1")); - - assert!(broker - .publish("owner", invalid) - .unwrap_err() - .contains("not allowed")); - } - - #[test] - fn resubscribe_cancels_previous_generation_sessions() { - let broker = PreviewBroker::default(); - subscribe(&broker, "owner"); - let observer_messages = subscribe(&broker, "observer"); - let session_id = session_id(); - broker - .publish("owner", request(&session_id, 1)) - .expect("publish succeeds"); - assert_eq!(observer_messages.load(Ordering::SeqCst), 1); - - subscribe(&broker, "owner"); - - assert_eq!(observer_messages.load(Ordering::SeqCst), 2); - assert!(broker - .publish("owner", request(&session_id, 2)) - .unwrap_err() - .contains("already ended")); - } - - #[test] - fn committed_sessions_are_tombstoned_without_auxiliary_broadcast() { - let broker = PreviewBroker::default(); - subscribe(&broker, "owner"); - let observer_messages = subscribe(&broker, "observer"); - let session_ids = vec![session_id(), session_id()]; - for session_id in &session_ids { - broker - .publish("owner", request(session_id, 1)) - .expect("publish succeeds"); - } - - for session_id in &session_ids { - assert!(broker - .finish_committed_session("owner", session_id, false) - .expect("commit cleanup succeeds")); - } - assert_eq!(observer_messages.load(Ordering::SeqCst), 2); - for session_id in &session_ids { - assert!(broker - .publish("owner", request(session_id, 2)) - .unwrap_err() - .contains("already ended")); - } - } - - #[test] - fn no_op_commit_broadcasts_cancel_and_rejects_late_patch() { - let broker = PreviewBroker::default(); - subscribe(&broker, "owner"); - let observer_messages = subscribe(&broker, "observer"); - let session_id = session_id(); - - assert!(broker - .finish_committed_session("owner", &session_id, true) - .expect("commit cleanup succeeds")); - assert_eq!(observer_messages.load(Ordering::SeqCst), 1); - - assert!(broker - .publish("owner", request(&session_id, 1)) - .unwrap_err() - .contains("already ended")); - } - - #[test] - fn cancel_before_first_publish_rejects_late_patch() { - let broker = PreviewBroker::default(); - subscribe(&broker, "owner"); - let session_id = session_id(); - - broker - .cancel("owner", &session_id) - .expect("early cancel succeeds"); - - assert!(broker - .publish("owner", request(&session_id, 1)) - .unwrap_err() - .contains("already ended")); - } - - #[test] - fn destroyed_window_cancels_owned_sessions() { - let broker = PreviewBroker::default(); - subscribe(&broker, "owner"); - let observer_messages = subscribe(&broker, "observer"); - let session_id = session_id(); - broker - .publish("owner", request(&session_id, 1)) - .expect("publish succeeds"); - - broker.remove_label("owner"); - - assert_eq!(observer_messages.load(Ordering::SeqCst), 2); - assert!(broker - .publish("owner", request(&session_id, 2)) - .unwrap_err() - .contains("already ended")); - } - - #[test] - fn history_gate_rejects_busy_preview_admission() { - let broker = PreviewBroker::default(); - let gate = Arc::clone(&broker.history_gate); - let operation_id = Uuid::new_v4().to_string(); - let barrier = gate.close(&operation_id).unwrap(); - - let subscribe_error = broker - .subscribe("owner", channel(Arc::new(AtomicUsize::new(0)))) - .unwrap_err(); - assert_eq!(subscribe_error, "HISTORY_IN_PROGRESS"); - let publish_error = broker - .publish("owner", request(&session_id(), 1)) - .unwrap_err(); - assert_eq!(publish_error, "HISTORY_IN_PROGRESS"); - drop(barrier); - } - - #[test] - fn preview_revalidates_admission_generation_after_broker_wait() { - let broker = PreviewBroker::default(); - subscribe(&broker, "owner"); - let gate = Arc::clone(&broker.history_gate); - - let subscribe_admission = gate.try_admit().unwrap(); - let first_barrier = gate.close(&Uuid::new_v4().to_string()).unwrap(); - drop(first_barrier); - let subscribe_error = broker - .subscribe_after_admission( - "observer", - channel(Arc::new(AtomicUsize::new(0))), - subscribe_admission, - ) - .unwrap_err(); - assert_eq!(subscribe_error, "HISTORY_IN_PROGRESS"); - - let publish_admission = gate.try_admit().unwrap(); - let second_barrier = gate.close(&Uuid::new_v4().to_string()).unwrap(); - drop(second_barrier); - let session_id = session_id(); - let publish_error = broker - .publish_after_admission( - "owner", - envelope("owner", request(&session_id, 1)), - publish_admission, - ) - .unwrap_err(); - assert_eq!(publish_error, "HISTORY_IN_PROGRESS"); - } - - #[test] - fn cancel_all_tombstones_every_active_session() { - let broker = PreviewBroker::default(); - let owner_messages = subscribe(&broker, "owner"); - let observer_messages = subscribe(&broker, "observer"); - let session_id = session_id(); - broker - .publish("owner", request(&session_id, 1)) - .expect("publish succeeds"); - assert_eq!(observer_messages.load(Ordering::SeqCst), 1); - - assert_eq!(broker.cancel_all(), 1); - assert_eq!(owner_messages.load(Ordering::SeqCst), 1); - assert_eq!(observer_messages.load(Ordering::SeqCst), 2); - assert!(broker.state.lock().sessions.is_empty()); - assert!(broker.state.lock().tombstones.contains(&session_id)); - assert!(broker - .publish("owner", request(&session_id, 2)) - .unwrap_err() - .contains("already ended")); - } -} +mod tests; diff --git a/src-tauri/src/services/preview_broker/tests.rs b/src-tauri/src/services/preview_broker/tests.rs new file mode 100644 index 000000000..76affd3be --- /dev/null +++ b/src-tauri/src/services/preview_broker/tests.rs @@ -0,0 +1,515 @@ +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, +}; + +use tauri::ipc::InvokeResponseBody; + +use super::*; + +fn session_id() -> String { + Uuid::new_v4().to_string() +} + +fn request(session_id: &str, seq: u64) -> PreviewPublishRequest { + request_for_domain(session_id, seq, PreviewDomain::KeyPosition) +} + +fn request_for_domain(session_id: &str, seq: u64, domain: PreviewDomain) -> PreviewPublishRequest { + PreviewPublishRequest { + schema_version: PREVIEW_SCHEMA_VERSION, + session_id: session_id.to_string(), + seq, + kind: PreviewKind::Patch, + domain, + mode: "default".to_string(), + targets: vec![0], + patch: Map::from_iter([("width".to_string(), Value::from(80))]), + } +} + +fn envelope(label: &str, request: PreviewPublishRequest) -> PreviewEnvelope { + PreviewEnvelope { + schema_version: request.schema_version, + session_id: request.session_id, + seq: request.seq, + kind: request.kind, + source_label: label.to_string(), + domain: request.domain, + mode: request.mode, + targets: request.targets, + patch: request.patch, + } +} + +fn channel(counter: Arc) -> Channel { + Channel::new(move |_| { + counter.fetch_add(1, Ordering::SeqCst); + Ok(()) + }) +} + +fn recording_channel(messages: Arc>>) -> Channel { + Channel::new(move |body| { + let InvokeResponseBody::Json(json) = body else { + panic!("preview envelope must use a JSON channel body"); + }; + messages + .lock() + .push(serde_json::from_str(&json).expect("preview envelope is valid JSON")); + Ok(()) + }) +} + +fn subscribe(broker: &PreviewBroker, label: &str) -> Arc { + let counter = Arc::new(AtomicUsize::new(0)); + broker + .subscribe(label, channel(counter.clone())) + .expect("subscribe succeeds"); + counter +} + +#[test] +fn rejects_publish_from_non_owner() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + subscribe(&broker, "intruder"); + let session_id = session_id(); + + broker + .publish("owner", request(&session_id, 1)) + .expect("owner claims session"); + + assert!(broker + .publish("intruder", request(&session_id, 2)) + .unwrap_err() + .contains("another window")); +} + +#[test] +fn publish_injects_source_label_and_defaults_to_patch() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + let messages = Arc::new(Mutex::new(Vec::new())); + broker + .subscribe("observer", recording_channel(messages.clone())) + .expect("observer subscribes"); + let session_id = session_id(); + let request: PreviewPublishRequest = serde_json::from_value(serde_json::json!({ + "schemaVersion": PREVIEW_SCHEMA_VERSION, + "sessionId": session_id, + "seq": 1, + "domain": "keyPosition", + "mode": "default", + "targets": [0], + "patch": { "width": 80 } + })) + .expect("frontend request shape is accepted"); + + broker.publish("owner", request).expect("publish succeeds"); + + let messages = messages.lock(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].source_label, "owner"); + assert_eq!(messages[0].kind, PreviewKind::Patch); +} + +#[test] +fn image_transform_preview_fields_round_trip_through_channel() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + let messages = Arc::new(Mutex::new(Vec::new())); + broker + .subscribe("observer", recording_channel(messages.clone())) + .expect("observer subscribes"); + let session_id = session_id(); + let expected_patch = serde_json::json!({ + "idleImageTransform": { + "offsetX": 12.5, + "offsetY": -4.0, + "rotation": 25.0, + "scale": 1.35 + }, + "activeImageTransform": { + "offsetX": -8.0, + "offsetY": 6.25, + "rotation": -15.0, + "scale": 0.75 + } + }); + let request: PreviewPublishRequest = serde_json::from_value(serde_json::json!({ + "schemaVersion": PREVIEW_SCHEMA_VERSION, + "sessionId": session_id, + "seq": 1, + "domain": "keyPosition", + "mode": "default", + "targets": [0], + "patch": expected_patch + })) + .expect("nested image transform request is valid JSON"); + + broker + .publish("owner", request) + .expect("image transform preview publishes"); + + let messages = messages.lock(); + assert_eq!(messages.len(), 1); + assert_eq!( + messages[0].patch, + expected_patch.as_object().unwrap().clone() + ); +} + +#[test] +fn font_gradient_preview_fields_are_allowed_without_widening_the_allowlist() { + let session_id = session_id(); + let mut allowed = request(&session_id, 1); + allowed.patch = Map::from_iter([ + ( + "fontGradient".to_string(), + serde_json::json!({ + "angle": 45, + "stops": [ + { "color": "#112233", "pos": 0 }, + { "color": "#445566", "pos": 1 } + ] + }), + ), + ("activeFontGradient".to_string(), Value::Null), + ]); + validate_publish_request(&allowed).expect("font gradient preview fields are allowed"); + + let mut rejected = request(&session_id, 2); + rejected.patch = Map::from_iter([("fontPaint".to_string(), Value::Null)]); + assert_eq!( + validate_publish_request(&rejected).unwrap_err(), + "preview patch field 'fontPaint' is not allowed" + ); +} + +#[test] +fn note_glow_sync_paint_preview_field_is_allowed() { + let session_id = session_id(); + let mut allowed = request(&session_id, 1); + allowed.patch = Map::from_iter([("noteGlowSyncPaint".to_string(), Value::Bool(true))]); + + validate_publish_request(&allowed).expect("note glow paint sync preview field is allowed"); +} + +#[test] +fn malformed_gradient_preview_values_are_rejected_before_broadcast() { + let session_id = session_id(); + let cases: [(&str, Value); 6] = [ + // 객체 아님 + ("fontGradient", serde_json::json!("broken")), + // stops 누락 + ("activeFontGradient", serde_json::json!({ "angle": 45 })), + // angle이 숫자가 아님 + ( + "backgroundGradient", + serde_json::json!({ + "angle": "45", + "stops": [ + { "color": "#112233", "pos": 0 }, + { "color": "#445566", "pos": 1 } + ] + }), + ), + // stop 1개 + ( + "borderGradient", + serde_json::json!({ + "angle": 45, + "stops": [{ "color": "#112233", "pos": 0 }] + }), + ), + // 빈 stop 색 + ( + "fontGradient", + serde_json::json!({ + "angle": 45, + "stops": [ + { "color": " ", "pos": 0 }, + { "color": "#445566", "pos": 1 } + ] + }), + ), + // pos 범위 밖 + ( + "activeBorderGradient", + serde_json::json!({ + "angle": 45, + "stops": [ + { "color": "#112233", "pos": 2 }, + { "color": "#445566", "pos": 1 } + ] + }), + ), + ]; + for (seq, (field, value)) in cases.into_iter().enumerate() { + let mut rejected = request(&session_id, seq as u64 + 1); + rejected.patch = Map::from_iter([(field.to_string(), value)]); + let error = validate_publish_request(&rejected) + .expect_err("malformed gradient preview must be rejected"); + assert!( + error.contains(&format!("preview field '{field}'")), + "unexpected error for {field}: {error}" + ); + } +} + +#[test] +fn forwards_every_position_preview_domain() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + let messages = Arc::new(Mutex::new(Vec::new())); + broker + .subscribe("observer", recording_channel(messages.clone())) + .expect("observer subscribes"); + let session_id = session_id(); + let domains = [ + PreviewDomain::KeyPosition, + PreviewDomain::StatPosition, + PreviewDomain::GraphPosition, + PreviewDomain::KnobPosition, + ]; + + for (index, domain) in domains.iter().copied().enumerate() { + broker + .publish( + "owner", + request_for_domain(&session_id, index as u64 + 1, domain), + ) + .expect("supported domain publishes"); + } + + let forwarded = messages + .lock() + .iter() + .map(|message| message.domain) + .collect::>(); + assert_eq!(forwarded, domains); +} + +#[test] +fn rejects_non_monotonic_sequence() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + let session_id = session_id(); + broker + .publish("owner", request(&session_id, 2)) + .expect("first publish succeeds"); + + assert!(broker + .publish("owner", request(&session_id, 2)) + .unwrap_err() + .contains("monotonically")); + assert!(broker + .publish("owner", request(&session_id, 1)) + .unwrap_err() + .contains("monotonically")); +} + +#[test] +fn rejects_oversized_payload() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + let session_id = session_id(); + let mut oversized = request(&session_id, 1); + oversized.patch.insert( + "displayText".to_string(), + Value::String("x".repeat(MAX_PREVIEW_BYTES)), + ); + + assert!(broker + .publish("owner", oversized) + .unwrap_err() + .contains("byte limit")); +} + +#[test] +fn rejects_patch_field_outside_allowlist() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + let session_id = session_id(); + let mut invalid = request(&session_id, 1); + invalid + .patch + .insert("groupId".to_string(), Value::from("g1")); + + assert!(broker + .publish("owner", invalid) + .unwrap_err() + .contains("not allowed")); +} + +#[test] +fn resubscribe_cancels_previous_generation_sessions() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + let observer_messages = subscribe(&broker, "observer"); + let session_id = session_id(); + broker + .publish("owner", request(&session_id, 1)) + .expect("publish succeeds"); + assert_eq!(observer_messages.load(Ordering::SeqCst), 1); + + subscribe(&broker, "owner"); + + assert_eq!(observer_messages.load(Ordering::SeqCst), 2); + assert!(broker + .publish("owner", request(&session_id, 2)) + .unwrap_err() + .contains("already ended")); +} + +#[test] +fn committed_sessions_are_tombstoned_without_auxiliary_broadcast() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + let observer_messages = subscribe(&broker, "observer"); + let session_ids = vec![session_id(), session_id()]; + for session_id in &session_ids { + broker + .publish("owner", request(session_id, 1)) + .expect("publish succeeds"); + } + + for session_id in &session_ids { + assert!(broker + .finish_committed_session("owner", session_id, false) + .expect("commit cleanup succeeds")); + } + assert_eq!(observer_messages.load(Ordering::SeqCst), 2); + for session_id in &session_ids { + assert!(broker + .publish("owner", request(session_id, 2)) + .unwrap_err() + .contains("already ended")); + } +} + +#[test] +fn no_op_commit_broadcasts_cancel_and_rejects_late_patch() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + let observer_messages = subscribe(&broker, "observer"); + let session_id = session_id(); + + assert!(broker + .finish_committed_session("owner", &session_id, true) + .expect("commit cleanup succeeds")); + assert_eq!(observer_messages.load(Ordering::SeqCst), 1); + + assert!(broker + .publish("owner", request(&session_id, 1)) + .unwrap_err() + .contains("already ended")); +} + +#[test] +fn cancel_before_first_publish_rejects_late_patch() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + let session_id = session_id(); + + broker + .cancel("owner", &session_id) + .expect("early cancel succeeds"); + + assert!(broker + .publish("owner", request(&session_id, 1)) + .unwrap_err() + .contains("already ended")); +} + +#[test] +fn destroyed_window_cancels_owned_sessions() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + let observer_messages = subscribe(&broker, "observer"); + let session_id = session_id(); + broker + .publish("owner", request(&session_id, 1)) + .expect("publish succeeds"); + + broker.remove_label("owner"); + + assert_eq!(observer_messages.load(Ordering::SeqCst), 2); + assert!(broker + .publish("owner", request(&session_id, 2)) + .unwrap_err() + .contains("already ended")); +} + +#[test] +fn history_gate_rejects_busy_preview_admission() { + let broker = PreviewBroker::default(); + let gate = Arc::clone(&broker.history_gate); + let operation_id = Uuid::new_v4().to_string(); + let barrier = gate.close(&operation_id).unwrap(); + + let subscribe_error = broker + .subscribe("owner", channel(Arc::new(AtomicUsize::new(0)))) + .unwrap_err(); + assert_eq!(subscribe_error, "HISTORY_IN_PROGRESS"); + let publish_error = broker + .publish("owner", request(&session_id(), 1)) + .unwrap_err(); + assert_eq!(publish_error, "HISTORY_IN_PROGRESS"); + drop(barrier); +} + +#[test] +fn preview_revalidates_admission_generation_after_broker_wait() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + let gate = Arc::clone(&broker.history_gate); + + let subscribe_admission = gate.try_admit().unwrap(); + let first_barrier = gate.close(&Uuid::new_v4().to_string()).unwrap(); + drop(first_barrier); + let subscribe_error = broker + .subscribe_after_admission( + "observer", + channel(Arc::new(AtomicUsize::new(0))), + subscribe_admission, + ) + .unwrap_err(); + assert_eq!(subscribe_error, "HISTORY_IN_PROGRESS"); + + let publish_admission = gate.try_admit().unwrap(); + let second_barrier = gate.close(&Uuid::new_v4().to_string()).unwrap(); + drop(second_barrier); + let session_id = session_id(); + let publish_error = broker + .publish_after_admission( + "owner", + envelope("owner", request(&session_id, 1)), + publish_admission, + ) + .unwrap_err(); + assert_eq!(publish_error, "HISTORY_IN_PROGRESS"); +} + +#[test] +fn cancel_all_tombstones_every_active_session() { + let broker = PreviewBroker::default(); + let owner_messages = subscribe(&broker, "owner"); + let observer_messages = subscribe(&broker, "observer"); + let session_id = session_id(); + broker + .publish("owner", request(&session_id, 1)) + .expect("publish succeeds"); + assert_eq!(observer_messages.load(Ordering::SeqCst), 1); + + assert_eq!(broker.cancel_all(), 1); + assert_eq!(owner_messages.load(Ordering::SeqCst), 1); + assert_eq!(observer_messages.load(Ordering::SeqCst), 2); + assert!(broker.state.lock().sessions.is_empty()); + assert!(broker.state.lock().tombstones.contains(&session_id)); + assert!(broker + .publish("owner", request(&session_id, 2)) + .unwrap_err() + .contains("already ended")); +} From b26f59ba3850fa82953c3d947afc795f18788d45 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 17:03:03 +0900 Subject: [PATCH 150/245] =?UTF-8?q?refactor:=20=ED=82=A4=EB=B3=B4=EB=93=9C?= =?UTF-8?q?=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/keyboard/manager.rs | 691 +----------------------- src-tauri/src/keyboard/manager/tests.rs | 688 +++++++++++++++++++++++ 2 files changed, 689 insertions(+), 690 deletions(-) create mode 100644 src-tauri/src/keyboard/manager/tests.rs diff --git a/src-tauri/src/keyboard/manager.rs b/src-tauri/src/keyboard/manager.rs index abb66e47a..89624e926 100644 --- a/src-tauri/src/keyboard/manager.rs +++ b/src-tauri/src/keyboard/manager.rs @@ -457,693 +457,4 @@ fn compose_active_key(mode: &str, canonical: &str) -> String { } #[cfg(test)] -mod tests { - use std::collections::HashMap; - - use crate::{ - ipc::InputDeviceKind, - models::{KeySlot, SlotMatch}, - }; - - use super::{KeyboardManager, MatchOutcome, SlotEvent}; - - fn single(key: &str) -> KeySlot { - KeySlot::Single(key.to_string()) - } - - fn multi(keys: &[&str], match_mode: SlotMatch) -> KeySlot { - KeySlot::Multi { - keys: keys.iter().map(|key| (*key).to_string()).collect(), - match_mode, - } - } - - fn input( - manager: &KeyboardManager, - physical_id: Option<&str>, - device: InputDeviceKind, - labels: &[&str], - is_down: bool, - ) -> Option { - manager.match_and_register(physical_id, device, labels.iter().copied(), is_down) - } - - fn event( - canonical: &str, - slot_indices: Vec, - transition: Option, - press: bool, - ) -> SlotEvent { - SlotEvent { - canonical: canonical.to_string(), - slot_indices, - transition, - press, - can_use_physical_hold_duration: false, - } - } - - fn event_with_physical_hold( - canonical: &str, - slot_indices: Vec, - transition: Option, - press: bool, - ) -> SlotEvent { - SlotEvent { - can_use_physical_hold_duration: true, - ..event(canonical, slot_indices, transition, press) - } - } - - #[test] - fn any_slot_counts_each_alternating_fresh_press_and_transitions_at_edges() { - let manager = KeyboardManager::new( - HashMap::from([("mode".to_string(), vec![multi(&["Z", "B"], SlotMatch::Any)])]), - "mode", - ); - - let z_down = input( - &manager, - Some("keyboard:z"), - InputDeviceKind::Keyboard, - &["Z"], - true, - ) - .unwrap(); - assert_eq!(z_down.pressed_label.as_deref(), Some("Z")); - assert_eq!(z_down.events, vec![event("Z|B", vec![0], Some(true), true)]); - - let b_down = input( - &manager, - Some("keyboard:b"), - InputDeviceKind::Keyboard, - &["B"], - true, - ) - .unwrap(); - assert_eq!(b_down.events, vec![event("Z|B", vec![0], None, true)]); - assert_eq!( - input( - &manager, - Some("keyboard:b"), - InputDeviceKind::Keyboard, - &["B"], - false, - ) - .unwrap() - .events, - vec![event("Z|B", vec![0], None, false)] - ); - assert_eq!( - input( - &manager, - Some("keyboard:b"), - InputDeviceKind::Keyboard, - &["B"], - true, - ) - .unwrap() - .events, - vec![event("Z|B", vec![0], None, true)] - ); - input( - &manager, - Some("keyboard:z"), - InputDeviceKind::Keyboard, - &["Z"], - false, - ); - assert_eq!( - input( - &manager, - Some("keyboard:b"), - InputDeviceKind::Keyboard, - &["B"], - false, - ) - .unwrap() - .events, - vec![event("Z|B", vec![0], Some(false), false)] - ); - - input( - &manager, - Some("keyboard:z"), - InputDeviceKind::Keyboard, - &["Z"], - true, - ); - input( - &manager, - Some("keyboard:b"), - InputDeviceKind::Keyboard, - &["B"], - true, - ); - input( - &manager, - Some("keyboard:b"), - InputDeviceKind::Keyboard, - &["B"], - false, - ); - assert_eq!( - input( - &manager, - Some("keyboard:z"), - InputDeviceKind::Keyboard, - &["Z"], - false, - ) - .unwrap() - .events, - vec![event_with_physical_hold("Z|B", vec![0], Some(false), false,)] - ); - } - - #[test] - fn all_slot_completes_and_recompletes_in_either_order() { - let manager = KeyboardManager::new( - HashMap::from([( - "mode".to_string(), - vec![multi(&["LEFT CTRL", "Z"], SlotMatch::All)], - )]), - "mode", - ); - - assert_eq!( - input(&manager, Some("z"), InputDeviceKind::Keyboard, &["Z"], true,) - .unwrap() - .events, - vec![event("LEFT CTRL+Z", vec![0], None, false)] - ); - assert_eq!( - input( - &manager, - Some("ctrl"), - InputDeviceKind::Keyboard, - &["LEFT CTRL"], - true, - ) - .unwrap() - .events, - vec![event("LEFT CTRL+Z", vec![0], Some(true), true)] - ); - assert_eq!( - input( - &manager, - Some("z"), - InputDeviceKind::Keyboard, - &["Z"], - false, - ) - .unwrap() - .events, - vec![event("LEFT CTRL+Z", vec![0], Some(false), false)] - ); - assert_eq!( - input(&manager, Some("z"), InputDeviceKind::Keyboard, &["Z"], true,) - .unwrap() - .events, - vec![event("LEFT CTRL+Z", vec![0], Some(true), true)] - ); - assert_eq!( - input( - &manager, - Some("z"), - InputDeviceKind::Keyboard, - &["Z"], - false, - ) - .unwrap() - .events, - vec![event_with_physical_hold( - "LEFT CTRL+Z", - vec![0], - Some(false), - false, - )] - ); - } - - #[test] - fn repeat_down_and_ghost_up_are_suppressed_by_physical_identity() { - let manager = KeyboardManager::new( - HashMap::from([("mode".to_string(), vec![single("A")])]), - "mode", - ); - - assert!(input( - &manager, - Some("physical:a"), - InputDeviceKind::Keyboard, - &["A"], - true, - ) - .is_some()); - assert!(input( - &manager, - Some("physical:a"), - InputDeviceKind::Keyboard, - &["A"], - true, - ) - .is_none()); - assert!(input( - &manager, - Some("physical:ghost"), - InputDeviceKind::Keyboard, - &["A"], - false, - ) - .is_none()); - assert!(input( - &manager, - Some("physical:empty"), - InputDeviceKind::Keyboard, - &[], - true, - ) - .is_some()); - assert!(input( - &manager, - Some("physical:empty"), - InputDeviceKind::Keyboard, - &[], - false, - ) - .is_some()); - } - - #[test] - fn overlapping_same_label_uses_physical_hold_only_when_transition_sources_match() { - let mappings = HashMap::from([("mode".to_string(), vec![single("A")])]); - - let different_source = KeyboardManager::new(mappings.clone(), "mode"); - let first_down = input( - &different_source, - Some("keyboard-1:a"), - InputDeviceKind::Keyboard, - &["A"], - true, - ) - .unwrap(); - assert_eq!( - first_down.events, - vec![event("A", vec![0], Some(true), true)] - ); - assert_eq!( - input( - &different_source, - Some("keyboard-2:a"), - InputDeviceKind::Keyboard, - &["A"], - true, - ) - .unwrap() - .events, - vec![event("A", vec![0], None, true)] - ); - assert_eq!( - input( - &different_source, - Some("keyboard-1:a"), - InputDeviceKind::Keyboard, - &["A"], - false, - ) - .unwrap() - .events, - vec![event("A", vec![0], None, false)] - ); - assert_eq!( - input( - &different_source, - Some("keyboard-2:a"), - InputDeviceKind::Keyboard, - &["A"], - false, - ) - .unwrap() - .events, - vec![event("A", vec![0], Some(false), false)] - ); - - let same_source = KeyboardManager::new(mappings, "mode"); - input( - &same_source, - Some("keyboard-1:a"), - InputDeviceKind::Keyboard, - &["A"], - true, - ); - input( - &same_source, - Some("keyboard-2:a"), - InputDeviceKind::Keyboard, - &["A"], - true, - ); - input( - &same_source, - Some("keyboard-2:a"), - InputDeviceKind::Keyboard, - &["A"], - false, - ); - assert_eq!( - input( - &same_source, - Some("keyboard-1:a"), - InputDeviceKind::Keyboard, - &["A"], - false, - ) - .unwrap() - .events, - vec![event_with_physical_hold("A", vec![0], Some(false), false,)] - ); - } - - #[test] - fn mapping_rebuild_discards_unknown_activation_source() { - let mappings = HashMap::from([("mode".to_string(), vec![single("A")])]); - let manager = KeyboardManager::new(mappings.clone(), "mode"); - input( - &manager, - Some("keyboard:a"), - InputDeviceKind::Keyboard, - &["A"], - true, - ); - - manager.update_mappings(mappings); - - assert_eq!( - input( - &manager, - Some("keyboard:a"), - InputDeviceKind::Keyboard, - &["A"], - false, - ) - .unwrap() - .events, - vec![event("A", vec![0], Some(false), false)] - ); - } - - #[test] - fn mode_switch_and_mapping_removal_do_not_lose_later_key_up() { - let manager = KeyboardManager::new( - HashMap::from([ - ("source".to_string(), vec![single("A")]), - ("target".to_string(), vec![single("B")]), - ]), - "source", - ); - input( - &manager, - Some("physical:a"), - InputDeviceKind::Keyboard, - &["A"], - true, - ); - assert!(manager.set_mode("target")); - assert!(input( - &manager, - Some("physical:a"), - InputDeviceKind::Keyboard, - &["changed-up-label"], - false, - ) - .is_some()); - assert!(manager.set_mode("source")); - assert!(manager.pressed_keys().is_empty()); - - input( - &manager, - Some("physical:a2"), - InputDeviceKind::Keyboard, - &["A"], - true, - ); - manager.update_mappings(HashMap::from([("source".to_string(), Vec::new())])); - assert!(input( - &manager, - Some("physical:a2"), - InputDeviceKind::Keyboard, - &["A"], - false, - ) - .is_some()); - manager.update_mappings(HashMap::from([("source".to_string(), vec![single("A")])])); - assert!(manager.pressed_keys().is_empty()); - } - - #[test] - fn held_unmapped_key_activates_when_switching_to_a_matching_mode() { - let manager = KeyboardManager::new( - HashMap::from([ - ("source".to_string(), vec![single("B")]), - ("target".to_string(), vec![single("A")]), - ]), - "source", - ); - let down = input( - &manager, - Some("physical:a"), - InputDeviceKind::Keyboard, - &["A"], - true, - ) - .unwrap(); - assert!(down.pressed_label.is_none()); - - assert!(manager.set_mode("target")); - assert_eq!(manager.pressed_keys(), vec!["A"]); - } - - #[test] - fn collect_sound_dispatch_merges_press_slots_into_single_dispatch() { - let events = vec![ - SlotEvent { - canonical: "A".to_string(), - slot_indices: vec![1], - transition: Some(true), - press: true, - can_use_physical_hold_duration: false, - }, - SlotEvent { - canonical: "A|B".to_string(), - slot_indices: vec![0, 1], - transition: None, - press: true, - can_use_physical_hold_duration: false, - }, - SlotEvent { - canonical: "C".to_string(), - slot_indices: vec![2], - transition: Some(false), - press: false, - can_use_physical_hold_duration: true, - }, - ]; - - let (canonical, indices) = super::collect_sound_dispatch(&events).unwrap(); - - assert_eq!(canonical, "A"); - assert_eq!(indices, vec![0, 1]); - - // press 이벤트가 없으면 디스패치 자체가 없음 - assert!(super::collect_sound_dispatch(&events[2..]).is_none()); - } - - #[test] - fn shared_member_fans_out_slots_but_resolves_one_physical_press() { - let manager = KeyboardManager::new( - HashMap::from([( - "mode".to_string(), - vec![single("A"), multi(&["A", "B"], SlotMatch::Any)], - )]), - "mode", - ); - - let outcome = input( - &manager, - Some("physical:a"), - InputDeviceKind::Keyboard, - &["A"], - true, - ) - .unwrap(); - assert_eq!(outcome.pressed_label.as_deref(), Some("A")); - assert_eq!( - outcome.events, - vec![ - event("A", vec![0], Some(true), true), - event("A|B", vec![1], Some(true), true), - ] - ); - } - - #[test] - fn duplicate_canonical_slots_deduplicate_event_and_collect_indices() { - let slot = multi(&["A", "B"], SlotMatch::Any); - let manager = KeyboardManager::new( - HashMap::from([("mode".to_string(), vec![slot.clone(), slot])]), - "mode", - ); - - let outcome = input( - &manager, - Some("physical:a"), - InputDeviceKind::Keyboard, - &["A"], - true, - ) - .unwrap(); - assert_eq!( - outcome.events, - vec![event("A|B", vec![0, 1], Some(true), true)] - ); - } - - #[test] - fn alias_candidates_resolve_only_the_first_matching_label() { - let manager = KeyboardManager::new( - HashMap::from([( - "mode".to_string(), - vec![multi(&["21", "RIGHT ALT"], SlotMatch::All)], - )]), - "mode", - ); - - let alias = input( - &manager, - Some("physical:altgr"), - InputDeviceKind::Keyboard, - &["21", "RIGHT ALT"], - true, - ) - .unwrap(); - assert_eq!(alias.pressed_label.as_deref(), Some("21")); - assert_eq!(alias.events[0].transition, None); - - let second = input( - &manager, - Some("physical:right-alt-2"), - InputDeviceKind::Keyboard, - &["RIGHT ALT"], - true, - ) - .unwrap(); - assert_eq!(second.events[0].transition, Some(true)); - } - - #[test] - fn key_up_uses_resolved_label_saved_on_key_down() { - let manager = KeyboardManager::new( - HashMap::from([("mode".to_string(), vec![single("RIGHT ALT")])]), - "mode", - ); - input( - &manager, - Some("physical:altgr"), - InputDeviceKind::Keyboard, - &["21", "RIGHT ALT"], - true, - ); - - let up = input( - &manager, - Some("physical:altgr"), - InputDeviceKind::Keyboard, - &["21"], - false, - ) - .unwrap(); - assert_eq!( - up.events, - vec![event_with_physical_hold( - "RIGHT ALT", - vec![0], - Some(false), - false, - )] - ); - } - - #[test] - fn missing_physical_id_falls_back_to_device_kind_and_primary_label() { - let manager = KeyboardManager::new( - HashMap::from([("mode".to_string(), vec![single("A")])]), - "mode", - ); - - assert!(input(&manager, None, InputDeviceKind::Keyboard, &["A"], true,).is_some()); - assert!(input(&manager, None, InputDeviceKind::Keyboard, &["A"], true,).is_none()); - assert!(input(&manager, None, InputDeviceKind::Mouse, &["A"], true,).is_some()); - } - - #[test] - fn inert_single_canonical_collision_is_not_a_contributing_slot() { - let manager = KeyboardManager::new( - HashMap::from([( - "mode".to_string(), - vec![single("A+B"), multi(&["A", "B"], SlotMatch::All)], - )]), - "mode", - ); - - let first = input( - &manager, - Some("physical:a"), - InputDeviceKind::Keyboard, - &["A"], - true, - ) - .unwrap(); - assert_eq!(first.events, vec![event("A+B", vec![1], None, false)]); - let completed = input( - &manager, - Some("physical:b"), - InputDeviceKind::Keyboard, - &["B"], - true, - ) - .unwrap(); - assert_eq!( - completed.events, - vec![event("A+B", vec![1], Some(true), true)] - ); - } - - #[test] - fn clear_active_keys_clears_physical_and_canonical_state() { - let manager = KeyboardManager::new( - HashMap::from([("mode".to_string(), vec![single("A")])]), - "mode", - ); - input( - &manager, - Some("physical:a"), - InputDeviceKind::Keyboard, - &["A"], - true, - ); - manager.clear_active_keys(); - - assert!(manager.pressed_keys().is_empty()); - assert!(manager.register_key_down("mode", "A")); - assert!(manager.register_key_up("mode", "A")); - assert!(input( - &manager, - Some("physical:a"), - InputDeviceKind::Keyboard, - &["A"], - true, - ) - .is_some()); - } -} +mod tests; diff --git a/src-tauri/src/keyboard/manager/tests.rs b/src-tauri/src/keyboard/manager/tests.rs new file mode 100644 index 000000000..26dd1f3b7 --- /dev/null +++ b/src-tauri/src/keyboard/manager/tests.rs @@ -0,0 +1,688 @@ +use std::collections::HashMap; + +use crate::{ + ipc::InputDeviceKind, + models::{KeySlot, SlotMatch}, +}; + +use super::{KeyboardManager, MatchOutcome, SlotEvent}; + +fn single(key: &str) -> KeySlot { + KeySlot::Single(key.to_string()) +} + +fn multi(keys: &[&str], match_mode: SlotMatch) -> KeySlot { + KeySlot::Multi { + keys: keys.iter().map(|key| (*key).to_string()).collect(), + match_mode, + } +} + +fn input( + manager: &KeyboardManager, + physical_id: Option<&str>, + device: InputDeviceKind, + labels: &[&str], + is_down: bool, +) -> Option { + manager.match_and_register(physical_id, device, labels.iter().copied(), is_down) +} + +fn event( + canonical: &str, + slot_indices: Vec, + transition: Option, + press: bool, +) -> SlotEvent { + SlotEvent { + canonical: canonical.to_string(), + slot_indices, + transition, + press, + can_use_physical_hold_duration: false, + } +} + +fn event_with_physical_hold( + canonical: &str, + slot_indices: Vec, + transition: Option, + press: bool, +) -> SlotEvent { + SlotEvent { + can_use_physical_hold_duration: true, + ..event(canonical, slot_indices, transition, press) + } +} + +#[test] +fn any_slot_counts_each_alternating_fresh_press_and_transitions_at_edges() { + let manager = KeyboardManager::new( + HashMap::from([("mode".to_string(), vec![multi(&["Z", "B"], SlotMatch::Any)])]), + "mode", + ); + + let z_down = input( + &manager, + Some("keyboard:z"), + InputDeviceKind::Keyboard, + &["Z"], + true, + ) + .unwrap(); + assert_eq!(z_down.pressed_label.as_deref(), Some("Z")); + assert_eq!(z_down.events, vec![event("Z|B", vec![0], Some(true), true)]); + + let b_down = input( + &manager, + Some("keyboard:b"), + InputDeviceKind::Keyboard, + &["B"], + true, + ) + .unwrap(); + assert_eq!(b_down.events, vec![event("Z|B", vec![0], None, true)]); + assert_eq!( + input( + &manager, + Some("keyboard:b"), + InputDeviceKind::Keyboard, + &["B"], + false, + ) + .unwrap() + .events, + vec![event("Z|B", vec![0], None, false)] + ); + assert_eq!( + input( + &manager, + Some("keyboard:b"), + InputDeviceKind::Keyboard, + &["B"], + true, + ) + .unwrap() + .events, + vec![event("Z|B", vec![0], None, true)] + ); + input( + &manager, + Some("keyboard:z"), + InputDeviceKind::Keyboard, + &["Z"], + false, + ); + assert_eq!( + input( + &manager, + Some("keyboard:b"), + InputDeviceKind::Keyboard, + &["B"], + false, + ) + .unwrap() + .events, + vec![event("Z|B", vec![0], Some(false), false)] + ); + + input( + &manager, + Some("keyboard:z"), + InputDeviceKind::Keyboard, + &["Z"], + true, + ); + input( + &manager, + Some("keyboard:b"), + InputDeviceKind::Keyboard, + &["B"], + true, + ); + input( + &manager, + Some("keyboard:b"), + InputDeviceKind::Keyboard, + &["B"], + false, + ); + assert_eq!( + input( + &manager, + Some("keyboard:z"), + InputDeviceKind::Keyboard, + &["Z"], + false, + ) + .unwrap() + .events, + vec![event_with_physical_hold("Z|B", vec![0], Some(false), false,)] + ); +} + +#[test] +fn all_slot_completes_and_recompletes_in_either_order() { + let manager = KeyboardManager::new( + HashMap::from([( + "mode".to_string(), + vec![multi(&["LEFT CTRL", "Z"], SlotMatch::All)], + )]), + "mode", + ); + + assert_eq!( + input(&manager, Some("z"), InputDeviceKind::Keyboard, &["Z"], true,) + .unwrap() + .events, + vec![event("LEFT CTRL+Z", vec![0], None, false)] + ); + assert_eq!( + input( + &manager, + Some("ctrl"), + InputDeviceKind::Keyboard, + &["LEFT CTRL"], + true, + ) + .unwrap() + .events, + vec![event("LEFT CTRL+Z", vec![0], Some(true), true)] + ); + assert_eq!( + input( + &manager, + Some("z"), + InputDeviceKind::Keyboard, + &["Z"], + false, + ) + .unwrap() + .events, + vec![event("LEFT CTRL+Z", vec![0], Some(false), false)] + ); + assert_eq!( + input(&manager, Some("z"), InputDeviceKind::Keyboard, &["Z"], true,) + .unwrap() + .events, + vec![event("LEFT CTRL+Z", vec![0], Some(true), true)] + ); + assert_eq!( + input( + &manager, + Some("z"), + InputDeviceKind::Keyboard, + &["Z"], + false, + ) + .unwrap() + .events, + vec![event_with_physical_hold( + "LEFT CTRL+Z", + vec![0], + Some(false), + false, + )] + ); +} + +#[test] +fn repeat_down_and_ghost_up_are_suppressed_by_physical_identity() { + let manager = KeyboardManager::new( + HashMap::from([("mode".to_string(), vec![single("A")])]), + "mode", + ); + + assert!(input( + &manager, + Some("physical:a"), + InputDeviceKind::Keyboard, + &["A"], + true, + ) + .is_some()); + assert!(input( + &manager, + Some("physical:a"), + InputDeviceKind::Keyboard, + &["A"], + true, + ) + .is_none()); + assert!(input( + &manager, + Some("physical:ghost"), + InputDeviceKind::Keyboard, + &["A"], + false, + ) + .is_none()); + assert!(input( + &manager, + Some("physical:empty"), + InputDeviceKind::Keyboard, + &[], + true, + ) + .is_some()); + assert!(input( + &manager, + Some("physical:empty"), + InputDeviceKind::Keyboard, + &[], + false, + ) + .is_some()); +} + +#[test] +fn overlapping_same_label_uses_physical_hold_only_when_transition_sources_match() { + let mappings = HashMap::from([("mode".to_string(), vec![single("A")])]); + + let different_source = KeyboardManager::new(mappings.clone(), "mode"); + let first_down = input( + &different_source, + Some("keyboard-1:a"), + InputDeviceKind::Keyboard, + &["A"], + true, + ) + .unwrap(); + assert_eq!( + first_down.events, + vec![event("A", vec![0], Some(true), true)] + ); + assert_eq!( + input( + &different_source, + Some("keyboard-2:a"), + InputDeviceKind::Keyboard, + &["A"], + true, + ) + .unwrap() + .events, + vec![event("A", vec![0], None, true)] + ); + assert_eq!( + input( + &different_source, + Some("keyboard-1:a"), + InputDeviceKind::Keyboard, + &["A"], + false, + ) + .unwrap() + .events, + vec![event("A", vec![0], None, false)] + ); + assert_eq!( + input( + &different_source, + Some("keyboard-2:a"), + InputDeviceKind::Keyboard, + &["A"], + false, + ) + .unwrap() + .events, + vec![event("A", vec![0], Some(false), false)] + ); + + let same_source = KeyboardManager::new(mappings, "mode"); + input( + &same_source, + Some("keyboard-1:a"), + InputDeviceKind::Keyboard, + &["A"], + true, + ); + input( + &same_source, + Some("keyboard-2:a"), + InputDeviceKind::Keyboard, + &["A"], + true, + ); + input( + &same_source, + Some("keyboard-2:a"), + InputDeviceKind::Keyboard, + &["A"], + false, + ); + assert_eq!( + input( + &same_source, + Some("keyboard-1:a"), + InputDeviceKind::Keyboard, + &["A"], + false, + ) + .unwrap() + .events, + vec![event_with_physical_hold("A", vec![0], Some(false), false,)] + ); +} + +#[test] +fn mapping_rebuild_discards_unknown_activation_source() { + let mappings = HashMap::from([("mode".to_string(), vec![single("A")])]); + let manager = KeyboardManager::new(mappings.clone(), "mode"); + input( + &manager, + Some("keyboard:a"), + InputDeviceKind::Keyboard, + &["A"], + true, + ); + + manager.update_mappings(mappings); + + assert_eq!( + input( + &manager, + Some("keyboard:a"), + InputDeviceKind::Keyboard, + &["A"], + false, + ) + .unwrap() + .events, + vec![event("A", vec![0], Some(false), false)] + ); +} + +#[test] +fn mode_switch_and_mapping_removal_do_not_lose_later_key_up() { + let manager = KeyboardManager::new( + HashMap::from([ + ("source".to_string(), vec![single("A")]), + ("target".to_string(), vec![single("B")]), + ]), + "source", + ); + input( + &manager, + Some("physical:a"), + InputDeviceKind::Keyboard, + &["A"], + true, + ); + assert!(manager.set_mode("target")); + assert!(input( + &manager, + Some("physical:a"), + InputDeviceKind::Keyboard, + &["changed-up-label"], + false, + ) + .is_some()); + assert!(manager.set_mode("source")); + assert!(manager.pressed_keys().is_empty()); + + input( + &manager, + Some("physical:a2"), + InputDeviceKind::Keyboard, + &["A"], + true, + ); + manager.update_mappings(HashMap::from([("source".to_string(), Vec::new())])); + assert!(input( + &manager, + Some("physical:a2"), + InputDeviceKind::Keyboard, + &["A"], + false, + ) + .is_some()); + manager.update_mappings(HashMap::from([("source".to_string(), vec![single("A")])])); + assert!(manager.pressed_keys().is_empty()); +} + +#[test] +fn held_unmapped_key_activates_when_switching_to_a_matching_mode() { + let manager = KeyboardManager::new( + HashMap::from([ + ("source".to_string(), vec![single("B")]), + ("target".to_string(), vec![single("A")]), + ]), + "source", + ); + let down = input( + &manager, + Some("physical:a"), + InputDeviceKind::Keyboard, + &["A"], + true, + ) + .unwrap(); + assert!(down.pressed_label.is_none()); + + assert!(manager.set_mode("target")); + assert_eq!(manager.pressed_keys(), vec!["A"]); +} + +#[test] +fn collect_sound_dispatch_merges_press_slots_into_single_dispatch() { + let events = vec![ + SlotEvent { + canonical: "A".to_string(), + slot_indices: vec![1], + transition: Some(true), + press: true, + can_use_physical_hold_duration: false, + }, + SlotEvent { + canonical: "A|B".to_string(), + slot_indices: vec![0, 1], + transition: None, + press: true, + can_use_physical_hold_duration: false, + }, + SlotEvent { + canonical: "C".to_string(), + slot_indices: vec![2], + transition: Some(false), + press: false, + can_use_physical_hold_duration: true, + }, + ]; + + let (canonical, indices) = super::collect_sound_dispatch(&events).unwrap(); + + assert_eq!(canonical, "A"); + assert_eq!(indices, vec![0, 1]); + + // press 이벤트가 없으면 디스패치 자체가 없음 + assert!(super::collect_sound_dispatch(&events[2..]).is_none()); +} + +#[test] +fn shared_member_fans_out_slots_but_resolves_one_physical_press() { + let manager = KeyboardManager::new( + HashMap::from([( + "mode".to_string(), + vec![single("A"), multi(&["A", "B"], SlotMatch::Any)], + )]), + "mode", + ); + + let outcome = input( + &manager, + Some("physical:a"), + InputDeviceKind::Keyboard, + &["A"], + true, + ) + .unwrap(); + assert_eq!(outcome.pressed_label.as_deref(), Some("A")); + assert_eq!( + outcome.events, + vec![ + event("A", vec![0], Some(true), true), + event("A|B", vec![1], Some(true), true), + ] + ); +} + +#[test] +fn duplicate_canonical_slots_deduplicate_event_and_collect_indices() { + let slot = multi(&["A", "B"], SlotMatch::Any); + let manager = KeyboardManager::new( + HashMap::from([("mode".to_string(), vec![slot.clone(), slot])]), + "mode", + ); + + let outcome = input( + &manager, + Some("physical:a"), + InputDeviceKind::Keyboard, + &["A"], + true, + ) + .unwrap(); + assert_eq!( + outcome.events, + vec![event("A|B", vec![0, 1], Some(true), true)] + ); +} + +#[test] +fn alias_candidates_resolve_only_the_first_matching_label() { + let manager = KeyboardManager::new( + HashMap::from([( + "mode".to_string(), + vec![multi(&["21", "RIGHT ALT"], SlotMatch::All)], + )]), + "mode", + ); + + let alias = input( + &manager, + Some("physical:altgr"), + InputDeviceKind::Keyboard, + &["21", "RIGHT ALT"], + true, + ) + .unwrap(); + assert_eq!(alias.pressed_label.as_deref(), Some("21")); + assert_eq!(alias.events[0].transition, None); + + let second = input( + &manager, + Some("physical:right-alt-2"), + InputDeviceKind::Keyboard, + &["RIGHT ALT"], + true, + ) + .unwrap(); + assert_eq!(second.events[0].transition, Some(true)); +} + +#[test] +fn key_up_uses_resolved_label_saved_on_key_down() { + let manager = KeyboardManager::new( + HashMap::from([("mode".to_string(), vec![single("RIGHT ALT")])]), + "mode", + ); + input( + &manager, + Some("physical:altgr"), + InputDeviceKind::Keyboard, + &["21", "RIGHT ALT"], + true, + ); + + let up = input( + &manager, + Some("physical:altgr"), + InputDeviceKind::Keyboard, + &["21"], + false, + ) + .unwrap(); + assert_eq!( + up.events, + vec![event_with_physical_hold( + "RIGHT ALT", + vec![0], + Some(false), + false, + )] + ); +} + +#[test] +fn missing_physical_id_falls_back_to_device_kind_and_primary_label() { + let manager = KeyboardManager::new( + HashMap::from([("mode".to_string(), vec![single("A")])]), + "mode", + ); + + assert!(input(&manager, None, InputDeviceKind::Keyboard, &["A"], true,).is_some()); + assert!(input(&manager, None, InputDeviceKind::Keyboard, &["A"], true,).is_none()); + assert!(input(&manager, None, InputDeviceKind::Mouse, &["A"], true,).is_some()); +} + +#[test] +fn inert_single_canonical_collision_is_not_a_contributing_slot() { + let manager = KeyboardManager::new( + HashMap::from([( + "mode".to_string(), + vec![single("A+B"), multi(&["A", "B"], SlotMatch::All)], + )]), + "mode", + ); + + let first = input( + &manager, + Some("physical:a"), + InputDeviceKind::Keyboard, + &["A"], + true, + ) + .unwrap(); + assert_eq!(first.events, vec![event("A+B", vec![1], None, false)]); + let completed = input( + &manager, + Some("physical:b"), + InputDeviceKind::Keyboard, + &["B"], + true, + ) + .unwrap(); + assert_eq!( + completed.events, + vec![event("A+B", vec![1], Some(true), true)] + ); +} + +#[test] +fn clear_active_keys_clears_physical_and_canonical_state() { + let manager = KeyboardManager::new( + HashMap::from([("mode".to_string(), vec![single("A")])]), + "mode", + ); + input( + &manager, + Some("physical:a"), + InputDeviceKind::Keyboard, + &["A"], + true, + ); + manager.clear_active_keys(); + + assert!(manager.pressed_keys().is_empty()); + assert!(manager.register_key_down("mode", "A")); + assert!(manager.register_key_up("mode", "A")); + assert!(input( + &manager, + Some("physical:a"), + InputDeviceKind::Keyboard, + &["A"], + true, + ) + .is_some()); +} From 756e1fdf3262e42604954732dde43546086d19c3 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 17:04:42 +0900 Subject: [PATCH 151/245] =?UTF-8?q?refactor:=20=EC=98=A4=EB=B2=84=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=20=ED=9E=88=ED=8A=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/services/overlay_hit.rs | 366 +------------------- src-tauri/src/services/overlay_hit/tests.rs | 363 +++++++++++++++++++ 2 files changed, 364 insertions(+), 365 deletions(-) create mode 100644 src-tauri/src/services/overlay_hit/tests.rs diff --git a/src-tauri/src/services/overlay_hit.rs b/src-tauri/src/services/overlay_hit.rs index 7e9fed53d..82f70a893 100644 --- a/src-tauri/src/services/overlay_hit.rs +++ b/src-tauri/src/services/overlay_hit.rs @@ -532,368 +532,4 @@ mod platform; mod platform; #[cfg(test)] -mod tests { - use super::{ - clip_hit_rect_to_bounds, hit_region_status, probe_delay_ms, validate_hit_rects, - HitRegionStatus, OverlayHitDesiredState, OverlayHitRect, RegionSyncDecision, MAX_HIT_RECTS, - }; - use std::sync::Arc; - - fn desired_state() -> OverlayHitDesiredState { - OverlayHitDesiredState { - rects: Vec::new(), - device_pixel_ratio: 1.0, - last_revision: None, - parent: None, - visible: true, - locked: false, - always_on_top: true, - resync_epoch: 0, - renderer_session: None, - pending_resync: true, - probe_lease: Arc::new(()), - } - } - - // 보정 줌이 곱해진 실측 배율만 채택하고, 비정상값은 1.0으로 떨어뜨린다 - #[test] - fn device_pixel_ratio_is_adopted_and_sanitized() { - let mut desired = desired_state(); - - assert!(desired.apply_regions(vec![rect(1.0)], 1, 1.25).unwrap()); - assert_eq!(desired.device_pixel_ratio, 1.25); - - assert!(desired.apply_regions(vec![rect(2.0)], 2, f64::NAN).unwrap()); - assert_eq!(desired.device_pixel_ratio, 1.0); - - assert!(desired.apply_regions(vec![rect(3.0)], 3, 0.0).unwrap()); - assert_eq!(desired.device_pixel_ratio, 1.0); - } - - fn rect(x: f64) -> OverlayHitRect { - OverlayHitRect { - x, - y: 0.0, - width: 10.0, - height: 10.0, - } - } - - #[test] - fn hit_region_revision_only_accepts_newer_values() { - let mut desired = desired_state(); - assert!(desired.apply_regions(vec![rect(1.0)], 10, 1.0).unwrap()); - assert!(!desired.apply_regions(vec![rect(2.0)], 10, 1.0).unwrap()); - assert!(!desired - .apply_regions( - vec![OverlayHitRect { - x: f64::NAN, - y: 0.0, - width: 10.0, - height: 10.0, - }], - 9, - 1.0, - ) - .unwrap()); - assert_eq!(desired.rects, vec![rect(1.0)]); - assert!(desired - .apply_regions( - vec![OverlayHitRect { - x: f64::NAN, - y: 0.0, - width: 10.0, - height: 10.0, - }], - 11, - 1.0, - ) - .is_err()); - assert_eq!(desired.rects, vec![rect(1.0)]); - assert!(desired.apply_regions(vec![rect(4.0)], 11, 1.0).unwrap()); - assert_eq!(desired.rects, vec![rect(4.0)]); - } - - #[test] - fn parent_replacement_and_loss_are_invalidation_edges() { - let mut desired = desired_state(); - assert!(desired.apply_regions(vec![rect(0.0)], 1, 1.0).unwrap()); - assert!(desired.observe_parent(None)); - desired.invalidate(false).unwrap(); - - assert!(!desired.observe_parent(Some(10))); - assert!(desired.apply_regions(vec![rect(1.0)], 90, 1.0).unwrap()); - assert!(!desired.observe_parent(Some(10))); - - assert!(desired.observe_parent(Some(11))); - desired.invalidate(false).unwrap(); - assert!(desired.rects.is_empty()); - assert_eq!(desired.last_revision, None); - assert!(desired.apply_regions(vec![rect(2.0)], 1, 1.0).unwrap()); - - assert!(desired.observe_parent(None)); - desired.mark_parent_absent(); - desired.invalidate(true).unwrap(); - assert!(desired.rects.is_empty()); - assert_eq!(desired.last_revision, None); - assert!(!desired.observe_parent(None)); - assert!(!desired.observe_parent(Some(12))); - assert!(desired.apply_regions(vec![rect(3.0)], 1, 1.0).unwrap()); - assert!(desired.observe_parent(None)); - desired.invalidate(false).unwrap(); - assert!(!desired.observe_parent(None)); - } - - #[test] - fn matching_epoch_and_renderer_session_are_accepted() { - let mut desired = desired_state(); - let epoch = desired - .renew_renderer_session("renderer".to_string()) - .unwrap(); - let decision = desired - .apply_renderer_regions(vec![rect(1.0)], 1, 1.25, epoch, "renderer") - .unwrap(); - - assert_eq!(decision, RegionSyncDecision::Applied); - assert!(decision.accepted()); - assert!(!desired.pending_resync); - assert_eq!(desired.rects, vec![rect(1.0)]); - } - - #[test] - fn renderer_session_mismatch_is_not_accepted_or_mutated() { - let mut desired = desired_state(); - let epoch = desired - .renew_renderer_session("renderer".to_string()) - .unwrap(); - let decision = desired - .apply_renderer_regions(vec![rect(1.0)], 1, 1.0, epoch, "stale-renderer") - .unwrap(); - - assert_eq!(decision, RegionSyncDecision::LeaseMismatch); - assert!(!decision.accepted()); - assert!(desired.pending_resync); - assert!(desired.rects.is_empty()); - assert_eq!(desired.last_revision, None); - } - - #[test] - fn epoch_mismatch_is_not_accepted_or_mutated() { - let mut desired = desired_state(); - let epoch = desired - .renew_renderer_session("renderer".to_string()) - .unwrap(); - let decision = desired - .apply_renderer_regions(vec![rect(1.0)], 1, 1.0, epoch - 1, "renderer") - .unwrap(); - - assert_eq!(decision, RegionSyncDecision::LeaseMismatch); - assert!(!decision.accepted()); - assert!(desired.pending_resync); - assert!(desired.rects.is_empty()); - assert_eq!(desired.last_revision, None); - } - - #[test] - fn stale_revision_keeps_valid_lease_accepted() { - let mut desired = desired_state(); - let epoch = desired - .renew_renderer_session("renderer".to_string()) - .unwrap(); - assert_eq!( - desired - .apply_renderer_regions(vec![rect(2.0)], 2, 1.0, epoch, "renderer") - .unwrap(), - RegionSyncDecision::Applied - ); - let decision = desired - .apply_renderer_regions(vec![rect(1.0)], 1, 1.0, epoch, "renderer") - .unwrap(); - - assert_eq!(decision, RegionSyncDecision::StaleRevision); - assert!(decision.accepted()); - assert!(!desired.pending_resync); - assert_eq!(desired.rects, vec![rect(2.0)]); - assert_eq!(desired.last_revision, Some(2)); - } - - #[test] - fn hidden_locked_and_unready_states_pause_without_clearing_pending() { - let mut desired = desired_state(); - assert!(!desired.can_probe()); - assert!(desired.pending_resync); - - desired - .renew_renderer_session("renderer".to_string()) - .unwrap(); - assert!(desired.can_probe()); - - desired.visible = false; - assert!(!desired.can_probe()); - assert!(desired.pending_resync); - - desired.visible = true; - desired.locked = true; - assert!(!desired.can_probe()); - assert!(desired.pending_resync); - - desired.locked = false; - assert!(desired.can_probe()); - } - - #[test] - fn measured_empty_and_fully_clipped_regions_remain_distinct() { - assert_eq!(hit_region_status(true, 0), HitRegionStatus::Applied); - assert_eq!(hit_region_status(false, 0), HitRegionStatus::FullyClipped); - assert_eq!(hit_region_status(false, 1), HitRegionStatus::Applied); - - let mut desired = desired_state(); - let epoch = desired - .renew_renderer_session("renderer".to_string()) - .unwrap(); - assert_eq!( - desired - .apply_renderer_regions(Vec::new(), 1, 1.0, epoch, "renderer") - .unwrap(), - RegionSyncDecision::Applied - ); - assert!(desired.rects.is_empty()); - assert_eq!(desired.last_revision, Some(1)); - assert!(!desired.pending_resync); - } - - #[test] - fn epoch_overflow_revokes_renderer_and_keeps_resync_pending() { - let mut desired = desired_state(); - desired.resync_epoch = u64::MAX; - desired.renderer_session = Some("renderer".to_string()); - desired.pending_resync = false; - - assert!(desired.invalidate(false).is_err()); - assert_eq!(desired.resync_epoch, u64::MAX); - assert_eq!(desired.renderer_session, None); - assert!(desired.pending_resync); - assert!(!desired.can_probe()); - } - - #[test] - fn probe_schedule_reaches_indefinite_five_second_interval() { - assert_eq!( - (0..8).map(probe_delay_ms).collect::>(), - vec![100, 250, 500, 1_000, 5_000, 5_000, 5_000, 5_000] - ); - } - - #[test] - fn hit_rect_clipping_uses_client_bounds_and_drops_empty_intersections() { - assert_eq!( - clip_hit_rect_to_bounds( - OverlayHitRect { - x: -5.0, - y: -10.0, - width: 20.0, - height: 30.0, - }, - 100.0, - 100.0, - ), - Some(OverlayHitRect { - x: 0.0, - y: 0.0, - width: 15.0, - height: 20.0, - }) - ); - assert_eq!( - clip_hit_rect_to_bounds( - OverlayHitRect { - x: 90.0, - y: 95.0, - width: 30.0, - height: 10.0, - }, - 100.0, - 100.0, - ), - Some(OverlayHitRect { - x: 90.0, - y: 95.0, - width: 10.0, - height: 5.0, - }) - ); - assert_eq!(clip_hit_rect_to_bounds(rect(110.0), 100.0, 100.0), None); - assert_eq!(clip_hit_rect_to_bounds(rect(1.0), 0.0, 100.0), None); - } - - #[cfg(target_os = "macos")] - #[test] - fn macos_hit_classes_register() { - super::platform::register_classes_for_test(); - } - - #[test] - fn hit_rect_validation_accepts_finite_positive_rects() { - assert!(validate_hit_rects(&[OverlayHitRect { - x: -10.0, - y: 20.0, - width: 30.0, - height: 40.0, - }]) - .is_ok()); - } - - #[test] - fn hit_rect_validation_rejects_non_positive_dimensions() { - assert!(validate_hit_rects(&[OverlayHitRect { - x: 0.0, - y: 0.0, - width: 0.0, - height: 10.0, - }]) - .is_err()); - } - - #[test] - fn hit_rect_validation_rejects_non_finite_values() { - for invalid in [ - OverlayHitRect { - x: f64::NAN, - y: 0.0, - width: 10.0, - height: 10.0, - }, - OverlayHitRect { - x: 0.0, - y: f64::INFINITY, - width: 10.0, - height: 10.0, - }, - OverlayHitRect { - x: 0.0, - y: 0.0, - width: f64::NEG_INFINITY, - height: 10.0, - }, - OverlayHitRect { - x: 0.0, - y: 0.0, - width: 10.0, - height: f64::NAN, - }, - ] { - assert!(validate_hit_rects(&[invalid]).is_err()); - } - } - - #[test] - fn hit_rect_validation_rejects_excessive_count() { - let rect = OverlayHitRect { - x: 0.0, - y: 0.0, - width: 10.0, - height: 10.0, - }; - assert!(validate_hit_rects(&vec![rect; MAX_HIT_RECTS + 1]).is_err()); - } -} +mod tests; diff --git a/src-tauri/src/services/overlay_hit/tests.rs b/src-tauri/src/services/overlay_hit/tests.rs new file mode 100644 index 000000000..c4cbd7a6e --- /dev/null +++ b/src-tauri/src/services/overlay_hit/tests.rs @@ -0,0 +1,363 @@ +use super::{ + clip_hit_rect_to_bounds, hit_region_status, probe_delay_ms, validate_hit_rects, + HitRegionStatus, OverlayHitDesiredState, OverlayHitRect, RegionSyncDecision, MAX_HIT_RECTS, +}; +use std::sync::Arc; + +fn desired_state() -> OverlayHitDesiredState { + OverlayHitDesiredState { + rects: Vec::new(), + device_pixel_ratio: 1.0, + last_revision: None, + parent: None, + visible: true, + locked: false, + always_on_top: true, + resync_epoch: 0, + renderer_session: None, + pending_resync: true, + probe_lease: Arc::new(()), + } +} + +// 보정 줌이 곱해진 실측 배율만 채택하고, 비정상값은 1.0으로 떨어뜨린다 +#[test] +fn device_pixel_ratio_is_adopted_and_sanitized() { + let mut desired = desired_state(); + + assert!(desired.apply_regions(vec![rect(1.0)], 1, 1.25).unwrap()); + assert_eq!(desired.device_pixel_ratio, 1.25); + + assert!(desired.apply_regions(vec![rect(2.0)], 2, f64::NAN).unwrap()); + assert_eq!(desired.device_pixel_ratio, 1.0); + + assert!(desired.apply_regions(vec![rect(3.0)], 3, 0.0).unwrap()); + assert_eq!(desired.device_pixel_ratio, 1.0); +} + +fn rect(x: f64) -> OverlayHitRect { + OverlayHitRect { + x, + y: 0.0, + width: 10.0, + height: 10.0, + } +} + +#[test] +fn hit_region_revision_only_accepts_newer_values() { + let mut desired = desired_state(); + assert!(desired.apply_regions(vec![rect(1.0)], 10, 1.0).unwrap()); + assert!(!desired.apply_regions(vec![rect(2.0)], 10, 1.0).unwrap()); + assert!(!desired + .apply_regions( + vec![OverlayHitRect { + x: f64::NAN, + y: 0.0, + width: 10.0, + height: 10.0, + }], + 9, + 1.0, + ) + .unwrap()); + assert_eq!(desired.rects, vec![rect(1.0)]); + assert!(desired + .apply_regions( + vec![OverlayHitRect { + x: f64::NAN, + y: 0.0, + width: 10.0, + height: 10.0, + }], + 11, + 1.0, + ) + .is_err()); + assert_eq!(desired.rects, vec![rect(1.0)]); + assert!(desired.apply_regions(vec![rect(4.0)], 11, 1.0).unwrap()); + assert_eq!(desired.rects, vec![rect(4.0)]); +} + +#[test] +fn parent_replacement_and_loss_are_invalidation_edges() { + let mut desired = desired_state(); + assert!(desired.apply_regions(vec![rect(0.0)], 1, 1.0).unwrap()); + assert!(desired.observe_parent(None)); + desired.invalidate(false).unwrap(); + + assert!(!desired.observe_parent(Some(10))); + assert!(desired.apply_regions(vec![rect(1.0)], 90, 1.0).unwrap()); + assert!(!desired.observe_parent(Some(10))); + + assert!(desired.observe_parent(Some(11))); + desired.invalidate(false).unwrap(); + assert!(desired.rects.is_empty()); + assert_eq!(desired.last_revision, None); + assert!(desired.apply_regions(vec![rect(2.0)], 1, 1.0).unwrap()); + + assert!(desired.observe_parent(None)); + desired.mark_parent_absent(); + desired.invalidate(true).unwrap(); + assert!(desired.rects.is_empty()); + assert_eq!(desired.last_revision, None); + assert!(!desired.observe_parent(None)); + assert!(!desired.observe_parent(Some(12))); + assert!(desired.apply_regions(vec![rect(3.0)], 1, 1.0).unwrap()); + assert!(desired.observe_parent(None)); + desired.invalidate(false).unwrap(); + assert!(!desired.observe_parent(None)); +} + +#[test] +fn matching_epoch_and_renderer_session_are_accepted() { + let mut desired = desired_state(); + let epoch = desired + .renew_renderer_session("renderer".to_string()) + .unwrap(); + let decision = desired + .apply_renderer_regions(vec![rect(1.0)], 1, 1.25, epoch, "renderer") + .unwrap(); + + assert_eq!(decision, RegionSyncDecision::Applied); + assert!(decision.accepted()); + assert!(!desired.pending_resync); + assert_eq!(desired.rects, vec![rect(1.0)]); +} + +#[test] +fn renderer_session_mismatch_is_not_accepted_or_mutated() { + let mut desired = desired_state(); + let epoch = desired + .renew_renderer_session("renderer".to_string()) + .unwrap(); + let decision = desired + .apply_renderer_regions(vec![rect(1.0)], 1, 1.0, epoch, "stale-renderer") + .unwrap(); + + assert_eq!(decision, RegionSyncDecision::LeaseMismatch); + assert!(!decision.accepted()); + assert!(desired.pending_resync); + assert!(desired.rects.is_empty()); + assert_eq!(desired.last_revision, None); +} + +#[test] +fn epoch_mismatch_is_not_accepted_or_mutated() { + let mut desired = desired_state(); + let epoch = desired + .renew_renderer_session("renderer".to_string()) + .unwrap(); + let decision = desired + .apply_renderer_regions(vec![rect(1.0)], 1, 1.0, epoch - 1, "renderer") + .unwrap(); + + assert_eq!(decision, RegionSyncDecision::LeaseMismatch); + assert!(!decision.accepted()); + assert!(desired.pending_resync); + assert!(desired.rects.is_empty()); + assert_eq!(desired.last_revision, None); +} + +#[test] +fn stale_revision_keeps_valid_lease_accepted() { + let mut desired = desired_state(); + let epoch = desired + .renew_renderer_session("renderer".to_string()) + .unwrap(); + assert_eq!( + desired + .apply_renderer_regions(vec![rect(2.0)], 2, 1.0, epoch, "renderer") + .unwrap(), + RegionSyncDecision::Applied + ); + let decision = desired + .apply_renderer_regions(vec![rect(1.0)], 1, 1.0, epoch, "renderer") + .unwrap(); + + assert_eq!(decision, RegionSyncDecision::StaleRevision); + assert!(decision.accepted()); + assert!(!desired.pending_resync); + assert_eq!(desired.rects, vec![rect(2.0)]); + assert_eq!(desired.last_revision, Some(2)); +} + +#[test] +fn hidden_locked_and_unready_states_pause_without_clearing_pending() { + let mut desired = desired_state(); + assert!(!desired.can_probe()); + assert!(desired.pending_resync); + + desired + .renew_renderer_session("renderer".to_string()) + .unwrap(); + assert!(desired.can_probe()); + + desired.visible = false; + assert!(!desired.can_probe()); + assert!(desired.pending_resync); + + desired.visible = true; + desired.locked = true; + assert!(!desired.can_probe()); + assert!(desired.pending_resync); + + desired.locked = false; + assert!(desired.can_probe()); +} + +#[test] +fn measured_empty_and_fully_clipped_regions_remain_distinct() { + assert_eq!(hit_region_status(true, 0), HitRegionStatus::Applied); + assert_eq!(hit_region_status(false, 0), HitRegionStatus::FullyClipped); + assert_eq!(hit_region_status(false, 1), HitRegionStatus::Applied); + + let mut desired = desired_state(); + let epoch = desired + .renew_renderer_session("renderer".to_string()) + .unwrap(); + assert_eq!( + desired + .apply_renderer_regions(Vec::new(), 1, 1.0, epoch, "renderer") + .unwrap(), + RegionSyncDecision::Applied + ); + assert!(desired.rects.is_empty()); + assert_eq!(desired.last_revision, Some(1)); + assert!(!desired.pending_resync); +} + +#[test] +fn epoch_overflow_revokes_renderer_and_keeps_resync_pending() { + let mut desired = desired_state(); + desired.resync_epoch = u64::MAX; + desired.renderer_session = Some("renderer".to_string()); + desired.pending_resync = false; + + assert!(desired.invalidate(false).is_err()); + assert_eq!(desired.resync_epoch, u64::MAX); + assert_eq!(desired.renderer_session, None); + assert!(desired.pending_resync); + assert!(!desired.can_probe()); +} + +#[test] +fn probe_schedule_reaches_indefinite_five_second_interval() { + assert_eq!( + (0..8).map(probe_delay_ms).collect::>(), + vec![100, 250, 500, 1_000, 5_000, 5_000, 5_000, 5_000] + ); +} + +#[test] +fn hit_rect_clipping_uses_client_bounds_and_drops_empty_intersections() { + assert_eq!( + clip_hit_rect_to_bounds( + OverlayHitRect { + x: -5.0, + y: -10.0, + width: 20.0, + height: 30.0, + }, + 100.0, + 100.0, + ), + Some(OverlayHitRect { + x: 0.0, + y: 0.0, + width: 15.0, + height: 20.0, + }) + ); + assert_eq!( + clip_hit_rect_to_bounds( + OverlayHitRect { + x: 90.0, + y: 95.0, + width: 30.0, + height: 10.0, + }, + 100.0, + 100.0, + ), + Some(OverlayHitRect { + x: 90.0, + y: 95.0, + width: 10.0, + height: 5.0, + }) + ); + assert_eq!(clip_hit_rect_to_bounds(rect(110.0), 100.0, 100.0), None); + assert_eq!(clip_hit_rect_to_bounds(rect(1.0), 0.0, 100.0), None); +} + +#[cfg(target_os = "macos")] +#[test] +fn macos_hit_classes_register() { + super::platform::register_classes_for_test(); +} + +#[test] +fn hit_rect_validation_accepts_finite_positive_rects() { + assert!(validate_hit_rects(&[OverlayHitRect { + x: -10.0, + y: 20.0, + width: 30.0, + height: 40.0, + }]) + .is_ok()); +} + +#[test] +fn hit_rect_validation_rejects_non_positive_dimensions() { + assert!(validate_hit_rects(&[OverlayHitRect { + x: 0.0, + y: 0.0, + width: 0.0, + height: 10.0, + }]) + .is_err()); +} + +#[test] +fn hit_rect_validation_rejects_non_finite_values() { + for invalid in [ + OverlayHitRect { + x: f64::NAN, + y: 0.0, + width: 10.0, + height: 10.0, + }, + OverlayHitRect { + x: 0.0, + y: f64::INFINITY, + width: 10.0, + height: 10.0, + }, + OverlayHitRect { + x: 0.0, + y: 0.0, + width: f64::NEG_INFINITY, + height: 10.0, + }, + OverlayHitRect { + x: 0.0, + y: 0.0, + width: 10.0, + height: f64::NAN, + }, + ] { + assert!(validate_hit_rects(&[invalid]).is_err()); + } +} + +#[test] +fn hit_rect_validation_rejects_excessive_count() { + let rect = OverlayHitRect { + x: 0.0, + y: 0.0, + width: 10.0, + height: 10.0, + }; + assert!(validate_hit_rects(&vec![rect; MAX_HIT_RECTS + 1]).is_err()); +} From 30a9d06f5756146b29567c4530da070bcbe4e336 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 17:05:06 +0900 Subject: [PATCH 152/245] =?UTF-8?q?refactor:=20=ED=94=84=EB=A6=AC=EC=85=8B?= =?UTF-8?q?=20=EB=AA=85=EB=A0=B9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/preset/mod.rs | 625 +------------------------ src-tauri/src/commands/preset/tests.rs | 622 ++++++++++++++++++++++++ 2 files changed, 623 insertions(+), 624 deletions(-) create mode 100644 src-tauri/src/commands/preset/tests.rs diff --git a/src-tauri/src/commands/preset/mod.rs b/src-tauri/src/commands/preset/mod.rs index 41de430e1..8f611f92d 100644 --- a/src-tauri/src/commands/preset/mod.rs +++ b/src-tauri/src/commands/preset/mod.rs @@ -238,627 +238,4 @@ pub(crate) fn option_has_non_empty_text(value: &Option) -> bool { } #[cfg(test)] -mod tests { - use super::PresetFile; - // file URL 경로 테스트가 유닉스 전용이라 Windows에선 미사용 경고 방지 - #[cfg(not(target_os = "windows"))] - use super::local_source_path_from_image_ref; - use crate::models::{KeyCounterColor, KeySlot, NoteColor}; - use serde::{Deserialize, Serialize}; - use serde_json::json; - - #[test] - fn preset_round_trip_preserves_layer_groups_and_tab_css_overrides() { - let value = json!({ - "layerGroups": { "4key": [] }, - "tabCssOverrides": { - "4key": { - "path": "/tmp/tab.css", - "content": ".key { color: red; }", - "enabled": true - } - } - }); - let preset: PresetFile = serde_json::from_value(value.clone()).unwrap(); - let serialized = serde_json::to_value(preset).unwrap(); - - assert_eq!(serialized["layerGroups"], value["layerGroups"]); - assert_eq!(serialized["tabCssOverrides"], value["tabCssOverrides"]); - } - - #[test] - fn legacy_preset_defaults_new_fields_to_none() { - let preset: PresetFile = serde_json::from_value(json!({})).unwrap(); - - assert!(preset.layer_groups.is_none()); - assert!(preset.tab_css_overrides.is_none()); - } - - #[test] - fn legacy_preset_positions_default_new_surface_gradients_to_none() { - let preset: PresetFile = serde_json::from_value(json!({ - "keyPositions": { - "4key": [{ - "dx": 0, - "dy": 0, - "width": 60, - "count": 0, - "noteBorderColor": "#112233" - }] - } - })) - .unwrap(); - let position = &preset.key_positions.as_ref().unwrap()["4key"][0]; - - assert!(position.note_gradient.is_none()); - assert!(position.note_glow_gradient.is_none()); - assert!(position.note_border_gradient.is_none()); - assert!(position.font_gradient.is_none()); - assert!(position.active_font_gradient.is_none()); - } - - #[test] - fn preset_wire_schema_excludes_internal_store_fields() { - let serialized = serde_json::to_value(PresetFile::default()).unwrap(); - - assert!(serialized.get("customCssHistory").is_none()); - assert!(serialized.get("panelBounds").is_none()); - } - - #[derive(Serialize, Deserialize)] - #[serde(rename_all = "camelCase")] - struct PreFeaturePresetPosition { - dx: f64, - dy: f64, - width: f64, - count: u32, - background_color: Option, - font_color: Option, - active_font_color: Option, - note_color: NoteColor, - note_opacity: u32, - note_opacity_top: Option, - note_opacity_bottom: Option, - note_glow_color: Option, - note_glow_opacity: u32, - note_glow_opacity_top: Option, - note_glow_opacity_bottom: Option, - note_border_color: Option, - counter: PreFeaturePresetCounter, - } - - #[derive(Serialize, Deserialize)] - struct PreFeaturePresetCounter { - fill: KeyCounterColor, - } - - #[derive(Serialize, Deserialize)] - #[serde(rename_all = "camelCase")] - struct PreFeaturePreset { - key_positions: Option>>, - } - - #[test] - fn gradient_preset_round_trip_and_pre_feature_shadow_preserve_representative_colors() { - let source = json!({ - "keys": { "4key": ["Q"] }, - "keyPositions": { - "4key": [{ - "dx": 0, - "dy": 0, - "width": 60, - "count": 0, - "backgroundColor": "rgba(16, 32, 48, 1)", - "backgroundGradient": { - "angle": 90, - "stops": [ - { "color": "rgba(16, 32, 48, 1)", "pos": 0 }, - { "color": "rgba(64, 80, 96, 0.5)", "pos": 1 } - ] - }, - "fontColor": "#123456", - "fontGradient": { - "angle": 45, - "stops": [ - { "color": "#123456", "pos": 0 }, - { "color": "#ABCDEF", "pos": 1 } - ] - }, - "activeFontColor": "#654321", - "activeFontGradient": { - "angle": 135, - "stops": [ - { "color": "#654321", "pos": 0 }, - { "color": "#FEDCBA", "pos": 1 } - ] - }, - "counter": { - "fill": { - "idle": "rgba(255,255,255,1)", - "active": "rgba(20,20,24,0.9)" - }, - "fillIdleGradient": { - "angle": 180, - "stops": [ - { "color": "#FFFFFF", "pos": 0 }, - { "color": "#000000", "pos": 1 } - ] - } - } - }] - } - }); - let preset: PresetFile = serde_json::from_value(source).unwrap(); - let serialized = serde_json::to_value(&preset).unwrap(); - let restored: PresetFile = serde_json::from_value(serialized.clone()).unwrap(); - let reserialized = serde_json::to_value(restored).unwrap(); - - assert_eq!(reserialized, serialized); - assert_eq!( - serialized["keyPositions"]["4key"][0]["backgroundGradient"]["angle"].as_f64(), - Some(90.0) - ); - assert_eq!( - serialized["keyPositions"]["4key"][0]["fontGradient"]["angle"].as_f64(), - Some(45.0) - ); - assert_eq!( - serialized["keyPositions"]["4key"][0]["counter"]["fillIdleGradient"]["angle"].as_f64(), - Some(180.0) - ); - - let shadow: PreFeaturePreset = serde_json::from_value(serialized).unwrap(); - let shadow_positions = shadow.key_positions.unwrap(); - let shadow_position = &shadow_positions["4key"][0]; - assert_eq!( - shadow_position.background_color.as_deref(), - Some("rgba(16, 32, 48, 1)") - ); - assert_eq!(shadow_position.font_color.as_deref(), Some("#123456")); - assert_eq!( - shadow_position.active_font_color.as_deref(), - Some("#654321") - ); - assert_eq!(shadow_position.counter.fill.idle, "rgba(255,255,255,1)"); - } - - #[test] - fn preset_1_6_1_shadow_round_trip_drops_new_gradients_but_keeps_representatives() { - let source = json!({ - "keys": { "4key": ["Q"] }, - "keyPositions": { - "4key": [{ - "dx": 0, - "dy": 0, - "width": 60, - "count": 0, - "noteColor": { - "type": "gradient", - "top": "#112233", - "bottom": "#445566" - }, - "noteOpacity": 80, - "noteOpacityTop": 40, - "noteOpacityBottom": 20, - "noteGradient": { - "angle": 45, - "stops": [ - { "color": "rgba(17,34,51,.5)", "pos": 0 }, - { "color": "#44556640", "pos": 1 } - ] - }, - "noteGlowColor": { - "type": "gradient", - "top": "#778899", - "bottom": "#AABBCC" - }, - "noteGlowOpacity": 60, - "noteGlowOpacityTop": 30, - "noteGlowOpacityBottom": 60, - "noteGlowGradient": { - "angle": 135, - "stops": [ - { "color": "#77889980", "pos": 0 }, - { "color": "rgb(170,187,204)", "pos": 1 } - ] - }, - "noteBorderColor": "#112233", - "noteBorderGradient": { - "angle": 90, - "stops": [ - { "color": "rgba(17, 34, 51, .5)", "pos": 0 }, - { "color": "#ABC8", "pos": 1 } - ] - }, - "fontColor": "#123456", - "fontGradient": { - "angle": 90, - "stops": [ - { "color": "#123456", "pos": 0 }, - { "color": "#ABCDEF", "pos": 1 } - ] - }, - "activeFontColor": "#654321", - "activeFontGradient": { - "angle": 135, - "stops": [ - { "color": "#654321", "pos": 0 }, - { "color": "#FEDCBA", "pos": 1 } - ] - }, - "counter": { - "fill": { - "idle": "rgba(255,255,255,1)", - "active": "rgba(20,20,24,0.9)" - } - } - }] - } - }); - let current: PresetFile = serde_json::from_value(source).unwrap(); - let current_wire = serde_json::to_value(current).unwrap(); - - let old: PreFeaturePreset = serde_json::from_value(current_wire).unwrap(); - let old_wire = serde_json::to_value(old).unwrap(); - let restored: PresetFile = serde_json::from_value(old_wire.clone()).unwrap(); - let position = &restored.key_positions.as_ref().unwrap()["4key"][0]; - - assert!(old_wire["keyPositions"]["4key"][0] - .get("noteGradient") - .is_none()); - assert!(old_wire["keyPositions"]["4key"][0] - .get("noteGlowGradient") - .is_none()); - assert!(old_wire["keyPositions"]["4key"][0] - .get("noteBorderGradient") - .is_none()); - assert!(old_wire["keyPositions"]["4key"][0] - .get("fontGradient") - .is_none()); - assert!(old_wire["keyPositions"]["4key"][0] - .get("activeFontGradient") - .is_none()); - assert!(position.note_gradient.is_none()); - assert!(position.note_glow_gradient.is_none()); - assert!(position.note_border_gradient.is_none()); - assert!(position.font_gradient.is_none()); - assert!(position.active_font_gradient.is_none()); - assert_eq!( - position.note_color, - NoteColor::Gradient { - top: "#112233".to_string(), - bottom: "#445566".to_string(), - } - ); - assert_eq!(position.note_opacity, 80); - assert_eq!(position.note_opacity_top, Some(40)); - assert_eq!(position.note_opacity_bottom, Some(20)); - assert_eq!( - position.note_glow_color, - Some(NoteColor::Gradient { - top: "#778899".to_string(), - bottom: "#AABBCC".to_string(), - }) - ); - assert_eq!(position.note_glow_opacity, 60); - assert_eq!(position.note_glow_opacity_top, Some(30)); - assert_eq!(position.note_glow_opacity_bottom, Some(60)); - assert_eq!(position.note_border_color.as_deref(), Some("#112233")); - assert_eq!(position.font_color.as_deref(), Some("#123456")); - assert_eq!(position.active_font_color.as_deref(), Some("#654321")); - } - - #[test] - fn tauri_era_preset_schema_transitions_remain_readable() { - let tauri_130: PresetFile = serde_json::from_value(json!({ - "keys": { "custom-130": ["Q"] }, - "keyPositions": { - "custom-130": [{ - "dx": 12.5, - "dy": -3.0, - "width": 64.0, - "count": 7, - "displayText": "Tauri 1.3" - }] - }, - "backgroundColor": "#112233", - "noteSettings": { "speed": 321, "trackHeight": 222 }, - "noteEffect": false, - "laboratoryEnabled": true, - "customTabs": [{ "id": "custom-130", "name": "Old tab" }], - "selectedKeyType": "custom-130", - "useCustomCSS": true, - "customCSS": { "path": "/tmp/old.css", "content": ".old {}" } - })) - .expect("1.3.0 preset must deserialize"); - - assert_eq!( - tauri_130.keys.as_ref().unwrap()["custom-130"], - vec![KeySlot::from("Q")] - ); - let old_position = &tauri_130.key_positions.as_ref().unwrap()["custom-130"][0]; - assert_eq!(old_position.dx, 12.5); - assert_eq!(old_position.dy, -3.0); - assert_eq!(old_position.width, 64.0); - assert_eq!(old_position.count, 7); - assert_eq!(old_position.display_text.as_deref(), Some("Tauri 1.3")); - assert_eq!(tauri_130.background_color.as_deref(), Some("#112233")); - assert_eq!(tauri_130.note_settings.as_ref().unwrap().speed, 321); - assert_eq!(tauri_130.note_settings.as_ref().unwrap().track_height, 222); - assert_eq!(tauri_130.note_effect, Some(false)); - assert_eq!(tauri_130.laboratory_enabled, Some(true)); - assert_eq!(tauri_130.custom_tabs.as_ref().unwrap()[0].id, "custom-130"); - assert_eq!(tauri_130.selected_key_type.as_deref(), Some("custom-130")); - assert_eq!(tauri_130.use_custom_css, Some(true)); - assert_eq!(tauri_130.custom_css.as_ref().unwrap().content, ".old {}"); - - let transition_fixtures = [ - ( - "1.4.0", - json!({ - "keys": { "4key": ["Q"] }, - "keyPositions": {}, - "useCustomJS": true, - "customJS": { "path": null, "content": "void 0", "plugins": [] } - }), - ), - ( - "1.5.1", - json!({ - "keys": { "4key": ["Q"] }, - "keyPositions": {}, - "statPositions": {}, - "fontSettings": { "customFonts": [] }, - "embeddedLocalFonts": [], - "embeddedLocalImages": [] - }), - ), - ( - "1.6.0", - json!({ - "keys": { "4key": ["Q"] }, - "keyPositions": {}, - "statPositions": {}, - "graphPositions": {}, - "tabNoteOverrides": {}, - "embeddedLocalSounds": [] - }), - ), - ]; - - for (version, fixture) in transition_fixtures { - let preset: PresetFile = serde_json::from_value(fixture) - .unwrap_or_else(|error| panic!("{version} preset must deserialize: {error}")); - assert_eq!( - preset.keys.as_ref().unwrap()["4key"], - vec![KeySlot::from("Q")] - ); - } - - let tauri_161: PresetFile = serde_json::from_value(json!({ - "keys": { "custom-161": ["W"] }, - "keyPositions": { - "custom-161": [{ - "dx": 10.0, - "dy": 20.0, - "width": 70.0, - "height": 80.0, - "count": 9, - "groupId": "historic-group", - "activeImage": "dmnote-local-image://image-1", - "soundPath": "dmnote-local-sound://sound-1" - }] - }, - "statPositions": { - "custom-161": [{ - "statType": "total", - "dx": 1.0, - "dy": 2.0, - "width": 100.0, - "count": 0 - }] - }, - "graphPositions": { - "custom-161": [{ - "statType": "kpsAvg", - "graphType": "line", - "graphSpeed": 4, - "graphColor": "#abcdef", - "dx": 3.0, - "dy": 4.0, - "width": 120.0, - "count": 0 - }] - }, - "knobPositions": { - "custom-161": [{ - "axisId": "HIDA:1:2:3:4", - "sensitivity": 1.5, - "reverse": true, - "dx": 5.0, - "dy": 6.0, - "width": 90.0, - "count": 0 - }] - }, - "useCustomJS": true, - "customJS": { - "path": null, - "content": "globalThis.legacy = true", - "plugins": [{ - "id": "plugin-161", - "name": "Legacy plugin", - "path": null, - "content": "globalThis.plugin161 = true", - "enabled": true - }] - }, - "fontSettings": { - "customFonts": [{ - "id": "font-1", - "type": "local", - "name": "LegacyFont", - "displayName": "Legacy Font", - "enabled": true, - "localPath": null, - "cssContent": null - }] - }, - "tabNoteOverrides": { - "custom-161": { "speed": 444, "reverse": true } - }, - "embeddedLocalFonts": [{ - "fontId": "font-1", - "extension": "ttf", - "dataBase64": "AA==" - }], - "embeddedLocalImages": [{ - "imageId": "image-1", - "extension": "png", - "dataBase64": "AA==" - }], - "embeddedLocalSounds": [{ - "soundId": "sound-1", - "extension": "wav", - "dataBase64": "AA==" - }] - })) - .expect("1.6.1 preset must deserialize"); - - assert_eq!( - tauri_161.keys.as_ref().unwrap()["custom-161"], - [KeySlot::from("W")] - ); - let position_161 = &tauri_161.key_positions.as_ref().unwrap()["custom-161"][0]; - assert_eq!(position_161.group_id.as_deref(), Some("historic-group")); - assert_eq!( - position_161.active_image.as_deref(), - Some("dmnote-local-image://image-1") - ); - assert_eq!( - position_161.sound_path.as_deref(), - Some("dmnote-local-sound://sound-1") - ); - assert_eq!( - tauri_161.stat_positions.as_ref().unwrap()["custom-161"].len(), - 1 - ); - assert_eq!( - tauri_161.graph_positions.as_ref().unwrap()["custom-161"][0].graph_speed, - 4 - ); - assert_eq!( - tauri_161.knob_positions.as_ref().unwrap()["custom-161"][0].axis_id, - "HIDA:1:2:3:4" - ); - assert_eq!( - tauri_161.custom_js.as_ref().unwrap().plugins[0].id, - "plugin-161" - ); - assert_eq!( - tauri_161.font_settings.as_ref().unwrap().custom_fonts[0].id, - "font-1" - ); - assert_eq!( - tauri_161.tab_note_overrides.as_ref().unwrap()["custom-161"].speed, - Some(444) - ); - assert_eq!( - tauri_161.embedded_local_fonts.as_ref().unwrap()[0].font_id, - "font-1" - ); - assert_eq!( - tauri_161.embedded_local_images.as_ref().unwrap()[0].image_id, - "image-1" - ); - assert_eq!( - tauri_161.embedded_local_sounds.as_ref().unwrap()[0].sound_id, - "sound-1" - ); - } - - #[test] - fn internal_editor_revision_never_becomes_part_of_a_preset() { - let preset: PresetFile = serde_json::from_value(json!({ - "keys": { "4key": ["Q"] }, - "editorRevision": 42 - })) - .unwrap(); - let serialized = serde_json::to_value(preset).unwrap(); - - assert!(serialized.get("editorRevision").is_none()); - } - - #[cfg(not(target_os = "windows"))] - #[test] - fn file_url_image_source_preserves_its_absolute_path() { - assert_eq!( - local_source_path_from_image_ref("file:///tmp/dmnote-image.png"), - Some(std::path::PathBuf::from("/tmp/dmnote-image.png")) - ); - } - - #[cfg(not(target_os = "windows"))] - #[test] - fn file_url_image_source_decodes_percent_sequences_only() { - assert_eq!( - local_source_path_from_image_ref( - "file:///tmp/Application%20Support/%ED%95%9C%EA%B8%80%25+a.png" - ), - Some(std::path::PathBuf::from( - "/tmp/Application Support/한글%+a.png" - )) - ); - } - - #[cfg(not(target_os = "windows"))] - #[test] - fn invalid_file_url_uses_legacy_literal_path_for_preset_compatibility() { - assert_eq!( - local_source_path_from_image_ref("file://[invalid/path.png"), - Some(std::path::PathBuf::from("[invalid/path.png")) - ); - assert_eq!( - local_source_path_from_image_ref("file:///tmp/broken%ZZ.png"), - Some(std::path::PathBuf::from("/tmp/broken%ZZ.png")) - ); - } - - #[test] - fn preset_1_0_fixture_preserves_values_and_fills_visual_defaults() { - // 1.0.0이 실제 저장하던 프리셋 형식 — height/noteColor/noteOpacity 없음 - let fixture = r#"{ - "keys": { - "4key": ["Q"], - "5key": [], - "6key": [], - "8key": [] - }, - "keyPositions": { - "4key": [ - { - "dx": 777, - "dy": 130, - "width": 60, - "activeImage": "", - "inactiveImage": "", - "count": 42 - } - ], - "5key": [], - "6key": [], - "8key": [] - }, - "backgroundColor": "transparent" - }"#; - let preset: PresetFile = serde_json::from_str(fixture).unwrap(); - let positions = preset.key_positions.unwrap(); - let position = &positions["4key"][0]; - - assert_eq!(position.dx, 777.0); - assert_eq!(position.count, 42); - assert_eq!(position.height, 60.0); - assert_eq!(position.note_color, NoteColor::Solid("#FFFFFF".to_string())); - assert_eq!(position.note_opacity, 90); - } -} +mod tests; diff --git a/src-tauri/src/commands/preset/tests.rs b/src-tauri/src/commands/preset/tests.rs new file mode 100644 index 000000000..b86b26378 --- /dev/null +++ b/src-tauri/src/commands/preset/tests.rs @@ -0,0 +1,622 @@ +use super::PresetFile; +// file URL 경로 테스트가 유닉스 전용이라 Windows에선 미사용 경고 방지 +#[cfg(not(target_os = "windows"))] +use super::local_source_path_from_image_ref; +use crate::models::{KeyCounterColor, KeySlot, NoteColor}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +#[test] +fn preset_round_trip_preserves_layer_groups_and_tab_css_overrides() { + let value = json!({ + "layerGroups": { "4key": [] }, + "tabCssOverrides": { + "4key": { + "path": "/tmp/tab.css", + "content": ".key { color: red; }", + "enabled": true + } + } + }); + let preset: PresetFile = serde_json::from_value(value.clone()).unwrap(); + let serialized = serde_json::to_value(preset).unwrap(); + + assert_eq!(serialized["layerGroups"], value["layerGroups"]); + assert_eq!(serialized["tabCssOverrides"], value["tabCssOverrides"]); +} + +#[test] +fn legacy_preset_defaults_new_fields_to_none() { + let preset: PresetFile = serde_json::from_value(json!({})).unwrap(); + + assert!(preset.layer_groups.is_none()); + assert!(preset.tab_css_overrides.is_none()); +} + +#[test] +fn legacy_preset_positions_default_new_surface_gradients_to_none() { + let preset: PresetFile = serde_json::from_value(json!({ + "keyPositions": { + "4key": [{ + "dx": 0, + "dy": 0, + "width": 60, + "count": 0, + "noteBorderColor": "#112233" + }] + } + })) + .unwrap(); + let position = &preset.key_positions.as_ref().unwrap()["4key"][0]; + + assert!(position.note_gradient.is_none()); + assert!(position.note_glow_gradient.is_none()); + assert!(position.note_border_gradient.is_none()); + assert!(position.font_gradient.is_none()); + assert!(position.active_font_gradient.is_none()); +} + +#[test] +fn preset_wire_schema_excludes_internal_store_fields() { + let serialized = serde_json::to_value(PresetFile::default()).unwrap(); + + assert!(serialized.get("customCssHistory").is_none()); + assert!(serialized.get("panelBounds").is_none()); +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PreFeaturePresetPosition { + dx: f64, + dy: f64, + width: f64, + count: u32, + background_color: Option, + font_color: Option, + active_font_color: Option, + note_color: NoteColor, + note_opacity: u32, + note_opacity_top: Option, + note_opacity_bottom: Option, + note_glow_color: Option, + note_glow_opacity: u32, + note_glow_opacity_top: Option, + note_glow_opacity_bottom: Option, + note_border_color: Option, + counter: PreFeaturePresetCounter, +} + +#[derive(Serialize, Deserialize)] +struct PreFeaturePresetCounter { + fill: KeyCounterColor, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PreFeaturePreset { + key_positions: Option>>, +} + +#[test] +fn gradient_preset_round_trip_and_pre_feature_shadow_preserve_representative_colors() { + let source = json!({ + "keys": { "4key": ["Q"] }, + "keyPositions": { + "4key": [{ + "dx": 0, + "dy": 0, + "width": 60, + "count": 0, + "backgroundColor": "rgba(16, 32, 48, 1)", + "backgroundGradient": { + "angle": 90, + "stops": [ + { "color": "rgba(16, 32, 48, 1)", "pos": 0 }, + { "color": "rgba(64, 80, 96, 0.5)", "pos": 1 } + ] + }, + "fontColor": "#123456", + "fontGradient": { + "angle": 45, + "stops": [ + { "color": "#123456", "pos": 0 }, + { "color": "#ABCDEF", "pos": 1 } + ] + }, + "activeFontColor": "#654321", + "activeFontGradient": { + "angle": 135, + "stops": [ + { "color": "#654321", "pos": 0 }, + { "color": "#FEDCBA", "pos": 1 } + ] + }, + "counter": { + "fill": { + "idle": "rgba(255,255,255,1)", + "active": "rgba(20,20,24,0.9)" + }, + "fillIdleGradient": { + "angle": 180, + "stops": [ + { "color": "#FFFFFF", "pos": 0 }, + { "color": "#000000", "pos": 1 } + ] + } + } + }] + } + }); + let preset: PresetFile = serde_json::from_value(source).unwrap(); + let serialized = serde_json::to_value(&preset).unwrap(); + let restored: PresetFile = serde_json::from_value(serialized.clone()).unwrap(); + let reserialized = serde_json::to_value(restored).unwrap(); + + assert_eq!(reserialized, serialized); + assert_eq!( + serialized["keyPositions"]["4key"][0]["backgroundGradient"]["angle"].as_f64(), + Some(90.0) + ); + assert_eq!( + serialized["keyPositions"]["4key"][0]["fontGradient"]["angle"].as_f64(), + Some(45.0) + ); + assert_eq!( + serialized["keyPositions"]["4key"][0]["counter"]["fillIdleGradient"]["angle"].as_f64(), + Some(180.0) + ); + + let shadow: PreFeaturePreset = serde_json::from_value(serialized).unwrap(); + let shadow_positions = shadow.key_positions.unwrap(); + let shadow_position = &shadow_positions["4key"][0]; + assert_eq!( + shadow_position.background_color.as_deref(), + Some("rgba(16, 32, 48, 1)") + ); + assert_eq!(shadow_position.font_color.as_deref(), Some("#123456")); + assert_eq!( + shadow_position.active_font_color.as_deref(), + Some("#654321") + ); + assert_eq!(shadow_position.counter.fill.idle, "rgba(255,255,255,1)"); +} + +#[test] +fn preset_1_6_1_shadow_round_trip_drops_new_gradients_but_keeps_representatives() { + let source = json!({ + "keys": { "4key": ["Q"] }, + "keyPositions": { + "4key": [{ + "dx": 0, + "dy": 0, + "width": 60, + "count": 0, + "noteColor": { + "type": "gradient", + "top": "#112233", + "bottom": "#445566" + }, + "noteOpacity": 80, + "noteOpacityTop": 40, + "noteOpacityBottom": 20, + "noteGradient": { + "angle": 45, + "stops": [ + { "color": "rgba(17,34,51,.5)", "pos": 0 }, + { "color": "#44556640", "pos": 1 } + ] + }, + "noteGlowColor": { + "type": "gradient", + "top": "#778899", + "bottom": "#AABBCC" + }, + "noteGlowOpacity": 60, + "noteGlowOpacityTop": 30, + "noteGlowOpacityBottom": 60, + "noteGlowGradient": { + "angle": 135, + "stops": [ + { "color": "#77889980", "pos": 0 }, + { "color": "rgb(170,187,204)", "pos": 1 } + ] + }, + "noteBorderColor": "#112233", + "noteBorderGradient": { + "angle": 90, + "stops": [ + { "color": "rgba(17, 34, 51, .5)", "pos": 0 }, + { "color": "#ABC8", "pos": 1 } + ] + }, + "fontColor": "#123456", + "fontGradient": { + "angle": 90, + "stops": [ + { "color": "#123456", "pos": 0 }, + { "color": "#ABCDEF", "pos": 1 } + ] + }, + "activeFontColor": "#654321", + "activeFontGradient": { + "angle": 135, + "stops": [ + { "color": "#654321", "pos": 0 }, + { "color": "#FEDCBA", "pos": 1 } + ] + }, + "counter": { + "fill": { + "idle": "rgba(255,255,255,1)", + "active": "rgba(20,20,24,0.9)" + } + } + }] + } + }); + let current: PresetFile = serde_json::from_value(source).unwrap(); + let current_wire = serde_json::to_value(current).unwrap(); + + let old: PreFeaturePreset = serde_json::from_value(current_wire).unwrap(); + let old_wire = serde_json::to_value(old).unwrap(); + let restored: PresetFile = serde_json::from_value(old_wire.clone()).unwrap(); + let position = &restored.key_positions.as_ref().unwrap()["4key"][0]; + + assert!(old_wire["keyPositions"]["4key"][0] + .get("noteGradient") + .is_none()); + assert!(old_wire["keyPositions"]["4key"][0] + .get("noteGlowGradient") + .is_none()); + assert!(old_wire["keyPositions"]["4key"][0] + .get("noteBorderGradient") + .is_none()); + assert!(old_wire["keyPositions"]["4key"][0] + .get("fontGradient") + .is_none()); + assert!(old_wire["keyPositions"]["4key"][0] + .get("activeFontGradient") + .is_none()); + assert!(position.note_gradient.is_none()); + assert!(position.note_glow_gradient.is_none()); + assert!(position.note_border_gradient.is_none()); + assert!(position.font_gradient.is_none()); + assert!(position.active_font_gradient.is_none()); + assert_eq!( + position.note_color, + NoteColor::Gradient { + top: "#112233".to_string(), + bottom: "#445566".to_string(), + } + ); + assert_eq!(position.note_opacity, 80); + assert_eq!(position.note_opacity_top, Some(40)); + assert_eq!(position.note_opacity_bottom, Some(20)); + assert_eq!( + position.note_glow_color, + Some(NoteColor::Gradient { + top: "#778899".to_string(), + bottom: "#AABBCC".to_string(), + }) + ); + assert_eq!(position.note_glow_opacity, 60); + assert_eq!(position.note_glow_opacity_top, Some(30)); + assert_eq!(position.note_glow_opacity_bottom, Some(60)); + assert_eq!(position.note_border_color.as_deref(), Some("#112233")); + assert_eq!(position.font_color.as_deref(), Some("#123456")); + assert_eq!(position.active_font_color.as_deref(), Some("#654321")); +} + +#[test] +fn tauri_era_preset_schema_transitions_remain_readable() { + let tauri_130: PresetFile = serde_json::from_value(json!({ + "keys": { "custom-130": ["Q"] }, + "keyPositions": { + "custom-130": [{ + "dx": 12.5, + "dy": -3.0, + "width": 64.0, + "count": 7, + "displayText": "Tauri 1.3" + }] + }, + "backgroundColor": "#112233", + "noteSettings": { "speed": 321, "trackHeight": 222 }, + "noteEffect": false, + "laboratoryEnabled": true, + "customTabs": [{ "id": "custom-130", "name": "Old tab" }], + "selectedKeyType": "custom-130", + "useCustomCSS": true, + "customCSS": { "path": "/tmp/old.css", "content": ".old {}" } + })) + .expect("1.3.0 preset must deserialize"); + + assert_eq!( + tauri_130.keys.as_ref().unwrap()["custom-130"], + vec![KeySlot::from("Q")] + ); + let old_position = &tauri_130.key_positions.as_ref().unwrap()["custom-130"][0]; + assert_eq!(old_position.dx, 12.5); + assert_eq!(old_position.dy, -3.0); + assert_eq!(old_position.width, 64.0); + assert_eq!(old_position.count, 7); + assert_eq!(old_position.display_text.as_deref(), Some("Tauri 1.3")); + assert_eq!(tauri_130.background_color.as_deref(), Some("#112233")); + assert_eq!(tauri_130.note_settings.as_ref().unwrap().speed, 321); + assert_eq!(tauri_130.note_settings.as_ref().unwrap().track_height, 222); + assert_eq!(tauri_130.note_effect, Some(false)); + assert_eq!(tauri_130.laboratory_enabled, Some(true)); + assert_eq!(tauri_130.custom_tabs.as_ref().unwrap()[0].id, "custom-130"); + assert_eq!(tauri_130.selected_key_type.as_deref(), Some("custom-130")); + assert_eq!(tauri_130.use_custom_css, Some(true)); + assert_eq!(tauri_130.custom_css.as_ref().unwrap().content, ".old {}"); + + let transition_fixtures = [ + ( + "1.4.0", + json!({ + "keys": { "4key": ["Q"] }, + "keyPositions": {}, + "useCustomJS": true, + "customJS": { "path": null, "content": "void 0", "plugins": [] } + }), + ), + ( + "1.5.1", + json!({ + "keys": { "4key": ["Q"] }, + "keyPositions": {}, + "statPositions": {}, + "fontSettings": { "customFonts": [] }, + "embeddedLocalFonts": [], + "embeddedLocalImages": [] + }), + ), + ( + "1.6.0", + json!({ + "keys": { "4key": ["Q"] }, + "keyPositions": {}, + "statPositions": {}, + "graphPositions": {}, + "tabNoteOverrides": {}, + "embeddedLocalSounds": [] + }), + ), + ]; + + for (version, fixture) in transition_fixtures { + let preset: PresetFile = serde_json::from_value(fixture) + .unwrap_or_else(|error| panic!("{version} preset must deserialize: {error}")); + assert_eq!( + preset.keys.as_ref().unwrap()["4key"], + vec![KeySlot::from("Q")] + ); + } + + let tauri_161: PresetFile = serde_json::from_value(json!({ + "keys": { "custom-161": ["W"] }, + "keyPositions": { + "custom-161": [{ + "dx": 10.0, + "dy": 20.0, + "width": 70.0, + "height": 80.0, + "count": 9, + "groupId": "historic-group", + "activeImage": "dmnote-local-image://image-1", + "soundPath": "dmnote-local-sound://sound-1" + }] + }, + "statPositions": { + "custom-161": [{ + "statType": "total", + "dx": 1.0, + "dy": 2.0, + "width": 100.0, + "count": 0 + }] + }, + "graphPositions": { + "custom-161": [{ + "statType": "kpsAvg", + "graphType": "line", + "graphSpeed": 4, + "graphColor": "#abcdef", + "dx": 3.0, + "dy": 4.0, + "width": 120.0, + "count": 0 + }] + }, + "knobPositions": { + "custom-161": [{ + "axisId": "HIDA:1:2:3:4", + "sensitivity": 1.5, + "reverse": true, + "dx": 5.0, + "dy": 6.0, + "width": 90.0, + "count": 0 + }] + }, + "useCustomJS": true, + "customJS": { + "path": null, + "content": "globalThis.legacy = true", + "plugins": [{ + "id": "plugin-161", + "name": "Legacy plugin", + "path": null, + "content": "globalThis.plugin161 = true", + "enabled": true + }] + }, + "fontSettings": { + "customFonts": [{ + "id": "font-1", + "type": "local", + "name": "LegacyFont", + "displayName": "Legacy Font", + "enabled": true, + "localPath": null, + "cssContent": null + }] + }, + "tabNoteOverrides": { + "custom-161": { "speed": 444, "reverse": true } + }, + "embeddedLocalFonts": [{ + "fontId": "font-1", + "extension": "ttf", + "dataBase64": "AA==" + }], + "embeddedLocalImages": [{ + "imageId": "image-1", + "extension": "png", + "dataBase64": "AA==" + }], + "embeddedLocalSounds": [{ + "soundId": "sound-1", + "extension": "wav", + "dataBase64": "AA==" + }] + })) + .expect("1.6.1 preset must deserialize"); + + assert_eq!( + tauri_161.keys.as_ref().unwrap()["custom-161"], + [KeySlot::from("W")] + ); + let position_161 = &tauri_161.key_positions.as_ref().unwrap()["custom-161"][0]; + assert_eq!(position_161.group_id.as_deref(), Some("historic-group")); + assert_eq!( + position_161.active_image.as_deref(), + Some("dmnote-local-image://image-1") + ); + assert_eq!( + position_161.sound_path.as_deref(), + Some("dmnote-local-sound://sound-1") + ); + assert_eq!( + tauri_161.stat_positions.as_ref().unwrap()["custom-161"].len(), + 1 + ); + assert_eq!( + tauri_161.graph_positions.as_ref().unwrap()["custom-161"][0].graph_speed, + 4 + ); + assert_eq!( + tauri_161.knob_positions.as_ref().unwrap()["custom-161"][0].axis_id, + "HIDA:1:2:3:4" + ); + assert_eq!( + tauri_161.custom_js.as_ref().unwrap().plugins[0].id, + "plugin-161" + ); + assert_eq!( + tauri_161.font_settings.as_ref().unwrap().custom_fonts[0].id, + "font-1" + ); + assert_eq!( + tauri_161.tab_note_overrides.as_ref().unwrap()["custom-161"].speed, + Some(444) + ); + assert_eq!( + tauri_161.embedded_local_fonts.as_ref().unwrap()[0].font_id, + "font-1" + ); + assert_eq!( + tauri_161.embedded_local_images.as_ref().unwrap()[0].image_id, + "image-1" + ); + assert_eq!( + tauri_161.embedded_local_sounds.as_ref().unwrap()[0].sound_id, + "sound-1" + ); +} + +#[test] +fn internal_editor_revision_never_becomes_part_of_a_preset() { + let preset: PresetFile = serde_json::from_value(json!({ + "keys": { "4key": ["Q"] }, + "editorRevision": 42 + })) + .unwrap(); + let serialized = serde_json::to_value(preset).unwrap(); + + assert!(serialized.get("editorRevision").is_none()); +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn file_url_image_source_preserves_its_absolute_path() { + assert_eq!( + local_source_path_from_image_ref("file:///tmp/dmnote-image.png"), + Some(std::path::PathBuf::from("/tmp/dmnote-image.png")) + ); +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn file_url_image_source_decodes_percent_sequences_only() { + assert_eq!( + local_source_path_from_image_ref( + "file:///tmp/Application%20Support/%ED%95%9C%EA%B8%80%25+a.png" + ), + Some(std::path::PathBuf::from( + "/tmp/Application Support/한글%+a.png" + )) + ); +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn invalid_file_url_uses_legacy_literal_path_for_preset_compatibility() { + assert_eq!( + local_source_path_from_image_ref("file://[invalid/path.png"), + Some(std::path::PathBuf::from("[invalid/path.png")) + ); + assert_eq!( + local_source_path_from_image_ref("file:///tmp/broken%ZZ.png"), + Some(std::path::PathBuf::from("/tmp/broken%ZZ.png")) + ); +} + +#[test] +fn preset_1_0_fixture_preserves_values_and_fills_visual_defaults() { + // 1.0.0이 실제 저장하던 프리셋 형식 — height/noteColor/noteOpacity 없음 + let fixture = r#"{ + "keys": { + "4key": ["Q"], + "5key": [], + "6key": [], + "8key": [] + }, + "keyPositions": { + "4key": [ + { + "dx": 777, + "dy": 130, + "width": 60, + "activeImage": "", + "inactiveImage": "", + "count": 42 + } + ], + "5key": [], + "6key": [], + "8key": [] + }, + "backgroundColor": "transparent" + }"#; + let preset: PresetFile = serde_json::from_str(fixture).unwrap(); + let positions = preset.key_positions.unwrap(); + let position = &positions["4key"][0]; + + assert_eq!(position.dx, 777.0); + assert_eq!(position.count, 42); + assert_eq!(position.height, 60.0); + assert_eq!(position.note_color, NoteColor::Solid("#FFFFFF".to_string())); + assert_eq!(position.note_opacity, 90); +} From c195fe5dc14df22fd33e9ea0892f19fb9cb245e7 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 17:09:02 +0900 Subject: [PATCH 153/245] =?UTF-8?q?refactor:=20=EB=85=B8=ED=8A=B8=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=AA=A8=EB=8D=B8=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/models/mod.rs | 250 +------------------------- src-tauri/src/models/note_settings.rs | 248 +++++++++++++++++++++++++ 2 files changed, 255 insertions(+), 243 deletions(-) create mode 100644 src-tauri/src/models/note_settings.rs diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index aa3cbc55c..c5eed296d 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -6,6 +6,7 @@ pub mod plugin; mod bootstrap; mod counter; mod key_position_normalization; +mod note_settings; mod settings; mod shortcuts; @@ -20,6 +21,12 @@ pub use counter::{ use counter::{default_counter_font_size, default_counter_font_weight, default_gap}; pub use editor::*; pub use gesture::*; +use note_settings::{ + default_key_height, default_key_note_color, default_key_note_opacity, + default_note_auto_y_correction, default_note_border_opacity, default_note_effect_enabled, + default_note_glow_enabled, default_note_glow_opacity, default_note_glow_size, +}; +pub use note_settings::{FadePosition, ImageFit, NoteSettings, TabNoteOverrides, TabNoteSettings}; pub use plugin::*; pub use settings::*; pub use shortcuts::*; @@ -903,213 +910,6 @@ pub struct KnobPosition { pub position: KeyPosition, } -fn default_note_effect_enabled() -> bool { - true -} -fn default_key_height() -> f64 { - 60.0 -} -fn default_key_note_color() -> NoteColor { - NoteColor::Solid("#FFFFFF".to_string()) -} -fn default_key_note_opacity() -> u32 { - 90 -} -fn default_note_glow_enabled() -> bool { - false -} -fn default_note_glow_size() -> f64 { - 10.0 -} - -fn default_note_border_opacity() -> u32 { - 100 -} -fn default_note_glow_opacity() -> u32 { - 70 -} -fn default_note_auto_y_correction() -> bool { - true -} -fn default_note_frame_limit() -> u32 { - 0 -} - -fn default_fade_top_px() -> u32 { - 50 -} - -fn default_reverse_fade_bottom_px() -> u32 { - 50 -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase", default)] -pub struct NoteSettings { - // Legacy: 전역 노트 라운딩 (개별 키 noteBorderRadius로 마이그레이션됨) - #[serde(default, skip_serializing)] - pub border_radius: Option, - #[serde(default = "default_note_frame_limit")] - pub frame_limit: u32, - pub speed: u32, - pub track_height: u32, - pub reverse: bool, - pub fade_position: FadePosition, - #[serde(default = "default_fade_top_px")] - pub fade_top_px: u32, - #[serde(default)] - pub fade_bottom_px: u32, - #[serde(default)] - pub reverse_fade_top_px: u32, - #[serde(default = "default_reverse_fade_bottom_px")] - pub reverse_fade_bottom_px: u32, - pub delayed_note_enabled: bool, - pub short_note_threshold_ms: u32, - pub short_note_min_length_px: u32, - #[serde(default)] - pub key_display_delay_ms: u32, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "kebab-case")] -pub enum FadePosition { - Auto, - Top, - Bottom, - None, - Both, -} - -/// 이미지 맞춤 설정 (CSS object-fit과 동일) -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "kebab-case")] -#[derive(Default)] -pub enum ImageFit { - #[default] - Cover, - Contain, - Fill, - None, -} - -impl Default for NoteSettings { - fn default() -> Self { - Self { - border_radius: None, - frame_limit: default_note_frame_limit(), - speed: 400, - track_height: 300, - reverse: false, - fade_position: FadePosition::Auto, - fade_top_px: 50, - fade_bottom_px: 0, - reverse_fade_top_px: 0, - reverse_fade_bottom_px: 50, - delayed_note_enabled: false, - short_note_threshold_ms: 50, - short_note_min_length_px: 30, - key_display_delay_ms: 0, - } - } -} - -impl NoteSettings { - /// Legacy migration: fadePosition enum → pixel-based fade values - /// serde defaults로 채워진 새 필드가 auto 기본값과 동일하고 - /// fadePosition이 non-auto면 레거시 store로 판단하여 변환 - pub fn migrate_fade_position(&mut self) { - let d = Self::default(); - let at_auto_defaults = self.fade_top_px == d.fade_top_px - && self.fade_bottom_px == d.fade_bottom_px - && self.reverse_fade_top_px == d.reverse_fade_top_px - && self.reverse_fade_bottom_px == d.reverse_fade_bottom_px; - - if !at_auto_defaults { - return; - } - - match self.fade_position { - FadePosition::Auto => {} - FadePosition::Top => { - // 항상 상단 페이드 - self.reverse_fade_top_px = d.fade_top_px; - self.reverse_fade_bottom_px = 0; - } - FadePosition::Bottom => { - // 항상 하단 페이드 - self.fade_top_px = 0; - self.fade_bottom_px = d.reverse_fade_bottom_px; - } - FadePosition::None => { - // 페이드 없음 - self.fade_top_px = 0; - self.reverse_fade_bottom_px = 0; - } - FadePosition::Both => { - // 양방향 페이드 - self.fade_bottom_px = d.fade_top_px; - self.reverse_fade_top_px = d.reverse_fade_bottom_px; - } - } - self.fade_position = FadePosition::Auto; - } -} - -impl TabNoteSettings { - /// Legacy migration: 탭 오버라이드의 fadePosition → pixel-based fade values - /// Option 기반이라 None = 필드 부재 확실 (heuristic 불필요) - pub fn migrate_fade_position(&mut self) { - let fp = match self.fade_position.take() { - Some(fp) => fp, - None => return, - }; - - let has_new_fields = self.fade_top_px.is_some() - || self.fade_bottom_px.is_some() - || self.reverse_fade_top_px.is_some() - || self.reverse_fade_bottom_px.is_some(); - - if has_new_fields { - return; // 이미 새 필드가 설정됨 - } - - let d = NoteSettings::default(); - match fp { - FadePosition::Auto => { - // 명시적 auto 오버라이드 → 전역과 무관하게 auto 동작 보장 - self.fade_top_px = Some(d.fade_top_px); - self.fade_bottom_px = Some(d.fade_bottom_px); - self.reverse_fade_top_px = Some(d.reverse_fade_top_px); - self.reverse_fade_bottom_px = Some(d.reverse_fade_bottom_px); - } - FadePosition::Top => { - self.fade_top_px = Some(d.fade_top_px); - self.fade_bottom_px = Some(0); - self.reverse_fade_top_px = Some(d.fade_top_px); - self.reverse_fade_bottom_px = Some(0); - } - FadePosition::Bottom => { - self.fade_top_px = Some(0); - self.fade_bottom_px = Some(d.reverse_fade_bottom_px); - self.reverse_fade_top_px = Some(0); - self.reverse_fade_bottom_px = Some(d.reverse_fade_bottom_px); - } - FadePosition::None => { - self.fade_top_px = Some(0); - self.fade_bottom_px = Some(0); - self.reverse_fade_top_px = Some(0); - self.reverse_fade_bottom_px = Some(0); - } - FadePosition::Both => { - self.fade_top_px = Some(d.fade_top_px); - self.fade_bottom_px = Some(d.fade_top_px); - self.reverse_fade_top_px = Some(d.reverse_fade_bottom_px); - self.reverse_fade_bottom_px = Some(d.reverse_fade_bottom_px); - } - } - } -} - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] #[derive(Default)] @@ -1155,42 +955,6 @@ impl Default for TabCss { /// 탭별 CSS 오버라이드 맵 (키: 탭 ID, 값: TabCss) pub type TabCssOverrides = HashMap; -/// 탭별 노트 트랙 설정 (전역 NoteSettings를 탭별로 오버라이드) -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -#[derive(Default)] -pub struct TabNoteSettings { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub frame_limit: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub speed: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub track_height: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reverse: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub fade_position: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub fade_top_px: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub fade_bottom_px: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reverse_fade_top_px: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reverse_fade_bottom_px: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub delayed_note_enabled: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub short_note_threshold_ms: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub short_note_min_length_px: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub key_display_delay_ms: Option, -} - -/// 탭별 노트 트랙 설정 오버라이드 맵 (키: 탭 ID, 값: TabNoteSettings) -pub type TabNoteOverrides = HashMap; - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct JsPlugin { diff --git a/src-tauri/src/models/note_settings.rs b/src-tauri/src/models/note_settings.rs new file mode 100644 index 000000000..46329f117 --- /dev/null +++ b/src-tauri/src/models/note_settings.rs @@ -0,0 +1,248 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use super::NoteColor; + +pub(super) fn default_note_effect_enabled() -> bool { + true +} +pub(super) fn default_key_height() -> f64 { + 60.0 +} +pub(super) fn default_key_note_color() -> NoteColor { + NoteColor::Solid("#FFFFFF".to_string()) +} +pub(super) fn default_key_note_opacity() -> u32 { + 90 +} +pub(super) fn default_note_glow_enabled() -> bool { + false +} +pub(super) fn default_note_glow_size() -> f64 { + 10.0 +} + +pub(super) fn default_note_border_opacity() -> u32 { + 100 +} +pub(super) fn default_note_glow_opacity() -> u32 { + 70 +} +pub(super) fn default_note_auto_y_correction() -> bool { + true +} +fn default_note_frame_limit() -> u32 { + 0 +} + +fn default_fade_top_px() -> u32 { + 50 +} + +fn default_reverse_fade_bottom_px() -> u32 { + 50 +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", default)] +pub struct NoteSettings { + // Legacy: 전역 노트 라운딩 (개별 키 noteBorderRadius로 마이그레이션됨) + #[serde(default, skip_serializing)] + pub border_radius: Option, + #[serde(default = "default_note_frame_limit")] + pub frame_limit: u32, + pub speed: u32, + pub track_height: u32, + pub reverse: bool, + pub fade_position: FadePosition, + #[serde(default = "default_fade_top_px")] + pub fade_top_px: u32, + #[serde(default)] + pub fade_bottom_px: u32, + #[serde(default)] + pub reverse_fade_top_px: u32, + #[serde(default = "default_reverse_fade_bottom_px")] + pub reverse_fade_bottom_px: u32, + pub delayed_note_enabled: bool, + pub short_note_threshold_ms: u32, + pub short_note_min_length_px: u32, + #[serde(default)] + pub key_display_delay_ms: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub enum FadePosition { + Auto, + Top, + Bottom, + None, + Both, +} + +/// 이미지 맞춤 설정 (CSS object-fit과 동일) +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "kebab-case")] +#[derive(Default)] +pub enum ImageFit { + #[default] + Cover, + Contain, + Fill, + None, +} + +impl Default for NoteSettings { + fn default() -> Self { + Self { + border_radius: None, + frame_limit: default_note_frame_limit(), + speed: 400, + track_height: 300, + reverse: false, + fade_position: FadePosition::Auto, + fade_top_px: 50, + fade_bottom_px: 0, + reverse_fade_top_px: 0, + reverse_fade_bottom_px: 50, + delayed_note_enabled: false, + short_note_threshold_ms: 50, + short_note_min_length_px: 30, + key_display_delay_ms: 0, + } + } +} + +impl NoteSettings { + /// Legacy migration: fadePosition enum → pixel-based fade values + /// serde defaults로 채워진 새 필드가 auto 기본값과 동일하고 + /// fadePosition이 non-auto면 레거시 store로 판단하여 변환 + pub fn migrate_fade_position(&mut self) { + let d = Self::default(); + let at_auto_defaults = self.fade_top_px == d.fade_top_px + && self.fade_bottom_px == d.fade_bottom_px + && self.reverse_fade_top_px == d.reverse_fade_top_px + && self.reverse_fade_bottom_px == d.reverse_fade_bottom_px; + + if !at_auto_defaults { + return; + } + + match self.fade_position { + FadePosition::Auto => {} + FadePosition::Top => { + // 항상 상단 페이드 + self.reverse_fade_top_px = d.fade_top_px; + self.reverse_fade_bottom_px = 0; + } + FadePosition::Bottom => { + // 항상 하단 페이드 + self.fade_top_px = 0; + self.fade_bottom_px = d.reverse_fade_bottom_px; + } + FadePosition::None => { + // 페이드 없음 + self.fade_top_px = 0; + self.reverse_fade_bottom_px = 0; + } + FadePosition::Both => { + // 양방향 페이드 + self.fade_bottom_px = d.fade_top_px; + self.reverse_fade_top_px = d.reverse_fade_bottom_px; + } + } + self.fade_position = FadePosition::Auto; + } +} + +impl TabNoteSettings { + /// Legacy migration: 탭 오버라이드의 fadePosition → pixel-based fade values + /// Option 기반이라 None = 필드 부재 확실 (heuristic 불필요) + pub fn migrate_fade_position(&mut self) { + let fp = match self.fade_position.take() { + Some(fp) => fp, + None => return, + }; + + let has_new_fields = self.fade_top_px.is_some() + || self.fade_bottom_px.is_some() + || self.reverse_fade_top_px.is_some() + || self.reverse_fade_bottom_px.is_some(); + + if has_new_fields { + return; // 이미 새 필드가 설정됨 + } + + let d = NoteSettings::default(); + match fp { + FadePosition::Auto => { + // 명시적 auto 오버라이드 → 전역과 무관하게 auto 동작 보장 + self.fade_top_px = Some(d.fade_top_px); + self.fade_bottom_px = Some(d.fade_bottom_px); + self.reverse_fade_top_px = Some(d.reverse_fade_top_px); + self.reverse_fade_bottom_px = Some(d.reverse_fade_bottom_px); + } + FadePosition::Top => { + self.fade_top_px = Some(d.fade_top_px); + self.fade_bottom_px = Some(0); + self.reverse_fade_top_px = Some(d.fade_top_px); + self.reverse_fade_bottom_px = Some(0); + } + FadePosition::Bottom => { + self.fade_top_px = Some(0); + self.fade_bottom_px = Some(d.reverse_fade_bottom_px); + self.reverse_fade_top_px = Some(0); + self.reverse_fade_bottom_px = Some(d.reverse_fade_bottom_px); + } + FadePosition::None => { + self.fade_top_px = Some(0); + self.fade_bottom_px = Some(0); + self.reverse_fade_top_px = Some(0); + self.reverse_fade_bottom_px = Some(0); + } + FadePosition::Both => { + self.fade_top_px = Some(d.fade_top_px); + self.fade_bottom_px = Some(d.fade_top_px); + self.reverse_fade_top_px = Some(d.reverse_fade_bottom_px); + self.reverse_fade_bottom_px = Some(d.reverse_fade_bottom_px); + } + } + } +} + +/// 탭별 노트 트랙 설정 (전역 NoteSettings를 탭별로 오버라이드) +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[derive(Default)] +pub struct TabNoteSettings { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub frame_limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub speed: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub track_height: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reverse: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fade_position: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fade_top_px: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fade_bottom_px: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reverse_fade_top_px: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reverse_fade_bottom_px: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub delayed_note_enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub short_note_threshold_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub short_note_min_length_px: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key_display_delay_ms: Option, +} + +/// 탭별 노트 트랙 설정 오버라이드 맵 (키: 탭 ID, 값: TabNoteSettings) +pub type TabNoteOverrides = HashMap; From 1b6024558dc3e140e213e44af59c10e7b1573f41 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 17:11:21 +0900 Subject: [PATCH 154/245] =?UTF-8?q?refactor:=20=EC=B9=B4=EC=9A=B4=ED=84=B0?= =?UTF-8?q?=20=EB=AA=A8=EC=85=98=20=EC=BB=A4=EB=B8=8C=20=EC=BA=94=EB=B2=84?= =?UTF-8?q?=EC=8A=A4=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CounterAnimationCurveCanvas.test.tsx | 118 +++++++++++ .../editors/CounterAnimationCurveCanvas.tsx | 190 ++++++++++++++++++ .../editors/CounterAnimationEditorModal.tsx | 169 ++-------------- 3 files changed, 324 insertions(+), 153 deletions(-) create mode 100644 src/renderer/components/main/Modal/content/editors/CounterAnimationCurveCanvas.test.tsx create mode 100644 src/renderer/components/main/Modal/content/editors/CounterAnimationCurveCanvas.tsx diff --git a/src/renderer/components/main/Modal/content/editors/CounterAnimationCurveCanvas.test.tsx b/src/renderer/components/main/Modal/content/editors/CounterAnimationCurveCanvas.test.tsx new file mode 100644 index 000000000..32301526a --- /dev/null +++ b/src/renderer/components/main/Modal/content/editors/CounterAnimationCurveCanvas.test.tsx @@ -0,0 +1,118 @@ +// @vitest-environment jsdom +import React, { act, createRef } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import CounterAnimationCurveCanvas from './CounterAnimationCurveCanvas'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const pointerEvent = () => { + const event = new MouseEvent('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + }); + Object.defineProperty(event, 'pointerId', { value: 1 }); + return event; +}; + +describe('CounterAnimationCurveCanvas', () => { + let host: HTMLDivElement; + let root: Root; + + beforeEach(() => { + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + }); + + afterEach(() => { + act(() => root.unmount()); + host.remove(); + }); + + it('SVG 구조·좌표·ref와 입력 핸들러 계약을 유지한다', () => { + const editorAreaRef = vi.fn(); + const svgRef = createRef(); + const onWheel = vi.fn(); + const onPointerDown = vi.fn(); + const onDoubleClick = vi.fn(); + const handleTargets: string[] = []; + + act(() => { + root.render( + { + handleTargets.push(target); + }} + />, + ); + }); + + const svg = host.querySelector( + '[data-counter-bezier-editor="true"]', + )!; + const area = svg.parentElement as HTMLDivElement; + expect(svgRef.current).toBe(svg); + expect(editorAreaRef).toHaveBeenCalledWith(area); + expect(svg.getAttribute('viewBox')).toBe('0 0 150 150'); + expect(svg.getAttribute('preserveAspectRatio')).toBe('xMidYMid meet'); + expect( + Array.from(svg.children).map((node) => node.tagName.toLowerCase()), + ).toEqual([ + 'rect', + 'path', + 'path', + 'rect', + 'line', + 'line', + 'line', + 'path', + 'circle', + 'circle', + 'circle', + 'circle', + ]); + + const p1 = svg.querySelector( + '[data-counter-bezier-handle="p1"]', + )!; + const p2 = svg.querySelector( + '[data-counter-bezier-handle="p2"]', + )!; + expect(p1.getAttribute('cx')).toBe('47.5'); + expect(p1.getAttribute('cy')).toBe('79.4'); + expect(Number(p1.getAttribute('r'))).toBeCloseTo((10 * 150) / 220); + expect(Number(p1.nextElementSibling!.getAttribute('r'))).toBeCloseTo( + (6 * 150) / 220, + ); + expect(p2.getAttribute('cx')).toBe('69.5'); + expect( + Number(p2.nextElementSibling!.getAttribute('stroke-width')), + ).toBeCloseTo((2 * 150) / 220); + + act(() => { + svg.dispatchEvent( + new WheelEvent('wheel', { bubbles: true, cancelable: true }), + ); + svg.dispatchEvent(pointerEvent()); + svg.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); + p1.dispatchEvent(pointerEvent()); + p2.dispatchEvent(pointerEvent()); + }); + + expect(onWheel).toHaveBeenCalledTimes(1); + expect(onPointerDown).toHaveBeenCalled(); + expect(onDoubleClick).toHaveBeenCalledTimes(1); + expect(handleTargets).toEqual(['p1', 'p2']); + }); +}); diff --git a/src/renderer/components/main/Modal/content/editors/CounterAnimationCurveCanvas.tsx b/src/renderer/components/main/Modal/content/editors/CounterAnimationCurveCanvas.tsx new file mode 100644 index 000000000..432a7d606 --- /dev/null +++ b/src/renderer/components/main/Modal/content/editors/CounterAnimationCurveCanvas.tsx @@ -0,0 +1,190 @@ +import React from 'react'; +import type { CounterAnimationBezier } from '@src/types/key/keys'; +import { + COUNTER_EDITOR_PADDING as EDITOR_PADDING, + COUNTER_EDITOR_SIZE as EDITOR_SIZE, + COUNTER_EDITOR_TOTAL_SIZE as TOTAL_SIZE, + COUNTER_GRID_PATH_MAJOR as GRID_PATH_MAJOR, + COUNTER_GRID_PATH_MINOR as GRID_PATH_MINOR, + resolveCounterEditorViewDimensions as viewDims, +} from './counterAnimationEditorModel'; + +// 캔버스 그리드 색 — 커브 에디터와 미리보기 스테이지가 공유 +// 흰색 알파 토큰이라 반투명 인셋 웰(글래스) 위에서 배경 톤을 따라 자연 합성됨 +const GRID_MAJOR_COLOR = 'var(--ui-line)'; +export const COUNTER_ANIMATION_GRID_MINOR_COLOR = 'var(--ui-line-faint)'; +export const COUNTER_ANIMATION_HANDLE_RADIUS = 6; +const HANDLE_HIT_RADIUS = 10; + +interface CounterAnimationCurveCanvasProps { + editorAreaRef: React.RefCallback; + svgRef: React.RefObject; + bezier: CounterAnimationBezier; + editorSize: { width: number; height: number }; + viewOffset: { x: number; y: number }; + viewScale: number; + onWheel: React.WheelEventHandler; + onPointerDown: React.PointerEventHandler; + onDoubleClick: React.MouseEventHandler; + onHandlePointerDown: ( + event: React.PointerEvent, + target: 'p1' | 'p2', + ) => void; +} + +const CounterAnimationCurveCanvas = ({ + editorAreaRef, + svgRef, + bezier, + editorSize, + viewOffset, + viewScale, + onWheel, + onPointerDown, + onDoubleClick, + onHandlePointerDown, +}: CounterAnimationCurveCanvasProps) => { + const P = EDITOR_PADDING; + const S = EDITOR_SIZE; + const p1w = { x: P + bezier[0] * S, y: P + (1 - bezier[1]) * S }; + const p2w = { x: P + bezier[2] * S, y: P + (1 - bezier[3]) * S }; + const startW = { x: P, y: P + S }; + const endW = { x: P + S, y: P }; + + const renderAspect = + editorSize.height > 0 ? editorSize.width / editorSize.height : 1; + const { base: vbBase, vbW, vbH } = viewDims(viewScale, renderAspect); + const viewLeft = viewOffset.x - (vbW - vbBase) / 2; + const viewTop = viewOffset.y - (vbH - vbBase) / 2; + const viewBoxStr = `${viewLeft} ${viewTop} ${vbW} ${vbH}`; + const ns = 1 / viewScale; + // 캔버스와 줌에 관계없이 기존 손잡이 화면 크기 유지 + const uns = + ns * + (TOTAL_SIZE / Math.max(Math.min(editorSize.width, editorSize.height), 1)); + + return ( +
+
+ + {/* 배경 웰은 컨테이너 div(bg-inset)가 소유 — 프레임별 좌표 갱신 제거 */} + {/* 커브 작업 사각형 — 좌표 영역이라 라운딩 없이 각을 유지 */} + + {/* crispEdges — CSS 그리드(미리보기)와 같은 또렷한 1px 라인 */} + + + + + + + + onHandlePointerDown(event, 'p1')} + /> + + onHandlePointerDown(event, 'p2')} + /> + + +
+
+ ); +}; + +export default CounterAnimationCurveCanvas; diff --git a/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.tsx b/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.tsx index 586179aec..849bc69e0 100644 --- a/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.tsx +++ b/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.tsx @@ -26,8 +26,6 @@ import { COUNTER_EDITOR_PADDING as EDITOR_PADDING, COUNTER_EDITOR_SIZE as EDITOR_SIZE, COUNTER_EDITOR_TOTAL_SIZE as TOTAL_SIZE, - COUNTER_GRID_PATH_MAJOR as GRID_PATH_MAJOR, - COUNTER_GRID_PATH_MINOR as GRID_PATH_MINOR, clampCounterDuration as clampDuration, createCounterAnimationEditorState as toInitialState, formatCounterBezierInput as formatBezierInput, @@ -36,6 +34,10 @@ import { parseCounterNumber as parseNumber, resolveCounterEditorViewDimensions as viewDims, } from './counterAnimationEditorModel'; +import CounterAnimationCurveCanvas, { + COUNTER_ANIMATION_GRID_MINOR_COLOR as GRID_MINOR_COLOR, + COUNTER_ANIMATION_HANDLE_RADIUS as HANDLE_RADIUS, +} from './CounterAnimationCurveCanvas'; import CounterAnimationPreviewStage from './CounterAnimationPreviewStage'; type EditorMode = 'create' | 'edit'; @@ -59,12 +61,6 @@ interface CounterAnimationEditorModalProps { type DragTarget = 'p1' | 'p2' | null; -// 캔버스 그리드 색 — 커브 에디터와 미리보기 스테이지가 공유 -// 흰색 알파 토큰이라 반투명 인셋 웰(글래스) 위에서 배경 톤을 따라 자연 합성됨 -const GRID_MAJOR_COLOR = 'var(--ui-line)'; -const GRID_MINOR_COLOR = 'var(--ui-line-faint)'; -const HANDLE_RADIUS = 6; -const HANDLE_HIT_RADIUS = 10; // 기준 렌더 크기 — 핸들·코너 화면 크기의 기준값 (실제 렌더는 캔버스 실측) // 뷰박스 세로는 TOTAL_SIZE 기준, 가로는 캔버스 종횡비만큼 넓어지는 풀블리드 캔버스 // 오프셋 (0,0) = 커브 정사각이 뷰 중앙, 포인터 수학은 비율 좌표라 크기 변화에 안전 @@ -785,25 +781,6 @@ const CounterAnimationEditorModal = ({ if (!isOpen) return null; - const P = EDITOR_PADDING; - const S = EDITOR_SIZE; - const p1w = { x: P + localBezier[0] * S, y: P + (1 - localBezier[1]) * S }; - const p2w = { x: P + localBezier[2] * S, y: P + (1 - localBezier[3]) * S }; - const startW = { x: P, y: P + S }; - const endW = { x: P + S, y: P }; - - const renderAspect = - editorSize.height > 0 ? editorSize.width / editorSize.height : 1; - const { base: vbBase, vbW, vbH } = viewDims(viewScale, renderAspect); - const viewLeft = viewOffset.x - (vbW - vbBase) / 2; - const viewTop = viewOffset.y - (vbH - vbBase) / 2; - const viewBoxStr = `${viewLeft} ${viewTop} ${vbW} ${vbH}`; - const ns = 1 / viewScale; - // 캔버스와 줌에 관계없이 기존 손잡이 화면 크기 유지 - const uns = - ns * - (TOTAL_SIZE / Math.max(Math.min(editorSize.width, editorSize.height), 1)); - const headerTitle = mode === 'edit' ? t('counterSetting.editAnimationTitle') || '모션 편집' @@ -843,132 +820,18 @@ const CounterAnimationEditorModal = ({
{/* 커브 캔버스 — 카드 내부를 통째로 채우는 풀블리드 캔버스 */} -
-
- - {/* 배경 웰은 컨테이너 div(bg-inset)가 소유 — 프레임별 좌표 갱신 제거 */} - {/* 커브 작업 사각형 — 좌표 영역이라 라운딩 없이 각을 유지 */} - - {/* crispEdges — CSS 그리드(미리보기)와 같은 또렷한 1px 라인 */} - - - - - - - - handlePointPointerDown(e, 'p1')} - /> - - handlePointPointerDown(e, 'p2')} - /> - - -
-
+ Date: Sun, 30 Aug 2026 17:21:15 +0900 Subject: [PATCH 155/245] =?UTF-8?q?test:=20=ED=94=8C=EB=9F=AC=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EB=8B=A8=EB=8F=85=20=EC=A0=9C=EA=B1=B0=20=EC=A0=95?= =?UTF-8?q?=EC=82=B0=20=EA=B3=84=EC=95=BD=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../settingsPluginLifecycleController.test.ts | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/renderer/components/main/settingsPluginLifecycleController.test.ts b/src/renderer/components/main/settingsPluginLifecycleController.test.ts index 59eb2039e..cc4fbea99 100644 --- a/src/renderer/components/main/settingsPluginLifecycleController.test.ts +++ b/src/renderer/components/main/settingsPluginLifecycleController.test.ts @@ -230,6 +230,60 @@ describe('Settings 플러그인 생명주기 controller', () => { ); }); + it('플러그인만 제거하는 동안 중복 요청을 막고 성공 뒤 상태를 정산한다', async () => { + let resolveRemove: ((result: { success: boolean }) => void) | undefined; + mocks.remove.mockImplementation( + () => + new Promise<{ success: boolean }>((resolve) => { + resolveRemove = resolve; + }), + ); + const harness = createHarness(); + + const first = harness.removePluginOnly('alpha-id'); + await harness.removePluginOnly('beta-id'); + + expect(mocks.remove).toHaveBeenCalledTimes(1); + expect(mocks.remove).toHaveBeenCalledWith('alpha-id'); + expect(harness.removingPluginRef.current).toBe('alpha-id'); + expect(harness.setPendingPluginId).toHaveBeenCalledTimes(1); + expect(harness.setPendingPluginId).toHaveBeenCalledWith('alpha-id'); + + resolveRemove?.({ success: true }); + await first; + + expect(harness.showAlert).not.toHaveBeenCalled(); + expect(harness.removingPluginRef.current).toBeNull(); + expect(harness.pendingPluginRef.current).toBeNull(); + expect(harness.setPendingPluginId.mock.calls).toEqual([ + ['alpha-id'], + [null], + ]); + expect(harness.setDataDeleteModalOpen).toHaveBeenCalledWith(false); + expect(harness.setPluginToDelete).toHaveBeenCalledWith(null); + }); + + it('플러그인만 제거에 실패해도 경고하고 상태를 정산한다', async () => { + mocks.remove.mockResolvedValue({ success: false }); + const harness = createHarness(); + + await harness.removePluginOnly('alpha-id'); + + expect(mocks.remove).toHaveBeenCalledTimes(1); + expect(mocks.remove).toHaveBeenCalledWith('alpha-id'); + expect(harness.showAlert).toHaveBeenCalledWith( + 'settings.jsPluginRemoveFailed', + ); + expect(harness.removingPluginRef.current).toBeNull(); + expect(harness.pendingPluginRef.current).toBeNull(); + expect(harness.setPendingPluginId.mock.calls).toEqual([ + ['alpha-id'], + [null], + ]); + expect(harness.setDataDeleteModalOpen).toHaveBeenCalledWith(false); + expect(harness.setPluginToDelete).toHaveBeenCalledWith(null); + }); + it('데이터 포함 제거는 remove 정산 뒤 prefix를 지우고 실패여도 모달 상태를 닫는다', async () => { const order: string[] = []; mocks.remove.mockImplementation(async () => { From eb0660098604c6084ad511cce9a8b26805e29982 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 17:24:42 +0900 Subject: [PATCH 156/245] =?UTF-8?q?test:=20OBS=20=EC=A0=84=EC=86=A1=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=84=20=EA=B3=84=EC=95=BD=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/services/obs_bridge/tests.rs | 112 +++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/src-tauri/src/services/obs_bridge/tests.rs b/src-tauri/src/services/obs_bridge/tests.rs index 7100391d6..0768e2652 100644 --- a/src-tauri/src/services/obs_bridge/tests.rs +++ b/src-tauri/src/services/obs_bridge/tests.rs @@ -3,6 +3,18 @@ use tokio_tungstenite::{connect_async, MaybeTlsStream}; type TestWebSocket = WebSocketStream>; +async fn tcp_pair() -> (TcpStream, TcpStream) { + let listener = TcpListener::bind(("127.0.0.1", 0)) + .await + .expect("TCP listener 바인딩 실패"); + let address = listener.local_addr().expect("TCP listener 주소 조회 실패"); + let (client, server) = tokio::join!(TcpStream::connect(address), listener.accept()); + ( + client.expect("TCP client 연결 실패"), + server.expect("TCP server accept 실패").0, + ) +} + async fn receive_envelope(ws: &mut TestWebSocket, expected_type: &str) -> ObsEnvelope { tokio::time::timeout(Duration::from_secs(2), async { loop { @@ -314,6 +326,106 @@ fn http_host_validation_rejects_missing_or_duplicate_headers() { )); } +#[test] +fn http_header_parser_stops_at_the_first_empty_line_and_preserves_upgrade_tokens() { + let headers = + "GET / HTTP/1.1\r\nhOsT: localhost:34891\r\nUpgrade: keep-alive, WebSocket\r\n\r\n"; + let request = format!("{headers}Host: example.com\r\n"); + + assert_eq!(http_header_end(request.as_bytes()), Some(headers.len())); + assert_eq!( + http_header_values(&request, "host"), + vec!["localhost:34891"] + ); + assert!(is_websocket_upgrade_request(&request)); + assert_eq!(http_header_end(b"GET / HTTP/1.1\n\n"), None); +} + +#[test] +fn websocket_validation_reports_host_before_origin_and_uses_forbidden_status() { + let invalid_host_and_origin = WsRequest::builder() + .header(header::HOST, "example.com") + .header(header::ORIGIN, "https://example.com") + .body(()) + .unwrap(); + assert_eq!( + validate_websocket_request(&invalid_host_and_origin), + Err("Invalid Host header") + ); + + let duplicate_origin = WsRequest::builder() + .header(header::HOST, "localhost:34891") + .header(header::ORIGIN, "http://localhost:3400") + .header(header::ORIGIN, "http://127.0.0.1:3400") + .body(()) + .unwrap(); + assert_eq!( + validate_websocket_request(&duplicate_origin), + Err("Invalid Origin header") + ); + + let response = websocket_forbidden_response("Invalid Host header"); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(response.body().as_deref(), Some("Invalid Host header")); +} + +#[tokio::test] +async fn prefixed_stream_replays_partial_prefix_before_socket_bytes() { + let (mut client, server) = tcp_pair().await; + client + .write_all(b"socket") + .await + .expect("socket 바이트 전송 실패"); + + let mut stream = PrefixedStream::new(b"header".to_vec(), server); + let mut first = [0u8; 2]; + stream + .read_exact(&mut first) + .await + .expect("prefix 첫 조각 수신 실패"); + assert_eq!(&first, b"he"); + + let mut remaining = [0u8; 10]; + stream + .read_exact(&mut remaining) + .await + .expect("prefix와 socket 바이트 수신 실패"); + assert_eq!(&remaining, b"adersocket"); +} + +#[tokio::test] +async fn transport_io_preserves_incomplete_header_error_and_empty_response_bytes() { + let (mut incomplete_client, mut incomplete_server) = tcp_pair().await; + incomplete_client + .write_all(b"GET / HTTP/1.1\r\nHost: localhost") + .await + .expect("미완성 header 전송 실패"); + incomplete_client + .shutdown() + .await + .expect("미완성 header 연결 종료 실패"); + + let error = read_http_request_headers(&mut incomplete_server) + .await + .expect_err("미완성 header가 허용됨"); + assert_eq!(error.kind(), std::io::ErrorKind::UnexpectedEof); + assert_eq!(error.to_string(), "incomplete HTTP request headers"); + + let (mut response_client, mut response_server) = tcp_pair().await; + write_empty_http_response(&mut response_server, "400 Bad Request").await; + drop(response_server); + + let mut response = Vec::new(); + response_client + .read_to_end(&mut response) + .await + .expect("빈 HTTP 응답 수신 실패"); + assert_eq!( + response, + b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ); +} + #[test] fn websocket_origin_allows_absence_or_local_machine_hosts_only() { let no_origin = WsRequest::builder() From 1bcd5553df86c24f1eaa1fa3bcb290add85e29d1 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 17:24:42 +0900 Subject: [PATCH 157/245] =?UTF-8?q?refactor:=20OBS=20=EC=A0=84=EC=86=A1=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=86=A0=EC=BD=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/services/obs_bridge.rs | 245 ++---------------- .../src/services/obs_bridge/transport.rs | 234 +++++++++++++++++ 2 files changed, 250 insertions(+), 229 deletions(-) create mode 100644 src-tauri/src/services/obs_bridge/transport.rs diff --git a/src-tauri/src/services/obs_bridge.rs b/src-tauri/src/services/obs_bridge.rs index 40817c7ae..23f585778 100644 --- a/src-tauri/src/services/obs_bridge.rs +++ b/src-tauri/src/services/obs_bridge.rs @@ -1,10 +1,7 @@ -use std::io::Cursor; -use std::net::{IpAddr, SocketAddr}; +use std::net::SocketAddr; use std::path::PathBuf; -use std::pin::Pin; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::Arc; -use std::task::{Context, Poll}; use std::time::Duration; use futures_util::{SinkExt, StreamExt}; @@ -13,14 +10,17 @@ use serde_json::Value; use tauri::ipc::{CallbackFn, InvokeBody, InvokeResponse, InvokeResponseBody}; use tauri::webview::InvokeRequest; use tauri::{AppHandle, Manager, Wry}; -use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf}; +#[cfg(test)] +use tokio::io::AsyncReadExt; +use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::{broadcast, oneshot}; +#[cfg(test)] +use tokio_tungstenite::tungstenite::http::{header, StatusCode}; use tokio_tungstenite::{ accept_hdr_async, tungstenite::{ - handshake::server::{ErrorResponse, Request as WsRequest, Response as WsResponse}, - http::{header, StatusCode}, + handshake::server::{Request as WsRequest, Response as WsResponse}, Message, }, WebSocketStream, @@ -31,7 +31,15 @@ use crate::models::obs::{ OBS_PROTOCOL_VERSION, }; -const MAX_HTTP_HEADER_SIZE: usize = 16 * 1024; +mod transport; + +use transport::{ + bind_address, has_allowed_http_host, http_header_end, is_local_machine_ip, + is_websocket_upgrade_request, read_http_request_headers, validate_websocket_request, + websocket_forbidden_response, write_empty_http_response, PrefixedStream, +}; +#[cfg(test)] +use transport::{http_header_values, is_allowed_host_header}; /// OBS 클라이언트에서 실행 가능한 커맨드 목록 const ALLOWED_WS_COMMANDS: &[&str] = &[ @@ -125,227 +133,6 @@ fn build_allowed_list() -> Vec { .collect() } -struct PrefixedStream { - prefix: Cursor>, - stream: TcpStream, -} - -impl PrefixedStream { - fn new(prefix: Vec, stream: TcpStream) -> Self { - Self { - prefix: Cursor::new(prefix), - stream, - } - } -} - -impl AsyncRead for PrefixedStream { - fn poll_read( - self: Pin<&mut Self>, - cx: &mut Context<'_>, - buffer: &mut ReadBuf<'_>, - ) -> Poll> { - let this = self.get_mut(); - let position = this.prefix.position() as usize; - let prefix = this.prefix.get_ref(); - if position < prefix.len() { - let count = (prefix.len() - position).min(buffer.remaining()); - buffer.put_slice(&prefix[position..position + count]); - this.prefix.set_position((position + count) as u64); - return Poll::Ready(Ok(())); - } - Pin::new(&mut this.stream).poll_read(cx, buffer) - } -} - -impl AsyncWrite for PrefixedStream { - fn poll_write( - self: Pin<&mut Self>, - cx: &mut Context<'_>, - buffer: &[u8], - ) -> Poll> { - Pin::new(&mut self.get_mut().stream).poll_write(cx, buffer) - } - - fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - Pin::new(&mut self.get_mut().stream).poll_flush(cx) - } - - fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - Pin::new(&mut self.get_mut().stream).poll_shutdown(cx) - } -} - -async fn read_http_request_headers(stream: &mut TcpStream) -> std::io::Result> { - let mut request = Vec::with_capacity(1024); - let mut chunk = [0u8; 1024]; - - loop { - if request.len() == MAX_HTTP_HEADER_SIZE { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "HTTP request headers too large", - )); - } - - let remaining = MAX_HTTP_HEADER_SIZE - request.len(); - let chunk_len = remaining.min(chunk.len()); - let read = stream.read(&mut chunk[..chunk_len]).await?; - if read == 0 { - return Err(std::io::Error::new( - std::io::ErrorKind::UnexpectedEof, - "incomplete HTTP request headers", - )); - } - request.extend_from_slice(&chunk[..read]); - - if http_header_end(&request).is_some() { - return Ok(request); - } - } -} - -fn http_header_end(request: &[u8]) -> Option { - request - .windows(4) - .position(|window| window == b"\r\n\r\n") - .map(|position| position + 4) -} - -fn http_header_values<'a>(request: &'a str, name: &str) -> Vec<&'a str> { - request - .lines() - .skip(1) - .take_while(|line| !line.trim_end_matches('\r').is_empty()) - .filter_map(|line| line.trim_end_matches('\r').split_once(':')) - .filter_map(|(header_name, value)| { - header_name - .eq_ignore_ascii_case(name) - .then_some(value.trim()) - }) - .collect() -} - -fn is_websocket_upgrade_request(request: &str) -> bool { - http_header_values(request, "upgrade").iter().any(|value| { - value - .split(',') - .any(|token| token.trim().eq_ignore_ascii_case("websocket")) - }) -} - -fn is_allowed_host_name(host: &str) -> bool { - if host.eq_ignore_ascii_case("localhost") { - return true; - } - - let ip_literal = host - .strip_prefix('[') - .and_then(|value| value.strip_suffix(']')) - .unwrap_or(host); - ip_literal.parse::().is_ok() -} - -fn is_allowed_host_header(value: &str) -> bool { - let value = value.trim(); - if value.is_empty() || value.bytes().any(|byte| byte.is_ascii_whitespace()) { - return false; - } - if is_allowed_host_name(value) { - return true; - } - - if let Some(closing_bracket) = value.find(']') { - let (host, suffix) = value.split_at(closing_bracket + 1); - return is_allowed_host_name(host) - && suffix - .strip_prefix(':') - .is_some_and(|port| port.parse::().is_ok()); - } - - value - .rsplit_once(':') - .is_some_and(|(host, port)| is_allowed_host_name(host) && port.parse::().is_ok()) -} - -/// 이 머신 자신의 IP인지 판정 (loopback 또는 로컬 인터페이스에 실제 할당된 IP) -fn is_local_machine_ip(ip: &IpAddr) -> bool { - if ip.is_loopback() { - return true; - } - local_ip_address::list_afinet_netifas() - .map(|interfaces| { - interfaces - .iter() - .any(|(_, interface_ip)| interface_ip == ip) - }) - .unwrap_or(false) -} - -/// Origin은 브라우저가 강제하는 헤더 — 이 머신이 서빙한 페이지만 신뢰 -/// (localhost/loopback 또는 로컬 인터페이스에 실제 할당된 IP만 허용, OWASP 권고 allowlist) -fn is_local_machine_host(host: &str) -> bool { - if host.eq_ignore_ascii_case("localhost") { - return true; - } - - let ip_literal = host - .strip_prefix('[') - .and_then(|value| value.strip_suffix(']')) - .unwrap_or(host); - let Ok(ip) = ip_literal.parse::() else { - return false; - }; - is_local_machine_ip(&ip) -} - -fn is_allowed_origin(value: &str) -> bool { - tauri::Url::parse(value) - .ok() - .and_then(|url| url.host_str().map(str::to_string)) - .is_some_and(|host| is_local_machine_host(&host)) -} - -fn has_allowed_http_host(request: &str) -> bool { - let hosts = http_header_values(request, "host"); - hosts.len() == 1 && is_allowed_host_header(hosts[0]) -} - -fn validate_websocket_request(request: &WsRequest) -> Result<(), &'static str> { - let hosts: Vec<_> = request.headers().get_all(header::HOST).iter().collect(); - if hosts.len() != 1 || !hosts[0].to_str().ok().is_some_and(is_allowed_host_header) { - return Err("Invalid Host header"); - } - - let origins: Vec<_> = request.headers().get_all(header::ORIGIN).iter().collect(); - if origins.len() > 1 - || origins - .first() - .is_some_and(|origin| !origin.to_str().ok().is_some_and(is_allowed_origin)) - { - return Err("Invalid Origin header"); - } - - Ok(()) -} - -fn websocket_forbidden_response(reason: &str) -> ErrorResponse { - let mut response = ErrorResponse::new(Some(reason.to_string())); - *response.status_mut() = StatusCode::FORBIDDEN; - response -} - -// OBS 모드는 같은 네트워크의 다른 PC 접속이 제품 계약 — 항상 전체 인터페이스에 바인딩 -// (WS/미디어는 토큰 인증, 커맨드는 allowlist로 보호) -fn bind_address(port: u16) -> SocketAddr { - SocketAddr::from(([0, 0, 0, 0], port)) -} - -async fn write_empty_http_response(stream: &mut TcpStream, status: &str) { - let response = format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); - let _ = stream.write_all(response.as_bytes()).await; -} - /// 임베딩 에셋 조회 함수 타입 (path → Option<(bytes, mime_type)>) pub type AssetFetcher = Arc Option<(Vec, String)> + Send + Sync>; diff --git a/src-tauri/src/services/obs_bridge/transport.rs b/src-tauri/src/services/obs_bridge/transport.rs new file mode 100644 index 000000000..fc641cc0f --- /dev/null +++ b/src-tauri/src/services/obs_bridge/transport.rs @@ -0,0 +1,234 @@ +use std::io::Cursor; +use std::net::{IpAddr, SocketAddr}; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf}; +use tokio::net::TcpStream; +use tokio_tungstenite::tungstenite::{ + handshake::server::{ErrorResponse, Request as WsRequest}, + http::{header, StatusCode}, +}; + +const MAX_HTTP_HEADER_SIZE: usize = 16 * 1024; + +pub(super) struct PrefixedStream { + prefix: Cursor>, + stream: TcpStream, +} + +impl PrefixedStream { + pub(super) fn new(prefix: Vec, stream: TcpStream) -> Self { + Self { + prefix: Cursor::new(prefix), + stream, + } + } +} + +impl AsyncRead for PrefixedStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buffer: &mut ReadBuf<'_>, + ) -> Poll> { + let this = self.get_mut(); + let position = this.prefix.position() as usize; + let prefix = this.prefix.get_ref(); + if position < prefix.len() { + let count = (prefix.len() - position).min(buffer.remaining()); + buffer.put_slice(&prefix[position..position + count]); + this.prefix.set_position((position + count) as u64); + return Poll::Ready(Ok(())); + } + Pin::new(&mut this.stream).poll_read(cx, buffer) + } +} + +impl AsyncWrite for PrefixedStream { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buffer: &[u8], + ) -> Poll> { + Pin::new(&mut self.get_mut().stream).poll_write(cx, buffer) + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().stream).poll_flush(cx) + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().stream).poll_shutdown(cx) + } +} + +pub(super) async fn read_http_request_headers(stream: &mut TcpStream) -> std::io::Result> { + let mut request = Vec::with_capacity(1024); + let mut chunk = [0u8; 1024]; + + loop { + if request.len() == MAX_HTTP_HEADER_SIZE { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "HTTP request headers too large", + )); + } + + let remaining = MAX_HTTP_HEADER_SIZE - request.len(); + let chunk_len = remaining.min(chunk.len()); + let read = stream.read(&mut chunk[..chunk_len]).await?; + if read == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "incomplete HTTP request headers", + )); + } + request.extend_from_slice(&chunk[..read]); + + if http_header_end(&request).is_some() { + return Ok(request); + } + } +} + +pub(super) fn http_header_end(request: &[u8]) -> Option { + request + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|position| position + 4) +} + +pub(super) fn http_header_values<'a>(request: &'a str, name: &str) -> Vec<&'a str> { + request + .lines() + .skip(1) + .take_while(|line| !line.trim_end_matches('\r').is_empty()) + .filter_map(|line| line.trim_end_matches('\r').split_once(':')) + .filter_map(|(header_name, value)| { + header_name + .eq_ignore_ascii_case(name) + .then_some(value.trim()) + }) + .collect() +} + +pub(super) fn is_websocket_upgrade_request(request: &str) -> bool { + http_header_values(request, "upgrade").iter().any(|value| { + value + .split(',') + .any(|token| token.trim().eq_ignore_ascii_case("websocket")) + }) +} + +fn is_allowed_host_name(host: &str) -> bool { + if host.eq_ignore_ascii_case("localhost") { + return true; + } + + let ip_literal = host + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + .unwrap_or(host); + ip_literal.parse::().is_ok() +} + +pub(super) fn is_allowed_host_header(value: &str) -> bool { + let value = value.trim(); + if value.is_empty() || value.bytes().any(|byte| byte.is_ascii_whitespace()) { + return false; + } + if is_allowed_host_name(value) { + return true; + } + + if let Some(closing_bracket) = value.find(']') { + let (host, suffix) = value.split_at(closing_bracket + 1); + return is_allowed_host_name(host) + && suffix + .strip_prefix(':') + .is_some_and(|port| port.parse::().is_ok()); + } + + value + .rsplit_once(':') + .is_some_and(|(host, port)| is_allowed_host_name(host) && port.parse::().is_ok()) +} + +/// 이 머신 자신의 IP인지 판정 (loopback 또는 로컬 인터페이스에 실제 할당된 IP) +pub(super) fn is_local_machine_ip(ip: &IpAddr) -> bool { + if ip.is_loopback() { + return true; + } + local_ip_address::list_afinet_netifas() + .map(|interfaces| { + interfaces + .iter() + .any(|(_, interface_ip)| interface_ip == ip) + }) + .unwrap_or(false) +} + +/// Origin은 브라우저가 강제하는 헤더 — 이 머신이 서빙한 페이지만 신뢰 +/// (localhost/loopback 또는 로컬 인터페이스에 실제 할당된 IP만 허용, OWASP 권고 allowlist) +fn is_local_machine_host(host: &str) -> bool { + if host.eq_ignore_ascii_case("localhost") { + return true; + } + + let ip_literal = host + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + .unwrap_or(host); + let Ok(ip) = ip_literal.parse::() else { + return false; + }; + is_local_machine_ip(&ip) +} + +fn is_allowed_origin(value: &str) -> bool { + tauri::Url::parse(value) + .ok() + .and_then(|url| url.host_str().map(str::to_string)) + .is_some_and(|host| is_local_machine_host(&host)) +} + +pub(super) fn has_allowed_http_host(request: &str) -> bool { + let hosts = http_header_values(request, "host"); + hosts.len() == 1 && is_allowed_host_header(hosts[0]) +} + +pub(super) fn validate_websocket_request(request: &WsRequest) -> Result<(), &'static str> { + let hosts: Vec<_> = request.headers().get_all(header::HOST).iter().collect(); + if hosts.len() != 1 || !hosts[0].to_str().ok().is_some_and(is_allowed_host_header) { + return Err("Invalid Host header"); + } + + let origins: Vec<_> = request.headers().get_all(header::ORIGIN).iter().collect(); + if origins.len() > 1 + || origins + .first() + .is_some_and(|origin| !origin.to_str().ok().is_some_and(is_allowed_origin)) + { + return Err("Invalid Origin header"); + } + + Ok(()) +} + +pub(super) fn websocket_forbidden_response(reason: &str) -> ErrorResponse { + let mut response = ErrorResponse::new(Some(reason.to_string())); + *response.status_mut() = StatusCode::FORBIDDEN; + response +} + +// OBS 모드는 같은 네트워크의 다른 PC 접속이 제품 계약 — 항상 전체 인터페이스에 바인딩 +// (WS/미디어는 토큰 인증, 커맨드는 allowlist로 보호) +pub(super) fn bind_address(port: u16) -> SocketAddr { + SocketAddr::from(([0, 0, 0, 0], port)) +} + +pub(super) async fn write_empty_http_response(stream: &mut TcpStream, status: &str) { + let response = format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + let _ = stream.write_all(response.as_bytes()).await; +} From 1de5c4308767047f435ee6c36f7097c3029a6ab5 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 17:27:45 +0900 Subject: [PATCH 158/245] =?UTF-8?q?test:=20=EA=B7=B8=EB=9E=98=EB=94=94?= =?UTF-8?q?=EC=96=B8=ED=8A=B8=20=EC=B6=95=20=EB=93=9C=EB=9E=98=EA=B7=B8=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=84=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/gradientAxisHandle.test.tsx | 224 +++++++++++++++++- 1 file changed, 222 insertions(+), 2 deletions(-) diff --git a/src/renderer/__tests__/gradientAxisHandle.test.tsx b/src/renderer/__tests__/gradientAxisHandle.test.tsx index c0a9b9fff..999a0a313 100644 --- a/src/renderer/__tests__/gradientAxisHandle.test.tsx +++ b/src/renderer/__tests__/gradientAxisHandle.test.tsx @@ -8,8 +8,9 @@ import React, { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import GradientAxisOverlay from '@components/main/Grid/handles/GradientAxisHandle'; +import { useCommittedApplyStore } from '@stores/data/useCommittedApplyStore'; import { useGradientEditStore } from '@stores/grid/useGradientEditStore'; -import type { GradientSpec } from '@src/types/color'; +import { GRADIENT_STOPS_MAX, type GradientSpec } from '@src/types/color'; vi.mock('@contexts/useTranslation', () => ({ useTranslation: () => ({ t: (key: string) => key }), @@ -45,6 +46,9 @@ const pointerEvent = ( clientX: number; clientY: number; button?: number; + buttons?: number; + ctrlKey?: boolean; + metaKey?: boolean; }, ) => { const event = new MouseEvent(type, { @@ -54,7 +58,11 @@ const pointerEvent = ( clientY: init.clientY, button: init.button ?? 0, // 드래그 중 버튼 유지 — buttons 0은 stale 드래그로 간주돼 취소됨 - buttons: type === 'pointerup' || type === 'pointercancel' ? 0 : 1, + buttons: + init.buttons ?? + (type === 'pointerup' || type === 'pointercancel' ? 0 : 1), + ctrlKey: init.ctrlKey, + metaKey: init.metaKey, }); Object.defineProperty(event, 'pointerId', { value: init.pointerId }); return event; @@ -352,6 +360,46 @@ describe('GradientAxisOverlay 드래그 로직', () => { expect(finalSpec.angle).toBe(0); }); + it('모서리 자석은 기본 적용되고 Ctrl/Cmd 중에는 해제된다', () => { + const dragNearCorner = (modifier?: 'ctrlKey' | 'metaKey') => { + act(() => { + axisAnchor('end').dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 30, + clientX: 300, + clientY: 150, + }), + ); + window.dispatchEvent( + pointerEvent('pointermove', { + pointerId: 30, + clientX: 290, + clientY: 110, + ...(modifier ? { [modifier]: true } : {}), + }), + ); + }); + const preview = apply.mock.calls.at(-1)?.[0]; + act(() => { + window.dispatchEvent( + pointerEvent('pointerup', { + pointerId: 30, + clientX: 290, + clientY: 110, + ...(modifier ? { [modifier]: true } : {}), + }), + ); + }); + return preview?.angle; + }; + + expect(dragNearCorner()).toBe(63); + apply.mockClear(); + expect(dragNearCorner('ctrlKey')).toBe(66); + apply.mockClear(); + expect(dragNearCorner('metaKey')).toBe(66); + }); + it('스톱 점을 끌면 축에 사영된 pos만 바뀌고 각도는 불변이다', () => { act(() => { stopDot(1).dispatchEvent( @@ -508,6 +556,82 @@ describe('GradientAxisOverlay 드래그 로직', () => { ); }); + it('history 반영은 진행 중 드래그를 롤백·커밋 없이 종료한다', () => { + act(() => { + axisAnchor('end').dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 31, + clientX: 300, + clientY: 150, + }), + ); + window.dispatchEvent( + pointerEvent('pointermove', { + pointerId: 31, + clientX: 200, + clientY: 60, + }), + ); + }); + expect(apply).toHaveBeenLastCalledWith( + expect.objectContaining({ angle: 0 }), + false, + ); + const callCountBeforeHistory = apply.mock.calls.length; + + act(() => useCommittedApplyStore.getState().bump('historyUndo')); + expect(apply).toHaveBeenCalledTimes(callCountBeforeHistory); + expect(cancel).not.toHaveBeenCalled(); + expect(document.documentElement.classList.contains('dmn-drag-cursor')).toBe( + false, + ); + + act(() => { + window.dispatchEvent( + pointerEvent('pointerup', { + pointerId: 31, + clientX: 200, + clientY: 60, + }), + ); + }); + expect(apply).toHaveBeenCalledTimes(callCountBeforeHistory); + }); + + it('buttons=0 move는 stale 드래그를 시작 spec으로 복원하고 종료한다', () => { + act(() => { + axisAnchor('end').dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 32, + clientX: 300, + clientY: 150, + }), + ); + window.dispatchEvent( + pointerEvent('pointermove', { + pointerId: 32, + clientX: 200, + clientY: 60, + buttons: 0, + }), + ); + }); + + expect(apply).toHaveBeenLastCalledWith(SPEC, false); + expect(cancel).toHaveBeenCalledOnce(); + const callCountAfterCancel = apply.mock.calls.length; + act(() => { + window.dispatchEvent( + pointerEvent('pointerup', { + pointerId: 32, + clientX: 200, + clientY: 60, + }), + ); + }); + expect(apply).toHaveBeenCalledTimes(callCountAfterCancel); + }); + it('축 선을 클릭하면 그 위치에 스톱이 추가된다', () => { act(() => { strip().dispatchEvent( @@ -531,6 +655,57 @@ describe('GradientAxisOverlay 드래그 로직', () => { expect(selectStop).toHaveBeenCalledWith(1); }); + it('최소 스톱은 삭제하지 않고 최대 스톱에는 추가하지 않는다', () => { + act(() => { + stopDot(1).dispatchEvent( + new MouseEvent('contextmenu', { bubbles: true, cancelable: true }), + ); + }); + expect(apply).not.toHaveBeenCalled(); + + const maxSpec: GradientSpec = { + angle: 90, + stops: Array.from({ length: GRADIENT_STOPS_MAX }, (_, index) => ({ + color: `#00000${index}`, + pos: index / (GRADIENT_STOPS_MAX - 1), + })), + }; + act(() => { + useGradientEditStore + .getState() + .patchSession('key:4key:0:backgroundColor:idle', { + spec: maxSpec, + selectedIndex: 3, + }); + }); + act(() => { + strip().dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 33, + clientX: 240, + clientY: 150, + }), + ); + window.dispatchEvent( + pointerEvent('pointerup', { + pointerId: 33, + clientX: 240, + clientY: 150, + }), + ); + }); + expect(apply).not.toHaveBeenCalled(); + + act(() => { + stopDot(4).dispatchEvent( + new MouseEvent('contextmenu', { bubbles: true, cancelable: true }), + ); + }); + const [deletedSpec, deletedCommit] = apply.mock.calls.at(-1)!; + expect(deletedCommit).toBe(true); + expect(deletedSpec.stops).toHaveLength(GRADIENT_STOPS_MAX - 1); + }); + it('키보드 화살표로 각도가 커밋된다', () => { act(() => { strip().dispatchEvent( @@ -806,6 +981,51 @@ describe('GradientAxisOverlay 드래그 로직', () => { expect(strip().style.top).toBe('150px'); }); + it('등록 앵커 bounds는 요소 저장 좌표 이동 델타를 추종한다', () => { + act(() => { + useGradientEditStore + .getState() + .setAnchorBounds( + 'key:4key:0:backgroundColor:idle', + { x: 10, y: 20, width: 40, height: 30 }, + { x: 100, y: 100 }, + ); + }); + expect(strip().style.left).toBe('30px'); + expect(strip().style.top).toBe('35px'); + + act(() => { + root.render( + , + ); + }); + expect(strip().style.left).toBe('50px'); + expect(strip().style.top).toBe('65px'); + }); + it('A→B→새 A 왕복 후 pointercancel도 새 세션에 stale 롤백을 보내지 않는다', () => { act(() => { strip().dispatchEvent( From 3fbd4f903f4e126b22fc7836767b9a22f7a46e16 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 17:27:45 +0900 Subject: [PATCH 159/245] =?UTF-8?q?refactor:=20=EA=B7=B8=EB=9E=98=EB=94=94?= =?UTF-8?q?=EC=96=B8=ED=8A=B8=20=EC=B6=95=20=EA=B8=B0=ED=95=98=EC=99=80=20?= =?UTF-8?q?=EB=93=9C=EB=9E=98=EA=B7=B8=20=EC=84=B8=EC=85=98=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/Grid/handles/GradientAxisHandle.tsx | 704 +----------------- .../main/Grid/handles/gradientAxisGeometry.ts | 277 +++++++ .../handles/useGradientAxisDragSession.ts | 559 ++++++++++++++ 3 files changed, 877 insertions(+), 663 deletions(-) create mode 100644 src/renderer/components/main/Grid/handles/gradientAxisGeometry.ts create mode 100644 src/renderer/components/main/Grid/handles/useGradientAxisDragSession.ts diff --git a/src/renderer/components/main/Grid/handles/GradientAxisHandle.tsx b/src/renderer/components/main/Grid/handles/GradientAxisHandle.tsx index 7a5853fd5..b2bc01882 100644 --- a/src/renderer/components/main/Grid/handles/GradientAxisHandle.tsx +++ b/src/renderer/components/main/Grid/handles/GradientAxisHandle.tsx @@ -1,25 +1,18 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect } from 'react'; import { useTranslation } from '@contexts/useTranslation'; -import { - supportsActiveVisualState, - useGradientEditStore, -} from '@stores/grid/useGradientEditStore'; -import { - toCanonicalGradient, - GRADIENT_STOPS_MAX, - type GradientSpec, -} from '@src/types/color'; +import { useGradientEditStore } from '@stores/grid/useGradientEditStore'; import type { KeyPosition } from '@src/types/key/keys'; import type { StatItemPosition } from '@src/types/key/statItems'; import type { GraphItemPosition } from '@src/types/key/graphItems'; import type { KnobItemPosition } from '@src/types/key/knobs'; import type { SelectedElement } from '@stores/grid/useGridSelectionStore'; +import type { ContinuousInputStrategy } from '@utils/animation/rafLatestScheduler'; import { - createRafLatestScheduler, - type ContinuousInputStrategy, -} from '@utils/animation/rafLatestScheduler'; -import { beginDragCursor, endDragCursor } from '@utils/core/dragCursor'; -import { useCommittedApplyStore } from '@stores/data/useCommittedApplyStore'; + gradientAxisStopPoint, + resolveGradientAxisBounds, + type GradientAxisEnd, +} from './gradientAxisGeometry'; +import { useGradientAxisDragSession } from './useGradientAxisDragSession'; /** * 온캔버스 그라데이션 축 - 피커가 그라데이션 형식으로 열려 있는 동안 @@ -30,13 +23,6 @@ import { useCommittedApplyStore } from '@stores/data/useCommittedApplyStore'; * 모서리·변 중앙 방향 자석 스냅 기본, Ctrl/Cmd를 누르면 스냅 해제 */ -interface Bounds { - x: number; - y: number; - width: number; - height: number; -} - interface GradientAxisOverlayProps { positions: Record; statPositions: Record; @@ -60,68 +46,6 @@ const SWATCH_SIZE = 15; const SWATCH_LIFT = 16; // 축 선의 드래그 히트 두께(px) - 시각 선은 1.5px, 잡는 영역은 넓게 const AXIS_HIT_THICKNESS = 12; -// 자석 스냅 판정 각도(도) - 모서리·변 중앙 방향에 이 범위 안이면 흡착 -const MAGNET_THRESHOLD_DEG = 6; -// 클릭이 드래그로 승격되는 이동 임계값(px) -const DRAG_THRESHOLD_PX = 3; - -type AxisEnd = 'start' | 'end'; - -type DragState = - | { - type: 'rotate'; - pointerId: number; - end: AxisEnd; - ownerGeneration: number; - startSpec: GradientSpec; - moved: boolean; - /** 이동 없이 떼면 그 자리에 스톱 추가 - 축 히트 스트립 한정 */ - addOnClick: boolean; - /** 잡은 지점 보정 - 시작 각도와 프레스 포인터 각도의 차. 앵커 히트 - 영역 어디를 잡아도 첫 이동에서 축이 튀지 않는다 */ - angleOffset: number; - downX: number; - downY: number; - } - | { - type: 'stop'; - pointerId: number; - index: number; - moved: boolean; - ownerGeneration: number; - startSpec: GradientSpec; - lastPos: number; - /** 잡은 지점 보정 - 스톱 pos와 프레스 포인터 투영의 차. 스왓치가 축에서 - 띄워져 있어도(세로 축에서는 축 방향과 겹침) 첫 이동에 점프가 없다 */ - posOffset: number; - downX: number; - downY: number; - }; - -const normalizeAngle = (deg: number): number => ((deg % 360) + 360) % 360; - -const circularDistance = (a: number, b: number): number => { - const diff = Math.abs(normalizeAngle(a) - normalizeAngle(b)); - return Math.min(diff, 360 - diff); -}; - -// 변 중앙 4방향 + 모서리 4방향 (요소 종횡비 반영) -const buildMagnetAngles = (width: number, height: number): number[] => { - const corner = normalizeAngle( - (Math.atan2(width / 2, height / 2) * 180) / Math.PI, - ); - return [ - 0, - 90, - 180, - 270, - corner, - normalizeAngle(180 - corner), - normalizeAngle(180 + corner), - normalizeAngle(360 - corner), - ]; -}; - const stopAll = (e: React.SyntheticEvent) => { e.preventDefault(); e.stopPropagation(); @@ -147,154 +71,35 @@ const GradientAxisOverlay = ({ ); // 피커 색 드래그 중 - 오버레이를 흐려 대상의 실제 색이 보이게 한다 const colorAdjusting = useGradientEditStore((state) => state.colorAdjusting); - const dragRef = useRef(null); - const [dragAngle, setDragAngle] = useState(null); - const [dragStop, setDragStop] = useState<{ - index: number; - pos: number; - } | null>(null); - const rootRef = useRef(null); - // window 드래그 핸들러가 리렌더와 무관하게 최신 값을 읽도록 ref로 노출 - const sessionRef = useRef(session); - // eslint-disable-next-line react-hooks/refs - sessionRef.current = session; - const geoRef = useRef<{ - cx: number; - cy: number; - halfLine: number; - dirX: number; - dirY: number; - magnetAngles: number[]; - worldW: number; - worldH: number; - zoom: number; - } | null>(null); - // 드래그 세션 해제자 - begin에서 등록, 종료 경로 어디서든 1회 실행 - const detachRef = useRef<(() => void) | null>(null); - // 잡은 요소 - 캡처로 드래그 중 커서를 고정하고 해제 시 복원 - const grabbedRef = useRef<{ el: HTMLElement; pointerId: number } | null>( - null, - ); - const moveSchedulerRef = useRef - > | null>(null); - const cancelActiveDragRef = useRef<() => void>(() => {}); - - // 드래그 중 오버레이가 언마운트되면 현재 소유 세션의 preview도 복원 - useEffect(() => () => cancelActiveDragRef.current(), []); - - // 피커 종료·대상 교체로 세션이 사라진 뒤에도 남은 window 리스너 즉시 정리 - useEffect(() => { - const drag = dragRef.current; - if (!drag) return; - if ( - session && - drag.ownerGeneration === useGradientEditStore.getState().generation - ) { - return; - } - detachRef.current?.(); - setDragAngle(null); - setDragStop(null); - }, [session]); - - // undo/redo 반영 시 진행 중 드래그를 복원 preview 없이 종료 - 저장값이 - // 되돌아간 뒤 pointerup이 마지막 포인터 좌표를 다시 커밋하지 않게 - const historyTick = useCommittedApplyStore((state) => state.historyTick); - const historyTickRef = useRef(historyTick); - useEffect(() => { - if (historyTickRef.current === historyTick) return; - historyTickRef.current = historyTick; - if (!dragRef.current) return; - detachRef.current?.(); - setDragAngle(null); - setDragStop(null); - }, [historyTick]); - - // 앵커 → 월드 bounds 해석 - const resolveBounds = (): Bounds | null => { - if (!session) return null; - // 표면 소유 레이어가 실측 박스를 등록했으면 우선 사용 - 카운터 표면은 - // 요소(키) 박스가 아니라 실제 카운터 텍스트 박스가 축의 기준이다 - if (registeredAnchorBounds?.sessionKey === session.sessionKey) { - const { bounds, origin } = registeredAnchorBounds; - const anchor = session.anchor; - // 등록 후 요소가 이동하면 저장 좌표 델타로 실측 박스를 추종 - if (origin && anchor.kind !== 'batch') { - const collection = - anchor.kind === 'key' - ? positions[selectedKeyType] - : anchor.kind === 'stat' - ? statPositions[selectedKeyType] - : anchor.kind === 'graph' - ? graphPositions?.[selectedKeyType] - : knobPositions?.[selectedKeyType]; - const pos = collection?.find((position) => position.id === anchor.id); - if (pos && (pos.dx !== origin.x || pos.dy !== origin.y)) { - return { - ...bounds, - x: bounds.x + (pos.dx - origin.x), - y: bounds.y + (pos.dy - origin.y), - }; - } - } - return bounds; - } - const { anchor } = session; - if (anchor.kind === 'batch') { - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - for (const el of selectedElements) { - if ( - session.stateMode === 'active' && - !supportsActiveVisualState(el.type) - ) { - continue; - } - const collection = - el.type === 'key' - ? positions[selectedKeyType] - : el.type === 'stat' - ? statPositions[selectedKeyType] - : el.type === 'graph' - ? graphPositions?.[selectedKeyType] - : el.type === 'knob' - ? knobPositions?.[selectedKeyType] - : undefined; - const pos = collection?.find((position) => position.id === el.id); - if (!pos) continue; - const w = pos.width || (el.type === 'graph' ? 200 : 60); - const h = pos.height || (el.type === 'graph' ? 100 : 60); - minX = Math.min(minX, pos.dx); - minY = Math.min(minY, pos.dy); - maxX = Math.max(maxX, pos.dx + w); - maxY = Math.max(maxY, pos.dy + h); - } - if (!Number.isFinite(minX)) return null; - return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; - } - - const collection = - anchor.kind === 'key' - ? positions[selectedKeyType] - : anchor.kind === 'stat' - ? statPositions[selectedKeyType] - : anchor.kind === 'graph' - ? graphPositions?.[selectedKeyType] - : knobPositions?.[selectedKeyType]; - const pos = collection?.find((position) => position.id === anchor.id); - if (!pos) return null; - return { - x: pos.dx, - y: pos.dy, - width: pos.width || (anchor.kind === 'graph' ? 200 : 60), - height: pos.height || (anchor.kind === 'graph' ? 100 : 60), - }; - }; - - const bounds = resolveBounds(); + const bounds = resolveGradientAxisBounds({ + session, + registeredAnchorBounds, + positions, + statPositions, + graphPositions, + knobPositions, + selectedElements, + selectedKeyType, + }); + const { + rootRef, + angle, + geometry, + dragStop, + isRotating, + beginStripPointer, + beginAnchorRotate, + beginStopDrag, + handleRotateKeyDown, + handleStopContextMenu, + } = useGradientAxisDragSession({ + session, + bounds, + zoom, + panX, + panY, + continuousInputStrategy, + }); const missingSingleSessionKey = session && session.anchor.kind !== 'batch' && !bounds ? session.sessionKey @@ -308,439 +113,12 @@ const GradientAxisOverlay = ({ } }, [missingSingleSessionKey]); - if (!session || !bounds) return null; - - const cx = (bounds.x + bounds.width / 2) * zoom + panX; - const cy = (bounds.y + bounds.height / 2) * zoom + panY; - const magnetAngles = buildMagnetAngles(bounds.width, bounds.height); + if (!session || !bounds || !geometry) return null; - const angle = dragAngle ?? session.spec.angle; - const rad = (angle * Math.PI) / 180; - // CSS linear-gradient: 0deg = 위, 시계 방향 - 화면 좌표(y 아래)로 변환 - const dirX = Math.sin(rad); - const dirY = -Math.cos(rad); - // CSS 그라데이션 라인 절반 길이 - pos 0/1이 이 지점에 해당 - const halfLine = - ((Math.abs(bounds.width * Math.sin(rad)) + - Math.abs(bounds.height * Math.cos(rad))) / - 2) * - zoom; - const endX = cx + dirX * halfLine; - const endY = cy + dirY * halfLine; + const { cx, cy, halfLine, endX, endY } = geometry; // pos(0~1) → 축 위 화면 좌표 - const stopPoint = (pos: number) => ({ - x: cx + dirX * (pos - 0.5) * 2 * halfLine, - y: cy + dirY * (pos - 0.5) * 2 * halfLine, - }); - - // eslint-disable-next-line react-hooks/refs - geoRef.current = { - cx, - cy, - halfLine, - dirX, - dirY, - magnetAngles, - worldW: bounds.width, - worldH: bounds.height, - zoom, - }; - - // 화면(client) 기준 중심 - 드래그 중 휠 팬·줌으로 좌표가 움직여도 - // 매 이벤트에서 최신 지오메트리로 재계산한다 - const clientOrigin = () => { - const hostRect = rootRef.current?.getBoundingClientRect(); - const geo = geoRef.current; - return { - x: (hostRect?.left ?? 0) + (geo?.cx ?? 0), - y: (hostRect?.top ?? 0) + (geo?.cy ?? 0), - }; - }; - - // 포인터가 가리키는 축 각도 (보정·자석 이전의 원값) - const pointerAngleFromClient = ( - clientX: number, - clientY: number, - end: AxisEnd, - ): number => { - const origin = clientOrigin(); - const raw = - (Math.atan2(clientX - origin.x, origin.y - clientY) * 180) / Math.PI; - // 시작점 쪽을 잡으면 축 반대 방향이 그라데이션 진행 방향 - return normalizeAngle(end === 'start' ? raw + 180 : raw); - }; - - const applyMagnet = (angle: number, magnetDisabled: boolean): number => { - let next = normalizeAngle(Math.round(angle)); - if (!magnetDisabled) { - for (const magnet of geoRef.current?.magnetAngles ?? []) { - if (circularDistance(next, magnet) <= MAGNET_THRESHOLD_DEG) { - next = magnet; - break; - } - } - } - return normalizeAngle(Math.round(next)); - }; - - const angleFromClient = ( - clientX: number, - clientY: number, - drag: Extract, - magnetDisabled: boolean, - ): number => - applyMagnet( - pointerAngleFromClient(clientX, clientY, drag.end) + drag.angleOffset, - magnetDisabled, - ); - - // 포인터의 축 투영 원값 - 잡은 지점 보정 후 호출부가 클램프 - const projectionFromClient = (clientX: number, clientY: number): number => { - const origin = clientOrigin(); - const geo = geoRef.current; - if (!geo || geo.halfLine === 0) return 0.5; - const dx = clientX - origin.x; - const dy = clientY - origin.y; - return (dx * geo.dirX + dy * geo.dirY) / (2 * geo.halfLine) + 0.5; - }; - - const clampPos = (pos: number): number => Math.min(1, Math.max(0, pos)); - - // 포인터를 축에 사영해 pos(0~1) 계산 - 클릭 지점 직접 사용 경로(스톱 추가) - const posFromClient = (clientX: number, clientY: number): number => - clampPos(projectionFromClient(clientX, clientY)); - - const currentSpec = () => sessionRef.current?.spec ?? session.spec; - - const stopsWithMovedIndex = (index: number, pos: number) => - currentSpec().stops.map((s, i) => (i === index ? { ...s, pos } : s)); - - const commitStopDrag = (index: number, pos: number) => { - const live = sessionRef.current; - if (!live) return; - const stops = stopsWithMovedIndex(index, pos); - const next = toCanonicalGradient({ ...currentSpec(), stops }); - // canonical과 동일한 안정 정렬을 원본 인덱스 태그로 재현해 선택 재매핑 - const sortedIndexes = stops - .map((s, i) => ({ i, pos: Math.min(1, Math.max(0, s.pos)) })) - .sort((a, b) => a.pos - b.pos); - const newIndex = sortedIndexes.findIndex((s) => s.i === index); - if (newIndex >= 0) live.selectStop(newIndex); - live.apply(next, true); - }; - - // 축 클릭 스톱 추가 - 색은 선택 스톱 기준 (피커 스톱 바와 동일 규칙) - const addStopAt = (pos: number) => { - const live = sessionRef.current; - if (!live) return; - const spec = currentSpec(); - if (spec.stops.length >= GRADIENT_STOPS_MAX) return; - const color = - spec.stops[live.selectedIndex]?.color ?? - spec.stops[0]?.color ?? - '#ffffff'; - const next = toCanonicalGradient({ - ...spec, - stops: [...spec.stops, { color, pos }], - }); - const newIndex = next.stops.findIndex( - (s) => s.pos === pos && s.color === color, - ); - live.selectStop(newIndex >= 0 ? newIndex : next.stops.length - 1); - live.apply(next, true); - }; - - // 드래그 소유 세션 검증 - 세션이 사라지거나 한 번이라도 교체되면 중단. - // 세대 비교라 포인터 이벤트 사이의 A→B→새 A 왕복도 잡는다 - const ownedDrag = (e: PointerEvent): DragState | null => { - const drag = dragRef.current; - if (!drag || e.pointerId !== drag.pointerId) return null; - const live = sessionRef.current; - if ( - !live || - drag.ownerGeneration !== useGradientEditStore.getState().generation - ) { - detachRef.current?.(); - setDragAngle(null); - setDragStop(null); - return null; - } - return drag; - }; - - const applyWindowMove = (e: PointerEvent) => { - const drag = ownedDrag(e); - if (!drag) return; - const live = sessionRef.current; - if (!live) return; - if (!drag.moved) { - if ( - Math.hypot(e.clientX - drag.downX, e.clientY - drag.downY) < - DRAG_THRESHOLD_PX - ) { - return; - } - drag.moved = true; - } - - if (drag.type === 'rotate') { - const next = angleFromClient( - e.clientX, - e.clientY, - drag, - e.ctrlKey || e.metaKey, - ); - setDragAngle(next); - live.apply({ ...currentSpec(), angle: next }, false); - return; - } - - // 스톱은 항상 현재 축에 사영 - 위치만 이동, 각도 불변, 잡은 지점 보정 - const pos = clampPos( - projectionFromClient(e.clientX, e.clientY) + drag.posOffset, - ); - drag.lastPos = pos; - setDragStop({ index: drag.index, pos }); - live.apply( - { ...currentSpec(), stops: stopsWithMovedIndex(drag.index, pos) }, - false, - ); - }; - - const handleWindowMove = (e: PointerEvent) => { - const drag = ownedDrag(e); - if (!drag) return; - // 창 밖에서 버튼이 이미 떼졌으면 stale 드래그 - 커밋 없이 종료 - if (e.buttons === 0) { - cancelActiveDrag(); - return; - } - moveSchedulerRef.current?.push(e); - }; - - const handleWindowUp = (e: PointerEvent) => { - // 마지막 move 프리뷰를 먼저 반영해 moved 상태와 화면을 확정 - moveSchedulerRef.current?.flush(); - const drag = ownedDrag(e); - if (!drag) return; - detachRef.current?.(); - setDragAngle(null); - setDragStop(null); - const live = sessionRef.current; - if (!live) return; - if (drag.type === 'rotate') { - if (!drag.moved) { - // 클릭 - 축 히트 스트립이면 그 위치에 스톱 추가 - if (drag.addOnClick) addStopAt(posFromClient(e.clientX, e.clientY)); - return; - } - const finalAngle = angleFromClient( - e.clientX, - e.clientY, - drag, - e.ctrlKey || e.metaKey, - ); - live.apply( - toCanonicalGradient({ ...currentSpec(), angle: finalAngle }), - true, - ); - return; - } - if (!drag.moved) return; // 클릭 - 선택만 - // pointerup 좌표를 직접 사용해 마지막 프레임 사이 입력도 유실하지 않음 - commitStopDrag( - drag.index, - clampPos(projectionFromClient(e.clientX, e.clientY) + drag.posOffset), - ); - }; - - // 취소 - preview로 반영된 변경을 시작 시점 spec으로 복원 - const cancelActiveDrag = () => { - const drag = dragRef.current; - if (!drag) return; - detachRef.current?.(); - setDragAngle(null); - setDragStop(null); - const live = sessionRef.current; - // 세대가 드래그 시작 시점 그대로일 때만 복원 - 포인터 이벤트 없이 - // A→B→새 A로 교체된 세션에 stale 롤백이 새지 않게 - if ( - live && - drag.ownerGeneration === useGradientEditStore.getState().generation - ) { - live.apply(drag.startSpec, false); - live.cancel?.(); - } - }; - // eslint-disable-next-line react-hooks/refs - cancelActiveDragRef.current = cancelActiveDrag; - - const handleWindowCancel = (e: PointerEvent) => { - const drag = dragRef.current; - if (!drag || e.pointerId !== drag.pointerId) return; - cancelActiveDrag(); - }; - - // 창 포커스 상실 - pointerup이 오지 않으므로 유령 드래그 방지 취소 - const handleWindowBlur = () => cancelActiveDrag(); - - // 드래그 제스처의 후속 click이 그리드 선택 해제로 새지 않게 1회 억제 - const suppressNextClick = () => { - const swallow = (e: MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - window.removeEventListener('click', swallow, true); - }; - window.addEventListener('click', swallow, true); - // click이 아예 안 오는 경로(cancel 등) 대비 - 다음 틱에 정리 - setTimeout(() => window.removeEventListener('click', swallow, true), 0); - }; - - // 프레스 즉시 grabbing - 키와 같은 정책(호버 무변화, 잡는 동안만 grabbing). - // pointerdown preventDefault로 :active가 안 걸리고, 캡처 중에도 커서는 - // 히트테스트 기준(크로뮴)이라 문서 전역 클래스로 드래그 내내 고정한다 - const grabPointer = (e: React.PointerEvent) => { - const el = e.currentTarget as HTMLElement; - try { - el.setPointerCapture(e.pointerId); - } catch { - // 미지원 환경은 커서 고정 없이 동작만 유지 - } - beginDragCursor('grabbing', el.ownerDocument); - grabbedRef.current = { el, pointerId: e.pointerId }; - }; - - const releaseGrabbed = () => { - const grabbed = grabbedRef.current; - if (!grabbed) return; - endDragCursor(grabbed.el.ownerDocument); - try { - grabbed.el.releasePointerCapture(grabbed.pointerId); - } catch { - // 이미 해제됐으면 무시 - } - grabbedRef.current = null; - }; - - const attachWindowDrag = () => { - moveSchedulerRef.current = createRafLatestScheduler( - applyWindowMove, - continuousInputStrategy, - ); - window.addEventListener('pointermove', handleWindowMove); - window.addEventListener('pointerup', handleWindowUp); - window.addEventListener('pointercancel', handleWindowCancel); - window.addEventListener('blur', handleWindowBlur); - detachRef.current = () => { - window.removeEventListener('pointermove', handleWindowMove); - window.removeEventListener('pointerup', handleWindowUp); - window.removeEventListener('pointercancel', handleWindowCancel); - window.removeEventListener('blur', handleWindowBlur); - releaseGrabbed(); - moveSchedulerRef.current?.cancel(); - moveSchedulerRef.current = null; - detachRef.current = null; - dragRef.current = null; - suppressNextClick(); - }; - }; - - const beginRotateDrag = ( - e: React.PointerEvent, - end: AxisEnd, - addOnClick: boolean, - ) => { - if (e.button !== 0) return; - stopAll(e); - detachRef.current?.(); - grabPointer(e); - dragRef.current = { - type: 'rotate', - pointerId: e.pointerId, - end, - ownerGeneration: useGradientEditStore.getState().generation, - startSpec: session.spec, - moved: false, - addOnClick, - angleOffset: - session.spec.angle - pointerAngleFromClient(e.clientX, e.clientY, end), - downX: e.clientX, - downY: e.clientY, - }; - attachWindowDrag(); - }; - - // 축 히트 스트립 - 잡은 지점이 축의 어느 절반인지로 회전 기준 방향 결정 - const beginStripPointer = (e: React.PointerEvent) => { - // preventDefault가 기본 포커스 이동을 막으므로 명시 부여 - 화살표 각도 조절이 - // 그리드 키 이동으로 새지 않게 슬라이더가 포커스를 가져간다 - if (e.button === 0) { - e.currentTarget.focus({ preventScroll: true }); - } - const origin = clientOrigin(); - const geo = geoRef.current; - const along = - (e.clientX - origin.x) * (geo?.dirX ?? 0) + - (e.clientY - origin.y) * (geo?.dirY ?? 0); - beginRotateDrag(e, along >= 0 ? 'end' : 'start', true); - }; - - const beginAnchorRotate = - (end: AxisEnd) => (e: React.PointerEvent) => - beginRotateDrag(e, end, false); - - const beginStopDrag = - (index: number) => (e: React.PointerEvent) => { - if (e.button !== 0) return; - stopAll(e); - detachRef.current?.(); - grabPointer(e); - session.selectStop(index); - dragRef.current = { - type: 'stop', - pointerId: e.pointerId, - index, - moved: false, - ownerGeneration: useGradientEditStore.getState().generation, - startSpec: session.spec, - lastPos: session.spec.stops[index]?.pos ?? 0.5, - posOffset: - (session.spec.stops[index]?.pos ?? 0.5) - - projectionFromClient(e.clientX, e.clientY), - downX: e.clientX, - downY: e.clientY, - }; - attachWindowDrag(); - }; - - // 키보드 각도 조절 - 화살표 ±1°, Shift ±15° (슬라이더 시맨틱) - const handleRotateKeyDown = (e: React.KeyboardEvent) => { - let delta = 0; - if (e.key === 'ArrowRight' || e.key === 'ArrowUp') delta = 1; - else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') delta = -1; - else return; - e.preventDefault(); - e.stopPropagation(); - const step = e.shiftKey ? 15 : 1; - const next = normalizeAngle(session.spec.angle + delta * step); - session.apply(toCanonicalGradient({ ...session.spec, angle: next }), true); - }; - - // 우클릭 삭제 - 최소 2개 유지 (피커 스톱 바와 동일 규칙) - const handleStopContextMenu = (index: number) => (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (session.spec.stops.length <= 2) return; - const stops = session.spec.stops.filter((_, i) => i !== index); - const nextSelected = - session.selectedIndex > index - ? session.selectedIndex - 1 - : Math.min(session.selectedIndex, stops.length - 1); - session.selectStop(Math.max(0, nextSelected)); - session.apply(toCanonicalGradient({ ...session.spec, stops }), true); - }; + const stopPoint = (pos: number) => gradientAxisStopPoint(geometry, pos); - const isRotating = dragAngle !== null; const dragStopScreen = dragStop ? stopPoint(dragStop.pos) : null; return ( @@ -800,7 +178,7 @@ const GradientAxisOverlay = ({ }} /> {/* 축 끝 앵커 - 선 끝점 위 흰 점, 드래그로 각도만 조절 */} - {(['start', 'end'] as AxisEnd[]).map((end) => { + {(['start', 'end'] as GradientAxisEnd[]).map((end) => { const point = stopPoint(end === 'end' ? 1 : 0); return (
; + statPositions: Record; + graphPositions?: Record; + knobPositions?: Record; + selectedElements: SelectedElement[]; + selectedKeyType: string; +} + +export type GradientAxisEnd = 'start' | 'end'; + +export const normalizeGradientAxisAngle = (deg: number): number => + ((deg % 360) + 360) % 360; + +const circularDistance = (a: number, b: number): number => { + const diff = Math.abs( + normalizeGradientAxisAngle(a) - normalizeGradientAxisAngle(b), + ); + return Math.min(diff, 360 - diff); +}; + +// 변 중앙 4방향 + 모서리 4방향 (요소 종횡비 반영) +export const buildGradientAxisMagnetAngles = ( + width: number, + height: number, +): number[] => { + const corner = normalizeGradientAxisAngle( + (Math.atan2(width / 2, height / 2) * 180) / Math.PI, + ); + return [ + 0, + 90, + 180, + 270, + corner, + normalizeGradientAxisAngle(180 - corner), + normalizeGradientAxisAngle(180 + corner), + normalizeGradientAxisAngle(360 - corner), + ]; +}; + +export const resolveGradientAxisBounds = ({ + session, + registeredAnchorBounds, + positions, + statPositions, + graphPositions, + knobPositions, + selectedElements, + selectedKeyType, +}: ResolveGradientAxisBoundsOptions): GradientAxisBounds | null => { + if (!session) return null; + // 표면 소유 레이어가 실측 박스를 등록했으면 우선 사용 - 카운터 표면은 + // 요소(키) 박스가 아니라 실제 카운터 텍스트 박스가 축의 기준 + if (registeredAnchorBounds?.sessionKey === session.sessionKey) { + const { bounds, origin } = registeredAnchorBounds; + const anchor = session.anchor; + // 등록 후 요소가 이동하면 저장 좌표 델타로 실측 박스를 추종 + if (origin && anchor.kind !== 'batch') { + const collection = + anchor.kind === 'key' + ? positions[selectedKeyType] + : anchor.kind === 'stat' + ? statPositions[selectedKeyType] + : anchor.kind === 'graph' + ? graphPositions?.[selectedKeyType] + : knobPositions?.[selectedKeyType]; + const pos = collection?.find((position) => position.id === anchor.id); + if (pos && (pos.dx !== origin.x || pos.dy !== origin.y)) { + return { + ...bounds, + x: bounds.x + (pos.dx - origin.x), + y: bounds.y + (pos.dy - origin.y), + }; + } + } + return bounds; + } + const { anchor } = session; + if (anchor.kind === 'batch') { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const element of selectedElements) { + if ( + session.stateMode === 'active' && + !supportsActiveVisualState(element.type) + ) { + continue; + } + const collection = + element.type === 'key' + ? positions[selectedKeyType] + : element.type === 'stat' + ? statPositions[selectedKeyType] + : element.type === 'graph' + ? graphPositions?.[selectedKeyType] + : element.type === 'knob' + ? knobPositions?.[selectedKeyType] + : undefined; + const pos = collection?.find((position) => position.id === element.id); + if (!pos) continue; + const width = pos.width || (element.type === 'graph' ? 200 : 60); + const height = pos.height || (element.type === 'graph' ? 100 : 60); + minX = Math.min(minX, pos.dx); + minY = Math.min(minY, pos.dy); + maxX = Math.max(maxX, pos.dx + width); + maxY = Math.max(maxY, pos.dy + height); + } + if (!Number.isFinite(minX)) return null; + return { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, + }; + } + + const collection = + anchor.kind === 'key' + ? positions[selectedKeyType] + : anchor.kind === 'stat' + ? statPositions[selectedKeyType] + : anchor.kind === 'graph' + ? graphPositions?.[selectedKeyType] + : knobPositions?.[selectedKeyType]; + const pos = collection?.find((position) => position.id === anchor.id); + if (!pos) return null; + return { + x: pos.dx, + y: pos.dy, + width: pos.width || (anchor.kind === 'graph' ? 200 : 60), + height: pos.height || (anchor.kind === 'graph' ? 100 : 60), + }; +}; + +export const buildGradientAxisGeometry = ( + bounds: GradientAxisBounds, + angle: number, + zoom: number, + panX: number, + panY: number, +): GradientAxisGeometry => { + const cx = (bounds.x + bounds.width / 2) * zoom + panX; + const cy = (bounds.y + bounds.height / 2) * zoom + panY; + const magnetAngles = buildGradientAxisMagnetAngles( + bounds.width, + bounds.height, + ); + const rad = (angle * Math.PI) / 180; + // CSS linear-gradient: 0deg = 위, 시계 방향 - 화면 좌표(y 아래)로 변환 + const dirX = Math.sin(rad); + const dirY = -Math.cos(rad); + // CSS 그라데이션 라인 절반 길이 - pos 0/1이 이 지점에 해당 + const halfLine = + ((Math.abs(bounds.width * Math.sin(rad)) + + Math.abs(bounds.height * Math.cos(rad))) / + 2) * + zoom; + return { + cx, + cy, + halfLine, + dirX, + dirY, + magnetAngles, + worldW: bounds.width, + worldH: bounds.height, + zoom, + endX: cx + dirX * halfLine, + endY: cy + dirY * halfLine, + }; +}; + +export const gradientAxisStopPoint = ( + geometry: GradientAxisGeometry, + pos: number, +): { x: number; y: number } => ({ + x: geometry.cx + geometry.dirX * (pos - 0.5) * 2 * geometry.halfLine, + y: geometry.cy + geometry.dirY * (pos - 0.5) * 2 * geometry.halfLine, +}); + +export const gradientAxisClientOrigin = ( + geometry: GradientAxisGeometry | null, + hostRect: Pick | null, +): { x: number; y: number } => ({ + x: (hostRect?.left ?? 0) + (geometry?.cx ?? 0), + y: (hostRect?.top ?? 0) + (geometry?.cy ?? 0), +}); + +export const gradientAxisPointerAngle = ( + clientX: number, + clientY: number, + end: GradientAxisEnd, + origin: { x: number; y: number }, +): number => { + const raw = + (Math.atan2(clientX - origin.x, origin.y - clientY) * 180) / Math.PI; + // 시작점 쪽을 잡으면 축 반대 방향이 그라데이션 진행 방향 + return normalizeGradientAxisAngle(end === 'start' ? raw + 180 : raw); +}; + +export const applyGradientAxisMagnet = ( + angle: number, + magnetAngles: readonly number[], + magnetDisabled: boolean, + thresholdDeg: number, +): number => { + let next = normalizeGradientAxisAngle(Math.round(angle)); + if (!magnetDisabled) { + for (const magnet of magnetAngles) { + if (circularDistance(next, magnet) <= thresholdDeg) { + next = magnet; + break; + } + } + } + return normalizeGradientAxisAngle(Math.round(next)); +}; + +export const projectClientToGradientAxis = ( + clientX: number, + clientY: number, + geometry: GradientAxisGeometry | null, + origin: { x: number; y: number }, +): number => { + if (!geometry || geometry.halfLine === 0) return 0.5; + const dx = clientX - origin.x; + const dy = clientY - origin.y; + return ( + (dx * geometry.dirX + dy * geometry.dirY) / (2 * geometry.halfLine) + 0.5 + ); +}; + +export const clampGradientAxisPosition = (pos: number): number => + Math.min(1, Math.max(0, pos)); diff --git a/src/renderer/components/main/Grid/handles/useGradientAxisDragSession.ts b/src/renderer/components/main/Grid/handles/useGradientAxisDragSession.ts new file mode 100644 index 000000000..f31f4cc0a --- /dev/null +++ b/src/renderer/components/main/Grid/handles/useGradientAxisDragSession.ts @@ -0,0 +1,559 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { useCommittedApplyStore } from '@stores/data/useCommittedApplyStore'; +import { + useGradientEditStore, + type GradientEditSession, +} from '@stores/grid/useGradientEditStore'; +import { + GRADIENT_STOPS_MAX, + toCanonicalGradient, + type GradientSpec, +} from '@src/types/color'; +import { + createRafLatestScheduler, + type ContinuousInputStrategy, +} from '@utils/animation/rafLatestScheduler'; +import { beginDragCursor, endDragCursor } from '@utils/core/dragCursor'; +import { + applyGradientAxisMagnet, + buildGradientAxisGeometry, + clampGradientAxisPosition, + gradientAxisClientOrigin, + gradientAxisPointerAngle, + normalizeGradientAxisAngle, + projectClientToGradientAxis, + type GradientAxisBounds, + type GradientAxisEnd, + type GradientAxisGeometry, +} from './gradientAxisGeometry'; + +const MAGNET_THRESHOLD_DEG = 6; +const DRAG_THRESHOLD_PX = 3; + +type DragState = + | { + type: 'rotate'; + pointerId: number; + end: GradientAxisEnd; + ownerGeneration: number; + startSpec: GradientSpec; + moved: boolean; + /** 이동 없이 떼면 그 자리에 스톱 추가 - 축 히트 스트립 한정 */ + addOnClick: boolean; + /** 잡은 지점 보정 - 시작 각도와 프레스 포인터 각도의 차. 앵커 히트 + 영역 어디를 잡아도 첫 이동에서 축이 튀지 않는다 */ + angleOffset: number; + downX: number; + downY: number; + } + | { + type: 'stop'; + pointerId: number; + index: number; + moved: boolean; + ownerGeneration: number; + startSpec: GradientSpec; + lastPos: number; + /** 잡은 지점 보정 - 스톱 pos와 프레스 포인터 투영의 차. 스왓치가 축에서 + 띄워져 있어도(세로 축에서는 축 방향과 겹침) 첫 이동에 점프가 없다 */ + posOffset: number; + downX: number; + downY: number; + }; + +interface UseGradientAxisDragSessionOptions { + session: GradientEditSession | null; + bounds: GradientAxisBounds | null; + zoom: number; + panX: number; + panY: number; + continuousInputStrategy: ContinuousInputStrategy; +} + +interface GradientAxisDragSession { + rootRef: React.RefObject; + angle: number; + geometry: GradientAxisGeometry | null; + dragStop: { index: number; pos: number } | null; + isRotating: boolean; + beginStripPointer: (event: React.PointerEvent) => void; + beginAnchorRotate: ( + end: GradientAxisEnd, + ) => (event: React.PointerEvent) => void; + beginStopDrag: ( + index: number, + ) => (event: React.PointerEvent) => void; + handleRotateKeyDown: (event: React.KeyboardEvent) => void; + handleStopContextMenu: (index: number) => (event: React.MouseEvent) => void; +} + +const stopAll = (event: React.SyntheticEvent) => { + event.preventDefault(); + event.stopPropagation(); +}; + +export const useGradientAxisDragSession = ({ + session, + bounds, + zoom, + panX, + panY, + continuousInputStrategy, +}: UseGradientAxisDragSessionOptions): GradientAxisDragSession => { + const dragRef = useRef(null); + const [dragAngle, setDragAngle] = useState(null); + const [dragStop, setDragStop] = useState<{ + index: number; + pos: number; + } | null>(null); + const rootRef = useRef(null); + + const sessionRef = useRef(session); + // eslint-disable-next-line react-hooks/refs + sessionRef.current = session; + const geoRef = useRef(null); + // 드래그 세션 해제자 - begin에서 등록, 종료 경로 어디서든 1회 실행 + const detachRef = useRef<(() => void) | null>(null); + // 잡은 요소 - 캡처로 드래그 중 커서를 고정하고 해제 시 복원 + const grabbedRef = useRef<{ el: HTMLElement; pointerId: number } | null>( + null, + ); + const moveSchedulerRef = useRef + > | null>(null); + const cancelActiveDragRef = useRef<() => void>(() => {}); + + // 드래그 중 오버레이가 언마운트되면 현재 소유 세션의 preview도 복원 + useEffect(() => () => cancelActiveDragRef.current(), []); + + // 피커 종료·대상 교체로 세션이 사라진 뒤에도 남은 window 리스너 즉시 정리 + useEffect(() => { + const drag = dragRef.current; + if (!drag) return; + if ( + session && + drag.ownerGeneration === useGradientEditStore.getState().generation + ) { + return; + } + detachRef.current?.(); + setDragAngle(null); + setDragStop(null); + }, [session]); + + // undo/redo 반영 시 진행 중 드래그를 복원 preview 없이 종료 - 저장값이 + // 되돌아간 뒤 pointerup이 마지막 포인터 좌표를 다시 커밋하지 않게 + const historyTick = useCommittedApplyStore((state) => state.historyTick); + const historyTickRef = useRef(historyTick); + useEffect(() => { + if (historyTickRef.current === historyTick) return; + historyTickRef.current = historyTick; + if (!dragRef.current) return; + detachRef.current?.(); + setDragAngle(null); + setDragStop(null); + }, [historyTick]); + + const angle = dragAngle ?? session?.spec.angle ?? 0; + const geometry = bounds + ? buildGradientAxisGeometry(bounds, angle, zoom, panX, panY) + : null; + // eslint-disable-next-line react-hooks/refs + if (geometry) geoRef.current = geometry; + + // 화면(client) 기준 중심 - 드래그 중 휠 팬·줌으로 좌표가 움직여도 + // 매 이벤트에서 최신 지오메트리로 재계산 + const clientOrigin = () => + gradientAxisClientOrigin( + geoRef.current, + rootRef.current?.getBoundingClientRect() ?? null, + ); + + // 포인터가 가리키는 축 각도 (보정·자석 이전의 원값) + const pointerAngleFromClient = ( + clientX: number, + clientY: number, + end: GradientAxisEnd, + ): number => gradientAxisPointerAngle(clientX, clientY, end, clientOrigin()); + + const angleFromClient = ( + clientX: number, + clientY: number, + drag: Extract, + magnetDisabled: boolean, + ): number => + applyGradientAxisMagnet( + pointerAngleFromClient(clientX, clientY, drag.end) + drag.angleOffset, + geoRef.current?.magnetAngles ?? [], + magnetDisabled, + MAGNET_THRESHOLD_DEG, + ); + + // 포인터의 축 투영 원값 - 잡은 지점 보정 후 호출부가 클램프 + const projectionFromClient = (clientX: number, clientY: number): number => + projectClientToGradientAxis( + clientX, + clientY, + geoRef.current, + clientOrigin(), + ); + + // 포인터를 축에 사영해 pos(0~1) 계산 - 클릭 지점 직접 사용 경로(스톱 추가) + const posFromClient = (clientX: number, clientY: number): number => + clampGradientAxisPosition(projectionFromClient(clientX, clientY)); + + const currentSpec = () => sessionRef.current?.spec ?? session?.spec ?? null; + + const stopsWithMovedIndex = (index: number, pos: number) => + currentSpec()?.stops.map((stop, stopIndex) => + stopIndex === index ? { ...stop, pos } : stop, + ) ?? []; + + const commitStopDrag = (index: number, pos: number) => { + const live = sessionRef.current; + const spec = currentSpec(); + if (!live || !spec) return; + const stops = stopsWithMovedIndex(index, pos); + const next = toCanonicalGradient({ ...spec, stops }); + // canonical과 동일한 안정 정렬을 원본 인덱스 태그로 재현해 선택 재매핑 + const sortedIndexes = stops + .map((stop, stopIndex) => ({ + i: stopIndex, + pos: Math.min(1, Math.max(0, stop.pos)), + })) + .sort((a, b) => a.pos - b.pos); + const newIndex = sortedIndexes.findIndex((stop) => stop.i === index); + if (newIndex >= 0) live.selectStop(newIndex); + live.apply(next, true); + }; + + // 축 클릭 스톱 추가 - 색은 선택 스톱 기준 (피커 스톱 바와 동일 규칙) + const addStopAt = (pos: number) => { + const live = sessionRef.current; + const spec = currentSpec(); + if (!live || !spec) return; + if (spec.stops.length >= GRADIENT_STOPS_MAX) return; + const color = + spec.stops[live.selectedIndex]?.color ?? + spec.stops[0]?.color ?? + '#ffffff'; + const next = toCanonicalGradient({ + ...spec, + stops: [...spec.stops, { color, pos }], + }); + const newIndex = next.stops.findIndex( + (stop) => stop.pos === pos && stop.color === color, + ); + live.selectStop(newIndex >= 0 ? newIndex : next.stops.length - 1); + live.apply(next, true); + }; + + // 드래그 소유 세션 검증 - 세션이 사라지거나 한 번이라도 교체되면 중단. + // 세대 비교라 포인터 이벤트 사이의 A→B→새 A 왕복도 잡는다 + const ownedDrag = (event: PointerEvent): DragState | null => { + const drag = dragRef.current; + if (!drag || event.pointerId !== drag.pointerId) return null; + const live = sessionRef.current; + if ( + !live || + drag.ownerGeneration !== useGradientEditStore.getState().generation + ) { + detachRef.current?.(); + setDragAngle(null); + setDragStop(null); + return null; + } + return drag; + }; + + const applyWindowMove = (event: PointerEvent) => { + const drag = ownedDrag(event); + if (!drag) return; + const live = sessionRef.current; + const spec = currentSpec(); + if (!live || !spec) return; + if (!drag.moved) { + if ( + Math.hypot(event.clientX - drag.downX, event.clientY - drag.downY) < + DRAG_THRESHOLD_PX + ) { + return; + } + drag.moved = true; + } + + if (drag.type === 'rotate') { + const next = angleFromClient( + event.clientX, + event.clientY, + drag, + event.ctrlKey || event.metaKey, + ); + setDragAngle(next); + live.apply({ ...spec, angle: next }, false); + return; + } + + // 스톱은 항상 현재 축에 사영 - 위치만 이동, 각도 불변, 잡은 지점 보정 + const pos = clampGradientAxisPosition( + projectionFromClient(event.clientX, event.clientY) + drag.posOffset, + ); + drag.lastPos = pos; + setDragStop({ index: drag.index, pos }); + live.apply({ ...spec, stops: stopsWithMovedIndex(drag.index, pos) }, false); + }; + + const handleWindowMove = (event: PointerEvent) => { + const drag = ownedDrag(event); + if (!drag) return; + // 창 밖에서 버튼이 이미 떼졌으면 stale 드래그 - 커밋 없이 종료 + if (event.buttons === 0) { + cancelActiveDrag(); + return; + } + moveSchedulerRef.current?.push(event); + }; + + const handleWindowUp = (event: PointerEvent) => { + // 마지막 move 프리뷰를 먼저 반영해 moved 상태와 화면을 확정 + moveSchedulerRef.current?.flush(); + const drag = ownedDrag(event); + if (!drag) return; + detachRef.current?.(); + setDragAngle(null); + setDragStop(null); + const live = sessionRef.current; + const spec = currentSpec(); + if (!live || !spec) return; + if (drag.type === 'rotate') { + if (!drag.moved) { + // 클릭 - 축 히트 스트립이면 그 위치에 스톱 추가 + if (drag.addOnClick) { + addStopAt(posFromClient(event.clientX, event.clientY)); + } + return; + } + const finalAngle = angleFromClient( + event.clientX, + event.clientY, + drag, + event.ctrlKey || event.metaKey, + ); + live.apply(toCanonicalGradient({ ...spec, angle: finalAngle }), true); + return; + } + if (!drag.moved) return; // 클릭 - 선택만 + // pointerup 좌표를 직접 사용해 마지막 프레임 사이 입력도 유실하지 않음 + commitStopDrag( + drag.index, + clampGradientAxisPosition( + projectionFromClient(event.clientX, event.clientY) + drag.posOffset, + ), + ); + }; + + // 취소 - preview로 반영된 변경을 시작 시점 spec으로 복원 + const cancelActiveDrag = () => { + const drag = dragRef.current; + if (!drag) return; + detachRef.current?.(); + setDragAngle(null); + setDragStop(null); + const live = sessionRef.current; + // 세대가 드래그 시작 시점 그대로일 때만 복원 - 포인터 이벤트 없이 + // A→B→새 A로 교체된 세션에 stale 롤백이 새지 않게 + if ( + live && + drag.ownerGeneration === useGradientEditStore.getState().generation + ) { + live.apply(drag.startSpec, false); + live.cancel?.(); + } + }; + // eslint-disable-next-line react-hooks/refs + cancelActiveDragRef.current = cancelActiveDrag; + + const handleWindowCancel = (event: PointerEvent) => { + const drag = dragRef.current; + if (!drag || event.pointerId !== drag.pointerId) return; + cancelActiveDrag(); + }; + + // 창 포커스 상실 - pointerup이 오지 않으므로 유령 드래그 방지 취소 + const handleWindowBlur = () => cancelActiveDrag(); + + // 드래그 제스처의 후속 click이 그리드 선택 해제로 새지 않게 1회 억제 + const suppressNextClick = () => { + const swallow = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + window.removeEventListener('click', swallow, true); + }; + window.addEventListener('click', swallow, true); + // click이 아예 안 오는 경로(cancel 등) 대비 - 다음 틱에 정리 + setTimeout(() => window.removeEventListener('click', swallow, true), 0); + }; + + // 프레스 즉시 grabbing - 키와 같은 정책(호버 무변화, 잡는 동안만 grabbing). + // pointerdown preventDefault로 :active가 안 걸리고, 캡처 중에도 커서는 + // 히트테스트 기준(크로뮴)이라 문서 전역 클래스로 드래그 내내 고정 + const grabPointer = (event: React.PointerEvent) => { + const element = event.currentTarget as HTMLElement; + try { + element.setPointerCapture(event.pointerId); + } catch { + // 미지원 환경은 커서 고정 없이 동작만 유지 + } + beginDragCursor('grabbing', element.ownerDocument); + grabbedRef.current = { el: element, pointerId: event.pointerId }; + }; + + const releaseGrabbed = () => { + const grabbed = grabbedRef.current; + if (!grabbed) return; + endDragCursor(grabbed.el.ownerDocument); + try { + grabbed.el.releasePointerCapture(grabbed.pointerId); + } catch { + // 이미 해제됐으면 무시 + } + grabbedRef.current = null; + }; + + const attachWindowDrag = () => { + moveSchedulerRef.current = createRafLatestScheduler( + applyWindowMove, + continuousInputStrategy, + ); + window.addEventListener('pointermove', handleWindowMove); + window.addEventListener('pointerup', handleWindowUp); + window.addEventListener('pointercancel', handleWindowCancel); + window.addEventListener('blur', handleWindowBlur); + detachRef.current = () => { + window.removeEventListener('pointermove', handleWindowMove); + window.removeEventListener('pointerup', handleWindowUp); + window.removeEventListener('pointercancel', handleWindowCancel); + window.removeEventListener('blur', handleWindowBlur); + releaseGrabbed(); + moveSchedulerRef.current?.cancel(); + moveSchedulerRef.current = null; + detachRef.current = null; + dragRef.current = null; + suppressNextClick(); + }; + }; + + const beginRotateDrag = ( + event: React.PointerEvent, + end: GradientAxisEnd, + addOnClick: boolean, + ) => { + if (event.button !== 0 || !session) return; + stopAll(event); + detachRef.current?.(); + grabPointer(event); + dragRef.current = { + type: 'rotate', + pointerId: event.pointerId, + end, + ownerGeneration: useGradientEditStore.getState().generation, + startSpec: session.spec, + moved: false, + addOnClick, + angleOffset: + session.spec.angle - + pointerAngleFromClient(event.clientX, event.clientY, end), + downX: event.clientX, + downY: event.clientY, + }; + attachWindowDrag(); + }; + + // 축 히트 스트립 - 잡은 지점이 축의 어느 절반인지로 회전 기준 방향 결정 + const beginStripPointer = (event: React.PointerEvent) => { + // preventDefault가 기본 포커스 이동을 막으므로 명시 부여 - 화살표 각도 조절이 + // 그리드 키 이동으로 새지 않게 슬라이더가 포커스를 가져간다 + if (event.button === 0) { + event.currentTarget.focus({ preventScroll: true }); + } + const origin = clientOrigin(); + const geo = geoRef.current; + const along = + (event.clientX - origin.x) * (geo?.dirX ?? 0) + + (event.clientY - origin.y) * (geo?.dirY ?? 0); + beginRotateDrag(event, along >= 0 ? 'end' : 'start', true); + }; + + const beginAnchorRotate = + (end: GradientAxisEnd) => (event: React.PointerEvent) => + beginRotateDrag(event, end, false); + + const beginStopDrag = + (index: number) => (event: React.PointerEvent) => { + if (event.button !== 0 || !session) return; + stopAll(event); + detachRef.current?.(); + grabPointer(event); + session.selectStop(index); + dragRef.current = { + type: 'stop', + pointerId: event.pointerId, + index, + moved: false, + ownerGeneration: useGradientEditStore.getState().generation, + startSpec: session.spec, + lastPos: session.spec.stops[index]?.pos ?? 0.5, + posOffset: + (session.spec.stops[index]?.pos ?? 0.5) - + projectionFromClient(event.clientX, event.clientY), + downX: event.clientX, + downY: event.clientY, + }; + attachWindowDrag(); + }; + + // 키보드 각도 조절 - 화살표 ±1°, Shift ±15° (슬라이더 시맨틱) + const handleRotateKeyDown = (event: React.KeyboardEvent) => { + if (!session) return; + let delta = 0; + if (event.key === 'ArrowRight' || event.key === 'ArrowUp') delta = 1; + else if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') delta = -1; + else return; + event.preventDefault(); + event.stopPropagation(); + const step = event.shiftKey ? 15 : 1; + const next = normalizeGradientAxisAngle(session.spec.angle + delta * step); + session.apply(toCanonicalGradient({ ...session.spec, angle: next }), true); + }; + + // 우클릭 삭제 - 최소 2개 유지 (피커 스톱 바와 동일 규칙) + const handleStopContextMenu = + (index: number) => (event: React.MouseEvent) => { + if (!session) return; + event.preventDefault(); + event.stopPropagation(); + if (session.spec.stops.length <= 2) return; + const stops = session.spec.stops.filter( + (_stop, stopIndex) => stopIndex !== index, + ); + const nextSelected = + session.selectedIndex > index + ? session.selectedIndex - 1 + : Math.min(session.selectedIndex, stops.length - 1); + session.selectStop(Math.max(0, nextSelected)); + session.apply(toCanonicalGradient({ ...session.spec, stops }), true); + }; + + return { + rootRef, + angle, + geometry, + dragStop, + isRotating: dragAngle !== null, + beginStripPointer, + beginAnchorRotate, + beginStopDrag, + handleRotateKeyDown, + handleStopContextMenu, + }; +}; From 2a5f7c3e793f0123405a295cbb6e1ac638485402 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 17:34:43 +0900 Subject: [PATCH 160/245] =?UTF-8?q?refactor:=20=EA=B7=B8=EB=A3=B9=20?= =?UTF-8?q?=EB=A6=AC=EC=82=AC=EC=9D=B4=EC=A6=88=20=EA=B3=84=ED=9A=8D?= =?UTF-8?q?=EA=B3=BC=20=EC=84=B8=EC=85=98=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Grid/handles/GroupResizeHandles.test.tsx | 264 ++++++++ .../main/Grid/handles/GroupResizeHandles.tsx | 585 +----------------- .../main/Grid/handles/groupResizePlan.test.ts | 130 ++++ .../main/Grid/handles/groupResizePlan.ts | 367 +++++++++++ .../Grid/handles/useGroupResizeSession.ts | 277 +++++++++ 5 files changed, 1065 insertions(+), 558 deletions(-) create mode 100644 src/renderer/components/main/Grid/handles/GroupResizeHandles.test.tsx create mode 100644 src/renderer/components/main/Grid/handles/groupResizePlan.test.ts create mode 100644 src/renderer/components/main/Grid/handles/groupResizePlan.ts create mode 100644 src/renderer/components/main/Grid/handles/useGroupResizeSession.ts diff --git a/src/renderer/components/main/Grid/handles/GroupResizeHandles.test.tsx b/src/renderer/components/main/Grid/handles/GroupResizeHandles.test.tsx new file mode 100644 index 000000000..55c0a88a3 --- /dev/null +++ b/src/renderer/components/main/Grid/handles/GroupResizeHandles.test.tsx @@ -0,0 +1,264 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { CanonicalEditorDocumentV1 } from '@src/types/editor'; +import type { ElementBounds as SmartGuideElementBounds } from '@utils/grid/smartGuides'; +import GroupResizeHandles from './GroupResizeHandles'; +import type { GroupResizeHandle, GroupResizeResult } from './groupResizePlan'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const mocks = vi.hoisted(() => { + const settingsState = { + gridSettings: { + gridSnapSize: 10, + alignmentGuides: true, + spacingGuides: true, + sizeMatchGuides: true, + }, + }; + const smartGuidesState = { + clearGuides: vi.fn(), + setDraggedBounds: vi.fn(), + setActiveGuides: vi.fn(), + setSpacingGuides: vi.fn(), + setSizeMatchGuides: vi.fn(), + }; + + return { + settingsState, + smartGuidesState, + settingsGetState: vi.fn(() => settingsState), + smartGuidesGetState: vi.fn(() => smartGuidesState), + lockCustomCursor: vi.fn(), + unlockCustomCursor: vi.fn(), + }; +}); + +vi.mock('@utils/core/platform', () => ({ + isMac: () => false, +})); + +vi.mock('@stores/useSettingsStore', () => ({ + useSettingsStore: { + getState: mocks.settingsGetState, + }, +})); + +vi.mock('@stores/grid/useSmartGuidesStore', () => ({ + useSmartGuidesStore: { + getState: mocks.smartGuidesGetState, + }, +})); + +vi.mock('@utils/grid/cursorUtils', () => ({ + clearPendingCustomCursorHover: vi.fn(), + getCursor: () => 'default', + isCustomCursorHoverSuspended: () => false, + lockCustomCursor: mocks.lockCustomCursor, + setCustomCursorHover: vi.fn(), + setPendingCustomCursorHover: vi.fn(), + unlockCustomCursor: mocks.unlockCustomCursor, +})); + +const ID_A = '00000000-0000-0000-0000-000000000001'; +const ID_B = '00000000-0000-0000-0000-000000000002'; + +const positions = { + mode: [ + { id: ID_A, dx: 0, dy: 0, width: 40, height: 40 }, + { id: ID_B, dx: 60, dy: 40, width: 40, height: 40 }, + ], +} as unknown as CanonicalEditorDocumentV1['keyPositions']; + +const mouse = ( + type: string, + clientX: number, + clientY: number, + options: MouseEventInit = {}, +) => + new MouseEvent(type, { + bubbles: true, + cancelable: true, + buttons: type === 'mouseup' ? 0 : 1, + clientX, + clientY, + ...options, + }); + +describe('GroupResizeHandles 세션', () => { + let host: HTMLDivElement; + let root: Root; + let mounted: boolean; + let frameCallbacks: Map; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.settingsState.gridSettings.gridSnapSize = 10; + mocks.settingsState.gridSettings.alignmentGuides = true; + mocks.settingsState.gridSettings.spacingGuides = true; + mocks.settingsState.gridSettings.sizeMatchGuides = true; + frameCallbacks = new Map(); + let nextFrame = 1; + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + const frame = nextFrame++; + frameCallbacks.set(frame, callback); + return frame; + }); + vi.stubGlobal('cancelAnimationFrame', (frame: number) => { + frameCallbacks.delete(frame); + }); + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + mounted = true; + }); + + afterEach(() => { + if (mounted) act(() => root.unmount()); + host.remove(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + const renderHandles = ({ + strategy = 'legacy' as const, + zoom = 1, + onStart = vi.fn<(handle: GroupResizeHandle) => void>(), + onResize = vi.fn<(result: GroupResizeResult) => void>(), + onEnd = vi.fn<() => void>(), + getOtherElements, + }: { + strategy?: 'legacy' | 'frame'; + zoom?: number; + onStart?: (handle: GroupResizeHandle) => void; + onResize?: (result: GroupResizeResult) => void; + onEnd?: () => void; + getOtherElements?: (excludeIds: string[]) => SmartGuideElementBounds[]; + } = {}) => { + act(() => { + root.render( + , + ); + }); + }; + + const startResize = (handleId = 'se') => { + const handle = host.querySelector( + `[data-group-resize-handle="${handleId}"]`, + )!; + act(() => handle.dispatchEvent(mouse('mousedown', 100, 100))); + }; + + it('move마다 최신 store를 읽고 zoom·grid snap을 적용하며 primary modifier는 smart snap을 막는다', () => { + const onResize = vi.fn(); + const getOtherElements = vi.fn(() => []); + renderHandles({ zoom: 2, onResize, getOtherElements }); + startResize(); + + act(() => document.dispatchEvent(mouse('mousemove', 126, 126))); + expect(onResize.mock.calls.at(-1)?.[0].groupBounds).toEqual({ + x: 0, + y: 0, + width: 110, + height: 90, + }); + + mocks.settingsState.gridSettings.gridSnapSize = 5; + act(() => document.dispatchEvent(mouse('mousemove', 126, 126))); + expect(onResize.mock.calls.at(-1)?.[0].groupBounds).toEqual({ + x: 0, + y: 0, + width: 115, + height: 95, + }); + + act(() => + document.dispatchEvent(mouse('mousemove', 126, 126, { ctrlKey: true })), + ); + expect(mocks.settingsGetState).toHaveBeenCalledTimes(6); + expect(mocks.smartGuidesGetState).toHaveBeenCalledTimes(3); + expect(getOtherElements).toHaveBeenCalledTimes(2); + expect(mocks.smartGuidesState.clearGuides).toHaveBeenCalled(); + + act(() => document.dispatchEvent(mouse('mouseup', 126, 126))); + }); + + it('최종 rAF를 flush한 뒤 guides·cursor·end 순서로 정산한다', () => { + const order: string[] = []; + const onStart = vi.fn(() => order.push('start')); + const onResize = vi.fn(() => order.push('resize')); + const onEnd = vi.fn(() => order.push('end')); + mocks.smartGuidesState.clearGuides.mockImplementation(() => + order.push('clear'), + ); + mocks.unlockCustomCursor.mockImplementation(() => order.push('unlock')); + renderHandles({ strategy: 'frame', onStart, onResize, onEnd }); + startResize('e'); + + act(() => document.dispatchEvent(mouse('mousemove', 125, 100))); + expect(onResize).not.toHaveBeenCalled(); + + act(() => document.dispatchEvent(mouse('mouseup', 125, 100))); + + expect(order).toEqual(['start', 'resize', 'clear', 'unlock', 'end']); + expect(frameCallbacks.size).toBe(0); + }); + + it('이동 없이 끝나면 resize callback을 내보내지 않는다', () => { + const onStart = vi.fn(); + const onResize = vi.fn(); + const onEnd = vi.fn(); + renderHandles({ strategy: 'frame', onStart, onResize, onEnd }); + startResize('n'); + + act(() => document.dispatchEvent(mouse('mousemove', 102, 102))); + act(() => document.dispatchEvent(mouse('mouseup', 100, 100))); + + expect(onStart).not.toHaveBeenCalled(); + expect(onResize).not.toHaveBeenCalled(); + expect(onEnd).not.toHaveBeenCalled(); + }); + + it('unmount는 pending move를 flush하고 listener와 cursor를 한 번만 정산한다', () => { + const onResize = vi.fn(); + const onEnd = vi.fn(); + renderHandles({ strategy: 'frame', onResize, onEnd }); + startResize('w'); + act(() => document.dispatchEvent(mouse('mousemove', 80, 100))); + + act(() => root.unmount()); + mounted = false; + + expect(onResize).toHaveBeenCalledTimes(1); + expect(onEnd).toHaveBeenCalledTimes(1); + expect(mocks.smartGuidesState.clearGuides).toHaveBeenCalledTimes(1); + expect(mocks.unlockCustomCursor).toHaveBeenCalledTimes(1); + + act(() => { + document.dispatchEvent(mouse('mousemove', 70, 100)); + document.dispatchEvent(mouse('mouseup', 70, 100)); + }); + expect(onResize).toHaveBeenCalledTimes(1); + expect(onEnd).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/renderer/components/main/Grid/handles/GroupResizeHandles.tsx b/src/renderer/components/main/Grid/handles/GroupResizeHandles.tsx index 616cb368d..7beff8e58 100644 --- a/src/renderer/components/main/Grid/handles/GroupResizeHandles.tsx +++ b/src/renderer/components/main/Grid/handles/GroupResizeHandles.tsx @@ -1,41 +1,24 @@ -import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { isMac } from '@utils/core/platform'; -import { snapToGrid } from '@hooks/Grid/utils'; -import { useSmartGuidesStore } from '@stores/grid/useSmartGuidesStore'; -import { useSettingsStore } from '@stores/useSettingsStore'; -import { - calculateBounds, - calculateSnapPoints, - calculateSizeSnap, - type ElementBounds as SmartGuideElementBounds, - type SizeSnapResult, - type SpacingGuide, -} from '@utils/grid/smartGuides'; +import React, { useEffect, useRef, useState } from 'react'; +import type { ElementBounds as SmartGuideElementBounds } from '@utils/grid/smartGuides'; import { clearPendingCustomCursorHover, - type CursorType, getCursor, isCustomCursorHoverSuspended, - lockCustomCursor, setCustomCursorHover, setPendingCustomCursorHover, - unlockCustomCursor, } from '@utils/grid/cursorUtils'; -import { - createRafLatestScheduler, - type ContinuousInputStrategy, -} from '@utils/animation/rafLatestScheduler'; +import type { ContinuousInputStrategy } from '@utils/animation/rafLatestScheduler'; import type { CanonicalEditorDocumentV1 } from '@src/types/editor'; import type { PluginDisplayElementInternal } from '@src/types/plugin/api'; import { isElementResizable, getElementBounds, calculateGroupBounds, - elementBoundsChanged, type Bounds, type SelectedElement, - type ElementBounds, } from './groupResizeUtils'; +import type { GroupResizeHandle, GroupResizeResult } from './groupResizePlan'; +import { useGroupResizeSession } from './useGroupResizeSession'; /** * 다중 선택 시 그룹 전체를 감싸는 리사이즈 핸들을 표시하는 컴포넌트 @@ -44,21 +27,11 @@ import { // ===== 타입 정의 ===== -interface HandleDef { - id: string; - cursor: CursorType; - x: number; - y: number; - dx: -1 | 0 | 1; - dy: -1 | 0 | 1; - type: 'corner' | 'edge-v' | 'edge-h'; -} - interface HandleProps { - handle: HandleDef; + handle: GroupResizeHandle; centerX: number; centerY: number; - onMouseDown: (e: React.MouseEvent, handle: HandleDef) => void; + onMouseDown: (e: React.MouseEvent, handle: GroupResizeHandle) => void; } interface GroupResizeHandlesProps { @@ -73,43 +46,25 @@ interface GroupResizeHandlesProps { panX?: number; panY?: number; previewGroupBounds?: Bounds | null; - onGroupResizeStart?: (handle: HandleDef) => void; - onGroupResize?: (result: { - groupBounds: Bounds; - elementBounds: ElementBounds[]; - handle: HandleDef; - }) => void; + onGroupResizeStart?: (handle: GroupResizeHandle) => void; + onGroupResize?: (result: GroupResizeResult) => void; onGroupResizeEnd?: () => void; getOtherElements?: (excludeIds: string[]) => SmartGuideElementBounds[]; continuousInputStrategy?: ContinuousInputStrategy; } -interface ResizeState { - isResizing: boolean; - handleId: string | null; - startMouseX: number; - startMouseY: number; - startGroupBounds: Bounds | null; - startElementBounds: ElementBounds[]; - nonResizableElementBounds?: ElementBounds[]; - maxShrinkX: number; - maxShrinkY: number; - handle?: HandleDef; -} - // ===== 조절 가능한 설정 값들 ===== const CORNER_HANDLE_SIZE = 10; // 꼭짓점 핸들의 시각적 크기 (픽셀) const EDGE_HANDLE_WIDTH = 8; // 상하좌우 핸들의 두께 (픽셀) const EDGE_HANDLE_LENGTH = 18; // 상하좌우 핸들의 길이 (픽셀) const HANDLE_HIT_SIZE = 18; // 핸들의 클릭 가능 영역 크기 (픽셀) -const MIN_SIZE = 10; // 최소 크기 (픽셀) const GROUP_BORDER_WIDTH = 3; // 그룹 테두리 두께 (픽셀) // ================================ const HANDLE_HIT_HALF = HANDLE_HIT_SIZE / 2; // 핸들 타입 정의 -const HANDLES: HandleDef[] = [ +const HANDLES: GroupResizeHandle[] = [ { id: 'nw', cursor: 'nwse-resize', @@ -146,7 +101,7 @@ const HANDLES: HandleDef[] = [ // 핸들 시각적 스타일 반환 const getHandleStyle = ( - type: HandleDef['type'], + type: GroupResizeHandle['type'], isHovered: boolean, ): React.CSSProperties => { const baseStyle: React.CSSProperties = { @@ -278,22 +233,6 @@ const GroupResizeHandles = ({ getOtherElements, continuousInputStrategy = 'frame', }: GroupResizeHandlesProps): React.ReactElement | null => { - const resizeRef = useRef({ - isResizing: false, - handleId: null, - startMouseX: 0, - startMouseY: 0, - startGroupBounds: null, - startElementBounds: [], - maxShrinkX: 0, - maxShrinkY: 0, - }); - const activeResizeCleanupRef = useRef<(() => void) | null>(null); - - useLayoutEffect(() => { - return () => activeResizeCleanupRef.current?.(); - }, []); - // 그룹 바운딩 박스 계산 const groupData = calculateGroupBounds( selectedElements, @@ -324,492 +263,22 @@ const GroupResizeHandles = ({ ); const resizableElements = resizabilityInfo.filter((info) => info.isResizable); - const handleMouseDown = (e: React.MouseEvent, handle: HandleDef) => { - e.preventDefault(); - e.stopPropagation(); - lockCustomCursor(handle.cursor, e.nativeEvent); - - if (!groupData) return; - - // 리사이즈 가능한 요소만 처리 (리사이즈 불가능한 요소의 bounds도 기억) - const resizableElementBounds = groupData.elementBounds.filter( - ({ element }) => - isElementResizable( - element, - positions, - statPositions, - graphPositions, - knobPositions, - selectedKeyType, - pluginElements, - ), - ); - - const nonResizableElementBounds = groupData.elementBounds.filter( - ({ element }) => - !isElementResizable( - element, - positions, - statPositions, - graphPositions, - knobPositions, - selectedKeyType, - pluginElements, - ), - ); - - const getMaxShrink = ( - boundsList: ElementBounds[], - groupSize: number, - axis: 'x' | 'y', - ): number => { - if (!Number.isFinite(groupSize) || groupSize <= 0) return 0; - let minScale = 0; - for (const { bounds } of boundsList) { - const size = axis === 'x' ? bounds.width : bounds.height; - if (!Number.isFinite(size) || size <= 0) continue; - if (size >= MIN_SIZE) { - minScale = Math.max(minScale, MIN_SIZE / size); - } - } - const groupMinScale = MIN_SIZE / groupSize; - minScale = Math.min(1, Math.max(minScale, groupMinScale)); - return groupSize * (1 - minScale); - }; - - resizeRef.current = { - isResizing: true, - handleId: handle.id, - startMouseX: e.clientX, - startMouseY: e.clientY, - startGroupBounds: { - x: groupData.x, - y: groupData.y, - width: groupData.width, - height: groupData.height, - }, - startElementBounds: resizableElementBounds, - nonResizableElementBounds: nonResizableElementBounds, - maxShrinkX: getMaxShrink(resizableElementBounds, groupData.width, 'x'), - maxShrinkY: getMaxShrink(resizableElementBounds, groupData.height, 'y'), - handle, - }; - - let resizeStarted = false; - let resizeFinished = false; - - const applyMouseMove = (moveEvent: MouseEvent) => { - if (!resizeRef.current.isResizing) return; - - const { - handle, - startMouseX, - startMouseY, - startGroupBounds, - startElementBounds, - nonResizableElementBounds, - maxShrinkX, - maxShrinkY, - } = resizeRef.current; - - if (!handle || !startGroupBounds) return; - - // 마우스 이동량 계산 (줌 보정) - const rawDeltaX = (moveEvent.clientX - startMouseX) / zoom; - const rawDeltaY = (moveEvent.clientY - startMouseY) / zoom; - - // store에서 스냅 크기 가져오기 - const snapSize = - useSettingsStore.getState().gridSettings?.gridSnapSize ?? 5; - - const snapDelta = (delta: number): number => snapToGrid(delta, snapSize); - - const clampShrinkDelta = ( - delta: number, - handleDir: -1 | 0 | 1, - maxShrink: number, - ): number => { - if (!Number.isFinite(maxShrink) || maxShrink <= 0) return delta; - const maxSnapped = - snapSize > 0 - ? Math.floor(maxShrink / snapSize) * snapSize - : maxShrink; - if (handleDir === -1) { - return Math.min(delta, maxSnapped); - } - if (handleDir === 1) { - return Math.max(delta, -maxSnapped); - } - return delta; - }; - - let snappedDeltaX = handle.dx !== 0 ? snapDelta(rawDeltaX) : 0; - let snappedDeltaY = handle.dy !== 0 ? snapDelta(rawDeltaY) : 0; - - if (handle.dx !== 0) { - snappedDeltaX = clampShrinkDelta(snappedDeltaX, handle.dx, maxShrinkX); - } - if (handle.dy !== 0) { - snappedDeltaY = clampShrinkDelta(snappedDeltaY, handle.dy, maxShrinkY); - } - - // 새 그룹 bounds 계산 - let newGroupX = startGroupBounds.x; - let newGroupY = startGroupBounds.y; - let newGroupWidth = startGroupBounds.width; - let newGroupHeight = startGroupBounds.height; - - // 핸들 방향에 따라 크기 조정 - if (handle.dx === -1) { - newGroupWidth = Math.max( - MIN_SIZE, - startGroupBounds.width - snappedDeltaX, - ); - if (newGroupWidth > MIN_SIZE) { - newGroupX = startGroupBounds.x + snappedDeltaX; - } else { - newGroupX = startGroupBounds.x + startGroupBounds.width - MIN_SIZE; - } - } else if (handle.dx === 1) { - newGroupWidth = Math.max( - MIN_SIZE, - startGroupBounds.width + snappedDeltaX, - ); - } - - if (handle.dy === -1) { - newGroupHeight = Math.max( - MIN_SIZE, - startGroupBounds.height - snappedDeltaY, - ); - if (newGroupHeight > MIN_SIZE) { - newGroupY = startGroupBounds.y + snappedDeltaY; - } else { - newGroupY = startGroupBounds.y + startGroupBounds.height - MIN_SIZE; - } - } else if (handle.dy === 1) { - newGroupHeight = Math.max( - MIN_SIZE, - startGroupBounds.height + snappedDeltaY, - ); - } - - // 최소 크기 보장 - newGroupWidth = Math.max(MIN_SIZE, newGroupWidth); - newGroupHeight = Math.max(MIN_SIZE, newGroupHeight); - - // === 스마트 가이드 스냅 적용 (그룹 바운딩 박스 기준) === - const smartGuidesStore = useSmartGuidesStore.getState(); - const gridSettings = useSettingsStore.getState().gridSettings; - // 플랫폼 primary modifier로 스마트 스냅 일시 해제 (그리드 스냅은 유지) - const suppressSmartSnap = isMac() ? moveEvent.metaKey : moveEvent.ctrlKey; - const alignmentGuidesEnabled = - gridSettings?.alignmentGuides !== false && !suppressSmartSnap; - const spacingGuidesEnabled = gridSettings?.spacingGuides !== false; - const sizeMatchGuidesEnabled = gridSettings?.sizeMatchGuides !== false; - - // 선택된 요소들의 ID 수집 (스마트 가이드에서 제외). - // 선택 id와 가이드 bounds id가 같은 생성자(position.id)를 쓰므로 그대로 넘긴다 - const selectedIds = selectedElements.map((el) => el.id); - - if (suppressSmartSnap) { - smartGuidesStore.clearGuides(); - } else if (getOtherElements && alignmentGuidesEnabled) { - const otherElements = getOtherElements(selectedIds); - - // 그룹 바운딩 박스를 기준으로 스냅 계산 - const groupBoundsForSnap = calculateBounds( - newGroupX, - newGroupY, - newGroupWidth, - newGroupHeight, - 'group', - ); - - const snapResult = calculateSnapPoints( - groupBoundsForSnap, - otherElements, - undefined, - { disableSpacing: !spacingGuidesEnabled, gridSnapSize: snapSize }, - ); - - // X축 스냅 적용 - if ( - handle.dx !== 0 && - snapResult.didSnapX && - !(snapResult.didSpacingSnapX && !spacingGuidesEnabled) - ) { - if (handle.dx === -1) { - // 왼쪽 핸들: 왼쪽 가장자리 스냅 - const widthDiff = newGroupX - snapResult.snappedX; - newGroupX = snapResult.snappedX; - newGroupWidth = newGroupWidth + widthDiff; - } else if (handle.dx === 1) { - // 오른쪽 핸들: 오른쪽 가장자리 스냅 - const snappedRight = snapResult.snappedX + groupBoundsForSnap.width; - newGroupWidth = snappedRight - newGroupX; - } else if (handle.dx === 0) { - // 수직 핸들 (상/하): 중앙 정렬 스냅 - newGroupX = snapResult.snappedX; - } - } - - // Y축 스냅 적용 - if ( - handle.dy !== 0 && - snapResult.didSnapY && - !(snapResult.didSpacingSnapY && !spacingGuidesEnabled) - ) { - if (handle.dy === -1) { - // 위쪽 핸들: 위쪽 가장자리 스냅 - const heightDiff = newGroupY - snapResult.snappedY; - newGroupY = snapResult.snappedY; - newGroupHeight = newGroupHeight + heightDiff; - } else if (handle.dy === 1) { - // 아래쪽 핸들: 아래쪽 가장자리 스냅 - const snappedBottom = - snapResult.snappedY + groupBoundsForSnap.height; - newGroupHeight = snappedBottom - newGroupY; - } else if (handle.dy === 0) { - // 수평 핸들 (좌/우): 중앙 정렬 스냅 - newGroupY = snapResult.snappedY; - } - } - - // Size Matching: 다른 요소와 동일한 크기로 스냅 - let sizeSnapResult: SizeSnapResult | null = null; - if (sizeMatchGuidesEnabled) { - sizeSnapResult = calculateSizeSnap( - newGroupWidth, - newGroupHeight, - otherElements, - 'group', - ); - - if (sizeSnapResult.didSnapWidth) { - if (handle.dx === -1) { - newGroupX = - newGroupX - (sizeSnapResult.snappedWidth - newGroupWidth); - } - newGroupWidth = sizeSnapResult.snappedWidth; - } - - if (sizeSnapResult.didSnapHeight) { - if (handle.dy === -1) { - newGroupY = - newGroupY - (sizeSnapResult.snappedHeight - newGroupHeight); - } - newGroupHeight = sizeSnapResult.snappedHeight; - } - } - - // 가이드라인 업데이트 - const hasAlignSnap = - (handle.dx !== 0 && - snapResult.didSnapX && - !(snapResult.didSpacingSnapX && !spacingGuidesEnabled)) || - (handle.dy !== 0 && - snapResult.didSnapY && - !(snapResult.didSpacingSnapY && !spacingGuidesEnabled)); - const hasSizeSnap = - sizeSnapResult && - (sizeSnapResult.didSnapWidth || sizeSnapResult.didSnapHeight); - - if (hasAlignSnap || hasSizeSnap) { - const snappedBounds = calculateBounds( - newGroupX, - newGroupY, - newGroupWidth, - newGroupHeight, - 'group', - ); - smartGuidesStore.setDraggedBounds(snappedBounds); - - if (hasAlignSnap) { - smartGuidesStore.setActiveGuides(snapResult.guides); - if ( - spacingGuidesEnabled && - snapResult.spacingGuides && - snapResult.spacingGuides.length > 0 - ) { - // 핸들 방향에 따라 간격 가이드 필터링 - const filteredSpacingGuides = snapResult.spacingGuides.filter( - (guide: SpacingGuide) => { - // 수평 방향 간격 가이드 (좌우 간격) - if (guide.direction === 'horizontal') { - // 좌우 핸들이 아니면 표시 안 함 - if (handle.dx === 0) return false; - - // 드래그 중인 요소와 관련된 가이드만 표시 - const isDraggedElement = - guide.fromElementId === 'group' || - guide.toElementId === 'group'; - - if (!isDraggedElement) return false; - - // 왼쪽 핸들(dx: -1): 왼쪽 간격만 표시 - if (handle.dx === -1) { - return guide.toElementId === 'group'; - } - // 오른쪽 핸들(dx: 1): 오른쪽 간격만 표시 - if (handle.dx === 1) { - return guide.fromElementId === 'group'; - } - } - - // 수직 방향 간격 가이드 (상하 간격) - if (guide.direction === 'vertical') { - // 상하 핸들이 아니면 표시 안 함 - if (handle.dy === 0) return false; - - // 드래그 중인 요소와 관련된 가이드만 표시 - const isDraggedElement = - guide.fromElementId === 'group' || - guide.toElementId === 'group'; - - if (!isDraggedElement) return false; - - // 위쪽 핸들(dy: -1): 위쪽 간격만 표시 - if (handle.dy === -1) { - return guide.toElementId === 'group'; - } - // 아래쪽 핸들(dy: 1): 아래쪽 간격만 표시 - if (handle.dy === 1) { - return guide.fromElementId === 'group'; - } - } - - return false; - }, - ); - smartGuidesStore.setSpacingGuides(filteredSpacingGuides); - } else { - smartGuidesStore.setSpacingGuides([]); - } - } else { - smartGuidesStore.setActiveGuides([]); - smartGuidesStore.setSpacingGuides([]); - } - - if (hasSizeSnap) { - smartGuidesStore.setSizeMatchGuides( - sizeSnapResult!.sizeMatchGuides, - ); - } else { - smartGuidesStore.setSizeMatchGuides([]); - } - } else { - smartGuidesStore.clearGuides(); - } - } - - // 최소 크기 재보장 (스마트 가이드 스냅 후) - newGroupWidth = Math.max(MIN_SIZE, newGroupWidth); - newGroupHeight = Math.max(MIN_SIZE, newGroupHeight); - - // 스케일 비율 계산 (스냅 적용된 그룹 bounds 기준) - const scaleX = - startGroupBounds.width > 0 ? newGroupWidth / startGroupBounds.width : 1; - const scaleY = - startGroupBounds.height > 0 - ? newGroupHeight / startGroupBounds.height - : 1; - - // 각 리사이즈 가능한 요소의 새 위치/크기 계산 (그룹 스케일만 적용) - const newElementBounds: ElementBounds[] = startElementBounds.map( - ({ element, bounds }) => { - // 그룹 내에서의 상대 위치 - const relativeX = bounds.x - startGroupBounds.x; - const relativeY = bounds.y - startGroupBounds.y; - - // 새 위치 계산 (스케일 적용) - const newX = newGroupX + relativeX * scaleX; - const newY = newGroupY + relativeY * scaleY; - const newWidth = bounds.width * scaleX; - const newHeight = bounds.height * scaleY; - - // 내부 요소는 스케일만 적용해 비율을 유지한다. (스냅은 그룹 bounds에서만 처리) - - return { - element, - bounds: { - x: newX, - y: newY, - width: newWidth, - height: newHeight, - }, - }; - }, - ); - - // 새 그룹 bounds 계산 - // - 요소별 스냅 결과로 min/max가 흔들리면(특히 왼쪽 핸들) 프리뷰 선이 좌우로 떨릴 수 있어, - // 프리뷰는 "그룹 변환 결과"를 기준으로 유지한다. - let finalGroupMinX = newGroupX; - let finalGroupMinY = newGroupY; - let finalGroupMaxX = newGroupX + newGroupWidth; - let finalGroupMaxY = newGroupY + newGroupHeight; - - // 리사이즈 불가능한 요소들 (원래 위치 유지) - for (const { bounds } of nonResizableElementBounds || []) { - finalGroupMinX = Math.min(finalGroupMinX, bounds.x); - finalGroupMinY = Math.min(finalGroupMinY, bounds.y); - finalGroupMaxX = Math.max(finalGroupMaxX, bounds.x + bounds.width); - finalGroupMaxY = Math.max(finalGroupMaxY, bounds.y + bounds.height); - } - - const result = { - groupBounds: { - x: finalGroupMinX, - y: finalGroupMinY, - width: finalGroupMaxX - finalGroupMinX, - height: finalGroupMaxY - finalGroupMinY, - }, - elementBounds: newElementBounds, - handle, - }; - const changed = elementBoundsChanged( - startElementBounds, - newElementBounds, - ); - if (!resizeStarted && changed) { - resizeStarted = true; - onGroupResizeStart?.(handle); - } - if (resizeStarted) onGroupResize?.(result); - }; - const moveScheduler = createRafLatestScheduler( - applyMouseMove, - continuousInputStrategy, - ); - const handleMouseMove = (moveEvent: MouseEvent) => { - moveScheduler.push(moveEvent); - }; - - const handleMouseUp = () => { - if (resizeFinished) return; - moveScheduler.flush(); - resizeFinished = true; - resizeRef.current.isResizing = false; - activeResizeCleanupRef.current = null; - // 스마트 가이드 클리어 - useSmartGuidesStore.getState().clearGuides(); - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - window.removeEventListener('blur', handleMouseUp); - window.removeEventListener('pointercancel', handleMouseUp); - unlockCustomCursor(); - if (resizeStarted) onGroupResizeEnd?.(); - moveScheduler.cancel(); - }; - - activeResizeCleanupRef.current = handleMouseUp; - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - window.addEventListener('blur', handleMouseUp); - window.addEventListener('pointercancel', handleMouseUp); - }; - + const handleMouseDown = useGroupResizeSession({ + groupData, + selectedElements, + positions, + statPositions, + graphPositions, + knobPositions, + selectedKeyType, + pluginElements, + zoom, + onGroupResizeStart, + onGroupResize, + onGroupResizeEnd, + getOtherElements, + continuousInputStrategy, + }); if (!groupData || selectedElements.length < 2) return null; // 표시할 bounds (프리뷰 또는 실제) diff --git a/src/renderer/components/main/Grid/handles/groupResizePlan.test.ts b/src/renderer/components/main/Grid/handles/groupResizePlan.test.ts new file mode 100644 index 000000000..02d502dfd --- /dev/null +++ b/src/renderer/components/main/Grid/handles/groupResizePlan.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest'; + +import { + calculateGroupResizePlan, + type GroupResizeHandle, +} from './groupResizePlan'; +import type { ElementBounds } from './groupResizeUtils'; + +const ID_A = '00000000-0000-0000-0000-000000000001'; +const ID_B = '00000000-0000-0000-0000-000000000002'; + +const elementBounds: ElementBounds[] = [ + { + element: { type: 'key', id: ID_A, index: 0 }, + bounds: { x: 100, y: 200, width: 40, height: 40 }, + }, + { + element: { type: 'key', id: ID_B, index: 1 }, + bounds: { x: 160, y: 240, width: 40, height: 40 }, + }, +]; + +const handle = ( + id: string, + dx: -1 | 0 | 1, + dy: -1 | 0 | 1, +): GroupResizeHandle => ({ + id, + cursor: + dx === 0 + ? 'ns-resize' + : dy === 0 + ? 'ew-resize' + : id === 'nw' || id === 'se' + ? 'nwse-resize' + : 'nesw-resize', + x: 0, + y: 0, + dx, + dy, + type: dx !== 0 && dy !== 0 ? 'corner' : dy !== 0 ? 'edge-h' : 'edge-v', +}); + +const calculate = ( + resizeHandle: GroupResizeHandle, + pointerX = 20, + pointerY = 10, + overrides: Partial[0]> = {}, +) => + calculateGroupResizePlan({ + handle: resizeHandle, + startMouseX: 0, + startMouseY: 0, + pointerX, + pointerY, + zoom: 1, + snapSize: 1, + startGroupBounds: { x: 100, y: 200, width: 100, height: 80 }, + startElementBounds: elementBounds, + maxShrinkX: 90, + maxShrinkY: 70, + smartSnap: { type: 'unchanged' }, + ...overrides, + }); + +describe('calculateGroupResizePlan', () => { + it.each([ + ['nw', -1, -1, { x: 120, y: 210, width: 80, height: 70 }], + ['n', 0, -1, { x: 100, y: 210, width: 100, height: 70 }], + ['ne', 1, -1, { x: 100, y: 210, width: 120, height: 70 }], + ['w', -1, 0, { x: 120, y: 200, width: 80, height: 80 }], + ['e', 1, 0, { x: 100, y: 200, width: 120, height: 80 }], + ['sw', -1, 1, { x: 120, y: 200, width: 80, height: 90 }], + ['s', 0, 1, { x: 100, y: 200, width: 100, height: 90 }], + ['se', 1, 1, { x: 100, y: 200, width: 120, height: 90 }], + ] as const)('%s 방향은 고정 반대편을 유지한다', (id, dx, dy, expected) => { + expect(calculate(handle(id, dx, dy)).result.groupBounds).toEqual(expected); + }); + + it('corner는 두 축을, edge는 자기 축만 요소에 스케일한다', () => { + const corner = calculate(handle('se', 1, 1)).result.elementBounds[0]; + const edge = calculate(handle('e', 1, 0)).result.elementBounds[0]; + + expect(corner.bounds).toEqual({ + x: 100, + y: 200, + width: 48, + height: 45, + }); + expect(edge.bounds).toEqual({ + x: 100, + y: 200, + width: 48, + height: 40, + }); + }); + + it('client delta를 zoom으로 보정한 뒤 grid에 스냅한다', () => { + const plan = calculate(handle('se', 1, 1), 26, 26, { + zoom: 2, + snapSize: 5, + }); + + expect(plan.result.groupBounds).toEqual({ + x: 100, + y: 200, + width: 115, + height: 95, + }); + }); + + it('리사이즈 불가능한 요소는 원래 bounds를 유지한 채 최종 그룹에 포함한다', () => { + const fixed: ElementBounds = { + element: { type: 'plugin', id: 'fixed' }, + bounds: { x: 80, y: 180, width: 30, height: 30 }, + }; + const plan = calculate(handle('se', 1, 1), 20, 10, { + nonResizableElementBounds: [fixed], + }); + + expect(plan.result.elementBounds).toHaveLength(2); + expect(plan.result.groupBounds).toEqual({ + x: 80, + y: 180, + width: 140, + height: 110, + }); + expect(fixed.bounds).toEqual({ x: 80, y: 180, width: 30, height: 30 }); + }); +}); diff --git a/src/renderer/components/main/Grid/handles/groupResizePlan.ts b/src/renderer/components/main/Grid/handles/groupResizePlan.ts new file mode 100644 index 000000000..76b40a281 --- /dev/null +++ b/src/renderer/components/main/Grid/handles/groupResizePlan.ts @@ -0,0 +1,367 @@ +import { snapToGrid } from '@hooks/Grid/utils'; +import type { CursorType } from '@utils/grid/cursorUtils'; +import { + calculateBounds, + calculateSizeSnap, + calculateSnapPoints, + type ElementBounds as SmartGuideElementBounds, + type GuideLine, + type SizeMatchGuide, + type SizeSnapResult, + type SpacingGuide, +} from '@utils/grid/smartGuides'; +import type { Bounds, ElementBounds } from './groupResizeUtils'; + +export const GROUP_RESIZE_MIN_SIZE = 10; + +export interface GroupResizeHandle { + id: string; + cursor: CursorType; + x: number; + y: number; + dx: -1 | 0 | 1; + dy: -1 | 0 | 1; + type: 'corner' | 'edge-v' | 'edge-h'; +} + +export interface GroupResizeResult { + groupBounds: Bounds; + elementBounds: ElementBounds[]; + handle: GroupResizeHandle; +} + +export type GroupResizeGuidePlan = + | { type: 'unchanged' } + | { type: 'clear' } + | { + type: 'set'; + draggedBounds: SmartGuideElementBounds; + activeGuides: GuideLine[]; + spacingGuides: SpacingGuide[]; + sizeMatchGuides: SizeMatchGuide[]; + }; + +type SmartSnapPlan = + | { type: 'suppressed' } + | { type: 'unchanged' } + | { + type: 'enabled'; + otherElements: SmartGuideElementBounds[]; + spacingGuidesEnabled: boolean; + sizeMatchGuidesEnabled: boolean; + }; + +interface CalculateGroupResizePlanOptions { + handle: GroupResizeHandle; + startMouseX: number; + startMouseY: number; + pointerX: number; + pointerY: number; + zoom: number; + snapSize: number; + startGroupBounds: Bounds; + startElementBounds: ElementBounds[]; + nonResizableElementBounds?: ElementBounds[]; + maxShrinkX: number; + maxShrinkY: number; + smartSnap: SmartSnapPlan; +} + +export interface GroupResizePlan { + result: GroupResizeResult; + guides: GroupResizeGuidePlan; +} + +export const calculateMaxGroupShrink = ( + boundsList: ElementBounds[], + groupSize: number, + axis: 'x' | 'y', + minSize: number, +): number => { + if (!Number.isFinite(groupSize) || groupSize <= 0) return 0; + let minScale = 0; + for (const { bounds } of boundsList) { + const size = axis === 'x' ? bounds.width : bounds.height; + if (!Number.isFinite(size) || size <= 0) continue; + if (size >= minSize) { + minScale = Math.max(minScale, minSize / size); + } + } + const groupMinScale = minSize / groupSize; + minScale = Math.min(1, Math.max(minScale, groupMinScale)); + return groupSize * (1 - minScale); +}; + +const clampShrinkDelta = ( + delta: number, + handleDir: -1 | 0 | 1, + maxShrink: number, + snapSize: number, +): number => { + if (!Number.isFinite(maxShrink) || maxShrink <= 0) return delta; + const maxSnapped = + snapSize > 0 ? Math.floor(maxShrink / snapSize) * snapSize : maxShrink; + if (handleDir === -1) return Math.min(delta, maxSnapped); + if (handleDir === 1) return Math.max(delta, -maxSnapped); + return delta; +}; + +const filterSpacingGuides = ( + guides: SpacingGuide[], + handle: GroupResizeHandle, +): SpacingGuide[] => + guides.filter((guide) => { + if (guide.direction === 'horizontal') { + if (handle.dx === 0) return false; + const isDraggedElement = + guide.fromElementId === 'group' || guide.toElementId === 'group'; + if (!isDraggedElement) return false; + if (handle.dx === -1) return guide.toElementId === 'group'; + if (handle.dx === 1) return guide.fromElementId === 'group'; + } + + if (guide.direction === 'vertical') { + if (handle.dy === 0) return false; + const isDraggedElement = + guide.fromElementId === 'group' || guide.toElementId === 'group'; + if (!isDraggedElement) return false; + if (handle.dy === -1) return guide.toElementId === 'group'; + if (handle.dy === 1) return guide.fromElementId === 'group'; + } + + return false; + }); + +export const calculateGroupResizePlan = ({ + handle, + startMouseX, + startMouseY, + pointerX, + pointerY, + zoom, + snapSize, + startGroupBounds, + startElementBounds, + nonResizableElementBounds, + maxShrinkX, + maxShrinkY, + smartSnap, +}: CalculateGroupResizePlanOptions): GroupResizePlan => { + const rawDeltaX = (pointerX - startMouseX) / zoom; + const rawDeltaY = (pointerY - startMouseY) / zoom; + + let snappedDeltaX = handle.dx !== 0 ? snapToGrid(rawDeltaX, snapSize) : 0; + let snappedDeltaY = handle.dy !== 0 ? snapToGrid(rawDeltaY, snapSize) : 0; + + if (handle.dx !== 0) { + snappedDeltaX = clampShrinkDelta( + snappedDeltaX, + handle.dx, + maxShrinkX, + snapSize, + ); + } + if (handle.dy !== 0) { + snappedDeltaY = clampShrinkDelta( + snappedDeltaY, + handle.dy, + maxShrinkY, + snapSize, + ); + } + + const minSize = GROUP_RESIZE_MIN_SIZE; + let newGroupX = startGroupBounds.x; + let newGroupY = startGroupBounds.y; + let newGroupWidth = startGroupBounds.width; + let newGroupHeight = startGroupBounds.height; + + if (handle.dx === -1) { + newGroupWidth = Math.max(minSize, startGroupBounds.width - snappedDeltaX); + if (newGroupWidth > minSize) { + newGroupX = startGroupBounds.x + snappedDeltaX; + } else { + newGroupX = startGroupBounds.x + startGroupBounds.width - minSize; + } + } else if (handle.dx === 1) { + newGroupWidth = Math.max(minSize, startGroupBounds.width + snappedDeltaX); + } + + if (handle.dy === -1) { + newGroupHeight = Math.max(minSize, startGroupBounds.height - snappedDeltaY); + if (newGroupHeight > minSize) { + newGroupY = startGroupBounds.y + snappedDeltaY; + } else { + newGroupY = startGroupBounds.y + startGroupBounds.height - minSize; + } + } else if (handle.dy === 1) { + newGroupHeight = Math.max(minSize, startGroupBounds.height + snappedDeltaY); + } + + newGroupWidth = Math.max(minSize, newGroupWidth); + newGroupHeight = Math.max(minSize, newGroupHeight); + + let guides: GroupResizeGuidePlan = { type: 'unchanged' }; + if (smartSnap.type === 'suppressed') { + guides = { type: 'clear' }; + } else if (smartSnap.type === 'enabled') { + const groupBoundsForSnap = calculateBounds( + newGroupX, + newGroupY, + newGroupWidth, + newGroupHeight, + 'group', + ); + const snapResult = calculateSnapPoints( + groupBoundsForSnap, + smartSnap.otherElements, + undefined, + { + disableSpacing: !smartSnap.spacingGuidesEnabled, + gridSnapSize: snapSize, + }, + ); + + if ( + handle.dx !== 0 && + snapResult.didSnapX && + !(snapResult.didSpacingSnapX && !smartSnap.spacingGuidesEnabled) + ) { + if (handle.dx === -1) { + const widthDiff = newGroupX - snapResult.snappedX; + newGroupX = snapResult.snappedX; + newGroupWidth = newGroupWidth + widthDiff; + } else if (handle.dx === 1) { + const snappedRight = snapResult.snappedX + groupBoundsForSnap.width; + newGroupWidth = snappedRight - newGroupX; + } else if (handle.dx === 0) { + newGroupX = snapResult.snappedX; + } + } + + if ( + handle.dy !== 0 && + snapResult.didSnapY && + !(snapResult.didSpacingSnapY && !smartSnap.spacingGuidesEnabled) + ) { + if (handle.dy === -1) { + const heightDiff = newGroupY - snapResult.snappedY; + newGroupY = snapResult.snappedY; + newGroupHeight = newGroupHeight + heightDiff; + } else if (handle.dy === 1) { + const snappedBottom = snapResult.snappedY + groupBoundsForSnap.height; + newGroupHeight = snappedBottom - newGroupY; + } else if (handle.dy === 0) { + newGroupY = snapResult.snappedY; + } + } + + let sizeSnapResult: SizeSnapResult | null = null; + if (smartSnap.sizeMatchGuidesEnabled) { + sizeSnapResult = calculateSizeSnap( + newGroupWidth, + newGroupHeight, + smartSnap.otherElements, + 'group', + ); + + if (sizeSnapResult.didSnapWidth) { + if (handle.dx === -1) { + newGroupX = newGroupX - (sizeSnapResult.snappedWidth - newGroupWidth); + } + newGroupWidth = sizeSnapResult.snappedWidth; + } + + if (sizeSnapResult.didSnapHeight) { + if (handle.dy === -1) { + newGroupY = + newGroupY - (sizeSnapResult.snappedHeight - newGroupHeight); + } + newGroupHeight = sizeSnapResult.snappedHeight; + } + } + + const hasAlignSnap = + (handle.dx !== 0 && + snapResult.didSnapX && + !(snapResult.didSpacingSnapX && !smartSnap.spacingGuidesEnabled)) || + (handle.dy !== 0 && + snapResult.didSnapY && + !(snapResult.didSpacingSnapY && !smartSnap.spacingGuidesEnabled)); + const hasSizeSnap = + sizeSnapResult && + (sizeSnapResult.didSnapWidth || sizeSnapResult.didSnapHeight); + + if (hasAlignSnap || hasSizeSnap) { + guides = { + type: 'set', + draggedBounds: calculateBounds( + newGroupX, + newGroupY, + newGroupWidth, + newGroupHeight, + 'group', + ), + activeGuides: hasAlignSnap ? snapResult.guides : [], + spacingGuides: + hasAlignSnap && + smartSnap.spacingGuidesEnabled && + snapResult.spacingGuides && + snapResult.spacingGuides.length > 0 + ? filterSpacingGuides(snapResult.spacingGuides, handle) + : [], + sizeMatchGuides: hasSizeSnap ? sizeSnapResult!.sizeMatchGuides : [], + }; + } else { + guides = { type: 'clear' }; + } + } + + newGroupWidth = Math.max(minSize, newGroupWidth); + newGroupHeight = Math.max(minSize, newGroupHeight); + + const scaleX = + startGroupBounds.width > 0 ? newGroupWidth / startGroupBounds.width : 1; + const scaleY = + startGroupBounds.height > 0 ? newGroupHeight / startGroupBounds.height : 1; + const newElementBounds: ElementBounds[] = startElementBounds.map( + ({ element, bounds }) => { + const relativeX = bounds.x - startGroupBounds.x; + const relativeY = bounds.y - startGroupBounds.y; + return { + element, + bounds: { + x: newGroupX + relativeX * scaleX, + y: newGroupY + relativeY * scaleY, + width: bounds.width * scaleX, + height: bounds.height * scaleY, + }, + }; + }, + ); + + let finalGroupMinX = newGroupX; + let finalGroupMinY = newGroupY; + let finalGroupMaxX = newGroupX + newGroupWidth; + let finalGroupMaxY = newGroupY + newGroupHeight; + for (const { bounds } of nonResizableElementBounds || []) { + finalGroupMinX = Math.min(finalGroupMinX, bounds.x); + finalGroupMinY = Math.min(finalGroupMinY, bounds.y); + finalGroupMaxX = Math.max(finalGroupMaxX, bounds.x + bounds.width); + finalGroupMaxY = Math.max(finalGroupMaxY, bounds.y + bounds.height); + } + + return { + result: { + groupBounds: { + x: finalGroupMinX, + y: finalGroupMinY, + width: finalGroupMaxX - finalGroupMinX, + height: finalGroupMaxY - finalGroupMinY, + }, + elementBounds: newElementBounds, + handle, + }, + guides, + }; +}; diff --git a/src/renderer/components/main/Grid/handles/useGroupResizeSession.ts b/src/renderer/components/main/Grid/handles/useGroupResizeSession.ts new file mode 100644 index 000000000..e0ecc81f7 --- /dev/null +++ b/src/renderer/components/main/Grid/handles/useGroupResizeSession.ts @@ -0,0 +1,277 @@ +import { + useLayoutEffect, + useRef, + type MouseEvent as ReactMouseEvent, +} from 'react'; + +import { isMac } from '@utils/core/platform'; +import { useSmartGuidesStore } from '@stores/grid/useSmartGuidesStore'; +import { useSettingsStore } from '@stores/useSettingsStore'; +import { lockCustomCursor, unlockCustomCursor } from '@utils/grid/cursorUtils'; +import { + createRafLatestScheduler, + type ContinuousInputStrategy, +} from '@utils/animation/rafLatestScheduler'; +import type { ElementBounds as SmartGuideElementBounds } from '@utils/grid/smartGuides'; +import type { CanonicalEditorDocumentV1 } from '@src/types/editor'; +import type { PluginDisplayElementInternal } from '@src/types/plugin/api'; +import { + calculateGroupResizePlan, + calculateMaxGroupShrink, + GROUP_RESIZE_MIN_SIZE, + type GroupResizeGuidePlan, + type GroupResizeHandle, + type GroupResizeResult, +} from './groupResizePlan'; +import { + elementBoundsChanged, + isElementResizable, + type Bounds, + type ElementBounds, + type SelectedElement, +} from './groupResizeUtils'; + +interface GroupResizeData extends Bounds { + elementBounds: ElementBounds[]; +} + +interface ResizeState { + isResizing: boolean; + handleId: string | null; + startMouseX: number; + startMouseY: number; + startGroupBounds: Bounds | null; + startElementBounds: ElementBounds[]; + nonResizableElementBounds?: ElementBounds[]; + maxShrinkX: number; + maxShrinkY: number; + handle?: GroupResizeHandle; +} + +interface UseGroupResizeSessionOptions { + groupData: GroupResizeData | null; + selectedElements: SelectedElement[]; + positions: CanonicalEditorDocumentV1['keyPositions']; + statPositions: CanonicalEditorDocumentV1['statPositions']; + graphPositions: CanonicalEditorDocumentV1['graphPositions']; + knobPositions: CanonicalEditorDocumentV1['knobPositions']; + selectedKeyType: string; + pluginElements: PluginDisplayElementInternal[]; + zoom: number; + onGroupResizeStart?: (handle: GroupResizeHandle) => void; + onGroupResize?: (result: GroupResizeResult) => void; + onGroupResizeEnd?: () => void; + getOtherElements?: (excludeIds: string[]) => SmartGuideElementBounds[]; + continuousInputStrategy: ContinuousInputStrategy; +} + +const applyGuidePlan = ( + smartGuidesStore: ReturnType, + guides: GroupResizeGuidePlan, +) => { + if (guides.type === 'clear') { + smartGuidesStore.clearGuides(); + return; + } + if (guides.type === 'unchanged') return; + + smartGuidesStore.setDraggedBounds(guides.draggedBounds); + smartGuidesStore.setActiveGuides(guides.activeGuides); + smartGuidesStore.setSpacingGuides(guides.spacingGuides); + smartGuidesStore.setSizeMatchGuides(guides.sizeMatchGuides); +}; + +export const useGroupResizeSession = ({ + groupData, + selectedElements, + positions, + statPositions, + graphPositions, + knobPositions, + selectedKeyType, + pluginElements, + zoom, + onGroupResizeStart, + onGroupResize, + onGroupResizeEnd, + getOtherElements, + continuousInputStrategy, +}: UseGroupResizeSessionOptions) => { + const resizeRef = useRef({ + isResizing: false, + handleId: null, + startMouseX: 0, + startMouseY: 0, + startGroupBounds: null, + startElementBounds: [], + maxShrinkX: 0, + maxShrinkY: 0, + }); + const activeResizeCleanupRef = useRef<(() => void) | null>(null); + + useLayoutEffect(() => { + return () => activeResizeCleanupRef.current?.(); + }, []); + + const handleMouseDown = ( + event: ReactMouseEvent, + handle: GroupResizeHandle, + ) => { + event.preventDefault(); + event.stopPropagation(); + lockCustomCursor(handle.cursor, event.nativeEvent); + + if (!groupData) return; + + const resizableElementBounds = groupData.elementBounds.filter( + ({ element }) => + isElementResizable( + element, + positions, + statPositions, + graphPositions, + knobPositions, + selectedKeyType, + pluginElements, + ), + ); + const nonResizableElementBounds = groupData.elementBounds.filter( + ({ element }) => + !isElementResizable( + element, + positions, + statPositions, + graphPositions, + knobPositions, + selectedKeyType, + pluginElements, + ), + ); + + resizeRef.current = { + isResizing: true, + handleId: handle.id, + startMouseX: event.clientX, + startMouseY: event.clientY, + startGroupBounds: { + x: groupData.x, + y: groupData.y, + width: groupData.width, + height: groupData.height, + }, + startElementBounds: resizableElementBounds, + nonResizableElementBounds, + maxShrinkX: calculateMaxGroupShrink( + resizableElementBounds, + groupData.width, + 'x', + GROUP_RESIZE_MIN_SIZE, + ), + maxShrinkY: calculateMaxGroupShrink( + resizableElementBounds, + groupData.height, + 'y', + GROUP_RESIZE_MIN_SIZE, + ), + handle, + }; + + let resizeStarted = false; + let resizeFinished = false; + + const applyMouseMove = (moveEvent: MouseEvent) => { + if (!resizeRef.current.isResizing) return; + const { + handle: activeHandle, + startMouseX, + startMouseY, + startGroupBounds, + startElementBounds, + nonResizableElementBounds, + maxShrinkX, + maxShrinkY, + } = resizeRef.current; + if (!activeHandle || !startGroupBounds) return; + + const snapSize = + useSettingsStore.getState().gridSettings?.gridSnapSize ?? 5; + const smartGuidesStore = useSmartGuidesStore.getState(); + const gridSettings = useSettingsStore.getState().gridSettings; + const suppressSmartSnap = isMac() ? moveEvent.metaKey : moveEvent.ctrlKey; + const alignmentGuidesEnabled = + gridSettings?.alignmentGuides !== false && !suppressSmartSnap; + const spacingGuidesEnabled = gridSettings?.spacingGuides !== false; + const sizeMatchGuidesEnabled = gridSettings?.sizeMatchGuides !== false; + const selectedIds = selectedElements.map((element) => element.id); + + const smartSnap = suppressSmartSnap + ? ({ type: 'suppressed' } as const) + : getOtherElements && alignmentGuidesEnabled + ? ({ + type: 'enabled', + otherElements: getOtherElements(selectedIds), + spacingGuidesEnabled, + sizeMatchGuidesEnabled, + } as const) + : ({ type: 'unchanged' } as const); + const plan = calculateGroupResizePlan({ + handle: activeHandle, + startMouseX, + startMouseY, + pointerX: moveEvent.clientX, + pointerY: moveEvent.clientY, + zoom, + snapSize, + startGroupBounds, + startElementBounds, + nonResizableElementBounds, + maxShrinkX, + maxShrinkY, + smartSnap, + }); + + applyGuidePlan(smartGuidesStore, plan.guides); + + const changed = elementBoundsChanged( + startElementBounds, + plan.result.elementBounds, + ); + if (!resizeStarted && changed) { + resizeStarted = true; + onGroupResizeStart?.(activeHandle); + } + if (resizeStarted) onGroupResize?.(plan.result); + }; + const moveScheduler = createRafLatestScheduler( + applyMouseMove, + continuousInputStrategy, + ); + const handleMouseMove = (moveEvent: MouseEvent) => { + moveScheduler.push(moveEvent); + }; + + const handleMouseUp = () => { + if (resizeFinished) return; + moveScheduler.flush(); + resizeFinished = true; + resizeRef.current.isResizing = false; + activeResizeCleanupRef.current = null; + useSmartGuidesStore.getState().clearGuides(); + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + window.removeEventListener('blur', handleMouseUp); + window.removeEventListener('pointercancel', handleMouseUp); + unlockCustomCursor(); + if (resizeStarted) onGroupResizeEnd?.(); + moveScheduler.cancel(); + }; + + activeResizeCleanupRef.current = handleMouseUp; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + window.addEventListener('blur', handleMouseUp); + window.addEventListener('pointercancel', handleMouseUp); + }; + + return handleMouseDown; +}; From ad3b89ba95626df960f21f0fde74556f554ab9d1 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 17:35:55 +0900 Subject: [PATCH 161/245] =?UTF-8?q?test:=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=9E=90=EC=82=B0=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=84=20=EA=B3=84=EC=95=BD=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/state/migration/tests.rs | 56 ++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/state/migration/tests.rs b/src-tauri/src/state/migration/tests.rs index 7a393e30e..7a65b387a 100644 --- a/src-tauri/src/state/migration/tests.rs +++ b/src-tauri/src/state/migration/tests.rs @@ -1,8 +1,10 @@ use super::{ - load_store_from_path, migrate_local_fonts_to_app_data, migrate_sound_library_enabled, - normalize_state, parse_portable_asset_reference, recover_key_mapping_entries, - rehome_foreign_asset_references, repair_image_transforms, rgba_to_hex, AssetCategory, - LEGACY_OVERLAY_HEIGHT, LEGACY_OVERLAY_WIDTH, LEGACY_PANEL_DETACH_ENABLED_KEY, + decode_font_data_url, decode_image_data_url, load_store_from_path, + migrate_local_fonts_to_app_data, migrate_sound_library_enabled, normalize_font_extension, + normalize_image_extension, normalize_state, parse_portable_asset_reference, + recover_key_mapping_entries, rehome_foreign_asset_references, repair_image_transforms, + rgba_to_hex, AssetCategory, LEGACY_OVERLAY_HEIGHT, LEGACY_OVERLAY_WIDTH, + LEGACY_PANEL_DETACH_ENABLED_KEY, }; use crate::{ defaults::{default_keys, default_positions}, @@ -3525,6 +3527,52 @@ fn missing_local_font_is_restored_from_embedded_data_uri() { let _ = std::fs::remove_dir_all(app_data_dir); } +#[test] +fn asset_data_url_mime_and_extension_fallback_order_is_stable() { + let encoded = BASE64_STANDARD.encode(b"asset-bytes"); + + assert_eq!( + decode_font_data_url(&format!("data:font/woff2;base64,{encoded}"), Some("otf")), + Some((b"asset-bytes".to_vec(), "woff2".to_string())) + ); + assert_eq!( + decode_font_data_url( + &format!("data:font/custom;base64,{encoded}"), + Some(" WOFF ") + ), + Some((b"asset-bytes".to_vec(), "woff".to_string())) + ); + assert_eq!( + decode_font_data_url( + &format!("data:application/octet-stream;base64,{encoded}"), + Some("woff2") + ), + None + ); + assert_eq!( + decode_font_data_url("data:font/woff2;base64,%%%", Some("otf")), + None + ); + assert_eq!(normalize_font_extension(Some(" OTF ")), "otf"); + assert_eq!(normalize_font_extension(Some("unknown")), "ttf"); + + assert_eq!( + decode_image_data_url(&format!("data:image/jpeg;base64,{encoded}")), + Some((b"asset-bytes".to_vec(), "jpg".to_string())) + ); + assert_eq!( + decode_image_data_url(&format!("data:image/unknown;base64,{encoded}")), + Some((b"asset-bytes".to_vec(), "png".to_string())) + ); + assert_eq!( + decode_image_data_url(&format!("data:font/woff2;base64,{encoded}")), + None + ); + assert_eq!(decode_image_data_url("data:image/png;base64,%%%"), None); + assert_eq!(normalize_image_extension(Some(" JPEG ")), "jpg"); + assert_eq!(normalize_image_extension(Some("unknown")), "png"); +} + #[test] fn portable_asset_reference_parser_accepts_cross_platform_absolute_forms() { let cases = [ From 7a4200e082447029992f389ab49085fe1dfeba20 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 17:35:58 +0900 Subject: [PATCH 162/245] =?UTF-8?q?refactor:=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=9E=90=EC=82=B0=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/state/migration.rs | 655 +----------------------- src-tauri/src/state/migration/assets.rs | 655 ++++++++++++++++++++++++ 2 files changed, 666 insertions(+), 644 deletions(-) create mode 100644 src-tauri/src/state/migration/assets.rs diff --git a/src-tauri/src/state/migration.rs b/src-tauri/src/state/migration.rs index 5649b5b73..da5cbb851 100644 --- a/src-tauri/src/state/migration.rs +++ b/src-tauri/src/state/migration.rs @@ -6,17 +6,9 @@ use std::{ }; use anyhow::{Context, Result}; -use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; use dirs_next::config_dir; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde_json::{Map, Value}; -use unicode_normalization::UnicodeNormalization; -use uuid::Uuid; - -use super::{ - image_asset::{import_image_bytes, import_image_file}, - local_asset_path::{file_url_to_path, path_identity_key, FileUrlPath}, -}; use crate::{ custom_css::{ @@ -36,11 +28,21 @@ use crate::{ IMAGE_TRANSFORM_ROTATION_MAX, IMAGE_TRANSFORM_ROTATION_MIN, IMAGE_TRANSFORM_SCALE_MAX, IMAGE_TRANSFORM_SCALE_MIN, POSITION_COLLECTION_FIELDS, }, - services::font_metadata::parse_font_metadata, }; +mod assets; mod recovery; +#[cfg(test)] +use assets::{ + decode_font_data_url, decode_image_data_url, normalize_font_extension, + normalize_image_extension, +}; +pub(crate) use assets::{ + is_foreign_portable_asset_reference, migrate_key_images_to_app_data, + migrate_local_fonts_to_app_data, rehome_foreign_asset_references, +}; +use assets::{parse_portable_asset_reference, AssetCategory}; #[cfg(test)] use recovery::{ recover_key_mapping_entries, recover_local_font_enabled, recover_sound_library_entries, @@ -49,35 +51,8 @@ use recovery::{repair_custom_tab_key_layout_pairs, repair_legacy_state}; const LEGACY_OVERLAY_WIDTH: f64 = 860.0; const LEGACY_OVERLAY_HEIGHT: f64 = 320.0; -const APP_DATA_MARKER: &str = "com.dmnote.desktop"; const LEGACY_PANEL_DETACH_ENABLED_KEY: &str = "panelDetachEnabled"; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum AssetCategory { - Sounds, - Fonts, - Images, -} - -impl AssetCategory { - fn directory_name(self) -> &'static str { - match self { - Self::Sounds => "sounds", - Self::Fonts => "fonts", - Self::Images => "images", - } - } - - fn from_directory_name(value: &str) -> Option { - match value { - "sounds" => Some(Self::Sounds), - "fonts" => Some(Self::Fonts), - "images" => Some(Self::Images), - _ => None, - } - } -} - pub(crate) struct LoadedStore { pub(crate) data: AppStoreData, pub(crate) needs_persist: bool, @@ -359,508 +334,6 @@ pub(crate) fn find_legacy_store_file() -> Option { } } -/// 로컬 폰트 base64 cssContent → 앱 데이터 경로 기반 파일로 변환 -pub(crate) fn migrate_local_fonts_to_app_data( - app_data_dir: &Path, - data: &mut AppStoreData, -) -> bool { - let mut changed = false; - - let has_local_fonts = data - .font_settings - .custom_fonts - .iter() - .any(|font| font.font_type == FontType::Local); - if !has_local_fonts { - return false; - } - - let fonts_dir = app_data_dir.join("fonts"); - if let Err(err) = fs::create_dir_all(&fonts_dir) { - log::warn!( - "[Fonts] Failed to create fonts directory at {}: {err}", - fonts_dir.display() - ); - return false; - } - - for font in data.font_settings.custom_fonts.iter_mut() { - if font.font_type != FontType::Local { - continue; - } - - let local_path = match font.local_path.as_ref() { - Some(path) if !path.trim().is_empty() => path.trim(), - _ => "", - }; - - let source = (!local_path.is_empty()).then(|| PathBuf::from(local_path)); - let source_exists = source.as_ref().is_some_and(|path| path.exists()); - - if let Some(source) = source.as_ref() { - // 이미 앱 데이터 fonts 디렉터리 내부에 있으면 cssContent만 제거 - if source.starts_with(&fonts_dir) && source.exists() { - if font.css_content.is_some() { - font.css_content = None; - changed = true; - } - continue; - } - - if source_exists { - let ext = source - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("ttf") - .to_lowercase(); - let dest = fonts_dir.join(format!("{}.{}", Uuid::new_v4(), ext)); - match fs::copy(source, &dest) { - Ok(_) => { - font.local_path = Some(dest.to_string_lossy().to_string()); - font.css_content = None; - changed = true; - continue; - } - Err(err) => { - log::warn!( - "[Fonts] Failed to import local font from {}: {err}", - source.display() - ); - } - } - } - } - - let fallback_extension = source - .as_ref() - .and_then(|path| path.extension()) - .and_then(|extension| extension.to_str()); - if let Some((bytes, extension)) = font - .css_content - .as_deref() - .and_then(|css| decode_font_data_url(css, fallback_extension)) - { - let dest = fonts_dir.join(format!("{}.{}", Uuid::new_v4(), extension)); - match fs::write(&dest, bytes) { - Ok(_) => { - font.local_path = Some(dest.to_string_lossy().to_string()); - font.css_content = None; - changed = true; - continue; - } - Err(err) => { - log::warn!( - "[Fonts] Failed to restore embedded local font into {}: {err}", - dest.display() - ); - continue; - } - } - } - - if source_exists { - continue; - } - - // 외래 기기 자산 참조는 파일이 나중에 복사되면 재귀화로 치유됨 — 비활성화 보류 - if is_foreign_portable_asset_reference(app_data_dir, local_path) { - continue; - } - - // 복구 가능한 원본과 data URI가 모두 없을 때 비활성화만 수행 - // css_content는 보존 — 미지 mime 등 디코더 개선 시 재복구 여지 유지 - if font.enabled { - font.enabled = false; - changed = true; - } - } - - for font in data - .font_settings - .custom_fonts - .iter_mut() - .filter(|font| font.font_type == FontType::Local && font.weight_ranges.is_empty()) - { - font.weight_ranges = font - .local_path - .as_deref() - .filter(|path| !path.trim().is_empty()) - .and_then(|path| parse_font_metadata(Path::new(path)).ok()) - .map(|metadata| metadata.weight_ranges) - .unwrap_or_else(|| vec![FontWeightRange { min: 400, max: 400 }]); - changed = true; - } - - changed -} - -/// 키 이미지 data URL/로컬 파일 → 앱 데이터 디렉터리로 마이그레이션 -pub(crate) fn migrate_key_images_to_app_data(app_data_dir: &Path, data: &mut AppStoreData) -> bool { - let mut changed = false; - - let has_any_images = data.key_positions.values().any(|positions| { - positions.iter().any(|position| { - option_has_non_empty_text(&position.active_image) - || option_has_non_empty_text(&position.inactive_image) - }) - }) || data.stat_positions.values().any(|positions| { - positions.iter().any(|stat_position| { - option_has_non_empty_text(&stat_position.position.active_image) - || option_has_non_empty_text(&stat_position.position.inactive_image) - }) - }) || data.graph_positions.values().any(|positions| { - positions.iter().any(|graph_position| { - option_has_non_empty_text(&graph_position.position.active_image) - || option_has_non_empty_text(&graph_position.position.inactive_image) - }) - }) || data.knob_positions.values().any(|positions| { - positions.iter().any(|knob_position| { - option_has_non_empty_text(&knob_position.position.active_image) - || option_has_non_empty_text(&knob_position.position.inactive_image) - }) - }); - - if !has_any_images { - return false; - } - - let images_dir = app_data_dir.join("images"); - if let Err(err) = fs::create_dir_all(&images_dir) { - log::warn!( - "[Images] Failed to create images directory at {}: {err}", - images_dir.display() - ); - return false; - } - - for positions in data.key_positions.values_mut() { - for position in positions.iter_mut() { - changed |= migrate_image_reference_to_app_data(&images_dir, &mut position.active_image); - changed |= - migrate_image_reference_to_app_data(&images_dir, &mut position.inactive_image); - } - } - - for positions in data.stat_positions.values_mut() { - for stat_position in positions.iter_mut() { - changed |= migrate_image_reference_to_app_data( - &images_dir, - &mut stat_position.position.active_image, - ); - changed |= migrate_image_reference_to_app_data( - &images_dir, - &mut stat_position.position.inactive_image, - ); - } - } - - for positions in data.graph_positions.values_mut() { - for graph_position in positions.iter_mut() { - changed |= migrate_image_reference_to_app_data( - &images_dir, - &mut graph_position.position.active_image, - ); - changed |= migrate_image_reference_to_app_data( - &images_dir, - &mut graph_position.position.inactive_image, - ); - } - } - - for positions in data.knob_positions.values_mut() { - for knob_position in positions.iter_mut() { - changed |= migrate_image_reference_to_app_data( - &images_dir, - &mut knob_position.position.active_image, - ); - changed |= migrate_image_reference_to_app_data( - &images_dir, - &mut knob_position.position.inactive_image, - ); - } - } - - changed -} - -/// 다른 기기의 appData 자산 참조를 현재 기기의 실존 파일로 재연결 -pub(crate) fn rehome_foreign_asset_references( - app_data_dir: &Path, - data: &mut AppStoreData, -) -> bool { - let mut changed = false; - - for positions in data.key_positions.values_mut() { - for position in positions { - changed |= rehome_position_asset_references(app_data_dir, position); - } - } - for positions in data.stat_positions.values_mut() { - for position in positions { - changed |= rehome_position_asset_references(app_data_dir, &mut position.position); - } - } - for positions in data.graph_positions.values_mut() { - for position in positions { - changed |= rehome_position_asset_references(app_data_dir, &mut position.position); - } - } - for positions in data.knob_positions.values_mut() { - for position in positions { - changed |= rehome_position_asset_references(app_data_dir, &mut position.position); - } - } - - for font in data - .font_settings - .custom_fonts - .iter_mut() - .filter(|font| font.font_type == FontType::Local) - { - changed |= rehome_optional_asset_reference(app_data_dir, &mut font.local_path); - } - - let mut replacements: HashMap> = HashMap::new(); - for (foreign_key, entry) in &data.sound_library { - let Some(local_key) = rehomed_asset_reference(app_data_dir, foreign_key) else { - continue; - }; - replacements - .entry(local_key) - .or_default() - .push((foreign_key.clone(), entry.clone())); - } - for (local_key, candidates) in replacements { - let Some((_, candidate_entry)) = candidates.first() else { - continue; - }; - let has_conflict = data - .sound_library - .get(&local_key) - .is_some_and(|existing| existing != candidate_entry) - || candidates.iter().any(|(_, entry)| entry != candidate_entry); - if has_conflict { - continue; - } - - data.sound_library - .entry(local_key) - .or_insert_with(|| candidate_entry.clone()); - for (foreign_key, _) in candidates { - data.sound_library.remove(&foreign_key); - } - changed = true; - } - - changed -} - -fn rehome_position_asset_references(app_data_dir: &Path, position: &mut KeyPosition) -> bool { - rehome_optional_asset_reference(app_data_dir, &mut position.sound_path) - | rehome_optional_asset_reference(app_data_dir, &mut position.active_image) - | rehome_optional_asset_reference(app_data_dir, &mut position.inactive_image) -} - -fn rehome_optional_asset_reference(app_data_dir: &Path, raw: &mut Option) -> bool { - let Some(current) = raw.as_ref() else { - return false; - }; - let Some(rehomed) = rehomed_asset_reference(app_data_dir, current) else { - return false; - }; - if *current == rehomed { - return false; - } - *raw = Some(rehomed); - true -} - -fn rehomed_asset_reference(app_data_dir: &Path, raw: &str) -> Option { - if local_asset_reference_exists(raw) { - return None; - } - let (category, file_name) = parse_portable_asset_reference(raw)?; - let category_dir = app_data_dir.join(category.directory_name()); - let exact = category_dir.join(&file_name); - let resolved = if exact.is_file() { - exact - } else { - find_unique_normalized_file(&category_dir, &file_name)? - }; - Some(resolved.to_string_lossy().into_owned()) -} - -fn local_asset_reference_exists(raw: &str) -> bool { - let trimmed = raw.trim(); - match file_url_to_path(trimmed) { - FileUrlPath::Path(path) => path.is_file(), - FileUrlPath::Invalid => false, - FileUrlPath::NotFileUrl => Path::new(trimmed).is_file(), - } -} - -fn find_unique_normalized_file(directory: &Path, expected_name: &str) -> Option { - let expected = expected_name.nfc().collect::(); - let mut matches = fs::read_dir(directory) - .ok()? - .filter_map(Result::ok) - .filter_map(|entry| { - let file_type = entry.file_type().ok()?; - if !file_type.is_file() { - return None; - } - let name = entry.file_name().into_string().ok()?; - (name.nfc().collect::() == expected).then_some(entry.path()) - }); - let first = matches.next()?; - matches.next().is_none().then_some(first) -} - -fn parse_portable_asset_reference(raw: &str) -> Option<(AssetCategory, String)> { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return None; - } - let lower = trimmed.to_ascii_lowercase(); - if lower.starts_with("http://") - || lower.starts_with("https://") - || lower.starts_with("data:") - || lower.starts_with("blob:") - || lower.starts_with("asset:") - || lower.starts_with("tauri:") - { - return None; - } - - let portable = match file_url_to_path(trimmed) { - FileUrlPath::Path(path) => path.to_string_lossy().into_owned(), - FileUrlPath::Invalid => return None, - FileUrlPath::NotFileUrl => { - let bytes = trimmed.as_bytes(); - let windows_drive = bytes.len() >= 3 - && bytes[0].is_ascii_alphabetic() - && bytes[1] == b':' - && matches!(bytes[2], b'\\' | b'/'); - let verbatim_drive = lower.starts_with(r"\\?\") - && bytes.get(4).is_some_and(u8::is_ascii_alphabetic) - && bytes.get(5) == Some(&b':') - && bytes - .get(6) - .is_some_and(|byte| matches!(byte, b'\\' | b'/')); - let network = trimmed.starts_with(r"\\") || trimmed.starts_with("//"); - if !trimmed.starts_with('/') && !windows_drive && !verbatim_drive && !network { - return None; - } - trimmed.to_string() - } - }; - - let components = portable - .split(['/', '\\']) - .filter(|component| !component.is_empty()) - .collect::>(); - if components - .iter() - .any(|component| *component == "." || *component == "..") - { - return None; - } - let marker_index = components - .iter() - .rposition(|component| component.eq_ignore_ascii_case(APP_DATA_MARKER))?; - if components.len() != marker_index + 3 { - return None; - } - let category = AssetCategory::from_directory_name(components[marker_index + 1])?; - let file_name = components[marker_index + 2]; - if file_name.is_empty() { - return None; - } - Some((category, file_name.to_string())) -} - -/// 외래 기기의 appData 자산 참조인지 — 현재 appData 루트 하위면 로컬 참조로 간주 -pub(crate) fn is_foreign_portable_asset_reference(app_data_dir: &Path, raw: &str) -> bool { - if parse_portable_asset_reference(raw).is_none() { - return false; - } - let trimmed = raw.trim(); - let resolved = match file_url_to_path(trimmed) { - FileUrlPath::Path(path) => path, - FileUrlPath::Invalid => return false, - FileUrlPath::NotFileUrl => PathBuf::from(trimmed), - }; - !path_is_under_root(&resolved, app_data_dir) -} - -fn path_is_under_root(path: &Path, root: &Path) -> bool { - if path_ancestor_matches_identity(path, root) { - return true; - } - // 루트가 심링크 경유(/var ↔ /private/var)면 정규화 후 재비교 - fs::canonicalize(root) - .map(|canonical| path_ancestor_matches_identity(path, &canonical)) - .unwrap_or(false) -} - -// Windows 대소문자·verbatim 표기 차이를 identity 키로 흡수해 조상 비교 -fn path_ancestor_matches_identity(path: &Path, root: &Path) -> bool { - let root_key = path_identity_key(root); - path.ancestors() - .any(|ancestor| path_identity_key(ancestor) == root_key) -} - -/// 개별 이미지 참조를 앱 데이터 디렉터리로 마이그레이션 -fn migrate_image_reference_to_app_data(images_dir: &Path, image_ref: &mut Option) -> bool { - let Some(raw_value) = image_ref.clone() else { - return false; - }; - - let trimmed = raw_value.trim(); - if trimmed.is_empty() { - return false; - } - - if let Some((bytes, extension)) = decode_image_data_url(trimmed) { - match import_image_bytes(&bytes, images_dir, &extension) { - Ok(imported) => { - *image_ref = Some(imported.path.to_string_lossy().to_string()); - return true; - } - Err(err) => { - log::warn!( - "[Images] Failed to migrate data URL image into {}: {err:#}", - images_dir.display() - ); - return false; - } - } - } - - let source = PathBuf::from(trimmed); - if !source.is_absolute() || !source.exists() { - return false; - } - if source.starts_with(images_dir) { - return false; - } - - let extension = normalize_image_extension(source.extension().and_then(|ext| ext.to_str())); - match import_image_file(&source, images_dir, &extension) { - Ok(imported) => { - *image_ref = Some(imported.path.to_string_lossy().to_string()); - true - } - Err(err) => { - log::warn!( - "[Images] Failed to copy local image from {}: {err}", - source.display() - ); - false - } - } -} - /// `rgba(r, g, b, a)` 문자열을 `#RRGGBB`로 변환. rgba 형식이 아니거나 파싱 실패 시 None fn rgba_to_hex(color: &str) -> Option { let inner = color.trim().strip_prefix("rgba(")?.strip_suffix(')')?; @@ -1366,112 +839,6 @@ fn merge_default_counters(target: &mut KeyCounters, keys: &KeyMappings) { target.retain(|mode, _| available_modes.contains(mode)); } -fn option_has_non_empty_text(value: &Option) -> bool { - value - .as_ref() - .map(|text| !text.trim().is_empty()) - .unwrap_or(false) -} - -fn decode_font_data_url( - value: &str, - fallback_extension: Option<&str>, -) -> Option<(Vec, String)> { - let lower = value.to_ascii_lowercase(); - let data_start = lower.find("data:")?; - let data_url = &value[data_start..]; - let (header, raw_payload) = data_url.split_once(',')?; - let header_lower = header.to_ascii_lowercase(); - if !header_lower.contains(";base64") { - return None; - } - - let mime = header_lower.strip_prefix("data:")?.split(';').next()?; - let extension = match mime { - "font/otf" | "application/x-font-opentype" => "otf", - "font/woff" | "application/font-woff" | "application/x-font-woff" => "woff", - "font/woff2" | "application/font-woff2" => "woff2", - "font/ttf" | "application/x-font-ttf" => "ttf", - _ if mime.starts_with("font/") => normalize_font_extension(fallback_extension), - _ => return None, - }; - let payload: String = raw_payload - .chars() - .take_while(|character| !matches!(character, ')' | '\'' | '"' | ';')) - .filter(|character| !character.is_ascii_whitespace()) - .collect(); - if payload.is_empty() { - return None; - } - - let bytes = BASE64_STANDARD.decode(payload.as_bytes()).ok()?; - Some((bytes, extension.to_string())) -} - -fn normalize_font_extension(extension: Option<&str>) -> &'static str { - match extension - .unwrap_or_default() - .trim() - .to_ascii_lowercase() - .as_str() - { - "otf" => "otf", - "woff" => "woff", - "woff2" => "woff2", - _ => "ttf", - } -} - -fn decode_image_data_url(value: &str) -> Option<(Vec, String)> { - let (header, payload) = value.split_once(',')?; - let header_lower = header.to_ascii_lowercase(); - if !header_lower.starts_with("data:image/") || !header_lower.contains(";base64") { - return None; - } - - let mime = header - .split(';') - .next() - .and_then(|part| part.strip_prefix("data:")) - .unwrap_or("image/png"); - let extension = extension_from_image_mime(mime); - let bytes = BASE64_STANDARD.decode(payload.as_bytes()).ok()?; - Some((bytes, extension)) -} - -fn extension_from_image_mime(mime: &str) -> String { - match mime.trim().to_ascii_lowercase().as_str() { - "image/jpeg" | "image/jpg" => "jpg".to_string(), - "image/png" => "png".to_string(), - "image/webp" => "webp".to_string(), - "image/gif" => "gif".to_string(), - "image/bmp" => "bmp".to_string(), - "image/svg+xml" => "svg".to_string(), - "image/x-icon" | "image/vnd.microsoft.icon" => "ico".to_string(), - "image/avif" => "avif".to_string(), - _ => "png".to_string(), - } -} - -fn normalize_image_extension(extension: Option<&str>) -> String { - match extension - .unwrap_or_default() - .trim() - .to_ascii_lowercase() - .as_str() - { - "jpg" | "jpeg" => "jpg".to_string(), - "png" => "png".to_string(), - "webp" => "webp".to_string(), - "gif" => "gif".to_string(), - "bmp" => "bmp".to_string(), - "svg" => "svg".to_string(), - "ico" => "ico".to_string(), - "avif" => "avif".to_string(), - _ => "png".to_string(), - } -} - #[derive(Deserialize)] struct LegacyOverlayPosition { x: f64, diff --git a/src-tauri/src/state/migration/assets.rs b/src-tauri/src/state/migration/assets.rs new file mode 100644 index 000000000..26b3b5ab8 --- /dev/null +++ b/src-tauri/src/state/migration/assets.rs @@ -0,0 +1,655 @@ +use std::{ + collections::HashMap, + fs, + path::{Path, PathBuf}, +}; + +use anyhow::Result; +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; +use unicode_normalization::UnicodeNormalization; +use uuid::Uuid; + +use super::super::{ + image_asset::{import_image_bytes, import_image_file}, + local_asset_path::{file_url_to_path, path_identity_key, FileUrlPath}, +}; +use crate::{ + models::{AppStoreData, FontType, FontWeightRange, KeyPosition, SoundLibraryEntry}, + services::font_metadata::parse_font_metadata, +}; + +const APP_DATA_MARKER: &str = "com.dmnote.desktop"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum AssetCategory { + Sounds, + Fonts, + Images, +} + +impl AssetCategory { + fn directory_name(self) -> &'static str { + match self { + Self::Sounds => "sounds", + Self::Fonts => "fonts", + Self::Images => "images", + } + } + + fn from_directory_name(value: &str) -> Option { + match value { + "sounds" => Some(Self::Sounds), + "fonts" => Some(Self::Fonts), + "images" => Some(Self::Images), + _ => None, + } + } +} + +/// 로컬 폰트 base64 cssContent → 앱 데이터 경로 기반 파일로 변환 +pub(crate) fn migrate_local_fonts_to_app_data( + app_data_dir: &Path, + data: &mut AppStoreData, +) -> bool { + let mut changed = false; + + let has_local_fonts = data + .font_settings + .custom_fonts + .iter() + .any(|font| font.font_type == FontType::Local); + if !has_local_fonts { + return false; + } + + let fonts_dir = app_data_dir.join("fonts"); + if let Err(err) = fs::create_dir_all(&fonts_dir) { + log::warn!( + "[Fonts] Failed to create fonts directory at {}: {err}", + fonts_dir.display() + ); + return false; + } + + for font in data.font_settings.custom_fonts.iter_mut() { + if font.font_type != FontType::Local { + continue; + } + + let local_path = match font.local_path.as_ref() { + Some(path) if !path.trim().is_empty() => path.trim(), + _ => "", + }; + + let source = (!local_path.is_empty()).then(|| PathBuf::from(local_path)); + let source_exists = source.as_ref().is_some_and(|path| path.exists()); + + if let Some(source) = source.as_ref() { + // 이미 앱 데이터 fonts 디렉터리 내부에 있으면 cssContent만 제거 + if source.starts_with(&fonts_dir) && source.exists() { + if font.css_content.is_some() { + font.css_content = None; + changed = true; + } + continue; + } + + if source_exists { + let ext = source + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("ttf") + .to_lowercase(); + let dest = fonts_dir.join(format!("{}.{}", Uuid::new_v4(), ext)); + match fs::copy(source, &dest) { + Ok(_) => { + font.local_path = Some(dest.to_string_lossy().to_string()); + font.css_content = None; + changed = true; + continue; + } + Err(err) => { + log::warn!( + "[Fonts] Failed to import local font from {}: {err}", + source.display() + ); + } + } + } + } + + let fallback_extension = source + .as_ref() + .and_then(|path| path.extension()) + .and_then(|extension| extension.to_str()); + if let Some((bytes, extension)) = font + .css_content + .as_deref() + .and_then(|css| decode_font_data_url(css, fallback_extension)) + { + let dest = fonts_dir.join(format!("{}.{}", Uuid::new_v4(), extension)); + match fs::write(&dest, bytes) { + Ok(_) => { + font.local_path = Some(dest.to_string_lossy().to_string()); + font.css_content = None; + changed = true; + continue; + } + Err(err) => { + log::warn!( + "[Fonts] Failed to restore embedded local font into {}: {err}", + dest.display() + ); + continue; + } + } + } + + if source_exists { + continue; + } + + // 외래 기기 자산 참조는 파일이 나중에 복사되면 재귀화로 치유됨 — 비활성화 보류 + if is_foreign_portable_asset_reference(app_data_dir, local_path) { + continue; + } + + // 복구 가능한 원본과 data URI가 모두 없을 때 비활성화만 수행 + // css_content는 보존 — 미지 mime 등 디코더 개선 시 재복구 여지 유지 + if font.enabled { + font.enabled = false; + changed = true; + } + } + + for font in data + .font_settings + .custom_fonts + .iter_mut() + .filter(|font| font.font_type == FontType::Local && font.weight_ranges.is_empty()) + { + font.weight_ranges = font + .local_path + .as_deref() + .filter(|path| !path.trim().is_empty()) + .and_then(|path| parse_font_metadata(Path::new(path)).ok()) + .map(|metadata| metadata.weight_ranges) + .unwrap_or_else(|| vec![FontWeightRange { min: 400, max: 400 }]); + changed = true; + } + + changed +} + +/// 키 이미지 data URL/로컬 파일 → 앱 데이터 디렉터리로 마이그레이션 +pub(crate) fn migrate_key_images_to_app_data(app_data_dir: &Path, data: &mut AppStoreData) -> bool { + let mut changed = false; + + let has_any_images = data.key_positions.values().any(|positions| { + positions.iter().any(|position| { + option_has_non_empty_text(&position.active_image) + || option_has_non_empty_text(&position.inactive_image) + }) + }) || data.stat_positions.values().any(|positions| { + positions.iter().any(|stat_position| { + option_has_non_empty_text(&stat_position.position.active_image) + || option_has_non_empty_text(&stat_position.position.inactive_image) + }) + }) || data.graph_positions.values().any(|positions| { + positions.iter().any(|graph_position| { + option_has_non_empty_text(&graph_position.position.active_image) + || option_has_non_empty_text(&graph_position.position.inactive_image) + }) + }) || data.knob_positions.values().any(|positions| { + positions.iter().any(|knob_position| { + option_has_non_empty_text(&knob_position.position.active_image) + || option_has_non_empty_text(&knob_position.position.inactive_image) + }) + }); + + if !has_any_images { + return false; + } + + let images_dir = app_data_dir.join("images"); + if let Err(err) = fs::create_dir_all(&images_dir) { + log::warn!( + "[Images] Failed to create images directory at {}: {err}", + images_dir.display() + ); + return false; + } + + for positions in data.key_positions.values_mut() { + for position in positions.iter_mut() { + changed |= migrate_image_reference_to_app_data(&images_dir, &mut position.active_image); + changed |= + migrate_image_reference_to_app_data(&images_dir, &mut position.inactive_image); + } + } + + for positions in data.stat_positions.values_mut() { + for stat_position in positions.iter_mut() { + changed |= migrate_image_reference_to_app_data( + &images_dir, + &mut stat_position.position.active_image, + ); + changed |= migrate_image_reference_to_app_data( + &images_dir, + &mut stat_position.position.inactive_image, + ); + } + } + + for positions in data.graph_positions.values_mut() { + for graph_position in positions.iter_mut() { + changed |= migrate_image_reference_to_app_data( + &images_dir, + &mut graph_position.position.active_image, + ); + changed |= migrate_image_reference_to_app_data( + &images_dir, + &mut graph_position.position.inactive_image, + ); + } + } + + for positions in data.knob_positions.values_mut() { + for knob_position in positions.iter_mut() { + changed |= migrate_image_reference_to_app_data( + &images_dir, + &mut knob_position.position.active_image, + ); + changed |= migrate_image_reference_to_app_data( + &images_dir, + &mut knob_position.position.inactive_image, + ); + } + } + + changed +} + +/// 다른 기기의 appData 자산 참조를 현재 기기의 실존 파일로 재연결 +pub(crate) fn rehome_foreign_asset_references( + app_data_dir: &Path, + data: &mut AppStoreData, +) -> bool { + let mut changed = false; + + for positions in data.key_positions.values_mut() { + for position in positions { + changed |= rehome_position_asset_references(app_data_dir, position); + } + } + for positions in data.stat_positions.values_mut() { + for position in positions { + changed |= rehome_position_asset_references(app_data_dir, &mut position.position); + } + } + for positions in data.graph_positions.values_mut() { + for position in positions { + changed |= rehome_position_asset_references(app_data_dir, &mut position.position); + } + } + for positions in data.knob_positions.values_mut() { + for position in positions { + changed |= rehome_position_asset_references(app_data_dir, &mut position.position); + } + } + + for font in data + .font_settings + .custom_fonts + .iter_mut() + .filter(|font| font.font_type == FontType::Local) + { + changed |= rehome_optional_asset_reference(app_data_dir, &mut font.local_path); + } + + let mut replacements: HashMap> = HashMap::new(); + for (foreign_key, entry) in &data.sound_library { + let Some(local_key) = rehomed_asset_reference(app_data_dir, foreign_key) else { + continue; + }; + replacements + .entry(local_key) + .or_default() + .push((foreign_key.clone(), entry.clone())); + } + for (local_key, candidates) in replacements { + let Some((_, candidate_entry)) = candidates.first() else { + continue; + }; + let has_conflict = data + .sound_library + .get(&local_key) + .is_some_and(|existing| existing != candidate_entry) + || candidates.iter().any(|(_, entry)| entry != candidate_entry); + if has_conflict { + continue; + } + + data.sound_library + .entry(local_key) + .or_insert_with(|| candidate_entry.clone()); + for (foreign_key, _) in candidates { + data.sound_library.remove(&foreign_key); + } + changed = true; + } + + changed +} + +fn rehome_position_asset_references(app_data_dir: &Path, position: &mut KeyPosition) -> bool { + rehome_optional_asset_reference(app_data_dir, &mut position.sound_path) + | rehome_optional_asset_reference(app_data_dir, &mut position.active_image) + | rehome_optional_asset_reference(app_data_dir, &mut position.inactive_image) +} + +fn rehome_optional_asset_reference(app_data_dir: &Path, raw: &mut Option) -> bool { + let Some(current) = raw.as_ref() else { + return false; + }; + let Some(rehomed) = rehomed_asset_reference(app_data_dir, current) else { + return false; + }; + if *current == rehomed { + return false; + } + *raw = Some(rehomed); + true +} + +fn rehomed_asset_reference(app_data_dir: &Path, raw: &str) -> Option { + if local_asset_reference_exists(raw) { + return None; + } + let (category, file_name) = parse_portable_asset_reference(raw)?; + let category_dir = app_data_dir.join(category.directory_name()); + let exact = category_dir.join(&file_name); + let resolved = if exact.is_file() { + exact + } else { + find_unique_normalized_file(&category_dir, &file_name)? + }; + Some(resolved.to_string_lossy().into_owned()) +} + +fn local_asset_reference_exists(raw: &str) -> bool { + let trimmed = raw.trim(); + match file_url_to_path(trimmed) { + FileUrlPath::Path(path) => path.is_file(), + FileUrlPath::Invalid => false, + FileUrlPath::NotFileUrl => Path::new(trimmed).is_file(), + } +} + +fn find_unique_normalized_file(directory: &Path, expected_name: &str) -> Option { + let expected = expected_name.nfc().collect::(); + let mut matches = fs::read_dir(directory) + .ok()? + .filter_map(Result::ok) + .filter_map(|entry| { + let file_type = entry.file_type().ok()?; + if !file_type.is_file() { + return None; + } + let name = entry.file_name().into_string().ok()?; + (name.nfc().collect::() == expected).then_some(entry.path()) + }); + let first = matches.next()?; + matches.next().is_none().then_some(first) +} + +pub(super) fn parse_portable_asset_reference(raw: &str) -> Option<(AssetCategory, String)> { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + let lower = trimmed.to_ascii_lowercase(); + if lower.starts_with("http://") + || lower.starts_with("https://") + || lower.starts_with("data:") + || lower.starts_with("blob:") + || lower.starts_with("asset:") + || lower.starts_with("tauri:") + { + return None; + } + + let portable = match file_url_to_path(trimmed) { + FileUrlPath::Path(path) => path.to_string_lossy().into_owned(), + FileUrlPath::Invalid => return None, + FileUrlPath::NotFileUrl => { + let bytes = trimmed.as_bytes(); + let windows_drive = bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && matches!(bytes[2], b'\\' | b'/'); + let verbatim_drive = lower.starts_with(r"\\?\") + && bytes.get(4).is_some_and(u8::is_ascii_alphabetic) + && bytes.get(5) == Some(&b':') + && bytes + .get(6) + .is_some_and(|byte| matches!(byte, b'\\' | b'/')); + let network = trimmed.starts_with(r"\\") || trimmed.starts_with("//"); + if !trimmed.starts_with('/') && !windows_drive && !verbatim_drive && !network { + return None; + } + trimmed.to_string() + } + }; + + let components = portable + .split(['/', '\\']) + .filter(|component| !component.is_empty()) + .collect::>(); + if components + .iter() + .any(|component| *component == "." || *component == "..") + { + return None; + } + let marker_index = components + .iter() + .rposition(|component| component.eq_ignore_ascii_case(APP_DATA_MARKER))?; + if components.len() != marker_index + 3 { + return None; + } + let category = AssetCategory::from_directory_name(components[marker_index + 1])?; + let file_name = components[marker_index + 2]; + if file_name.is_empty() { + return None; + } + Some((category, file_name.to_string())) +} + +/// 외래 기기의 appData 자산 참조인지 — 현재 appData 루트 하위면 로컬 참조로 간주 +pub(crate) fn is_foreign_portable_asset_reference(app_data_dir: &Path, raw: &str) -> bool { + if parse_portable_asset_reference(raw).is_none() { + return false; + } + let trimmed = raw.trim(); + let resolved = match file_url_to_path(trimmed) { + FileUrlPath::Path(path) => path, + FileUrlPath::Invalid => return false, + FileUrlPath::NotFileUrl => PathBuf::from(trimmed), + }; + !path_is_under_root(&resolved, app_data_dir) +} + +fn path_is_under_root(path: &Path, root: &Path) -> bool { + if path_ancestor_matches_identity(path, root) { + return true; + } + // 루트가 심링크 경유(/var ↔ /private/var)면 정규화 후 재비교 + fs::canonicalize(root) + .map(|canonical| path_ancestor_matches_identity(path, &canonical)) + .unwrap_or(false) +} + +// Windows 대소문자·verbatim 표기 차이를 identity 키로 흡수해 조상 비교 +fn path_ancestor_matches_identity(path: &Path, root: &Path) -> bool { + let root_key = path_identity_key(root); + path.ancestors() + .any(|ancestor| path_identity_key(ancestor) == root_key) +} + +/// 개별 이미지 참조를 앱 데이터 디렉터리로 마이그레이션 +fn migrate_image_reference_to_app_data(images_dir: &Path, image_ref: &mut Option) -> bool { + let Some(raw_value) = image_ref.clone() else { + return false; + }; + + let trimmed = raw_value.trim(); + if trimmed.is_empty() { + return false; + } + + if let Some((bytes, extension)) = decode_image_data_url(trimmed) { + match import_image_bytes(&bytes, images_dir, &extension) { + Ok(imported) => { + *image_ref = Some(imported.path.to_string_lossy().to_string()); + return true; + } + Err(err) => { + log::warn!( + "[Images] Failed to migrate data URL image into {}: {err:#}", + images_dir.display() + ); + return false; + } + } + } + + let source = PathBuf::from(trimmed); + if !source.is_absolute() || !source.exists() { + return false; + } + if source.starts_with(images_dir) { + return false; + } + + let extension = normalize_image_extension(source.extension().and_then(|ext| ext.to_str())); + match import_image_file(&source, images_dir, &extension) { + Ok(imported) => { + *image_ref = Some(imported.path.to_string_lossy().to_string()); + true + } + Err(err) => { + log::warn!( + "[Images] Failed to copy local image from {}: {err}", + source.display() + ); + false + } + } +} + +fn option_has_non_empty_text(value: &Option) -> bool { + value + .as_ref() + .map(|text| !text.trim().is_empty()) + .unwrap_or(false) +} + +pub(super) fn decode_font_data_url( + value: &str, + fallback_extension: Option<&str>, +) -> Option<(Vec, String)> { + let lower = value.to_ascii_lowercase(); + let data_start = lower.find("data:")?; + let data_url = &value[data_start..]; + let (header, raw_payload) = data_url.split_once(',')?; + let header_lower = header.to_ascii_lowercase(); + if !header_lower.contains(";base64") { + return None; + } + + let mime = header_lower.strip_prefix("data:")?.split(';').next()?; + let extension = match mime { + "font/otf" | "application/x-font-opentype" => "otf", + "font/woff" | "application/font-woff" | "application/x-font-woff" => "woff", + "font/woff2" | "application/font-woff2" => "woff2", + "font/ttf" | "application/x-font-ttf" => "ttf", + _ if mime.starts_with("font/") => normalize_font_extension(fallback_extension), + _ => return None, + }; + let payload: String = raw_payload + .chars() + .take_while(|character| !matches!(character, ')' | '\'' | '"' | ';')) + .filter(|character| !character.is_ascii_whitespace()) + .collect(); + if payload.is_empty() { + return None; + } + + let bytes = BASE64_STANDARD.decode(payload.as_bytes()).ok()?; + Some((bytes, extension.to_string())) +} + +pub(super) fn normalize_font_extension(extension: Option<&str>) -> &'static str { + match extension + .unwrap_or_default() + .trim() + .to_ascii_lowercase() + .as_str() + { + "otf" => "otf", + "woff" => "woff", + "woff2" => "woff2", + _ => "ttf", + } +} + +pub(super) fn decode_image_data_url(value: &str) -> Option<(Vec, String)> { + let (header, payload) = value.split_once(',')?; + let header_lower = header.to_ascii_lowercase(); + if !header_lower.starts_with("data:image/") || !header_lower.contains(";base64") { + return None; + } + + let mime = header + .split(';') + .next() + .and_then(|part| part.strip_prefix("data:")) + .unwrap_or("image/png"); + let extension = extension_from_image_mime(mime); + let bytes = BASE64_STANDARD.decode(payload.as_bytes()).ok()?; + Some((bytes, extension)) +} + +fn extension_from_image_mime(mime: &str) -> String { + match mime.trim().to_ascii_lowercase().as_str() { + "image/jpeg" | "image/jpg" => "jpg".to_string(), + "image/png" => "png".to_string(), + "image/webp" => "webp".to_string(), + "image/gif" => "gif".to_string(), + "image/bmp" => "bmp".to_string(), + "image/svg+xml" => "svg".to_string(), + "image/x-icon" | "image/vnd.microsoft.icon" => "ico".to_string(), + "image/avif" => "avif".to_string(), + _ => "png".to_string(), + } +} + +pub(super) fn normalize_image_extension(extension: Option<&str>) -> String { + match extension + .unwrap_or_default() + .trim() + .to_ascii_lowercase() + .as_str() + { + "jpg" | "jpeg" => "jpg".to_string(), + "png" => "png".to_string(), + "webp" => "webp".to_string(), + "gif" => "gif".to_string(), + "bmp" => "bmp".to_string(), + "svg" => "svg".to_string(), + "ico" => "ico".to_string(), + "avif" => "avif".to_string(), + _ => "png".to_string(), + } +} From 79eee12e85e78bbcbdbeef7f5a18f45a5f0d6936 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 17:38:23 +0900 Subject: [PATCH 163/245] =?UTF-8?q?test:=20=EC=9D=98=EB=AF=B8=20=EC=BB=A4?= =?UTF-8?q?=EB=B0=8B=20=EC=9D=91=EB=8B=B5=20=EB=B6=88=EC=9D=BC=EC=B9=98=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../editor/runtime/editorCoordinator.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/renderer/editor/runtime/editorCoordinator.test.ts b/src/renderer/editor/runtime/editorCoordinator.test.ts index f2cc50e20..6747cf658 100644 --- a/src/renderer/editor/runtime/editorCoordinator.test.ts +++ b/src/renderer/editor/runtime/editorCoordinator.test.ts @@ -5899,6 +5899,31 @@ describe('commitSemanticOpsInternal', () => { harness.coordinator.stop(); }); + it('opResults 개수 불일치는 같은 mutation 재전송 후 transient protocol 오류로 남긴다', async () => { + const id = '00000000-0000-4000-8000-000000000118'; + const harness = createHarness(withStableId(id)); + await harness.coordinator.start(); + harness.transport.commitMock.mockResolvedValue({ + revision: 1, + changedFields: ['keyPositions'], + opResults: [], + }); + + await expect( + harness.coordinator.commitSemanticOpsInternal([setBoundsOp(id)]), + ).rejects.toBeInstanceOf(EditorProtocolError); + + expect(harness.transport.commitMock).toHaveBeenCalledTimes(2); + expect(harness.transport.commitMock.mock.calls[0][0]).toEqual( + harness.transport.commitMock.mock.calls[1][0], + ); + expect(harness.coordinator.getState()).toMatchObject({ + phase: 'error', + failureKind: 'transient', + }); + harness.coordinator.stop(); + }); + it('두 번의 protocol 응답 실패 후 canonical 조회도 실패하면 재시도 상태와 게스처를 보존한다', async () => { const id = '00000000-0000-4000-8000-000000000116'; const onGestureIdsDiscarded = vi.fn(); From 2111e7b3dc31ee42d0f5651d774dedea759feac8 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 17:38:23 +0900 Subject: [PATCH 164/245] =?UTF-8?q?refactor:=20=EC=9D=98=EB=AF=B8=20?= =?UTF-8?q?=EC=BB=A4=EB=B0=8B=20=EC=8B=9C=EB=8F=84=20=EB=9F=B0=ED=83=80?= =?UTF-8?q?=EC=9E=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../editor/runtime/editorCoordinator.ts | 273 +++---------- .../runtime/semanticCommitAttemptRuntime.ts | 361 ++++++++++++++++++ 2 files changed, 408 insertions(+), 226 deletions(-) create mode 100644 src/renderer/editor/runtime/semanticCommitAttemptRuntime.ts diff --git a/src/renderer/editor/runtime/editorCoordinator.ts b/src/renderer/editor/runtime/editorCoordinator.ts index 3faff4382..5257e402c 100644 --- a/src/renderer/editor/runtime/editorCoordinator.ts +++ b/src/renderer/editor/runtime/editorCoordinator.ts @@ -1,11 +1,12 @@ import { applySemanticOps, fieldsForSemanticOp } from './semanticOpsProjection'; import { SerialTaskQueue } from './serialTaskQueue'; +import { hasReachedEditorAutoRebaseLimit } from './editorRetryPolicy'; import { - hasReachedEditorAutoRebaseLimit, - isSemanticCommitFailureRetryable, - shouldAutoRebaseSemanticConflict, - shouldRetryUnknownSemanticOutcome, -} from './editorRetryPolicy'; + runSemanticCommitAttemptRuntime, + type EditorSemanticCommitMeta, + type EditorSemanticCommitOutcome, + type EditorSemanticOpsGenerator, +} from './semanticCommitAttemptRuntime'; import { applyEditorPatch, applyIsolatedPluginPatch, @@ -166,20 +167,11 @@ interface EditorSyncOptions { reapply?: boolean; } -export interface EditorSemanticCommitOutcome { - document: CanonicalEditorDocumentV1; - opResults: EditorOpResultV1[]; -} - -export interface EditorSemanticCommitMeta { - gestureId?: string; - onEnrolled?: () => void; - preflight?: () => void; -} - -export type EditorSemanticOpsGenerator = ( - base: CanonicalEditorDocumentV1, -) => readonly EditorOpV1[] | null; +export type { + EditorSemanticCommitMeta, + EditorSemanticCommitOutcome, + EditorSemanticOpsGenerator, +} from './semanticCommitAttemptRuntime'; export class EditorReadOnlyError extends Error { constructor() { @@ -542,214 +534,43 @@ class EditorSaveCoordinator { generate: EditorSemanticOpsGenerator, meta: EditorSemanticCommitMeta, ): Promise { - await this.start(); - await this.drainUntilSettled(); - await this.eventQueue.wait(); - if (this.conflict) { - throw this.error ?? new Error('editor conflict pending'); - } - - let baseDocument = clone(this.requireLastAck()); - let baseRevision = this.requireRevision(); - let mutationId = this.createMutationId(); - let conflictRetryCount = 0; - let totalRetryCount = 0; - let enrolled = false; - - while (true) { - let ops: EditorOpV1[]; - try { - meta.preflight?.(); - const generated = generate(clone(baseDocument)); - if (!generated) { - if (enrolled) { - this.applyDocument(clone(this.requireLastAck()), 'rejected'); - if (meta.gestureId) { - this.onGestureIdsDiscarded?.([meta.gestureId]); - } - this.error = null; - this.failureKind = null; - this.phase = 'idle'; - this.notify(); - } - return null; - } - assertEditorOpsV1(generated); - ops = clone([...generated]); - } catch (error) { - if (enrolled) { - this.applyDocument(clone(this.requireLastAck()), 'rejected'); - if (meta.gestureId) { - this.onGestureIdsDiscarded?.([meta.gestureId]); - } - this.error = null; - this.failureKind = null; - this.phase = 'idle'; - this.notify(); - } - throw error; - } - const request: EditorCommitRequest = { - baseRevision, - mutationId, - opsVersion: EDITOR_OPS_VERSION, - ops, - ...(meta.gestureId ? { gestureId: meta.gestureId } : {}), - }; - const target = applySemanticOps(baseDocument, ops); - assertCanonicalEditorDocument(target, 'semantic target document'); - this.reapplyFrozenIntent(baseDocument, { ops }); - const requestFields = [...new Set(ops.flatMap(fieldsForSemanticOp))]; - const inFlight: InFlightCommit = { - mutationId, - baseRevision, - baseDocument: clone(baseDocument), - target, - localFields: getChangedEditorFields(baseDocument, target), - requestFields, - gestureIds: meta.gestureId ? [meta.gestureId] : [], - semanticOps: true, - }; - this.inFlight = inFlight; - this.rememberOwnMutation(inFlight); - this.phase = 'saving'; - this.error = null; - this.failureKind = null; - this.notify(); - if (!enrolled) { - enrolled = true; - try { - meta.onEnrolled?.(); - } catch (error) { - console.error('onEnrolled callback failed', error); - } - } - - let outcomeUnknownRetryCount = 0; - try { - let result: EditorCommitResult; - let opResults: EditorOpResultV1[]; - while (true) { - try { - result = await this.transport.commit(request); - assertEditorOpCommitResult(result, ops); - opResults = clone(result.opResults!); - this.assertSemanticChangedFields( - baseDocument, - ops, - opResults, - result, - ); - break; - } catch (error) { - if ( - !shouldRetryUnknownSemanticOutcome( - error, - outcomeUnknownRetryCount, - ) - ) { - throw error; - } - outcomeUnknownRetryCount += 1; - totalRetryCount += 1; - } - } - - const hasMissing = opResults.some( - (opResult) => opResult.status === 'targetMissing', - ); - const currentRevision = this.requireRevision(); - if (hasMissing || result.revision > currentRevision + 1) { - await this.syncSemanticCanonical(); - } else if (result.revision >= currentRevision) { - const acknowledged = applySemanticOps( - this.requireLastAck(), - ops, - opResults, - ); - this.revision = result.revision; - this.lastAck = clone(acknowledged); - } - this.error = null; - this.failureKind = null; - this.phase = 'idle'; - this.notify(); - this.logSemanticCommit(result, opResults, totalRetryCount); - return { - document: clone(this.requireLastAck()), - opResults, - }; - } catch (error) { - if (this.inFlight?.mutationId === mutationId) this.inFlight = null; - - if (shouldAutoRebaseSemanticConflict(error, conflictRetryCount)) { - this.ownMutations.delete(mutationId); - try { - const canonical = await this.syncSemanticCanonical(); - baseDocument = canonical.document; - baseRevision = canonical.revision; - mutationId = this.createMutationId(); - conflictRetryCount += 1; - totalRetryCount += 1; - continue; - } catch (syncError) { - this.applyDocument(clone(this.requireLastAck()), 'rejected'); - if (inFlight.gestureIds.length > 0) { - this.onGestureIdsDiscarded?.(inFlight.gestureIds); - } - this.error = syncError; - this.failureKind = 'transient'; - this.phase = 'error'; - this.notify(); - throw syncError; - } - } - - if (!isEditorCommitError(error) || error.errorCode !== 'IO_ERROR') { - this.ownMutations.delete(mutationId); - } - let canonical: CanonicalEditorGetResult | null = null; - try { - canonical = await this.syncSemanticCanonical(); - } catch { - // 원래 커밋 오류를 유지 - if (!(error instanceof EditorProtocolError)) { - this.applyDocument(clone(this.requireLastAck()), 'rejected'); - } - } - const protocolOutcomeReflected = - error instanceof EditorProtocolError && - canonical !== null && - getChangedEditorFields( - canonical.document, - applySemanticOps(canonical.document, ops), - ).length === 0; - if (protocolOutcomeReflected) { - if (inFlight.gestureIds.length > 0) { - this.onGestureIdsDiscarded?.(inFlight.gestureIds); - } - this.error = null; - this.failureKind = null; - this.phase = 'idle'; - this.notify(); - throw error; + return runSemanticCommitAttemptRuntime(generate, meta, { + start: () => this.start(), + drainUntilSettled: () => this.drainUntilSettled(), + waitForEvents: () => this.eventQueue.wait(), + readConflict: () => ({ + active: this.conflict !== null, + error: this.error, + }), + readLastAck: () => this.requireLastAck(), + readRevision: () => this.requireRevision(), + readInFlightMutationId: () => this.inFlight?.mutationId ?? null, + createMutationId: () => this.createMutationId(), + setState: (patch) => { + if ('inFlight' in patch) this.inFlight = patch.inFlight ?? null; + if ('revision' in patch) this.revision = patch.revision ?? null; + if ('lastAck' in patch) this.lastAck = patch.lastAck ?? null; + if ('error' in patch) this.error = patch.error; + if ('failureKind' in patch) { + this.failureKind = patch.failureKind ?? null; } - const retryable = isSemanticCommitFailureRetryable(error); - if ( - !(error instanceof EditorProtocolError) && - inFlight.gestureIds.length > 0 - ) { - this.onGestureIdsDiscarded?.(inFlight.gestureIds); - } - this.error = error; - this.failureKind = retryable ? 'transient' : 'permanent'; - this.phase = 'error'; - this.notify(); - throw error; - } finally { - if (this.inFlight?.mutationId === mutationId) this.inFlight = null; - } - } + if ('phase' in patch && patch.phase) this.phase = patch.phase; + }, + notify: () => this.notify(), + applyDocument: (document, reason) => this.applyDocument(document, reason), + reapplyFrozenIntent: (base, ops) => + this.reapplyFrozenIntent(base, { ops }), + rememberOwnMutation: (inFlight) => this.rememberOwnMutation(inFlight), + forgetOwnMutation: (mutationId) => this.ownMutations.delete(mutationId), + discardGestureIds: (gestureIds) => + this.onGestureIdsDiscarded?.(gestureIds), + commit: (request) => this.transport.commit(request), + syncCanonical: () => this.syncSemanticCanonical(), + assertChangedFields: (base, ops, opResults, result) => + this.assertSemanticChangedFields(base, ops, opResults, result), + logCommit: (result, opResults, retryCount) => + this.logSemanticCommit(result, opResults, retryCount), + }); } private async syncSemanticCanonical(): Promise { diff --git a/src/renderer/editor/runtime/semanticCommitAttemptRuntime.ts b/src/renderer/editor/runtime/semanticCommitAttemptRuntime.ts new file mode 100644 index 000000000..f664f7609 --- /dev/null +++ b/src/renderer/editor/runtime/semanticCommitAttemptRuntime.ts @@ -0,0 +1,361 @@ +import { + isSemanticCommitFailureRetryable, + shouldAutoRebaseSemanticConflict, + shouldRetryUnknownSemanticOutcome, +} from './editorRetryPolicy'; +import { clone, getChangedEditorFields } from './editorRebaseModel'; +import { applySemanticOps, fieldsForSemanticOp } from './semanticOpsProjection'; +import { + EDITOR_OPS_VERSION, + EditorProtocolError, + assertCanonicalEditorDocument, + assertEditorOpCommitResult, + assertEditorOpsV1, + isEditorCommitError, +} from '@src/types/editor'; + +import type { + CanonicalEditorDocumentV1, + CanonicalEditorGetResult, + EditorCommitRequest, + EditorCommitResult, + EditorField, + EditorOpResultV1, + EditorOpV1, +} from '@src/types/editor'; + +export interface EditorSemanticCommitOutcome { + document: CanonicalEditorDocumentV1; + opResults: EditorOpResultV1[]; +} + +export interface EditorSemanticCommitMeta { + gestureId?: string; + onEnrolled?: () => void; + preflight?: () => void; +} + +export type EditorSemanticOpsGenerator = ( + base: CanonicalEditorDocumentV1, +) => readonly EditorOpV1[] | null; + +export interface SemanticCommitAttemptInFlight { + mutationId: string; + baseRevision: number; + baseDocument: CanonicalEditorDocumentV1; + target: CanonicalEditorDocumentV1; + localFields: EditorField[]; + requestFields: EditorField[]; + gestureIds: string[]; + semanticOps: true; +} + +type SemanticCommitAttemptPhase = 'idle' | 'saving' | 'error'; +type SemanticCommitAttemptFailureKind = 'transient' | 'permanent' | null; + +interface SemanticCommitAttemptStatePatch { + inFlight?: SemanticCommitAttemptInFlight | null; + revision?: number; + lastAck?: CanonicalEditorDocumentV1; + error?: unknown; + failureKind?: SemanticCommitAttemptFailureKind; + phase?: SemanticCommitAttemptPhase; +} + +export interface SemanticCommitAttemptDependencies { + start(): Promise; + drainUntilSettled(): Promise; + waitForEvents(): Promise; + readConflict(): { active: boolean; error: unknown }; + readLastAck(): CanonicalEditorDocumentV1; + readRevision(): number; + readInFlightMutationId(): string | null; + createMutationId(): string; + setState(patch: SemanticCommitAttemptStatePatch): void; + notify(): void; + applyDocument(document: CanonicalEditorDocumentV1, reason: 'rejected'): void; + reapplyFrozenIntent( + base: CanonicalEditorDocumentV1, + ops: readonly EditorOpV1[], + ): void; + rememberOwnMutation(inFlight: SemanticCommitAttemptInFlight): void; + forgetOwnMutation(mutationId: string): void; + discardGestureIds(gestureIds: readonly string[]): void; + commit(request: EditorCommitRequest): Promise; + syncCanonical(): Promise; + assertChangedFields( + base: CanonicalEditorDocumentV1, + ops: readonly EditorOpV1[], + opResults: readonly EditorOpResultV1[], + result: EditorCommitResult, + ): void; + logCommit( + result: EditorCommitResult, + opResults: readonly EditorOpResultV1[], + retryCount: number, + ): void; +} + +interface SemanticCommitRuntimeState { + baseDocument: CanonicalEditorDocumentV1; + baseRevision: number; + mutationId: string; + conflictRetryCount: number; + totalRetryCount: number; + enrolled: boolean; +} + +interface PreparedSemanticCommitAttempt { + ops: EditorOpV1[]; + request: EditorCommitRequest; + inFlight: SemanticCommitAttemptInFlight; +} + +const rejectEnrolledAttempt = ( + dependencies: SemanticCommitAttemptDependencies, + gestureId: string | undefined, +): void => { + dependencies.applyDocument(clone(dependencies.readLastAck()), 'rejected'); + if (gestureId) { + dependencies.discardGestureIds([gestureId]); + } + dependencies.setState({ error: null }); + dependencies.setState({ failureKind: null }); + dependencies.setState({ phase: 'idle' }); + dependencies.notify(); +}; + +const prepareSemanticCommitAttempt = ( + generate: EditorSemanticOpsGenerator, + meta: EditorSemanticCommitMeta, + runtime: SemanticCommitRuntimeState, + dependencies: SemanticCommitAttemptDependencies, +): PreparedSemanticCommitAttempt | null => { + let ops: EditorOpV1[]; + try { + meta.preflight?.(); + const generated = generate(clone(runtime.baseDocument)); + if (!generated) { + if (runtime.enrolled) { + rejectEnrolledAttempt(dependencies, meta.gestureId); + } + return null; + } + assertEditorOpsV1(generated); + ops = clone([...generated]); + } catch (error) { + if (runtime.enrolled) { + rejectEnrolledAttempt(dependencies, meta.gestureId); + } + throw error; + } + + const request: EditorCommitRequest = { + baseRevision: runtime.baseRevision, + mutationId: runtime.mutationId, + opsVersion: EDITOR_OPS_VERSION, + ops, + ...(meta.gestureId ? { gestureId: meta.gestureId } : {}), + }; + const target = applySemanticOps(runtime.baseDocument, ops); + assertCanonicalEditorDocument(target, 'semantic target document'); + dependencies.reapplyFrozenIntent(runtime.baseDocument, ops); + const requestFields = [...new Set(ops.flatMap(fieldsForSemanticOp))]; + const inFlight: SemanticCommitAttemptInFlight = { + mutationId: runtime.mutationId, + baseRevision: runtime.baseRevision, + baseDocument: clone(runtime.baseDocument), + target, + localFields: getChangedEditorFields(runtime.baseDocument, target), + requestFields, + gestureIds: meta.gestureId ? [meta.gestureId] : [], + semanticOps: true, + }; + dependencies.setState({ inFlight }); + dependencies.rememberOwnMutation(inFlight); + dependencies.setState({ phase: 'saving' }); + dependencies.setState({ error: null }); + dependencies.setState({ failureKind: null }); + dependencies.notify(); + if (!runtime.enrolled) { + runtime.enrolled = true; + try { + meta.onEnrolled?.(); + } catch (error) { + console.error('onEnrolled callback failed', error); + } + } + return { ops, request, inFlight }; +}; + +export const runSemanticCommitAttemptRuntime = async ( + generate: EditorSemanticOpsGenerator, + meta: EditorSemanticCommitMeta, + dependencies: SemanticCommitAttemptDependencies, +): Promise => { + // 직렬 슬롯 준비 + await dependencies.start(); + await dependencies.drainUntilSettled(); + await dependencies.waitForEvents(); + const conflict = dependencies.readConflict(); + if (conflict.active) { + throw conflict.error ?? new Error('editor conflict pending'); + } + + const runtime: SemanticCommitRuntimeState = { + baseDocument: clone(dependencies.readLastAck()), + baseRevision: dependencies.readRevision(), + mutationId: dependencies.createMutationId(), + conflictRetryCount: 0, + totalRetryCount: 0, + enrolled: false, + }; + + while (true) { + // 최신 canonical 기반 attempt 준비 + const attempt = prepareSemanticCommitAttempt( + generate, + meta, + runtime, + dependencies, + ); + if (!attempt) return null; + + let outcomeUnknownRetryCount = 0; + try { + let result: EditorCommitResult; + let opResults: EditorOpResultV1[]; + // 결과 미상 동일 mutation 재전송 + while (true) { + try { + result = await dependencies.commit(attempt.request); + assertEditorOpCommitResult(result, attempt.ops); + opResults = clone(result.opResults!); + dependencies.assertChangedFields( + runtime.baseDocument, + attempt.ops, + opResults, + result, + ); + break; + } catch (error) { + if ( + !shouldRetryUnknownSemanticOutcome(error, outcomeUnknownRetryCount) + ) { + throw error; + } + outcomeUnknownRetryCount += 1; + runtime.totalRetryCount += 1; + } + } + + // 성공 assertion 이후 canonical 정산 + const hasMissing = opResults.some( + (opResult) => opResult.status === 'targetMissing', + ); + const currentRevision = dependencies.readRevision(); + if (hasMissing || result.revision > currentRevision + 1) { + await dependencies.syncCanonical(); + } else if (result.revision >= currentRevision) { + const acknowledged = applySemanticOps( + dependencies.readLastAck(), + attempt.ops, + opResults, + ); + dependencies.setState({ revision: result.revision }); + dependencies.setState({ lastAck: clone(acknowledged) }); + } + dependencies.setState({ error: null }); + dependencies.setState({ failureKind: null }); + dependencies.setState({ phase: 'idle' }); + dependencies.notify(); + dependencies.logCommit(result, opResults, runtime.totalRetryCount); + return { + document: clone(dependencies.readLastAck()), + opResults, + }; + } catch (error) { + // retryable failure canonical 조정·자동 rebase + if (dependencies.readInFlightMutationId() === runtime.mutationId) { + dependencies.setState({ inFlight: null }); + } + + if (shouldAutoRebaseSemanticConflict(error, runtime.conflictRetryCount)) { + dependencies.forgetOwnMutation(runtime.mutationId); + try { + const canonical = await dependencies.syncCanonical(); + runtime.baseDocument = canonical.document; + runtime.baseRevision = canonical.revision; + runtime.mutationId = dependencies.createMutationId(); + runtime.conflictRetryCount += 1; + runtime.totalRetryCount += 1; + continue; + } catch (syncError) { + dependencies.applyDocument( + clone(dependencies.readLastAck()), + 'rejected', + ); + if (attempt.inFlight.gestureIds.length > 0) { + dependencies.discardGestureIds(attempt.inFlight.gestureIds); + } + dependencies.setState({ error: syncError }); + dependencies.setState({ failureKind: 'transient' }); + dependencies.setState({ phase: 'error' }); + dependencies.notify(); + throw syncError; + } + } + + if (!isEditorCommitError(error) || error.errorCode !== 'IO_ERROR') { + dependencies.forgetOwnMutation(runtime.mutationId); + } + let canonical: CanonicalEditorGetResult | null = null; + try { + canonical = await dependencies.syncCanonical(); + } catch { + // 원래 커밋 오류 유지 + if (!(error instanceof EditorProtocolError)) { + dependencies.applyDocument( + clone(dependencies.readLastAck()), + 'rejected', + ); + } + } + const protocolOutcomeReflected = + error instanceof EditorProtocolError && + canonical !== null && + getChangedEditorFields( + canonical.document, + applySemanticOps(canonical.document, attempt.ops), + ).length === 0; + if (protocolOutcomeReflected) { + if (attempt.inFlight.gestureIds.length > 0) { + dependencies.discardGestureIds(attempt.inFlight.gestureIds); + } + dependencies.setState({ error: null }); + dependencies.setState({ failureKind: null }); + dependencies.setState({ phase: 'idle' }); + dependencies.notify(); + throw error; + } + const retryable = isSemanticCommitFailureRetryable(error); + if ( + !(error instanceof EditorProtocolError) && + attempt.inFlight.gestureIds.length > 0 + ) { + dependencies.discardGestureIds(attempt.inFlight.gestureIds); + } + dependencies.setState({ error }); + dependencies.setState({ + failureKind: retryable ? 'transient' : 'permanent', + }); + dependencies.setState({ phase: 'error' }); + dependencies.notify(); + throw error; + } finally { + if (dependencies.readInFlightMutationId() === runtime.mutationId) { + dependencies.setState({ inFlight: null }); + } + } + } +}; From 2b9712f98013922b47a65e19daaf05188275ebe6 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 17:46:57 +0900 Subject: [PATCH 165/245] =?UTF-8?q?test:=20=ED=9E=88=EC=8A=A4=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=20=EC=8A=A4=EB=83=85=EC=83=B7=20=EB=8C=80=EC=88=98=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/state/history/tests.rs | 112 ++++++++++++++++++++++++++- 1 file changed, 111 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/state/history/tests.rs b/src-tauri/src/state/history/tests.rs index 1aec21017..e3f0f7ff0 100644 --- a/src-tauri/src/state/history/tests.rs +++ b/src-tauri/src/state/history/tests.rs @@ -1,7 +1,8 @@ use super::*; use crate::{ keyboard::KeyboardManager, - models::{KeySlot, EDITOR_SCHEMA_VERSION}, + models::{AppStoreData, KeySlot, EDITOR_SCHEMA_VERSION}, + state::plugin::plugin_instances_storage_key, }; use std::{ collections::HashMap, @@ -28,6 +29,115 @@ fn plugin_snapshot(plugin_id: &str) -> PluginElementsHistorySnapshot { } } +#[test] +fn custom_tabs_snapshot_keeps_patch_ownership_and_sorted_plugin_ids() { + let plugin_a = plugin_instances_storage_key("plugin-a"); + let plugin_b = plugin_instances_storage_key("plugin-b"); + let plugin_z = plugin_instances_storage_key("plugin-z"); + let unrelated = "plugin_data_unrelated/settings".to_string(); + let mut before = AppStoreData::default(); + before + .plugin_data + .insert(plugin_z.clone(), serde_json::json!(["before-z"])); + before + .plugin_data + .insert(plugin_a.clone(), serde_json::json!(["before-a"])); + before + .plugin_data + .insert(unrelated.clone(), serde_json::json!({ "before": true })); + let mut after = before.clone(); + after.plugin_data.remove(&plugin_z); + after + .plugin_data + .insert(plugin_a.clone(), serde_json::json!(["after-a"])); + after + .plugin_data + .insert(plugin_b.clone(), serde_json::json!(["after-b"])); + after + .plugin_data + .insert(unrelated, serde_json::json!({ "after": true })); + + let snapshot = CustomTabsHistorySnapshot::from_transition(&before, &after); + + assert_eq!( + snapshot.changed_plugin_ids(), + ["plugin-a", "plugin-b", "plugin-z"] + ); + assert_eq!( + snapshot.plugin_instances_patch[&plugin_a], + Some(serde_json::json!(["before-a"])) + ); + assert_eq!(snapshot.plugin_instances_patch[&plugin_b], None); + assert_eq!( + snapshot.plugin_instances_patch[&plugin_z], + Some(serde_json::json!(["before-z"])) + ); + assert_eq!(snapshot.plugin_instances_patch.len(), 3); + + snapshot.apply_override_patches(&mut after); + assert!(snapshot.matches_store(&after)); +} + +#[test] +fn compound_validation_and_payload_serialization_order_are_stable() { + assert_eq!( + validate_compound_snapshots(&[]).unwrap_err(), + "compound history cannot be empty" + ); + assert_eq!( + validate_compound_snapshots(&[ + HistorySnapshot::Editor { + changed_fields: vec![EditorField::Keys], + before: Box::new(patch("first")), + key_counters: None, + }, + HistorySnapshot::Editor { + changed_fields: vec![EditorField::Keys], + before: Box::new(patch("second")), + key_counters: None, + }, + HistorySnapshot::Mode("unsupported-after-duplicate".to_string()), + ]) + .unwrap_err(), + "compound history contains duplicate editor snapshots" + ); + assert_eq!( + validate_compound_snapshots(&[ + HistorySnapshot::PluginElements(plugin_snapshot("plugin-a")), + HistorySnapshot::PluginElements(plugin_snapshot("plugin-a")), + ]) + .unwrap_err(), + "compound history contains duplicate plugin snapshots" + ); + assert_eq!( + validate_compound_snapshots(&[HistorySnapshot::Mode("unsupported".to_string())]) + .unwrap_err(), + "compound history contains an unsupported snapshot" + ); + assert_eq!( + normalize_gesture_ids(vec![ + "second".to_string(), + "first".to_string(), + "second".to_string(), + "third".to_string(), + "first".to_string(), + ]), + ["second", "first", "third"] + ); + + let before = HistorySnapshot::Mode("4key".to_string()); + let gesture_ids = vec!["first".to_string(), "second".to_string()]; + assert_eq!( + serde_json::to_string(&HistoryEntryPayload { + scope: HistoryScope::Mode, + before: &before, + gesture_ids: &gesture_ids, + }) + .unwrap(), + r#"{"scope":"mode","before":{"kind":"mode","value":"4key"},"gestureIds":["first","second"]}"# + ); +} + #[test] fn preset_history_target_match_ignores_live_counter_drift() { let mut store = AppStoreData::default(); From 9c1d8511ef05b563cead1ca554fe962dccf20f83 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 17:46:57 +0900 Subject: [PATCH 166/245] =?UTF-8?q?refactor:=20=ED=9E=88=EC=8A=A4=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=20=EC=8A=A4=EB=83=85=EC=83=B7=20=EB=8C=80=EC=88=98=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/state/history.rs | 592 +----------------------- src-tauri/src/state/history/snapshot.rs | 588 +++++++++++++++++++++++ 2 files changed, 601 insertions(+), 579 deletions(-) create mode 100644 src-tauri/src/state/history/snapshot.rs diff --git a/src-tauri/src/state/history.rs b/src-tauri/src/state/history.rs index c54c02ab8..e339deb8c 100644 --- a/src-tauri/src/state/history.rs +++ b/src-tauri/src/state/history.rs @@ -1,21 +1,26 @@ -use std::collections::{HashMap, HashSet, VecDeque}; - -use serde::Serialize; -use serde_json::Value; +use std::collections::VecDeque; mod admission; +mod snapshot; pub(crate) use admission::{ HistoryAdmission, HistoryAdmissionGate, HistoryAdmissionLease, HistoryBarrierLease, HistoryBarrierWaiter, }; +use snapshot::{ + merge_editor_snapshot, merge_gesture_snapshots, merge_plugin_elements_snapshot, + normalize_gesture_ids, remove_net_zero_editor_snapshot, remove_net_zero_plugin_snapshot, + snapshot_contains_plugin_elements, validate_compound_snapshots, HistoryEntryPayload, +}; +pub(crate) use snapshot::{ + CustomTabsHistorySnapshot, HistoryDirection, HistoryEntry, HistoryRecordPlan, HistoryScope, + HistorySnapshot, PluginElementsHistorySnapshot, PresetFullHistorySnapshot, + PresetHistorySettingsSnapshot, +}; use crate::models::{ - AppStoreData, CustomCss, CustomJs, CustomTab, EditorDocumentV1, EditorField, EditorPatchV1, - FontSettings, HistoryStatus, HistoryTruncated, KeyCounters, NoteSettings, SavedPluginInstance, - TabCss, TabCssOverrides, TabNoteOverrides, TabNoteSettings, + EditorDocumentV1, EditorField, EditorPatchV1, HistoryStatus, HistoryTruncated, KeyCounters, }; -use crate::state::plugin::{is_plugin_instances_storage_key, plugin_id_from_instances_storage_key}; pub(crate) const HISTORY_ENTRY_MAX_BYTES: usize = 8 * 1024 * 1024; const HISTORY_TOTAL_MAX_BYTES: usize = 32 * 1024 * 1024; @@ -29,558 +34,6 @@ pub(crate) const HISTORY_SCOPE_MISMATCH: &str = "HISTORY_SCOPE_MISMATCH"; pub(crate) const HISTORY_TARGET_ALREADY_APPLIED: &str = "HISTORY_TARGET_ALREADY_APPLIED"; pub(crate) const HISTORY_INVALID_OPPOSITE_ENTRY: &str = "HISTORY_INVALID_OPPOSITE_ENTRY"; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) enum HistoryScope { - Editor, - CustomTabs, - Mode, - Counters, - PresetFull, - PluginElements, - Compound, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum HistoryDirection { - Undo, - Redo, -} - -impl HistoryDirection { - pub(crate) fn empty_error(self) -> &'static str { - match self { - Self::Undo => "HISTORY_NOTHING_TO_UNDO", - Self::Redo => "HISTORY_NOTHING_TO_REDO", - } - } -} - -#[derive(Debug, Clone)] -pub(crate) struct HistoryEntry { - pub(crate) scope: HistoryScope, - pub(crate) before: HistorySnapshot, - pub(crate) gesture_id: Option, - pub(crate) gesture_ids: Vec, - size_bytes: usize, - access_sequence: u64, -} - -impl HistoryEntry { - fn matches_any_gesture(&self, gesture_ids: &[String]) -> bool { - gesture_ids - .iter() - .any(|gesture_id| self.gesture_ids.contains(gesture_id)) - } -} - -#[derive(Debug, Clone, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct CustomTabsHistorySnapshot { - pub(crate) document: EditorDocumentV1, - pub(crate) custom_tabs: Vec, - pub(crate) selected_key_type: String, - pub(crate) key_counters: KeyCounters, - pub(crate) tab_css_patch: HashMap>, - pub(crate) tab_note_patch: HashMap>, - pub(crate) plugin_instances_patch: HashMap>, -} - -impl CustomTabsHistorySnapshot { - pub(crate) fn from_transition(before: &AppStoreData, after: &AppStoreData) -> Self { - Self { - document: EditorDocumentV1::from_store(before), - custom_tabs: before.custom_tabs.clone(), - selected_key_type: before.selected_key_type.clone(), - key_counters: before.key_counters.clone(), - tab_css_patch: changed_value_patch(&before.tab_css_overrides, &after.tab_css_overrides), - tab_note_patch: changed_value_patch( - &before.tab_note_overrides, - &after.tab_note_overrides, - ), - plugin_instances_patch: changed_value_patch(&before.plugin_data, &after.plugin_data) - .into_iter() - .filter(|(key, _)| is_plugin_instances_storage_key(key)) - .collect(), - } - } - - pub(crate) fn from_store_for_target(store: &AppStoreData, target: &Self) -> Self { - Self { - document: EditorDocumentV1::from_store(store), - custom_tabs: store.custom_tabs.clone(), - selected_key_type: store.selected_key_type.clone(), - key_counters: store.key_counters.clone(), - tab_css_patch: current_values_for_keys( - &store.tab_css_overrides, - target.tab_css_patch.keys(), - ), - tab_note_patch: current_values_for_keys( - &store.tab_note_overrides, - target.tab_note_patch.keys(), - ), - plugin_instances_patch: current_values_for_keys( - &store.plugin_data, - target.plugin_instances_patch.keys(), - ), - } - } - - pub(crate) fn matches_store(&self, store: &AppStoreData) -> bool { - self.document == EditorDocumentV1::from_store(store) - && self.custom_tabs == store.custom_tabs - && self.selected_key_type == store.selected_key_type - && patch_matches(&self.tab_css_patch, &store.tab_css_overrides) - && patch_matches(&self.tab_note_patch, &store.tab_note_overrides) - && patch_matches(&self.plugin_instances_patch, &store.plugin_data) - } - - pub(crate) fn apply_override_patches(&self, store: &mut AppStoreData) { - apply_value_patch(&self.tab_css_patch, &mut store.tab_css_overrides); - apply_value_patch(&self.tab_note_patch, &mut store.tab_note_overrides); - apply_value_patch(&self.plugin_instances_patch, &mut store.plugin_data); - } - - pub(crate) fn changed_plugin_ids(&self) -> Vec { - let mut plugin_ids = self - .plugin_instances_patch - .keys() - .filter_map(|key| plugin_id_from_instances_storage_key(key)) - .map(str::to_string) - .collect::>(); - plugin_ids.sort_unstable(); - plugin_ids.dedup(); - plugin_ids - } -} - -#[derive(Debug, Clone, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct PresetHistorySettingsSnapshot { - pub(crate) use_custom_css: bool, - pub(crate) custom_css: CustomCss, - pub(crate) use_custom_js: bool, - pub(crate) custom_js: CustomJs, - pub(crate) font_settings: FontSettings, - pub(crate) background_color: String, - pub(crate) note_settings: NoteSettings, - pub(crate) note_effect: bool, - pub(crate) laboratory_enabled: bool, - pub(crate) tab_note_overrides: TabNoteOverrides, -} - -#[derive(Debug, Clone, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct PresetFullHistorySnapshot { - pub(crate) document: EditorDocumentV1, - pub(crate) custom_tabs: Vec, - pub(crate) selected_key_type: String, - pub(crate) key_counters: KeyCounters, - pub(crate) settings: PresetHistorySettingsSnapshot, - pub(crate) tab_css_overrides: TabCssOverrides, -} - -#[derive(Debug, Clone, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct PluginElementsHistorySnapshot { - pub(crate) plugin_id: String, - pub(crate) instances: Option>, -} - -impl PresetFullHistorySnapshot { - pub(crate) fn from_store(store: &AppStoreData) -> Self { - Self { - document: EditorDocumentV1::from_store(store), - custom_tabs: store.custom_tabs.clone(), - selected_key_type: store.selected_key_type.clone(), - key_counters: store.key_counters.clone(), - settings: PresetHistorySettingsSnapshot { - use_custom_css: store.use_custom_css, - custom_css: store.custom_css.clone(), - use_custom_js: store.use_custom_js, - custom_js: store.custom_js.clone(), - font_settings: store.font_settings.clone(), - background_color: store.background_color.clone(), - note_settings: store.note_settings.clone(), - note_effect: store.note_effect, - laboratory_enabled: store.laboratory_enabled, - tab_note_overrides: store.tab_note_overrides.clone(), - }, - tab_css_overrides: store.tab_css_overrides.clone(), - } - } - - pub(crate) fn matches_store(&self, store: &AppStoreData) -> bool { - let current = Self::from_store(store); - self.document == current.document - && self.custom_tabs == current.custom_tabs - && self.selected_key_type == current.selected_key_type - && self.settings == current.settings - && self.tab_css_overrides == current.tab_css_overrides - } -} - -fn changed_value_patch( - before: &HashMap, - after: &HashMap, -) -> HashMap> { - before - .keys() - .chain(after.keys()) - .collect::>() - .into_iter() - .filter(|key| before.get(*key) != after.get(*key)) - .map(|key| (key.clone(), before.get(key).cloned())) - .collect() -} - -fn current_values_for_keys<'a, T: Clone + 'a>( - values: &HashMap, - keys: impl Iterator, -) -> HashMap> { - keys.map(|key| (key.clone(), values.get(key).cloned())) - .collect() -} - -fn patch_matches( - patch: &HashMap>, - values: &HashMap, -) -> bool { - patch - .iter() - .all(|(key, value)| values.get(key) == value.as_ref()) -} - -fn apply_value_patch( - patch: &HashMap>, - values: &mut HashMap, -) { - for (key, value) in patch { - match value { - Some(value) => { - values.insert(key.clone(), value.clone()); - } - None => { - values.remove(key); - } - } - } -} - -fn preserve_editor_before_values(merged: &mut EditorPatchV1, first: &EditorPatchV1) { - if first.keys.is_some() { - merged.keys.clone_from(&first.keys); - } - if first.key_positions.is_some() { - merged.key_positions.clone_from(&first.key_positions); - } - if first.stat_positions.is_some() { - merged.stat_positions.clone_from(&first.stat_positions); - } - if first.graph_positions.is_some() { - merged.graph_positions.clone_from(&first.graph_positions); - } - if first.knob_positions.is_some() { - merged.knob_positions.clone_from(&first.knob_positions); - } - if first.layer_groups.is_some() { - merged.layer_groups.clone_from(&first.layer_groups); - } -} - -#[derive(Debug, Clone, PartialEq, Serialize)] -#[serde(tag = "kind", content = "value", rename_all = "camelCase")] -pub(crate) enum HistorySnapshot { - Editor { - changed_fields: Vec, - before: Box, - #[serde(skip_serializing_if = "Option::is_none")] - key_counters: Option, - }, - CustomTabs(Box), - Mode(String), - Counters(KeyCounters), - PresetFull(Box), - PluginElements(PluginElementsHistorySnapshot), - Compound { - snapshots: Vec, - }, -} - -impl HistorySnapshot { - pub(crate) fn scope(&self) -> HistoryScope { - match self { - Self::Editor { .. } => HistoryScope::Editor, - Self::CustomTabs(_) => HistoryScope::CustomTabs, - Self::Mode(_) => HistoryScope::Mode, - Self::Counters(_) => HistoryScope::Counters, - Self::PresetFull(_) => HistoryScope::PresetFull, - Self::PluginElements(_) => HistoryScope::PluginElements, - Self::Compound { .. } => HistoryScope::Compound, - } - } -} - -fn merged_editor_before( - first_fields: &[EditorField], - first_before: &EditorPatchV1, - first_key_counters: &Option, - changed_fields: Vec, - before: EditorPatchV1, - key_counters: Option, -) -> HistorySnapshot { - let mut merged_fields = first_fields.to_vec(); - for field in changed_fields { - if !merged_fields.contains(&field) { - merged_fields.push(field); - } - } - let mut merged_before = before; - preserve_editor_before_values(&mut merged_before, first_before); - let key_counters = if first_fields.contains(&EditorField::Keys) { - first_key_counters.clone().or(key_counters) - } else if merged_fields.contains(&EditorField::Keys) { - key_counters - } else { - None - }; - HistorySnapshot::Editor { - changed_fields: merged_fields, - before: Box::new(merged_before), - key_counters, - } -} - -fn merge_editor_snapshot( - existing: &HistorySnapshot, - changed_fields: Vec, - before: EditorPatchV1, - key_counters: Option, -) -> Result { - match existing { - HistorySnapshot::Editor { - changed_fields: first_fields, - before: first_before, - key_counters: first_key_counters, - } => Ok(merged_editor_before( - first_fields, - first_before, - first_key_counters, - changed_fields, - before, - key_counters, - )), - HistorySnapshot::PluginElements(_) => Ok(HistorySnapshot::Compound { - snapshots: vec![ - existing.clone(), - HistorySnapshot::Editor { - changed_fields, - before: Box::new(before), - key_counters, - }, - ], - }), - HistorySnapshot::Compound { snapshots } => { - validate_compound_snapshots(snapshots)?; - let mut merged = snapshots.clone(); - if let Some(index) = merged - .iter() - .position(|snapshot| matches!(snapshot, HistorySnapshot::Editor { .. })) - { - let HistorySnapshot::Editor { - changed_fields: first_fields, - before: first_before, - key_counters: first_key_counters, - } = &merged[index] - else { - unreachable!(); - }; - merged[index] = merged_editor_before( - first_fields, - first_before, - first_key_counters, - changed_fields, - before, - key_counters, - ); - } else { - merged.push(HistorySnapshot::Editor { - changed_fields, - before: Box::new(before), - key_counters, - }); - } - Ok(HistorySnapshot::Compound { snapshots: merged }) - } - _ => Err("gesture history cannot merge editor with this scope".to_string()), - } -} - -fn merge_plugin_elements_snapshot( - existing: &HistorySnapshot, - before: PluginElementsHistorySnapshot, -) -> Result { - match existing { - HistorySnapshot::Editor { .. } => Ok(HistorySnapshot::Compound { - snapshots: vec![existing.clone(), HistorySnapshot::PluginElements(before)], - }), - HistorySnapshot::PluginElements(first_before) => { - if first_before.plugin_id == before.plugin_id { - Ok(existing.clone()) - } else { - Ok(HistorySnapshot::Compound { - snapshots: vec![existing.clone(), HistorySnapshot::PluginElements(before)], - }) - } - } - HistorySnapshot::Compound { snapshots } => { - validate_compound_snapshots(snapshots)?; - if snapshots.iter().any(|snapshot| { - matches!( - snapshot, - HistorySnapshot::PluginElements(first_before) - if first_before.plugin_id == before.plugin_id - ) - }) { - return Ok(existing.clone()); - } - let mut merged = snapshots.clone(); - merged.push(HistorySnapshot::PluginElements(before)); - Ok(HistorySnapshot::Compound { snapshots: merged }) - } - _ => Err("gesture history cannot merge plugin elements with this scope".to_string()), - } -} - -fn merge_gesture_snapshots( - existing: &HistorySnapshot, - snapshots: &[HistorySnapshot], -) -> Result { - let mut merged = existing.clone(); - for snapshot in snapshots { - merged = match snapshot { - HistorySnapshot::Editor { - changed_fields, - before, - key_counters, - } => merge_editor_snapshot( - &merged, - changed_fields.clone(), - before.as_ref().clone(), - key_counters.clone(), - )?, - HistorySnapshot::PluginElements(before) => { - merge_plugin_elements_snapshot(&merged, before.clone())? - } - _ => return Err("gesture history cannot merge this snapshot scope".to_string()), - }; - } - Ok(merged) -} - -fn validate_compound_snapshots(snapshots: &[HistorySnapshot]) -> Result<(), String> { - let mut has_editor = false; - let mut plugin_ids = HashSet::new(); - for snapshot in snapshots { - match snapshot { - HistorySnapshot::Editor { .. } if !has_editor => has_editor = true, - HistorySnapshot::PluginElements(before) - if plugin_ids.insert(before.plugin_id.as_str()) => {} - HistorySnapshot::Editor { .. } => { - return Err("compound history contains duplicate editor snapshots".to_string()) - } - HistorySnapshot::PluginElements(_) => { - return Err("compound history contains duplicate plugin snapshots".to_string()) - } - _ => return Err("compound history contains an unsupported snapshot".to_string()), - } - } - if snapshots.is_empty() { - return Err("compound history cannot be empty".to_string()); - } - Ok(()) -} - -fn normalize_gesture_ids(gesture_ids: Vec) -> Vec { - let mut normalized = Vec::with_capacity(gesture_ids.len()); - for gesture_id in gesture_ids { - if !normalized.contains(&gesture_id) { - normalized.push(gesture_id); - } - } - normalized -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct HistoryEntryPayload<'a> { - scope: HistoryScope, - before: &'a HistorySnapshot, - #[serde(skip_serializing_if = "Vec::is_empty")] - gesture_ids: &'a Vec, -} - -fn refresh_history_entry_size(entry: &mut HistoryEntry) { - if let Ok(payload) = serde_json::to_vec(&HistoryEntryPayload { - scope: entry.scope, - before: &entry.before, - gesture_ids: &entry.gesture_ids, - }) { - entry.size_bytes = payload.len(); - } -} - -fn remove_net_zero_editor_snapshot(entry: &mut HistoryEntry, canonical: &EditorDocumentV1) -> bool { - let editor_is_net_zero = |snapshot: &HistorySnapshot| { - matches!( - snapshot, - HistorySnapshot::Editor { - changed_fields, - before, - .. - } if canonical.patch_for_fields(changed_fields) == **before - ) - }; - match &mut entry.before { - snapshot @ HistorySnapshot::Editor { .. } if editor_is_net_zero(snapshot) => return true, - HistorySnapshot::Compound { snapshots } => { - snapshots.retain(|snapshot| !editor_is_net_zero(snapshot)); - } - _ => {} - } - refresh_history_entry_size(entry); - matches!(&entry.before, HistorySnapshot::Compound { snapshots } if snapshots.is_empty()) -} - -fn remove_net_zero_plugin_snapshot( - entry: &mut HistoryEntry, - canonical: &PluginElementsHistorySnapshot, -) -> bool { - let plugin_is_net_zero = |snapshot: &HistorySnapshot| matches!(snapshot, HistorySnapshot::PluginElements(before) if before == canonical); - match &mut entry.before { - snapshot @ HistorySnapshot::PluginElements(_) if plugin_is_net_zero(snapshot) => { - return true - } - HistorySnapshot::Compound { snapshots } => { - snapshots.retain(|snapshot| !plugin_is_net_zero(snapshot)); - } - _ => {} - } - refresh_history_entry_size(entry); - matches!(&entry.before, HistorySnapshot::Compound { snapshots } if snapshots.is_empty()) -} - -#[derive(Debug)] -pub(crate) enum HistoryRecordPlan { - Entry(Box), - Merge { - entry: Box, - target_access_sequence: u64, - remove_access_sequences: Vec, - }, - Truncate, -} - #[derive(Debug, Clone)] struct HistoryOperationAck { operation_id: String, @@ -1233,24 +686,5 @@ impl HistoryService { } } -fn snapshot_contains_plugin_elements(snapshot: &HistorySnapshot, plugin_id: Option<&str>) -> bool { - match snapshot { - HistorySnapshot::PluginElements(snapshot) => { - plugin_id.is_none_or(|plugin_id| snapshot.plugin_id == plugin_id) - } - HistorySnapshot::Compound { snapshots } => snapshots - .iter() - .any(|snapshot| snapshot_contains_plugin_elements(snapshot, plugin_id)), - HistorySnapshot::CustomTabs(snapshot) => snapshot - .changed_plugin_ids() - .iter() - .any(|candidate| plugin_id.is_none_or(|plugin_id| candidate == plugin_id)), - HistorySnapshot::Editor { .. } - | HistorySnapshot::Mode(_) - | HistorySnapshot::Counters(_) - | HistorySnapshot::PresetFull(_) => false, - } -} - #[cfg(test)] mod tests; diff --git a/src-tauri/src/state/history/snapshot.rs b/src-tauri/src/state/history/snapshot.rs new file mode 100644 index 000000000..73ec11570 --- /dev/null +++ b/src-tauri/src/state/history/snapshot.rs @@ -0,0 +1,588 @@ +use std::collections::{HashMap, HashSet}; + +use serde::Serialize; +use serde_json::Value; + +use crate::models::{ + AppStoreData, CustomCss, CustomJs, CustomTab, EditorDocumentV1, EditorField, EditorPatchV1, + FontSettings, KeyCounters, NoteSettings, SavedPluginInstance, TabCss, TabCssOverrides, + TabNoteOverrides, TabNoteSettings, +}; +use crate::state::plugin::{is_plugin_instances_storage_key, plugin_id_from_instances_storage_key}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum HistoryScope { + Editor, + CustomTabs, + Mode, + Counters, + PresetFull, + PluginElements, + Compound, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum HistoryDirection { + Undo, + Redo, +} + +impl HistoryDirection { + pub(crate) fn empty_error(self) -> &'static str { + match self { + Self::Undo => "HISTORY_NOTHING_TO_UNDO", + Self::Redo => "HISTORY_NOTHING_TO_REDO", + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct HistoryEntry { + pub(crate) scope: HistoryScope, + pub(crate) before: HistorySnapshot, + pub(crate) gesture_id: Option, + pub(crate) gesture_ids: Vec, + pub(super) size_bytes: usize, + pub(super) access_sequence: u64, +} + +impl HistoryEntry { + pub(super) fn matches_any_gesture(&self, gesture_ids: &[String]) -> bool { + gesture_ids + .iter() + .any(|gesture_id| self.gesture_ids.contains(gesture_id)) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CustomTabsHistorySnapshot { + pub(crate) document: EditorDocumentV1, + pub(crate) custom_tabs: Vec, + pub(crate) selected_key_type: String, + pub(crate) key_counters: KeyCounters, + pub(crate) tab_css_patch: HashMap>, + pub(crate) tab_note_patch: HashMap>, + pub(crate) plugin_instances_patch: HashMap>, +} + +impl CustomTabsHistorySnapshot { + pub(crate) fn from_transition(before: &AppStoreData, after: &AppStoreData) -> Self { + Self { + document: EditorDocumentV1::from_store(before), + custom_tabs: before.custom_tabs.clone(), + selected_key_type: before.selected_key_type.clone(), + key_counters: before.key_counters.clone(), + tab_css_patch: changed_value_patch(&before.tab_css_overrides, &after.tab_css_overrides), + tab_note_patch: changed_value_patch( + &before.tab_note_overrides, + &after.tab_note_overrides, + ), + plugin_instances_patch: changed_value_patch(&before.plugin_data, &after.plugin_data) + .into_iter() + .filter(|(key, _)| is_plugin_instances_storage_key(key)) + .collect(), + } + } + + pub(crate) fn from_store_for_target(store: &AppStoreData, target: &Self) -> Self { + Self { + document: EditorDocumentV1::from_store(store), + custom_tabs: store.custom_tabs.clone(), + selected_key_type: store.selected_key_type.clone(), + key_counters: store.key_counters.clone(), + tab_css_patch: current_values_for_keys( + &store.tab_css_overrides, + target.tab_css_patch.keys(), + ), + tab_note_patch: current_values_for_keys( + &store.tab_note_overrides, + target.tab_note_patch.keys(), + ), + plugin_instances_patch: current_values_for_keys( + &store.plugin_data, + target.plugin_instances_patch.keys(), + ), + } + } + + pub(crate) fn matches_store(&self, store: &AppStoreData) -> bool { + self.document == EditorDocumentV1::from_store(store) + && self.custom_tabs == store.custom_tabs + && self.selected_key_type == store.selected_key_type + && patch_matches(&self.tab_css_patch, &store.tab_css_overrides) + && patch_matches(&self.tab_note_patch, &store.tab_note_overrides) + && patch_matches(&self.plugin_instances_patch, &store.plugin_data) + } + + pub(crate) fn apply_override_patches(&self, store: &mut AppStoreData) { + apply_value_patch(&self.tab_css_patch, &mut store.tab_css_overrides); + apply_value_patch(&self.tab_note_patch, &mut store.tab_note_overrides); + apply_value_patch(&self.plugin_instances_patch, &mut store.plugin_data); + } + + pub(crate) fn changed_plugin_ids(&self) -> Vec { + let mut plugin_ids = self + .plugin_instances_patch + .keys() + .filter_map(|key| plugin_id_from_instances_storage_key(key)) + .map(str::to_string) + .collect::>(); + plugin_ids.sort_unstable(); + plugin_ids.dedup(); + plugin_ids + } +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PresetHistorySettingsSnapshot { + pub(crate) use_custom_css: bool, + pub(crate) custom_css: CustomCss, + pub(crate) use_custom_js: bool, + pub(crate) custom_js: CustomJs, + pub(crate) font_settings: FontSettings, + pub(crate) background_color: String, + pub(crate) note_settings: NoteSettings, + pub(crate) note_effect: bool, + pub(crate) laboratory_enabled: bool, + pub(crate) tab_note_overrides: TabNoteOverrides, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PresetFullHistorySnapshot { + pub(crate) document: EditorDocumentV1, + pub(crate) custom_tabs: Vec, + pub(crate) selected_key_type: String, + pub(crate) key_counters: KeyCounters, + pub(crate) settings: PresetHistorySettingsSnapshot, + pub(crate) tab_css_overrides: TabCssOverrides, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PluginElementsHistorySnapshot { + pub(crate) plugin_id: String, + pub(crate) instances: Option>, +} + +impl PresetFullHistorySnapshot { + pub(crate) fn from_store(store: &AppStoreData) -> Self { + Self { + document: EditorDocumentV1::from_store(store), + custom_tabs: store.custom_tabs.clone(), + selected_key_type: store.selected_key_type.clone(), + key_counters: store.key_counters.clone(), + settings: PresetHistorySettingsSnapshot { + use_custom_css: store.use_custom_css, + custom_css: store.custom_css.clone(), + use_custom_js: store.use_custom_js, + custom_js: store.custom_js.clone(), + font_settings: store.font_settings.clone(), + background_color: store.background_color.clone(), + note_settings: store.note_settings.clone(), + note_effect: store.note_effect, + laboratory_enabled: store.laboratory_enabled, + tab_note_overrides: store.tab_note_overrides.clone(), + }, + tab_css_overrides: store.tab_css_overrides.clone(), + } + } + + pub(crate) fn matches_store(&self, store: &AppStoreData) -> bool { + let current = Self::from_store(store); + self.document == current.document + && self.custom_tabs == current.custom_tabs + && self.selected_key_type == current.selected_key_type + && self.settings == current.settings + && self.tab_css_overrides == current.tab_css_overrides + } +} + +fn changed_value_patch( + before: &HashMap, + after: &HashMap, +) -> HashMap> { + before + .keys() + .chain(after.keys()) + .collect::>() + .into_iter() + .filter(|key| before.get(*key) != after.get(*key)) + .map(|key| (key.clone(), before.get(key).cloned())) + .collect() +} + +fn current_values_for_keys<'a, T: Clone + 'a>( + values: &HashMap, + keys: impl Iterator, +) -> HashMap> { + keys.map(|key| (key.clone(), values.get(key).cloned())) + .collect() +} + +fn patch_matches( + patch: &HashMap>, + values: &HashMap, +) -> bool { + patch + .iter() + .all(|(key, value)| values.get(key) == value.as_ref()) +} + +fn apply_value_patch( + patch: &HashMap>, + values: &mut HashMap, +) { + for (key, value) in patch { + match value { + Some(value) => { + values.insert(key.clone(), value.clone()); + } + None => { + values.remove(key); + } + } + } +} + +fn preserve_editor_before_values(merged: &mut EditorPatchV1, first: &EditorPatchV1) { + if first.keys.is_some() { + merged.keys.clone_from(&first.keys); + } + if first.key_positions.is_some() { + merged.key_positions.clone_from(&first.key_positions); + } + if first.stat_positions.is_some() { + merged.stat_positions.clone_from(&first.stat_positions); + } + if first.graph_positions.is_some() { + merged.graph_positions.clone_from(&first.graph_positions); + } + if first.knob_positions.is_some() { + merged.knob_positions.clone_from(&first.knob_positions); + } + if first.layer_groups.is_some() { + merged.layer_groups.clone_from(&first.layer_groups); + } +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(tag = "kind", content = "value", rename_all = "camelCase")] +pub(crate) enum HistorySnapshot { + Editor { + changed_fields: Vec, + before: Box, + #[serde(skip_serializing_if = "Option::is_none")] + key_counters: Option, + }, + CustomTabs(Box), + Mode(String), + Counters(KeyCounters), + PresetFull(Box), + PluginElements(PluginElementsHistorySnapshot), + Compound { + snapshots: Vec, + }, +} + +impl HistorySnapshot { + pub(crate) fn scope(&self) -> HistoryScope { + match self { + Self::Editor { .. } => HistoryScope::Editor, + Self::CustomTabs(_) => HistoryScope::CustomTabs, + Self::Mode(_) => HistoryScope::Mode, + Self::Counters(_) => HistoryScope::Counters, + Self::PresetFull(_) => HistoryScope::PresetFull, + Self::PluginElements(_) => HistoryScope::PluginElements, + Self::Compound { .. } => HistoryScope::Compound, + } + } +} + +fn merged_editor_before( + first_fields: &[EditorField], + first_before: &EditorPatchV1, + first_key_counters: &Option, + changed_fields: Vec, + before: EditorPatchV1, + key_counters: Option, +) -> HistorySnapshot { + let mut merged_fields = first_fields.to_vec(); + for field in changed_fields { + if !merged_fields.contains(&field) { + merged_fields.push(field); + } + } + let mut merged_before = before; + preserve_editor_before_values(&mut merged_before, first_before); + let key_counters = if first_fields.contains(&EditorField::Keys) { + first_key_counters.clone().or(key_counters) + } else if merged_fields.contains(&EditorField::Keys) { + key_counters + } else { + None + }; + HistorySnapshot::Editor { + changed_fields: merged_fields, + before: Box::new(merged_before), + key_counters, + } +} + +pub(super) fn merge_editor_snapshot( + existing: &HistorySnapshot, + changed_fields: Vec, + before: EditorPatchV1, + key_counters: Option, +) -> Result { + match existing { + HistorySnapshot::Editor { + changed_fields: first_fields, + before: first_before, + key_counters: first_key_counters, + } => Ok(merged_editor_before( + first_fields, + first_before, + first_key_counters, + changed_fields, + before, + key_counters, + )), + HistorySnapshot::PluginElements(_) => Ok(HistorySnapshot::Compound { + snapshots: vec![ + existing.clone(), + HistorySnapshot::Editor { + changed_fields, + before: Box::new(before), + key_counters, + }, + ], + }), + HistorySnapshot::Compound { snapshots } => { + validate_compound_snapshots(snapshots)?; + let mut merged = snapshots.clone(); + if let Some(index) = merged + .iter() + .position(|snapshot| matches!(snapshot, HistorySnapshot::Editor { .. })) + { + let HistorySnapshot::Editor { + changed_fields: first_fields, + before: first_before, + key_counters: first_key_counters, + } = &merged[index] + else { + unreachable!(); + }; + merged[index] = merged_editor_before( + first_fields, + first_before, + first_key_counters, + changed_fields, + before, + key_counters, + ); + } else { + merged.push(HistorySnapshot::Editor { + changed_fields, + before: Box::new(before), + key_counters, + }); + } + Ok(HistorySnapshot::Compound { snapshots: merged }) + } + _ => Err("gesture history cannot merge editor with this scope".to_string()), + } +} + +pub(super) fn merge_plugin_elements_snapshot( + existing: &HistorySnapshot, + before: PluginElementsHistorySnapshot, +) -> Result { + match existing { + HistorySnapshot::Editor { .. } => Ok(HistorySnapshot::Compound { + snapshots: vec![existing.clone(), HistorySnapshot::PluginElements(before)], + }), + HistorySnapshot::PluginElements(first_before) => { + if first_before.plugin_id == before.plugin_id { + Ok(existing.clone()) + } else { + Ok(HistorySnapshot::Compound { + snapshots: vec![existing.clone(), HistorySnapshot::PluginElements(before)], + }) + } + } + HistorySnapshot::Compound { snapshots } => { + validate_compound_snapshots(snapshots)?; + if snapshots.iter().any(|snapshot| { + matches!( + snapshot, + HistorySnapshot::PluginElements(first_before) + if first_before.plugin_id == before.plugin_id + ) + }) { + return Ok(existing.clone()); + } + let mut merged = snapshots.clone(); + merged.push(HistorySnapshot::PluginElements(before)); + Ok(HistorySnapshot::Compound { snapshots: merged }) + } + _ => Err("gesture history cannot merge plugin elements with this scope".to_string()), + } +} + +pub(super) fn merge_gesture_snapshots( + existing: &HistorySnapshot, + snapshots: &[HistorySnapshot], +) -> Result { + let mut merged = existing.clone(); + for snapshot in snapshots { + merged = match snapshot { + HistorySnapshot::Editor { + changed_fields, + before, + key_counters, + } => merge_editor_snapshot( + &merged, + changed_fields.clone(), + before.as_ref().clone(), + key_counters.clone(), + )?, + HistorySnapshot::PluginElements(before) => { + merge_plugin_elements_snapshot(&merged, before.clone())? + } + _ => return Err("gesture history cannot merge this snapshot scope".to_string()), + }; + } + Ok(merged) +} + +pub(super) fn validate_compound_snapshots(snapshots: &[HistorySnapshot]) -> Result<(), String> { + let mut has_editor = false; + let mut plugin_ids = HashSet::new(); + for snapshot in snapshots { + match snapshot { + HistorySnapshot::Editor { .. } if !has_editor => has_editor = true, + HistorySnapshot::PluginElements(before) + if plugin_ids.insert(before.plugin_id.as_str()) => {} + HistorySnapshot::Editor { .. } => { + return Err("compound history contains duplicate editor snapshots".to_string()) + } + HistorySnapshot::PluginElements(_) => { + return Err("compound history contains duplicate plugin snapshots".to_string()) + } + _ => return Err("compound history contains an unsupported snapshot".to_string()), + } + } + if snapshots.is_empty() { + return Err("compound history cannot be empty".to_string()); + } + Ok(()) +} + +pub(super) fn normalize_gesture_ids(gesture_ids: Vec) -> Vec { + let mut normalized = Vec::with_capacity(gesture_ids.len()); + for gesture_id in gesture_ids { + if !normalized.contains(&gesture_id) { + normalized.push(gesture_id); + } + } + normalized +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct HistoryEntryPayload<'a> { + pub(super) scope: HistoryScope, + pub(super) before: &'a HistorySnapshot, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub(super) gesture_ids: &'a Vec, +} + +fn refresh_history_entry_size(entry: &mut HistoryEntry) { + if let Ok(payload) = serde_json::to_vec(&HistoryEntryPayload { + scope: entry.scope, + before: &entry.before, + gesture_ids: &entry.gesture_ids, + }) { + entry.size_bytes = payload.len(); + } +} + +pub(super) fn remove_net_zero_editor_snapshot( + entry: &mut HistoryEntry, + canonical: &EditorDocumentV1, +) -> bool { + let editor_is_net_zero = |snapshot: &HistorySnapshot| { + matches!( + snapshot, + HistorySnapshot::Editor { + changed_fields, + before, + .. + } if canonical.patch_for_fields(changed_fields) == **before + ) + }; + match &mut entry.before { + snapshot @ HistorySnapshot::Editor { .. } if editor_is_net_zero(snapshot) => return true, + HistorySnapshot::Compound { snapshots } => { + snapshots.retain(|snapshot| !editor_is_net_zero(snapshot)); + } + _ => {} + } + refresh_history_entry_size(entry); + matches!(&entry.before, HistorySnapshot::Compound { snapshots } if snapshots.is_empty()) +} + +pub(super) fn remove_net_zero_plugin_snapshot( + entry: &mut HistoryEntry, + canonical: &PluginElementsHistorySnapshot, +) -> bool { + let plugin_is_net_zero = |snapshot: &HistorySnapshot| matches!(snapshot, HistorySnapshot::PluginElements(before) if before == canonical); + match &mut entry.before { + snapshot @ HistorySnapshot::PluginElements(_) if plugin_is_net_zero(snapshot) => { + return true + } + HistorySnapshot::Compound { snapshots } => { + snapshots.retain(|snapshot| !plugin_is_net_zero(snapshot)); + } + _ => {} + } + refresh_history_entry_size(entry); + matches!(&entry.before, HistorySnapshot::Compound { snapshots } if snapshots.is_empty()) +} + +#[derive(Debug)] +pub(crate) enum HistoryRecordPlan { + Entry(Box), + Merge { + entry: Box, + target_access_sequence: u64, + remove_access_sequences: Vec, + }, + Truncate, +} + +pub(super) fn snapshot_contains_plugin_elements( + snapshot: &HistorySnapshot, + plugin_id: Option<&str>, +) -> bool { + match snapshot { + HistorySnapshot::PluginElements(snapshot) => { + plugin_id.is_none_or(|plugin_id| snapshot.plugin_id == plugin_id) + } + HistorySnapshot::Compound { snapshots } => snapshots + .iter() + .any(|snapshot| snapshot_contains_plugin_elements(snapshot, plugin_id)), + HistorySnapshot::CustomTabs(snapshot) => snapshot + .changed_plugin_ids() + .iter() + .any(|candidate| plugin_id.is_none_or(|plugin_id| candidate == plugin_id)), + HistorySnapshot::Editor { .. } + | HistorySnapshot::Mode(_) + | HistorySnapshot::Counters(_) + | HistorySnapshot::PresetFull(_) => false, + } +} From 6cfdfce3ddfd03b53c7fa1a7122d8c74753ebf46 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 17:50:29 +0900 Subject: [PATCH 167/245] =?UTF-8?q?test:=20=EC=82=AC=EC=9A=B4=EB=93=9C=20?= =?UTF-8?q?=ED=8A=B8=EB=A6=BC=20=EC=9E=AC=EC=83=9D=20=EC=88=98=EB=AA=85?= =?UTF-8?q?=EC=A3=BC=EA=B8=B0=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../managers/SoundTrimModal.runtime.test.tsx | 438 ++++++++++++++++++ 1 file changed, 438 insertions(+) create mode 100644 src/renderer/components/main/Modal/content/managers/SoundTrimModal.runtime.test.tsx diff --git a/src/renderer/components/main/Modal/content/managers/SoundTrimModal.runtime.test.tsx b/src/renderer/components/main/Modal/content/managers/SoundTrimModal.runtime.test.tsx new file mode 100644 index 000000000..bd0054d16 --- /dev/null +++ b/src/renderer/components/main/Modal/content/managers/SoundTrimModal.runtime.test.tsx @@ -0,0 +1,438 @@ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const soundTrimMocks = vi.hoisted(() => ({ + createAudioContext: vi.fn(), + decodeAudioFromArrayBuffer: vi.fn(), + drawWaveform: vi.fn(), + extractWaveformPeaks: vi.fn(), +})); + +vi.mock('@contexts/useTranslation', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock('@api/modules/resourceApi', () => ({ + soundApi: {}, +})); +vi.mock('@utils/core/dragCursor', () => ({ + beginDragCursor: vi.fn(), + endDragCursor: vi.fn(), +})); +vi.mock('@utils/grid/cursorUtils', () => ({ + getCursor: () => 'ew-resize', + setCustomCursorHover: vi.fn(), + lockCustomCursor: vi.fn(), + unlockCustomCursor: vi.fn(), +})); +vi.mock('./soundTrimModel', async (importOriginal) => ({ + ...(await importOriginal()), + createAudioContext: soundTrimMocks.createAudioContext, + decodeAudioFromArrayBuffer: soundTrimMocks.decodeAudioFromArrayBuffer, + drawWaveform: soundTrimMocks.drawWaveform, + extractWaveformPeaks: soundTrimMocks.extractWaveformPeaks, +})); + +import SoundTrimModal from './SoundTrimModal'; + +interface FakeAudioSource { + buffer: AudioBuffer | null; + onended: (() => void) | null; + connect: ReturnType; + start: ReturnType; + stop: ReturnType; +} + +interface FakeAudioContext { + currentTime: number; + destination: object; + gain: { gain: { value: number }; connect: ReturnType }; + source: FakeAudioSource; + createGain: ReturnType; + createBufferSource: ReturnType; + close: ReturnType; +} + +const AUDIO_BUFFER = { + duration: 10, + length: 1000, +} as AudioBuffer; + +const INITIAL_FILE = { + name: 'sample.wav', + arrayBuffer: vi.fn(async () => new ArrayBuffer(8)), +} as unknown as File; + +describe('SoundTrimModal playback·waveform runtime', () => { + let host: HTMLDivElement; + let root: Root; + let mounted: boolean; + let rafId: number; + let rafCallbacks: Map; + let lifecycle: string[]; + let contexts: FakeAudioContext[]; + let resizeObservers: Array<{ + target: Element | null; + disconnect: ReturnType; + }>; + let addEventListenerSpy: ReturnType; + let removeEventListenerSpy: ReturnType; + let windowAddEventListenerSpy: ReturnType; + let windowRemoveEventListenerSpy: ReturnType; + + const createContext = (): AudioContext => { + const id = contexts.length + 1; + const source: FakeAudioSource = { + buffer: null, + onended: null, + connect: vi.fn(), + start: vi.fn((...args: unknown[]) => { + lifecycle.push(`source${id}.start:${args.join(',')}`); + }), + stop: vi.fn(() => lifecycle.push(`source${id}.stop`)), + }; + const gain = { + gain: { value: -1 }, + connect: vi.fn(), + }; + const context: FakeAudioContext = { + currentTime: 0, + destination: {}, + gain, + source, + createGain: vi.fn(() => gain), + createBufferSource: vi.fn(() => source), + close: vi.fn(() => { + lifecycle.push(`context${id}.close`); + return Promise.resolve(); + }), + }; + contexts.push(context); + return context as unknown as AudioContext; + }; + + const runRaf = (id: number) => { + const callback = rafCallbacks.get(id); + expect(callback).toBeTypeOf('function'); + rafCallbacks.delete(id); + callback!(performance.now()); + }; + + const settleDeferredContent = async () => { + const frame = [...rafCallbacks.keys()][0]; + expect(frame).toBeDefined(); + act(() => runRaf(frame)); + await act(async () => { + await new Promise((resolve) => window.setTimeout(resolve, 0)); + await Promise.resolve(); + await Promise.resolve(); + }); + }; + + const renderOpen = async (previewVolume = 100) => { + await act(async () => { + root.render( + undefined} + onSaved={() => undefined} + previewVolume={previewVolume} + initialFile={INITIAL_FILE} + continuousInputStrategy="legacy" + />, + ); + await Promise.resolve(); + await Promise.resolve(); + }); + await settleDeferredContent(); + }; + + const playbackButton = (): HTMLButtonElement => { + const button = [...document.querySelectorAll('button')].find((candidate) => + candidate.querySelector('svg'), + ); + expect(button).toBeDefined(); + return button!; + }; + + const waveformHost = (): HTMLDivElement => + document.querySelector('[data-sound-waveform="true"]')!; + + const eventCallCount = ( + spy: ReturnType, + target: EventTarget, + type: string, + ): number => + spy.mock.calls.filter( + (call, index) => spy.mock.instances[index] === target && call[0] === type, + ).length; + + beforeEach(() => { + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + mounted = true; + rafId = 0; + rafCallbacks = new Map(); + lifecycle = []; + contexts = []; + resizeObservers = []; + + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + const id = ++rafId; + rafCallbacks.set(id, callback); + return id; + }); + vi.stubGlobal('cancelAnimationFrame', (id: number) => { + lifecycle.push(`raf.cancel:${id}`); + rafCallbacks.delete(id); + }); + vi.stubGlobal( + 'ResizeObserver', + class { + private readonly record = { + target: null as Element | null, + disconnect: vi.fn(), + }; + + constructor() { + resizeObservers.push(this.record); + } + + observe(target: Element) { + this.record.target = target; + } + + disconnect() { + this.record.disconnect(); + } + }, + ); + + soundTrimMocks.createAudioContext.mockImplementation(createContext); + soundTrimMocks.decodeAudioFromArrayBuffer.mockResolvedValue(AUDIO_BUFFER); + soundTrimMocks.extractWaveformPeaks.mockReturnValue( + new Float32Array([0.1, 0.5, 0.25]), + ); + soundTrimMocks.drawWaveform.mockClear(); + INITIAL_FILE.arrayBuffer = vi.fn(async () => new ArrayBuffer(8)); + + addEventListenerSpy = vi.spyOn(EventTarget.prototype, 'addEventListener'); + removeEventListenerSpy = vi.spyOn( + EventTarget.prototype, + 'removeEventListener', + ); + windowAddEventListenerSpy = vi.spyOn(window, 'addEventListener'); + windowRemoveEventListenerSpy = vi.spyOn(window, 'removeEventListener'); + }); + + afterEach(async () => { + if (mounted) { + await act(async () => root.unmount()); + } + host.remove(); + document.body.innerHTML = ''; + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('play→pause→resume→natural end와 volume·RAF·audio 해제 순서를 유지한다', async () => { + await renderOpen(250); + + act(() => playbackButton().click()); + const first = contexts[0]; + expect(first.gain.gain.value).toBe(2); + expect(first.source.start).toHaveBeenCalledWith(0, 0, 10); + + first.currentTime = 2; + const firstPlaybackFrame = [...rafCallbacks.keys()][0]; + act(() => runRaf(firstPlaybackFrame)); + expect(soundTrimMocks.drawWaveform).toHaveBeenLastCalledWith( + expect.any(HTMLCanvasElement), + expect.any(Float32Array), + 0, + 1, + 0.2, + 0, + 1, + ); + + lifecycle = []; + act(() => playbackButton().click()); + expect(first.source.onended).toBeNull(); + expect(first.source.stop).toHaveBeenCalledOnce(); + expect(first.close).toHaveBeenCalledOnce(); + expect(lifecycle[0]).toMatch(/^raf\.cancel:/); + expect(lifecycle).toEqual([ + expect.stringMatching(/^raf\.cancel:/), + 'source1.stop', + 'context1.close', + ]); + expect(soundTrimMocks.drawWaveform.mock.calls.at(-1)?.[4]).toBe(0.2); + + await act(async () => { + root.render( + undefined} + onSaved={() => undefined} + previewVolume={-20} + initialFile={INITIAL_FILE} + continuousInputStrategy="legacy" + />, + ); + }); + act(() => playbackButton().click()); + const resumed = contexts[1]; + expect(resumed.gain.gain.value).toBe(0); + expect(resumed.source.start).toHaveBeenCalledWith(0, 2, 8); + + lifecycle = []; + const naturalEnd = resumed.source.onended; + expect(naturalEnd).not.toBeNull(); + act(() => naturalEnd?.()); + expect(resumed.source.onended).toBeNull(); + expect(lifecycle).toEqual([ + expect.stringMatching(/^raf\.cancel:/), + 'source2.stop', + 'context2.close', + ]); + expect(soundTrimMocks.drawWaveform.mock.calls.at(-1)?.[4]).toBeNull(); + }); + + it('canvas·observer·wheel·pointer listener를 열 때 설치하고 close reset에서 해제한다', async () => { + await renderOpen(); + const waveform = waveformHost(); + const canvas = waveform.querySelector('canvas')!; + + expect(resizeObservers.some(({ target }) => target === waveform)).toBe( + true, + ); + expect(eventCallCount(addEventListenerSpy, waveform, 'wheel')).toBe(1); + expect(eventCallCount(addEventListenerSpy, waveform, 'mousedown')).toBe(1); + expect(eventCallCount(addEventListenerSpy, canvas, 'mousemove')).toBe(1); + expect(eventCallCount(addEventListenerSpy, canvas, 'mouseleave')).toBe(1); + + const pointerListenerStart = windowAddEventListenerSpy.mock.calls.length; + act(() => { + waveform.dispatchEvent( + new MouseEvent('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + clientX: 20, + }), + ); + }); + const pointerListeners = windowAddEventListenerSpy.mock.calls + .slice(pointerListenerStart) + .filter(([type]) => + ['pointermove', 'pointerup', 'pointercancel', 'blur'].includes(type), + ); + for (const type of ['pointermove', 'pointerup', 'pointercancel', 'blur']) { + expect( + pointerListeners.some(([registeredType]) => registeredType === type), + ).toBe(true); + } + + await act(async () => { + root.render( + undefined} + onSaved={() => undefined} + initialFile={INITIAL_FILE} + continuousInputStrategy="legacy" + />, + ); + }); + + expect(eventCallCount(removeEventListenerSpy, waveform, 'wheel')).toBe(1); + expect(eventCallCount(removeEventListenerSpy, waveform, 'mousedown')).toBe( + 1, + ); + expect(eventCallCount(removeEventListenerSpy, canvas, 'mousemove')).toBe(1); + expect(eventCallCount(removeEventListenerSpy, canvas, 'mouseleave')).toBe( + 1, + ); + for (const [type, listener] of pointerListeners) { + expect(windowRemoveEventListenerSpy).toHaveBeenCalledWith(type, listener); + } + expect( + resizeObservers.some(({ disconnect }) => + disconnect.mock.calls.some(() => true), + ), + ).toBe(true); + }); + + it('close 전환은 playback·pointer를 reset한다', async () => { + await renderOpen(); + act(() => playbackButton().click()); + const waveform = waveformHost(); + act(() => { + waveform.dispatchEvent( + new MouseEvent('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + clientX: 20, + }), + ); + }); + const playing = contexts[0]; + + await act(async () => { + root.render( + undefined} + onSaved={() => undefined} + initialFile={INITIAL_FILE} + continuousInputStrategy="legacy" + />, + ); + }); + expect(playing.source.stop).toHaveBeenCalledOnce(); + expect(playing.close).toHaveBeenCalledOnce(); + }); + + it('열린 상태 직접 unmount는 기존처럼 playback·pointer를 정리하지 않는다', async () => { + await renderOpen(); + act(() => playbackButton().click()); + const leakedWaveform = waveformHost(); + const pointerListenerStart = windowAddEventListenerSpy.mock.calls.length; + act(() => { + leakedWaveform.dispatchEvent( + new MouseEvent('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + clientX: 20, + }), + ); + }); + // 핸들 프레스가 첫 재생을 정지하므로 pointer 세션을 유지한 채 다시 재생 + act(() => playbackButton().click()); + const stillPlaying = contexts[1]; + const pointerListeners = windowAddEventListenerSpy.mock.calls + .slice(pointerListenerStart) + .filter(([type]) => + ['pointermove', 'pointerup', 'pointercancel', 'blur'].includes(type), + ); + windowRemoveEventListenerSpy.mockClear(); + + await act(async () => root.unmount()); + mounted = false; + + expect(stillPlaying.source.stop).not.toHaveBeenCalled(); + expect(stillPlaying.close).not.toHaveBeenCalled(); + for (const [type, listener] of pointerListeners) { + expect(windowRemoveEventListenerSpy).not.toHaveBeenCalledWith( + type, + listener, + ); + } + }); +}); From df0ebcf63cca4d24e253328ef0cabf2b2a163580 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 17:50:29 +0900 Subject: [PATCH 168/245] =?UTF-8?q?refactor:=20=EC=82=AC=EC=9A=B4=EB=93=9C?= =?UTF-8?q?=20=ED=8A=B8=EB=A6=BC=20=EC=9E=AC=EC=83=9D=20=EB=9F=B0=ED=83=80?= =?UTF-8?q?=EC=9E=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Modal/content/managers/SoundTrimModal.tsx | 152 ++------------ .../managers/soundTrimPlaybackRuntime.ts | 190 ++++++++++++++++++ 2 files changed, 211 insertions(+), 131 deletions(-) create mode 100644 src/renderer/components/main/Modal/content/managers/soundTrimPlaybackRuntime.ts diff --git a/src/renderer/components/main/Modal/content/managers/SoundTrimModal.tsx b/src/renderer/components/main/Modal/content/managers/SoundTrimModal.tsx index f478e6620..3d23c64d4 100644 --- a/src/renderer/components/main/Modal/content/managers/SoundTrimModal.tsx +++ b/src/renderer/components/main/Modal/content/managers/SoundTrimModal.tsx @@ -20,7 +20,6 @@ import { arrayBufferToBase64, base64ToArrayBuffer, clamp, - createAudioContext, decodeAudioFromArrayBuffer, drawWaveform, encodeWavBase64, @@ -28,6 +27,7 @@ import { formatSecLabel, stripExtension, } from './soundTrimModel'; +import { createSoundTrimPlaybackRuntime } from './soundTrimPlaybackRuntime'; interface SoundTrimModalProps { isOpen: boolean; @@ -133,136 +133,26 @@ const SoundTrimModal = ({ const viewEndRef = useRef(viewEnd); viewEndRef.current = viewEnd; - const teardownAudio = () => { - if (animFrameRef.current) { - cancelAnimationFrame(animFrameRef.current); - animFrameRef.current = 0; - } - if (playSourceRef.current) { - playSourceRef.current.onended = null; - try { - playSourceRef.current.stop(); - } catch { - /* 이미 정지됨 */ - } - playSourceRef.current = null; - } - if (playContextRef.current) { - void playContextRef.current.close(); - playContextRef.current = null; - } - setIsPlaying(false); - }; - - const redrawWaveformStatic = (pausedRatio?: number | null) => { - const canvas = canvasRef.current; - const currentPeaks = peaksRef.current; - if (canvas && currentPeaks.length > 0) { - drawWaveform( - canvas, - currentPeaks, - startRatioRef.current, - endRatioRef.current, - pausedRatio ?? null, - viewStartRef.current, - viewEndRef.current, - ); - } - }; - - // 일시정지: 현재 위치 저장 및 일시정지 위치에 표시기 표시 - const pausePlayback = () => { - const playCtx = playContextRef.current; - if (playCtx) { - const elapsed = playCtx.currentTime - playStartCtxTimeRef.current; - const totalDur = playDurationSecRef.current; - const progress = totalDur > 0 ? clamp(elapsed / totalDur, 0, 1) : 0; - const sR = playStartRatioRef.current; - const eR = playEndRatioRef.current; - pausedAtRatioRef.current = sR + progress * (eR - sR); - } - teardownAudio(); - redrawWaveformStatic(pausedAtRatioRef.current); - }; - - // 완전 정지: 범위 시작 위치로 초기화 - const stopPlayback = () => { - pausedAtRatioRef.current = null; - teardownAudio(); - redrawWaveformStatic(); - }; - - const handlePlay = () => { - if (!audioBuffer) return; - - // 재생 중 → 일시정지 - if (playSourceRef.current) { - pausePlayback(); - return; - } - - const ctx = createAudioContext(); - const gainNode = ctx.createGain(); - gainNode.gain.value = clamp(previewVolume / 100, 0, 2); - gainNode.connect(ctx.destination); - const source = ctx.createBufferSource(); - source.buffer = audioBuffer; - source.connect(gainNode); - - const sR = startRatioRef.current; - const eR = endRatioRef.current; - - // 일시정지 위치부터 재개, 또는 범위 시작부터 재생 - const resumeRatio = pausedAtRatioRef.current ?? sR; - const offsetSec = audioBuffer.duration * resumeRatio; - const remainingSec = audioBuffer.duration * (eR - resumeRatio); - pausedAtRatioRef.current = null; - - source.onended = () => { - // 자연 종료 → 위치 초기화 - stopPlayback(); - }; - - playContextRef.current = ctx; - playSourceRef.current = source; - playStartCtxTimeRef.current = ctx.currentTime; - playDurationSecRef.current = remainingSec; - playStartRatioRef.current = resumeRatio; - playEndRatioRef.current = eR; - setIsPlaying(true); - - source.start(0, offsetSec, remainingSec); - - // 재생 표시기 애니메이션 루프 시작 - const animate = () => { - const playCtx = playContextRef.current; - const canvas = canvasRef.current; - const currentPeaks = peaksRef.current; - if (!playCtx || !canvas || currentPeaks.length === 0) return; - - const elapsed = playCtx.currentTime - playStartCtxTimeRef.current; - const totalDur = playDurationSecRef.current; - const progress = totalDur > 0 ? clamp(elapsed / totalDur, 0, 1) : 0; - const animSR = playStartRatioRef.current; - const animER = playEndRatioRef.current; - const playbackRatio = animSR + progress * (animER - animSR); - - drawWaveform( - canvas, - currentPeaks, - startRatioRef.current, - endRatioRef.current, - playbackRatio, - viewStartRef.current, - viewEndRef.current, - ); - - if (progress < 1) { - animFrameRef.current = requestAnimationFrame(animate); - } - }; - animFrameRef.current = requestAnimationFrame(animate); - }; + const { teardownAudio, redrawWaveformStatic, stopPlayback, handlePlay } = + createSoundTrimPlaybackRuntime({ + audioBuffer, + previewVolume, + canvasRef, + peaksRef, + startRatioRef, + endRatioRef, + viewStartRef, + viewEndRef, + playContextRef, + playSourceRef, + playStartCtxTimeRef, + playDurationSecRef, + playStartRatioRef, + playEndRatioRef, + animFrameRef, + pausedAtRatioRef, + setIsPlaying, + }); const handlePlayRef = useRef<() => void>(() => {}); handlePlayRef.current = handlePlay; diff --git a/src/renderer/components/main/Modal/content/managers/soundTrimPlaybackRuntime.ts b/src/renderer/components/main/Modal/content/managers/soundTrimPlaybackRuntime.ts new file mode 100644 index 000000000..4edf6e8fc --- /dev/null +++ b/src/renderer/components/main/Modal/content/managers/soundTrimPlaybackRuntime.ts @@ -0,0 +1,190 @@ +import { clamp, createAudioContext, drawWaveform } from './soundTrimModel'; + +interface RuntimeRef { + current: T; +} + +interface SoundTrimPlaybackRuntimeOptions { + audioBuffer: AudioBuffer | null; + previewVolume: number; + canvasRef: RuntimeRef; + peaksRef: RuntimeRef; + startRatioRef: RuntimeRef; + endRatioRef: RuntimeRef; + viewStartRef: RuntimeRef; + viewEndRef: RuntimeRef; + playContextRef: RuntimeRef; + playSourceRef: RuntimeRef; + playStartCtxTimeRef: RuntimeRef; + playDurationSecRef: RuntimeRef; + playStartRatioRef: RuntimeRef; + playEndRatioRef: RuntimeRef; + animFrameRef: RuntimeRef; + pausedAtRatioRef: RuntimeRef; + setIsPlaying(isPlaying: boolean): void; +} + +interface SoundTrimPlaybackRuntime { + teardownAudio(): void; + redrawWaveformStatic(pausedRatio?: number | null): void; + stopPlayback(): void; + handlePlay(): void; +} + +export const createSoundTrimPlaybackRuntime = ({ + audioBuffer, + previewVolume, + canvasRef, + peaksRef, + startRatioRef, + endRatioRef, + viewStartRef, + viewEndRef, + playContextRef, + playSourceRef, + playStartCtxTimeRef, + playDurationSecRef, + playStartRatioRef, + playEndRatioRef, + animFrameRef, + pausedAtRatioRef, + setIsPlaying, +}: SoundTrimPlaybackRuntimeOptions): SoundTrimPlaybackRuntime => { + const teardownAudio = () => { + if (animFrameRef.current) { + cancelAnimationFrame(animFrameRef.current); + animFrameRef.current = 0; + } + if (playSourceRef.current) { + playSourceRef.current.onended = null; + try { + playSourceRef.current.stop(); + } catch { + /* 이미 정지됨 */ + } + playSourceRef.current = null; + } + if (playContextRef.current) { + void playContextRef.current.close(); + playContextRef.current = null; + } + setIsPlaying(false); + }; + + const redrawWaveformStatic = (pausedRatio?: number | null) => { + const canvas = canvasRef.current; + const currentPeaks = peaksRef.current; + if (canvas && currentPeaks.length > 0) { + drawWaveform( + canvas, + currentPeaks, + startRatioRef.current, + endRatioRef.current, + pausedRatio ?? null, + viewStartRef.current, + viewEndRef.current, + ); + } + }; + + // 일시정지: 현재 위치 저장 및 일시정지 위치에 표시기 표시 + const pausePlayback = () => { + const playCtx = playContextRef.current; + if (playCtx) { + const elapsed = playCtx.currentTime - playStartCtxTimeRef.current; + const totalDur = playDurationSecRef.current; + const progress = totalDur > 0 ? clamp(elapsed / totalDur, 0, 1) : 0; + const sR = playStartRatioRef.current; + const eR = playEndRatioRef.current; + pausedAtRatioRef.current = sR + progress * (eR - sR); + } + teardownAudio(); + redrawWaveformStatic(pausedAtRatioRef.current); + }; + + // 완전 정지: 범위 시작 위치로 초기화 + const stopPlayback = () => { + pausedAtRatioRef.current = null; + teardownAudio(); + redrawWaveformStatic(); + }; + + const handlePlay = () => { + if (!audioBuffer) return; + + // 재생 중 → 일시정지 + if (playSourceRef.current) { + pausePlayback(); + return; + } + + const ctx = createAudioContext(); + const gainNode = ctx.createGain(); + gainNode.gain.value = clamp(previewVolume / 100, 0, 2); + gainNode.connect(ctx.destination); + const source = ctx.createBufferSource(); + source.buffer = audioBuffer; + source.connect(gainNode); + + const sR = startRatioRef.current; + const eR = endRatioRef.current; + + // 일시정지 위치부터 재개, 또는 범위 시작부터 재생 + const resumeRatio = pausedAtRatioRef.current ?? sR; + const offsetSec = audioBuffer.duration * resumeRatio; + const remainingSec = audioBuffer.duration * (eR - resumeRatio); + pausedAtRatioRef.current = null; + + source.onended = () => { + // 자연 종료 → 위치 초기화 + stopPlayback(); + }; + + playContextRef.current = ctx; + playSourceRef.current = source; + playStartCtxTimeRef.current = ctx.currentTime; + playDurationSecRef.current = remainingSec; + playStartRatioRef.current = resumeRatio; + playEndRatioRef.current = eR; + setIsPlaying(true); + + source.start(0, offsetSec, remainingSec); + + // 재생 표시기 애니메이션 루프 시작 + const animate = () => { + const playCtx = playContextRef.current; + const canvas = canvasRef.current; + const currentPeaks = peaksRef.current; + if (!playCtx || !canvas || currentPeaks.length === 0) return; + + const elapsed = playCtx.currentTime - playStartCtxTimeRef.current; + const totalDur = playDurationSecRef.current; + const progress = totalDur > 0 ? clamp(elapsed / totalDur, 0, 1) : 0; + const animSR = playStartRatioRef.current; + const animER = playEndRatioRef.current; + const playbackRatio = animSR + progress * (animER - animSR); + + drawWaveform( + canvas, + currentPeaks, + startRatioRef.current, + endRatioRef.current, + playbackRatio, + viewStartRef.current, + viewEndRef.current, + ); + + if (progress < 1) { + animFrameRef.current = requestAnimationFrame(animate); + } + }; + animFrameRef.current = requestAnimationFrame(animate); + }; + + return { + teardownAudio, + redrawWaveformStatic, + stopPlayback, + handlePlay, + }; +}; From 0301512290f005d5971606c02ef3e5e98179d6a8 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 17:50:29 +0900 Subject: [PATCH 169/245] =?UTF-8?q?test:=20=EB=A0=88=EC=9D=B4=EC=96=B4=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EC=83=81=ED=98=B8=EC=9E=91=EC=9A=A9=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../layer/LayerTabContent.selection.test.tsx | 499 ++++++++++++++++++ 1 file changed, 499 insertions(+) create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.selection.test.tsx diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.selection.test.tsx b/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.selection.test.tsx new file mode 100644 index 000000000..ca48f338f --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.selection.test.tsx @@ -0,0 +1,499 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + didDrag: false, + isDragging: false, + mac: false, + events: [] as string[], + getDidDrag: vi.fn(() => false), + resetDidDrag: vi.fn(), + getIsDraggingRef: vi.fn(() => false), +})); + +vi.mock('@components/main/common/IconSwap', () => ({ + default: ({ active }: { active: boolean }) => ( + + ), +})); + +vi.mock('@contexts/useTranslation', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'ko', changeLanguage: () => {} }, + }), +})); + +vi.mock('@hooks/useLenis', () => ({ + useLenis: () => ({ + scrollContainerRef: () => {}, + lenisInstance: { current: null }, + }), +})); + +vi.mock('@utils/core/platform', () => ({ + isMac: () => mocks.mac, +})); + +vi.mock('./useLayerDnD', () => ({ + useLayerDnD: () => ({ + draggedItemId: null, + isDragging: false, + draggedGroupId: null, + dragOverItemDisplayIndex: null, + dragOverIntoGroupId: null, + dragOverTargetGroupId: null, + dragOverDisplayIndex: null, + getDidDrag: mocks.getDidDrag, + resetDidDrag: mocks.resetDidDrag, + getIsDraggingRef: mocks.getIsDraggingRef, + handleMouseDown: vi.fn(), + handleGroupMouseDown: vi.fn(), + }), +})); + +import { useKeyStore } from '@stores/data/useKeyStore'; +import { useStatItemStore } from '@stores/data/useStatItemStore'; +import { useGraphItemStore } from '@stores/data/useGraphItemStore'; +import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; +import { + useGridSelectionStore, + type SelectedElement, +} from '@stores/grid/useGridSelectionStore'; +import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; +import LayerTabContent from './LayerTabContent'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const IDS = { + a: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + b: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + c: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + d: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', + e: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', + f: 'ffffffff-ffff-4fff-8fff-ffffffffffff', +} as const; + +const GROUP_G = 'group-g'; +const GROUP_H = 'group-h'; + +const selected = (id: string, index: number): SelectedElement => ({ + type: 'key', + id, + index, +}); + +const initialGridState = useGridSelectionStore.getInitialState(); + +let host: HTMLDivElement; +let root: Root; + +const selectionIds = () => + useGridSelectionStore + .getState() + .selectedElements.map((element) => element.id); + +const groupIds = () => useGridSelectionStore.getState().selectedGroupIds; + +const findRow = (label: string) => { + const row = [ + ...host.querySelectorAll('.dmn-row-grabbable'), + ].find((candidate) => candidate.textContent?.includes(label)); + if (!row) throw new Error(`row not found: ${label}`); + return row; +}; + +const clickRow = async ( + label: string, + init: Pick = {}, +) => { + await act(async () => { + findRow(label).dispatchEvent( + new MouseEvent('click', { bubbles: true, cancelable: true, ...init }), + ); + }); +}; + +const setSelection = async ( + elements: SelectedElement[], + selectedGroupIds: string[] = [], +) => { + await act(async () => { + useGridSelectionStore.setState({ + selectedElements: elements, + selectedGroupIds, + }); + }); +}; + +const setCollapsedGroups = async (collapsedGroups: Set) => { + await act(async () => { + useLayerGroupStore.setState({ collapsedGroups }); + }); +}; + +const mount = async ({ collapsed = [] }: { collapsed?: string[] } = {}) => { + useKeyStore.setState({ + selectedKeyType: '4key', + keyMappings: { '4key': ['A', 'B', 'C', 'D', 'E', 'F'] }, + positions: { + '4key': [ + { + id: IDS.a, + layerName: 'Layer A', + dx: 0, + dy: 0, + width: 60, + height: 60, + zIndex: 6, + }, + { + id: IDS.b, + layerName: 'Layer B', + dx: 0, + dy: 0, + width: 60, + height: 60, + zIndex: 5, + groupId: GROUP_G, + }, + { + id: IDS.c, + layerName: 'Layer C', + dx: 0, + dy: 0, + width: 60, + height: 60, + zIndex: 4, + groupId: GROUP_G, + }, + { + id: IDS.d, + layerName: 'Layer D', + dx: 0, + dy: 0, + width: 60, + height: 60, + zIndex: 3, + }, + { + id: IDS.e, + layerName: 'Layer E', + dx: 0, + dy: 0, + width: 60, + height: 60, + zIndex: 2, + groupId: GROUP_H, + }, + { + id: IDS.f, + layerName: 'Layer F', + dx: 0, + dy: 0, + width: 60, + height: 60, + zIndex: 1, + groupId: GROUP_H, + }, + ], + } as never, + }); + useStatItemStore.setState({ positions: {} }); + useGraphItemStore.setState({ positions: {} }); + useKnobItemStore.setState({ positions: {} }); + useLayerGroupStore.setState({ + layerGroups: { + '4key': [ + { id: GROUP_G, name: 'Group G' }, + { id: GROUP_H, name: 'Group H' }, + ], + }, + collapsedGroups: new Set(collapsed), + }); + usePluginDisplayElementStore.setState({ elements: [] }); + useGridSelectionStore.setState({ + ...initialGridState, + selectedElements: [], + selectedGroupIds: [], + clearSelection: vi.fn(() => { + mocks.events.push('clearSelection'); + initialGridState.clearSelection(); + }), + toggleSelection: vi.fn((element: SelectedElement) => { + mocks.events.push('toggleSelection'); + initialGridState.toggleSelection(element); + }), + setSelectedElements: vi.fn((elements: SelectedElement[]) => { + mocks.events.push('setSelectedElements'); + initialGridState.setSelectedElements(elements); + }), + setFullSelection: vi.fn( + (elements: SelectedElement[], selectedGroupIds: string[]) => { + mocks.events.push('setFullSelection'); + initialGridState.setFullSelection(elements, selectedGroupIds); + }, + ), + }); + + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + await act(async () => { + root.render( + mocks.events.push('callback')} + />, + ); + }); +}; + +beforeEach(() => { + mocks.didDrag = false; + mocks.isDragging = false; + mocks.mac = false; + mocks.events = []; + mocks.getDidDrag.mockImplementation(() => { + mocks.events.push('getDidDrag'); + return mocks.didDrag; + }); + mocks.resetDidDrag.mockImplementation(() => { + mocks.events.push('resetDidDrag'); + mocks.didDrag = false; + }); + mocks.getIsDraggingRef.mockImplementation(() => { + mocks.events.push('getIsDraggingRef'); + return mocks.isDragging; + }); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + vi.stubGlobal('cancelAnimationFrame', () => {}); +}); + +afterEach(async () => { + if (root) await act(async () => root.unmount()); + host?.remove(); + vi.useRealTimers(); + vi.clearAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('LayerTab item selection intent', () => { + it('ordinary와 primary 클릭의 선택 교체·추가·제거 계약을 유지한다', async () => { + await mount(); + + await clickRow('Layer A'); + expect(selectionIds()).toEqual([IDS.a]); + expect(mocks.events).toEqual([ + 'getDidDrag', + 'getIsDraggingRef', + 'callback', + 'clearSelection', + 'toggleSelection', + ]); + + mocks.events = []; + await clickRow('Layer B', { ctrlKey: true }); + expect(selectionIds()).toEqual([IDS.a, IDS.b]); + expect(mocks.events).toEqual([ + 'getDidDrag', + 'getIsDraggingRef', + 'callback', + 'setFullSelection', + ]); + + mocks.events = []; + await clickRow('Layer A', { ctrlKey: true }); + expect(selectionIds()).toEqual([IDS.b]); + expect(mocks.events.at(-1)).toBe('setFullSelection'); + }); + + it('collapsed display range는 숨은 그룹 자식과 그룹 ID를 포함한다', async () => { + await mount({ collapsed: [GROUP_G, GROUP_H] }); + + await clickRow('Layer A'); + await clickRow('Layer D', { shiftKey: true }); + + expect(selectionIds()).toEqual([IDS.a, IDS.b, IDS.c, IDS.d]); + expect(groupIds()).toEqual([GROUP_G]); + expect(mocks.events.at(-1)).toBe('setFullSelection'); + }); + + it('display anchor가 접기 변경으로 초기화되면 flat index fallback을 쓴다', async () => { + await mount(); + + await clickRow('Layer A'); + await setCollapsedGroups(new Set([GROUP_G])); + mocks.events = []; + await clickRow('Layer D', { shiftKey: true }); + + expect(selectionIds()).toEqual([IDS.a, IDS.b, IDS.c, IDS.d]); + expect(groupIds()).toEqual([]); + expect(mocks.events).toEqual([ + 'getDidDrag', + 'getIsDraggingRef', + 'callback', + 'setSelectedElements', + ]); + }); + + it('shift+primary display range는 기존 요소·그룹 뒤에 새 항목만 병합한다', async () => { + await mount({ collapsed: [GROUP_G, GROUP_H] }); + + await clickRow('Layer A'); + await setSelection([selected(IDS.f, 5)], [GROUP_H]); + mocks.events = []; + await clickRow('Layer D', { shiftKey: true, ctrlKey: true }); + + expect(selectionIds()).toEqual([IDS.f, IDS.a, IDS.b, IDS.c, IDS.d]); + expect(groupIds()).toEqual([GROUP_H, GROUP_G]); + expect(mocks.events.at(-1)).toBe('setFullSelection'); + }); + + it('macOS에서는 Ctrl click을 callback 뒤에 막고 Meta를 primary로 사용한다', async () => { + mocks.mac = true; + await mount(); + + await clickRow('Layer A', { ctrlKey: true }); + expect(selectionIds()).toEqual([]); + expect(mocks.events).toEqual([ + 'getDidDrag', + 'getIsDraggingRef', + 'callback', + ]); + + mocks.events = []; + await clickRow('Layer A', { metaKey: true }); + expect(selectionIds()).toEqual([IDS.a]); + expect(mocks.events.at(-1)).toBe('setFullSelection'); + }); +}); + +describe('LayerTab delayed deselection', () => { + it('selected multi는 50ms 뒤 단일 선택, selected single은 50ms 뒤 해제한다', async () => { + vi.useFakeTimers(); + await mount(); + // 같은 행 재클릭 계약: 첫 클릭으로 range anchor를 먼저 고정한다. + await clickRow('Layer B'); + await setSelection([selected(IDS.a, 0), selected(IDS.b, 1)], [GROUP_G]); + + mocks.events = []; + await clickRow('Layer B'); + expect(selectionIds()).toEqual([IDS.a, IDS.b]); + await act(async () => vi.advanceTimersByTime(49)); + expect(selectionIds()).toEqual([IDS.a, IDS.b]); + await act(async () => vi.advanceTimersByTime(1)); + expect(selectionIds()).toEqual([IDS.b]); + expect(groupIds()).toEqual([]); + + mocks.events = []; + await clickRow('Layer B'); + await act(async () => vi.advanceTimersByTime(50)); + expect(selectionIds()).toEqual([]); + expect(mocks.events.at(-1)).toBe('clearSelection'); + }); + + it('다른 anchor의 selected 행은 기존 render cleanup이 pending timer를 취소한다', async () => { + vi.useFakeTimers(); + await mount(); + await setSelection([selected(IDS.a, 0), selected(IDS.b, 1)]); + + await clickRow('Layer B'); + await act(async () => vi.advanceTimersByTime(50)); + + expect(selectionIds()).toEqual([IDS.a, IDS.b]); + }); + + it('다음 item click은 pending timer를 먼저 취소한다', async () => { + vi.useFakeTimers(); + await mount(); + await clickRow('Layer B'); + await setSelection([selected(IDS.a, 0), selected(IDS.b, 1)]); + + mocks.events = []; + await clickRow('Layer B'); + mocks.mac = true; + mocks.events = []; + await clickRow('Layer A', { ctrlKey: true }); + await act(async () => vi.advanceTimersByTime(50)); + + expect(selectionIds()).toEqual([IDS.a, IDS.b]); + expect(mocks.events).toEqual([ + 'getDidDrag', + 'getIsDraggingRef', + 'callback', + ]); + }); +}); + +describe('LayerTab group selection intent', () => { + it('ordinary와 primary 클릭의 자식·그룹 선택 추가와 제거를 유지한다', async () => { + await mount(); + + await clickRow('Group G'); + expect(selectionIds()).toEqual([IDS.b, IDS.c]); + expect(groupIds()).toEqual([GROUP_G]); + + await setSelection([selected(IDS.a, 0), selected(IDS.b, 1)], []); + await clickRow('Group G', { ctrlKey: true }); + expect(selectionIds()).toEqual([IDS.a, IDS.b, IDS.c]); + expect(groupIds()).toEqual([GROUP_G]); + + await clickRow('Group G', { ctrlKey: true }); + expect(selectionIds()).toEqual([IDS.a]); + expect(groupIds()).toEqual([]); + }); + + it('group shift와 shift+primary는 display range를 선택·병합한다', async () => { + await mount(); + + await clickRow('Group G'); + await clickRow('Group H', { shiftKey: true }); + expect(selectionIds()).toEqual([IDS.b, IDS.c, IDS.d, IDS.e, IDS.f]); + expect(groupIds()).toEqual([GROUP_G, GROUP_H]); + + await clickRow('Group G'); + await setSelection([selected(IDS.a, 0)], ['external-group']); + await clickRow('Group H', { shiftKey: true, ctrlKey: true }); + expect(selectionIds()).toEqual([IDS.a, IDS.b, IDS.c, IDS.d, IDS.e, IDS.f]); + expect(groupIds()).toEqual(['external-group', GROUP_G, GROUP_H]); + }); +}); + +describe('LayerTab drag gate callback order', () => { + it('trailing click은 item callback 전, group callback 후에 reset하고 선택하지 않는다', async () => { + await mount(); + + mocks.didDrag = true; + await clickRow('Layer A'); + expect(mocks.events).toEqual(['getDidDrag', 'resetDidDrag']); + expect(selectionIds()).toEqual([]); + + mocks.events = []; + mocks.didDrag = true; + await clickRow('Group G'); + expect(mocks.events).toEqual(['callback', 'getDidDrag', 'resetDidDrag']); + expect(selectionIds()).toEqual([]); + }); + + it('active drag gate도 item은 callback 전, group은 callback 후에 평가한다', async () => { + await mount(); + mocks.isDragging = true; + + await clickRow('Layer A'); + expect(mocks.events).toEqual(['getDidDrag', 'getIsDraggingRef']); + + mocks.events = []; + await clickRow('Group G'); + expect(mocks.events).toEqual([ + 'callback', + 'getDidDrag', + 'getIsDraggingRef', + ]); + }); +}); From f852fd7b18a978fd02a7ae9f2aeacf88ba9053b3 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 17:50:29 +0900 Subject: [PATCH 170/245] =?UTF-8?q?refactor:=20=EB=A0=88=EC=9D=B4=EC=96=B4?= =?UTF-8?q?=20=EC=84=A0=ED=83=9D=20=EC=9D=98=EB=8F=84=20=EA=B3=84=EC=82=B0?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PropertiesPanel/layer/LayerTabContent.tsx | 286 ++++----------- .../layer/layerSelectionIntent.test.ts | 338 ++++++++++++++++++ .../layer/layerSelectionIntent.ts | 336 +++++++++++++++++ 3 files changed, 747 insertions(+), 213 deletions(-) create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/layer/layerSelectionIntent.test.ts create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/layer/layerSelectionIntent.ts diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx index b1fcfe7c0..a0c4944b7 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx @@ -3,10 +3,7 @@ import { createPortal } from 'react-dom'; import { useTranslation } from '@contexts/useTranslation'; import { usePopupPresence } from '@hooks/ui/usePopupPresence'; import { useRetainedWhileOpen } from '@hooks/ui/useRetainedValue'; -import { - useGridSelectionStore, - type SelectedElement, -} from '@stores/grid/useGridSelectionStore'; +import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; import { useKeyStore } from '@stores/data/useKeyStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { useGraphItemStore } from '@stores/data/useGraphItemStore'; @@ -37,15 +34,12 @@ import { import { useLayerActions } from './useLayerActions'; import { useLayerDnD } from './useLayerDnD'; import { useOptimisticBooleanCommit } from '@hooks/useOptimisticBooleanCommit'; - -function layerItemToSelectedElement(item: LayerItem): SelectedElement { - if (item.type === 'plugin') return { type: 'plugin', id: item.id }; - return { - type: item.type, - id: item.id, - ...(item.index !== undefined ? { index: item.index } : {}), - }; -} +import { + layerItemToSelectedElement, + resolveLayerGroupSelectionIntent, + resolveLayerItemSelectionIntent, + type LayerSelectionIntent, +} from './layerSelectionIntent'; interface LayerGroupDisclosureProps { collapsed: boolean; @@ -331,6 +325,41 @@ const LayerTabContent: React.FC = ({ const isGroupHeaderSelected = (groupId: string) => selectedGroupIdSet.has(groupId); + const applySelectionIntent = (intent: LayerSelectionIntent) => { + switch (intent.type) { + case 'set-full': + setFullSelection(intent.elements, intent.groupIds); + return; + case 'set-elements': + setSelectedElements(intent.elements); + return; + case 'replace': + clearSelection(); + toggleSelection(intent.element); + return; + case 'delay-single': + pendingDeselectTimerRef.current = (hostWindow ?? window).setTimeout( + () => { + setFullSelection([intent.element], []); + pendingDeselectTimerRef.current = null; + }, + 50, + ); + return; + case 'delay-clear': + pendingDeselectTimerRef.current = (hostWindow ?? window).setTimeout( + () => { + clearSelection(); + pendingDeselectTimerRef.current = null; + }, + 50, + ); + return; + case 'none': + return; + } + }; + // ────────────────────────────────────────────────────────────────────────── // 더블클릭 핸들러 // ────────────────────────────────────────────────────────────────────────── @@ -384,134 +413,24 @@ const LayerTabContent: React.FC = ({ const isPrimaryModifierPressed = isMac() ? e.metaKey : e.ctrlKey; const isShiftPressed = e.shiftKey; + const resolution = resolveLayerItemSelectionIntent({ + item, + index, + primary: isPrimaryModifierPressed, + shift: isShiftPressed, + lastClickedIndex, + lastClickedDisplayIndex, + layerItems: layerItemsRef.current, + displayItems: displayItemsRef.current, + selectedElements, + selectedGroupIds, + }); - // Shift+클릭: 범위 선택 - if ( - isShiftPressed && - (lastClickedDisplayIndex !== null || lastClickedIndex !== null) - ) { - const thisDisplayIdx = displayItemsRef.current.findIndex( - (di) => di.displayType === 'layer' && di.item.id === item.id, - ); - - if (lastClickedDisplayIndex !== null && thisDisplayIdx !== -1) { - const startIdx = Math.min(lastClickedDisplayIndex, thisDisplayIdx); - const endIdx = Math.max(lastClickedDisplayIndex, thisDisplayIdx); - const currentDisplay = displayItemsRef.current; - - const rangeElements: SelectedElement[] = []; - const rangeGroupIds: string[] = []; - - for (let i = startIdx; i <= endIdx; i++) { - const di = currentDisplay[i]; - if (!di) continue; - if (di.displayType === 'group-header') { - rangeGroupIds.push(di.groupId); - const groupChildren = layerItemsRef.current.filter( - (it) => it.groupId === di.groupId, - ); - groupChildren.forEach((child) => { - rangeElements.push(layerItemToSelectedElement(child)); - }); - } else { - rangeElements.push(layerItemToSelectedElement(di.item)); - } - } - - const seen = new Set(); - const deduped = rangeElements.filter((el) => { - if (seen.has(el.id)) return false; - seen.add(el.id); - return true; - }); - - if (isPrimaryModifierPressed) { - const existingIds = new Set(selectedElements.map((el) => el.id)); - const newEls = deduped.filter((el) => !existingIds.has(el.id)); - const mergedElements = [...selectedElements, ...newEls]; - const existingGroupIds = new Set(selectedGroupIds); - const newGroupIds = rangeGroupIds.filter( - (id) => !existingGroupIds.has(id), - ); - setFullSelection(mergedElements, [ - ...selectedGroupIds, - ...newGroupIds, - ]); - } else { - setFullSelection(deduped, rangeGroupIds); - } - return; - } - - // fallback: layerItems 인덱스 기반 - if (lastClickedIndex !== null) { - const startIdx = Math.min(lastClickedIndex, index); - const endIdx = Math.max(lastClickedIndex, index); - const currentItems = layerItemsRef.current; - - const rangeElements: SelectedElement[] = []; - for (let i = startIdx; i <= endIdx; i++) { - const rangeItem = currentItems[i]; - if (rangeItem) { - rangeElements.push(layerItemToSelectedElement(rangeItem)); - } - } - - if (isPrimaryModifierPressed) { - const existingIds = new Set(selectedElements.map((el) => el.id)); - const newElements = rangeElements.filter( - (el) => !existingIds.has(el.id), - ); - setSelectedElements([...selectedElements, ...newElements]); - } else { - setSelectedElements(rangeElements); - } - return; - } - } - - // Ctrl+클릭 또는 일반 클릭 - const isAlreadySelected = selectedElements.some((el) => el.id === item.id); - const element = layerItemToSelectedElement(item); - - if (isPrimaryModifierPressed) { - const exists = selectedElements.some((el) => el.id === element.id); - if (exists) { - setFullSelection( - selectedElements.filter((el) => el.id !== element.id), - selectedGroupIds, - ); - } else { - setFullSelection([...selectedElements, element], selectedGroupIds); - } - } else { - if (isAlreadySelected && selectedElements.length > 1) { - pendingDeselectTimerRef.current = (hostWindow ?? window).setTimeout( - () => { - setFullSelection([element], []); - pendingDeselectTimerRef.current = null; - }, - 50, - ); - } else if (isAlreadySelected) { - pendingDeselectTimerRef.current = (hostWindow ?? window).setTimeout( - () => { - clearSelection(); - pendingDeselectTimerRef.current = null; - }, - 50, - ); - } else { - clearSelection(); - toggleSelection(element); - } + applySelectionIntent(resolution.intent); + if (resolution.anchor) { + setLastClickedIndex(resolution.anchor.index); + setLastClickedDisplayIndex(resolution.anchor.displayIndex); } - - setLastClickedIndex(index); - const displayIdx = displayItemsRef.current.findIndex( - (di) => di.displayType === 'layer' && di.item.id === item.id, - ); - setLastClickedDisplayIndex(displayIdx !== -1 ? displayIdx : null); }; // ────────────────────────────────────────────────────────────────────────── @@ -533,82 +452,23 @@ const LayerTabContent: React.FC = ({ const isPrimaryModifierPressed = isMac() ? e.metaKey : e.ctrlKey; const isShiftPressed = e.shiftKey; + const resolution = resolveLayerGroupSelectionIntent({ + groupId, + primary: isPrimaryModifierPressed, + shift: isShiftPressed, + lastClickedDisplayIndex, + layerItems, + rangeLayerItems: layerItemsRef.current, + displayItems: displayItemsRef.current, + selectedElements, + selectedGroupIds, + }); - const children = layerItems.filter((item) => item.groupId === groupId); - const childElements = children.map(layerItemToSelectedElement); - - const thisDisplayIdx = displayItemsRef.current.findIndex( - (di) => di.displayType === 'group-header' && di.groupId === groupId, - ); - if (thisDisplayIdx < 0) return; - - if (isShiftPressed && lastClickedDisplayIndex !== null) { - const startIdx = Math.min(lastClickedDisplayIndex, thisDisplayIdx); - const endIdx = Math.max(lastClickedDisplayIndex, thisDisplayIdx); - const currentDisplay = displayItemsRef.current; - - const rangeElements: SelectedElement[] = []; - const rangeGroupIds: string[] = []; - - for (let i = startIdx; i <= endIdx; i++) { - const di = currentDisplay[i]; - if (!di) continue; - if (di.displayType === 'group-header') { - rangeGroupIds.push(di.groupId); - const groupChildren = layerItemsRef.current.filter( - (item) => item.groupId === di.groupId, - ); - groupChildren.forEach((child) => { - rangeElements.push(layerItemToSelectedElement(child)); - }); - } else { - rangeElements.push(layerItemToSelectedElement(di.item)); - } - } - - const seen = new Set(); - const deduped = rangeElements.filter((el) => { - if (seen.has(el.id)) return false; - seen.add(el.id); - return true; - }); - - if (isPrimaryModifierPressed) { - const existingIds = new Set(selectedElements.map((el) => el.id)); - const newEls = deduped.filter((el) => !existingIds.has(el.id)); - const mergedElements = [...selectedElements, ...newEls]; - const existingGroupIds = new Set(selectedGroupIds); - const newGroupIds = rangeGroupIds.filter( - (id) => !existingGroupIds.has(id), - ); - const mergedGroupIds = [...selectedGroupIds, ...newGroupIds]; - setFullSelection(mergedElements, mergedGroupIds); - } else { - setFullSelection(deduped, rangeGroupIds); - } - return; - } - - if (isPrimaryModifierPressed) { - const isCurrentlySelected = selectedGroupIdSet.has(groupId); - if (isCurrentlySelected) { - const childIds = new Set(children.map((c) => c.id)); - const remaining = selectedElements.filter((el) => !childIds.has(el.id)); - const remainingGroups = selectedGroupIds.filter((id) => id !== groupId); - setFullSelection(remaining, remainingGroups); - } else { - const existingIds = new Set(selectedElements.map((el) => el.id)); - const newEls = childElements.filter((el) => !existingIds.has(el.id)); - const mergedElements = [...selectedElements, ...newEls]; - const mergedGroupIds = [...selectedGroupIds, groupId]; - setFullSelection(mergedElements, mergedGroupIds); - } - } else { - setFullSelection(childElements, [groupId]); + applySelectionIntent(resolution.intent); + if (resolution.anchor) { + setLastClickedDisplayIndex(resolution.anchor.displayIndex); + setLastClickedIndex(resolution.anchor.index); } - - setLastClickedDisplayIndex(thisDisplayIdx); - setLastClickedIndex(null); }; // 빈 공간 클릭 시 선택 해제 diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/layerSelectionIntent.test.ts b/src/renderer/components/main/Grid/PropertiesPanel/layer/layerSelectionIntent.test.ts new file mode 100644 index 000000000..62af56a9e --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/layerSelectionIntent.test.ts @@ -0,0 +1,338 @@ +import { describe, expect, it } from 'vitest'; +import type { SelectedElement } from '@stores/grid/useGridSelectionStore'; +import type { DisplayItem, LayerItem } from '../types'; +import { + layerItemToSelectedElement, + resolveLayerGroupSelectionIntent, + resolveLayerItemSelectionIntent, +} from './layerSelectionIntent'; + +const item = (id: string, index: number, groupId?: string): LayerItem => ({ + type: 'key', + id, + index, + name: id, + zIndex: 10 - index, + hidden: false, + ...(groupId ? { groupId } : {}), +}); + +const A = item('a', 0); +const B = item('b', 1, 'g'); +const C = item('c', 2, 'g'); +const D = item('d', 3); +const E = item('e', 4, 'h'); +const F = item('f', 5, 'h'); +const LAYER_ITEMS = [A, B, C, D, E, F]; + +const selected = (layerItem: LayerItem): SelectedElement => + layerItemToSelectedElement(layerItem); + +const expandedDisplay: DisplayItem[] = [ + { displayType: 'layer', item: A, groupDepth: 0, flatIndex: 0 }, + { + displayType: 'group-header', + groupId: 'g', + groupName: 'G', + isCollapsed: false, + childCount: 2, + allHidden: false, + }, + { displayType: 'layer', item: B, groupDepth: 1, flatIndex: 1 }, + { displayType: 'layer', item: C, groupDepth: 1, flatIndex: 2 }, + { displayType: 'layer', item: D, groupDepth: 0, flatIndex: 3 }, + { + displayType: 'group-header', + groupId: 'h', + groupName: 'H', + isCollapsed: false, + childCount: 2, + allHidden: false, + }, + { displayType: 'layer', item: E, groupDepth: 1, flatIndex: 4 }, + { displayType: 'layer', item: F, groupDepth: 1, flatIndex: 5 }, +]; + +const collapsedDisplay: DisplayItem[] = [ + expandedDisplay[0], + { ...expandedDisplay[1], isCollapsed: true } as DisplayItem, + expandedDisplay[4], + { ...expandedDisplay[5], isCollapsed: true } as DisplayItem, +]; + +const resolveItem = ( + overrides: Partial< + Parameters[0] + > = {}, +) => + resolveLayerItemSelectionIntent({ + item: A, + index: 0, + primary: false, + shift: false, + lastClickedIndex: null, + lastClickedDisplayIndex: null, + layerItems: LAYER_ITEMS, + displayItems: expandedDisplay, + selectedElements: [], + selectedGroupIds: [], + ...overrides, + }); + +const resolveGroup = ( + overrides: Partial< + Parameters[0] + > = {}, +) => + resolveLayerGroupSelectionIntent({ + groupId: 'g', + primary: false, + shift: false, + lastClickedDisplayIndex: null, + layerItems: LAYER_ITEMS, + rangeLayerItems: LAYER_ITEMS, + displayItems: expandedDisplay, + selectedElements: [], + selectedGroupIds: [], + ...overrides, + }); + +describe('resolveLayerItemSelectionIntent', () => { + it('ordinary 선택 상태별 즉시 교체와 50ms 지연 의도를 구분한다', () => { + expect(resolveItem().intent).toEqual({ + type: 'replace', + element: selected(A), + }); + expect(resolveItem({ selectedElements: [selected(A)] }).intent).toEqual({ + type: 'delay-clear', + }); + expect( + resolveItem({ selectedElements: [selected(A), selected(B)] }).intent, + ).toEqual({ type: 'delay-single', element: selected(A) }); + }); + + it('primary는 요소를 토글하면서 기존 groupIds 참조를 유지한다', () => { + const selectedGroupIds = ['g']; + const added = resolveItem({ + item: D, + index: 3, + primary: true, + selectedElements: [selected(A)], + selectedGroupIds, + }); + expect(added.intent).toEqual({ + type: 'set-full', + elements: [selected(A), selected(D)], + groupIds: selectedGroupIds, + }); + if (added.intent.type !== 'set-full') throw new Error('unexpected intent'); + expect(added.intent.groupIds).toBe(selectedGroupIds); + + expect( + resolveItem({ + primary: true, + selectedElements: [selected(A), selected(D)], + }).intent, + ).toEqual({ + type: 'set-full', + elements: [selected(D)], + groupIds: [], + }); + }); + + it('collapsed display range가 그룹 자식을 포함하고 행 중복을 ID로 제거한다', () => { + const resolution = resolveItem({ + item: D, + index: 3, + shift: true, + lastClickedIndex: 0, + lastClickedDisplayIndex: 0, + displayItems: collapsedDisplay, + }); + + expect(resolution).toEqual({ + intent: { + type: 'set-full', + elements: [selected(A), selected(B), selected(C), selected(D)], + groupIds: ['g'], + }, + anchor: null, + }); + }); + + it('shift+primary display range는 기존 순서를 유지하며 새 요소·그룹만 붙인다', () => { + expect( + resolveItem({ + item: D, + index: 3, + primary: true, + shift: true, + lastClickedIndex: 0, + lastClickedDisplayIndex: 0, + displayItems: collapsedDisplay, + selectedElements: [selected(F), selected(A)], + selectedGroupIds: ['h'], + }).intent, + ).toEqual({ + type: 'set-full', + elements: [ + selected(F), + selected(A), + selected(B), + selected(C), + selected(D), + ], + groupIds: ['h', 'g'], + }); + }); + + it('display target이 없으면 flat range fallback과 primary 병합을 사용한다', () => { + expect( + resolveItem({ + item: D, + index: 3, + shift: true, + lastClickedIndex: 1, + lastClickedDisplayIndex: null, + }).intent, + ).toEqual({ + type: 'set-elements', + elements: [selected(B), selected(C), selected(D)], + }); + + expect( + resolveItem({ + item: D, + index: 3, + primary: true, + shift: true, + lastClickedIndex: 1, + lastClickedDisplayIndex: null, + selectedElements: [selected(A), selected(C)], + }).intent, + ).toEqual({ + type: 'set-elements', + elements: [selected(A), selected(C), selected(B), selected(D)], + }); + }); + + it('ordinary 경로만 다음 flat/display anchor를 갱신한다', () => { + expect(resolveItem({ item: D, index: 3 }).anchor).toEqual({ + index: 3, + displayIndex: 4, + }); + expect( + resolveItem({ + item: D, + index: 3, + shift: true, + lastClickedIndex: 0, + }).anchor, + ).toBeNull(); + }); +}); + +describe('resolveLayerGroupSelectionIntent', () => { + it('ordinary는 자식과 그룹을 교체하고 primary는 전체 그룹을 추가·제거한다', () => { + expect(resolveGroup()).toEqual({ + intent: { + type: 'set-full', + elements: [selected(B), selected(C)], + groupIds: ['g'], + }, + anchor: { index: null, displayIndex: 1 }, + }); + + expect( + resolveGroup({ + primary: true, + selectedElements: [selected(A), selected(B)], + }).intent, + ).toEqual({ + type: 'set-full', + elements: [selected(A), selected(B), selected(C)], + groupIds: ['g'], + }); + + expect( + resolveGroup({ + primary: true, + selectedElements: [selected(A), selected(B), selected(C)], + selectedGroupIds: ['g'], + }).intent, + ).toEqual({ + type: 'set-full', + elements: [selected(A)], + groupIds: [], + }); + }); + + it('group shift와 shift+primary도 같은 display range 병합 규칙을 쓴다', () => { + expect( + resolveGroup({ + groupId: 'h', + shift: true, + lastClickedDisplayIndex: 1, + }).intent, + ).toEqual({ + type: 'set-full', + elements: [ + selected(B), + selected(C), + selected(D), + selected(E), + selected(F), + ], + groupIds: ['g', 'h'], + }); + + expect( + resolveGroup({ + groupId: 'h', + primary: true, + shift: true, + lastClickedDisplayIndex: 1, + selectedElements: [selected(A), selected(C)], + selectedGroupIds: ['external', 'g'], + }).intent, + ).toEqual({ + type: 'set-full', + elements: [ + selected(A), + selected(C), + selected(B), + selected(D), + selected(E), + selected(F), + ], + groupIds: ['external', 'g', 'h'], + }); + }); + + it('렌더 모델에 없는 그룹은 선택과 anchor를 모두 건드리지 않는다', () => { + expect(resolveGroup({ groupId: 'missing' })).toEqual({ + intent: { type: 'none' }, + anchor: null, + }); + }); +}); + +describe('layerItemToSelectedElement', () => { + it('plugin은 index를 내보내지 않고 native는 locator index를 유지한다', () => { + expect( + layerItemToSelectedElement({ + type: 'plugin', + id: 'plugin:element', + index: 9, + name: 'Plugin', + zIndex: 0, + hidden: false, + }), + ).toEqual({ type: 'plugin', id: 'plugin:element' }); + expect(layerItemToSelectedElement(B)).toEqual({ + type: 'key', + id: 'b', + index: 1, + }); + }); +}); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/layerSelectionIntent.ts b/src/renderer/components/main/Grid/PropertiesPanel/layer/layerSelectionIntent.ts new file mode 100644 index 000000000..541026778 --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/layerSelectionIntent.ts @@ -0,0 +1,336 @@ +import type { SelectedElement } from '@stores/grid/useGridSelectionStore'; +import type { DisplayItem, LayerItem } from '../types'; + +export type LayerSelectionIntent = + | { + type: 'set-full'; + elements: SelectedElement[]; + groupIds: string[]; + } + | { type: 'set-elements'; elements: SelectedElement[] } + | { type: 'replace'; element: SelectedElement } + | { type: 'delay-single'; element: SelectedElement } + | { type: 'delay-clear' } + | { type: 'none' }; + +interface ItemSelectionAnchor { + index: number; + displayIndex: number | null; +} + +interface GroupSelectionAnchor { + index: null; + displayIndex: number; +} + +export interface LayerItemSelectionResolution { + intent: LayerSelectionIntent; + anchor: ItemSelectionAnchor | null; +} + +export interface LayerGroupSelectionResolution { + intent: LayerSelectionIntent; + anchor: GroupSelectionAnchor | null; +} + +interface ResolveLayerItemSelectionIntentParams { + item: LayerItem; + index: number; + primary: boolean; + shift: boolean; + lastClickedIndex: number | null; + lastClickedDisplayIndex: number | null; + layerItems: readonly LayerItem[]; + displayItems: readonly DisplayItem[]; + selectedElements: SelectedElement[]; + selectedGroupIds: string[]; +} + +interface ResolveLayerGroupSelectionIntentParams { + groupId: string; + primary: boolean; + shift: boolean; + lastClickedDisplayIndex: number | null; + layerItems: readonly LayerItem[]; + rangeLayerItems: readonly LayerItem[]; + displayItems: readonly DisplayItem[]; + selectedElements: SelectedElement[]; + selectedGroupIds: string[]; +} + +interface DisplayRangeSelection { + elements: SelectedElement[]; + groupIds: string[]; +} + +export const layerItemToSelectedElement = ( + item: LayerItem, +): SelectedElement => { + if (item.type === 'plugin') return { type: 'plugin', id: item.id }; + return { + type: item.type, + id: item.id, + ...(item.index !== undefined ? { index: item.index } : {}), + }; +}; + +const collectDisplayRangeSelection = ( + startIndex: number, + endIndex: number, + displayItems: readonly DisplayItem[], + layerItems: readonly LayerItem[], +): DisplayRangeSelection => { + const rangeElements: SelectedElement[] = []; + const rangeGroupIds: string[] = []; + + for (let index = startIndex; index <= endIndex; index++) { + const displayItem = displayItems[index]; + if (!displayItem) continue; + if (displayItem.displayType === 'group-header') { + rangeGroupIds.push(displayItem.groupId); + layerItems + .filter((item) => item.groupId === displayItem.groupId) + .forEach((child) => { + rangeElements.push(layerItemToSelectedElement(child)); + }); + } else { + rangeElements.push(layerItemToSelectedElement(displayItem.item)); + } + } + + const seen = new Set(); + const elements = rangeElements.filter((element) => { + if (seen.has(element.id)) return false; + seen.add(element.id); + return true; + }); + + return { elements, groupIds: rangeGroupIds }; +}; + +const resolveDisplayRangeIntent = ( + range: DisplayRangeSelection, + primary: boolean, + selectedElements: readonly SelectedElement[], + selectedGroupIds: readonly string[], +): LayerSelectionIntent => { + if (!primary) { + return { + type: 'set-full', + elements: range.elements, + groupIds: range.groupIds, + }; + } + + const existingIds = new Set(selectedElements.map((element) => element.id)); + const newElements = range.elements.filter( + (element) => !existingIds.has(element.id), + ); + const existingGroupIds = new Set(selectedGroupIds); + const newGroupIds = range.groupIds.filter( + (groupId) => !existingGroupIds.has(groupId), + ); + + return { + type: 'set-full', + elements: [...selectedElements, ...newElements], + groupIds: [...selectedGroupIds, ...newGroupIds], + }; +}; + +export const resolveLayerItemSelectionIntent = ({ + item, + index, + primary, + shift, + lastClickedIndex, + lastClickedDisplayIndex, + layerItems, + displayItems, + selectedElements, + selectedGroupIds, +}: ResolveLayerItemSelectionIntentParams): LayerItemSelectionResolution => { + if ( + shift && + (lastClickedDisplayIndex !== null || lastClickedIndex !== null) + ) { + const displayIndex = displayItems.findIndex( + (displayItem) => + displayItem.displayType === 'layer' && displayItem.item.id === item.id, + ); + + if (lastClickedDisplayIndex !== null && displayIndex !== -1) { + const range = collectDisplayRangeSelection( + Math.min(lastClickedDisplayIndex, displayIndex), + Math.max(lastClickedDisplayIndex, displayIndex), + displayItems, + layerItems, + ); + return { + intent: resolveDisplayRangeIntent( + range, + primary, + selectedElements, + selectedGroupIds, + ), + anchor: null, + }; + } + + if (lastClickedIndex !== null) { + const rangeElements: SelectedElement[] = []; + const startIndex = Math.min(lastClickedIndex, index); + const endIndex = Math.max(lastClickedIndex, index); + for (let rangeIndex = startIndex; rangeIndex <= endIndex; rangeIndex++) { + const rangeItem = layerItems[rangeIndex]; + if (rangeItem) { + rangeElements.push(layerItemToSelectedElement(rangeItem)); + } + } + + if (primary) { + const existingIds = new Set( + selectedElements.map((element) => element.id), + ); + const newElements = rangeElements.filter( + (element) => !existingIds.has(element.id), + ); + return { + intent: { + type: 'set-elements', + elements: [...selectedElements, ...newElements], + }, + anchor: null, + }; + } + + return { + intent: { type: 'set-elements', elements: rangeElements }, + anchor: null, + }; + } + } + + const element = layerItemToSelectedElement(item); + let intent: LayerSelectionIntent; + + if (primary) { + const exists = selectedElements.some( + (selectedElement) => selectedElement.id === element.id, + ); + intent = { + type: 'set-full', + elements: exists + ? selectedElements.filter( + (selectedElement) => selectedElement.id !== element.id, + ) + : [...selectedElements, element], + groupIds: selectedGroupIds, + }; + } else { + const isAlreadySelected = selectedElements.some( + (selectedElement) => selectedElement.id === item.id, + ); + if (isAlreadySelected && selectedElements.length > 1) { + intent = { type: 'delay-single', element }; + } else if (isAlreadySelected) { + intent = { type: 'delay-clear' }; + } else { + intent = { type: 'replace', element }; + } + } + + const displayIndex = displayItems.findIndex( + (displayItem) => + displayItem.displayType === 'layer' && displayItem.item.id === item.id, + ); + return { + intent, + anchor: { + index, + displayIndex: displayIndex !== -1 ? displayIndex : null, + }, + }; +}; + +export const resolveLayerGroupSelectionIntent = ({ + groupId, + primary, + shift, + lastClickedDisplayIndex, + layerItems, + rangeLayerItems, + displayItems, + selectedElements, + selectedGroupIds, +}: ResolveLayerGroupSelectionIntentParams): LayerGroupSelectionResolution => { + const children = layerItems.filter((item) => item.groupId === groupId); + const childElements = children.map(layerItemToSelectedElement); + const displayIndex = displayItems.findIndex( + (displayItem) => + displayItem.displayType === 'group-header' && + displayItem.groupId === groupId, + ); + + if (displayIndex < 0) { + return { intent: { type: 'none' }, anchor: null }; + } + + if (shift && lastClickedDisplayIndex !== null) { + const range = collectDisplayRangeSelection( + Math.min(lastClickedDisplayIndex, displayIndex), + Math.max(lastClickedDisplayIndex, displayIndex), + displayItems, + rangeLayerItems, + ); + return { + intent: resolveDisplayRangeIntent( + range, + primary, + selectedElements, + selectedGroupIds, + ), + anchor: null, + }; + } + + let intent: LayerSelectionIntent; + if (primary) { + const isCurrentlySelected = selectedGroupIds.includes(groupId); + if (isCurrentlySelected) { + const childIds = new Set(children.map((child) => child.id)); + intent = { + type: 'set-full', + elements: selectedElements.filter( + (element) => !childIds.has(element.id), + ), + groupIds: selectedGroupIds.filter( + (selectedGroupId) => selectedGroupId !== groupId, + ), + }; + } else { + const existingIds = new Set( + selectedElements.map((element) => element.id), + ); + const newElements = childElements.filter( + (element) => !existingIds.has(element.id), + ); + intent = { + type: 'set-full', + elements: [...selectedElements, ...newElements], + groupIds: [...selectedGroupIds, groupId], + }; + } + } else { + intent = { + type: 'set-full', + elements: childElements, + groupIds: [groupId], + }; + } + + return { + intent, + anchor: { index: null, displayIndex }, + }; +}; From a506af4238768a5b27c9ca70d70c7315ab882d72 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 17:55:39 +0900 Subject: [PATCH 171/245] =?UTF-8?q?test:=20=EC=9A=94=EC=86=8C=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=20=EB=AA=A8=EB=8D=B8=20=EC=A7=81=EB=A0=AC=ED=99=94=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/models/tests.rs | 86 +++++++++++++++++++++++++++++++++-- 1 file changed, 82 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/models/tests.rs b/src-tauri/src/models/tests.rs index cc6dccf04..ab30164ab 100644 --- a/src-tauri/src/models/tests.rs +++ b/src-tauri/src/models/tests.rs @@ -1,9 +1,9 @@ use super::{ compact_canonical_rgba, note_border_representative_hex, scrub_removed_text_outline_fields, - FadePosition, GradientSpec, GraphPosition, GraphStatType, GraphType, KeyCounterAlign, - KeyCounterAlignMode, KeyCounterColor, KeyCounterPlacement, KeyCounterSettings, KeyMappings, - KeyPosition, KeySlot, KnobPosition, NoteColor, NoteSettings, SlotMatch, StatPosition, StatType, - MAX_SLOT_KEYS, POSITION_COLLECTION_FIELDS, + FadePosition, GradientSpec, GraphPosition, GraphStatType, GraphType, ImageTransform, + KeyCounterAlign, KeyCounterAlignMode, KeyCounterColor, KeyCounterPlacement, KeyCounterSettings, + KeyMappings, KeyPosition, KeySlot, KnobPosition, NoteAlignment, NoteColor, NoteSettings, + SlotMatch, StatPosition, StatType, MAX_SLOT_KEYS, POSITION_COLLECTION_FIELDS, }; use serde::Deserialize; @@ -123,6 +123,84 @@ fn element_id_defaults_to_empty_and_flattens_into_every_position_type() { assert!(missing.id.is_empty()); } +#[test] +fn position_wrappers_preserve_legacy_missing_field_defaults_and_round_trip() { + let base = serde_json::json!({ + "dx": 1, + "dy": 2, + "width": 60, + "count": 3 + }); + let mut stat_value = base.clone(); + stat_value["statType"] = serde_json::json!("kpsAvg"); + let stat: StatPosition = serde_json::from_value(stat_value).unwrap(); + + let mut graph_value = base.clone(); + graph_value["statType"] = serde_json::json!("total"); + graph_value["graphType"] = serde_json::json!("bar"); + graph_value["graphSpeed"] = serde_json::json!(120); + graph_value["graphColor"] = serde_json::json!("#123456"); + let graph: GraphPosition = serde_json::from_value(graph_value).unwrap(); + + let knob: KnobPosition = serde_json::from_value(base).unwrap(); + + assert!(graph.show_avg_line); + assert!(knob.axis_id.is_empty()); + assert_eq!(knob.sensitivity, 1.0); + assert!(!knob.reverse); + assert_eq!( + serde_json::from_value::(serde_json::to_value(&stat).unwrap()).unwrap(), + stat + ); + assert_eq!( + serde_json::from_value::(serde_json::to_value(&graph).unwrap()).unwrap(), + graph + ); + assert_eq!( + serde_json::from_value::(serde_json::to_value(&knob).unwrap()).unwrap(), + knob + ); +} + +#[test] +fn position_serialization_field_order_and_related_defaults_are_stable() { + let position = KeyPosition::default(); + assert!(serde_json::to_string(&position).unwrap().starts_with( + r#"{"dx":0.0,"dy":0.0,"width":60.0,"height":60.0,"hidden":false,"activeImage":null"# + )); + assert!(serde_json::to_string(&StatPosition { + stat_type: StatType::Kps, + position: position.clone(), + }) + .unwrap() + .starts_with(r#"{"statType":"kps","dx":0.0,"dy":0.0,"width":60.0"#)); + assert!(serde_json::to_string(&GraphPosition { + stat_type: GraphStatType::KpsAvg, + graph_type: GraphType::Bar, + graph_speed: 120, + graph_color: "#123456".to_string(), + show_avg_line: false, + position: position.clone(), + }) + .unwrap() + .starts_with( + r##"{"statType":"kpsAvg","graphType":"bar","graphSpeed":120,"graphColor":"#123456","showAvgLine":false,"dx":0.0"## + )); + assert!(serde_json::to_string(&KnobPosition { + axis_id: "axis".to_string(), + sensitivity: 2.5, + reverse: true, + position, + }) + .unwrap() + .starts_with(r#"{"axisId":"axis","sensitivity":2.5,"reverse":true,"dx":0.0"#)); + assert_eq!( + serde_json::to_string(&ImageTransform::default()).unwrap(), + r#"{"offsetX":0.0,"offsetY":0.0,"rotation":0.0,"scale":1.0}"# + ); + assert_eq!(NoteAlignment::default(), NoteAlignment::Center); +} + #[test] fn new_surface_gradients_flatten_into_every_position_collection() { let position = KeyPosition { From f26663f94196a778dec6237de4b14808305e3cf5 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 17:55:39 +0900 Subject: [PATCH 172/245] =?UTF-8?q?refactor:=20=EC=9A=94=EC=86=8C=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EB=AA=A8=EB=8D=B8=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/models/element_positions.rs | 416 ++++++++++++++++++++++ src-tauri/src/models/mod.rs | 414 +-------------------- src/types/key/statItems.test.ts | 2 +- 3 files changed, 427 insertions(+), 405 deletions(-) create mode 100644 src-tauri/src/models/element_positions.rs diff --git a/src-tauri/src/models/element_positions.rs b/src-tauri/src/models/element_positions.rs new file mode 100644 index 000000000..cae2840af --- /dev/null +++ b/src-tauri/src/models/element_positions.rs @@ -0,0 +1,416 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use super::{ + default_key_height, default_key_note_color, default_key_note_opacity, + default_note_auto_y_correction, default_note_border_opacity, default_note_effect_enabled, + default_note_glow_enabled, default_note_glow_opacity, default_note_glow_size, GradientSpec, + ImageFit, KeyCounterSettings, NoteColor, +}; + +pub const POSITION_COLLECTION_FIELDS: [&str; 4] = [ + "keyPositions", + "statPositions", + "graphPositions", + "knobPositions", +]; + +pub type KeyPositions = HashMap>; +pub type StatPositions = HashMap>; +pub type GraphPositions = HashMap>; +pub type KnobPositions = HashMap>; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "kebab-case")] +#[derive(Default)] +pub enum NoteAlignment { + Left, + #[default] + Center, + Right, +} + +// 그림자 범위 계약 — 프론트 zod(ELEMENT_SHADOW_CONSTRAINTS)와 동기 유지 +pub const SHADOW_OFFSET_MIN: f64 = -100.0; +pub const SHADOW_OFFSET_MAX: f64 = 100.0; +pub const SHADOW_BLUR_MIN: f64 = 0.0; +pub const SHADOW_BLUR_MAX: f64 = 100.0; + +pub const IMAGE_TRANSFORM_OFFSET_MIN: f64 = -500.0; +pub const IMAGE_TRANSFORM_OFFSET_MAX: f64 = 500.0; +pub const IMAGE_TRANSFORM_ROTATION_MIN: f64 = -180.0; +pub const IMAGE_TRANSFORM_ROTATION_MAX: f64 = 180.0; +pub const IMAGE_TRANSFORM_SCALE_MIN: f64 = 0.1; +pub const IMAGE_TRANSFORM_SCALE_MAX: f64 = 10.0; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ImageMode { + Replace, + Overlay, +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ImageTransform { + pub offset_x: f64, + pub offset_y: f64, + pub rotation: f64, + pub scale: f64, +} + +impl Default for ImageTransform { + fn default() -> Self { + Self { + offset_x: 0.0, + offset_y: 0.0, + rotation: 0.0, + scale: 1.0, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ElementShadowSpec { + pub enabled: bool, + pub color: String, + pub offset_x: f64, + pub offset_y: f64, + pub blur: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct KeyPosition { + #[serde(default, skip_serializing_if = "String::is_empty")] + pub id: String, + pub dx: f64, + pub dy: f64, + pub width: f64, + #[serde(default = "default_key_height")] + pub height: f64, + /// 레이어 표시 여부 (true면 숨김) + #[serde(default)] + pub hidden: bool, + #[serde(default)] + pub active_image: Option, + #[serde(default)] + pub inactive_image: Option, + /// 키별 사운드 활성화 여부 (기본값 false) + #[serde(default)] + pub sound_enabled: Option, + /// 키 입력 시 재생할 로컬 사운드 파일 경로 + #[serde(default)] + pub sound_path: Option, + /// 키별 사운드 볼륨 (0~200, 기본값 100) + #[serde(default)] + pub sound_volume: Option, + #[serde(default)] + pub active_transparent: bool, + #[serde(default)] + pub idle_transparent: bool, + pub count: u32, + #[serde(default = "default_key_note_color")] + pub note_color: NoteColor, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note_gradient: Option, + #[serde(default = "default_key_note_opacity")] + pub note_opacity: u32, + #[serde(default)] + pub note_opacity_top: Option, + #[serde(default)] + pub note_opacity_bottom: Option, + #[serde(default)] + pub note_border_radius: Option, + /// 노트 넓이(px). None이면 키 width를 사용(자동). + #[serde(default)] + pub note_width: Option, + /// 노트 정렬 (left/center/right). 기본값 center. + #[serde(default)] + pub note_alignment: NoteAlignment, + #[serde(default = "default_note_effect_enabled")] + pub note_effect_enabled: bool, + #[serde(default = "default_note_glow_enabled")] + pub note_glow_enabled: bool, + #[serde(default)] + pub note_glow_sync_paint: bool, + #[serde(default = "default_note_glow_size")] + pub note_glow_size: f64, + #[serde(default = "default_note_glow_opacity")] + pub note_glow_opacity: u32, + #[serde(default)] + pub note_glow_opacity_top: Option, + #[serde(default)] + pub note_glow_opacity_bottom: Option, + #[serde(default)] + pub note_glow_color: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note_glow_gradient: Option, + #[serde(default = "default_note_auto_y_correction")] + pub note_auto_y_correction: bool, + /// 노트 오프셋 X (px). 기본 정렬에 추가 보정값. + #[serde(default)] + pub note_offset_x: Option, + /// 노트 오프셋 Y (px). 기본 정렬에 추가 보정값. + #[serde(default)] + pub note_offset_y: Option, + /// 노트 테두리 굵기 (px). 0이면 테두리 없음. + #[serde(default)] + pub note_border_width: Option, + /// 노트 테두리 색상 + #[serde(default)] + pub note_border_color: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note_border_gradient: Option, + /// 노트 테두리 투명도 (0~100). 노트 배경 투명도와 독립. 기본 100. + #[serde(default = "default_note_border_opacity")] + pub note_border_opacity: u32, + /// 노트 테두리 방향 (all/vertical/horizontal) + #[serde(default)] + pub note_border_side: Option, + #[serde(default)] + pub class_name: Option, + #[serde(default)] + pub z_index: Option, + #[serde(default)] + pub counter: KeyCounterSettings, + // 스타일 관련 속성들 + #[serde(default)] + pub background_color: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub background_gradient: Option, + #[serde(default)] + pub active_background_color: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_background_gradient: Option, + #[serde(default)] + pub border_color: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub border_gradient: Option, + #[serde(default)] + pub active_border_color: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_border_gradient: Option, + #[serde(default)] + pub border_width: Option, + #[serde(default)] + pub border_radius: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub shadow: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_shadow: Option, + #[serde(default)] + pub font_size: Option, + #[serde(default)] + pub font_color: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub font_gradient: Option, + #[serde(default)] + pub active_font_color: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_font_gradient: Option, + #[serde(default)] + pub graph_animation_enabled: Option, + /// 글꼴 패밀리 (커스텀 폰트 이름) + #[serde(default)] + pub font_family: Option, + #[serde(default)] + pub image_fit: Option, + /// 이미지 맞춤(대기/입력 개별). 없으면 image_fit을 fallback으로 사용. + #[serde(default)] + pub idle_image_fit: Option, + #[serde(default)] + pub active_image_fit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub image_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub idle_image_transform: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_image_transform: Option, + /// 인라인 스타일 우선 여부 (true: 속성 패널 스타일 우선, false: 커스텀 CSS 우선) + #[serde(default)] + pub use_inline_styles: Option, + /// 키에 표시할 커스텀 텍스트 (None이면 기본 키 이름 표시) + #[serde(default)] + pub display_text: Option, + /// 글꼴 굵기 (CSS font-weight 값, 예: 400, 700) + #[serde(default)] + pub font_weight: Option, + /// 선택 굵기에 +300을 적용하는 Bold 토글 - None은 직렬화하지 않는다 + /// (IPC에서 null로 나가면 프론트 스키마가 거부해 설정 전체가 기본값으로 떨어진다) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub font_bold: Option, + /// 이탤릭체 여부 + #[serde(default)] + pub font_italic: Option, + /// 밑줄 여부 + #[serde(default)] + pub font_underline: Option, + /// 취소선 여부 + #[serde(default)] + pub font_strikethrough: Option, + /// 레이어 패널에서 표시할 커스텀 이름 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub layer_name: Option, + /// 레이어 그룹 ID + #[serde(default, skip_serializing_if = "Option::is_none")] + pub group_id: Option, +} + +impl Default for KeyPosition { + fn default() -> Self { + Self { + id: String::new(), + dx: 0.0, + dy: 0.0, + width: 60.0, + height: default_key_height(), + hidden: false, + active_image: None, + inactive_image: None, + sound_enabled: None, + sound_path: None, + sound_volume: None, + active_transparent: false, + idle_transparent: false, + count: 0, + note_color: default_key_note_color(), + note_gradient: None, + note_opacity: default_key_note_opacity(), + note_opacity_top: None, + note_opacity_bottom: None, + note_border_radius: None, + note_width: None, + note_alignment: NoteAlignment::default(), + note_effect_enabled: default_note_effect_enabled(), + note_glow_enabled: default_note_glow_enabled(), + note_glow_sync_paint: false, + note_glow_size: default_note_glow_size(), + note_glow_opacity: default_note_glow_opacity(), + note_glow_opacity_top: None, + note_glow_opacity_bottom: None, + note_glow_color: None, + note_glow_gradient: None, + note_auto_y_correction: default_note_auto_y_correction(), + note_offset_x: None, + note_offset_y: None, + note_border_width: None, + note_border_color: None, + note_border_gradient: None, + note_border_opacity: default_note_border_opacity(), + note_border_side: None, + class_name: None, + z_index: None, + counter: KeyCounterSettings::default(), + background_color: None, + background_gradient: None, + active_background_color: None, + active_background_gradient: None, + border_color: None, + border_gradient: None, + active_border_color: None, + active_border_gradient: None, + border_width: None, + border_radius: None, + shadow: None, + active_shadow: None, + font_size: None, + font_color: None, + font_gradient: None, + active_font_color: None, + active_font_gradient: None, + graph_animation_enabled: None, + font_family: None, + image_fit: None, + idle_image_fit: None, + active_image_fit: None, + image_mode: None, + idle_image_transform: None, + active_image_transform: None, + use_inline_styles: None, + display_text: None, + font_weight: Some(400), + font_bold: Some(true), + font_italic: None, + font_underline: None, + font_strikethrough: None, + layer_name: None, + group_id: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub enum StatType { + Kps, + KpsAvg, + KpsMax, + Total, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct StatPosition { + pub stat_type: StatType, + #[serde(flatten)] + pub position: KeyPosition, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub enum GraphStatType { + Kps, + KpsAvg, + KpsMax, + Total, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum GraphType { + Line, + Bar, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct GraphPosition { + pub stat_type: GraphStatType, + pub graph_type: GraphType, + pub graph_speed: u32, + pub graph_color: String, + #[serde(default = "default_true")] + pub show_avg_line: bool, + #[serde(flatten)] + pub position: KeyPosition, +} + +fn default_knob_sensitivity() -> f64 { + // 순수 배율 — 1이면 축 해상도와 무관하게 물리 1회전 ≈ 화면 1회전 + // (프론트엔드가 wrap 델타를 축 해상도로 정규화해 회전수 단위로 누적) + 1.0 +} + +/// 회전(노브) 요소 — HID 축에 바인딩. KeyPosition 상속(표시명/스타일/클래스/이미지) + 노브 전용 필드. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct KnobPosition { + /// 바인딩된 HID 축 식별자 "HIDA:vid:pid:usagePage:usage" + #[serde(default)] + pub axis_id: String, + /// 회전 배율 (물리 1회전당 화면 회전 수) + #[serde(default = "default_knob_sensitivity")] + pub sensitivity: f64, + /// 회전 방향 반전 + #[serde(default)] + pub reverse: bool, + #[serde(flatten)] + pub position: KeyPosition, +} + +pub(super) fn default_true() -> bool { + true +} diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index c5eed296d..5ba6f4ba4 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -5,6 +5,7 @@ pub mod plugin; mod bootstrap; mod counter; +mod element_positions; mod key_position_normalization; mod note_settings; mod settings; @@ -20,6 +21,15 @@ pub use counter::{ }; use counter::{default_counter_font_size, default_counter_font_weight, default_gap}; pub use editor::*; +use element_positions::default_true; +pub use element_positions::{ + ElementShadowSpec, GraphPosition, GraphPositions, GraphStatType, GraphType, ImageMode, + ImageTransform, KeyPosition, KeyPositions, KnobPosition, KnobPositions, NoteAlignment, + StatPosition, StatPositions, StatType, IMAGE_TRANSFORM_OFFSET_MAX, IMAGE_TRANSFORM_OFFSET_MIN, + IMAGE_TRANSFORM_ROTATION_MAX, IMAGE_TRANSFORM_ROTATION_MIN, IMAGE_TRANSFORM_SCALE_MAX, + IMAGE_TRANSFORM_SCALE_MIN, POSITION_COLLECTION_FIELDS, SHADOW_BLUR_MAX, SHADOW_BLUR_MIN, + SHADOW_OFFSET_MAX, SHADOW_OFFSET_MIN, +}; pub use gesture::*; use note_settings::{ default_key_height, default_key_note_color, default_key_note_opacity, @@ -45,12 +55,6 @@ use std::path::Path; use uuid::Uuid; pub const MAX_SLOT_KEYS: usize = 8; -pub const POSITION_COLLECTION_FIELDS: [&str; 4] = [ - "keyPositions", - "statPositions", - "graphPositions", - "knobPositions", -]; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] @@ -210,11 +214,7 @@ pub fn key_mappings_contain_multi(mappings: &HashMap>) -> b } pub type KeyMappings = HashMap>; -pub type KeyPositions = HashMap>; pub type KeyCounters = HashMap>; -pub type StatPositions = HashMap>; -pub type GraphPositions = HashMap>; -pub type KnobPositions = HashMap>; const DEFAULT_GRADIENT_ANGLE: f64 = 90.0; const MAX_GRADIENT_STOPS: usize = 8; @@ -520,396 +520,6 @@ impl<'de> Deserialize<'de> for NoteColor { } } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "kebab-case")] -#[derive(Default)] -pub enum NoteAlignment { - Left, - #[default] - Center, - Right, -} - -// 그림자 범위 계약 — 프론트 zod(ELEMENT_SHADOW_CONSTRAINTS)와 동기 유지 -pub const SHADOW_OFFSET_MIN: f64 = -100.0; -pub const SHADOW_OFFSET_MAX: f64 = 100.0; -pub const SHADOW_BLUR_MIN: f64 = 0.0; -pub const SHADOW_BLUR_MAX: f64 = 100.0; - -pub const IMAGE_TRANSFORM_OFFSET_MIN: f64 = -500.0; -pub const IMAGE_TRANSFORM_OFFSET_MAX: f64 = 500.0; -pub const IMAGE_TRANSFORM_ROTATION_MIN: f64 = -180.0; -pub const IMAGE_TRANSFORM_ROTATION_MAX: f64 = 180.0; -pub const IMAGE_TRANSFORM_SCALE_MIN: f64 = 0.1; -pub const IMAGE_TRANSFORM_SCALE_MAX: f64 = 10.0; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum ImageMode { - Replace, - Overlay, -} - -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ImageTransform { - pub offset_x: f64, - pub offset_y: f64, - pub rotation: f64, - pub scale: f64, -} - -impl Default for ImageTransform { - fn default() -> Self { - Self { - offset_x: 0.0, - offset_y: 0.0, - rotation: 0.0, - scale: 1.0, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct ElementShadowSpec { - pub enabled: bool, - pub color: String, - pub offset_x: f64, - pub offset_y: f64, - pub blur: f64, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct KeyPosition { - #[serde(default, skip_serializing_if = "String::is_empty")] - pub id: String, - pub dx: f64, - pub dy: f64, - pub width: f64, - #[serde(default = "default_key_height")] - pub height: f64, - /// 레이어 표시 여부 (true면 숨김) - #[serde(default)] - pub hidden: bool, - #[serde(default)] - pub active_image: Option, - #[serde(default)] - pub inactive_image: Option, - /// 키별 사운드 활성화 여부 (기본값 false) - #[serde(default)] - pub sound_enabled: Option, - /// 키 입력 시 재생할 로컬 사운드 파일 경로 - #[serde(default)] - pub sound_path: Option, - /// 키별 사운드 볼륨 (0~200, 기본값 100) - #[serde(default)] - pub sound_volume: Option, - #[serde(default)] - pub active_transparent: bool, - #[serde(default)] - pub idle_transparent: bool, - pub count: u32, - #[serde(default = "default_key_note_color")] - pub note_color: NoteColor, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub note_gradient: Option, - #[serde(default = "default_key_note_opacity")] - pub note_opacity: u32, - #[serde(default)] - pub note_opacity_top: Option, - #[serde(default)] - pub note_opacity_bottom: Option, - #[serde(default)] - pub note_border_radius: Option, - /// 노트 넓이(px). None이면 키 width를 사용(자동). - #[serde(default)] - pub note_width: Option, - /// 노트 정렬 (left/center/right). 기본값 center. - #[serde(default)] - pub note_alignment: NoteAlignment, - #[serde(default = "default_note_effect_enabled")] - pub note_effect_enabled: bool, - #[serde(default = "default_note_glow_enabled")] - pub note_glow_enabled: bool, - #[serde(default)] - pub note_glow_sync_paint: bool, - #[serde(default = "default_note_glow_size")] - pub note_glow_size: f64, - #[serde(default = "default_note_glow_opacity")] - pub note_glow_opacity: u32, - #[serde(default)] - pub note_glow_opacity_top: Option, - #[serde(default)] - pub note_glow_opacity_bottom: Option, - #[serde(default)] - pub note_glow_color: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub note_glow_gradient: Option, - #[serde(default = "default_note_auto_y_correction")] - pub note_auto_y_correction: bool, - /// 노트 오프셋 X (px). 기본 정렬에 추가 보정값. - #[serde(default)] - pub note_offset_x: Option, - /// 노트 오프셋 Y (px). 기본 정렬에 추가 보정값. - #[serde(default)] - pub note_offset_y: Option, - /// 노트 테두리 굵기 (px). 0이면 테두리 없음. - #[serde(default)] - pub note_border_width: Option, - /// 노트 테두리 색상 - #[serde(default)] - pub note_border_color: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub note_border_gradient: Option, - /// 노트 테두리 투명도 (0~100). 노트 배경 투명도와 독립. 기본 100. - #[serde(default = "default_note_border_opacity")] - pub note_border_opacity: u32, - /// 노트 테두리 방향 (all/vertical/horizontal) - #[serde(default)] - pub note_border_side: Option, - #[serde(default)] - pub class_name: Option, - #[serde(default)] - pub z_index: Option, - #[serde(default)] - pub counter: KeyCounterSettings, - // 스타일 관련 속성들 - #[serde(default)] - pub background_color: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub background_gradient: Option, - #[serde(default)] - pub active_background_color: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub active_background_gradient: Option, - #[serde(default)] - pub border_color: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub border_gradient: Option, - #[serde(default)] - pub active_border_color: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub active_border_gradient: Option, - #[serde(default)] - pub border_width: Option, - #[serde(default)] - pub border_radius: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub shadow: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub active_shadow: Option, - #[serde(default)] - pub font_size: Option, - #[serde(default)] - pub font_color: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub font_gradient: Option, - #[serde(default)] - pub active_font_color: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub active_font_gradient: Option, - #[serde(default)] - pub graph_animation_enabled: Option, - /// 글꼴 패밀리 (커스텀 폰트 이름) - #[serde(default)] - pub font_family: Option, - #[serde(default)] - pub image_fit: Option, - /// 이미지 맞춤(대기/입력 개별). 없으면 image_fit을 fallback으로 사용. - #[serde(default)] - pub idle_image_fit: Option, - #[serde(default)] - pub active_image_fit: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub image_mode: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub idle_image_transform: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub active_image_transform: Option, - /// 인라인 스타일 우선 여부 (true: 속성 패널 스타일 우선, false: 커스텀 CSS 우선) - #[serde(default)] - pub use_inline_styles: Option, - /// 키에 표시할 커스텀 텍스트 (None이면 기본 키 이름 표시) - #[serde(default)] - pub display_text: Option, - /// 글꼴 굵기 (CSS font-weight 값, 예: 400, 700) - #[serde(default)] - pub font_weight: Option, - /// 선택 굵기에 +300을 적용하는 Bold 토글 - None은 직렬화하지 않는다 - /// (IPC에서 null로 나가면 프론트 스키마가 거부해 설정 전체가 기본값으로 떨어진다) - #[serde(default, skip_serializing_if = "Option::is_none")] - pub font_bold: Option, - /// 이탤릭체 여부 - #[serde(default)] - pub font_italic: Option, - /// 밑줄 여부 - #[serde(default)] - pub font_underline: Option, - /// 취소선 여부 - #[serde(default)] - pub font_strikethrough: Option, - /// 레이어 패널에서 표시할 커스텀 이름 - #[serde(default, skip_serializing_if = "Option::is_none")] - pub layer_name: Option, - /// 레이어 그룹 ID - #[serde(default, skip_serializing_if = "Option::is_none")] - pub group_id: Option, -} - -impl Default for KeyPosition { - fn default() -> Self { - Self { - id: String::new(), - dx: 0.0, - dy: 0.0, - width: 60.0, - height: default_key_height(), - hidden: false, - active_image: None, - inactive_image: None, - sound_enabled: None, - sound_path: None, - sound_volume: None, - active_transparent: false, - idle_transparent: false, - count: 0, - note_color: default_key_note_color(), - note_gradient: None, - note_opacity: default_key_note_opacity(), - note_opacity_top: None, - note_opacity_bottom: None, - note_border_radius: None, - note_width: None, - note_alignment: NoteAlignment::default(), - note_effect_enabled: default_note_effect_enabled(), - note_glow_enabled: default_note_glow_enabled(), - note_glow_sync_paint: false, - note_glow_size: default_note_glow_size(), - note_glow_opacity: default_note_glow_opacity(), - note_glow_opacity_top: None, - note_glow_opacity_bottom: None, - note_glow_color: None, - note_glow_gradient: None, - note_auto_y_correction: default_note_auto_y_correction(), - note_offset_x: None, - note_offset_y: None, - note_border_width: None, - note_border_color: None, - note_border_gradient: None, - note_border_opacity: default_note_border_opacity(), - note_border_side: None, - class_name: None, - z_index: None, - counter: KeyCounterSettings::default(), - background_color: None, - background_gradient: None, - active_background_color: None, - active_background_gradient: None, - border_color: None, - border_gradient: None, - active_border_color: None, - active_border_gradient: None, - border_width: None, - border_radius: None, - shadow: None, - active_shadow: None, - font_size: None, - font_color: None, - font_gradient: None, - active_font_color: None, - active_font_gradient: None, - graph_animation_enabled: None, - font_family: None, - image_fit: None, - idle_image_fit: None, - active_image_fit: None, - image_mode: None, - idle_image_transform: None, - active_image_transform: None, - use_inline_styles: None, - display_text: None, - font_weight: Some(400), - font_bold: Some(true), - font_italic: None, - font_underline: None, - font_strikethrough: None, - layer_name: None, - group_id: None, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub enum StatType { - Kps, - KpsAvg, - KpsMax, - Total, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct StatPosition { - pub stat_type: StatType, - #[serde(flatten)] - pub position: KeyPosition, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub enum GraphStatType { - Kps, - KpsAvg, - KpsMax, - Total, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "lowercase")] -pub enum GraphType { - Line, - Bar, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct GraphPosition { - pub stat_type: GraphStatType, - pub graph_type: GraphType, - pub graph_speed: u32, - pub graph_color: String, - #[serde(default = "default_true")] - pub show_avg_line: bool, - #[serde(flatten)] - pub position: KeyPosition, -} - -fn default_knob_sensitivity() -> f64 { - // 순수 배율 — 1이면 축 해상도와 무관하게 물리 1회전 ≈ 화면 1회전 - // (프론트엔드가 wrap 델타를 축 해상도로 정규화해 회전수 단위로 누적) - 1.0 -} - -/// 회전(노브) 요소 — HID 축에 바인딩. KeyPosition 상속(표시명/스타일/클래스/이미지) + 노브 전용 필드. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct KnobPosition { - /// 바인딩된 HID 축 식별자 "HIDA:vid:pid:usagePage:usage" - #[serde(default)] - pub axis_id: String, - /// 회전 배율 (물리 1회전당 화면 회전 수) - #[serde(default = "default_knob_sensitivity")] - pub sensitivity: f64, - /// 회전 방향 반전 - #[serde(default)] - pub reverse: bool, - #[serde(flatten)] - pub position: KeyPosition, -} - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] #[derive(Default)] @@ -1060,10 +670,6 @@ pub struct GridSettings { pub overlay_padding: u32, } -fn default_true() -> bool { - true -} - fn default_auto_update_enabled() -> bool { true } diff --git a/src/types/key/statItems.test.ts b/src/types/key/statItems.test.ts index 231b2b1d8..9ab9c3b9c 100644 --- a/src/types/key/statItems.test.ts +++ b/src/types/key/statItems.test.ts @@ -19,7 +19,7 @@ describe('statistics item wire contract', () => { expect(uiTypes).toEqual(new Set(STAT_ITEM_TYPES)); const rustSource = readFileSync( - resolve(process.cwd(), 'src-tauri/src/models/mod.rs'), + resolve(process.cwd(), 'src-tauri/src/models/element_positions.rs'), 'utf8', ); const declaration = rustSource.match( From 51663ce2ab91a8ef7fb4be86f71e2b8b23706cc0 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 18:00:08 +0900 Subject: [PATCH 173/245] =?UTF-8?q?test:=20=ED=82=A4=20=ED=86=B5=EA=B3=84?= =?UTF-8?q?=20=EC=B9=B4=EC=9A=B4=ED=84=B0=20=ED=91=9C=EB=A9=B4=20=EA=B3=84?= =?UTF-8?q?=EC=95=BD=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../renderedElementContract.test.tsx | 63 +++++++++++++++ .../Grid/layers/CounterPreviewLayers.test.tsx | 81 +++++++++++++++++++ .../overlay/counters/counterLayers.test.tsx | 23 ++++++ 3 files changed, 167 insertions(+) diff --git a/src/renderer/__tests__/renderedElementContract.test.tsx b/src/renderer/__tests__/renderedElementContract.test.tsx index a54e6b395..c6f73f0cd 100644 --- a/src/renderer/__tests__/renderedElementContract.test.tsx +++ b/src/renderer/__tests__/renderedElementContract.test.tsx @@ -282,6 +282,69 @@ describe('렌더 DOM 계약', () => { }); }); + describe('Key·Stat 공유 face DOM', () => { + it('border ring → image → label 순서를 공유하고 이미지 실패 시 label을 복구한다', () => { + const imagePosition: KeyElementPosition = { + ...basePosition, + inactiveImage: 'data:image/png;base64,aW1hZ2U=', + imageMode: 'overlay', + }; + act(() => { + root.render( + <> + + + , + ); + }); + + const faces = [ + ...host.querySelectorAll('[data-key-element="true"]'), + ]; + expect(faces).toHaveLength(2); + expect( + faces.map((face) => + [...face.children].map((child) => { + if (child.hasAttribute('data-gradient-border-ring')) return 'ring'; + if (child.hasAttribute('data-key-image-layer')) return 'image'; + return 'label'; + }), + ), + ).toEqual([ + ['ring', 'image', 'label'], + ['ring', 'image', 'label'], + ]); + + act(() => { + faces.forEach((face) => { + face + .querySelector('[data-key-image-layer]') + ?.dispatchEvent(new Event('error')); + }); + }); + + expect(host.querySelector('[data-key-image-layer]')).toBeNull(); + expect( + [ + ...host.querySelectorAll('[data-key-element="true"]'), + ].map((face) => + [...face.children].map((child) => + child.hasAttribute('data-gradient-border-ring') ? 'ring' : 'label', + ), + ), + ).toEqual([ + ['ring', 'label'], + ['ring', 'label'], + ]); + expect(host.textContent).toContain('A'); + expect(host.textContent).toContain('KPS'); + }); + }); + describe('오버레이 KnobItem', () => { it('data 속성·노브 변수를 싣고 회전 입력이 active로 전환한다', () => { act(() => { diff --git a/src/renderer/components/main/Grid/layers/CounterPreviewLayers.test.tsx b/src/renderer/components/main/Grid/layers/CounterPreviewLayers.test.tsx index 873e4248d..2359e4f2b 100644 --- a/src/renderer/components/main/Grid/layers/CounterPreviewLayers.test.tsx +++ b/src/renderer/components/main/Grid/layers/CounterPreviewLayers.test.tsx @@ -1,6 +1,7 @@ import React, { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { useEditStatePreviewStore } from '@stores/grid/useEditStatePreviewStore'; import KeyCounterPreviewLayer from './KeyCounterPreviewLayer'; import StatCounterLayer from './StatCounterLayer'; @@ -30,6 +31,7 @@ beforeEach(() => { afterEach(() => { act(() => root.unmount()); container.remove(); + useEditStatePreviewStore.setState({ entries: [] }); }); describe('Grid 카운터 프리뷰 공용 표면', () => { @@ -91,4 +93,83 @@ describe('Grid 카운터 프리뷰 공용 표면', () => { expect(container.querySelector('.counter')).toBeNull(); }); + + it('Key와 Stat adapter가 각 anchor의 active/inactive 상태를 독립 구독한다', () => { + const positions = [ + { id: 'key-preview-state', counter: outsideCounter }, + { id: 'stat-preview-state', counter: outsideCounter }, + ]; + + act(() => { + root.render( + <> + + + , + ); + }); + const states = () => + [...container.querySelectorAll('.counter')].map( + (counter) => counter.dataset.counterState, + ); + expect(states()).toEqual(['inactive', 'inactive']); + + act(() => { + useEditStatePreviewStore + .getState() + .publish(101, { kind: 'key', id: positions[0].id }, 'active'); + }); + expect(states()).toEqual(['active', 'inactive']); + + act(() => { + useEditStatePreviewStore + .getState() + .publish(102, { kind: 'stat', id: positions[1].id }, 'active'); + }); + expect(states()).toEqual(['inactive', 'active']); + }); + + it('두 layer와 face가 pointer-events, class, style, DOM 순서를 동일하게 유지한다', () => { + const position = { + id: 'counter-dom-contract', + dx: 4, + dy: 8, + width: 60, + height: 40, + className: 'counter-face-class', + counter: outsideCounter, + }; + + act(() => { + root.render( + <> + + + , + ); + }); + + const layers = [...container.children] as HTMLElement[]; + expect(layers).toHaveLength(2); + expect(layers.map((layer) => layer.className)).toEqual([ + 'absolute inset-0 pointer-events-none', + 'absolute inset-0 pointer-events-none', + ]); + expect(layers.map((layer) => layer.getAttribute('style'))).toEqual([ + 'z-index: var(--z-canvas-counter-preview);', + 'z-index: var(--z-canvas-counter-preview);', + ]); + + const faces = layers.map((layer) => layer.firstElementChild as HTMLElement); + expect(faces.map((face) => face.className)).toEqual([ + 'pointer-events-none counter-face-class', + 'pointer-events-none counter-face-class', + ]); + expect(faces[0].getAttribute('style')).toBe(faces[1].getAttribute('style')); + expect( + faces.map((face) => + [...face.children].map((child) => child.tagName).join(','), + ), + ).toEqual(['SPAN', 'SPAN']); + }); }); diff --git a/src/renderer/components/overlay/counters/counterLayers.test.tsx b/src/renderer/components/overlay/counters/counterLayers.test.tsx index 96aadb1ba..e73bdb041 100644 --- a/src/renderer/components/overlay/counters/counterLayers.test.tsx +++ b/src/renderer/components/overlay/counters/counterLayers.test.tsx @@ -135,4 +135,27 @@ describe('StatCounterLayer 게이팅', () => { expect(commits).toBe(1); expect(container.querySelector('span.counter')?.textContent).toBe('9'); }); + + it('outside stat 실시간 값은 기존 bitwise 정수 정규화를 유지한다', () => { + act(() => + applyStatsSnapshot({ + kps: 0, + kpsAvg: 0, + kpsMax: 0, + total: 9.75, + }), + ); + render(); + expect(container.querySelector('span.counter')?.textContent).toBe('9'); + + act(() => + applyStatsSnapshot({ + kps: 0, + kpsAvg: 0, + kpsMax: 0, + total: -2.75, + }), + ); + expect(container.querySelector('span.counter')?.textContent).toBe('-2'); + }); }); From 37a2ec71dd17d24fc777e7b92bce089d9b8f41dd Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 18:00:08 +0900 Subject: [PATCH 174/245] =?UTF-8?q?refactor:=20=ED=82=A4=20=ED=86=B5?= =?UTF-8?q?=EA=B3=84=20=EC=B9=B4=EC=9A=B4=ED=84=B0=20=ED=94=84=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EA=B3=B5=EC=9A=A9=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/Grid/layers/CounterPreviewLayer.tsx | 100 ++++++++++++++++++ .../Grid/layers/KeyCounterPreviewLayer.tsx | 84 ++------------- .../main/Grid/layers/StatCounterLayer.tsx | 86 ++------------- 3 files changed, 119 insertions(+), 151 deletions(-) create mode 100644 src/renderer/components/main/Grid/layers/CounterPreviewLayer.tsx diff --git a/src/renderer/components/main/Grid/layers/CounterPreviewLayer.tsx b/src/renderer/components/main/Grid/layers/CounterPreviewLayer.tsx new file mode 100644 index 000000000..8be77fd14 --- /dev/null +++ b/src/renderer/components/main/Grid/layers/CounterPreviewLayer.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { useCounterSettings } from '@hooks/overlay/useCounterSettings'; +import { useGradientPreviewSession } from '@stores/grid/useGradientEditStore'; +import { useEditStatePreviewActive } from '@stores/grid/useEditStatePreviewStore'; +import type { SelectedElement } from '@stores/grid/useGridSelectionStore'; +import CounterPreviewBody, { + type CounterPreviewPosition, +} from './CounterPreviewBody'; + +export type { CounterPreviewPosition } from './CounterPreviewBody'; + +type CounterPreviewKind = 'key' | 'stat'; + +interface CounterPreviewFaceProps { + kind: CounterPreviewKind; + position: CounterPreviewPosition; + value: number; + isInBatchSelection: boolean; +} + +interface CounterPreviewLayerProps { + kind: CounterPreviewKind; + positions: CounterPreviewPosition[]; + value: number; + selectedElements: SelectedElement[]; +} + +// 프리뷰 합성은 바뀐 position만 새 객체로 만든다. leaf memo 경계에서 나머지 +// 항목의 설정 정규화·스타일 합성·DOM 조정을 차단하고, 편집 세션은 leaf가 직접 +// 구독해 상태 전환을 놓치지 않는다. +const CounterPreviewFace = React.memo(function CounterPreviewFace({ + kind, + position, + value, + isInBatchSelection, +}: CounterPreviewFaceProps) { + const counterSettings = useCounterSettings(position?.counter); + // 편집 세션 일시 페인트 - 다른 표면을 편집해도 같은 대기/입력 상태 유지 + const previewSession = useGradientPreviewSession( + kind, + position.id, + isInBatchSelection, + ); + // 상태 프리뷰는 전용 스토어가 유일한 원천 (세션은 spec 페인트 전용) + const previewActive = useEditStatePreviewActive( + kind, + position.id, + isInBatchSelection, + ); + + if (!counterSettings.enabled || counterSettings.placement !== 'outside') { + return null; + } + + return ( + + ); +}); + +const CounterPreviewLayer = ({ + kind, + positions, + value, + selectedElements, +}: CounterPreviewLayerProps) => { + if (!positions?.length) { + return null; + } + + return ( +
+ {positions.map((position) => { + if (!position) return null; + if (position.hidden) return null; + return ( + element.type === kind && element.id === position.id, + )} + /> + ); + })} +
+ ); +}; + +export default CounterPreviewLayer; diff --git a/src/renderer/components/main/Grid/layers/KeyCounterPreviewLayer.tsx b/src/renderer/components/main/Grid/layers/KeyCounterPreviewLayer.tsx index c353a11ca..da8b8581c 100644 --- a/src/renderer/components/main/Grid/layers/KeyCounterPreviewLayer.tsx +++ b/src/renderer/components/main/Grid/layers/KeyCounterPreviewLayer.tsx @@ -1,17 +1,7 @@ -import React from 'react'; -import { useCounterSettings } from '@hooks/overlay/useCounterSettings'; -import { useGradientPreviewSession } from '@stores/grid/useGradientEditStore'; -import { useEditStatePreviewActive } from '@stores/grid/useEditStatePreviewStore'; import type { SelectedElement } from '@stores/grid/useGridSelectionStore'; -import CounterPreviewBody, { +import CounterPreviewLayer, { type CounterPreviewPosition, -} from './CounterPreviewBody'; - -interface KeyCounterPreviewProps { - position: CounterPreviewPosition; - previewValue?: number; - isInBatchSelection?: boolean; -} +} from './CounterPreviewLayer'; interface KeyCounterPreviewLayerProps { positions: CounterPreviewPosition[]; @@ -19,76 +9,18 @@ interface KeyCounterPreviewLayerProps { selectedElements?: SelectedElement[]; } -// 프리뷰로 위치 하나가 바뀌면 컴파일러가 map 전체를 한 단위로 캐시하고 있어 -// 목록이 통째로 다시 돈다. leaf를 memo로 격리해 안 바뀐 항목의 리렌더 자체를 -// 걸러낸다 (설정 정규화는 useCounterSettings의 identity 캐시가 재사용하지만 -// 스타일 합성·DOM 재조정은 memo 없이는 매번 돈다). -// 위치 객체는 프리뷰 합성에서 바뀐 대상만 새로 만들어지므로 얕은 비교로 충분하다. -// 그라디언트 편집 세션은 leaf가 직접 구독하므로 memo가 막지 않는다 -const KeyCounterPreview = React.memo(function KeyCounterPreview({ - position, - previewValue = 0, - isInBatchSelection = false, -}: KeyCounterPreviewProps) { - const counterSettings = useCounterSettings(position?.counter); - // 편집 세션 일시 페인트 - 다른 표면을 편집해도 같은 대기/입력 상태 유지 - const previewSession = useGradientPreviewSession( - 'key', - position.id, - isInBatchSelection, - ); - // 상태 프리뷰는 전용 스토어가 유일한 원천 (세션은 spec 페인트 전용) - const previewActive = useEditStatePreviewActive( - 'key', - position.id, - isInBatchSelection, - ); - - // 개별 키의 카운터가 비활성화되었거나 outside가 아니면 렌더링하지 않음 - if (!counterSettings.enabled || counterSettings.placement !== 'outside') { - return null; - } - - return ( - - ); -}); - const KeyCounterPreviewLayer = ({ positions, previewValue = 0, selectedElements = [], }: KeyCounterPreviewLayerProps) => { - if (!positions?.length) { - return null; - } - return ( -
- {positions.map((position) => { - if (!position) return null; - if (position.hidden) return null; - return ( - element.type === 'key' && element.id === position.id, - )} - /> - ); - })} -
+ ); }; diff --git a/src/renderer/components/main/Grid/layers/StatCounterLayer.tsx b/src/renderer/components/main/Grid/layers/StatCounterLayer.tsx index ed7f30c83..3bcc09ad9 100644 --- a/src/renderer/components/main/Grid/layers/StatCounterLayer.tsx +++ b/src/renderer/components/main/Grid/layers/StatCounterLayer.tsx @@ -1,92 +1,28 @@ -import React from 'react'; -import { useCounterSettings } from '@hooks/overlay/useCounterSettings'; -import { useGradientPreviewSession } from '@stores/grid/useGradientEditStore'; -import { useEditStatePreviewActive } from '@stores/grid/useEditStatePreviewStore'; import type { SelectedElement } from '@stores/grid/useGridSelectionStore'; -import CounterPreviewBody, { +import CounterPreviewLayer, { type CounterPreviewPosition, -} from './CounterPreviewBody'; - -interface StatCounterProps { - position: CounterPreviewPosition; - previewValue?: number; - isInBatchSelection?: boolean; -} +} from './CounterPreviewLayer'; interface StatCounterLayerProps { positions: CounterPreviewPosition[]; selectedElements?: SelectedElement[]; } -// 프리뷰로 위치 하나가 바뀌면 컴파일러가 map 전체를 한 단위로 캐시하고 있어 -// 목록이 통째로 다시 돈다. 이때 leaf를 memo로 격리하지 않으면 안 바뀐 항목까지 -// Zod 정규화를 다시 돌린다 - useCounterSettings는 use 접두사라 컴파일러가 -// 훅으로 보고 절대 메모하지 않는다. -// 위치 객체는 프리뷰 합성에서 바뀐 대상만 새로 만들어지므로 얕은 비교로 충분하다. -// 그라디언트 편집 세션은 leaf가 직접 구독하므로 memo가 막지 않는다 -const StatCounter = React.memo(function StatCounter({ - position, - previewValue = 0, - isInBatchSelection = false, -}: StatCounterProps) { - const counterSettings = useCounterSettings(position?.counter); - // 편집 세션 일시 페인트 - 다른 표면을 편집해도 같은 대기/입력 상태 유지 - const previewSession = useGradientPreviewSession( - 'stat', - position.id, - isInBatchSelection, - ); - // 상태 프리뷰는 전용 스토어가 유일한 원천 (세션은 spec 페인트 전용) - const previewActive = useEditStatePreviewActive( - 'stat', - position.id, - isInBatchSelection, - ); - - if (!counterSettings.enabled || counterSettings.placement !== 'outside') { - return null; - } - - return ( - - ); -}); +const normalizeStatCounterPreviewValue = ( + value: number | null | undefined, +): number => (value ?? 0) | 0; const StatCounterLayer = ({ positions, selectedElements = [], }: StatCounterLayerProps) => { - if (!positions?.length) { - return null; - } - return ( -
- {positions.map((position) => { - if (!position) return null; - if (position.hidden) return null; - return ( - - element.type === 'stat' && element.id === position.id, - )} - /> - ); - })} -
+ ); }; From d8cb44f07616f8e0966eb2a0ff3397f1d49c6d99 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 18:00:29 +0900 Subject: [PATCH 175/245] =?UTF-8?q?test:=20OBS=20=EB=AF=B8=EB=94=94?= =?UTF-8?q?=EC=96=B4=20=EC=9A=94=EC=B2=AD=20=EA=B2=BD=EA=B3=84=20=EA=B3=84?= =?UTF-8?q?=EC=95=BD=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/services/obs_bridge/tests.rs | 95 ++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/src-tauri/src/services/obs_bridge/tests.rs b/src-tauri/src/services/obs_bridge/tests.rs index 0768e2652..dc256db4f 100644 --- a/src-tauri/src/services/obs_bridge/tests.rs +++ b/src-tauri/src/services/obs_bridge/tests.rs @@ -502,6 +502,101 @@ fn local_machine_ip_rejects_foreign_addresses() { } } +async fn media_response_without_app_handle(expected_token: &str, rest: &str) -> Vec { + let bridge = ObsBridgeService::new("test"); + bridge.set_token(expected_token.to_string()); + let (mut client, mut server) = tcp_pair().await; + + bridge.handle_media_request(&mut server, rest).await; + drop(server); + + let mut response = Vec::new(); + client + .read_to_end(&mut response) + .await + .expect("media 응답 수신 실패"); + response +} + +#[tokio::test] +async fn media_token_query_uses_the_first_exact_token_parameter() { + for (rest, expected_status) in [ + ("*?token=secret", "400 Bad Request"), + ("*?other=value&token=secret", "400 Bad Request"), + ("*?token=wrong&token=secret", "403 Forbidden"), + ("*?not-token=secret", "403 Forbidden"), + ("*?token=secret%20", "403 Forbidden"), + ("*?TOKEN=secret", "403 Forbidden"), + ] { + let response = media_response_without_app_handle("secret", rest).await; + assert!( + response.starts_with(format!("HTTP/1.1 {expected_status}").as_bytes()), + "rest={rest}, response={}", + String::from_utf8_lossy(&response) + ); + } + + let response = media_response_without_app_handle("", "*").await; + assert!(response.starts_with(b"HTTP/1.1 400 Bad Request")); +} + +#[tokio::test] +async fn media_path_decode_preserves_malformed_input_status_order() { + use base64::Engine as _; + + let invalid_utf8 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0xff]); + let relative_path = + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode("images/preview.PNG"); + let absolute_path = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode( + std::env::temp_dir() + .join("preview.PNG") + .to_string_lossy() + .as_bytes(), + ); + + for (rest, expected_status) in [ + ("%GG", "400 Bad Request"), + ("%", "400 Bad Request"), + (invalid_utf8.as_str(), "400 Bad Request"), + (relative_path.as_str(), "403 Forbidden"), + (absolute_path.as_str(), "403 Forbidden"), + ] { + let response = media_response_without_app_handle("", rest).await; + assert!( + response.starts_with(format!("HTTP/1.1 {expected_status}").as_bytes()), + "rest={rest}, response={}", + String::from_utf8_lossy(&response) + ); + } +} + +#[test] +fn media_percent_decoding_preserves_lossy_and_incomplete_sequences() { + assert_eq!(percent_decode("abc%2Fdef%20ghi"), "abc/def ghi"); + assert_eq!(percent_decode("%2f%41"), "/A"); + assert_eq!(percent_decode("%GG%2"), "%GG%2"); + assert_eq!(percent_decode("%FF"), "\u{fffd}"); + assert_eq!(percent_decode("plus+sign"), "plus+sign"); +} + +#[test] +fn media_mime_mapping_is_case_insensitive_and_preserves_fallbacks() { + for (path, expected) in [ + ("index.HTML", "text/html; charset=utf-8"), + ("bundle.MJS", "application/javascript; charset=utf-8"), + ("theme.Css", "text/css; charset=utf-8"), + ("data.JSON", "application/json; charset=utf-8"), + ("image.JPEG", "image/jpeg"), + ("clip.OgG", "video/ogg"), + ("font.WOFF2", "font/woff2"), + ("module.WASM", "application/wasm"), + ("no-extension", "application/octet-stream"), + ("archive.exe", "application/octet-stream"), + ] { + assert_eq!(guess_mime(path), expected, "path={path}"); + } +} + // dev 리다이렉트(token 쿼리 포함)는 이 머신 자신의 peer에만 허용 — // 같은 PC가 자기 LAN IP로 접속하는 URL 복사 경로는 유지되어야 함 #[tokio::test] From c314cdded60a640a8f2520f06ff8b40d618daaa3 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 18:00:38 +0900 Subject: [PATCH 176/245] =?UTF-8?q?refactor:=20OBS=20=EB=AF=B8=EB=94=94?= =?UTF-8?q?=EC=96=B4=20=EC=84=9C=EB=B9=99=20=EA=B2=BD=EA=B3=84=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/services/obs_bridge.rs | 199 ++------------------- src-tauri/src/services/obs_bridge/media.rs | 194 ++++++++++++++++++++ 2 files changed, 209 insertions(+), 184 deletions(-) create mode 100644 src-tauri/src/services/obs_bridge/media.rs diff --git a/src-tauri/src/services/obs_bridge.rs b/src-tauri/src/services/obs_bridge.rs index 23f585778..90ed8ce7f 100644 --- a/src-tauri/src/services/obs_bridge.rs +++ b/src-tauri/src/services/obs_bridge.rs @@ -1,5 +1,4 @@ use std::net::SocketAddr; -use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::Arc; use std::time::Duration; @@ -31,8 +30,12 @@ use crate::models::obs::{ OBS_PROTOCOL_VERSION, }; +mod media; mod transport; +#[cfg(test)] +use media::{guess_mime, percent_decode}; + use transport::{ bind_address, has_allowed_http_host, http_header_end, is_local_machine_ip, is_websocket_upgrade_request, read_http_request_headers, validate_websocket_request, @@ -908,191 +911,19 @@ impl ObsBridgeService { /// /media/?token=xxx — 사용자 로컬 미디어 파일 서빙 async fn handle_media_request(&self, stream: &mut TcpStream, rest: &str) { - use base64::Engine; - - // 경로와 쿼리 분리: "base64path?token=xxx" - let (encoded, query) = rest.split_once('?').unwrap_or((rest, "")); - - // 토큰 검증 - let expected_token = self.session_token.read().clone(); - if !expected_token.is_empty() { - let client_token = query - .split('&') - .find_map(|pair| pair.strip_prefix("token=")) - .unwrap_or(""); - if client_token != expected_token { - let _ = stream - .write_all( - b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", - ) - .await; - return; - } - } - - // URL 디코딩 (%2F 등) + base64url → 절대 파일 경로 - let decoded_url = percent_decode(encoded); - let file_path = match base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(decoded_url.as_bytes()) - { - Ok(bytes) => match String::from_utf8(bytes) { - Ok(path) => PathBuf::from(path), - Err(_) => { - let _ = stream - .write_all( - b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", - ) - .await; - return; - } + media::handle_media_request( + stream, + rest, + || self.session_token.read().clone(), + || { + self.app_handle + .read() + .clone() + .and_then(|app| app.path().app_data_dir().ok()) }, - Err(_) => { - let _ = stream - .write_all( - b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", - ) - .await; - return; - } - }; - - if !file_path.is_absolute() { - write_empty_http_response(stream, "403 Forbidden").await; - return; - } - - let app_data_dir = self - .app_handle - .read() - .clone() - .and_then(|app| app.path().app_data_dir().ok()); - let Some(app_data_dir) = app_data_dir else { - write_empty_http_response(stream, "403 Forbidden").await; - return; - }; - let canonical_app_data = match tokio::fs::canonicalize(app_data_dir).await { - Ok(path) => path, - Err(error) => { - log::warn!("[ObsBridge] app data 경로 확인 실패: {error}"); - write_empty_http_response(stream, "403 Forbidden").await; - return; - } - }; - let canonical_file_path = match tokio::fs::canonicalize(&file_path).await { - Ok(path) => path, - Err(_) => { - write_empty_http_response(stream, "404 Not Found").await; - return; - } - }; - if !canonical_file_path.starts_with(&canonical_app_data) { - log::warn!( - "[ObsBridge] app data 밖 media 요청 차단: {}", - file_path.display() - ); - write_empty_http_response(stream, "403 Forbidden").await; - return; - } - - // 허용 확장자 화이트리스트 (미디어/폰트 파일만) - let ext = canonical_file_path - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("") - .to_ascii_lowercase(); - if !matches!( - ext.as_str(), - "png" - | "jpg" - | "jpeg" - | "gif" - | "webp" - | "svg" - | "mp4" - | "webm" - | "ogg" - | "woff" - | "woff2" - | "ttf" - | "otf" - ) { - let _ = stream - .write_all( - b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", - ) - .await; - return; - } - - // 파일 읽기 및 서빙 - match tokio::fs::read(&canonical_file_path).await { - Ok(content) => { - let mime = guess_mime(&canonical_file_path.to_string_lossy()); - let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: {mime}\r\nContent-Length: {}\r\nCache-Control: max-age=3600\r\nConnection: close\r\n\r\n", - content.len() - ); - let _ = stream.write_all(response.as_bytes()).await; - let _ = stream.write_all(&content).await; - } - Err(_) => { - let _ = stream - .write_all( - b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", - ) - .await; - } - } - } -} - -/// 파일 확장자로 MIME 타입 추정 -fn guess_mime(path: &str) -> &'static str { - match path - .rsplit('.') - .next() - .unwrap_or("") - .to_ascii_lowercase() - .as_str() - { - "html" | "htm" => "text/html; charset=utf-8", - "js" | "mjs" => "application/javascript; charset=utf-8", - "css" => "text/css; charset=utf-8", - "json" => "application/json; charset=utf-8", - "png" => "image/png", - "jpg" | "jpeg" => "image/jpeg", - "gif" => "image/gif", - "webp" => "image/webp", - "svg" => "image/svg+xml", - "mp4" => "video/mp4", - "webm" => "video/webm", - "ogg" => "video/ogg", - "woff2" => "font/woff2", - "woff" => "font/woff", - "ttf" => "font/ttf", - "otf" => "font/otf", - "wasm" => "application/wasm", - _ => "application/octet-stream", - } -} - -/// 간단한 percent-decoding (%XX → 바이트) -fn percent_decode(input: &str) -> String { - let mut result = Vec::with_capacity(input.len()); - let bytes = input.as_bytes(); - let mut i = 0; - while i < bytes.len() { - if bytes[i] == b'%' && i + 2 < bytes.len() { - if let Ok(byte) = u8::from_str_radix(&input[i + 1..i + 3], 16) { - result.push(byte); - i += 3; - continue; - } - } - result.push(bytes[i]); - i += 1; + ) + .await; } - String::from_utf8_lossy(&result).into_owned() } /// ObsBroadcast → JSON envelope 변환 diff --git a/src-tauri/src/services/obs_bridge/media.rs b/src-tauri/src/services/obs_bridge/media.rs new file mode 100644 index 000000000..4f937403c --- /dev/null +++ b/src-tauri/src/services/obs_bridge/media.rs @@ -0,0 +1,194 @@ +use std::path::PathBuf; + +use base64::Engine; +use tokio::io::AsyncWriteExt; +use tokio::net::TcpStream; + +use super::transport::write_empty_http_response; + +pub(super) async fn handle_media_request( + stream: &mut TcpStream, + rest: &str, + expected_token: ExpectedToken, + app_data_dir: AppDataDir, +) where + ExpectedToken: FnOnce() -> String, + AppDataDir: FnOnce() -> Option, +{ + // 경로와 쿼리 분리: "base64path?token=xxx" + let (encoded, query) = rest.split_once('?').unwrap_or((rest, "")); + + // 토큰 검증 + let expected_token = expected_token(); + if !expected_token.is_empty() { + let client_token = query + .split('&') + .find_map(|pair| pair.strip_prefix("token=")) + .unwrap_or(""); + if client_token != expected_token { + let _ = stream + .write_all( + b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await; + return; + } + } + + // URL 디코딩 (%2F 등) + base64url → 절대 파일 경로 + let decoded_url = percent_decode(encoded); + let file_path = match base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(decoded_url.as_bytes()) + { + Ok(bytes) => match String::from_utf8(bytes) { + Ok(path) => PathBuf::from(path), + Err(_) => { + let _ = stream + .write_all( + b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await; + return; + } + }, + Err(_) => { + let _ = stream + .write_all( + b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await; + return; + } + }; + + if !file_path.is_absolute() { + write_empty_http_response(stream, "403 Forbidden").await; + return; + } + + let app_data_dir = app_data_dir(); + let Some(app_data_dir) = app_data_dir else { + write_empty_http_response(stream, "403 Forbidden").await; + return; + }; + let canonical_app_data = match tokio::fs::canonicalize(app_data_dir).await { + Ok(path) => path, + Err(error) => { + log::warn!("[ObsBridge] app data 경로 확인 실패: {error}"); + write_empty_http_response(stream, "403 Forbidden").await; + return; + } + }; + let canonical_file_path = match tokio::fs::canonicalize(&file_path).await { + Ok(path) => path, + Err(_) => { + write_empty_http_response(stream, "404 Not Found").await; + return; + } + }; + if !canonical_file_path.starts_with(&canonical_app_data) { + log::warn!( + "[ObsBridge] app data 밖 media 요청 차단: {}", + file_path.display() + ); + write_empty_http_response(stream, "403 Forbidden").await; + return; + } + + // 허용 확장자 화이트리스트 (미디어/폰트 파일만) + let ext = canonical_file_path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + if !matches!( + ext.as_str(), + "png" + | "jpg" + | "jpeg" + | "gif" + | "webp" + | "svg" + | "mp4" + | "webm" + | "ogg" + | "woff" + | "woff2" + | "ttf" + | "otf" + ) { + let _ = stream + .write_all(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .await; + return; + } + + // 파일 읽기 및 서빙 + match tokio::fs::read(&canonical_file_path).await { + Ok(content) => { + let mime = guess_mime(&canonical_file_path.to_string_lossy()); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: {mime}\r\nContent-Length: {}\r\nCache-Control: max-age=3600\r\nConnection: close\r\n\r\n", + content.len() + ); + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.write_all(&content).await; + } + Err(_) => { + let _ = stream + .write_all( + b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await; + } + } +} + +/// 파일 확장자로 MIME 타입 추정 +pub(super) fn guess_mime(path: &str) -> &'static str { + match path + .rsplit('.') + .next() + .unwrap_or("") + .to_ascii_lowercase() + .as_str() + { + "html" | "htm" => "text/html; charset=utf-8", + "js" | "mjs" => "application/javascript; charset=utf-8", + "css" => "text/css; charset=utf-8", + "json" => "application/json; charset=utf-8", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "svg" => "image/svg+xml", + "mp4" => "video/mp4", + "webm" => "video/webm", + "ogg" => "video/ogg", + "woff2" => "font/woff2", + "woff" => "font/woff", + "ttf" => "font/ttf", + "otf" => "font/otf", + "wasm" => "application/wasm", + _ => "application/octet-stream", + } +} + +/// 간단한 percent-decoding (%XX → 바이트) +pub(super) fn percent_decode(input: &str) -> String { + let mut result = Vec::with_capacity(input.len()); + let bytes = input.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + if let Ok(byte) = u8::from_str_radix(&input[i + 1..i + 3], 16) { + result.push(byte); + i += 3; + continue; + } + } + result.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&result).into_owned() +} From f31810298a38196efdd21bdd0bac2a2518463e12 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 18:01:20 +0900 Subject: [PATCH 177/245] =?UTF-8?q?test:=20OBS=20=EB=AF=B8=EB=94=94?= =?UTF-8?q?=EC=96=B4=20=ED=8C=8C=EC=9D=BC=20=EC=A0=95=EC=B1=85=20=EA=B3=84?= =?UTF-8?q?=EC=95=BD=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/services/obs_bridge/media.rs | 3 + .../src/services/obs_bridge/media/tests.rs | 254 ++++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 src-tauri/src/services/obs_bridge/media/tests.rs diff --git a/src-tauri/src/services/obs_bridge/media.rs b/src-tauri/src/services/obs_bridge/media.rs index 4f937403c..2fe3eff46 100644 --- a/src-tauri/src/services/obs_bridge/media.rs +++ b/src-tauri/src/services/obs_bridge/media.rs @@ -192,3 +192,6 @@ pub(super) fn percent_decode(input: &str) -> String { } String::from_utf8_lossy(&result).into_owned() } + +#[cfg(test)] +mod tests; diff --git a/src-tauri/src/services/obs_bridge/media/tests.rs b/src-tauri/src/services/obs_bridge/media/tests.rs new file mode 100644 index 000000000..71230d3ea --- /dev/null +++ b/src-tauri/src/services/obs_bridge/media/tests.rs @@ -0,0 +1,254 @@ +use std::cell::Cell; +use std::path::{Path, PathBuf}; + +use base64::Engine as _; +use tokio::io::AsyncReadExt; +use tokio::net::{TcpListener, TcpStream}; + +use super::*; + +async fn tcp_pair() -> (TcpStream, TcpStream) { + let listener = TcpListener::bind(("127.0.0.1", 0)) + .await + .expect("TCP listener 바인딩 실패"); + let address = listener.local_addr().expect("TCP listener 주소 조회 실패"); + let (client, server) = tokio::join!(TcpStream::connect(address), listener.accept()); + ( + client.expect("TCP client 연결 실패"), + server.expect("TCP server accept 실패").0, + ) +} + +fn encode_path(path: &Path) -> String { + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(path.to_string_lossy().as_bytes()) +} + +async fn media_response( + expected_token: &str, + rest: &str, + app_data_dir: Option, +) -> Vec { + let (mut client, mut server) = tcp_pair().await; + + handle_media_request( + &mut server, + rest, + || expected_token.to_string(), + || app_data_dir, + ) + .await; + drop(server); + + let mut response = Vec::new(); + client + .read_to_end(&mut response) + .await + .expect("media 응답 수신 실패"); + response +} + +fn empty_response(status: &str) -> Vec { + format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").into_bytes() +} + +#[tokio::test] +async fn success_response_preserves_mime_cache_header_and_body_order() { + let temp = tempfile::tempdir().expect("임시 경로 생성 실패"); + let app_data_dir = temp.path().join("app-data"); + let file_path = app_data_dir.join("images/Preview.SvG"); + tokio::fs::create_dir_all(file_path.parent().expect("상위 경로 없음")) + .await + .expect("media 디렉터리 생성 실패"); + let content = b""; + tokio::fs::write(&file_path, content) + .await + .expect("media 파일 생성 실패"); + + let encoded = encode_path(&file_path); + let response = media_response( + "secret", + &format!("{encoded}?other=value&token=secret"), + Some(app_data_dir), + ) + .await; + let mut expected = format!( + "HTTP/1.1 200 OK\r\nContent-Type: image/svg+xml\r\nContent-Length: {}\r\nCache-Control: max-age=3600\r\nConnection: close\r\n\r\n", + content.len() + ) + .into_bytes(); + expected.extend_from_slice(content); + + assert_eq!(response, expected); +} + +#[tokio::test] +async fn canonical_extension_allowlist_is_case_insensitive_and_fail_closed() { + let temp = tempfile::tempdir().expect("임시 경로 생성 실패"); + let app_data_dir = temp.path().join("app-data"); + tokio::fs::create_dir_all(&app_data_dir) + .await + .expect("app data 생성 실패"); + + for extension in [ + "PNG", "jpg", "jpeg", "gif", "webp", "svg", "mp4", "webm", "ogg", "woff", "WOFF2", "ttf", + "otf", + ] { + let file_path = app_data_dir.join(format!("asset.{extension}")); + tokio::fs::write(&file_path, b"x") + .await + .expect("허용 media 파일 생성 실패"); + let response = + media_response("", &encode_path(&file_path), Some(app_data_dir.clone())).await; + assert!( + response.starts_with(b"HTTP/1.1 200 OK"), + "extension={extension}, response={}", + String::from_utf8_lossy(&response) + ); + } + + for extension in ["html", "wasm", "exe", ""] { + let file_name = if extension.is_empty() { + "asset".to_string() + } else { + format!("asset.{extension}") + }; + let file_path = app_data_dir.join(file_name); + tokio::fs::write(&file_path, b"x") + .await + .expect("거부 media 파일 생성 실패"); + let response = + media_response("", &encode_path(&file_path), Some(app_data_dir.clone())).await; + assert_eq!( + response, + empty_response("403 Forbidden"), + "extension={extension}" + ); + } +} + +#[tokio::test] +async fn app_data_and_file_failures_preserve_status_mapping() { + let temp = tempfile::tempdir().expect("임시 경로 생성 실패"); + let app_data_dir = temp.path().join("app-data"); + tokio::fs::create_dir_all(&app_data_dir) + .await + .expect("app data 생성 실패"); + let missing_file = app_data_dir.join("missing.png"); + let encoded_missing_file = encode_path(&missing_file); + + assert_eq!( + media_response("", &encoded_missing_file, None).await, + empty_response("403 Forbidden") + ); + assert_eq!( + media_response( + "", + &encoded_missing_file, + Some(temp.path().join("missing-app-data")), + ) + .await, + empty_response("403 Forbidden") + ); + assert_eq!( + media_response("", &encoded_missing_file, Some(app_data_dir.clone())).await, + empty_response("404 Not Found") + ); + + let unreadable_directory = app_data_dir.join("directory.png"); + tokio::fs::create_dir(&unreadable_directory) + .await + .expect("읽기 실패용 디렉터리 생성 실패"); + assert_eq!( + media_response("", &encode_path(&unreadable_directory), Some(app_data_dir)).await, + empty_response("404 Not Found") + ); +} + +#[tokio::test] +async fn app_data_lookup_remains_after_auth_decode_and_absolute_path_gates() { + let app_data_calls = Cell::new(0); + + for (rest, expected_status) in [ + ("*?token=wrong", "403 Forbidden"), + ("*?token=secret", "400 Bad Request"), + ("aW1hZ2VzL3ByZXZpZXcucG5n?token=secret", "403 Forbidden"), + ] { + let (mut client, mut server) = tcp_pair().await; + handle_media_request( + &mut server, + rest, + || "secret".to_string(), + || { + app_data_calls.set(app_data_calls.get() + 1); + None + }, + ) + .await; + drop(server); + let mut response = Vec::new(); + client + .read_to_end(&mut response) + .await + .expect("media 응답 수신 실패"); + assert_eq!(response, empty_response(expected_status)); + assert_eq!(app_data_calls.get(), 0, "rest={rest}"); + } + + let absolute_path = std::env::temp_dir().join("preview.png"); + let (mut client, mut server) = tcp_pair().await; + handle_media_request( + &mut server, + &format!("{}?token=secret", encode_path(&absolute_path)), + || "secret".to_string(), + || { + app_data_calls.set(app_data_calls.get() + 1); + None + }, + ) + .await; + drop(server); + let mut response = Vec::new(); + client + .read_to_end(&mut response) + .await + .expect("media 응답 수신 실패"); + assert_eq!(response, empty_response("403 Forbidden")); + assert_eq!(app_data_calls.get(), 1); +} + +#[cfg(unix)] +#[tokio::test] +async fn canonical_containment_follows_symlinks_before_authorization() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().expect("임시 경로 생성 실패"); + let app_data_dir = temp.path().join("app-data"); + let outside_dir = temp.path().join("outside"); + tokio::fs::create_dir_all(&app_data_dir) + .await + .expect("app data 생성 실패"); + tokio::fs::create_dir_all(&outside_dir) + .await + .expect("외부 경로 생성 실패"); + + let outside_file = outside_dir.join("outside.png"); + tokio::fs::write(&outside_file, b"outside") + .await + .expect("외부 파일 생성 실패"); + let inside_link = app_data_dir.join("inside-link.png"); + symlink(&outside_file, &inside_link).expect("외부 파일 symlink 생성 실패"); + assert_eq!( + media_response("", &encode_path(&inside_link), Some(app_data_dir.clone())).await, + empty_response("403 Forbidden") + ); + + let inside_file = app_data_dir.join("inside.png"); + tokio::fs::write(&inside_file, b"inside") + .await + .expect("내부 파일 생성 실패"); + let outside_link = outside_dir.join("outside-link.png"); + symlink(&inside_file, &outside_link).expect("내부 파일 symlink 생성 실패"); + let response = media_response("", &encode_path(&outside_link), Some(app_data_dir)).await; + assert!(response.starts_with(b"HTTP/1.1 200 OK")); + assert!(response.ends_with(b"inside")); +} From a4d2523a11f3f3e5f3173c8d3afe9d439e58691f Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 18:05:20 +0900 Subject: [PATCH 178/245] =?UTF-8?q?test:=20=ED=82=A4=20=EC=8A=AC=EB=A1=AF?= =?UTF-8?q?=20=EC=A0=95=EA=B7=9C=ED=99=94=20=EA=B3=84=EC=95=BD=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/models/tests.rs | 97 +++++++++++++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/models/tests.rs b/src-tauri/src/models/tests.rs index ab30164ab..c20a83d9f 100644 --- a/src-tauri/src/models/tests.rs +++ b/src-tauri/src/models/tests.rs @@ -1,9 +1,10 @@ use super::{ - compact_canonical_rgba, note_border_representative_hex, scrub_removed_text_outline_fields, - FadePosition, GradientSpec, GraphPosition, GraphStatType, GraphType, ImageTransform, - KeyCounterAlign, KeyCounterAlignMode, KeyCounterColor, KeyCounterPlacement, KeyCounterSettings, - KeyMappings, KeyPosition, KeySlot, KnobPosition, NoteAlignment, NoteColor, NoteSettings, - SlotMatch, StatPosition, StatType, MAX_SLOT_KEYS, POSITION_COLLECTION_FIELDS, + compact_canonical_rgba, key_mappings_contain_multi, normalize_key_mappings, normalize_key_slot, + note_border_representative_hex, scrub_removed_text_outline_fields, FadePosition, GradientSpec, + GraphPosition, GraphStatType, GraphType, ImageTransform, KeyCounterAlign, KeyCounterAlignMode, + KeyCounterColor, KeyCounterPlacement, KeyCounterSettings, KeyMappings, KeyPosition, KeySlot, + KnobPosition, NoteAlignment, NoteColor, NoteSettings, SlotMatch, StatPosition, StatType, + MAX_SLOT_KEYS, POSITION_COLLECTION_FIELDS, }; use serde::Deserialize; @@ -325,6 +326,92 @@ fn malformed_key_slots_normalize_without_deserialization_failure() { } } +#[test] +fn key_slot_wire_order_and_invalid_match_fallback_are_stable() { + let slot = KeySlot::Multi { + keys: vec!["A".to_string(), "B".to_string()], + match_mode: SlotMatch::Any, + }; + assert_eq!( + serde_json::to_string(&slot).unwrap(), + r#"{"keys":["A","B"],"match":"any"}"# + ); + + for raw in [ + serde_json::json!({ "keys": ["A", "B"], "match": "ALL" }), + serde_json::json!({ "keys": ["A", "B"], "match": null }), + serde_json::json!({ "keys": ["A", "B"] }), + ] { + assert_eq!(normalize_key_slot(raw.clone()), KeySlot::default()); + assert_eq!( + serde_json::from_value::(raw).unwrap(), + KeySlot::default() + ); + } + + for key in ["A+B", "A|B", "+"] { + let slot: KeySlot = serde_json::from_value(serde_json::json!(key)).unwrap(); + assert_eq!(slot, KeySlot::Single(key.to_string())); + assert_eq!(slot.canonical(), key); + } +} + +#[test] +fn key_mapping_normalization_preserves_first_seen_members_and_helper_semantics() { + let mut mappings = KeyMappings::from([( + "mode".to_string(), + vec![ + KeySlot::Multi { + keys: vec![ + "A".to_string(), + String::new(), + "A".to_string(), + "B+C".to_string(), + "B".to_string(), + "C|D".to_string(), + "C".to_string(), + ], + match_mode: SlotMatch::Any, + }, + KeySlot::Multi { + keys: vec!["ONLY".to_string(), "ONLY".to_string()], + match_mode: SlotMatch::All, + }, + KeySlot::Multi { + keys: Vec::new(), + match_mode: SlotMatch::Any, + }, + KeySlot::Single("A+B".to_string()), + ], + )]); + + assert!(key_mappings_contain_multi(&mappings)); + normalize_key_mappings(&mut mappings); + + let slots = &mappings["mode"]; + assert_eq!( + slots, + &[ + KeySlot::Multi { + keys: vec!["A".to_string(), "B".to_string(), "C".to_string()], + match_mode: SlotMatch::Any, + }, + KeySlot::Single("ONLY".to_string()), + KeySlot::default(), + KeySlot::Single("A+B".to_string()), + ] + ); + assert_eq!( + slots[0].members().map(String::as_str).collect::>(), + ["A", "B", "C"] + ); + assert_eq!(slots[0].canonical(), "A|B|C"); + assert!(slots[0].is_multi()); + assert!(!slots[1].is_multi()); + assert!(slots[2].is_unassigned()); + assert!(key_mappings_contain_multi(&mappings)); +} + #[test] fn stat_type_wire_values_round_trip() { for (stat_type, wire_value) in [ From 8e1b366cb7f9cf457cde6ce976fceb796ce1b4d7 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 18:05:20 +0900 Subject: [PATCH 179/245] =?UTF-8?q?refactor:=20=ED=82=A4=20=EC=8A=AC?= =?UTF-8?q?=EB=A1=AF=20=EB=AA=A8=EB=8D=B8=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/models/key_slot.rs | 163 ++++++++++++++++++++++++++++++ src-tauri/src/models/mod.rs | 167 ++----------------------------- 2 files changed, 169 insertions(+), 161 deletions(-) create mode 100644 src-tauri/src/models/key_slot.rs diff --git a/src-tauri/src/models/key_slot.rs b/src-tauri/src/models/key_slot.rs new file mode 100644 index 000000000..3e750bc89 --- /dev/null +++ b/src-tauri/src/models/key_slot.rs @@ -0,0 +1,163 @@ +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; + +pub const MAX_SLOT_KEYS: usize = 8; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SlotMatch { + All, + Any, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged, from = "RawKeySlot")] +pub enum KeySlot { + Single(String), + Multi { + keys: Vec, + #[serde(rename = "match")] + match_mode: SlotMatch, + }, +} + +#[derive(Deserialize)] +#[serde(transparent)] +struct RawKeySlot(serde_json::Value); + +impl From for KeySlot { + fn from(raw: RawKeySlot) -> Self { + normalize_key_slot(raw.0) + } +} + +impl From for KeySlot { + fn from(value: String) -> Self { + Self::Single(value) + } +} + +impl From<&str> for KeySlot { + fn from(value: &str) -> Self { + Self::Single(value.to_string()) + } +} + +impl Default for KeySlot { + fn default() -> Self { + Self::Single(String::new()) + } +} + +impl KeySlot { + pub fn canonical(&self) -> String { + match self { + Self::Single(key) => key.clone(), + Self::Multi { + keys, + match_mode: SlotMatch::All, + } => keys.join("+"), + Self::Multi { + keys, + match_mode: SlotMatch::Any, + } => keys.join("|"), + } + } + + pub fn members(&self) -> std::slice::Iter<'_, String> { + match self { + Self::Single(key) => std::slice::from_ref(key).iter(), + Self::Multi { keys, .. } => keys.iter(), + } + } + + pub fn is_unassigned(&self) -> bool { + matches!(self, Self::Single(key) if key.is_empty()) + } + + pub fn is_multi(&self) -> bool { + matches!(self, Self::Multi { .. }) + } +} + +pub fn normalize_key_slot(raw: serde_json::Value) -> KeySlot { + match raw { + serde_json::Value::String(key) => { + if (key.contains('+') && key != "+") || key.contains('|') { + log::warn!( + "[Store] Key slot string contains a reserved canonical separator and may collide visually" + ); + } + KeySlot::Single(key) + } + serde_json::Value::Object(object) => { + let has_unknown_fields = object.keys().any(|key| key != "keys" && key != "match"); + let match_mode = match object.get("match").and_then(serde_json::Value::as_str) { + Some("all") => SlotMatch::All, + Some("any") => SlotMatch::Any, + _ => { + log::warn!("[Store] Normalized an invalid multi-key slot to an unassigned key"); + return KeySlot::default(); + } + }; + + let mut changed = has_unknown_fields; + let mut seen = HashSet::new(); + let mut keys = Vec::new(); + let entries = match object.get("keys") { + Some(serde_json::Value::Array(entries)) => entries.as_slice(), + _ => { + changed = true; + &[] + } + }; + for entry in entries { + let Some(key) = entry.as_str() else { + changed = true; + continue; + }; + if key.is_empty() || key.contains('+') || key.contains('|') { + changed = true; + continue; + } + if !seen.insert(key.to_string()) { + changed = true; + continue; + } + if keys.len() == MAX_SLOT_KEYS { + changed = true; + continue; + } + keys.push(key.to_string()); + } + + let normalized = match keys.len() { + 0 => KeySlot::default(), + 1 => KeySlot::Single(keys.pop().unwrap_or_default()), + _ => KeySlot::Multi { keys, match_mode }, + }; + changed |= !normalized.is_multi(); + if changed { + log::warn!("[Store] Normalized a malformed multi-key slot"); + } + normalized + } + _ => { + log::warn!("[Store] Normalized an invalid key slot to an unassigned key"); + KeySlot::default() + } + } +} + +pub fn normalize_key_mappings(mappings: &mut HashMap>) { + for slot in mappings.values_mut().flatten() { + let raw = serde_json::to_value(&*slot).unwrap_or(serde_json::Value::Null); + *slot = normalize_key_slot(raw); + } +} + +pub fn key_mappings_contain_multi(mappings: &HashMap>) -> bool { + mappings.values().flatten().any(KeySlot::is_multi) +} + +pub type KeyMappings = HashMap>; diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 5ba6f4ba4..59430c50b 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -7,6 +7,7 @@ mod bootstrap; mod counter; mod element_positions; mod key_position_normalization; +mod key_slot; mod note_settings; mod settings; mod shortcuts; @@ -31,6 +32,10 @@ pub use element_positions::{ SHADOW_OFFSET_MAX, SHADOW_OFFSET_MIN, }; pub use gesture::*; +pub use key_slot::{ + key_mappings_contain_multi, normalize_key_mappings, normalize_key_slot, KeyMappings, KeySlot, + SlotMatch, MAX_SLOT_KEYS, +}; use note_settings::{ default_key_height, default_key_note_color, default_key_note_opacity, default_note_auto_y_correction, default_note_border_opacity, default_note_effect_enabled, @@ -50,170 +55,10 @@ pub(crate) use key_position_normalization::{ use serde::de::Error as DeError; use serde::ser::{Error as SerError, SerializeMap}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::path::Path; use uuid::Uuid; -pub const MAX_SLOT_KEYS: usize = 8; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum SlotMatch { - All, - Any, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(untagged, from = "RawKeySlot")] -pub enum KeySlot { - Single(String), - Multi { - keys: Vec, - #[serde(rename = "match")] - match_mode: SlotMatch, - }, -} - -#[derive(Deserialize)] -#[serde(transparent)] -struct RawKeySlot(serde_json::Value); - -impl From for KeySlot { - fn from(raw: RawKeySlot) -> Self { - normalize_key_slot(raw.0) - } -} - -impl From for KeySlot { - fn from(value: String) -> Self { - Self::Single(value) - } -} - -impl From<&str> for KeySlot { - fn from(value: &str) -> Self { - Self::Single(value.to_string()) - } -} - -impl Default for KeySlot { - fn default() -> Self { - Self::Single(String::new()) - } -} - -impl KeySlot { - pub fn canonical(&self) -> String { - match self { - Self::Single(key) => key.clone(), - Self::Multi { - keys, - match_mode: SlotMatch::All, - } => keys.join("+"), - Self::Multi { - keys, - match_mode: SlotMatch::Any, - } => keys.join("|"), - } - } - - pub fn members(&self) -> std::slice::Iter<'_, String> { - match self { - Self::Single(key) => std::slice::from_ref(key).iter(), - Self::Multi { keys, .. } => keys.iter(), - } - } - - pub fn is_unassigned(&self) -> bool { - matches!(self, Self::Single(key) if key.is_empty()) - } - - pub fn is_multi(&self) -> bool { - matches!(self, Self::Multi { .. }) - } -} - -pub fn normalize_key_slot(raw: serde_json::Value) -> KeySlot { - match raw { - serde_json::Value::String(key) => { - if (key.contains('+') && key != "+") || key.contains('|') { - log::warn!( - "[Store] Key slot string contains a reserved canonical separator and may collide visually" - ); - } - KeySlot::Single(key) - } - serde_json::Value::Object(object) => { - let has_unknown_fields = object.keys().any(|key| key != "keys" && key != "match"); - let match_mode = match object.get("match").and_then(serde_json::Value::as_str) { - Some("all") => SlotMatch::All, - Some("any") => SlotMatch::Any, - _ => { - log::warn!("[Store] Normalized an invalid multi-key slot to an unassigned key"); - return KeySlot::default(); - } - }; - - let mut changed = has_unknown_fields; - let mut seen = HashSet::new(); - let mut keys = Vec::new(); - let entries = match object.get("keys") { - Some(serde_json::Value::Array(entries)) => entries.as_slice(), - _ => { - changed = true; - &[] - } - }; - for entry in entries { - let Some(key) = entry.as_str() else { - changed = true; - continue; - }; - if key.is_empty() || key.contains('+') || key.contains('|') { - changed = true; - continue; - } - if !seen.insert(key.to_string()) { - changed = true; - continue; - } - if keys.len() == MAX_SLOT_KEYS { - changed = true; - continue; - } - keys.push(key.to_string()); - } - - let normalized = match keys.len() { - 0 => KeySlot::default(), - 1 => KeySlot::Single(keys.pop().unwrap_or_default()), - _ => KeySlot::Multi { keys, match_mode }, - }; - changed |= !normalized.is_multi(); - if changed { - log::warn!("[Store] Normalized a malformed multi-key slot"); - } - normalized - } - _ => { - log::warn!("[Store] Normalized an invalid key slot to an unassigned key"); - KeySlot::default() - } - } -} - -pub fn normalize_key_mappings(mappings: &mut HashMap>) { - for slot in mappings.values_mut().flatten() { - let raw = serde_json::to_value(&*slot).unwrap_or(serde_json::Value::Null); - *slot = normalize_key_slot(raw); - } -} - -pub fn key_mappings_contain_multi(mappings: &HashMap>) -> bool { - mappings.values().flatten().any(KeySlot::is_multi) -} - -pub type KeyMappings = HashMap>; pub type KeyCounters = HashMap>; const DEFAULT_GRADIENT_ANGLE: f64 = 90.0; From edf0f72965339646916d2077069fda6b789331c4 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 18:09:30 +0900 Subject: [PATCH 180/245] =?UTF-8?q?test:=20OBS=20RPC=20=EC=8A=B9=EC=9D=B8?= =?UTF-8?q?=20=EA=B2=BD=EA=B3=84=20=EA=B3=84=EC=95=BD=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/services/obs_bridge/tests.rs | 101 +++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/src-tauri/src/services/obs_bridge/tests.rs b/src-tauri/src/services/obs_bridge/tests.rs index dc256db4f..d2d7f7359 100644 --- a/src-tauri/src/services/obs_bridge/tests.rs +++ b/src-tauri/src/services/obs_bridge/tests.rs @@ -102,6 +102,107 @@ fn websocket_allowlist_uses_exact_matching() { assert_eq!(build_allowed_list().len(), ALLOWED_WS_COMMANDS.len()); } +fn invoke_test_address() -> SocketAddr { + "127.0.0.1:34891".parse().expect("테스트 주소 파싱 실패") +} + +#[test] +fn invoke_parse_failure_responds_only_for_a_string_request_id() { + let bridge = ObsBridgeService::new("test"); + let address = invoke_test_address(); + + let payload = serde_json::json!({ + "requestId": "parse-1", + "command": 42, + }); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + bridge.handle_invoke_request(&payload, &address, tx); + assert_eq!( + rx.try_recv().expect("파싱 오류 RPC 응답 누락"), + ( + "parse-1".to_string(), + Err(serde_json::json!( + "Invalid invoke_request: invalid type: integer `42`, expected a string" + )), + ) + ); + + for payload in [ + serde_json::json!({ "command": 42 }), + serde_json::json!({ "requestId": 1, "command": 42 }), + serde_json::json!({ "requestId": null, "command": 42 }), + ] { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + bridge.handle_invoke_request(&payload, &address, tx); + assert!( + matches!( + rx.try_recv(), + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) + ), + "문자열 requestId가 없으면 응답하지 않아야 함: {payload}" + ); + } +} + +#[test] +fn invoke_admission_preserves_error_bytes_and_channel_order() { + let bridge = ObsBridgeService::new("test"); + let address = invoke_test_address(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + + bridge.handle_invoke_request( + &serde_json::json!({ + "requestId": "denied", + "command": "app_bootstrap_extra", + "args": { "ignored": true }, + }), + &address, + tx.clone(), + ); + bridge.handle_invoke_request( + &serde_json::json!({ + "requestId": "allowed", + "command": "app_bootstrap", + "unknown": "ignored", + }), + &address, + tx, + ); + + assert_eq!( + rx.try_recv().expect("allowlist 거부 응답 누락"), + ( + "denied".to_string(), + Err(serde_json::json!( + "Command not allowed: app_bootstrap_extra" + )), + ) + ); + assert_eq!( + rx.try_recv().expect("AppHandle 부재 응답 누락"), + ( + "allowed".to_string(), + Err(serde_json::json!("AppHandle not available")), + ) + ); +} + +#[test] +fn invoke_response_send_is_best_effort_after_receiver_closes() { + let bridge = ObsBridgeService::new("test"); + let address = invoke_test_address(); + + for payload in [ + serde_json::json!({ "requestId": "parse", "command": 42 }), + serde_json::json!({ "requestId": "denied", "command": "not-allowed" }), + serde_json::json!({ "requestId": "no-app", "command": "settings_get" }), + ] { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + drop(rx); + bridge.handle_invoke_request(&payload, &address, tx); + } +} + #[test] fn bridge_messages_targeting_main_are_not_forwarded_to_obs() { let broadcast = serde_json::json!({ "type": "PING", "data": null }); From f5773ba44eed17a899681623f545d6b3a71f6842 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 18:09:39 +0900 Subject: [PATCH 181/245] =?UTF-8?q?refactor:=20OBS=20RPC=20=ED=88=AC?= =?UTF-8?q?=EC=98=81=20=EA=B2=BD=EA=B3=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/services/obs_bridge.rs | 144 +++++------------------ src-tauri/src/services/obs_bridge/rpc.rs | 129 ++++++++++++++++++++ 2 files changed, 161 insertions(+), 112 deletions(-) create mode 100644 src-tauri/src/services/obs_bridge/rpc.rs diff --git a/src-tauri/src/services/obs_bridge.rs b/src-tauri/src/services/obs_bridge.rs index 90ed8ce7f..2be90deac 100644 --- a/src-tauri/src/services/obs_bridge.rs +++ b/src-tauri/src/services/obs_bridge.rs @@ -6,8 +6,6 @@ use std::time::Duration; use futures_util::{SinkExt, StreamExt}; use parking_lot::RwLock; use serde_json::Value; -use tauri::ipc::{CallbackFn, InvokeBody, InvokeResponse, InvokeResponseBody}; -use tauri::webview::InvokeRequest; use tauri::{AppHandle, Manager, Wry}; #[cfg(test)] use tokio::io::AsyncReadExt; @@ -26,16 +24,19 @@ use tokio_tungstenite::{ }; use crate::models::obs::{ - make_envelope, HelloAckPayload, InvokeRequestPayload, ObsBroadcast, ObsEnvelope, ObsStatus, - OBS_PROTOCOL_VERSION, + make_envelope, HelloAckPayload, ObsBroadcast, ObsEnvelope, ObsStatus, OBS_PROTOCOL_VERSION, }; mod media; +mod rpc; mod transport; #[cfg(test)] use media::{guess_mime, percent_decode}; +#[cfg(test)] +use rpc::ALLOWED_WS_COMMANDS; +use rpc::{build_allowed_list, is_allowed_command}; use transport::{ bind_address, has_allowed_http_host, http_header_end, is_local_machine_ip, is_websocket_upgrade_request, read_http_request_headers, validate_websocket_request, @@ -44,43 +45,6 @@ use transport::{ #[cfg(test)] use transport::{http_header_values, is_allowed_host_header}; -/// OBS 클라이언트에서 실행 가능한 커맨드 목록 -const ALLOWED_WS_COMMANDS: &[&str] = &[ - "app_bootstrap", - "settings_get", - "editor_get", - "layer_groups_get", - "note_tab_get_all", - "note_tab_get", - "css_get", - "css_get_use", - "css_tab_get_all", - "css_tab_get", - "js_get", - "js_get_use", - "get_cursor_settings", - "keys_get", - "keys_get_counters", - "positions_get", - "stat_positions_get", - "graph_positions_get", - "knob_positions_get", - "custom_tabs_list", - "sound_list", - "sound_load_original", - "counter_animation_list", - "plugin_bridge_send", - "plugin_bridge_send_to", - "raw_input_subscribe", - "raw_input_unsubscribe", - "plugin_storage_get", - "plugin_storage_set", - "plugin_storage_remove", - "plugin_storage_keys", - "plugin_storage_has_data", - // 파괴적 bulk 삭제는 plugin_storage_clear와 동일하게 원격 차단 -]; - // OBS 브라우저 소스는 overlay 창을 대신한다 - main만을 향한 브릿지 메시지는 전달하지 않는다 fn is_forwarded_to_obs(event: &str, data: &Value) -> bool { if !FORWARDED_EVENTS.contains(&event) { @@ -125,17 +89,6 @@ const FORWARDED_EVENTS: &[&str] = &[ "plugin-bridge:message", ]; -fn is_allowed_command(command: &str) -> bool { - ALLOWED_WS_COMMANDS.contains(&command) -} - -fn build_allowed_list() -> Vec { - ALLOWED_WS_COMMANDS - .iter() - .map(|command| command.to_string()) - .collect() -} - /// 임베딩 에셋 조회 함수 타입 (path → Option<(bytes, mime_type)>) pub type AssetFetcher = Arc Option<(Vec, String)> + Send + Sync>; @@ -796,22 +749,18 @@ impl ObsBridgeService { } /// invoke_request 처리: webview.on_message()로 Tauri 커맨드 파이프라인에 주입 - fn handle_invoke_request( - &self, - payload: &Value, - addr: &SocketAddr, - rpc_tx: tokio::sync::mpsc::UnboundedSender<(String, Result)>, - ) { - let req: InvokeRequestPayload = match serde_json::from_value(payload.clone()) { + fn handle_invoke_request(&self, payload: &Value, addr: &SocketAddr, rpc_tx: rpc::RpcSender) { + let req = match rpc::parse_invoke_request(payload) { Ok(r) => r, Err(e) => { log::warn!("[ObsBridge] {addr}: invoke_request 파싱 실패: {e}"); // requestId를 추출 시도하여 에러 응답 전송 (파싱 실패여도 클라이언트 대기 방지) if let Some(request_id) = payload.get("requestId").and_then(|v| v.as_str()) { - let _ = rpc_tx.send(( + rpc::send_rpc_response( + &rpc_tx, request_id.to_string(), - Err(serde_json::json!(format!("Invalid invoke_request: {e}"))), - )); + Err(rpc::invalid_invoke_request_error(&e)), + ); } return; } @@ -820,13 +769,11 @@ impl ObsBridgeService { // allowlist 검사 (클라이언트 검사와 별도인 백엔드 안전망) if !is_allowed_command(&req.command) { log::debug!("[ObsBridge] {addr}: 허용되지 않은 cmd={}", req.command); - let _ = rpc_tx.send(( + rpc::send_rpc_response( + &rpc_tx, req.request_id, - Err(serde_json::json!(format!( - "Command not allowed: {}", - req.command - ))), - )); + Err(rpc::command_not_allowed_error(&req.command)), + ); return; } @@ -835,47 +782,37 @@ impl ObsBridgeService { Some(h) => h, None => { log::warn!("[ObsBridge] {addr}: AppHandle 미설정"); - let _ = rpc_tx.send(( + rpc::send_rpc_response( + &rpc_tx, req.request_id, - Err(serde_json::json!("AppHandle not available")), - )); + Err(serde_json::json!(rpc::APP_HANDLE_NOT_AVAILABLE)), + ); return; } }; // OBS 모드에서 오버레이가 destroy된 상태일 수 있으므로 main window로 fallback - let webview_window = match app_handle - .get_webview_window("overlay") - .or_else(|| app_handle.get_webview_window("main")) - { + let webview_window = match rpc::select_overlay_or_main( + || app_handle.get_webview_window("overlay"), + || app_handle.get_webview_window("main"), + ) { Some(w) => w, None => { log::warn!("[ObsBridge] {addr}: webview 없음 (overlay/main 모두)"); - let _ = rpc_tx.send(( + rpc::send_rpc_response( + &rpc_tx, req.request_id, - Err(serde_json::json!("No webview window available")), - )); + Err(serde_json::json!(rpc::NO_WEBVIEW_AVAILABLE)), + ); return; } }; // InvokeRequest 구성 - // 플랫폼별 로컬 URL (Windows: http://tauri.localhost, macOS/Linux: tauri://localhost) - let local_url = if cfg!(windows) || cfg!(target_os = "android") { - tauri::Url::parse("http://tauri.localhost").unwrap() - } else { - tauri::Url::parse("tauri://localhost").unwrap() - }; + let local_url = rpc::local_invoke_url(); let invoke_key = app_handle.invoke_key().to_string(); - let request = InvokeRequest { - cmd: req.command.clone(), - callback: CallbackFn(0), - error: CallbackFn(1), - url: local_url, - body: InvokeBody::Json(req.args), - headers: Default::default(), - invoke_key, - }; + let request = + rpc::build_invoke_request(req.command.clone(), req.args, local_url, invoke_key); let request_id = req.request_id; let cmd = req.command.clone(); @@ -884,25 +821,8 @@ impl ObsBridgeService { // OwnedInvokeResponder: 응답을 rpc_tx 채널로 전송 let responder: Box> = Box::new(move |_webview, _cmd, response, _callback, _error| { - let result = match response { - InvokeResponse::Ok(body) => { - let value = match body { - InvokeResponseBody::Json(json_str) => { - serde_json::from_str(&json_str).unwrap_or(Value::Null) - } - InvokeResponseBody::Raw(bytes) => { - // Raw bytes → base64 인코딩 - use base64::Engine; - Value::String( - base64::engine::general_purpose::STANDARD.encode(&bytes), - ) - } - }; - Ok(value) - } - InvokeResponse::Err(err) => Err(err.0), - }; - let _ = rpc_tx.send((request_id, result)); + let result = rpc::project_invoke_response(response); + rpc::send_rpc_response(&rpc_tx, request_id, result); }); log::debug!("[ObsBridge] {addr_clone}: invoke cmd={cmd}"); diff --git a/src-tauri/src/services/obs_bridge/rpc.rs b/src-tauri/src/services/obs_bridge/rpc.rs new file mode 100644 index 000000000..6ec6889d5 --- /dev/null +++ b/src-tauri/src/services/obs_bridge/rpc.rs @@ -0,0 +1,129 @@ +use base64::Engine; +use serde_json::Value; +use tauri::ipc::{CallbackFn, InvokeBody, InvokeResponse, InvokeResponseBody}; +use tauri::webview::InvokeRequest; + +use crate::models::obs::InvokeRequestPayload; + +pub(super) type RpcResult = Result; +pub(super) type RpcSender = tokio::sync::mpsc::UnboundedSender<(String, RpcResult)>; + +pub(super) const APP_HANDLE_NOT_AVAILABLE: &str = "AppHandle not available"; +pub(super) const NO_WEBVIEW_AVAILABLE: &str = "No webview window available"; + +pub(super) const ALLOWED_WS_COMMANDS: &[&str] = &[ + "app_bootstrap", + "settings_get", + "editor_get", + "layer_groups_get", + "note_tab_get_all", + "note_tab_get", + "css_get", + "css_get_use", + "css_tab_get_all", + "css_tab_get", + "js_get", + "js_get_use", + "get_cursor_settings", + "keys_get", + "keys_get_counters", + "positions_get", + "stat_positions_get", + "graph_positions_get", + "knob_positions_get", + "custom_tabs_list", + "sound_list", + "sound_load_original", + "counter_animation_list", + "plugin_bridge_send", + "plugin_bridge_send_to", + "raw_input_subscribe", + "raw_input_unsubscribe", + "plugin_storage_get", + "plugin_storage_set", + "plugin_storage_remove", + "plugin_storage_keys", + "plugin_storage_has_data", + // 파괴적 bulk 삭제는 plugin_storage_clear와 동일하게 원격 차단 +]; + +pub(super) fn is_allowed_command(command: &str) -> bool { + ALLOWED_WS_COMMANDS.contains(&command) +} + +pub(super) fn build_allowed_list() -> Vec { + ALLOWED_WS_COMMANDS + .iter() + .map(|command| command.to_string()) + .collect() +} + +pub(super) fn parse_invoke_request( + payload: &Value, +) -> Result { + serde_json::from_value(payload.clone()) +} + +pub(super) fn invalid_invoke_request_error(error: &serde_json::Error) -> Value { + serde_json::json!(format!("Invalid invoke_request: {error}")) +} + +pub(super) fn command_not_allowed_error(command: &str) -> Value { + serde_json::json!(format!("Command not allowed: {command}")) +} + +pub(super) fn select_overlay_or_main(overlay: Overlay, main: Main) -> Option +where + Overlay: FnOnce() -> Option, + Main: FnOnce() -> Option, +{ + overlay().or_else(main) +} + +pub(super) fn local_invoke_url() -> tauri::Url { + // 플랫폼별 로컬 URL (Windows: http://tauri.localhost, macOS/Linux: tauri://localhost) + if cfg!(windows) || cfg!(target_os = "android") { + tauri::Url::parse("http://tauri.localhost").unwrap() + } else { + tauri::Url::parse("tauri://localhost").unwrap() + } +} + +pub(super) fn build_invoke_request( + command: String, + args: Value, + local_url: tauri::Url, + invoke_key: String, +) -> InvokeRequest { + InvokeRequest { + cmd: command, + callback: CallbackFn(0), + error: CallbackFn(1), + url: local_url, + body: InvokeBody::Json(args), + headers: Default::default(), + invoke_key, + } +} + +pub(super) fn project_invoke_response(response: InvokeResponse) -> RpcResult { + match response { + InvokeResponse::Ok(body) => { + let value = match body { + InvokeResponseBody::Json(json_str) => { + serde_json::from_str(&json_str).unwrap_or(Value::Null) + } + InvokeResponseBody::Raw(bytes) => { + // Raw bytes → base64 인코딩 + Value::String(base64::engine::general_purpose::STANDARD.encode(&bytes)) + } + }; + Ok(value) + } + InvokeResponse::Err(err) => Err(err.0), + } +} + +pub(super) fn send_rpc_response(rpc_tx: &RpcSender, request_id: String, result: RpcResult) { + let _ = rpc_tx.send((request_id, result)); +} From 577a1432af780e113b0d11817facf63f5c03e6f3 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 18:09:46 +0900 Subject: [PATCH 182/245] =?UTF-8?q?test:=20OBS=20RPC=20=ED=88=AC=EC=98=81?= =?UTF-8?q?=20=EA=B3=84=EC=95=BD=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/services/obs_bridge/rpc.rs | 3 + .../src/services/obs_bridge/rpc/tests.rs | 136 ++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 src-tauri/src/services/obs_bridge/rpc/tests.rs diff --git a/src-tauri/src/services/obs_bridge/rpc.rs b/src-tauri/src/services/obs_bridge/rpc.rs index 6ec6889d5..04e555e9e 100644 --- a/src-tauri/src/services/obs_bridge/rpc.rs +++ b/src-tauri/src/services/obs_bridge/rpc.rs @@ -127,3 +127,6 @@ pub(super) fn project_invoke_response(response: InvokeResponse) -> RpcResult { pub(super) fn send_rpc_response(rpc_tx: &RpcSender, request_id: String, result: RpcResult) { let _ = rpc_tx.send((request_id, result)); } + +#[cfg(test)] +mod tests; diff --git a/src-tauri/src/services/obs_bridge/rpc/tests.rs b/src-tauri/src/services/obs_bridge/rpc/tests.rs new file mode 100644 index 000000000..6a6c750ed --- /dev/null +++ b/src-tauri/src/services/obs_bridge/rpc/tests.rs @@ -0,0 +1,136 @@ +use std::cell::RefCell; + +use tauri::ipc::{InvokeBody, InvokeError, InvokeResponse, InvokeResponseBody}; + +use super::*; + +#[test] +fn request_parser_preserves_camel_case_defaults_and_unknown_fields() { + let request = parse_invoke_request(&serde_json::json!({ + "requestId": "request-1", + "command": "settings_get", + "unknown": true, + })) + .expect("invoke_request 파싱 실패"); + assert_eq!(request.request_id, "request-1"); + assert_eq!(request.command, "settings_get"); + assert_eq!(request.args, Value::Null); + + let error = parse_invoke_request(&serde_json::json!({ + "request_id": "request-1", + "command": "settings_get", + })) + .expect_err("snake_case request_id가 허용되면 안 됨"); + assert_eq!(error.to_string(), "missing field `requestId`"); +} + +#[test] +fn webview_selection_prefers_overlay_and_lazily_falls_back_to_main() { + let calls = RefCell::new(Vec::new()); + let selected = select_overlay_or_main( + || { + calls.borrow_mut().push("overlay"); + Some("overlay-window") + }, + || { + calls.borrow_mut().push("main"); + Some("main-window") + }, + ); + assert_eq!(selected, Some("overlay-window")); + assert_eq!(calls.into_inner(), vec!["overlay"]); + + let calls = RefCell::new(Vec::new()); + let selected = select_overlay_or_main( + || { + calls.borrow_mut().push("overlay"); + None + }, + || { + calls.borrow_mut().push("main"); + Some("main-window") + }, + ); + assert_eq!(selected, Some("main-window")); + assert_eq!(calls.into_inner(), vec!["overlay", "main"]); + + assert_eq!(select_overlay_or_main(|| None::<&str>, || None), None); + assert_eq!(NO_WEBVIEW_AVAILABLE, "No webview window available"); +} + +#[test] +fn invoke_request_projection_preserves_platform_and_tauri_fields() { + let args = serde_json::json!({ "mode": "default", "value": 3 }); + let local_url = local_invoke_url(); + let request = build_invoke_request( + "settings_get".to_string(), + args.clone(), + local_url, + "invoke-key".to_string(), + ); + + assert_eq!(request.cmd, "settings_get"); + assert_eq!(request.callback.0, 0); + assert_eq!(request.error.0, 1); + if cfg!(windows) || cfg!(target_os = "android") { + assert_eq!(request.url.as_str(), "http://tauri.localhost/"); + } else { + assert_eq!(request.url.as_str(), "tauri://localhost"); + } + match request.body { + InvokeBody::Json(body) => assert_eq!(body, args), + InvokeBody::Raw(_) => panic!("invoke body는 JSON이어야 함"), + } + assert!(request.headers.is_empty()); + assert_eq!(request.invoke_key, "invoke-key"); +} + +#[test] +fn invoke_response_projection_preserves_json_raw_error_and_null_fallback() { + assert_eq!( + project_invoke_response(InvokeResponse::Ok(InvokeResponseBody::Json( + r#"{"ok":true,"value":3}"#.to_string(), + ))), + Ok(serde_json::json!({ "ok": true, "value": 3 })) + ); + assert_eq!( + project_invoke_response(InvokeResponse::Ok(InvokeResponseBody::Json( + "not-json".to_string(), + ))), + Ok(Value::Null) + ); + assert_eq!( + project_invoke_response(InvokeResponse::Ok(InvokeResponseBody::Raw(vec![ + 0, 1, 2, 253, 254, 255, + ]))), + Ok(Value::String("AAEC/f7/".to_string())) + ); + assert_eq!( + project_invoke_response(InvokeResponse::Err(InvokeError(serde_json::json!({ + "code": "FAILED", + "retryable": false, + })))), + Err(serde_json::json!({ + "code": "FAILED", + "retryable": false, + })) + ); +} + +#[test] +fn rpc_sender_preserves_fifo_and_ignores_closed_receiver() { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + send_rpc_response(&tx, "first".to_string(), Ok(serde_json::json!(1))); + send_rpc_response(&tx, "second".to_string(), Err(serde_json::json!("failed"))); + assert_eq!( + rx.try_recv().expect("첫 RPC 응답 누락"), + ("first".to_string(), Ok(serde_json::json!(1))) + ); + assert_eq!( + rx.try_recv().expect("두 번째 RPC 응답 누락"), + ("second".to_string(), Err(serde_json::json!("failed"))) + ); + + drop(rx); + send_rpc_response(&tx, "closed".to_string(), Ok(Value::Null)); +} From f9eca3a1016e0406ced19f7e831d211c72851ba8 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 18:13:52 +0900 Subject: [PATCH 183/245] =?UTF-8?q?test:=20=EC=B9=B4=EC=9A=B4=ED=84=B0=20?= =?UTF-8?q?=EB=AA=A8=EC=85=98=20=EC=BA=94=EB=B2=84=EC=8A=A4=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EA=B3=84=EC=95=BD=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CounterAnimationEditorModal.test.tsx | 223 +++++++++++++++++- 1 file changed, 219 insertions(+), 4 deletions(-) diff --git a/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.test.tsx b/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.test.tsx index 157311da2..f0c07e6ff 100644 --- a/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.test.tsx +++ b/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.test.tsx @@ -1,7 +1,15 @@ // @vitest-environment jsdom import React, { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + type Mock, + vi, +} from 'vitest'; import CounterAnimationEditorModal from './CounterAnimationEditorModal'; @@ -10,6 +18,15 @@ globalThis.IS_REACT_ACT_ENVIRONMENT = true; const preview = vi.hoisted(() => ({ countDisplayProps: null as Record | null, })); +const dragCursor = vi.hoisted(() => ({ + begin: vi.fn(), + end: vi.fn(), +})); + +vi.mock('@utils/core/dragCursor', () => ({ + beginDragCursor: dragCursor.begin, + endDragCursor: dragCursor.end, +})); vi.mock('@components/main/Modal/FullSurfaceModalLayout', () => ({ default: ({ children }: { children: React.ReactNode }) => <>{children}, @@ -28,7 +45,12 @@ vi.mock('@components/overlay/counters/CountDisplay', () => ({ }, })); -const pointerEvent = (type: string, clientX: number, clientY: number) => { +const pointerEvent = ( + type: string, + clientX: number, + clientY: number, + pointerId = 1, +) => { const event = new MouseEvent(type, { bubbles: true, cancelable: true, @@ -37,7 +59,7 @@ const pointerEvent = (type: string, clientX: number, clientY: number) => { clientX, clientY, }); - Object.defineProperty(event, 'pointerId', { value: 1 }); + Object.defineProperty(event, 'pointerId', { value: pointerId }); return event; }; @@ -46,9 +68,13 @@ describe('CounterAnimationEditorModal 베지어 드래그', () => { let root: Root; let callbacks: Map; let resizeCallback: ResizeObserverCallback; + let resizeDisconnect: Mock<() => void>; beforeEach(() => { preview.countDisplayProps = null; + dragCursor.begin.mockReset(); + dragCursor.end.mockReset(); + resizeDisconnect = vi.fn(); callbacks = new Map(); let nextId = 1; vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { @@ -65,7 +91,9 @@ describe('CounterAnimationEditorModal 베지어 드래그', () => { } observe() {} - disconnect() {} + disconnect() { + resizeDisconnect(); + } }, ); Object.defineProperty(window, 'api', { @@ -118,6 +146,9 @@ describe('CounterAnimationEditorModal 베지어 드래그', () => { const p1 = () => host.querySelector('[data-counter-bezier-handle="p1"]')!; const p1Visual = () => p1().nextElementSibling as SVGCircleElement; + const svg = () => + host.querySelector('[data-counter-bezier-editor="true"]')!; + const viewBox = () => svg().getAttribute('viewBox')!.split(' ').map(Number); it('캔버스 실측 전에도 베지어 좌표계를 비균등 확대하지 않는다', () => { const svg = host.querySelector( @@ -163,6 +194,36 @@ describe('CounterAnimationEditorModal 베지어 드래그', () => { expect(key.getAttribute('data-state')).toBe('inactive'); }); + it('미리보기의 연속 press는 각각 count를 소유하고 첫 전역 release에서 기존 listener를 모두 정산한다', () => { + const scope = host.querySelector( + '[data-dmn-user-css-scope=""]', + )!; + const stage = scope.parentElement!; + const removeListener = vi.spyOn(window, 'removeEventListener'); + + act(() => { + stage.dispatchEvent(pointerEvent('pointerdown', 80, 80, 1)); + stage.dispatchEvent(pointerEvent('pointerdown', 90, 90, 2)); + }); + expect(preview.countDisplayProps).toMatchObject({ + count: 2, + active: true, + }); + + act(() => window.dispatchEvent(pointerEvent('pointerup', 90, 90, 1))); + + expect(preview.countDisplayProps).toMatchObject({ + count: 2, + active: false, + }); + expect( + removeListener.mock.calls.filter(([type]) => type === 'pointerup'), + ).toHaveLength(2); + expect( + removeListener.mock.calls.filter(([type]) => type === 'pointercancel'), + ).toHaveLength(2); + }); + it('가로형 캔버스 실측 뒤에는 같은 비율의 풀블리드 viewBox를 쓴다', () => { const svg = host.querySelector( '[data-counter-bezier-editor="true"]', @@ -241,6 +302,33 @@ describe('CounterAnimationEditorModal 베지어 드래그', () => { act(() => window.dispatchEvent(pointerEvent('pointerup', 110, 110))); }); + it('legacy 입력 전략은 RAF 없이 모든 pointermove를 즉시 반영한다', () => { + act(() => { + root.render( + undefined} + onSaved={() => undefined} + t={(key) => key} + continuousInputStrategy="legacy" + />, + ); + }); + + act(() => { + p1().dispatchEvent(pointerEvent('pointerdown', 47.5, 79.4)); + window.dispatchEvent(pointerEvent('pointermove', 44, 110)); + }); + expect(callbacks).toHaveLength(0); + expect(Number(p1().getAttribute('cx'))).toBeCloseTo(30); + + act(() => window.dispatchEvent(pointerEvent('pointermove', 110, 110))); + expect(callbacks).toHaveLength(0); + expect(Number(p1().getAttribute('cx'))).toBeCloseTo(75); + act(() => window.dispatchEvent(pointerEvent('pointerup', 110, 110))); + }); + it('프레임 전에 pointerup하면 마지막 좌표를 flush한다', () => { act(() => { p1().dispatchEvent(pointerEvent('pointerdown', 47.5, 79.4)); @@ -251,6 +339,133 @@ describe('CounterAnimationEditorModal 베지어 드래그', () => { expect(Number(p1().getAttribute('cx'))).toBeCloseTo(75); }); + it('Ctrl-wheel 확대는 포인터 아래 world 좌표를 anchor로 유지한다', () => { + const before = viewBox(); + const frac = { x: 0.25, y: 0.75 }; + const beforeWorld = { + x: before[0] + before[2] * frac.x, + y: before[1] + before[3] * frac.y, + }; + const wheel = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + ctrlKey: true, + clientX: 55, + clientY: 165, + deltaY: -120, + }); + + act(() => svg().dispatchEvent(wheel)); + + const after = viewBox(); + expect(after[2]).toBeLessThan(before[2]); + expect(after[0] + after[2] * frac.x).toBeCloseTo(beforeWorld.x); + expect(after[1] + after[3] * frac.y).toBeCloseTo(beforeWorld.y); + }); + + it('Space+좌클릭 pan은 최신 move를 pointerup에서 flush한 뒤 커서 소유권을 반환한다', () => { + const before = viewBox(); + const keyDown = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + code: 'Space', + }); + act(() => window.dispatchEvent(keyDown)); + expect(keyDown.defaultPrevented).toBe(true); + expect(svg().style.cursor).toBe('grab'); + + act(() => { + svg().dispatchEvent(pointerEvent('pointerdown', 100, 100)); + window.dispatchEvent(pointerEvent('pointermove', 120, 130)); + window.dispatchEvent(pointerEvent('pointerup', 120, 130)); + }); + + expect(callbacks).toHaveLength(0); + expect(viewBox()[0] - before[0]).toBeCloseTo(-(20 / 220) * before[2]); + expect(viewBox()[1] - before[1]).toBeCloseTo(-(30 / 220) * before[3]); + expect(dragCursor.begin).toHaveBeenCalledWith('grabbing'); + expect(dragCursor.end).toHaveBeenCalledTimes(1); + expect(svg().style.cursor).toBe('grab'); + + act(() => + window.dispatchEvent( + new KeyboardEvent('keyup', { bubbles: true, code: 'Space' }), + ), + ); + expect(svg().style.cursor).toBe('default'); + }); + + it('포인트 드래그는 pointer capture 없이 전역 listener를 쓰고 닫힐 때 pending move와 auto-fit을 정리한다', () => { + Object.defineProperty(p1(), 'setPointerCapture', { + configurable: true, + value: vi.fn(), + }); + const capture = vi.spyOn(p1(), 'setPointerCapture'); + const removeListener = vi.spyOn(window, 'removeEventListener'); + const order: string[] = []; + const initialRequestAnimationFrame = requestAnimationFrame; + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + order.push('raf'); + return initialRequestAnimationFrame(callback); + }), + ); + dragCursor.end.mockImplementation(() => { + order.push('end-cursor'); + }); + + act(() => { + p1().dispatchEvent(pointerEvent('pointerdown', 47.5, 79.4)); + window.dispatchEvent(pointerEvent('pointermove', 110, -1000)); + }); + order.length = 0; + act(() => window.dispatchEvent(pointerEvent('pointerup', 110, -1000))); + expect(capture).not.toHaveBeenCalled(); + expect(dragCursor.end).toHaveBeenCalledTimes(1); + expect(Number(p1().getAttribute('cx'))).toBeCloseTo(75); + expect(order).toEqual(['end-cursor', 'raf']); + expect(callbacks).toHaveLength(1); + + act(() => { + root.render( + undefined} + onSaved={() => undefined} + t={(key) => key} + />, + ); + }); + + expect(callbacks).toHaveLength(0); + expect(resizeDisconnect).toHaveBeenCalledTimes(1); + for (const type of ['pointermove', 'pointerup', 'pointercancel']) { + expect( + removeListener.mock.calls.some(([removedType]) => removedType === type), + ).toBe(true); + } + }); + + it('Escape와 window blur는 캔버스 세션이 소비하지 않고 기존처럼 pointerup에서 드래그를 끝낸다', () => { + act(() => { + p1().dispatchEvent(pointerEvent('pointerdown', 47.5, 79.4)); + window.dispatchEvent(new Event('blur')); + window.dispatchEvent( + new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }), + ); + }); + expect(dragCursor.end).not.toHaveBeenCalled(); + + act(() => { + window.dispatchEvent(pointerEvent('pointermove', 110, 110)); + window.dispatchEvent(pointerEvent('pointerup', 110, 110)); + }); + expect(Number(p1().getAttribute('cx'))).toBeCloseTo(75); + expect(dragCursor.end).toHaveBeenCalledTimes(1); + }); + // 드래그의 preventDefault가 포커스를 남긴다. 편집 세션을 안 끊으면 // 뒤이은 Escape가 드래그 결과까지 함께 되돌린다 it('드래그를 시작하면 포커스된 입력의 편집 세션을 끊는다', () => { From 80f3e939da80a2223cb74288eae6b175c9beff7e Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 18:13:52 +0900 Subject: [PATCH 184/245] =?UTF-8?q?refactor:=20=EC=B9=B4=EC=9A=B4=ED=84=B0?= =?UTF-8?q?=20=EB=AA=A8=EC=85=98=20=EC=BA=94=EB=B2=84=EC=8A=A4=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../editors/CounterAnimationEditorModal.tsx | 658 +---------------- .../useCounterAnimationCanvasSession.ts | 659 ++++++++++++++++++ 2 files changed, 692 insertions(+), 625 deletions(-) create mode 100644 src/renderer/components/main/Modal/content/editors/useCounterAnimationCanvasSession.ts diff --git a/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.tsx b/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.tsx index 849bc69e0..0bd373234 100644 --- a/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.tsx +++ b/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.tsx @@ -1,5 +1,4 @@ -import { beginDragCursor, endDragCursor } from '@utils/core/dragCursor'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useRef, useState } from 'react'; import type { CounterAnimationBezier, KeyCounterSettings, @@ -17,28 +16,20 @@ import { findBezierPresetId, } from '@utils/cubicBezier'; import type { CounterAnimationKeyVisual } from '@utils/core/counterAnimationPreview'; -import { - createRafLatestScheduler, - type ContinuousInputStrategy, -} from '@utils/animation/rafLatestScheduler'; +import { type ContinuousInputStrategy } from '@utils/animation/rafLatestScheduler'; import { counterAnimationApi } from '@api/modules/resourceApi'; import { - COUNTER_EDITOR_PADDING as EDITOR_PADDING, - COUNTER_EDITOR_SIZE as EDITOR_SIZE, - COUNTER_EDITOR_TOTAL_SIZE as TOTAL_SIZE, clampCounterDuration as clampDuration, - createCounterAnimationEditorState as toInitialState, formatCounterBezierInput as formatBezierInput, normalizeCounterScale as normalizeScale, parseCounterBezierInput as parseBezierInput, parseCounterNumber as parseNumber, - resolveCounterEditorViewDimensions as viewDims, } from './counterAnimationEditorModel'; import CounterAnimationCurveCanvas, { COUNTER_ANIMATION_GRID_MINOR_COLOR as GRID_MINOR_COLOR, - COUNTER_ANIMATION_HANDLE_RADIUS as HANDLE_RADIUS, } from './CounterAnimationCurveCanvas'; import CounterAnimationPreviewStage from './CounterAnimationPreviewStage'; +import { useCounterAnimationCanvasSession } from './useCounterAnimationCanvasSession'; type EditorMode = 'create' | 'edit'; @@ -59,19 +50,6 @@ interface CounterAnimationEditorModalProps { continuousInputStrategy?: ContinuousInputStrategy; } -type DragTarget = 'p1' | 'p2' | null; - -// 기준 렌더 크기 — 핸들·코너 화면 크기의 기준값 (실제 렌더는 캔버스 실측) -// 뷰박스 세로는 TOTAL_SIZE 기준, 가로는 캔버스 종횡비만큼 넓어지는 풀블리드 캔버스 -// 오프셋 (0,0) = 커브 정사각이 뷰 중앙, 포인터 수학은 비율 좌표라 크기 변화에 안전 -const EDITOR_RENDER_SIZE = 220; -const PAN_MARGIN = 14; -const MIN_ZOOM = 0.15; -const MAX_ZOOM = 3.0; -const ZOOM_SENSITIVITY = 0.002; -const AUTO_FIT_MARGIN = 14; -const AUTO_FIT_DURATION = 260; - const CounterAnimationEditorModal = ({ isOpen, mode, @@ -83,613 +61,43 @@ const CounterAnimationEditorModal = ({ t, continuousInputStrategy = 'frame', }: CounterAnimationEditorModalProps) => { - const svgRef = useRef(null); - const dragTargetRef = useRef(null); - const localBezierRef = useRef([ - 0.25, 0.46, 0.45, 0.94, - ]); - const draggedBezierRef = useRef(null); - const viewOffsetRef = useRef({ x: 0, y: 0 }); - const viewScaleRef = useRef(1); - const isPanningRef = useRef(false); - const panStartRef = useRef({ - clientX: 0, - clientY: 0, - offsetX: 0, - offsetY: 0, - }); - const spaceHeldRef = useRef(false); - const activePointersRef = useRef< - Map - >(new Map()); - const pinchStartDistRef = useRef(0); - const pinchStartScaleRef = useRef(1); - const pinchStartOffsetRef = useRef({ x: 0, y: 0 }); - const pinchStartMidFracRef = useRef({ x: 0, y: 0 }); - const autoFitRafRef = useRef(null); - const editorSizeRef = useRef({ - width: EDITOR_RENDER_SIZE, - height: EDITOR_RENDER_SIZE, - }); - const [nameInput, setNameInput] = useState(''); - // 캔버스 실측 — 뷰박스 종횡비와 핸들 화면 크기 계산용 - const [editorSize, setEditorSize] = useState({ - width: EDITOR_RENDER_SIZE, - height: EDITOR_RENDER_SIZE, - }); - const [localBezier, setLocalBezier] = useState([ - 0.25, 0.46, 0.45, 0.94, - ]); - const [bezierInput, setBezierInput] = useState('0.25, 0.46, 0.45, 0.94'); const [scaleInput, setScaleInput] = useState('1.1'); const [durationInput, setDurationInput] = useState('300'); - const [viewOffset, setViewOffset] = useState({ x: 0, y: 0 }); - const [viewScale, setViewScale] = useState(1); - const [isSaving, setIsSaving] = useState(false); const savingRef = useRef(false); const [errorText, setErrorText] = useState(''); - const [previewCount, setPreviewCount] = useState(0); - const [previewActive, setPreviewActive] = useState(false); - - const cancelAutoFit = () => { - if (autoFitRafRef.current) { - cancelAnimationFrame(autoFitRafRef.current); - autoFitRafRef.current = null; - } - }; - - const applyView = (offset: { x: number; y: number }, scale: number) => { - viewOffsetRef.current = offset; - viewScaleRef.current = scale; - setViewOffset(offset); - setViewScale(scale); - }; - - const computeAutoFit = (bezier: CounterAnimationBezier) => { - const pts = [ - { x: EDITOR_PADDING, y: EDITOR_PADDING + EDITOR_SIZE }, - { x: EDITOR_PADDING + EDITOR_SIZE, y: EDITOR_PADDING }, - { - x: EDITOR_PADDING + bezier[0] * EDITOR_SIZE, - y: EDITOR_PADDING + (1 - bezier[1]) * EDITOR_SIZE, - }, - { - x: EDITOR_PADDING + bezier[2] * EDITOR_SIZE, - y: EDITOR_PADDING + (1 - bezier[3]) * EDITOR_SIZE, - }, - ]; - - const ptsMinX = Math.min(...pts.map((p) => p.x)); - const ptsMaxX = Math.max(...pts.map((p) => p.x)); - const ptsMinY = Math.min(...pts.map((p) => p.y)); - const ptsMaxY = Math.max(...pts.map((p) => p.y)); - - const minX = Math.min(ptsMinX, EDITOR_PADDING); - const maxX = Math.max(ptsMaxX, EDITOR_PADDING + EDITOR_SIZE); - const minY = Math.min(ptsMinY, EDITOR_PADDING); - const maxY = Math.max(ptsMaxY, EDITOR_PADDING + EDITOR_SIZE); - - // 기본 뷰의 긴 변 허용 범위는 종횡비만큼 넓다 (측정 전에는 정사각 취급) - const { width: canvasW, height: canvasH } = editorSizeRef.current; - const aspect = canvasH > 0 ? canvasW / canvasH : 1; - const defaultView = viewDims(1, aspect); - const defaultExtraW = (defaultView.vbW - TOTAL_SIZE) / 2; - const defaultExtraH = (defaultView.vbH - TOTAL_SIZE) / 2; - - if ( - minX >= -defaultExtraW && - maxX <= TOTAL_SIZE + defaultExtraW && - minY >= -defaultExtraH && - maxY <= TOTAL_SIZE + defaultExtraH - ) { - return { offset: { x: 0, y: 0 }, scale: 1 }; - } - - const rawW = maxX - minX; - const rawH = maxY - minY; - const rawSize = Math.max(rawW, rawH, TOTAL_SIZE); - const estHandleR = (HANDLE_RADIUS * rawSize) / TOTAL_SIZE; - const margin = AUTO_FIT_MARGIN + estHandleR; - - const fitMinX = minX - margin; - const fitMaxX = maxX + margin; - const fitMinY = minY - margin; - const fitMaxY = maxY + margin; - - const needW = fitMaxX - fitMinX; - const needH = fitMaxY - fitMinY; - // 긴 변은 종횡비만큼 더 보이므로 짧은 변 기준 크기로 환산해 맞춤 - const needSize = Math.max( - needW / Math.max(aspect, 1), - needH * Math.min(Math.max(aspect, 0.01), 1), - TOTAL_SIZE, - ); - - const maxVB = TOTAL_SIZE / MIN_ZOOM; - const vbSize = Math.min(needSize, maxVB); - const fitScale = TOTAL_SIZE / vbSize; - - const cx = (fitMinX + fitMaxX) / 2; - const cy = (fitMinY + fitMaxY) / 2; - - return { - offset: { x: cx - vbSize / 2, y: cy - vbSize / 2 }, - scale: fitScale, - }; - }; - - useEffect(() => { - if (!isOpen) return; - const initial = toInitialState(initialPreset); - localBezierRef.current = initial.bezier; - setNameInput(initial.name); - setLocalBezier(initial.bezier); - setBezierInput(formatBezierInput(initial.bezier)); - setScaleInput(String(Math.round(initial.scale * 100) / 100)); - setDurationInput(String(initial.durationMs)); - setErrorText(''); - - // edit 모드: 컨트롤 포인트가 기본 뷰 밖이면 auto-fit, 아니면 기본 뷰 - // create 모드: 항상 기본 뷰 - const fit = - mode === 'edit' && initialPreset - ? computeAutoFit(initial.bezier) - : { offset: { x: 0, y: 0 }, scale: 1 }; - applyView(fit.offset, fit.scale); - isPanningRef.current = false; - activePointersRef.current.clear(); - pinchStartDistRef.current = 0; - setPreviewCount(0); - setPreviewActive(false); - }, [initialPreset, isOpen, mode]); - - useEffect(() => { - if (isOpen) return; - setPreviewActive(false); - isPanningRef.current = false; - spaceHeldRef.current = false; - activePointersRef.current.clear(); - pinchStartDistRef.current = 0; - cancelAutoFit(); - }, [isOpen]); - - useEffect(() => () => cancelAutoFit(), []); - - // 캔버스 리사이즈 추적 - 첫 페인트 전에 실측해야 초기 aspect 불일치로 - // 커브가 늘어났다 복귀하는 프레임이 없음 (preserveAspectRatio=none). - // 시트 본문은 첫 paint 뒤에 붙으므로(after-paint) 마운트 이펙트로는 노드를 못 잡는다. - // 노드가 실제로 붙는 순간을 ref 콜백으로 받고, 콜백 정체성은 고정한다 - const observeEditorArea = useCallback((area: HTMLDivElement | null) => { - if (!area) return; - const measure = () => { - const rect = area.getBoundingClientRect(); - const width = Math.floor(rect.width); - const height = Math.floor(rect.height); - if (width <= 0 || height <= 0) return; - editorSizeRef.current = { width, height }; - setEditorSize({ width, height }); - }; - - measure(); - const observer = new ResizeObserver(measure); - observer.observe(area); - return () => observer.disconnect(); - }, []); - - useEffect(() => { - if (!isOpen) return; - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.code === 'Space' && !e.repeat) { - spaceHeldRef.current = true; - const tag = (e.target as HTMLElement)?.tagName; - if (tag !== 'INPUT' && tag !== 'TEXTAREA') { - e.preventDefault(); - } - if (svgRef.current) { - svgRef.current.style.cursor = 'grab'; - } - } - }; - - const handleKeyUp = (e: KeyboardEvent) => { - if (e.code === 'Space') { - spaceHeldRef.current = false; - if (svgRef.current && !isPanningRef.current) { - svgRef.current.style.cursor = 'default'; - } - } - }; - - window.addEventListener('keydown', handleKeyDown); - window.addEventListener('keyup', handleKeyUp); - return () => { - window.removeEventListener('keydown', handleKeyDown); - window.removeEventListener('keyup', handleKeyUp); - spaceHeldRef.current = false; - }; - }, [isOpen]); - - // window 리스너는 pointerup/pointercancel 시 자가 정리돼서 클린업 추가 안 해뒀어요 - - const handlePreviewPointerDown = (event: React.PointerEvent) => { - event.preventDefault(); - setPreviewActive(true); - setPreviewCount((prev) => prev + 1); - - const handleUp = () => { - setPreviewActive(false); - window.removeEventListener('pointerup', handleUp); - window.removeEventListener('pointercancel', handleUp); - }; - window.addEventListener('pointerup', handleUp); - window.addEventListener('pointercancel', handleUp); - }; - - const animateViewToFit = (bezier: CounterAnimationBezier) => { - cancelAutoFit(); - - const target = computeAutoFit(bezier); - const fromOffset = { ...viewOffsetRef.current }; - const fromScale = viewScaleRef.current; - - const EPS = 1e-3; - if ( - Math.abs(target.offset.x - fromOffset.x) < EPS && - Math.abs(target.offset.y - fromOffset.y) < EPS && - Math.abs(target.scale - fromScale) < EPS - ) { - return; - } - - const start = performance.now(); - const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3); - - const tick = (now: number) => { - const elapsed = now - start; - const t = Math.min(1, elapsed / AUTO_FIT_DURATION); - const k = easeOutCubic(t); - - const newOffset = { - x: fromOffset.x + (target.offset.x - fromOffset.x) * k, - y: fromOffset.y + (target.offset.y - fromOffset.y) * k, - }; - const newScale = fromScale + (target.scale - fromScale) * k; - - applyView(newOffset, newScale); - - if (t < 1) { - autoFitRafRef.current = requestAnimationFrame(tick); - } else { - autoFitRafRef.current = null; - } - }; - - autoFitRafRef.current = requestAnimationFrame(tick); - }; - - const updateBezierFromClient = ( - clientX: number, - clientY: number, - target: DragTarget, - ) => { - if (!target || !svgRef.current) return; - const rect = svgRef.current.getBoundingClientRect(); - - const fracX = (clientX - rect.left) / rect.width; - const fracY = (clientY - rect.top) / rect.height; - - const scale = viewScaleRef.current; - const offset = viewOffsetRef.current; - const { base, vbW, vbH } = viewDims( - scale, - rect.width / Math.max(rect.height, 1), - ); - const extraW = (vbW - base) / 2; - const extraH = (vbH - base) / 2; - - const worldX = offset.x - extraW + fracX * vbW; - const worldY = offset.y - extraH + fracY * vbH; - - const bezierX = (worldX - EDITOR_PADDING) / EDITOR_SIZE; - const bezierY = 1 - (worldY - EDITOR_PADDING) / EDITOR_SIZE; - - const current = localBezierRef.current; - const nextBezier: CounterAnimationBezier = - target === 'p1' - ? [ - Math.min(Math.max(bezierX, 0), 1), - Math.min(Math.max(bezierY, -4), 4), - current[2], - current[3], - ] - : [ - current[0], - current[1], - Math.min(Math.max(bezierX, 0), 1), - Math.min(Math.max(bezierY, -4), 4), - ]; - - const clamped = clampCounterBezier(nextBezier); - localBezierRef.current = clamped; - setLocalBezier(clamped); - setBezierInput(formatBezierInput(clamped)); - draggedBezierRef.current = clamped; - - const hx = - EDITOR_PADDING + - (target === 'p1' ? clamped[0] : clamped[2]) * EDITOR_SIZE; - const hy = - EDITOR_PADDING + - (1 - (target === 'p1' ? clamped[1] : clamped[3])) * EDITOR_SIZE; - - // 축소 시 auto-pan 과도 이동 방지용 margin 제한 - const effectiveScale = Math.max(scale, 0.8); - const margin = PAN_MARGIN / effectiveScale; - let nx = offset.x; - let ny = offset.y; - - const viewLeft = offset.x - extraW; - const viewTop = offset.y - extraH; - if (hx < viewLeft + margin) nx = hx - margin + extraW; - else if (hx > viewLeft + vbW - margin) nx = hx - vbW + margin + extraW; - if (hy < viewTop + margin) ny = hy - margin + extraH; - else if (hy > viewTop + vbH - margin) ny = hy - vbH + margin + extraH; - - if (nx !== offset.x || ny !== offset.y) { - const next = { x: nx, y: ny }; - viewOffsetRef.current = next; - setViewOffset(next); - } - }; - - useEffect(() => { - if (!isOpen) return; - - const applyPointerMove = (event: PointerEvent) => { - if ( - activePointersRef.current.size === 2 && - pinchStartDistRef.current > 0 - ) { - const svg = svgRef.current; - if (!svg) return; - const pointers = Array.from(activePointersRef.current.values()); - const dx = pointers[1].clientX - pointers[0].clientX; - const dy = pointers[1].clientY - pointers[0].clientY; - const dist = Math.sqrt(dx * dx + dy * dy); - const ratio = dist / pinchStartDistRef.current; - const oldScale = pinchStartScaleRef.current; - const newScale = Math.min( - Math.max(oldScale * ratio, MIN_ZOOM), - MAX_ZOOM, - ); - - const rect = svg.getBoundingClientRect(); - const aspect = rect.width / Math.max(rect.height, 1); - const fracX = pinchStartMidFracRef.current.x; - const fracY = pinchStartMidFracRef.current.y; - const oldView = viewDims(oldScale, aspect); - const newView = viewDims(newScale, aspect); - const startOff = pinchStartOffsetRef.current; - const worldX = - startOff.x - (oldView.vbW - oldView.base) / 2 + fracX * oldView.vbW; - const worldY = - startOff.y - (oldView.vbH - oldView.base) / 2 + fracY * oldView.vbH; - const newOff = { - x: worldX + (newView.vbW - newView.base) / 2 - fracX * newView.vbW, - y: worldY + (newView.vbH - newView.base) / 2 - fracY * newView.vbH, - }; - - applyView(newOff, newScale); - return; - } - - if (isPanningRef.current) { - const svg = svgRef.current; - if (!svg) return; - const rect = svg.getBoundingClientRect(); - const scale = viewScaleRef.current; - const { vbW, vbH } = viewDims( - scale, - rect.width / Math.max(rect.height, 1), - ); - const dxClient = event.clientX - panStartRef.current.clientX; - const dyClient = event.clientY - panStartRef.current.clientY; - const dxWorld = -(dxClient / rect.width) * vbW; - const dyWorld = -(dyClient / rect.height) * vbH; - - const newOffset = { - x: panStartRef.current.offsetX + dxWorld, - y: panStartRef.current.offsetY + dyWorld, - }; - viewOffsetRef.current = newOffset; - setViewOffset(newOffset); - return; - } - - updateBezierFromClient( - event.clientX, - event.clientY, - dragTargetRef.current, - ); - }; - const moveScheduler = createRafLatestScheduler( - applyPointerMove, - continuousInputStrategy, - ); - const handlePointerMove = (event: PointerEvent) => { - if (activePointersRef.current.has(event.pointerId)) { - activePointersRef.current.set(event.pointerId, { - clientX: event.clientX, - clientY: event.clientY, - }); - } - moveScheduler.push(event); - }; - - const handlePointerUp = (event: PointerEvent) => { - moveScheduler.flush(); - activePointersRef.current.delete(event.pointerId); - if (activePointersRef.current.size < 2) { - pinchStartDistRef.current = 0; - } - - if (isPanningRef.current) { - isPanningRef.current = false; - endDragCursor(); - if (svgRef.current) { - svgRef.current.style.cursor = spaceHeldRef.current - ? 'grab' - : 'default'; - } - return; - } - - // 컨트롤 포인트 드래그 종료 후 auto-fit - if (dragTargetRef.current) { - endDragCursor(); - animateViewToFit(localBezierRef.current); - } - - draggedBezierRef.current = null; - dragTargetRef.current = null; - }; - - window.addEventListener('pointermove', handlePointerMove); - window.addEventListener('pointerup', handlePointerUp); - window.addEventListener('pointercancel', handlePointerUp); - - return () => { - moveScheduler.cancel(); - // 드래그 중 모달이 닫히면 전역 커서도 함께 복원 - if (isPanningRef.current || dragTargetRef.current) endDragCursor(); - window.removeEventListener('pointermove', handlePointerMove); - window.removeEventListener('pointerup', handlePointerUp); - window.removeEventListener('pointercancel', handlePointerUp); - }; - // 핸들러는 ref만 읽어서 재구독 불필요 — 매 렌더 재등록이 드래그 렉을 만듦 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [continuousInputStrategy, isOpen]); - - const handlePointPointerDown = ( - event: React.PointerEvent, - target: DragTarget, - ) => { - if (event.button !== 0 || spaceHeldRef.current) return; - // 드래그가 시작되면 텍스트 편집은 끝난 것으로 본다. - // 아래 preventDefault가 포커스를 남기는데, 드래그는 베지어 입력값도 같이 바꾼다. - // 편집 세션을 안 끊으면 뒤이은 Escape가 드래그 결과까지 함께 되돌린다 - const active = document.activeElement; - if (active instanceof HTMLElement && active.matches('input, textarea')) { - active.blur(); - } - event.preventDefault(); - event.stopPropagation(); - cancelAutoFit(); - // 잡는 동안 grabbing 유지 - 좌표 클램프로 포인터가 핸들 밖에 있어도 복귀 방지 - beginDragCursor('grabbing'); - dragTargetRef.current = target; - }; - - const handleWheel = (event: React.WheelEvent) => { - event.preventDefault(); - event.stopPropagation(); - cancelAutoFit(); - const svg = svgRef.current; - if (!svg) return; - - const rect = svg.getBoundingClientRect(); - const scale = viewScaleRef.current; - const aspect = rect.width / Math.max(rect.height, 1); - const { base, vbW, vbH } = viewDims(scale, aspect); - - if (event.ctrlKey || event.metaKey) { - const fracX = (event.clientX - rect.left) / rect.width; - const fracY = (event.clientY - rect.top) / rect.height; - - const delta = -event.deltaY * ZOOM_SENSITIVITY; - const factor = Math.exp(delta); - const newScale = Math.min(Math.max(scale * factor, MIN_ZOOM), MAX_ZOOM); - const newView = viewDims(newScale, aspect); - - const off = viewOffsetRef.current; - const worldX = off.x - (vbW - base) / 2 + fracX * vbW; - const worldY = off.y - (vbH - base) / 2 + fracY * vbH; - const newOff = { - x: worldX + (newView.vbW - newView.base) / 2 - fracX * newView.vbW, - y: worldY + (newView.vbH - newView.base) / 2 - fracY * newView.vbH, - }; - - applyView(newOff, newScale); - return; - } - - const off = viewOffsetRef.current; - const dx = - ((event.shiftKey ? event.deltaX || event.deltaY : event.deltaX) / - rect.width) * - vbW; - const dy = - ((event.shiftKey && !event.deltaX ? 0 : event.deltaY) / rect.height) * - vbH; - const newOff = { x: off.x + dx, y: off.y + dy }; - viewOffsetRef.current = newOff; - setViewOffset(newOff); - }; - - const handleSvgPointerDown = (event: React.PointerEvent) => { - cancelAutoFit(); - activePointersRef.current.set(event.pointerId, { - clientX: event.clientX, - clientY: event.clientY, - }); - - if (activePointersRef.current.size === 2) { - dragTargetRef.current = null; - isPanningRef.current = false; - const pointers = Array.from(activePointersRef.current.values()); - const dx = pointers[1].clientX - pointers[0].clientX; - const dy = pointers[1].clientY - pointers[0].clientY; - pinchStartDistRef.current = Math.sqrt(dx * dx + dy * dy); - pinchStartScaleRef.current = viewScaleRef.current; - pinchStartOffsetRef.current = { ...viewOffsetRef.current }; - const rect = svgRef.current?.getBoundingClientRect(); - if (rect) { - const midX = (pointers[0].clientX + pointers[1].clientX) / 2; - const midY = (pointers[0].clientY + pointers[1].clientY) / 2; - pinchStartMidFracRef.current = { - x: (midX - rect.left) / rect.width, - y: (midY - rect.top) / rect.height, - }; - } - return; - } - - if (event.button === 1 || (event.button === 0 && spaceHeldRef.current)) { - event.preventDefault(); - if (dragTargetRef.current) return; - isPanningRef.current = true; - panStartRef.current = { - clientX: event.clientX, - clientY: event.clientY, - offsetX: viewOffsetRef.current.x, - offsetY: viewOffsetRef.current.y, - }; - beginDragCursor('grabbing'); - if (svgRef.current) { - svgRef.current.style.cursor = 'grabbing'; - } - } - }; - - const handleDoubleClick = (_event: React.MouseEvent) => { - cancelAutoFit(); - applyView({ x: 0, y: 0 }, 1); - }; - + const { + svgRef, + localBezierRef, + editorSize, + localBezier, + setLocalBezier, + bezierInput, + setBezierInput, + viewOffset, + viewScale, + previewCount, + previewActive, + cancelAutoFit, + applyView, + observeEditorArea, + handlePreviewPointerDown, + handlePointPointerDown, + handleWheel, + handleSvgPointerDown, + handleDoubleClick, + } = useCounterAnimationCanvasSession({ + isOpen, + mode, + initialPreset, + continuousInputStrategy, + setNameInput, + setScaleInput, + setDurationInput, + setErrorText, + }); const selectedPreset = findBezierPresetId(localBezier); const customLabel = t('counterSetting.presetCustom') || 'Custom'; diff --git a/src/renderer/components/main/Modal/content/editors/useCounterAnimationCanvasSession.ts b/src/renderer/components/main/Modal/content/editors/useCounterAnimationCanvasSession.ts new file mode 100644 index 000000000..db9662f77 --- /dev/null +++ b/src/renderer/components/main/Modal/content/editors/useCounterAnimationCanvasSession.ts @@ -0,0 +1,659 @@ +import { beginDragCursor, endDragCursor } from '@utils/core/dragCursor'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import type { CounterAnimationBezier } from '@src/types/key/keys'; +import type { CounterAnimationPreset } from '@src/types/key/counterAnimation'; +import { clampCounterBezier } from '@utils/cubicBezier'; +import { + createRafLatestScheduler, + type ContinuousInputStrategy, +} from '@utils/animation/rafLatestScheduler'; +import { + COUNTER_EDITOR_PADDING as EDITOR_PADDING, + COUNTER_EDITOR_SIZE as EDITOR_SIZE, + COUNTER_EDITOR_TOTAL_SIZE as TOTAL_SIZE, + createCounterAnimationEditorState as toInitialState, + formatCounterBezierInput as formatBezierInput, + resolveCounterEditorViewDimensions as viewDims, +} from './counterAnimationEditorModel'; +import { COUNTER_ANIMATION_HANDLE_RADIUS as HANDLE_RADIUS } from './CounterAnimationCurveCanvas'; + +type EditorMode = 'create' | 'edit'; +type DragTarget = 'p1' | 'p2' | null; + +const EDITOR_RENDER_SIZE = 220; +const PAN_MARGIN = 14; +const MIN_ZOOM = 0.15; +const MAX_ZOOM = 3.0; +const ZOOM_SENSITIVITY = 0.002; +const AUTO_FIT_MARGIN = 14; +const AUTO_FIT_DURATION = 260; + +interface UseCounterAnimationCanvasSessionOptions { + isOpen: boolean; + mode: EditorMode; + initialPreset?: CounterAnimationPreset | null; + continuousInputStrategy: ContinuousInputStrategy; + setNameInput: React.Dispatch>; + setScaleInput: React.Dispatch>; + setDurationInput: React.Dispatch>; + setErrorText: React.Dispatch>; +} + +export const useCounterAnimationCanvasSession = ({ + isOpen, + mode, + initialPreset, + continuousInputStrategy, + setNameInput, + setScaleInput, + setDurationInput, + setErrorText, +}: UseCounterAnimationCanvasSessionOptions) => { + const svgRef = useRef(null); + const dragTargetRef = useRef(null); + const localBezierRef = useRef([ + 0.25, 0.46, 0.45, 0.94, + ]); + const draggedBezierRef = useRef(null); + const viewOffsetRef = useRef({ x: 0, y: 0 }); + const viewScaleRef = useRef(1); + const isPanningRef = useRef(false); + const panStartRef = useRef({ + clientX: 0, + clientY: 0, + offsetX: 0, + offsetY: 0, + }); + const spaceHeldRef = useRef(false); + const activePointersRef = useRef< + Map + >(new Map()); + const pinchStartDistRef = useRef(0); + const pinchStartScaleRef = useRef(1); + const pinchStartOffsetRef = useRef({ x: 0, y: 0 }); + const pinchStartMidFracRef = useRef({ x: 0, y: 0 }); + const autoFitRafRef = useRef(null); + const editorSizeRef = useRef({ + width: EDITOR_RENDER_SIZE, + height: EDITOR_RENDER_SIZE, + }); + + const [editorSize, setEditorSize] = useState({ + width: EDITOR_RENDER_SIZE, + height: EDITOR_RENDER_SIZE, + }); + const [localBezier, setLocalBezier] = useState([ + 0.25, 0.46, 0.45, 0.94, + ]); + const [bezierInput, setBezierInput] = useState('0.25, 0.46, 0.45, 0.94'); + const [viewOffset, setViewOffset] = useState({ x: 0, y: 0 }); + const [viewScale, setViewScale] = useState(1); + const [previewCount, setPreviewCount] = useState(0); + const [previewActive, setPreviewActive] = useState(false); + + const cancelAutoFit = () => { + if (autoFitRafRef.current) { + cancelAnimationFrame(autoFitRafRef.current); + autoFitRafRef.current = null; + } + }; + + const applyView = (offset: { x: number; y: number }, scale: number) => { + viewOffsetRef.current = offset; + viewScaleRef.current = scale; + setViewOffset(offset); + setViewScale(scale); + }; + + const computeAutoFit = (bezier: CounterAnimationBezier) => { + const pts = [ + { x: EDITOR_PADDING, y: EDITOR_PADDING + EDITOR_SIZE }, + { x: EDITOR_PADDING + EDITOR_SIZE, y: EDITOR_PADDING }, + { + x: EDITOR_PADDING + bezier[0] * EDITOR_SIZE, + y: EDITOR_PADDING + (1 - bezier[1]) * EDITOR_SIZE, + }, + { + x: EDITOR_PADDING + bezier[2] * EDITOR_SIZE, + y: EDITOR_PADDING + (1 - bezier[3]) * EDITOR_SIZE, + }, + ]; + + const ptsMinX = Math.min(...pts.map((point) => point.x)); + const ptsMaxX = Math.max(...pts.map((point) => point.x)); + const ptsMinY = Math.min(...pts.map((point) => point.y)); + const ptsMaxY = Math.max(...pts.map((point) => point.y)); + + const minX = Math.min(ptsMinX, EDITOR_PADDING); + const maxX = Math.max(ptsMaxX, EDITOR_PADDING + EDITOR_SIZE); + const minY = Math.min(ptsMinY, EDITOR_PADDING); + const maxY = Math.max(ptsMaxY, EDITOR_PADDING + EDITOR_SIZE); + + const { width: canvasW, height: canvasH } = editorSizeRef.current; + const aspect = canvasH > 0 ? canvasW / canvasH : 1; + const defaultView = viewDims(1, aspect); + const defaultExtraW = (defaultView.vbW - TOTAL_SIZE) / 2; + const defaultExtraH = (defaultView.vbH - TOTAL_SIZE) / 2; + + if ( + minX >= -defaultExtraW && + maxX <= TOTAL_SIZE + defaultExtraW && + minY >= -defaultExtraH && + maxY <= TOTAL_SIZE + defaultExtraH + ) { + return { offset: { x: 0, y: 0 }, scale: 1 }; + } + + const rawW = maxX - minX; + const rawH = maxY - minY; + const rawSize = Math.max(rawW, rawH, TOTAL_SIZE); + const estHandleR = (HANDLE_RADIUS * rawSize) / TOTAL_SIZE; + const margin = AUTO_FIT_MARGIN + estHandleR; + + const fitMinX = minX - margin; + const fitMaxX = maxX + margin; + const fitMinY = minY - margin; + const fitMaxY = maxY + margin; + + const needW = fitMaxX - fitMinX; + const needH = fitMaxY - fitMinY; + const needSize = Math.max( + needW / Math.max(aspect, 1), + needH * Math.min(Math.max(aspect, 0.01), 1), + TOTAL_SIZE, + ); + + const maxVB = TOTAL_SIZE / MIN_ZOOM; + const vbSize = Math.min(needSize, maxVB); + const fitScale = TOTAL_SIZE / vbSize; + + const cx = (fitMinX + fitMaxX) / 2; + const cy = (fitMinY + fitMaxY) / 2; + + return { + offset: { x: cx - vbSize / 2, y: cy - vbSize / 2 }, + scale: fitScale, + }; + }; + + useEffect(() => { + if (!isOpen) return; + const initial = toInitialState(initialPreset); + localBezierRef.current = initial.bezier; + setNameInput(initial.name); + setLocalBezier(initial.bezier); + setBezierInput(formatBezierInput(initial.bezier)); + setScaleInput(String(Math.round(initial.scale * 100) / 100)); + setDurationInput(String(initial.durationMs)); + setErrorText(''); + + const fit = + mode === 'edit' && initialPreset + ? computeAutoFit(initial.bezier) + : { offset: { x: 0, y: 0 }, scale: 1 }; + applyView(fit.offset, fit.scale); + isPanningRef.current = false; + activePointersRef.current.clear(); + pinchStartDistRef.current = 0; + setPreviewCount(0); + setPreviewActive(false); + // setter와 계산 함수는 원본과 같은 열림 경계에서만 사용한다. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialPreset, isOpen, mode]); + + useEffect(() => { + if (isOpen) return; + setPreviewActive(false); + isPanningRef.current = false; + spaceHeldRef.current = false; + activePointersRef.current.clear(); + pinchStartDistRef.current = 0; + cancelAutoFit(); + }, [isOpen]); + + useEffect(() => () => cancelAutoFit(), []); + + const observeEditorArea = useCallback((area: HTMLDivElement | null) => { + if (!area) return; + const measure = () => { + const rect = area.getBoundingClientRect(); + const width = Math.floor(rect.width); + const height = Math.floor(rect.height); + if (width <= 0 || height <= 0) return; + editorSizeRef.current = { width, height }; + setEditorSize({ width, height }); + }; + + measure(); + const observer = new ResizeObserver(measure); + observer.observe(area); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.code === 'Space' && !event.repeat) { + spaceHeldRef.current = true; + const tag = (event.target as HTMLElement)?.tagName; + if (tag !== 'INPUT' && tag !== 'TEXTAREA') { + event.preventDefault(); + } + if (svgRef.current) { + svgRef.current.style.cursor = 'grab'; + } + } + }; + + const handleKeyUp = (event: KeyboardEvent) => { + if (event.code === 'Space') { + spaceHeldRef.current = false; + if (svgRef.current && !isPanningRef.current) { + svgRef.current.style.cursor = 'default'; + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + window.addEventListener('keyup', handleKeyUp); + return () => { + window.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('keyup', handleKeyUp); + spaceHeldRef.current = false; + }; + }, [isOpen]); + + const handlePreviewPointerDown = (event: React.PointerEvent) => { + event.preventDefault(); + setPreviewActive(true); + setPreviewCount((previous) => previous + 1); + + const handleUp = () => { + setPreviewActive(false); + window.removeEventListener('pointerup', handleUp); + window.removeEventListener('pointercancel', handleUp); + }; + window.addEventListener('pointerup', handleUp); + window.addEventListener('pointercancel', handleUp); + }; + + const animateViewToFit = (bezier: CounterAnimationBezier) => { + cancelAutoFit(); + + const target = computeAutoFit(bezier); + const fromOffset = { ...viewOffsetRef.current }; + const fromScale = viewScaleRef.current; + + const EPS = 1e-3; + if ( + Math.abs(target.offset.x - fromOffset.x) < EPS && + Math.abs(target.offset.y - fromOffset.y) < EPS && + Math.abs(target.scale - fromScale) < EPS + ) { + return; + } + + const start = performance.now(); + const easeOutCubic = (progress: number) => 1 - Math.pow(1 - progress, 3); + + const tick = (now: number) => { + const elapsed = now - start; + const progress = Math.min(1, elapsed / AUTO_FIT_DURATION); + const easing = easeOutCubic(progress); + + const newOffset = { + x: fromOffset.x + (target.offset.x - fromOffset.x) * easing, + y: fromOffset.y + (target.offset.y - fromOffset.y) * easing, + }; + const newScale = fromScale + (target.scale - fromScale) * easing; + + applyView(newOffset, newScale); + + if (progress < 1) { + autoFitRafRef.current = requestAnimationFrame(tick); + } else { + autoFitRafRef.current = null; + } + }; + + autoFitRafRef.current = requestAnimationFrame(tick); + }; + + const updateBezierFromClient = ( + clientX: number, + clientY: number, + target: DragTarget, + ) => { + if (!target || !svgRef.current) return; + const rect = svgRef.current.getBoundingClientRect(); + + const fracX = (clientX - rect.left) / rect.width; + const fracY = (clientY - rect.top) / rect.height; + + const scale = viewScaleRef.current; + const offset = viewOffsetRef.current; + const { base, vbW, vbH } = viewDims( + scale, + rect.width / Math.max(rect.height, 1), + ); + const extraW = (vbW - base) / 2; + const extraH = (vbH - base) / 2; + + const worldX = offset.x - extraW + fracX * vbW; + const worldY = offset.y - extraH + fracY * vbH; + + const bezierX = (worldX - EDITOR_PADDING) / EDITOR_SIZE; + const bezierY = 1 - (worldY - EDITOR_PADDING) / EDITOR_SIZE; + + const current = localBezierRef.current; + const nextBezier: CounterAnimationBezier = + target === 'p1' + ? [ + Math.min(Math.max(bezierX, 0), 1), + Math.min(Math.max(bezierY, -4), 4), + current[2], + current[3], + ] + : [ + current[0], + current[1], + Math.min(Math.max(bezierX, 0), 1), + Math.min(Math.max(bezierY, -4), 4), + ]; + + const clamped = clampCounterBezier(nextBezier); + localBezierRef.current = clamped; + setLocalBezier(clamped); + setBezierInput(formatBezierInput(clamped)); + draggedBezierRef.current = clamped; + + const hx = + EDITOR_PADDING + + (target === 'p1' ? clamped[0] : clamped[2]) * EDITOR_SIZE; + const hy = + EDITOR_PADDING + + (1 - (target === 'p1' ? clamped[1] : clamped[3])) * EDITOR_SIZE; + + const effectiveScale = Math.max(scale, 0.8); + const margin = PAN_MARGIN / effectiveScale; + let nx = offset.x; + let ny = offset.y; + + const viewLeft = offset.x - extraW; + const viewTop = offset.y - extraH; + if (hx < viewLeft + margin) nx = hx - margin + extraW; + else if (hx > viewLeft + vbW - margin) nx = hx - vbW + margin + extraW; + if (hy < viewTop + margin) ny = hy - margin + extraH; + else if (hy > viewTop + vbH - margin) ny = hy - vbH + margin + extraH; + + if (nx !== offset.x || ny !== offset.y) { + const next = { x: nx, y: ny }; + viewOffsetRef.current = next; + setViewOffset(next); + } + }; + + useEffect(() => { + if (!isOpen) return; + + const applyPointerMove = (event: PointerEvent) => { + if ( + activePointersRef.current.size === 2 && + pinchStartDistRef.current > 0 + ) { + const svg = svgRef.current; + if (!svg) return; + const pointers = Array.from(activePointersRef.current.values()); + const dx = pointers[1].clientX - pointers[0].clientX; + const dy = pointers[1].clientY - pointers[0].clientY; + const dist = Math.sqrt(dx * dx + dy * dy); + const ratio = dist / pinchStartDistRef.current; + const oldScale = pinchStartScaleRef.current; + const newScale = Math.min( + Math.max(oldScale * ratio, MIN_ZOOM), + MAX_ZOOM, + ); + + const rect = svg.getBoundingClientRect(); + const aspect = rect.width / Math.max(rect.height, 1); + const fracX = pinchStartMidFracRef.current.x; + const fracY = pinchStartMidFracRef.current.y; + const oldView = viewDims(oldScale, aspect); + const newView = viewDims(newScale, aspect); + const startOffset = pinchStartOffsetRef.current; + const worldX = + startOffset.x - + (oldView.vbW - oldView.base) / 2 + + fracX * oldView.vbW; + const worldY = + startOffset.y - + (oldView.vbH - oldView.base) / 2 + + fracY * oldView.vbH; + const newOffset = { + x: worldX + (newView.vbW - newView.base) / 2 - fracX * newView.vbW, + y: worldY + (newView.vbH - newView.base) / 2 - fracY * newView.vbH, + }; + + applyView(newOffset, newScale); + return; + } + + if (isPanningRef.current) { + const svg = svgRef.current; + if (!svg) return; + const rect = svg.getBoundingClientRect(); + const scale = viewScaleRef.current; + const { vbW, vbH } = viewDims( + scale, + rect.width / Math.max(rect.height, 1), + ); + const dxClient = event.clientX - panStartRef.current.clientX; + const dyClient = event.clientY - panStartRef.current.clientY; + const dxWorld = -(dxClient / rect.width) * vbW; + const dyWorld = -(dyClient / rect.height) * vbH; + + const newOffset = { + x: panStartRef.current.offsetX + dxWorld, + y: panStartRef.current.offsetY + dyWorld, + }; + viewOffsetRef.current = newOffset; + setViewOffset(newOffset); + return; + } + + updateBezierFromClient( + event.clientX, + event.clientY, + dragTargetRef.current, + ); + }; + const moveScheduler = createRafLatestScheduler( + applyPointerMove, + continuousInputStrategy, + ); + const handlePointerMove = (event: PointerEvent) => { + if (activePointersRef.current.has(event.pointerId)) { + activePointersRef.current.set(event.pointerId, { + clientX: event.clientX, + clientY: event.clientY, + }); + } + moveScheduler.push(event); + }; + + const handlePointerUp = (event: PointerEvent) => { + moveScheduler.flush(); + activePointersRef.current.delete(event.pointerId); + if (activePointersRef.current.size < 2) { + pinchStartDistRef.current = 0; + } + + if (isPanningRef.current) { + isPanningRef.current = false; + endDragCursor(); + if (svgRef.current) { + svgRef.current.style.cursor = spaceHeldRef.current + ? 'grab' + : 'default'; + } + return; + } + + if (dragTargetRef.current) { + endDragCursor(); + animateViewToFit(localBezierRef.current); + } + + draggedBezierRef.current = null; + dragTargetRef.current = null; + }; + + window.addEventListener('pointermove', handlePointerMove); + window.addEventListener('pointerup', handlePointerUp); + window.addEventListener('pointercancel', handlePointerUp); + + return () => { + moveScheduler.cancel(); + if (isPanningRef.current || dragTargetRef.current) endDragCursor(); + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', handlePointerUp); + window.removeEventListener('pointercancel', handlePointerUp); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [continuousInputStrategy, isOpen]); + + const handlePointPointerDown = ( + event: React.PointerEvent, + target: DragTarget, + ) => { + if (event.button !== 0 || spaceHeldRef.current) return; + const active = document.activeElement; + if (active instanceof HTMLElement && active.matches('input, textarea')) { + active.blur(); + } + event.preventDefault(); + event.stopPropagation(); + cancelAutoFit(); + beginDragCursor('grabbing'); + dragTargetRef.current = target; + }; + + const handleWheel = (event: React.WheelEvent) => { + event.preventDefault(); + event.stopPropagation(); + cancelAutoFit(); + const svg = svgRef.current; + if (!svg) return; + + const rect = svg.getBoundingClientRect(); + const scale = viewScaleRef.current; + const aspect = rect.width / Math.max(rect.height, 1); + const { base, vbW, vbH } = viewDims(scale, aspect); + + if (event.ctrlKey || event.metaKey) { + const fracX = (event.clientX - rect.left) / rect.width; + const fracY = (event.clientY - rect.top) / rect.height; + + const delta = -event.deltaY * ZOOM_SENSITIVITY; + const factor = Math.exp(delta); + const newScale = Math.min(Math.max(scale * factor, MIN_ZOOM), MAX_ZOOM); + const newView = viewDims(newScale, aspect); + + const offset = viewOffsetRef.current; + const worldX = offset.x - (vbW - base) / 2 + fracX * vbW; + const worldY = offset.y - (vbH - base) / 2 + fracY * vbH; + const newOffset = { + x: worldX + (newView.vbW - newView.base) / 2 - fracX * newView.vbW, + y: worldY + (newView.vbH - newView.base) / 2 - fracY * newView.vbH, + }; + + applyView(newOffset, newScale); + return; + } + + const offset = viewOffsetRef.current; + const dx = + ((event.shiftKey ? event.deltaX || event.deltaY : event.deltaX) / + rect.width) * + vbW; + const dy = + ((event.shiftKey && !event.deltaX ? 0 : event.deltaY) / rect.height) * + vbH; + const newOffset = { x: offset.x + dx, y: offset.y + dy }; + viewOffsetRef.current = newOffset; + setViewOffset(newOffset); + }; + + const handleSvgPointerDown = (event: React.PointerEvent) => { + cancelAutoFit(); + activePointersRef.current.set(event.pointerId, { + clientX: event.clientX, + clientY: event.clientY, + }); + + if (activePointersRef.current.size === 2) { + dragTargetRef.current = null; + isPanningRef.current = false; + const pointers = Array.from(activePointersRef.current.values()); + const dx = pointers[1].clientX - pointers[0].clientX; + const dy = pointers[1].clientY - pointers[0].clientY; + pinchStartDistRef.current = Math.sqrt(dx * dx + dy * dy); + pinchStartScaleRef.current = viewScaleRef.current; + pinchStartOffsetRef.current = { ...viewOffsetRef.current }; + const rect = svgRef.current?.getBoundingClientRect(); + if (rect) { + const midX = (pointers[0].clientX + pointers[1].clientX) / 2; + const midY = (pointers[0].clientY + pointers[1].clientY) / 2; + pinchStartMidFracRef.current = { + x: (midX - rect.left) / rect.width, + y: (midY - rect.top) / rect.height, + }; + } + return; + } + + if (event.button === 1 || (event.button === 0 && spaceHeldRef.current)) { + event.preventDefault(); + if (dragTargetRef.current) return; + isPanningRef.current = true; + panStartRef.current = { + clientX: event.clientX, + clientY: event.clientY, + offsetX: viewOffsetRef.current.x, + offsetY: viewOffsetRef.current.y, + }; + beginDragCursor('grabbing'); + if (svgRef.current) { + svgRef.current.style.cursor = 'grabbing'; + } + } + }; + + const handleDoubleClick = (_event: React.MouseEvent) => { + cancelAutoFit(); + applyView({ x: 0, y: 0 }, 1); + }; + + return { + svgRef, + localBezierRef, + editorSize, + localBezier, + setLocalBezier, + bezierInput, + setBezierInput, + viewOffset, + viewScale, + previewCount, + previewActive, + cancelAutoFit, + applyView, + observeEditorArea, + handlePreviewPointerDown, + handlePointPointerDown, + handleWheel, + handleSvgPointerDown, + handleDoubleClick, + }; +}; From 614af8a3da444c61076bcb97c305f7d89df06d73 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 18:18:56 +0900 Subject: [PATCH 185/245] =?UTF-8?q?test:=20=ED=94=84=EB=A1=A0=ED=8A=B8=20?= =?UTF-8?q?=ED=94=8C=EB=9F=AC=EC=8B=9C=20=ED=95=B8=EB=93=9C=EC=85=B0?= =?UTF-8?q?=EC=9D=B4=ED=81=AC=20=EA=B3=84=EC=95=BD=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/state/app_state/tests.rs | 130 +++++++++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/src-tauri/src/state/app_state/tests.rs b/src-tauri/src/state/app_state/tests.rs index bab3ccfa9..ecd9e1968 100644 --- a/src-tauri/src/state/app_state/tests.rs +++ b/src-tauri/src/state/app_state/tests.rs @@ -2102,6 +2102,136 @@ fn history_flush_completes_only_after_every_window_ack() { )); } +#[test] +fn lifecycle_ack_rejects_stale_duplicate_and_unknown_windows_without_mutating_pending() { + let targets = HashSet::from(["main".to_string(), OVERLAY_LABEL.to_string()]); + let mut slot = Some(EditorFlushHandshake { + id: "lifecycle-1".to_string(), + completion: EditorFlushCompletion::Lifecycle(FrontendLifecycleAction::Quit), + target_windows: targets.clone(), + pending_windows: targets.clone(), + }); + let gate = Arc::new(HistoryAdmissionGate::default()); + + assert!( + acknowledge_editor_flush_handshake(&mut slot, "stale-lifecycle", "main", &gate,).is_none() + ); + assert_eq!(slot.as_ref().unwrap().pending_windows, targets); + assert!( + acknowledge_editor_flush_handshake(&mut slot, "lifecycle-1", "unknown", &gate,).is_none() + ); + assert_eq!(slot.as_ref().unwrap().pending_windows, targets); + + assert!(acknowledge_editor_flush_handshake(&mut slot, "lifecycle-1", "main", &gate,).is_none()); + assert_eq!( + slot.as_ref().unwrap().pending_windows, + HashSet::from([OVERLAY_LABEL.to_string()]) + ); + assert!(acknowledge_editor_flush_handshake(&mut slot, "lifecycle-1", "main", &gate,).is_none()); + assert_eq!( + slot.as_ref().unwrap().pending_windows, + HashSet::from([OVERLAY_LABEL.to_string()]) + ); + + let completed = + acknowledge_editor_flush_handshake(&mut slot, "lifecycle-1", OVERLAY_LABEL, &gate) + .expect("the last exact acknowledgement should complete lifecycle"); + let EditorFlushAcknowledge::LifecycleReady(completed) = completed else { + panic!("lifecycle acknowledgement should not enter history close"); + }; + assert_eq!(completed.id, "lifecycle-1"); + assert_eq!(completed.target_windows, targets); + assert!(slot.is_none()); +} + +#[test] +fn concurrent_duplicate_last_ack_closes_history_gate_exactly_once() { + let (sender, _receiver) = tokio::sync::oneshot::channel(); + let slot = Arc::new(Mutex::new(Some(EditorFlushHandshake { + id: "history-concurrent".to_string(), + completion: EditorFlushCompletion::History { + operation_id: "00000000-0000-0000-0000-000000000006".to_string(), + sender: Some(sender), + phase: FrontendHistoryFlushPhase::Collecting, + barrier: None, + }, + target_windows: HashSet::from(["main".to_string()]), + pending_windows: HashSet::from(["main".to_string()]), + }))); + let gate = Arc::new(HistoryAdmissionGate::default()); + let start = Arc::new(std::sync::Barrier::new(3)); + let workers = (0..2) + .map(|_| { + let slot = Arc::clone(&slot); + let gate = Arc::clone(&gate); + let start = Arc::clone(&start); + thread::spawn(move || { + start.wait(); + matches!( + acknowledge_editor_flush_handshake( + &mut slot.lock(), + "history-concurrent", + "main", + &gate, + ), + Some(EditorFlushAcknowledge::HistoryClosing { .. }) + ) + }) + }) + .collect::>(); + + start.wait(); + assert_eq!( + workers + .into_iter() + .map(|worker| worker.join().unwrap()) + .filter(|closed| *closed) + .count(), + 1 + ); + assert!(gate.is_closed()); + assert!(slot.lock().as_ref().is_some_and(|active| { + active.completion.history_phase() == Some(FrontendHistoryFlushPhase::Closing) + })); + + drop(slot.lock().take()); + assert!(!gate.is_closed()); +} + +#[test] +fn history_gate_close_failure_returns_exact_error_and_removes_only_its_handshake() { + let gate = Arc::new(HistoryAdmissionGate::default()); + let existing = gate.close("00000000-0000-0000-0000-000000000007").unwrap(); + let (sender, _receiver) = tokio::sync::oneshot::channel(); + let mut slot = Some(EditorFlushHandshake { + id: "history-busy".to_string(), + completion: EditorFlushCompletion::History { + operation_id: "00000000-0000-0000-0000-000000000008".to_string(), + sender: Some(sender), + phase: FrontendHistoryFlushPhase::Collecting, + barrier: None, + }, + target_windows: HashSet::from(["main".to_string()]), + pending_windows: HashSet::from(["main".to_string()]), + }); + + let failed = acknowledge_editor_flush_handshake(&mut slot, "history-busy", "main", &gate) + .expect("the last acknowledgement should report gate close failure"); + let EditorFlushAcknowledge::HistoryCloseFailed { handshake, error } = failed else { + panic!("a preclosed gate should fail before the closing phase"); + }; + assert_eq!(handshake.id, "history-busy"); + assert_eq!(error, crate::state::history::HISTORY_IN_PROGRESS); + assert!(slot.is_none()); + assert_eq!( + gate.owner().as_deref(), + Some("00000000-0000-0000-0000-000000000007") + ); + + drop(existing); + assert!(!gate.is_closed()); +} + #[test] fn acknowledged_history_window_blocks_new_mutations_while_other_window_drains() { let (sender, _receiver) = tokio::sync::oneshot::channel(); From 1b6a7f5caef4469dcd6d9a04c98f9b1b7c723817 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 18:18:56 +0900 Subject: [PATCH 186/245] =?UTF-8?q?refactor:=20=ED=94=84=EB=A1=A0=ED=8A=B8?= =?UTF-8?q?=20=ED=94=8C=EB=9F=AC=EC=8B=9C=20=ED=95=B8=EB=93=9C=EC=85=B0?= =?UTF-8?q?=EC=9D=B4=ED=81=AC=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/state/app_state.rs | 283 +----------------- .../src/state/app_state/frontend_flush.rs | 274 +++++++++++++++++ 2 files changed, 289 insertions(+), 268 deletions(-) create mode 100644 src-tauri/src/state/app_state/frontend_flush.rs diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs index 211d1ae03..8b8dc195a 100644 --- a/src-tauri/src/state/app_state.rs +++ b/src-tauri/src/state/app_state.rs @@ -28,6 +28,7 @@ use tokio::sync::oneshot; mod audio_runtime; mod counter_runtime; mod css_runtime; +mod frontend_flush; mod frontend_lifecycle; mod keyboard_runtime; mod lifecycle_runtime; @@ -39,6 +40,19 @@ mod window_geometry; use css_runtime::collect_authorized_css_paths; #[cfg(test)] use css_runtime::global_css_watch_path; +use frontend_flush::{ + acknowledge_editor_flush_handshake, begin_history_gate_close, + emit_frontend_history_flush_released, frontend_history_mutation_blocked, + install_history_handshake, install_lifecycle_handshake, take_cancelable_editor_flush_handshake, + take_editor_flush_handshake, EditorFlushAcknowledge, EditorFlushCompletion, + EditorFlushHandshake, EditorFlushRequest, FrontendFlushAction, FrontendHistoryFlushPhase, + LifecycleHandshakeInstall, EDITOR_FLUSH_HANDSHAKE_TIMEOUT, +}; +pub(crate) use frontend_flush::{ + FrontendHistoryFlushReady, HISTORY_FRONTEND_FLUSH_BUSY, HISTORY_FRONTEND_FLUSH_CANCELED, + HISTORY_FRONTEND_FLUSH_EMIT_FAILED, HISTORY_FRONTEND_FLUSH_INTERRUPTED, + HISTORY_FRONTEND_FLUSH_TIMEOUT, +}; use keyboard_runtime::{ bootstrap_keyboard_state, build_key_sound_binding_table, KeySoundBindingTable, KeyboardDaemonTask, @@ -76,9 +90,7 @@ use window_geometry::{ pub use window_geometry::{LogicalPoint, LogicalRect, PanelDragContext}; use super::{ - history::{ - HistoryAdmissionGate, HistoryAdmissionLease, HistoryBarrierLease, HistoryBarrierWaiter, - }, + history::HistoryAdmissionLease, plugin::{PluginAuthorityLease, PluginRuntimeAuthority}, store::{ AdmittedEditorTransaction, AdmittedGestureCommit, AppStore, PluginInstancesResetScope, @@ -257,14 +269,8 @@ const PANEL_CLOSE_ACK_TIMEOUT: Duration = Duration::from_millis(1_500); const OVERLAY_MARGIN: f64 = 40.0; const OVERLAY_BOUNDS_DEBOUNCE_MS: u64 = 400; const OVERLAY_CREATION_LOCK_TIMEOUT: Duration = Duration::from_secs(10); -const EDITOR_FLUSH_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10); const SHUTDOWN_WATCHDOG_TIMEOUT: Duration = Duration::from_secs(5); const SHUTDOWN_WATCHDOG_EXIT_CODE: i32 = 1; -pub(crate) const HISTORY_FRONTEND_FLUSH_BUSY: &str = "HISTORY_FRONTEND_FLUSH_BUSY"; -pub(crate) const HISTORY_FRONTEND_FLUSH_CANCELED: &str = "HISTORY_FRONTEND_FLUSH_CANCELED"; -pub(crate) const HISTORY_FRONTEND_FLUSH_EMIT_FAILED: &str = "HISTORY_FRONTEND_FLUSH_EMIT_FAILED"; -pub(crate) const HISTORY_FRONTEND_FLUSH_INTERRUPTED: &str = "HISTORY_FRONTEND_FLUSH_INTERRUPTED"; -pub(crate) const HISTORY_FRONTEND_FLUSH_TIMEOUT: &str = "HISTORY_FRONTEND_FLUSH_TIMEOUT"; pub(crate) const MUTATION_SHUTDOWN_STARTED: &str = "MUTATION_SHUTDOWN_STARTED"; struct ShutdownWatchdogState { @@ -296,265 +302,6 @@ fn overlay_close_action(force_close: bool, lifecycle_pending: bool) -> OverlayCl } } -#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -enum FrontendFlushAction { - Quit, - Restart, - History, -} - -impl From for FrontendFlushAction { - fn from(action: FrontendLifecycleAction) -> Self { - match action { - FrontendLifecycleAction::Quit => Self::Quit, - FrontendLifecycleAction::Restart => Self::Restart, - } - } -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct EditorFlushRequest { - handshake_id: String, - action: FrontendFlushAction, -} - -enum EditorFlushCompletion { - Lifecycle(FrontendLifecycleAction), - History { - operation_id: String, - sender: Option>>, - phase: FrontendHistoryFlushPhase, - barrier: Option, - }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum FrontendHistoryFlushPhase { - Collecting, - Closing, - Running, -} - -impl EditorFlushCompletion { - fn is_lifecycle(&self) -> bool { - matches!(self, Self::Lifecycle(_)) - } - - fn is_history(&self) -> bool { - matches!(self, Self::History { .. }) - } - - fn history_phase(&self) -> Option { - match self { - Self::History { phase, .. } => Some(*phase), - Self::Lifecycle(_) => None, - } - } -} - -pub(crate) struct FrontendHistoryFlushReady { - barrier: Option, - complete: Option>, -} - -impl FrontendHistoryFlushReady { - pub(crate) fn take_barrier(&mut self) -> HistoryBarrierLease { - self.barrier - .take() - .expect("history flush barrier can only be taken once") - } -} - -impl Drop for FrontendHistoryFlushReady { - fn drop(&mut self) { - drop(self.barrier.take()); - if let Some(complete) = self.complete.take() { - complete(); - } - } -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct FrontendHistoryFlushReleased { - handshake_id: String, -} - -struct EditorFlushHandshake { - id: String, - completion: EditorFlushCompletion, - target_windows: HashSet, - pending_windows: HashSet, -} - -fn take_editor_flush_handshake( - slot: &mut Option, - handshake_id: &str, -) -> Option { - if slot - .as_ref() - .is_some_and(|active| active.id == handshake_id) - { - slot.take() - } else { - None - } -} - -fn take_cancelable_editor_flush_handshake( - slot: &mut Option, - handshake_id: &str, -) -> Option { - if slot.as_ref().is_some_and(|active| { - active.id == handshake_id - && active.completion.history_phase() != Some(FrontendHistoryFlushPhase::Running) - }) { - slot.take() - } else { - None - } -} - -enum EditorFlushAcknowledge { - LifecycleReady(EditorFlushHandshake), - HistoryClosing { - handshake_id: String, - waiter: HistoryBarrierWaiter, - }, - HistoryCloseFailed { - handshake: EditorFlushHandshake, - error: String, - }, -} - -fn acknowledge_editor_flush_handshake( - slot: &mut Option, - handshake_id: &str, - window_label: &str, - history_gate: &Arc, -) -> Option { - { - let active = slot.as_mut()?; - if active.id != handshake_id || !active.pending_windows.remove(window_label) { - return None; - } - if !active.pending_windows.is_empty() { - return None; - } - if active.completion.is_lifecycle() { - return slot.take().map(EditorFlushAcknowledge::LifecycleReady); - } - } - begin_history_gate_close(slot, handshake_id, history_gate) -} - -fn begin_history_gate_close( - slot: &mut Option, - handshake_id: &str, - history_gate: &Arc, -) -> Option { - let operation_id = match slot.as_ref() { - Some(EditorFlushHandshake { - id, - completion: - EditorFlushCompletion::History { - operation_id, - phase: FrontendHistoryFlushPhase::Collecting, - .. - }, - .. - }) if id == handshake_id => operation_id.clone(), - _ => return None, - }; - match history_gate.begin_close(&operation_id) { - Ok(next_barrier) => { - let waiter = next_barrier.waiter(); - let active = slot - .as_mut() - .expect("history handshake disappeared while closing gate"); - let EditorFlushCompletion::History { phase, barrier, .. } = &mut active.completion - else { - unreachable!("history handshake changed while closing gate"); - }; - *phase = FrontendHistoryFlushPhase::Closing; - *barrier = Some(next_barrier); - Some(EditorFlushAcknowledge::HistoryClosing { - handshake_id: handshake_id.to_string(), - waiter, - }) - } - Err(error) => slot - .take() - .map(|handshake| EditorFlushAcknowledge::HistoryCloseFailed { handshake, error }), - } -} - -enum LifecycleHandshakeInstall { - Installed, - InterruptedHistory(Box), - LifecycleAlreadyActive, - DeferredUntilHistoryComplete, -} - -fn install_lifecycle_handshake( - slot: &mut Option, - next: EditorFlushHandshake, -) -> LifecycleHandshakeInstall { - if slot - .as_ref() - .is_some_and(|active| active.completion.is_lifecycle()) - { - return LifecycleHandshakeInstall::LifecycleAlreadyActive; - } - if slot.as_ref().is_some_and(|active| { - active.completion.history_phase() == Some(FrontendHistoryFlushPhase::Running) - }) { - return LifecycleHandshakeInstall::DeferredUntilHistoryComplete; - } - - match slot.replace(next) { - Some(interrupted) => LifecycleHandshakeInstall::InterruptedHistory(Box::new(interrupted)), - None => LifecycleHandshakeInstall::Installed, - } -} - -fn install_history_handshake( - slot: &mut Option, - next: EditorFlushHandshake, -) -> bool { - if slot.is_some() { - return false; - } - *slot = Some(next); - true -} - -fn frontend_history_mutation_blocked( - slot: &Option, - window_label: &str, -) -> bool { - slot.as_ref().is_some_and(|active| { - active.completion.is_history() && !active.pending_windows.contains(window_label) - }) -} - -fn emit_frontend_history_flush_released( - app_handle: &AppHandle, - handshake_id: &str, - target_windows: &HashSet, -) { - let payload = FrontendHistoryFlushReleased { - handshake_id: handshake_id.to_string(), - }; - for label in target_windows { - if let Err(error) = app_handle.emit_to(label, "app:history-flush-released", &payload) { - log::warn!("failed to release history flush lock for {label}: {error}"); - } - } -} - fn frontend_lifecycle_restore_labels(target_windows: &HashSet) -> Vec<&'static str> { FRONTEND_LIFECYCLE_WINDOW_LABELS .into_iter() diff --git a/src-tauri/src/state/app_state/frontend_flush.rs b/src-tauri/src/state/app_state/frontend_flush.rs new file mode 100644 index 000000000..2aeb967ed --- /dev/null +++ b/src-tauri/src/state/app_state/frontend_flush.rs @@ -0,0 +1,274 @@ +use std::{collections::HashSet, sync::Arc, time::Duration}; + +use serde::Serialize; +use tauri::{AppHandle, Emitter}; +use tokio::sync::oneshot; + +use super::FrontendLifecycleAction; +use crate::state::history::{HistoryAdmissionGate, HistoryBarrierLease, HistoryBarrierWaiter}; + +pub(super) const EDITOR_FLUSH_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10); +pub(crate) const HISTORY_FRONTEND_FLUSH_BUSY: &str = "HISTORY_FRONTEND_FLUSH_BUSY"; +pub(crate) const HISTORY_FRONTEND_FLUSH_CANCELED: &str = "HISTORY_FRONTEND_FLUSH_CANCELED"; +pub(crate) const HISTORY_FRONTEND_FLUSH_EMIT_FAILED: &str = "HISTORY_FRONTEND_FLUSH_EMIT_FAILED"; +pub(crate) const HISTORY_FRONTEND_FLUSH_INTERRUPTED: &str = "HISTORY_FRONTEND_FLUSH_INTERRUPTED"; +pub(crate) const HISTORY_FRONTEND_FLUSH_TIMEOUT: &str = "HISTORY_FRONTEND_FLUSH_TIMEOUT"; + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(super) enum FrontendFlushAction { + Quit, + Restart, + History, +} + +impl From for FrontendFlushAction { + fn from(action: FrontendLifecycleAction) -> Self { + match action { + FrontendLifecycleAction::Quit => Self::Quit, + FrontendLifecycleAction::Restart => Self::Restart, + } + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct EditorFlushRequest { + pub(super) handshake_id: String, + pub(super) action: FrontendFlushAction, +} + +pub(super) enum EditorFlushCompletion { + Lifecycle(FrontendLifecycleAction), + History { + operation_id: String, + sender: Option>>, + phase: FrontendHistoryFlushPhase, + barrier: Option, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum FrontendHistoryFlushPhase { + Collecting, + Closing, + Running, +} + +impl EditorFlushCompletion { + pub(super) fn is_lifecycle(&self) -> bool { + matches!(self, Self::Lifecycle(_)) + } + + pub(super) fn is_history(&self) -> bool { + matches!(self, Self::History { .. }) + } + + pub(super) fn history_phase(&self) -> Option { + match self { + Self::History { phase, .. } => Some(*phase), + Self::Lifecycle(_) => None, + } + } +} + +pub(crate) struct FrontendHistoryFlushReady { + pub(super) barrier: Option, + pub(super) complete: Option>, +} + +impl FrontendHistoryFlushReady { + pub(crate) fn take_barrier(&mut self) -> HistoryBarrierLease { + self.barrier + .take() + .expect("history flush barrier can only be taken once") + } +} + +impl Drop for FrontendHistoryFlushReady { + fn drop(&mut self) { + drop(self.barrier.take()); + if let Some(complete) = self.complete.take() { + complete(); + } + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct FrontendHistoryFlushReleased { + handshake_id: String, +} + +pub(super) struct EditorFlushHandshake { + pub(super) id: String, + pub(super) completion: EditorFlushCompletion, + pub(super) target_windows: HashSet, + pub(super) pending_windows: HashSet, +} + +pub(super) fn take_editor_flush_handshake( + slot: &mut Option, + handshake_id: &str, +) -> Option { + if slot + .as_ref() + .is_some_and(|active| active.id == handshake_id) + { + slot.take() + } else { + None + } +} + +pub(super) fn take_cancelable_editor_flush_handshake( + slot: &mut Option, + handshake_id: &str, +) -> Option { + if slot.as_ref().is_some_and(|active| { + active.id == handshake_id + && active.completion.history_phase() != Some(FrontendHistoryFlushPhase::Running) + }) { + slot.take() + } else { + None + } +} + +pub(super) enum EditorFlushAcknowledge { + LifecycleReady(EditorFlushHandshake), + HistoryClosing { + handshake_id: String, + waiter: HistoryBarrierWaiter, + }, + HistoryCloseFailed { + handshake: EditorFlushHandshake, + error: String, + }, +} + +pub(super) fn acknowledge_editor_flush_handshake( + slot: &mut Option, + handshake_id: &str, + window_label: &str, + history_gate: &Arc, +) -> Option { + { + let active = slot.as_mut()?; + if active.id != handshake_id || !active.pending_windows.remove(window_label) { + return None; + } + if !active.pending_windows.is_empty() { + return None; + } + if active.completion.is_lifecycle() { + return slot.take().map(EditorFlushAcknowledge::LifecycleReady); + } + } + begin_history_gate_close(slot, handshake_id, history_gate) +} + +pub(super) fn begin_history_gate_close( + slot: &mut Option, + handshake_id: &str, + history_gate: &Arc, +) -> Option { + let operation_id = match slot.as_ref() { + Some(EditorFlushHandshake { + id, + completion: + EditorFlushCompletion::History { + operation_id, + phase: FrontendHistoryFlushPhase::Collecting, + .. + }, + .. + }) if id == handshake_id => operation_id.clone(), + _ => return None, + }; + match history_gate.begin_close(&operation_id) { + Ok(next_barrier) => { + let waiter = next_barrier.waiter(); + let active = slot + .as_mut() + .expect("history handshake disappeared while closing gate"); + let EditorFlushCompletion::History { phase, barrier, .. } = &mut active.completion + else { + unreachable!("history handshake changed while closing gate"); + }; + *phase = FrontendHistoryFlushPhase::Closing; + *barrier = Some(next_barrier); + Some(EditorFlushAcknowledge::HistoryClosing { + handshake_id: handshake_id.to_string(), + waiter, + }) + } + Err(error) => slot + .take() + .map(|handshake| EditorFlushAcknowledge::HistoryCloseFailed { handshake, error }), + } +} + +pub(super) enum LifecycleHandshakeInstall { + Installed, + InterruptedHistory(Box), + LifecycleAlreadyActive, + DeferredUntilHistoryComplete, +} + +pub(super) fn install_lifecycle_handshake( + slot: &mut Option, + next: EditorFlushHandshake, +) -> LifecycleHandshakeInstall { + if slot + .as_ref() + .is_some_and(|active| active.completion.is_lifecycle()) + { + return LifecycleHandshakeInstall::LifecycleAlreadyActive; + } + if slot.as_ref().is_some_and(|active| { + active.completion.history_phase() == Some(FrontendHistoryFlushPhase::Running) + }) { + return LifecycleHandshakeInstall::DeferredUntilHistoryComplete; + } + + match slot.replace(next) { + Some(interrupted) => LifecycleHandshakeInstall::InterruptedHistory(Box::new(interrupted)), + None => LifecycleHandshakeInstall::Installed, + } +} + +pub(super) fn install_history_handshake( + slot: &mut Option, + next: EditorFlushHandshake, +) -> bool { + if slot.is_some() { + return false; + } + *slot = Some(next); + true +} + +pub(super) fn frontend_history_mutation_blocked( + slot: &Option, + window_label: &str, +) -> bool { + slot.as_ref().is_some_and(|active| { + active.completion.is_history() && !active.pending_windows.contains(window_label) + }) +} + +pub(super) fn emit_frontend_history_flush_released( + app_handle: &AppHandle, + handshake_id: &str, + target_windows: &HashSet, +) { + let payload = FrontendHistoryFlushReleased { + handshake_id: handshake_id.to_string(), + }; + for label in target_windows { + if let Err(error) = app_handle.emit_to(label, "app:history-flush-released", &payload) { + log::warn!("failed to release history flush lock for {label}: {error}"); + } + } +} From bba4d159fb0761deefb876a6edd3534d8e2e2e5a Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 18:19:49 +0900 Subject: [PATCH 187/245] =?UTF-8?q?test:=20OBS=20WebSocket=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EC=88=9C=EC=84=9C=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/services/obs_bridge/tests.rs | 232 ++++++++++++++++++++- 1 file changed, 230 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/services/obs_bridge/tests.rs b/src-tauri/src/services/obs_bridge/tests.rs index d2d7f7359..5698b7d21 100644 --- a/src-tauri/src/services/obs_bridge/tests.rs +++ b/src-tauri/src/services/obs_bridge/tests.rs @@ -36,6 +36,42 @@ async fn receive_envelope(ws: &mut TestWebSocket, expected_type: &str) -> ObsEnv .expect("WS 메시지 수신 타임아웃") } +async fn receive_envelope_value(ws: &mut TestWebSocket, expected_type: &str) -> Value { + tokio::time::timeout(Duration::from_secs(2), async { + loop { + match ws.next().await { + Some(Ok(Message::Text(text))) => { + let envelope = + serde_json::from_str::(&text).expect("OBS envelope 파싱 실패"); + if envelope.get("type").and_then(Value::as_str) == Some(expected_type) { + return envelope; + } + } + Some(Ok(_)) => {} + Some(Err(error)) => panic!("WS 메시지 수신 실패: {error}"), + None => panic!("WS 연결이 예기치 않게 종료됨"), + } + } + }) + .await + .expect("WS 메시지 수신 타임아웃") +} + +async fn wait_for_client_count(bridge: &ObsBridgeService, expected: u32, timeout: Duration) { + tokio::time::timeout(timeout, async { + while bridge.client_count() != expected { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap_or_else(|_| { + panic!( + "OBS client count 대기 실패: expected={expected}, actual={}", + bridge.client_count() + ) + }); +} + async fn connect_authenticated(port: u16, token: &str) -> TestWebSocket { let (mut ws, _) = connect_async(format!("ws://127.0.0.1:{port}")) .await @@ -388,6 +424,109 @@ async fn lagged_publish_burst_recovers_with_latest_snapshot() { bridge.stop(); } +#[tokio::test] +async fn websocket_session_preserves_handshake_projection_and_client_sequence() { + let bridge = Arc::new(ObsBridgeService::new("session-test")); + let expected_snapshot = serde_json::json!({ "revision": 7 }); + bridge.update_snapshot(expected_snapshot.clone()); + let port = bridge + .start(0, "session-token".to_string()) + .await + .expect("OBS bridge 시작 실패"); + let (mut ws, _) = connect_async(format!("ws://127.0.0.1:{port}")) + .await + .expect("WS 연결 실패"); + + // hello 전의 malformed/다른 text envelope 무시 + ws.send(Message::Text("not-json".to_string())) + .await + .expect("malformed text 전송 실패"); + ws.send(Message::Text( + make_envelope("ping", 10, Value::Null).to_string(), + )) + .await + .expect("handshake 이전 ping 전송 실패"); + ws.send(Message::Text( + make_envelope( + "hello", + 11, + serde_json::json!({ + "token": "session-token", + "protocol": OBS_PROTOCOL_VERSION, + }), + ) + .to_string(), + )) + .await + .expect("hello 전송 실패"); + + let ack = receive_envelope_value(&mut ws, "hello_ack").await; + assert_eq!(ack["v"], OBS_PROTOCOL_VERSION); + assert_eq!(ack["seq"], 0); + assert_eq!(ack["payload"]["serverVersion"], "session-test"); + assert_eq!(ack["payload"]["obsMode"], true); + assert_eq!( + ack["payload"]["allowedList"], + serde_json::to_value(build_allowed_list()).expect("allowlist 직렬화 실패") + ); + + let snapshot = receive_envelope_value(&mut ws, "snapshot").await; + assert_eq!(snapshot["seq"], 1); + assert_eq!(snapshot["payload"], expected_snapshot); + assert_eq!(bridge.client_count(), 1); + + // tokio interval의 기존 첫 immediate tick + let server_ping = receive_envelope_value(&mut ws, "ping").await; + assert_eq!(server_ping["seq"], 2); + assert_eq!(server_ping["payload"], Value::Null); + + ws.send(Message::Text( + make_envelope("ping", 12, Value::Null).to_string(), + )) + .await + .expect("client ping 전송 실패"); + let pong = receive_envelope_value(&mut ws, "pong").await; + assert_eq!(pong["seq"], 3); + assert_eq!(pong["payload"], Value::Null); + + ws.send(Message::Text( + make_envelope("resync_request", 13, Value::Null).to_string(), + )) + .await + .expect("resync_request 전송 실패"); + let resync = receive_envelope_value(&mut ws, "snapshot").await; + assert_eq!(resync["seq"], 4); + assert_eq!(resync["payload"], serde_json::json!({ "revision": 7 })); + + ws.send(Message::Text( + make_envelope( + "invoke_request", + 14, + serde_json::json!({ + "requestId": "rpc-1", + "command": "not-allowed", + }), + ) + .to_string(), + )) + .await + .expect("invoke_request 전송 실패"); + let rpc = receive_envelope_value(&mut ws, "invoke_response").await; + assert_eq!(rpc["seq"], 5); + assert_eq!(rpc["payload"]["requestId"], "rpc-1"); + assert_eq!(rpc["payload"]["error"], "Command not allowed: not-allowed"); + + bridge.publish("settings:changed", serde_json::json!({ "revision": 8 })); + let broadcast = receive_envelope_value(&mut ws, "tauri_event").await; + assert_eq!(broadcast["seq"], 6); + assert_eq!(broadcast["payload"]["event"], "settings:changed"); + assert_eq!(broadcast["payload"]["data"]["revision"], 8); + + ws.close(None).await.expect("WS 종료 실패"); + wait_for_client_count(&bridge, 0, Duration::from_secs(2)).await; + bridge.stop(); +} + #[test] fn host_header_allows_only_ip_literals_and_localhost() { for allowed in [ @@ -804,12 +943,12 @@ async fn protocol_mismatch_is_rejected_before_auth() { let (mut ws, _) = connect_async(format!("ws://127.0.0.1:{port}")) .await .expect("WS 연결 실패"); - // 올바른 토큰이라도 프로토콜 버전이 다르면 거부되어야 함 + // 토큰까지 틀려도 프로토콜 오류가 먼저 결정되어야 함 let hello = serde_json::json!({ "v": 999, "type": "hello", "seq": 0, - "payload": { "token": "token", "protocol": 999 }, + "payload": { "token": "wrong-token", "protocol": 999 }, }); ws.send(Message::Text(hello.to_string())) .await @@ -820,10 +959,99 @@ async fn protocol_mismatch_is_rejected_before_auth() { error.payload.get("code").and_then(Value::as_str), Some("PROTOCOL_MISMATCH") ); + wait_for_client_count(&bridge, 0, Duration::from_secs(2)).await; bridge.stop(); } +#[tokio::test] +async fn auth_failure_follows_protocol_validation_and_empty_server_token_disables_auth() { + let bridge = Arc::new(ObsBridgeService::new("test")); + let port = bridge + .start(0, "secret".to_string()) + .await + .expect("OBS bridge 시작 실패"); + let (mut ws, _) = connect_async(format!("ws://127.0.0.1:{port}")) + .await + .expect("WS 연결 실패"); + ws.send(Message::Text( + make_envelope( + "hello", + 0, + serde_json::json!({ + "token": "wrong", + "protocol": OBS_PROTOCOL_VERSION, + }), + ) + .to_string(), + )) + .await + .expect("hello 전송 실패"); + let error = receive_envelope_value(&mut ws, "error").await; + assert_eq!(error["seq"], 0); + assert_eq!(error["payload"]["code"], "AUTH_FAILED"); + assert_eq!(error["payload"]["message"], "Invalid token"); + wait_for_client_count(&bridge, 0, Duration::from_secs(2)).await; + bridge.stop(); + + let bridge = Arc::new(ObsBridgeService::new("test")); + let port = bridge + .start(0, String::new()) + .await + .expect("빈 토큰 OBS bridge 시작 실패"); + let (mut ws, _) = connect_async(format!("ws://127.0.0.1:{port}")) + .await + .expect("빈 토큰 WS 연결 실패"); + ws.send(Message::Text( + make_envelope( + "hello", + 0, + serde_json::json!({ "protocol": OBS_PROTOCOL_VERSION }), + ) + .to_string(), + )) + .await + .expect("빈 토큰 hello 전송 실패"); + let ack = receive_envelope_value(&mut ws, "hello_ack").await; + assert_eq!(ack["seq"], 0); + let snapshot = receive_envelope_value(&mut ws, "snapshot").await; + assert_eq!(snapshot["seq"], 1); + bridge.stop(); +} + +#[tokio::test] +async fn hello_close_and_timeout_both_decrement_client_count() { + let bridge = Arc::new(ObsBridgeService::new("test")); + let port = bridge + .start(0, "token".to_string()) + .await + .expect("OBS bridge 시작 실패"); + let (mut ws, _) = connect_async(format!("ws://127.0.0.1:{port}")) + .await + .expect("WS 연결 실패"); + wait_for_client_count(&bridge, 1, Duration::from_secs(2)).await; + ws.close(None).await.expect("hello 이전 WS 종료 실패"); + wait_for_client_count(&bridge, 0, Duration::from_secs(2)).await; + bridge.stop(); + + let bridge = Arc::new(ObsBridgeService::new("test")); + let port = bridge + .start(0, "token".to_string()) + .await + .expect("OBS bridge 시작 실패"); + let (_ws, _) = connect_async(format!("ws://127.0.0.1:{port}")) + .await + .expect("WS 연결 실패"); + wait_for_client_count(&bridge, 1, Duration::from_secs(2)).await; + let started_at = tokio::time::Instant::now(); + wait_for_client_count(&bridge, 0, Duration::from_secs(7)).await; + assert!( + started_at.elapsed() >= Duration::from_secs(4), + "hello timeout이 기존 5초보다 지나치게 빨라지면 안 됨" + ); + bridge.stop(); +} + // v1 번들은 KeySlot union 와이어 형식을 소비할 수 없으므로 handshake에서 결정적으로 거부 #[tokio::test] async fn legacy_protocol_v1_hello_is_rejected() { From 6aebcf27baa29b4675889f191d5958c7af42d008 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 18:20:00 +0900 Subject: [PATCH 188/245] =?UTF-8?q?refactor:=20OBS=20WebSocket=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=ED=88=AC=EC=98=81=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/services/obs_bridge.rs | 54 ++++----------- .../src/services/obs_bridge/websocket.rs | 66 +++++++++++++++++++ 2 files changed, 80 insertions(+), 40 deletions(-) create mode 100644 src-tauri/src/services/obs_bridge/websocket.rs diff --git a/src-tauri/src/services/obs_bridge.rs b/src-tauri/src/services/obs_bridge.rs index 2be90deac..e1b45f16a 100644 --- a/src-tauri/src/services/obs_bridge.rs +++ b/src-tauri/src/services/obs_bridge.rs @@ -24,19 +24,20 @@ use tokio_tungstenite::{ }; use crate::models::obs::{ - make_envelope, HelloAckPayload, ObsBroadcast, ObsEnvelope, ObsStatus, OBS_PROTOCOL_VERSION, + make_envelope, ObsBroadcast, ObsEnvelope, ObsStatus, OBS_PROTOCOL_VERSION, }; mod media; mod rpc; mod transport; +mod websocket; #[cfg(test)] use media::{guess_mime, percent_decode}; +use rpc::is_allowed_command; #[cfg(test)] -use rpc::ALLOWED_WS_COMMANDS; -use rpc::{build_allowed_list, is_allowed_command}; +use rpc::{build_allowed_list, ALLOWED_WS_COMMANDS}; use transport::{ bind_address, has_allowed_http_host, http_header_end, is_local_machine_ip, is_websocket_upgrade_request, read_http_request_headers, validate_websocket_request, @@ -44,6 +45,7 @@ use transport::{ }; #[cfg(test)] use transport::{http_header_values, is_allowed_host_header}; +use websocket::WebSocketSessionProtocol; // OBS 브라우저 소스는 overlay 창을 대신한다 - main만을 향한 브릿지 메시지는 전달하지 않는다 fn is_forwarded_to_obs(event: &str, data: &Value) -> bool { @@ -523,12 +525,7 @@ impl ObsBridgeService { let mut broadcast_rx = self.broadcast_tx.subscribe(); // 클라이언트별 시퀀스 카운터 - let mut client_seq: u64 = 0; - let mut next_seq = || { - let s = client_seq; - client_seq += 1; - s - }; + let mut protocol = WebSocketSessionProtocol::new(); // hello 핸드셰이크 대기 (5초 타임아웃) let hello_result = tokio::time::timeout(Duration::from_secs(5), async { @@ -608,13 +605,7 @@ impl ObsBridgeService { self.client_count.fetch_sub(1, Ordering::Relaxed); return; } - let ack_payload = serde_json::to_value(HelloAckPayload { - server_version: self.server_version.clone(), - obs_mode: true, - allowed_list: build_allowed_list(), - }) - .unwrap_or_default(); - let ack_msg = make_envelope("hello_ack", next_seq(), ack_payload); + let ack_msg = protocol.hello_ack(self.server_version.clone()); if ws_tx .send(Message::Text(ack_msg.to_string())) .await @@ -630,7 +621,7 @@ impl ObsBridgeService { return; } let snapshot = self.cached_snapshot.read().clone(); - let snapshot_msg = make_envelope("snapshot", next_seq(), snapshot); + let snapshot_msg = protocol.snapshot(snapshot); if ws_tx .send(Message::Text(snapshot_msg.to_string())) .await @@ -657,7 +648,7 @@ impl ObsBridgeService { match result { Ok(ObsBroadcast::Shutdown) => break, Ok(broadcast) => { - let msg = broadcast_to_envelope(&broadcast, next_seq()); + let msg = protocol.broadcast(&broadcast); if ws_tx.send(Message::Text(msg.to_string())).await.is_err() { break; } @@ -665,7 +656,7 @@ impl ObsBridgeService { Err(broadcast::error::RecvError::Lagged(n)) => { log::warn!("[ObsBridge] {addr}: {n}개 메시지 누락, 스냅샷 재전송"); let snapshot = self.cached_snapshot.read().clone(); - let msg = make_envelope("snapshot", next_seq(), snapshot); + let msg = protocol.snapshot(snapshot); if ws_tx.send(Message::Text(msg.to_string())).await.is_err() { break; } @@ -684,14 +675,14 @@ impl ObsBridgeService { if let Ok(envelope) = serde_json::from_str::(&text) { match envelope.msg_type.as_str() { "ping" => { - let pong = make_envelope("pong", next_seq(), Value::Null); + let pong = protocol.pong(); if ws_tx.send(Message::Text(pong.to_string())).await.is_err() { break; } } "resync_request" => { let snapshot = self.cached_snapshot.read().clone(); - let msg = make_envelope("snapshot", next_seq(), snapshot); + let msg = protocol.snapshot(snapshot); if ws_tx.send(Message::Text(msg.to_string())).await.is_err() { break; } @@ -719,11 +710,7 @@ impl ObsBridgeService { if !self.is_current_session_token(&expected_token) { break; } - let payload = match result { - Ok(data) => serde_json::json!({ "requestId": request_id, "result": data }), - Err(err) => serde_json::json!({ "requestId": request_id, "error": err }), - }; - let msg = make_envelope("invoke_response", next_seq(), payload); + let msg = protocol.invoke_response(request_id, result); if ws_tx.send(Message::Text(msg.to_string())).await.is_err() { break; } @@ -733,7 +720,7 @@ impl ObsBridgeService { if !self.is_current_session_token(&expected_token) { break; } - let ping_msg = make_envelope("ping", next_seq(), Value::Null); + let ping_msg = protocol.ping(); if ws_tx.send(Message::Text(ping_msg.to_string())).await.is_err() { break; } @@ -846,18 +833,5 @@ impl ObsBridgeService { } } -/// ObsBroadcast → JSON envelope 변환 -fn broadcast_to_envelope(broadcast: &ObsBroadcast, seq: u64) -> Value { - match broadcast { - ObsBroadcast::Snapshot(snapshot) => make_envelope("snapshot", seq, snapshot.clone()), - ObsBroadcast::TauriEvent { event, data } => make_envelope( - "tauri_event", - seq, - serde_json::json!({ "event": event, "data": data }), - ), - ObsBroadcast::Shutdown => unreachable!("Shutdown은 직접 처리됨"), - } -} - #[cfg(test)] mod tests; diff --git a/src-tauri/src/services/obs_bridge/websocket.rs b/src-tauri/src/services/obs_bridge/websocket.rs new file mode 100644 index 000000000..3fe5effa6 --- /dev/null +++ b/src-tauri/src/services/obs_bridge/websocket.rs @@ -0,0 +1,66 @@ +use serde_json::Value; + +use crate::models::obs::{make_envelope, HelloAckPayload, ObsBroadcast}; + +use super::rpc::{build_allowed_list, RpcResult}; + +pub(super) struct WebSocketSessionProtocol { + next_sequence: u64, +} + +impl WebSocketSessionProtocol { + pub(super) fn new() -> Self { + Self { next_sequence: 0 } + } + + fn next_seq(&mut self) -> u64 { + let seq = self.next_sequence; + self.next_sequence += 1; + seq + } + + fn envelope(&mut self, msg_type: &str, payload: Value) -> Value { + make_envelope(msg_type, self.next_seq(), payload) + } + + pub(super) fn hello_ack(&mut self, server_version: String) -> Value { + let payload = serde_json::to_value(HelloAckPayload { + server_version, + obs_mode: true, + allowed_list: build_allowed_list(), + }) + .unwrap_or_default(); + self.envelope("hello_ack", payload) + } + + pub(super) fn snapshot(&mut self, snapshot: Value) -> Value { + self.envelope("snapshot", snapshot) + } + + pub(super) fn broadcast(&mut self, broadcast: &ObsBroadcast) -> Value { + match broadcast { + ObsBroadcast::Snapshot(snapshot) => self.envelope("snapshot", snapshot.clone()), + ObsBroadcast::TauriEvent { event, data } => self.envelope( + "tauri_event", + serde_json::json!({ "event": event, "data": data }), + ), + ObsBroadcast::Shutdown => unreachable!("Shutdown은 직접 처리됨"), + } + } + + pub(super) fn pong(&mut self) -> Value { + self.envelope("pong", Value::Null) + } + + pub(super) fn invoke_response(&mut self, request_id: String, result: RpcResult) -> Value { + let payload = match result { + Ok(data) => serde_json::json!({ "requestId": request_id, "result": data }), + Err(err) => serde_json::json!({ "requestId": request_id, "error": err }), + }; + self.envelope("invoke_response", payload) + } + + pub(super) fn ping(&mut self) -> Value { + self.envelope("ping", Value::Null) + } +} From 79c5c0b99f0bd24f6e94d5ebd9402b0552dfb7c4 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 18:20:38 +0900 Subject: [PATCH 189/245] =?UTF-8?q?test:=20OBS=20WebSocket=20=ED=88=AC?= =?UTF-8?q?=EC=98=81=20=EA=B3=84=EC=95=BD=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/obs_bridge/websocket.rs | 3 + .../services/obs_bridge/websocket/tests.rs | 85 +++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 src-tauri/src/services/obs_bridge/websocket/tests.rs diff --git a/src-tauri/src/services/obs_bridge/websocket.rs b/src-tauri/src/services/obs_bridge/websocket.rs index 3fe5effa6..494a2b835 100644 --- a/src-tauri/src/services/obs_bridge/websocket.rs +++ b/src-tauri/src/services/obs_bridge/websocket.rs @@ -64,3 +64,6 @@ impl WebSocketSessionProtocol { self.envelope("ping", Value::Null) } } + +#[cfg(test)] +mod tests; diff --git a/src-tauri/src/services/obs_bridge/websocket/tests.rs b/src-tauri/src/services/obs_bridge/websocket/tests.rs new file mode 100644 index 000000000..fa95dc180 --- /dev/null +++ b/src-tauri/src/services/obs_bridge/websocket/tests.rs @@ -0,0 +1,85 @@ +use super::*; + +fn assert_envelope(envelope: &Value, msg_type: &str, sequence: u64, payload: &Value) { + assert_eq!(envelope["type"], msg_type); + assert_eq!(envelope["seq"], sequence); + assert_eq!(&envelope["payload"], payload); +} + +#[test] +fn session_protocol_projects_every_envelope_with_one_monotonic_sequence() { + let mut protocol = WebSocketSessionProtocol::new(); + + let ack = protocol.hello_ack("2.0.1".to_string()); + assert_eq!(ack["type"], "hello_ack"); + assert_eq!(ack["seq"], 0); + assert_eq!(ack["payload"]["serverVersion"], "2.0.1"); + assert_eq!(ack["payload"]["obsMode"], true); + assert_eq!( + ack["payload"]["allowedList"], + serde_json::to_value(build_allowed_list()).expect("allowlist 직렬화 실패") + ); + + let snapshot_payload = serde_json::json!({ "revision": 1 }); + let snapshot = protocol.snapshot(snapshot_payload.clone()); + assert_envelope(&snapshot, "snapshot", 1, &snapshot_payload); + + let broadcast_snapshot_payload = serde_json::json!({ "revision": 2 }); + let broadcast_snapshot = + protocol.broadcast(&ObsBroadcast::Snapshot(broadcast_snapshot_payload.clone())); + assert_envelope( + &broadcast_snapshot, + "snapshot", + 2, + &broadcast_snapshot_payload, + ); + + let event = protocol.broadcast(&ObsBroadcast::TauriEvent { + event: "settings:changed".to_string(), + data: serde_json::json!({ "theme": "dark" }), + }); + assert_envelope( + &event, + "tauri_event", + 3, + &serde_json::json!({ + "event": "settings:changed", + "data": { "theme": "dark" }, + }), + ); + + let pong = protocol.pong(); + assert_envelope(&pong, "pong", 4, &Value::Null); + + let success = protocol.invoke_response("success".to_string(), Ok(serde_json::json!(3))); + assert_envelope( + &success, + "invoke_response", + 5, + &serde_json::json!({ "requestId": "success", "result": 3 }), + ); + + let error = protocol.invoke_response( + "error".to_string(), + Err(serde_json::json!({ "code": "FAILED" })), + ); + assert_envelope( + &error, + "invoke_response", + 6, + &serde_json::json!({ + "requestId": "error", + "error": { "code": "FAILED" }, + }), + ); + + let ping = protocol.ping(); + assert_envelope(&ping, "ping", 7, &Value::Null); +} + +#[test] +#[should_panic(expected = "Shutdown은 직접 처리됨")] +fn shutdown_broadcast_remains_unreachable_in_projection() { + let mut protocol = WebSocketSessionProtocol::new(); + let _ = protocol.broadcast(&ObsBroadcast::Shutdown); +} From d26a65c8aa1b623c142cdb1ef4cc72bb298e5095 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 18:36:17 +0900 Subject: [PATCH 190/245] =?UTF-8?q?test:=20=ED=94=8C=EB=9F=AC=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=84=A4=EC=A0=95=20=ED=8F=BC=20=EC=88=98=EB=AA=85?= =?UTF-8?q?=EC=A3=BC=EA=B8=B0=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/defineElement.modalHandlers.test.ts | 28 ++ .../api/defineSettings.modalHandlers.test.ts | 320 +++++++++++++++++- 2 files changed, 347 insertions(+), 1 deletion(-) diff --git a/src/renderer/plugins/runtime/api/defineElement.modalHandlers.test.ts b/src/renderer/plugins/runtime/api/defineElement.modalHandlers.test.ts index e92618803..6776ae9b1 100644 --- a/src/renderer/plugins/runtime/api/defineElement.modalHandlers.test.ts +++ b/src/renderer/plugins/runtime/api/defineElement.modalHandlers.test.ts @@ -43,6 +43,8 @@ describe('defineElement modal handler lifecycle', () => { }); delete window.__dmn_current_plugin_id; delete window.__dmn_window_type; + delete window.__dmn_getColorPickerState; + delete window.__dmn_showColorPicker; }); it('instance modal close와 reject 뒤 transient handler만 정리한다', async () => { @@ -373,6 +375,16 @@ describe('defineElement modal handler lifecycle', () => { expect(checkbox).not.toBeNull(); querySelectorAll.mockRestore(); + const textInput = + entries[1]?.querySelector('input[type="text"]'); + const textHandler = textInput?.getAttribute('data-plugin-handler-change'); + window[textHandler as `__dmn_handler_${string}`]?.({ + target: { value: 'expanded' }, + } as unknown as Event); + expect(update).toHaveBeenLastCalledWith(element.fullId, { + settings: expect.objectContaining({ nickname: 'expanded' }), + }); + const numberInput = entries[2]?.querySelector( 'input[type="number"]', ); @@ -435,6 +447,22 @@ describe('defineElement modal handler lifecycle', () => { pickerOptions.onClose(); expect(colorButton?.classList.contains('shadow-focus-ring')).toBe(false); + const showColorPicker = vi.fn(); + window.__dmn_getColorPickerState = () => ({ + isOpen: true, + id: 'plugin-plugin-a-plugin-a::one-color', + color: '#444444', + }); + window.__dmn_showColorPicker = showColorPicker; + handlerRegistry.get(colorHandler || '')?.({ + target: colorButton, + } as unknown as Event); + expect(showColorPicker).toHaveBeenCalledWith({ + initialColor: '#444444', + id: 'plugin-plugin-a-plugin-a::one-color', + }); + expect(pickColor).toHaveBeenCalledOnce(); + expect(normalizationError).toHaveBeenCalledTimes(1); settleDialog?.(false); await opening; diff --git a/src/renderer/plugins/runtime/api/defineSettings.modalHandlers.test.ts b/src/renderer/plugins/runtime/api/defineSettings.modalHandlers.test.ts index 85f92b905..e4d14f8dd 100644 --- a/src/renderer/plugins/runtime/api/defineSettings.modalHandlers.test.ts +++ b/src/renderer/plugins/runtime/api/defineSettings.modalHandlers.test.ts @@ -1,6 +1,10 @@ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createCheckbox } from '@utils/plugin/pluginComponents'; +import { + createCheckbox, + createDropdown, + createInput, +} from '@utils/plugin/pluginComponents'; import { clearComponentHandlers } from '@utils/plugin/pluginUtils'; import { handlerRegistry } from '../handlers'; import { createDefineSettings } from './defineSettings'; @@ -24,6 +28,10 @@ describe('defineSettings modal handler lifecycle', () => { handlerRegistry.clear(); clearComponentHandlers('plugin-a'); delete window.__dmn_current_plugin_id; + delete window.__dmn_window_type; + delete window.__dmn_getColorPickerState; + delete window.__dmn_showColorPicker; + document.body.replaceChildren(); }); it('반복 open/close 뒤 transient registry와 window key를 남기지 않는다', async () => { @@ -66,4 +74,314 @@ describe('defineSettings modal handler lifecycle', () => { await expect(settings.open()).rejects.toThrow('dialog failed'); expect(pluginHandlerKeys()).toEqual([persistentId]); }); + + it('설정 타입별 preview·visibility·picker·cancel 계약을 보존한다', async () => { + let settleDialog: ((confirmed: boolean) => void) | undefined; + const dialogRoot = document.createElement('div'); + document.body.appendChild(dialogRoot); + const custom = vi.fn( + (html: string) => + new Promise((resolve) => { + dialogRoot.innerHTML = html; + settleDialog = resolve; + }), + ); + const pickColor = vi.fn(); + const normalizationError = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const onChange = vi.fn(); + const subscriber = vi.fn(); + const defineSettings = createDefineSettings({ + pluginId: 'plugin-a', + api: { + settings: { get: vi.fn().mockResolvedValue({ language: 'en' }) }, + ui: { + dialog: { custom }, + components: { + checkbox: createCheckbox, + input: createInput, + dropdown: createDropdown, + }, + pickColor, + }, + }, + namespacedStorage: { + get: vi.fn().mockResolvedValue({ + enabled: false, + nickname: 'short', + amount: 2, + mode: 'one', + color: '#111111', + }), + set: vi.fn().mockResolvedValue(undefined), + }, + registerCleanup: vi.fn(), + } as never); + const settings = defineSettings({ + settingsUI: 'modal', + settings: { + appearance: { type: 'section', label: 'Appearance' }, + enabled: { type: 'boolean', default: false, label: 'Enabled' }, + nickname: { + type: 'string', + default: '', + label: 'Nickname', + visible: (values) => values.enabled === true, + }, + amount: { + type: 'number', + default: 0, + label: 'Amount', + min: 0, + max: 10, + step: 1, + }, + mode: { + type: 'select', + default: 'one', + label: 'Mode', + options: [ + { label: 'One', value: 'one' }, + { label: 'Two', value: 'two' }, + ], + }, + color: { type: 'color', default: '#000000', label: 'Color' }, + broken: { + type: 'string', + default: 'broken', + label: 'Broken', + visible: () => { + throw new Error('visibility failed'); + }, + }, + }, + onChange, + }); + settings.subscribe(subscriber); + + const opening = settings.open(); + await vi.waitFor(() => expect(custom).toHaveBeenCalledOnce()); + + expect(custom).toHaveBeenCalledWith( + expect.stringContaining( + 'data-settings-section="plugin-settings-plugin-a-0"', + ), + { + showCancel: true, + confirmText: 'Apply', + cancelText: 'Cancel', + }, + ); + const entries = Array.from( + dialogRoot.querySelectorAll('[data-settings-entry]'), + ); + expect(entries).toHaveLength(6); + expect(entries[1]?.style.display).toBe('none'); + expect(entries[5]?.style.display).toBe('none'); + expect(dialogRoot.textContent).toContain('One'); + expect(dialogRoot.textContent).toContain('Two'); + + const checkboxHandler = entries[0] + ?.querySelector('[data-plugin-handler-change]') + ?.getAttribute('data-plugin-handler-change'); + window.__dmn_current_plugin_id = 'outside-plugin'; + window[checkboxHandler as `__dmn_handler_${string}`]?.({ + target: { checked: true }, + } as unknown as Event); + expect(window.__dmn_current_plugin_id).toBe('outside-plugin'); + expect(settings.get()).toEqual(expect.objectContaining({ enabled: true })); + expect(entries[1]?.style.display).toBe(''); + + const textInput = + entries[1]?.querySelector('input[type="text"]'); + const textHandler = textInput?.getAttribute('data-plugin-handler-change'); + window[textHandler as `__dmn_handler_${string}`]?.({ + target: { value: 'expanded' }, + } as unknown as Event); + expect(settings.get()).toEqual( + expect.objectContaining({ nickname: 'expanded' }), + ); + + const numberInput = entries[2]?.querySelector( + 'input[type="number"]', + ); + const numberHandler = numberInput?.getAttribute( + 'data-plugin-handler-change', + ); + window[numberHandler as `__dmn_handler_${string}`]?.({ + target: { value: '99' }, + } as unknown as Event); + expect(settings.get()).toEqual(expect.objectContaining({ amount: 10 })); + + const dropdown = entries[3]?.querySelector( + '[data-plugin-handler-change]', + ); + const dropdownHandler = dropdown?.getAttribute( + 'data-plugin-handler-change', + ); + window[dropdownHandler as `__dmn_handler_${string}`]?.({ + target: { + getAttribute: (name: string) => + name === 'data-selected' ? 'two' : null, + }, + } as unknown as Event); + expect(settings.get()).toEqual(expect.objectContaining({ mode: 'two' })); + + const colorButton = entries[4]?.querySelector( + '[data-plugin-handler]', + ); + const colorHandler = colorButton?.getAttribute('data-plugin-handler'); + handlerRegistry.get(colorHandler || '')?.({ + target: colorButton, + } as unknown as Event); + expect(pickColor).toHaveBeenCalledWith( + expect.objectContaining({ + initialColor: '#111111', + id: 'plugin-settings-plugin-a-color', + referenceElement: colorButton, + }), + ); + const pickerOptions = pickColor.mock.calls[0]?.[0] as { + onColorChange: (color: string) => void; + onColorChangeComplete: (color: string) => void; + onClose: () => void; + }; + pickerOptions.onColorChange('#222222'); + expect( + colorButton?.style.getPropertyValue('--dmn-color-swatch-color'), + ).toBe('#222222'); + expect(settings.get()).toEqual( + expect.objectContaining({ color: '#111111' }), + ); + pickerOptions.onColorChangeComplete('#333333'); + expect(settings.get()).toEqual( + expect.objectContaining({ color: '#333333' }), + ); + expect(colorButton?.classList.contains('shadow-focus-ring')).toBe(true); + pickerOptions.onClose(); + expect(colorButton?.classList.contains('shadow-focus-ring')).toBe(false); + + const showColorPicker = vi.fn(); + window.__dmn_getColorPickerState = () => ({ + isOpen: true, + id: 'plugin-settings-plugin-a-color', + color: '#444444', + }); + window.__dmn_showColorPicker = showColorPicker; + handlerRegistry.get(colorHandler || '')?.({ + target: colorButton, + } as unknown as Event); + expect(showColorPicker).toHaveBeenCalledWith({ + initialColor: '#444444', + id: 'plugin-settings-plugin-a-color', + }); + expect(pickColor).toHaveBeenCalledOnce(); + + expect(normalizationError).toHaveBeenCalledTimes(1); + settleDialog?.(false); + await expect(opening).resolves.toBe(false); + + expect(settings.get()).toEqual({ + enabled: false, + nickname: 'short', + amount: 2, + mode: 'one', + color: '#111111', + broken: 'broken', + }); + expect(onChange).not.toHaveBeenCalled(); + expect(subscriber).not.toHaveBeenCalled(); + expect(pluginHandlerKeys()).toEqual([]); + }); + + it('확정은 저장 완료 뒤 onChange와 subscriber 순서로 정산한다', async () => { + let settleDialog: ((confirmed: boolean) => void) | undefined; + let finishSave: (() => void) | undefined; + const save = vi.fn( + () => + new Promise((resolve) => { + finishSave = resolve; + }), + ); + const custom = vi.fn( + () => + new Promise((resolve) => { + settleDialog = resolve; + }), + ); + const onChange = vi.fn(); + const subscriber = vi.fn(); + const defineSettings = createDefineSettings({ + pluginId: 'plugin-a', + api: { + settings: { get: vi.fn().mockResolvedValue({ language: 'ko' }) }, + ui: { + dialog: { custom }, + components: { checkbox: createCheckbox }, + }, + }, + namespacedStorage: { + get: vi.fn().mockResolvedValue(null), + set: save, + }, + registerCleanup: vi.fn(), + } as never); + const settings = defineSettings({ + settingsUI: 'modal', + settings: { + enabled: { type: 'boolean', default: false, label: 'Enabled' }, + }, + onChange, + }); + settings.subscribe(subscriber); + + const opening = settings.open(); + await vi.waitFor(() => expect(custom).toHaveBeenCalledOnce()); + settleDialog?.(true); + await vi.waitFor(() => expect(save).toHaveBeenCalledOnce()); + expect(onChange).not.toHaveBeenCalled(); + expect(subscriber).not.toHaveBeenCalled(); + + finishSave?.(); + await expect(opening).resolves.toBe(true); + expect(onChange).toHaveBeenCalledOnce(); + expect(subscriber).toHaveBeenCalledOnce(); + expect(save.mock.invocationCallOrder[0]).toBeLessThan( + onChange.mock.invocationCallOrder[0], + ); + expect(onChange.mock.invocationCallOrder[0]).toBeLessThan( + subscriber.mock.invocationCallOrder[0], + ); + }); + + it('빈 설정은 locale에 맞는 empty state와 버튼 문구를 만든다', async () => { + const custom = vi.fn().mockResolvedValue(false); + const defineSettings = createDefineSettings({ + pluginId: 'plugin-a', + api: { + settings: { get: vi.fn().mockResolvedValue({ language: 'en' }) }, + ui: { dialog: { custom }, components: {} }, + }, + namespacedStorage: { + get: vi.fn().mockResolvedValue(null), + set: vi.fn().mockResolvedValue(undefined), + }, + registerCleanup: vi.fn(), + } as never); + + await defineSettings({ settingsUI: 'modal', settings: {} }).open(); + + expect(custom).toHaveBeenCalledWith( + expect.stringContaining( + 'data-settings-empty="plugin-settings-plugin-a" style=""', + ), + { + showCancel: true, + confirmText: 'Apply', + cancelText: 'Cancel', + }, + ); + expect(custom.mock.calls[0]?.[0]).toContain('No settings available.'); + }); }); From f9902d6060ade022083f94797696afc5a1e41e4d Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 18:36:17 +0900 Subject: [PATCH 191/245] =?UTF-8?q?refactor:=20=ED=94=8C=EB=9F=AC=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=84=A4=EC=A0=95=20=ED=8F=BC=20=EB=B9=8C=EB=8D=94?= =?UTF-8?q?=20=EA=B3=B5=EC=9A=A9=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plugins/runtime/api/defineSettings.ts | 300 ++--------------- .../runtime/api/instanceSettingsDialog.ts | 299 ++--------------- .../api/pluginSettingsDialogContent.ts | 302 ++++++++++++++++++ 3 files changed, 360 insertions(+), 541 deletions(-) create mode 100644 src/renderer/plugins/runtime/api/pluginSettingsDialogContent.ts diff --git a/src/renderer/plugins/runtime/api/defineSettings.ts b/src/renderer/plugins/runtime/api/defineSettings.ts index 83d10abad..bf7b4253a 100644 --- a/src/renderer/plugins/runtime/api/defineSettings.ts +++ b/src/renderer/plugins/runtime/api/defineSettings.ts @@ -11,20 +11,10 @@ import { import { translatePluginMessage } from '@utils/plugin/pluginI18n'; import { sendBridgeMessageBestEffort } from '@utils/plugin/bridgeMessages'; import { - SECTION_WRAPPER_CLASS, - SECTION_LABEL_CLASS, - SECTION_CARD_CLASS, - FORM_ROW_CLASS, - FORM_LABEL_CLASS, -} from '@utils/cardRecipes'; -import { handlerRegistry } from '../handlers'; -import { createPluginDialogHandlerScope } from './pluginDialogHandlers'; -import { - coerceSettingValue, getDefaultSettings, - normalizeSettingsSections, omitLayoutSettingValues, } from '../settingsSections'; +import { createPluginSettingsDialogContent } from './pluginSettingsDialogContent'; import type { NamespacedStorage } from '../context'; import type { PluginSettingsDefinition, @@ -188,268 +178,38 @@ export const createDefineSettings = (deps: DefineSettingsDependencies) => { : `Failed to evaluate visibility for setting "${key}"`; console.error(`[Plugin ${pluginId}] ${message}:`, error); }; - const getNormalizedSections = () => - normalizeSettingsSections( - definition.settings, - dialogSettings, - reportNormalizationError, - ); const modalScope = `plugin-settings-${encodeURIComponent(pluginId)}`; - const findModalElement = (attribute: string, value: string) => - Array.from( - document.querySelectorAll(`[${attribute}]`), - ).find((element) => element.getAttribute(attribute) === value); - const updateVisibility = () => { - const sections = getNormalizedSections(); - sections.forEach((section, sectionIndex) => { - const sectionElement = findModalElement( - 'data-settings-section', - `${modalScope}-${sectionIndex}`, - ); - if (sectionElement) { - sectionElement.style.display = section.renderVisible ? '' : 'none'; + const dialogContent = await createPluginSettingsDialogContent({ + pluginId, + api, + settingsDefinition: definition.settings, + settings: dialogSettings, + modalScope, + translate, + createColorPickerId: (key) => `plugin-settings-${pluginId}-${key}`, + onNormalizationError: reportNormalizationError, + onSettingValueChange: (key, newValue, updateVisibility) => { + const prev = window.__dmn_current_plugin_id; + window.__dmn_current_plugin_id = pluginId; + try { + dialogSettings[key] = newValue; + applyPreview(dialogSettings); + updateVisibility(); + } finally { + window.__dmn_current_plugin_id = prev; } - section.entries.forEach((entry, entryIndex) => { - const entryElement = findModalElement( - 'data-settings-entry', - `${modalScope}-${sectionIndex}-${entryIndex}`, - ); - if (entryElement) { - entryElement.style.display = entry.renderVisible ? '' : 'none'; - } - }); - }); - const emptyElement = findModalElement( - 'data-settings-empty', - modalScope, - ); - if (emptyElement) { - emptyElement.style.display = sections.some( - (section) => section.renderVisible, - ) - ? 'none' - : ''; - } - }; - - const commitSettingValue = (key: string, newValue: unknown) => { - const prev = window.__dmn_current_plugin_id; - window.__dmn_current_plugin_id = pluginId; - try { - dialogSettings[key] = newValue; - applyPreview(dialogSettings); - updateVisibility(); - } finally { - window.__dmn_current_plugin_id = prev; - } - }; + }, + }); - const modalHandlers = createPluginDialogHandlerScope(); - let htmlContent = ''; try { - const normalizedSections = getNormalizedSections(); - // 패널(renderPluginSettingsForm)과 동일한 섹션 카드 구조·토큰 - section이 - // 없어도 암시적 카드 하나로 렌더 (모달-패널 외형 통합, 2026-07-12 결정) - htmlContent = '
'; - - for (const [sectionIndex, section] of normalizedSections.entries()) { - htmlContent += `
`; - if (section.label) { - const sectionLabel = translate( - section.label, - undefined, - section.label, - ); - htmlContent += `

${sectionLabel}

`; - } - htmlContent += `
`; - for (const [entryIndex, entry] of section.entries.entries()) { - const { key, schema } = entry; - const entryAttributes = `data-settings-entry="${modalScope}-${sectionIndex}-${entryIndex}" style="${ - entry.renderVisible ? '' : 'display:none' - }"`; - { - const value = - dialogSettings[key] !== undefined - ? dialogSettings[key] - : schema.default; - let componentHtml = ''; - const labelText = translate( - schema.label, - undefined, - schema.label, - ); - const placeholderText = - typeof schema.placeholder === 'string' - ? translate(schema.placeholder, undefined, schema.placeholder) - : schema.placeholder; - - const handleChange = (newValue: unknown) => { - // DOM 문자열을 스키마 타입으로 복원, 복원 불가면 커밋 스킵 - const coerced = coerceSettingValue(schema, newValue); - if (coerced === null) return; - commitSettingValue(key, coerced); - }; - - if (schema.type === 'boolean') { - componentHtml = modalHandlers.capture(() => - api.ui.components.checkbox({ - checked: !!value, - onChange: handleChange as ( - checked: boolean, - ) => void | Promise, - }), - ); - } else if (schema.type === 'color') { - const handleColorClick = (e: Event) => { - const target = (e.target as HTMLElement).closest('button'); - if (!target) return; - - const pickerId = `plugin-settings-${pluginId}-${key}`; - - if ( - window.__dmn_showColorPicker && - window.__dmn_getColorPickerState - ) { - const state = window.__dmn_getColorPickerState(); - if (state?.isOpen && state.id === pickerId) { - window.__dmn_showColorPicker({ - initialColor: state.color, - id: pickerId, - }); - return; - } - } - - target.classList.add('shadow-focus-ring'); - - api.ui.pickColor({ - initialColor: String(dialogSettings[key] ?? ''), - id: pickerId, - referenceElement: target as HTMLElement, - onColorChange: (newColor) => { - // 스와치(버튼 자체) 미리보기 업데이트 - target.style.setProperty( - '--dmn-color-swatch-color', - newColor, - ); - }, - onColorChangeComplete: (newColor) => { - commitSettingValue(key, newColor); - }, - onClose: () => { - target.classList.remove('shadow-focus-ring'); - }, - }); - }; - - const handlerId = modalHandlers.trackRegistryHandler( - handlerRegistry.register(pluginId, handleColorClick), - ); - - // 패널 ColorInput과 동일한 스와치 단독 버튼 - componentHtml = ` - - `; - } else if (schema.type === 'string' || schema.type === 'number') { - const strVal = String(value); - let inputWidth: number; - - if (schema.type === 'number') { - inputWidth = 60; - } else { - if (strVal.length <= 4) inputWidth = 60; - else if (strVal.length <= 10) inputWidth = 100; - else inputWidth = 200; - } - - componentHtml = modalHandlers.capture(() => - api.ui.components.input({ - type: - schema.type === 'string' - ? 'text' - : (schema.type as 'number'), - value: value as string | number, - onChange: handleChange as ( - value: string, - ) => void | Promise, - min: schema.min, - max: schema.max, - step: schema.step, - placeholder: placeholderText, - width: inputWidth, - }), - ); - } else if (schema.type === 'select') { - const translatedOptions = (schema.options || []).map( - (option: { label: string; value: string }) => ({ - ...option, - label: translate(option.label, undefined, option.label), - }), - ); - componentHtml = modalHandlers.capture(() => - api.ui.components.dropdown({ - options: translatedOptions, - selected: value as string, - onChange: handleChange as ( - value: string, - ) => void | Promise, - }), - ); - } - - htmlContent += ` -
-

${labelText}

- ${componentHtml} -
- `; - } - } - htmlContent += '
'; - } - - const noSettingsText = await api.settings - .get() - .then((s) => { - const locale = s.language || 'ko'; - return locale === 'en' - ? 'No settings available.' - : '설정할 항목이 없습니다.'; - }) - .catch(() => '설정할 항목이 없습니다.'); - htmlContent += `
${noSettingsText}
`; - - htmlContent += '
'; - - const [saveText, cancelText] = await api.settings - .get() - .then((s) => { - const locale = s.language || 'ko'; - return locale === 'en' ? ['Apply', 'Cancel'] : ['저장', '취소']; - }) - .catch(() => ['저장', '취소']); - - const confirmed = await api.ui.dialog.custom(htmlContent, { - showCancel: true, - confirmText: saveText, - cancelText: cancelText, - }); + const confirmed = await api.ui.dialog.custom( + dialogContent.htmlContent, + { + showCancel: true, + confirmText: dialogContent.confirmText, + cancelText: dialogContent.cancelText, + }, + ); if (confirmed) { // 확인: 현재 설정을 저장 (미리보기 상태가 이미 currentSettings에 반영됨) @@ -480,7 +240,7 @@ export const createDefineSettings = (deps: DefineSettingsDependencies) => { return false; } } finally { - modalHandlers.dispose(); + dialogContent.dispose(); } }; diff --git a/src/renderer/plugins/runtime/api/instanceSettingsDialog.ts b/src/renderer/plugins/runtime/api/instanceSettingsDialog.ts index 06f8c1af3..c3a53e763 100644 --- a/src/renderer/plugins/runtime/api/instanceSettingsDialog.ts +++ b/src/renderer/plugins/runtime/api/instanceSettingsDialog.ts @@ -1,20 +1,8 @@ import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; import { flushPluginInstancesEditSession } from '../displayElement/instancesCommitQueue'; -import { - SECTION_WRAPPER_CLASS, - SECTION_LABEL_CLASS, - SECTION_CARD_CLASS, - FORM_ROW_CLASS, - FORM_LABEL_CLASS, -} from '@utils/cardRecipes'; -import { handlerRegistry } from '../handlers'; -import { - coerceSettingValue, - normalizeSettingsSections, - omitLayoutSettingValues, -} from '../settingsSections'; +import { omitLayoutSettingValues } from '../settingsSections'; import type { DMNoteAPI, PluginDefinition } from '@src/types/plugin/api'; -import { createPluginDialogHandlerScope } from './pluginDialogHandlers'; +import { createPluginSettingsDialogContent } from './pluginSettingsDialogContent'; type Translate = ( key?: string, @@ -82,268 +70,37 @@ export const createInstanceSettingsDialogFactory = ({ : `Failed to evaluate visibility for setting "${key}"`; console.error(`[Plugin ${pluginId}] ${message}:`, error); }; - const getNormalizedSections = () => - normalizeSettingsSections( - definition.settings, - currentSettings, - reportNormalizationError, - ); const modalScope = `plugin-element-${encodeURIComponent( pluginId, )}-${encodeURIComponent(instanceId)}`; - const findModalElement = (attribute: string, value: string) => - Array.from( - document.querySelectorAll(`[${attribute}]`), - ).find((element) => element.getAttribute(attribute) === value); - const updateVisibility = () => { - const sections = getNormalizedSections(); - sections.forEach((section, sectionIndex) => { - const sectionElement = findModalElement( - 'data-settings-section', - `${modalScope}-${sectionIndex}`, - ); - if (sectionElement) { - sectionElement.style.display = section.renderVisible ? '' : 'none'; - } - section.entries.forEach((entry, entryIndex) => { - const entryElement = findModalElement( - 'data-settings-entry', - `${modalScope}-${sectionIndex}-${entryIndex}`, - ); - if (entryElement) { - entryElement.style.display = entry.renderVisible ? '' : 'none'; - } + const dialogContent = await createPluginSettingsDialogContent({ + pluginId, + api, + settingsDefinition: definition.settings, + settings: currentSettings, + modalScope, + translate, + createColorPickerId: (key) => `plugin-${pluginId}-${instanceId}-${key}`, + onNormalizationError: reportNormalizationError, + wrapSettingChange: wrapFunctionWithContext, + onSettingValueChange: async (key, newValue, updateVisibility) => { + currentSettings[key] = newValue; + api.ui.displayElement.update(instanceId, { + settings: { ...currentSettings }, }); - }); - const emptyElement = findModalElement( - 'data-settings-empty', - modalScope, - ); - if (emptyElement) { - emptyElement.style.display = sections.some( - (section) => section.renderVisible, - ) - ? 'none' - : ''; - } - }; - const commitSettingValue = async ( - key: string, - newValue: string | number | boolean, - ) => { - currentSettings[key] = newValue; - api.ui.displayElement.update(instanceId, { - settings: { ...currentSettings }, - }); - updateVisibility(); - }; + updateVisibility(); + }, + }); - const modalHandlers = createPluginDialogHandlerScope(); - let htmlContent = ''; try { - const normalizedSections = getNormalizedSections(); - // 패널(renderPluginSettingsForm)과 동일한 섹션 카드 구조·토큰 - section이 - // 없어도 암시적 카드 하나로 렌더 (모달-패널 외형 통합, 2026-07-12 결정) - htmlContent = '
'; - - for (const [sectionIndex, section] of normalizedSections.entries()) { - htmlContent += `
`; - if (section.label) { - const sectionLabel = translate( - section.label, - undefined, - section.label, - ); - htmlContent += `

${sectionLabel}

`; - } - htmlContent += `
`; - for (const [entryIndex, entry] of section.entries.entries()) { - const { key, schema } = entry; - const entryAttributes = `data-settings-entry="${modalScope}-${sectionIndex}-${entryIndex}" style="${ - entry.renderVisible ? '' : 'display:none' - }"`; - { - const value = - currentSettings[key] !== undefined - ? currentSettings[key] - : schema.default; - let componentHtml = ''; - const labelText = translate( - schema.label, - undefined, - schema.label, - ); - const placeholderText = - typeof schema.placeholder === 'string' - ? translate(schema.placeholder, undefined, schema.placeholder) - : schema.placeholder; - - const wrappedChange = wrapFunctionWithContext((newValue) => { - // DOM 문자열을 스키마 타입으로 복원, 복원 불가면 커밋 스킵 - const coerced = coerceSettingValue(schema, newValue); - if (coerced === null) return; - return commitSettingValue(key, coerced); - }); - - if (schema.type === 'boolean') { - componentHtml = modalHandlers.capture(() => - api.ui.components.checkbox({ - checked: !!value, - onChange: wrappedChange as unknown as ( - checked: boolean, - ) => void | Promise, - }), - ); - } else if (schema.type === 'color') { - const handleColorClick = (e: Event) => { - const target = (e.target as HTMLElement).closest('button'); - if (!target) return; - - const pickerId = `plugin-${pluginId}-${instanceId}-${key}`; - - if ( - window.__dmn_showColorPicker && - window.__dmn_getColorPickerState - ) { - const state = window.__dmn_getColorPickerState(); - if (state?.isOpen && state.id === pickerId) { - window.__dmn_showColorPicker({ - initialColor: state.color, - id: pickerId, - }); - return; - } - } - - target.classList.add('shadow-focus-ring'); - - api.ui.pickColor({ - initialColor: String(currentSettings[key] ?? ''), - id: pickerId, - referenceElement: target as HTMLElement, - onColorChange: (newColor) => { - // 스와치(버튼 자체) 미리보기 업데이트 - target.style.setProperty( - '--dmn-color-swatch-color', - newColor, - ); - }, - onColorChangeComplete: (newColor) => { - wrappedChange(newColor); - }, - onClose: () => { - target.classList.remove('shadow-focus-ring'); - }, - }); - }; - - const handlerId = modalHandlers.trackRegistryHandler( - handlerRegistry.register(pluginId, handleColorClick), - ); - - // 패널 ColorInput과 동일한 스와치 단독 버튼 - componentHtml = ` - - `; - } else if (schema.type === 'string' || schema.type === 'number') { - const strVal = String(value); - let inputWidth: number; - - if (schema.type === 'number') { - inputWidth = 60; - } else { - if (strVal.length <= 4) inputWidth = 60; - else if (strVal.length <= 10) inputWidth = 100; - else inputWidth = 200; - } - - componentHtml = modalHandlers.capture(() => - api.ui.components.input({ - type: - schema.type === 'string' - ? 'text' - : (schema.type as 'number'), - value: value as string | number, - onChange: wrappedChange as unknown as ( - value: string, - ) => void | Promise, - min: schema.min, - max: schema.max, - step: schema.step, - placeholder: placeholderText, - width: inputWidth, - }), - ); - } else if (schema.type === 'select') { - const translatedOptions = (schema.options || []).map( - (option: { label: string; value: string }) => ({ - ...option, - label: translate(option.label, undefined, option.label), - }), - ); - componentHtml = modalHandlers.capture(() => - api.ui.components.dropdown({ - options: translatedOptions, - selected: value as string, - onChange: wrappedChange as unknown as ( - value: string, - ) => void | Promise, - }), - ); - } - - htmlContent += ` -
-

${labelText}

- ${componentHtml} -
- `; - } - } - htmlContent += '
'; - } - - const noSettingsText = await api.settings - .get() - .then((s) => { - const locale = s.language || 'ko'; - return locale === 'en' - ? 'No settings available.' - : '설정할 항목이 없습니다.'; - }) - .catch(() => '설정할 항목이 없습니다.'); - htmlContent += `
${noSettingsText}
`; - - htmlContent += '
'; - - const [saveText, cancelText] = await api.settings - .get() - .then((s) => { - const locale = s.language || 'ko'; - return locale === 'en' ? ['Apply', 'Cancel'] : ['저장', '취소']; - }) - .catch(() => ['저장', '취소']); - - const confirmed = await api.ui.dialog.custom(htmlContent, { - showCancel: true, - confirmText: saveText, - cancelText: cancelText, - }); + const confirmed = await api.ui.dialog.custom( + dialogContent.htmlContent, + { + showCancel: true, + confirmText: dialogContent.confirmText, + cancelText: dialogContent.cancelText, + }, + ); if (!confirmed) { api.ui.displayElement.update(instanceId, { @@ -353,7 +110,7 @@ export const createInstanceSettingsDialogFactory = ({ // 모달 정산은 확정 경계 - 확정·취소 revert 모두 즉시 커밋 flushPluginInstancesEditSession(pluginId); } finally { - modalHandlers.dispose(); + dialogContent.dispose(); } }; }; diff --git a/src/renderer/plugins/runtime/api/pluginSettingsDialogContent.ts b/src/renderer/plugins/runtime/api/pluginSettingsDialogContent.ts new file mode 100644 index 000000000..1b07cec07 --- /dev/null +++ b/src/renderer/plugins/runtime/api/pluginSettingsDialogContent.ts @@ -0,0 +1,302 @@ +import { + SECTION_WRAPPER_CLASS, + SECTION_LABEL_CLASS, + SECTION_CARD_CLASS, + FORM_ROW_CLASS, + FORM_LABEL_CLASS, +} from '@utils/cardRecipes'; +import type { + DMNoteAPI, + PluginSettingSchema, + PluginSettingValue, +} from '@src/types/plugin/api'; +import { handlerRegistry } from '../handlers'; +import { + coerceSettingValue, + normalizeSettingsSections, + type SettingsNormalizationErrorKind, +} from '../settingsSections'; +import { createPluginDialogHandlerScope } from './pluginDialogHandlers'; + +type Translate = ( + key?: string, + params?: Record, + fallback?: string, +) => string; + +type SettingChangeHandler = (newValue: unknown) => unknown; + +interface CreatePluginSettingsDialogContentOptions { + pluginId: string; + api: DMNoteAPI; + settingsDefinition: Record | undefined; + settings: Record; + modalScope: string; + translate: Translate; + createColorPickerId: (key: string) => string; + onNormalizationError: ( + key: string, + error: unknown, + kind: SettingsNormalizationErrorKind, + ) => void; + onSettingValueChange: ( + key: string, + value: PluginSettingValue, + updateVisibility: () => void, + ) => unknown; + wrapSettingChange?: (handler: SettingChangeHandler) => SettingChangeHandler; +} + +interface PluginSettingsDialogContent { + htmlContent: string; + confirmText: string; + cancelText: string; + dispose: () => void; +} + +export const createPluginSettingsDialogContent = async ({ + pluginId, + api, + settingsDefinition, + settings, + modalScope, + translate, + createColorPickerId, + onNormalizationError, + onSettingValueChange, + wrapSettingChange = (handler) => handler, +}: CreatePluginSettingsDialogContentOptions): Promise => { + const getNormalizedSections = () => + normalizeSettingsSections( + settingsDefinition, + settings, + onNormalizationError, + ); + const findModalElement = (attribute: string, value: string) => + Array.from(document.querySelectorAll(`[${attribute}]`)).find( + (element) => element.getAttribute(attribute) === value, + ); + const updateVisibility = () => { + const sections = getNormalizedSections(); + sections.forEach((section, sectionIndex) => { + const sectionElement = findModalElement( + 'data-settings-section', + `${modalScope}-${sectionIndex}`, + ); + if (sectionElement) { + sectionElement.style.display = section.renderVisible ? '' : 'none'; + } + section.entries.forEach((entry, entryIndex) => { + const entryElement = findModalElement( + 'data-settings-entry', + `${modalScope}-${sectionIndex}-${entryIndex}`, + ); + if (entryElement) { + entryElement.style.display = entry.renderVisible ? '' : 'none'; + } + }); + }); + const emptyElement = findModalElement('data-settings-empty', modalScope); + if (emptyElement) { + emptyElement.style.display = sections.some( + (section) => section.renderVisible, + ) + ? 'none' + : ''; + } + }; + + const modalHandlers = createPluginDialogHandlerScope(); + try { + const normalizedSections = getNormalizedSections(); + // 패널(renderPluginSettingsForm)과 동일한 섹션 카드 구조·토큰 - section이 + // 없어도 암시적 카드 하나로 렌더 (모달-패널 외형 통합, 2026-07-12 결정) + let htmlContent = '
'; + + for (const [sectionIndex, section] of normalizedSections.entries()) { + htmlContent += `
`; + if (section.label) { + const sectionLabel = translate(section.label, undefined, section.label); + htmlContent += `

${sectionLabel}

`; + } + htmlContent += `
`; + for (const [entryIndex, entry] of section.entries.entries()) { + const { key, schema } = entry; + const entryAttributes = `data-settings-entry="${modalScope}-${sectionIndex}-${entryIndex}" style="${ + entry.renderVisible ? '' : 'display:none' + }"`; + const value = + settings[key] !== undefined ? settings[key] : schema.default; + let componentHtml = ''; + const labelText = translate(schema.label, undefined, schema.label); + const placeholderText = + typeof schema.placeholder === 'string' + ? translate(schema.placeholder, undefined, schema.placeholder) + : schema.placeholder; + + const handleChange = wrapSettingChange((newValue) => { + // DOM 문자열을 스키마 타입으로 복원, 복원 불가면 커밋 스킵 + const coerced = coerceSettingValue(schema, newValue); + if (coerced === null) return; + return onSettingValueChange(key, coerced, updateVisibility); + }); + + if (schema.type === 'boolean') { + componentHtml = modalHandlers.capture(() => + api.ui.components.checkbox({ + checked: !!value, + onChange: handleChange as unknown as ( + checked: boolean, + ) => void | Promise, + }), + ); + } else if (schema.type === 'color') { + const handleColorClick = (event: Event) => { + const target = (event.target as HTMLElement).closest('button'); + if (!target) return; + + const pickerId = createColorPickerId(key); + + if ( + window.__dmn_showColorPicker && + window.__dmn_getColorPickerState + ) { + const state = window.__dmn_getColorPickerState(); + if (state?.isOpen && state.id === pickerId) { + window.__dmn_showColorPicker({ + initialColor: state.color, + id: pickerId, + }); + return; + } + } + + target.classList.add('shadow-focus-ring'); + + api.ui.pickColor({ + initialColor: String(settings[key] ?? ''), + id: pickerId, + referenceElement: target as HTMLElement, + onColorChange: (newColor) => { + // 스와치(버튼 자체) 미리보기 업데이트 + target.style.setProperty('--dmn-color-swatch-color', newColor); + }, + onColorChangeComplete: (newColor) => { + handleChange(newColor); + }, + onClose: () => { + target.classList.remove('shadow-focus-ring'); + }, + }); + }; + + const handlerId = modalHandlers.trackRegistryHandler( + handlerRegistry.register(pluginId, handleColorClick), + ); + + // 패널 ColorInput과 동일한 스와치 단독 버튼 + componentHtml = ` + + `; + } else if (schema.type === 'string' || schema.type === 'number') { + const strVal = String(value); + let inputWidth: number; + + if (schema.type === 'number') { + inputWidth = 60; + } else { + if (strVal.length <= 4) inputWidth = 60; + else if (strVal.length <= 10) inputWidth = 100; + else inputWidth = 200; + } + + componentHtml = modalHandlers.capture(() => + api.ui.components.input({ + type: + schema.type === 'string' ? 'text' : (schema.type as 'number'), + value: value as string | number, + onChange: handleChange as unknown as ( + value: string, + ) => void | Promise, + min: schema.min, + max: schema.max, + step: schema.step, + placeholder: placeholderText, + width: inputWidth, + }), + ); + } else if (schema.type === 'select') { + const translatedOptions = (schema.options || []).map( + (option: { label: string; value: string }) => ({ + ...option, + label: translate(option.label, undefined, option.label), + }), + ); + componentHtml = modalHandlers.capture(() => + api.ui.components.dropdown({ + options: translatedOptions, + selected: value as string, + onChange: handleChange as unknown as ( + value: string, + ) => void | Promise, + }), + ); + } + + htmlContent += ` +
+

${labelText}

+ ${componentHtml} +
+ `; + } + htmlContent += '
'; + } + + const noSettingsText = await api.settings + .get() + .then((settingsState) => { + const locale = settingsState.language || 'ko'; + return locale === 'en' + ? 'No settings available.' + : '설정할 항목이 없습니다.'; + }) + .catch(() => '설정할 항목이 없습니다.'); + htmlContent += `
${noSettingsText}
`; + + htmlContent += '
'; + + const [confirmText, cancelText] = await api.settings + .get() + .then((settingsState) => { + const locale = settingsState.language || 'ko'; + return locale === 'en' ? ['Apply', 'Cancel'] : ['저장', '취소']; + }) + .catch(() => ['저장', '취소']); + + return { + htmlContent, + confirmText, + cancelText, + dispose: modalHandlers.dispose, + }; + } catch (error) { + modalHandlers.dispose(); + throw error; + } +}; From ab318df91a42c8d01aa70a50dc9635cb4ad1ea01 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 18:37:44 +0900 Subject: [PATCH 192/245] =?UTF-8?q?test:=20=EC=9A=94=EC=86=8C=20=EC=86=8D?= =?UTF-8?q?=EC=84=B1=20=ED=8C=A8=EC=B9=98=20=EA=B3=84=EC=95=BD=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/state/editor_ops/tests.rs | 128 ++++++++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/src-tauri/src/state/editor_ops/tests.rs b/src-tauri/src/state/editor_ops/tests.rs index f1245248c..ff769fc77 100644 --- a/src-tauri/src/state/editor_ops/tests.rs +++ b/src-tauri/src/state/editor_ops/tests.rs @@ -233,6 +233,134 @@ fn paint_descriptor( } } +#[test] +fn property_patch_pair_fallback_and_apply_noop_bool_are_stable() { + let descriptor = paint_descriptor( + "idle-first", + Some((45.0, &[("idle-first", 0.0), ("idle-last", 1.0)])), + ); + let expected_gradient = descriptor + .gradient + .as_ref() + .map(EditorPaintGradientV1::to_gradient_spec); + let mut position = KeyPosition { + background_color: Some(descriptor.color.clone()), + background_gradient: expected_gradient.clone(), + active_background_color: Some(" ".to_string()), + active_background_gradient: None, + ..KeyPosition::default() + }; + + assert!(preserve_active_paint_fallback( + &mut position, + PaintSurface::Background + )); + assert_eq!(position.active_background_color, position.background_color); + assert_eq!( + position.active_background_gradient, + position.background_gradient + ); + assert!(!preserve_active_paint_fallback( + &mut position, + PaintSurface::Background + )); + + let mut color = position.active_background_color.clone(); + let mut gradient = position.active_background_gradient.clone(); + assert!(!apply_paint_descriptor( + &mut color, + &mut gradient, + &descriptor + )); + + let replacement = paint_descriptor("replacement", None); + assert!(apply_paint_descriptor( + &mut color, + &mut gradient, + &replacement + )); + assert_eq!(color.as_deref(), Some("replacement")); + assert!(gradient.is_none()); + assert!(!apply_paint_descriptor( + &mut color, + &mut gradient, + &replacement + )); + + position.active_background_color = Some("explicit-active".to_string()); + position.active_background_gradient = None; + position.background_color = Some("new-idle".to_string()); + position.background_gradient = None; + assert!(!preserve_active_paint_fallback( + &mut position, + PaintSurface::Background + )); + assert_eq!( + position.active_background_color.as_deref(), + Some("explicit-active") + ); +} + +#[test] +fn property_patch_validation_precedence_and_synced_note_noop_are_stable() { + let store = base_store(); + let original = store.clone(); + let invalid_descriptor = paint_descriptor( + "mismatched-representative", + Some((360.0, &[("first", 0.0), ("last", 1.0)])), + ); + let error = prepare_editor_ops_transition( + &store, + &[ + patch_property_op( + EditorElementTypeV1::Key, + &store.key_positions["4key"][0].id, + EditorElementPropertyPatchV1::Hidden(true), + ), + patch_property_op( + EditorElementTypeV1::Key, + uuid::Uuid::new_v4().to_string(), + EditorElementPropertyPatchV1::BackgroundPaint(invalid_descriptor), + ), + ], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("INVALID_PAINT_GRADIENT")); + assert_eq!( + error.message, + "paint gradient angle must be finite and canonical between 0 and 360" + ); + assert_eq!(store, original); + + let mut synced_store = base_store(); + let id = synced_store.key_positions["4key"][0].id.clone(); + let position = &mut synced_store.key_positions.get_mut("4key").unwrap()[0]; + position.note_color = NoteColor::Solid("same-note".to_string()); + position.note_gradient = None; + position.note_glow_sync_paint = true; + assert!(position.mirror_note_body_to_glow()); + let synced_original = synced_store.clone(); + let op = patch_property_op( + EditorElementTypeV1::Key, + id, + EditorElementPropertyPatchV1::NotePaint(EditorNotePaintIntentV1::Color( + crate::models::EditorNotePaintColorIntentV1 { + color: EditorNoteColorV1::Solid("same-note".to_string()), + }, + )), + ); + let transition = prepare_editor_ops_transition(&synced_store, &[op]).unwrap(); + assert_eq!( + transition.op_results[0].status, + EditorOpResultStatusV1::NoChange + ); + assert!(transition.changed_fields.is_empty()); + assert_eq!( + transition.candidate, + EditorDocumentV1::from_store(&synced_original) + ); +} + fn counter_fill_solid(color: &str) -> EditorCounterFillIntentV1 { EditorCounterFillIntentV1::Solid(crate::models::EditorCounterFillSolidIntentV1 { color: color.to_string(), From 087c357664ba3c024527fe184c1e9f28597733d2 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 18:37:44 +0900 Subject: [PATCH 193/245] =?UTF-8?q?refactor:=20=EC=9A=94=EC=86=8C=20?= =?UTF-8?q?=EC=86=8D=EC=84=B1=20=ED=8C=A8=EC=B9=98=20=EA=B2=BD=EA=B3=84=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/state/editor_ops.rs | 742 +---------------- .../src/state/editor_ops/property_patch.rs | 757 ++++++++++++++++++ 2 files changed, 760 insertions(+), 739 deletions(-) create mode 100644 src-tauri/src/state/editor_ops/property_patch.rs diff --git a/src-tauri/src/state/editor_ops.rs b/src-tauri/src/state/editor_ops.rs index 7e1add150..b9b99e40a 100644 --- a/src-tauri/src/state/editor_ops.rs +++ b/src-tauri/src/state/editor_ops.rs @@ -25,8 +25,11 @@ use super::editor::{ use super::native_element_id::DUPLICATE_ELEMENT_ID; use super::plugin::{plugin_group_refs_from_store, PluginGroupRefs}; +mod property_patch; mod transition; +use property_patch::*; + pub(crate) use transition::{ prepare_editor_ops_transition, prepare_editor_ops_transition_with_plugin_refs, }; @@ -47,745 +50,6 @@ struct ElementLocation { index: usize, } -fn current_counter_animation_preset( - store: &AppStoreData, - preset_id: &str, -) -> Option { - let mut preset = store - .counter_animation_presets - .iter() - .find(|preset| preset.id == preset_id) - .cloned() - .or_else(|| { - default_counter_animation_builtin_presets() - .into_iter() - .find(|preset| preset.id == preset_id) - })?; - preset.normalize(); - Some(preset) -} - -fn validate_counter_animation_preset_patch( - store: &AppStoreData, - patch: &EditorCounterAnimationPresetIntentV1, -) -> Result<(), EditorCommitError> { - if let Some(bezier) = patch.bezier { - let valid = bezier.iter().enumerate().all(|(index, value)| { - value.is_finite() - && if matches!(index, 0 | 2) { - (0.0..=1.0).contains(value) - } else { - (-2.0..=2.0).contains(value) - } - }); - if !valid { - return Err(EditorCommitError::validation( - "INVALID_COUNTER_ANIMATION_PRESET_VALUE", - "counter animation bezier values are out of range", - )); - } - } - if patch.scale.is_some_and(|value| !value.is_finite()) { - return Err(EditorCommitError::validation( - "INVALID_COUNTER_ANIMATION_PRESET_VALUE", - "counter animation scale must be finite", - )); - } - if patch - .duration_ms - .is_some_and(|value| !(1..=5000).contains(&value)) - { - return Err(EditorCommitError::validation( - "INVALID_COUNTER_ANIMATION_PRESET_VALUE", - "counter animation duration must be between 1 and 5000 milliseconds", - )); - } - let preset = current_counter_animation_preset(store, &patch.preset_id).ok_or_else(|| { - EditorCommitError::validation( - "COUNTER_ANIMATION_PRESET_NOT_FOUND", - format!("counter animation preset not found: {}", patch.preset_id), - ) - })?; - if patch.bezier.is_some_and(|value| value != preset.bezier) - || patch.scale.is_some_and(|value| value != preset.scale) - || patch - .duration_ms - .is_some_and(|value| value != preset.duration_ms) - { - return Err(EditorCommitError::validation( - "COUNTER_ANIMATION_PRESET_MISMATCH", - format!( - "counter animation preset values are stale: {}", - patch.preset_id - ), - )); - } - Ok(()) -} - -fn validate_paint_descriptor( - descriptor: &EditorPaintDescriptorV1, -) -> Result<(), EditorCommitError> { - let Some(gradient) = descriptor.gradient.as_ref() else { - return Ok(()); - }; - validate_paint_gradient(gradient)?; - if gradient - .stops - .first() - .is_some_and(|stop| stop.color != descriptor.color) - { - return Err(EditorCommitError::validation( - "PAINT_COLOR_GRADIENT_MISMATCH", - "paint color must equal the first gradient stop color", - )); - } - Ok(()) -} - -fn validate_paint_gradient(gradient: &EditorPaintGradientV1) -> Result<(), EditorCommitError> { - if !gradient.angle.is_finite() - || !(0.0..360.0).contains(&gradient.angle) - || (gradient.angle == 0.0 && gradient.angle.is_sign_negative()) - { - return Err(EditorCommitError::validation( - "INVALID_PAINT_GRADIENT", - "paint gradient angle must be finite and canonical between 0 and 360", - )); - } - if !(2..=8).contains(&gradient.stops.len()) { - return Err(EditorCommitError::validation( - "INVALID_PAINT_GRADIENT", - "paint gradient must contain between 2 and 8 stops", - )); - } - let mut previous_pos = None; - for stop in &gradient.stops { - // 빈 stop 색은 로드 canonicalize가 빈 대표색을 만들어 blank 정규화와 - // 매 시작 진동한다 - 커밋에서 차단 - if stop.color.trim().is_empty() { - return Err(EditorCommitError::validation( - "INVALID_PAINT_GRADIENT", - "paint gradient stop colors must not be blank", - )); - } - if !stop.pos.is_finite() - || !(0.0..=1.0).contains(&stop.pos) - || (stop.pos == 0.0 && stop.pos.is_sign_negative()) - { - return Err(EditorCommitError::validation( - "INVALID_PAINT_GRADIENT", - "paint gradient stop positions must be finite and between 0 and 1", - )); - } - if previous_pos.is_some_and(|previous| stop.pos < previous) { - return Err(EditorCommitError::validation( - "INVALID_PAINT_GRADIENT", - "paint gradient stops must be ordered by position", - )); - } - previous_pos = Some(stop.pos); - } - Ok(()) -} - -fn validate_counter_fill_intent( - intent: &EditorCounterFillIntentV1, -) -> Result<(), EditorCommitError> { - let EditorCounterFillIntentV1::Gradient(intent) = intent else { - return Ok(()); - }; - validate_paint_gradient(&intent.gradient)?; - let representative = compact_canonical_rgba( - &intent - .gradient - .stops - .first() - .expect("a validated counter fill gradient has at least two stops") - .color, - ); - if intent.color != representative { - return Err(EditorCommitError::validation( - "COUNTER_FILL_COLOR_GRADIENT_MISMATCH", - "counter fill color must equal the compact first gradient stop color", - )); - } - Ok(()) -} - -fn has_stored_paint_value(color: &Option, gradient: &Option) -> bool { - color - .as_deref() - .is_some_and(|value| !value.trim().is_empty()) - || gradient.is_some() -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum PaintSurface { - Background, - Border, - Font, -} - -fn preserve_active_paint_fallback(position: &mut KeyPosition, surface: PaintSurface) -> bool { - let (idle_color, idle_gradient, active_color, active_gradient) = match surface { - PaintSurface::Background => ( - position.background_color.clone(), - position.background_gradient.clone(), - &mut position.active_background_color, - &mut position.active_background_gradient, - ), - PaintSurface::Border => ( - position.border_color.clone(), - position.border_gradient.clone(), - &mut position.active_border_color, - &mut position.active_border_gradient, - ), - PaintSurface::Font => ( - position.font_color.clone(), - position.font_gradient.clone(), - &mut position.active_font_color, - &mut position.active_font_gradient, - ), - }; - if has_stored_paint_value(active_color, active_gradient) { - return false; - } - - let changed = *active_color != idle_color || *active_gradient != idle_gradient; - *active_color = idle_color; - *active_gradient = idle_gradient; - changed -} - -fn apply_paint_descriptor( - color: &mut Option, - gradient: &mut Option, - descriptor: &EditorPaintDescriptorV1, -) -> bool { - let next_color = Some(descriptor.color.clone()); - let next_gradient = descriptor - .gradient - .as_ref() - .map(|gradient| gradient.to_gradient_spec()); - let changed = *color != next_color || *gradient != next_gradient; - *color = next_color; - *gradient = next_gradient; - changed -} - -// font 전용: 빈 solid 색은 None으로 저장 - 로드 정규화(normalize_blank_font_colors)와 -// 같은 의미라 커밋 직후 문서와 재로드 문서가 갈리지 않는다 -fn apply_font_paint_descriptor( - color: &mut Option, - gradient: &mut Option, - descriptor: &EditorPaintDescriptorV1, -) -> bool { - let next_color = Some(descriptor.color.clone()).filter(|value| !value.trim().is_empty()); - let next_gradient = descriptor - .gradient - .as_ref() - .map(|gradient| gradient.to_gradient_spec()); - let changed = *color != next_color || *gradient != next_gradient; - *color = next_color; - *gradient = next_gradient; - changed -} - -fn patch_counter_fill( - position: &mut KeyPosition, - active: bool, - intent: &EditorCounterFillIntentV1, -) -> bool { - let (next_color, next_gradient) = match intent { - EditorCounterFillIntentV1::Solid(intent) => (intent.color.clone(), None), - EditorCounterFillIntentV1::Gradient(intent) => ( - intent.color.clone(), - Some(intent.gradient.to_gradient_spec()), - ), - }; - let (color, gradient) = if active { - ( - &mut position.counter.fill.active, - &mut position.counter.fill_active_gradient, - ) - } else { - ( - &mut position.counter.fill.idle, - &mut position.counter.fill_idle_gradient, - ) - }; - let changed = *color != next_color || *gradient != next_gradient; - *color = next_color; - *gradient = next_gradient; - changed -} - -fn validate_shadow_leaf(patch: &EditorShadowLeafPatchV1) -> Result<(), EditorCommitError> { - let valid_offset = - |value: f64| value.is_finite() && (SHADOW_OFFSET_MIN..=SHADOW_OFFSET_MAX).contains(&value); - // 닫힌 enum 전수 나열 - 신규 leaf가 검증 없이 통과하면 컴파일 에러로 드러나게 - let violation = match patch { - EditorShadowLeafPatchV1::Color(color) => color - .is_empty() - .then_some("shadow color must be a non-empty string"), - EditorShadowLeafPatchV1::OffsetX(offset_x) => (!valid_offset(*offset_x)) - .then_some("shadow offset X must be finite and between -100 and 100"), - EditorShadowLeafPatchV1::OffsetY(offset_y) => (!valid_offset(*offset_y)) - .then_some("shadow offset Y must be finite and between -100 and 100"), - EditorShadowLeafPatchV1::Blur(blur) => (!blur.is_finite() - || !(SHADOW_BLUR_MIN..=SHADOW_BLUR_MAX).contains(blur)) - .then_some("shadow blur must be finite and between 0 and 100"), - }; - violation.map_or(Ok(()), |message| { - Err(EditorCommitError::validation( - "INVALID_ELEMENT_SHADOW", - message, - )) - }) -} - -fn validate_image_transform_leaf( - patch: &ImageTransformLeafPatchV1, -) -> Result<(), EditorCommitError> { - let violation = match patch { - ImageTransformLeafPatchV1::OffsetX(value) => (!value.is_finite() - || !(IMAGE_TRANSFORM_OFFSET_MIN..=IMAGE_TRANSFORM_OFFSET_MAX).contains(value)) - .then_some("image offset X must be finite and between -500 and 500"), - ImageTransformLeafPatchV1::OffsetY(value) => (!value.is_finite() - || !(IMAGE_TRANSFORM_OFFSET_MIN..=IMAGE_TRANSFORM_OFFSET_MAX).contains(value)) - .then_some("image offset Y must be finite and between -500 and 500"), - ImageTransformLeafPatchV1::Rotation(value) => (!value.is_finite() - || !(IMAGE_TRANSFORM_ROTATION_MIN..=IMAGE_TRANSFORM_ROTATION_MAX).contains(value)) - .then_some("image rotation must be finite and between -180 and 180"), - ImageTransformLeafPatchV1::Scale(value) => (!value.is_finite() - || !(IMAGE_TRANSFORM_SCALE_MIN..=IMAGE_TRANSFORM_SCALE_MAX).contains(value)) - .then_some("image scale must be finite and between 0.1 and 10"), - }; - violation.map_or(Ok(()), |message| { - Err(EditorCommitError::validation( - "INVALID_IMAGE_TRANSFORM", - message, - )) - }) -} - -fn default_shadow_spec( - position: &KeyPosition, - element_type: EditorElementTypeV1, - active: bool, -) -> ElementShadowSpec { - let has_image = if active { - position - .active_image - .as_deref() - .is_some_and(|path| !path.trim().is_empty()) - || position - .inactive_image - .as_deref() - .is_some_and(|path| !path.trim().is_empty()) - } else { - position - .inactive_image - .as_deref() - .is_some_and(|path| !path.trim().is_empty()) - }; - let knob_suppressed = element_type == EditorElementTypeV1::Knob - && (if active { - position.active_transparent - } else { - position.idle_transparent - } || position.border_width.unwrap_or(0.0) > 0.0); - ElementShadowSpec { - enabled: !(has_image || knob_suppressed), - color: if active { - "rgba(0, 0, 0, 0.32)".to_string() - } else { - "rgba(0, 0, 0, 0.28)".to_string() - }, - offset_x: 0.0, - offset_y: if active { 3.0 } else { 4.0 }, - blur: if active { 8.0 } else { 10.0 }, - } -} - -fn apply_shadow_leaf(shadow: &mut ElementShadowSpec, patch: &EditorShadowLeafPatchV1) -> bool { - match patch { - EditorShadowLeafPatchV1::Color(color) => { - if shadow.color == *color { - false - } else { - shadow.color.clone_from(color); - true - } - } - EditorShadowLeafPatchV1::OffsetX(offset_x) => { - if shadow.offset_x == *offset_x { - false - } else { - shadow.offset_x = *offset_x; - true - } - } - EditorShadowLeafPatchV1::OffsetY(offset_y) => { - if shadow.offset_y == *offset_y { - false - } else { - shadow.offset_y = *offset_y; - true - } - } - EditorShadowLeafPatchV1::Blur(blur) => { - if shadow.blur == *blur { - false - } else { - shadow.blur = *blur; - true - } - } - } -} - -fn apply_image_transform_leaf( - transform: &mut ImageTransform, - patch: &ImageTransformLeafPatchV1, -) -> bool { - let target = match patch { - ImageTransformLeafPatchV1::OffsetX(_) => &mut transform.offset_x, - ImageTransformLeafPatchV1::OffsetY(_) => &mut transform.offset_y, - ImageTransformLeafPatchV1::Rotation(_) => &mut transform.rotation, - ImageTransformLeafPatchV1::Scale(_) => &mut transform.scale, - }; - let value = match patch { - ImageTransformLeafPatchV1::OffsetX(value) - | ImageTransformLeafPatchV1::OffsetY(value) - | ImageTransformLeafPatchV1::Rotation(value) - | ImageTransformLeafPatchV1::Scale(value) => *value, - }; - if *target == value { - false - } else { - *target = value; - true - } -} - -fn patch_image_transform( - transform: &mut Option, - patch: &Option, -) -> bool { - let Some(patch) = patch else { - return transform.take().is_some(); - }; - let seeded = transform.is_none(); - let transform = transform.get_or_insert_default(); - apply_image_transform_leaf(transform, patch) || seeded -} - -fn patch_shadow( - position: &mut KeyPosition, - element_type: EditorElementTypeV1, - active: bool, - patch: &EditorShadowLeafPatchV1, -) -> bool { - let seeded = if active { - position.active_shadow.is_none() - } else { - position.shadow.is_none() - }; - let default = seeded.then(|| default_shadow_spec(position, element_type, active)); - let shadow = if active { - position - .active_shadow - .get_or_insert_with(|| default.unwrap()) - } else { - position.shadow.get_or_insert_with(|| default.unwrap()) - }; - apply_shadow_leaf(shadow, patch) || seeded -} - -fn patch_shadow_enabled( - position: &mut KeyPosition, - element_type: EditorElementTypeV1, - enabled: bool, -) -> bool { - let idle_seeded = position.shadow.is_none(); - let idle_default = idle_seeded.then(|| default_shadow_spec(position, element_type, false)); - let idle = position.shadow.get_or_insert_with(|| idle_default.unwrap()); - let mut changed = idle_seeded || idle.enabled != enabled; - idle.enabled = enabled; - - if matches!( - element_type, - EditorElementTypeV1::Key | EditorElementTypeV1::Knob - ) { - let active_seeded = position.active_shadow.is_none(); - let active_default = - active_seeded.then(|| default_shadow_spec(position, element_type, true)); - let active = position - .active_shadow - .get_or_insert_with(|| active_default.unwrap()); - changed |= active_seeded || active.enabled != enabled; - active.enabled = enabled; - } - changed -} - -fn validate_note_paint_intent(patch: &EditorNotePaintIntentV1) -> Result<(), EditorCommitError> { - let valid_opacity = |value: u32| value <= 100; - let valid = match patch { - EditorNotePaintIntentV1::Descriptor(patch) => valid_opacity(patch.opacity), - EditorNotePaintIntentV1::Color(_) => true, - EditorNotePaintIntentV1::Opacity(patch) => valid_opacity(patch.opacity), - EditorNotePaintIntentV1::GradientOpacity(patch) => { - valid_opacity(patch.opacity) - && valid_opacity(patch.opacity_top) - && valid_opacity(patch.opacity_bottom) - } - }; - if !valid { - return Err(EditorCommitError::validation( - "NOTE_OPACITY_OUT_OF_RANGE", - "note opacity values must be integers between 0 and 100", - )); - } - - let EditorNotePaintIntentV1::Descriptor(patch) = patch else { - return Ok(()); - }; - let Some(gradient) = patch.gradient.as_ref() else { - if matches!(patch.color, EditorNoteColorV1::Solid(_)) { - return Ok(()); - } - return Err(EditorCommitError::validation( - "PAINT_COLOR_GRADIENT_MISMATCH", - "note paint without a gradient must use a solid color", - )); - }; - - validate_paint_gradient(gradient)?; - let gradient = gradient.to_gradient_spec(); - let Some(shadow) = note_gradient_shadow(&gradient, patch.opacity) else { - return Err(EditorCommitError::validation( - "INVALID_PAINT_GRADIENT", - "note gradient contains an unsupported stop color", - )); - }; - if editor_note_color(&patch.color) != shadow.color { - return Err(EditorCommitError::validation( - "PAINT_COLOR_GRADIENT_MISMATCH", - "note paint color must equal the derived gradient shadow color", - )); - } - Ok(()) -} - -fn editor_note_color(color: &EditorNoteColorV1) -> NoteColor { - match color { - EditorNoteColorV1::Solid(color) => NoteColor::Solid(color.clone()), - EditorNoteColorV1::Gradient(gradient) => NoteColor::Gradient { - top: gradient.top.clone(), - bottom: gradient.bottom.clone(), - }, - } -} - -fn apply_note_gradient_shadow( - position: &mut KeyPosition, - glow: bool, - shadow: NoteGradientShadow, -) -> bool { - if glow { - let changed = position.note_glow_color.as_ref() != Some(&shadow.color) - || position.note_glow_opacity_top != Some(shadow.opacity_top) - || position.note_glow_opacity_bottom != Some(shadow.opacity_bottom); - position.note_glow_color = Some(shadow.color); - position.note_glow_opacity_top = Some(shadow.opacity_top); - position.note_glow_opacity_bottom = Some(shadow.opacity_bottom); - changed - } else { - let changed = position.note_color != shadow.color - || position.note_opacity_top != Some(shadow.opacity_top) - || position.note_opacity_bottom != Some(shadow.opacity_bottom); - position.note_color = shadow.color; - position.note_opacity_top = Some(shadow.opacity_top); - position.note_opacity_bottom = Some(shadow.opacity_bottom); - changed - } -} - -fn patch_note_paint( - position: &mut KeyPosition, - glow: bool, - patch: &EditorNotePaintIntentV1, -) -> bool { - match patch { - EditorNotePaintIntentV1::Descriptor(patch) => { - let color = editor_note_color(&patch.color); - let gradient = patch - .gradient - .as_ref() - .map(EditorPaintGradientV1::to_gradient_spec); - if let Some(gradient) = gradient { - let shadow = note_gradient_shadow(&gradient, patch.opacity) - .expect("a validated note gradient has supported stop colors"); - let changed = if glow { - position.note_glow_gradient.as_ref() != Some(&gradient) - || position.note_glow_opacity != patch.opacity - } else { - position.note_gradient.as_ref() != Some(&gradient) - || position.note_opacity != patch.opacity - }; - if glow { - position.note_glow_gradient = Some(gradient); - position.note_glow_opacity = patch.opacity; - } else { - position.note_gradient = Some(gradient); - position.note_opacity = patch.opacity; - } - changed | apply_note_gradient_shadow(position, glow, shadow) - } else if glow { - let changed = position.note_glow_gradient.is_some() - || position.note_glow_color.as_ref() != Some(&color) - || position.note_glow_opacity != patch.opacity - || position.note_glow_opacity_top != Some(patch.opacity) - || position.note_glow_opacity_bottom != Some(patch.opacity); - position.note_glow_gradient = None; - position.note_glow_color = Some(color); - position.note_glow_opacity = patch.opacity; - position.note_glow_opacity_top = Some(patch.opacity); - position.note_glow_opacity_bottom = Some(patch.opacity); - changed - } else { - let changed = position.note_gradient.is_some() - || position.note_color != color - || position.note_opacity != patch.opacity - || position.note_opacity_top != Some(patch.opacity) - || position.note_opacity_bottom != Some(patch.opacity); - position.note_gradient = None; - position.note_color = color; - position.note_opacity = patch.opacity; - position.note_opacity_top = Some(patch.opacity); - position.note_opacity_bottom = Some(patch.opacity); - changed - } - } - EditorNotePaintIntentV1::Color(patch) => { - let color = editor_note_color(&patch.color); - if glow { - let changed = position.note_glow_gradient.is_some() - || position.note_glow_color.as_ref() != Some(&color); - position.note_glow_gradient = None; - position.note_glow_color = Some(color); - changed - } else { - let changed = position.note_gradient.is_some() || position.note_color != color; - position.note_gradient = None; - position.note_color = color; - changed - } - } - EditorNotePaintIntentV1::Opacity(patch) => { - let gradient = if glow { - position.note_glow_gradient.as_ref() - } else { - position.note_gradient.as_ref() - }; - // store canonicalize가 §2A를 보장해 실패는 도달 불가 - 발동해도 - // 사용자 gradient를 지우지 않고 shadow 갱신만 건너뛴다 (비파괴) - let shadow = - gradient.and_then(|gradient| note_gradient_shadow(gradient, patch.opacity)); - let mut changed = if glow { - let changed = position.note_glow_opacity != patch.opacity; - position.note_glow_opacity = patch.opacity; - changed - } else { - let changed = position.note_opacity != patch.opacity; - position.note_opacity = patch.opacity; - changed - }; - if let Some(shadow) = shadow { - changed |= apply_note_gradient_shadow(position, glow, shadow); - } - changed - } - EditorNotePaintIntentV1::GradientOpacity(patch) => { - let (gradient, opacity, opacity_top, opacity_bottom) = if glow { - ( - &mut position.note_glow_gradient, - &mut position.note_glow_opacity, - &mut position.note_glow_opacity_top, - &mut position.note_glow_opacity_bottom, - ) - } else { - ( - &mut position.note_gradient, - &mut position.note_opacity, - &mut position.note_opacity_top, - &mut position.note_opacity_bottom, - ) - }; - let changed = gradient.is_some() - || *opacity != patch.opacity - || *opacity_top != Some(patch.opacity_top) - || *opacity_bottom != Some(patch.opacity_bottom); - *gradient = None; - *opacity = patch.opacity; - *opacity_top = Some(patch.opacity_top); - *opacity_bottom = Some(patch.opacity_bottom); - changed - } - } -} - -fn validate_note_border_paint(patch: &EditorNoteBorderPaintV1) -> Result<(), EditorCommitError> { - let valid_color = patch.color.len() == 7 - && patch.color.starts_with('#') - && patch.color.as_bytes()[1..] - .iter() - .all(u8::is_ascii_hexdigit); - if !valid_color { - return Err(EditorCommitError::validation( - "INVALID_NOTE_BORDER_COLOR", - "note border color must use #RRGGBB format", - )); - } - if patch.opacity > 100 { - return Err(EditorCommitError::validation( - "NOTE_OPACITY_OUT_OF_RANGE", - "note border opacity must be an integer between 0 and 100", - )); - } - - let Some(gradient) = patch.gradient.as_ref() else { - return Ok(()); - }; - validate_paint_gradient(gradient)?; - for (index, stop) in gradient.stops.iter().enumerate() { - if note_border_representative_hex(&stop.color).is_none() { - return Err(EditorCommitError::validation( - "INVALID_PAINT_GRADIENT", - format!("note border gradient stop {index} has an unsupported color"), - )); - } - } - let representative = note_border_representative_hex( - &gradient - .stops - .first() - .expect("a validated note border gradient has at least two stops") - .color, - ) - .expect("all note border gradient stop colors were validated"); - if patch.color != representative { - return Err(EditorCommitError::validation( - "PAINT_COLOR_GRADIENT_MISMATCH", - "note border color must equal the uppercase first gradient stop color", - )); - } - Ok(()) -} - fn insert_location( locations: &mut HashMap, element_type: EditorElementTypeV1, diff --git a/src-tauri/src/state/editor_ops/property_patch.rs b/src-tauri/src/state/editor_ops/property_patch.rs new file mode 100644 index 000000000..258f7120d --- /dev/null +++ b/src-tauri/src/state/editor_ops/property_patch.rs @@ -0,0 +1,757 @@ +use super::*; + +pub(super) fn current_counter_animation_preset( + store: &AppStoreData, + preset_id: &str, +) -> Option { + let mut preset = store + .counter_animation_presets + .iter() + .find(|preset| preset.id == preset_id) + .cloned() + .or_else(|| { + default_counter_animation_builtin_presets() + .into_iter() + .find(|preset| preset.id == preset_id) + })?; + preset.normalize(); + Some(preset) +} + +pub(super) fn validate_counter_animation_preset_patch( + store: &AppStoreData, + patch: &EditorCounterAnimationPresetIntentV1, +) -> Result<(), EditorCommitError> { + if let Some(bezier) = patch.bezier { + let valid = bezier.iter().enumerate().all(|(index, value)| { + value.is_finite() + && if matches!(index, 0 | 2) { + (0.0..=1.0).contains(value) + } else { + (-2.0..=2.0).contains(value) + } + }); + if !valid { + return Err(EditorCommitError::validation( + "INVALID_COUNTER_ANIMATION_PRESET_VALUE", + "counter animation bezier values are out of range", + )); + } + } + if patch.scale.is_some_and(|value| !value.is_finite()) { + return Err(EditorCommitError::validation( + "INVALID_COUNTER_ANIMATION_PRESET_VALUE", + "counter animation scale must be finite", + )); + } + if patch + .duration_ms + .is_some_and(|value| !(1..=5000).contains(&value)) + { + return Err(EditorCommitError::validation( + "INVALID_COUNTER_ANIMATION_PRESET_VALUE", + "counter animation duration must be between 1 and 5000 milliseconds", + )); + } + let preset = current_counter_animation_preset(store, &patch.preset_id).ok_or_else(|| { + EditorCommitError::validation( + "COUNTER_ANIMATION_PRESET_NOT_FOUND", + format!("counter animation preset not found: {}", patch.preset_id), + ) + })?; + if patch.bezier.is_some_and(|value| value != preset.bezier) + || patch.scale.is_some_and(|value| value != preset.scale) + || patch + .duration_ms + .is_some_and(|value| value != preset.duration_ms) + { + return Err(EditorCommitError::validation( + "COUNTER_ANIMATION_PRESET_MISMATCH", + format!( + "counter animation preset values are stale: {}", + patch.preset_id + ), + )); + } + Ok(()) +} + +pub(super) fn validate_paint_descriptor( + descriptor: &EditorPaintDescriptorV1, +) -> Result<(), EditorCommitError> { + let Some(gradient) = descriptor.gradient.as_ref() else { + return Ok(()); + }; + validate_paint_gradient(gradient)?; + if gradient + .stops + .first() + .is_some_and(|stop| stop.color != descriptor.color) + { + return Err(EditorCommitError::validation( + "PAINT_COLOR_GRADIENT_MISMATCH", + "paint color must equal the first gradient stop color", + )); + } + Ok(()) +} + +pub(super) fn validate_paint_gradient( + gradient: &EditorPaintGradientV1, +) -> Result<(), EditorCommitError> { + if !gradient.angle.is_finite() + || !(0.0..360.0).contains(&gradient.angle) + || (gradient.angle == 0.0 && gradient.angle.is_sign_negative()) + { + return Err(EditorCommitError::validation( + "INVALID_PAINT_GRADIENT", + "paint gradient angle must be finite and canonical between 0 and 360", + )); + } + if !(2..=8).contains(&gradient.stops.len()) { + return Err(EditorCommitError::validation( + "INVALID_PAINT_GRADIENT", + "paint gradient must contain between 2 and 8 stops", + )); + } + let mut previous_pos = None; + for stop in &gradient.stops { + // 빈 stop 색은 로드 canonicalize가 빈 대표색을 만들어 blank 정규화와 + // 매 시작 진동한다 - 커밋에서 차단 + if stop.color.trim().is_empty() { + return Err(EditorCommitError::validation( + "INVALID_PAINT_GRADIENT", + "paint gradient stop colors must not be blank", + )); + } + if !stop.pos.is_finite() + || !(0.0..=1.0).contains(&stop.pos) + || (stop.pos == 0.0 && stop.pos.is_sign_negative()) + { + return Err(EditorCommitError::validation( + "INVALID_PAINT_GRADIENT", + "paint gradient stop positions must be finite and between 0 and 1", + )); + } + if previous_pos.is_some_and(|previous| stop.pos < previous) { + return Err(EditorCommitError::validation( + "INVALID_PAINT_GRADIENT", + "paint gradient stops must be ordered by position", + )); + } + previous_pos = Some(stop.pos); + } + Ok(()) +} + +pub(super) fn validate_counter_fill_intent( + intent: &EditorCounterFillIntentV1, +) -> Result<(), EditorCommitError> { + let EditorCounterFillIntentV1::Gradient(intent) = intent else { + return Ok(()); + }; + validate_paint_gradient(&intent.gradient)?; + let representative = compact_canonical_rgba( + &intent + .gradient + .stops + .first() + .expect("a validated counter fill gradient has at least two stops") + .color, + ); + if intent.color != representative { + return Err(EditorCommitError::validation( + "COUNTER_FILL_COLOR_GRADIENT_MISMATCH", + "counter fill color must equal the compact first gradient stop color", + )); + } + Ok(()) +} + +pub(super) fn has_stored_paint_value( + color: &Option, + gradient: &Option, +) -> bool { + color + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + || gradient.is_some() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum PaintSurface { + Background, + Border, + Font, +} + +pub(super) fn preserve_active_paint_fallback( + position: &mut KeyPosition, + surface: PaintSurface, +) -> bool { + let (idle_color, idle_gradient, active_color, active_gradient) = match surface { + PaintSurface::Background => ( + position.background_color.clone(), + position.background_gradient.clone(), + &mut position.active_background_color, + &mut position.active_background_gradient, + ), + PaintSurface::Border => ( + position.border_color.clone(), + position.border_gradient.clone(), + &mut position.active_border_color, + &mut position.active_border_gradient, + ), + PaintSurface::Font => ( + position.font_color.clone(), + position.font_gradient.clone(), + &mut position.active_font_color, + &mut position.active_font_gradient, + ), + }; + if has_stored_paint_value(active_color, active_gradient) { + return false; + } + + let changed = *active_color != idle_color || *active_gradient != idle_gradient; + *active_color = idle_color; + *active_gradient = idle_gradient; + changed +} + +pub(super) fn apply_paint_descriptor( + color: &mut Option, + gradient: &mut Option, + descriptor: &EditorPaintDescriptorV1, +) -> bool { + let next_color = Some(descriptor.color.clone()); + let next_gradient = descriptor + .gradient + .as_ref() + .map(|gradient| gradient.to_gradient_spec()); + let changed = *color != next_color || *gradient != next_gradient; + *color = next_color; + *gradient = next_gradient; + changed +} + +// font 전용: 빈 solid 색은 None으로 저장 - 로드 정규화(normalize_blank_font_colors)와 +// 같은 의미라 커밋 직후 문서와 재로드 문서가 갈리지 않는다 +pub(super) fn apply_font_paint_descriptor( + color: &mut Option, + gradient: &mut Option, + descriptor: &EditorPaintDescriptorV1, +) -> bool { + let next_color = Some(descriptor.color.clone()).filter(|value| !value.trim().is_empty()); + let next_gradient = descriptor + .gradient + .as_ref() + .map(|gradient| gradient.to_gradient_spec()); + let changed = *color != next_color || *gradient != next_gradient; + *color = next_color; + *gradient = next_gradient; + changed +} + +pub(super) fn patch_counter_fill( + position: &mut KeyPosition, + active: bool, + intent: &EditorCounterFillIntentV1, +) -> bool { + let (next_color, next_gradient) = match intent { + EditorCounterFillIntentV1::Solid(intent) => (intent.color.clone(), None), + EditorCounterFillIntentV1::Gradient(intent) => ( + intent.color.clone(), + Some(intent.gradient.to_gradient_spec()), + ), + }; + let (color, gradient) = if active { + ( + &mut position.counter.fill.active, + &mut position.counter.fill_active_gradient, + ) + } else { + ( + &mut position.counter.fill.idle, + &mut position.counter.fill_idle_gradient, + ) + }; + let changed = *color != next_color || *gradient != next_gradient; + *color = next_color; + *gradient = next_gradient; + changed +} + +pub(super) fn validate_shadow_leaf( + patch: &EditorShadowLeafPatchV1, +) -> Result<(), EditorCommitError> { + let valid_offset = + |value: f64| value.is_finite() && (SHADOW_OFFSET_MIN..=SHADOW_OFFSET_MAX).contains(&value); + // 닫힌 enum 전수 나열 - 신규 leaf가 검증 없이 통과하면 컴파일 에러로 드러나게 + let violation = match patch { + EditorShadowLeafPatchV1::Color(color) => color + .is_empty() + .then_some("shadow color must be a non-empty string"), + EditorShadowLeafPatchV1::OffsetX(offset_x) => (!valid_offset(*offset_x)) + .then_some("shadow offset X must be finite and between -100 and 100"), + EditorShadowLeafPatchV1::OffsetY(offset_y) => (!valid_offset(*offset_y)) + .then_some("shadow offset Y must be finite and between -100 and 100"), + EditorShadowLeafPatchV1::Blur(blur) => (!blur.is_finite() + || !(SHADOW_BLUR_MIN..=SHADOW_BLUR_MAX).contains(blur)) + .then_some("shadow blur must be finite and between 0 and 100"), + }; + violation.map_or(Ok(()), |message| { + Err(EditorCommitError::validation( + "INVALID_ELEMENT_SHADOW", + message, + )) + }) +} + +pub(super) fn validate_image_transform_leaf( + patch: &ImageTransformLeafPatchV1, +) -> Result<(), EditorCommitError> { + let violation = match patch { + ImageTransformLeafPatchV1::OffsetX(value) => (!value.is_finite() + || !(IMAGE_TRANSFORM_OFFSET_MIN..=IMAGE_TRANSFORM_OFFSET_MAX).contains(value)) + .then_some("image offset X must be finite and between -500 and 500"), + ImageTransformLeafPatchV1::OffsetY(value) => (!value.is_finite() + || !(IMAGE_TRANSFORM_OFFSET_MIN..=IMAGE_TRANSFORM_OFFSET_MAX).contains(value)) + .then_some("image offset Y must be finite and between -500 and 500"), + ImageTransformLeafPatchV1::Rotation(value) => (!value.is_finite() + || !(IMAGE_TRANSFORM_ROTATION_MIN..=IMAGE_TRANSFORM_ROTATION_MAX).contains(value)) + .then_some("image rotation must be finite and between -180 and 180"), + ImageTransformLeafPatchV1::Scale(value) => (!value.is_finite() + || !(IMAGE_TRANSFORM_SCALE_MIN..=IMAGE_TRANSFORM_SCALE_MAX).contains(value)) + .then_some("image scale must be finite and between 0.1 and 10"), + }; + violation.map_or(Ok(()), |message| { + Err(EditorCommitError::validation( + "INVALID_IMAGE_TRANSFORM", + message, + )) + }) +} + +pub(super) fn default_shadow_spec( + position: &KeyPosition, + element_type: EditorElementTypeV1, + active: bool, +) -> ElementShadowSpec { + let has_image = if active { + position + .active_image + .as_deref() + .is_some_and(|path| !path.trim().is_empty()) + || position + .inactive_image + .as_deref() + .is_some_and(|path| !path.trim().is_empty()) + } else { + position + .inactive_image + .as_deref() + .is_some_and(|path| !path.trim().is_empty()) + }; + let knob_suppressed = element_type == EditorElementTypeV1::Knob + && (if active { + position.active_transparent + } else { + position.idle_transparent + } || position.border_width.unwrap_or(0.0) > 0.0); + ElementShadowSpec { + enabled: !(has_image || knob_suppressed), + color: if active { + "rgba(0, 0, 0, 0.32)".to_string() + } else { + "rgba(0, 0, 0, 0.28)".to_string() + }, + offset_x: 0.0, + offset_y: if active { 3.0 } else { 4.0 }, + blur: if active { 8.0 } else { 10.0 }, + } +} + +pub(super) fn apply_shadow_leaf( + shadow: &mut ElementShadowSpec, + patch: &EditorShadowLeafPatchV1, +) -> bool { + match patch { + EditorShadowLeafPatchV1::Color(color) => { + if shadow.color == *color { + false + } else { + shadow.color.clone_from(color); + true + } + } + EditorShadowLeafPatchV1::OffsetX(offset_x) => { + if shadow.offset_x == *offset_x { + false + } else { + shadow.offset_x = *offset_x; + true + } + } + EditorShadowLeafPatchV1::OffsetY(offset_y) => { + if shadow.offset_y == *offset_y { + false + } else { + shadow.offset_y = *offset_y; + true + } + } + EditorShadowLeafPatchV1::Blur(blur) => { + if shadow.blur == *blur { + false + } else { + shadow.blur = *blur; + true + } + } + } +} + +pub(super) fn apply_image_transform_leaf( + transform: &mut ImageTransform, + patch: &ImageTransformLeafPatchV1, +) -> bool { + let target = match patch { + ImageTransformLeafPatchV1::OffsetX(_) => &mut transform.offset_x, + ImageTransformLeafPatchV1::OffsetY(_) => &mut transform.offset_y, + ImageTransformLeafPatchV1::Rotation(_) => &mut transform.rotation, + ImageTransformLeafPatchV1::Scale(_) => &mut transform.scale, + }; + let value = match patch { + ImageTransformLeafPatchV1::OffsetX(value) + | ImageTransformLeafPatchV1::OffsetY(value) + | ImageTransformLeafPatchV1::Rotation(value) + | ImageTransformLeafPatchV1::Scale(value) => *value, + }; + if *target == value { + false + } else { + *target = value; + true + } +} + +pub(super) fn patch_image_transform( + transform: &mut Option, + patch: &Option, +) -> bool { + let Some(patch) = patch else { + return transform.take().is_some(); + }; + let seeded = transform.is_none(); + let transform = transform.get_or_insert_default(); + apply_image_transform_leaf(transform, patch) || seeded +} + +pub(super) fn patch_shadow( + position: &mut KeyPosition, + element_type: EditorElementTypeV1, + active: bool, + patch: &EditorShadowLeafPatchV1, +) -> bool { + let seeded = if active { + position.active_shadow.is_none() + } else { + position.shadow.is_none() + }; + let default = seeded.then(|| default_shadow_spec(position, element_type, active)); + let shadow = if active { + position + .active_shadow + .get_or_insert_with(|| default.unwrap()) + } else { + position.shadow.get_or_insert_with(|| default.unwrap()) + }; + apply_shadow_leaf(shadow, patch) || seeded +} + +pub(super) fn patch_shadow_enabled( + position: &mut KeyPosition, + element_type: EditorElementTypeV1, + enabled: bool, +) -> bool { + let idle_seeded = position.shadow.is_none(); + let idle_default = idle_seeded.then(|| default_shadow_spec(position, element_type, false)); + let idle = position.shadow.get_or_insert_with(|| idle_default.unwrap()); + let mut changed = idle_seeded || idle.enabled != enabled; + idle.enabled = enabled; + + if matches!( + element_type, + EditorElementTypeV1::Key | EditorElementTypeV1::Knob + ) { + let active_seeded = position.active_shadow.is_none(); + let active_default = + active_seeded.then(|| default_shadow_spec(position, element_type, true)); + let active = position + .active_shadow + .get_or_insert_with(|| active_default.unwrap()); + changed |= active_seeded || active.enabled != enabled; + active.enabled = enabled; + } + changed +} + +pub(super) fn validate_note_paint_intent( + patch: &EditorNotePaintIntentV1, +) -> Result<(), EditorCommitError> { + let valid_opacity = |value: u32| value <= 100; + let valid = match patch { + EditorNotePaintIntentV1::Descriptor(patch) => valid_opacity(patch.opacity), + EditorNotePaintIntentV1::Color(_) => true, + EditorNotePaintIntentV1::Opacity(patch) => valid_opacity(patch.opacity), + EditorNotePaintIntentV1::GradientOpacity(patch) => { + valid_opacity(patch.opacity) + && valid_opacity(patch.opacity_top) + && valid_opacity(patch.opacity_bottom) + } + }; + if !valid { + return Err(EditorCommitError::validation( + "NOTE_OPACITY_OUT_OF_RANGE", + "note opacity values must be integers between 0 and 100", + )); + } + + let EditorNotePaintIntentV1::Descriptor(patch) = patch else { + return Ok(()); + }; + let Some(gradient) = patch.gradient.as_ref() else { + if matches!(patch.color, EditorNoteColorV1::Solid(_)) { + return Ok(()); + } + return Err(EditorCommitError::validation( + "PAINT_COLOR_GRADIENT_MISMATCH", + "note paint without a gradient must use a solid color", + )); + }; + + validate_paint_gradient(gradient)?; + let gradient = gradient.to_gradient_spec(); + let Some(shadow) = note_gradient_shadow(&gradient, patch.opacity) else { + return Err(EditorCommitError::validation( + "INVALID_PAINT_GRADIENT", + "note gradient contains an unsupported stop color", + )); + }; + if editor_note_color(&patch.color) != shadow.color { + return Err(EditorCommitError::validation( + "PAINT_COLOR_GRADIENT_MISMATCH", + "note paint color must equal the derived gradient shadow color", + )); + } + Ok(()) +} + +pub(super) fn editor_note_color(color: &EditorNoteColorV1) -> NoteColor { + match color { + EditorNoteColorV1::Solid(color) => NoteColor::Solid(color.clone()), + EditorNoteColorV1::Gradient(gradient) => NoteColor::Gradient { + top: gradient.top.clone(), + bottom: gradient.bottom.clone(), + }, + } +} + +pub(super) fn apply_note_gradient_shadow( + position: &mut KeyPosition, + glow: bool, + shadow: NoteGradientShadow, +) -> bool { + if glow { + let changed = position.note_glow_color.as_ref() != Some(&shadow.color) + || position.note_glow_opacity_top != Some(shadow.opacity_top) + || position.note_glow_opacity_bottom != Some(shadow.opacity_bottom); + position.note_glow_color = Some(shadow.color); + position.note_glow_opacity_top = Some(shadow.opacity_top); + position.note_glow_opacity_bottom = Some(shadow.opacity_bottom); + changed + } else { + let changed = position.note_color != shadow.color + || position.note_opacity_top != Some(shadow.opacity_top) + || position.note_opacity_bottom != Some(shadow.opacity_bottom); + position.note_color = shadow.color; + position.note_opacity_top = Some(shadow.opacity_top); + position.note_opacity_bottom = Some(shadow.opacity_bottom); + changed + } +} + +pub(super) fn patch_note_paint( + position: &mut KeyPosition, + glow: bool, + patch: &EditorNotePaintIntentV1, +) -> bool { + match patch { + EditorNotePaintIntentV1::Descriptor(patch) => { + let color = editor_note_color(&patch.color); + let gradient = patch + .gradient + .as_ref() + .map(EditorPaintGradientV1::to_gradient_spec); + if let Some(gradient) = gradient { + let shadow = note_gradient_shadow(&gradient, patch.opacity) + .expect("a validated note gradient has supported stop colors"); + let changed = if glow { + position.note_glow_gradient.as_ref() != Some(&gradient) + || position.note_glow_opacity != patch.opacity + } else { + position.note_gradient.as_ref() != Some(&gradient) + || position.note_opacity != patch.opacity + }; + if glow { + position.note_glow_gradient = Some(gradient); + position.note_glow_opacity = patch.opacity; + } else { + position.note_gradient = Some(gradient); + position.note_opacity = patch.opacity; + } + changed | apply_note_gradient_shadow(position, glow, shadow) + } else if glow { + let changed = position.note_glow_gradient.is_some() + || position.note_glow_color.as_ref() != Some(&color) + || position.note_glow_opacity != patch.opacity + || position.note_glow_opacity_top != Some(patch.opacity) + || position.note_glow_opacity_bottom != Some(patch.opacity); + position.note_glow_gradient = None; + position.note_glow_color = Some(color); + position.note_glow_opacity = patch.opacity; + position.note_glow_opacity_top = Some(patch.opacity); + position.note_glow_opacity_bottom = Some(patch.opacity); + changed + } else { + let changed = position.note_gradient.is_some() + || position.note_color != color + || position.note_opacity != patch.opacity + || position.note_opacity_top != Some(patch.opacity) + || position.note_opacity_bottom != Some(patch.opacity); + position.note_gradient = None; + position.note_color = color; + position.note_opacity = patch.opacity; + position.note_opacity_top = Some(patch.opacity); + position.note_opacity_bottom = Some(patch.opacity); + changed + } + } + EditorNotePaintIntentV1::Color(patch) => { + let color = editor_note_color(&patch.color); + if glow { + let changed = position.note_glow_gradient.is_some() + || position.note_glow_color.as_ref() != Some(&color); + position.note_glow_gradient = None; + position.note_glow_color = Some(color); + changed + } else { + let changed = position.note_gradient.is_some() || position.note_color != color; + position.note_gradient = None; + position.note_color = color; + changed + } + } + EditorNotePaintIntentV1::Opacity(patch) => { + let gradient = if glow { + position.note_glow_gradient.as_ref() + } else { + position.note_gradient.as_ref() + }; + // store canonicalize가 §2A를 보장해 실패는 도달 불가 - 발동해도 + // 사용자 gradient를 지우지 않고 shadow 갱신만 건너뛴다 (비파괴) + let shadow = + gradient.and_then(|gradient| note_gradient_shadow(gradient, patch.opacity)); + let mut changed = if glow { + let changed = position.note_glow_opacity != patch.opacity; + position.note_glow_opacity = patch.opacity; + changed + } else { + let changed = position.note_opacity != patch.opacity; + position.note_opacity = patch.opacity; + changed + }; + if let Some(shadow) = shadow { + changed |= apply_note_gradient_shadow(position, glow, shadow); + } + changed + } + EditorNotePaintIntentV1::GradientOpacity(patch) => { + let (gradient, opacity, opacity_top, opacity_bottom) = if glow { + ( + &mut position.note_glow_gradient, + &mut position.note_glow_opacity, + &mut position.note_glow_opacity_top, + &mut position.note_glow_opacity_bottom, + ) + } else { + ( + &mut position.note_gradient, + &mut position.note_opacity, + &mut position.note_opacity_top, + &mut position.note_opacity_bottom, + ) + }; + let changed = gradient.is_some() + || *opacity != patch.opacity + || *opacity_top != Some(patch.opacity_top) + || *opacity_bottom != Some(patch.opacity_bottom); + *gradient = None; + *opacity = patch.opacity; + *opacity_top = Some(patch.opacity_top); + *opacity_bottom = Some(patch.opacity_bottom); + changed + } + } +} + +pub(super) fn validate_note_border_paint( + patch: &EditorNoteBorderPaintV1, +) -> Result<(), EditorCommitError> { + let valid_color = patch.color.len() == 7 + && patch.color.starts_with('#') + && patch.color.as_bytes()[1..] + .iter() + .all(u8::is_ascii_hexdigit); + if !valid_color { + return Err(EditorCommitError::validation( + "INVALID_NOTE_BORDER_COLOR", + "note border color must use #RRGGBB format", + )); + } + if patch.opacity > 100 { + return Err(EditorCommitError::validation( + "NOTE_OPACITY_OUT_OF_RANGE", + "note border opacity must be an integer between 0 and 100", + )); + } + + let Some(gradient) = patch.gradient.as_ref() else { + return Ok(()); + }; + validate_paint_gradient(gradient)?; + for (index, stop) in gradient.stops.iter().enumerate() { + if note_border_representative_hex(&stop.color).is_none() { + return Err(EditorCommitError::validation( + "INVALID_PAINT_GRADIENT", + format!("note border gradient stop {index} has an unsupported color"), + )); + } + } + let representative = note_border_representative_hex( + &gradient + .stops + .first() + .expect("a validated note border gradient has at least two stops") + .color, + ) + .expect("all note border gradient stop colors were validated"); + if patch.color != representative { + return Err(EditorCommitError::validation( + "PAINT_COLOR_GRADIENT_MISMATCH", + "note border color must equal the uppercase first gradient stop color", + )); + } + Ok(()) +} From e178a1b8b5d9e8776a5622bf38beaf3f6262176d Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 18:45:20 +0900 Subject: [PATCH 194/245] =?UTF-8?q?test:=20=EC=97=90=EB=94=94=ED=84=B0=20?= =?UTF-8?q?=EC=A0=9C=ED=95=9C=20=EC=98=A4=EB=A5=98=20=EC=9A=B0=EC=84=A0?= =?UTF-8?q?=EC=88=9C=EC=9C=84=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/state/editor/tests.rs | 37 +++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src-tauri/src/state/editor/tests.rs b/src-tauri/src/state/editor/tests.rs index 6763795dd..1cfa6a6ba 100644 --- a/src-tauri/src/state/editor/tests.rs +++ b/src-tauri/src/state/editor/tests.rs @@ -1831,6 +1831,43 @@ fn editor_op_bounds_reuse_position_numeric_limits() { assert!(!mismatch.retryable); } +#[test] +fn editor_op_bounds_report_coordinate_nan_before_dimension_errors() { + let error = validate_editor_op_bounds( + 7, + None, + EditorBoundsV1 { + dx: f64::NAN, + dy: MAX_ABS_COORDINATE + 1.0, + width: 0.0, + height: f64::NAN, + }, + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("COORDINATE_OUT_OF_RANGE")); + assert_eq!( + error.message, + format!("editor op 7.bounds.dx exceeds ±{MAX_ABS_COORDINATE}") + ); + + let error = validate_editor_op_bounds( + 8, + None, + EditorBoundsV1 { + dx: 0.0, + dy: 0.0, + width: f64::NAN, + height: 0.0, + }, + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("DIMENSION_OUT_OF_RANGE")); + assert_eq!( + error.message, + format!("editor op 8.bounds.width must satisfy 0 < value <= {MAX_DIMENSION}") + ); +} + #[test] fn canonical_fingerprint_includes_editor_op_payload() { let id = Uuid::new_v4().to_string(); From 00a4213b1c2b4b54feed4363d61ba4347b761877 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 18:45:20 +0900 Subject: [PATCH 195/245] =?UTF-8?q?refactor:=20=EC=97=90=EB=94=94=ED=84=B0?= =?UTF-8?q?=20=EC=A0=9C=ED=95=9C=20=EA=B2=80=EC=A6=9D=20=EA=B2=BD=EA=B3=84?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/errors.rs | 2 + src-tauri/src/state/editor.rs | 559 +-------------------------- src-tauri/src/state/editor/limits.rs | 554 ++++++++++++++++++++++++++ 3 files changed, 562 insertions(+), 553 deletions(-) create mode 100644 src-tauri/src/state/editor/limits.rs diff --git a/src-tauri/src/errors.rs b/src-tauri/src/errors.rs index 4a12e1818..8c44a887b 100644 --- a/src-tauri/src/errors.rs +++ b/src-tauri/src/errors.rs @@ -241,6 +241,7 @@ mod tests { // 한도 헬퍼를 쓰는 소스. 손목록끼리만 대조하면 백엔드에 새 한도 코드가 // 생겨도 전부 green이라, 실제 호출부를 스캔해 결합한다 const EDITOR_SOURCE: &str = include_str!("state/editor.rs"); + const EDITOR_LIMITS_SOURCE: &str = include_str!("state/editor/limits.rs"); const PLUGIN_SOURCE: &str = include_str!("state/plugin.rs"); /// 용량 목록에 넣지 않는 한도 코드와 사유. 새 한도 코드는 목록에 넣거나 @@ -298,6 +299,7 @@ mod tests { #[test] fn every_limit_helper_code_is_classified() { let mut found = limit_codes_in(EDITOR_SOURCE); + found.extend(limit_codes_in(EDITOR_LIMITS_SOURCE)); found.extend(limit_codes_in(PLUGIN_SOURCE)); found.sort(); found.dedup(); diff --git a/src-tauri/src/state/editor.rs b/src-tauri/src/state/editor.rs index 89d198079..7f05588f3 100644 --- a/src-tauri/src/state/editor.rs +++ b/src-tauri/src/state/editor.rs @@ -15,8 +15,14 @@ use crate::{ #[cfg(test)] use crate::models::EDITOR_OPS_VERSION; +mod limits; mod request; +#[cfg(test)] +use limits::position_bounds; +use limits::validate_metric_limits; +pub(crate) use limits::{validate_editor_op_bounds, validate_editor_op_target_type}; + pub(crate) use request::{ canonical_request_fingerprint, decode_editor_commit_request, decode_exact_frozen_insert, next_revision, request_fingerprint, request_payload_size, validate_history_restore_metadata, @@ -831,559 +837,6 @@ fn collect_collection_violations( } } -fn validate_metric_limits( - current: &EditorDocumentV1, - candidate: &EditorDocumentV1, - keying: GrandfatherKeying, -) -> Result<(), EditorCommitError> { - validate_aggregate_metric_limits(current, candidate)?; - validate_mode_metric_limits(current, candidate)?; - validate_per_owner_metric_limits(current, candidate, keying) -} - -fn validate_aggregate_metric_limits( - current: &EditorDocumentV1, - candidate: &EditorDocumentV1, -) -> Result<(), EditorCommitError> { - validate_count_limit( - "TOO_MANY_MODES", - "editor mode count", - editor_modes(current).len(), - editor_modes(candidate).len(), - MAX_MODES, - )?; - - validate_collection_limits("keys", ¤t.keys, &candidate.keys)?; - validate_key_member_fanout(¤t.keys, &candidate.keys)?; - validate_collection_limits( - "keyPositions", - ¤t.key_positions, - &candidate.key_positions, - )?; - validate_collection_limits( - "statPositions", - ¤t.stat_positions, - &candidate.stat_positions, - )?; - validate_collection_limits( - "graphPositions", - ¤t.graph_positions, - &candidate.graph_positions, - )?; - validate_collection_limits( - "knobPositions", - ¤t.knob_positions, - &candidate.knob_positions, - )?; - validate_collection_limits( - "layerGroups", - ¤t.layer_groups, - &candidate.layer_groups, - )?; - - validate_count_limit( - "TOO_MANY_RENDER_ITEMS", - "render item count", - render_item_count(current), - render_item_count(candidate), - MAX_RENDER_ITEMS, - )?; - validate_count_limit( - "TOO_MANY_LAYER_GROUPS", - "layer group count", - current.layer_groups.values().map(Vec::len).sum(), - candidate.layer_groups.values().map(Vec::len).sum(), - MAX_LAYER_GROUPS, - )?; - - Ok(()) -} - -fn validate_mode_metric_limits( - current: &EditorDocumentV1, - candidate: &EditorDocumentV1, -) -> Result<(), EditorCommitError> { - let current_modes = editor_modes(current); - for mode in editor_modes(candidate) { - let current_len = current_modes - .get(&mode) - .map_or(0, |current_mode| current_mode.len()); - validate_count_limit( - "MODE_ID_TOO_LONG", - &format!("mode id '{mode}' byte length"), - current_len, - mode.len(), - MAX_MODE_ID_BYTES, - )?; - } - - Ok(()) -} - -// keying에 따라 관용 상대를 찾는다. StableId는 안정 ID로, ModeIndex는 같은 -// 모드의 같은 자리로 짝짓는다 -fn grandfather_counterpart<'a, T>( - keying: GrandfatherKeying, - by_id: &HashMap<&str, &'a T>, - current_collection: &'a HashMap>, - mode: &str, - index: usize, - id: &str, - position_of: impl Fn(&'a T) -> &'a KeyPosition, -) -> Option<&'a KeyPosition> { - match keying { - GrandfatherKeying::StableId => by_id.get(id).map(|element| position_of(element)), - #[cfg(test)] - GrandfatherKeying::ModeIndex => current_collection - .get(mode) - .and_then(|elements| elements.get(index)) - .map(position_of), - GrandfatherKeying::LegacyPresetModeIndex => current_collection - .get(mode) - .and_then(|elements| elements.get(index)) - .map(position_of), - } -} - -fn validate_per_owner_metric_limits( - current: &EditorDocumentV1, - candidate: &EditorDocumentV1, - keying: GrandfatherKeying, -) -> Result<(), EditorCommitError> { - let mut current_key_slots = HashMap::new(); - for (mode, positions) in ¤t.key_positions { - let Some(slots) = current.keys.get(mode) else { - continue; - }; - for (position, slot) in positions.iter().zip(slots) { - current_key_slots.insert(position.id.as_str(), slot); - } - } - - for (mode, keys) in &candidate.keys { - for (slot_index, slot) in keys.iter().enumerate() { - let current_slot = match keying { - GrandfatherKeying::StableId => candidate - .key_positions - .get(mode) - .and_then(|positions| positions.get(slot_index)) - .and_then(|position| current_key_slots.get(position.id.as_str())) - .copied(), - #[cfg(test)] - GrandfatherKeying::ModeIndex => current - .keys - .get(mode) - .and_then(|slots| slots.get(slot_index)), - GrandfatherKeying::LegacyPresetModeIndex => current - .keys - .get(mode) - .and_then(|slots| slots.get(slot_index)), - }; - validate_key_slot_label_limits(mode, slot_index, current_slot, slot)?; - } - } - - let current_groups = current - .layer_groups - .iter() - .flat_map(|(mode, groups)| { - groups - .iter() - .map(move |group| ((mode.as_str(), group.id.as_str()), group)) - }) - .collect::>(); - for (mode, groups) in &candidate.layer_groups { - for (index, group) in groups.iter().enumerate() { - let current_group = current_groups - .get(&(mode.as_str(), group.id.as_str())) - .copied(); - validate_count_limit( - "GROUP_ID_TOO_LONG", - &format!("layer group id {mode}[{index}] byte length"), - current_group.map_or(0, |group| group.id.len()), - group.id.len(), - MAX_GROUP_ID_BYTES, - )?; - validate_count_limit( - "GROUP_NAME_TOO_LONG", - &format!("layer group name {mode}[{index}] byte length"), - current_group.map_or(0, |group| group.name.len()), - group.name.len(), - MAX_GROUP_NAME_BYTES, - )?; - } - } - - let current_key_positions = current - .key_positions - .values() - .flatten() - .map(|position| (position.id.as_str(), position)) - .collect::>(); - for (mode, positions) in &candidate.key_positions { - for (index, position) in positions.iter().enumerate() { - validate_position_metrics( - "keyPositions", - mode, - index, - grandfather_counterpart( - keying, - ¤t_key_positions, - ¤t.key_positions, - mode, - index, - &position.id, - |position| position, - ), - position, - keying == GrandfatherKeying::LegacyPresetModeIndex, - )?; - } - } - - let current_stat_positions = current - .stat_positions - .values() - .flatten() - .map(|position| (position.position.id.as_str(), position)) - .collect::>(); - for (mode, positions) in &candidate.stat_positions { - for (index, position) in positions.iter().enumerate() { - validate_position_metrics( - "statPositions", - mode, - index, - grandfather_counterpart( - keying, - ¤t_stat_positions, - ¤t.stat_positions, - mode, - index, - &position.position.id, - |element| &element.position, - ), - &position.position, - keying == GrandfatherKeying::LegacyPresetModeIndex, - )?; - } - } - - let current_graph_positions = current - .graph_positions - .values() - .flatten() - .map(|position| (position.position.id.as_str(), position)) - .collect::>(); - for (mode, positions) in &candidate.graph_positions { - for (index, position) in positions.iter().enumerate() { - validate_position_metrics( - "graphPositions", - mode, - index, - grandfather_counterpart( - keying, - ¤t_graph_positions, - ¤t.graph_positions, - mode, - index, - &position.position.id, - |element| &element.position, - ), - &position.position, - keying == GrandfatherKeying::LegacyPresetModeIndex, - )?; - } - } - - let current_knob_positions = current - .knob_positions - .values() - .flatten() - .map(|position| (position.position.id.as_str(), position)) - .collect::>(); - for (mode, positions) in &candidate.knob_positions { - for (index, position) in positions.iter().enumerate() { - validate_position_metrics( - "knobPositions", - mode, - index, - grandfather_counterpart( - keying, - ¤t_knob_positions, - ¤t.knob_positions, - mode, - index, - &position.position.id, - |element| &element.position, - ), - &position.position, - keying == GrandfatherKeying::LegacyPresetModeIndex, - )?; - } - } - - Ok(()) -} - -fn validate_key_slot_label_limits( - mode: &str, - slot_index: usize, - current: Option<&KeySlot>, - candidate: &KeySlot, -) -> Result<(), EditorCommitError> { - let mut grandfathered_lengths = current - .into_iter() - .flat_map(KeySlot::members) - .map(String::len) - .filter(|length| *length > MAX_KEY_LABEL_BYTES) - .collect::>(); - grandfathered_lengths.sort_unstable(); - - for (member_index, member) in candidate.members().enumerate() { - if member.len() <= MAX_KEY_LABEL_BYTES { - continue; - } - let Some(budget_index) = grandfathered_lengths - .iter() - .position(|length| member.len() <= *length) - else { - return Err(EditorCommitError::validation( - "KEY_LABEL_TOO_LONG", - format!( - "key label {mode}[{slot_index}].members[{member_index}] byte length exceeds {MAX_KEY_LABEL_BYTES} and has no matching stored allowance" - ), - )); - }; - grandfathered_lengths.remove(budget_index); - } - - Ok(()) -} - -fn editor_modes(document: &EditorDocumentV1) -> BTreeSet { - document - .keys - .keys() - .chain(document.key_positions.keys()) - .chain(document.stat_positions.keys()) - .chain(document.graph_positions.keys()) - .chain(document.knob_positions.keys()) - .chain(document.layer_groups.keys()) - .cloned() - .collect() -} - -fn render_item_count(document: &EditorDocumentV1) -> usize { - document.key_positions.values().map(Vec::len).sum::() - + document - .stat_positions - .values() - .map(Vec::len) - .sum::() - + document - .graph_positions - .values() - .map(Vec::len) - .sum::() - + document - .knob_positions - .values() - .map(Vec::len) - .sum::() -} - -fn validate_collection_limits( - field: &str, - current: &HashMap>, - candidate: &HashMap>, -) -> Result<(), EditorCommitError> { - for (mode, values) in candidate { - validate_count_limit( - "COLLECTION_TOO_LARGE", - &format!("{field} mode '{mode}' item count"), - current.get(mode).map_or(0, Vec::len), - values.len(), - MAX_ITEMS_PER_MODE, - )?; - } - Ok(()) -} - -fn validate_key_member_fanout( - current: &KeyMappings, - candidate: &KeyMappings, -) -> Result<(), EditorCommitError> { - for (mode, slots) in candidate { - let current_counts = current - .get(mode) - .map(|slots| member_slot_counts(slots)) - .unwrap_or_default(); - for (member, count) in member_slot_counts(slots) { - validate_count_limit( - "TOO_MANY_SLOTS_PER_MEMBER", - &format!("key member '{member}' slot count in mode '{mode}'"), - current_counts.get(&member).copied().unwrap_or_default(), - count, - MAX_SLOTS_PER_MEMBER, - )?; - } - } - Ok(()) -} - -fn member_slot_counts(slots: &[KeySlot]) -> HashMap { - let mut counts = HashMap::new(); - for member in slots - .iter() - .flat_map(KeySlot::members) - .filter(|member| !member.is_empty()) - { - *counts.entry(member.clone()).or_default() += 1; - } - counts -} - -fn validate_count_limit( - code: &'static str, - label: &str, - current: usize, - candidate: usize, - limit: usize, -) -> Result<(), EditorCommitError> { - if candidate <= limit || (current > limit && candidate <= current) { - return Ok(()); - } - Err(EditorCommitError::validation( - code, - format!("{label} exceeds {limit} and increases the stored metric"), - )) -} - -fn validate_position_metrics( - field: &str, - mode: &str, - index: usize, - current: Option<&KeyPosition>, - candidate: &KeyPosition, - allow_legacy_finite_bounds: bool, -) -> Result<(), EditorCommitError> { - validate_bounds_metrics( - &format!("{field} {mode}[{index}]"), - current.map(position_bounds), - position_bounds(candidate), - allow_legacy_finite_bounds, - ) -} - -fn position_bounds(position: &KeyPosition) -> EditorBoundsV1 { - EditorBoundsV1 { - dx: position.dx, - dy: position.dy, - width: position.width, - height: position.height, - } -} - -pub(crate) fn validate_editor_op_bounds( - op_index: usize, - current: Option<&KeyPosition>, - bounds: EditorBoundsV1, -) -> Result<(), EditorCommitError> { - validate_bounds_metrics( - &format!("editor op {op_index}.bounds"), - current.map(position_bounds), - bounds, - false, - ) -} - -pub(crate) fn validate_editor_op_target_type( - op_index: usize, - requested: EditorElementTypeV1, - actual: EditorElementTypeV1, -) -> Result<(), EditorCommitError> { - if requested == actual { - return Ok(()); - } - Err(EditorCommitError::validation( - "ELEMENT_TYPE_MISMATCH", - format!("editor op {op_index} targets a {actual:?} element as {requested:?}"), - )) -} - -fn validate_bounds_metrics( - label: &str, - current: Option, - candidate: EditorBoundsV1, - allow_legacy_finite_bounds: bool, -) -> Result<(), EditorCommitError> { - for (name, current, candidate) in [ - ("dx", current.map(|bounds| bounds.dx), candidate.dx), - ("dy", current.map(|bounds| bounds.dy), candidate.dy), - ] { - if (allow_legacy_finite_bounds && candidate.is_finite()) - || coordinate_within_limit(candidate) - || current.is_some_and(|value| { - !coordinate_within_limit(value) - && numeric_metric_non_increasing(value, candidate, false) - }) - { - continue; - } - return Err(EditorCommitError::validation( - "COORDINATE_OUT_OF_RANGE", - format!("{label}.{name} exceeds ±{MAX_ABS_COORDINATE}"), - )); - } - - for (name, current, candidate) in [ - ("width", current.map(|bounds| bounds.width), candidate.width), - ( - "height", - current.map(|bounds| bounds.height), - candidate.height, - ), - ] { - // 과거 프리셋의 범위 초과 치수는 관용하되 0·음수는 어떤 경로로도 허용하지 않는다 - if (allow_legacy_finite_bounds && candidate.is_finite() && candidate > 0.0) - || dimension_within_limit(candidate) - || current.is_some_and(|value| { - !dimension_within_limit(value) - && numeric_metric_non_increasing(value, candidate, true) - }) - { - continue; - } - return Err(EditorCommitError::validation( - "DIMENSION_OUT_OF_RANGE", - format!("{label}.{name} must satisfy 0 < value <= {MAX_DIMENSION}"), - )); - } - Ok(()) -} - -fn coordinate_within_limit(value: f64) -> bool { - value.is_finite() && value.abs() <= MAX_ABS_COORDINATE -} - -fn dimension_within_limit(value: f64) -> bool { - value.is_finite() && value > 0.0 && value <= MAX_DIMENSION -} - -fn numeric_metric_non_increasing(current: f64, candidate: f64, dimension: bool) -> bool { - if !current.is_finite() || !candidate.is_finite() { - return current.to_bits() == candidate.to_bits(); - } - if dimension && (current <= 0.0 || candidate <= 0.0) { - return current.to_bits() == candidate.to_bits(); - } - if dimension { - candidate <= current - } else { - candidate.abs() <= current.abs() - } -} - fn collect_group_violations( document: &EditorDocumentV1, violations: &mut BTreeSet, diff --git a/src-tauri/src/state/editor/limits.rs b/src-tauri/src/state/editor/limits.rs new file mode 100644 index 000000000..aa3a346aa --- /dev/null +++ b/src-tauri/src/state/editor/limits.rs @@ -0,0 +1,554 @@ +use super::*; + +pub(super) fn validate_metric_limits( + current: &EditorDocumentV1, + candidate: &EditorDocumentV1, + keying: GrandfatherKeying, +) -> Result<(), EditorCommitError> { + validate_aggregate_metric_limits(current, candidate)?; + validate_mode_metric_limits(current, candidate)?; + validate_per_owner_metric_limits(current, candidate, keying) +} + +fn validate_aggregate_metric_limits( + current: &EditorDocumentV1, + candidate: &EditorDocumentV1, +) -> Result<(), EditorCommitError> { + validate_count_limit( + "TOO_MANY_MODES", + "editor mode count", + editor_modes(current).len(), + editor_modes(candidate).len(), + MAX_MODES, + )?; + + validate_collection_limits("keys", ¤t.keys, &candidate.keys)?; + validate_key_member_fanout(¤t.keys, &candidate.keys)?; + validate_collection_limits( + "keyPositions", + ¤t.key_positions, + &candidate.key_positions, + )?; + validate_collection_limits( + "statPositions", + ¤t.stat_positions, + &candidate.stat_positions, + )?; + validate_collection_limits( + "graphPositions", + ¤t.graph_positions, + &candidate.graph_positions, + )?; + validate_collection_limits( + "knobPositions", + ¤t.knob_positions, + &candidate.knob_positions, + )?; + validate_collection_limits( + "layerGroups", + ¤t.layer_groups, + &candidate.layer_groups, + )?; + + validate_count_limit( + "TOO_MANY_RENDER_ITEMS", + "render item count", + render_item_count(current), + render_item_count(candidate), + MAX_RENDER_ITEMS, + )?; + validate_count_limit( + "TOO_MANY_LAYER_GROUPS", + "layer group count", + current.layer_groups.values().map(Vec::len).sum(), + candidate.layer_groups.values().map(Vec::len).sum(), + MAX_LAYER_GROUPS, + )?; + + Ok(()) +} + +fn validate_mode_metric_limits( + current: &EditorDocumentV1, + candidate: &EditorDocumentV1, +) -> Result<(), EditorCommitError> { + let current_modes = editor_modes(current); + for mode in editor_modes(candidate) { + let current_len = current_modes + .get(&mode) + .map_or(0, |current_mode| current_mode.len()); + validate_count_limit( + "MODE_ID_TOO_LONG", + &format!("mode id '{mode}' byte length"), + current_len, + mode.len(), + MAX_MODE_ID_BYTES, + )?; + } + + Ok(()) +} + +// keying에 따라 관용 상대를 찾는다. StableId는 안정 ID로, ModeIndex는 같은 +// 모드의 같은 자리로 짝짓는다 +fn grandfather_counterpart<'a, T>( + keying: GrandfatherKeying, + by_id: &HashMap<&str, &'a T>, + current_collection: &'a HashMap>, + mode: &str, + index: usize, + id: &str, + position_of: impl Fn(&'a T) -> &'a KeyPosition, +) -> Option<&'a KeyPosition> { + match keying { + GrandfatherKeying::StableId => by_id.get(id).map(|element| position_of(element)), + #[cfg(test)] + GrandfatherKeying::ModeIndex => current_collection + .get(mode) + .and_then(|elements| elements.get(index)) + .map(position_of), + GrandfatherKeying::LegacyPresetModeIndex => current_collection + .get(mode) + .and_then(|elements| elements.get(index)) + .map(position_of), + } +} + +fn validate_per_owner_metric_limits( + current: &EditorDocumentV1, + candidate: &EditorDocumentV1, + keying: GrandfatherKeying, +) -> Result<(), EditorCommitError> { + let mut current_key_slots = HashMap::new(); + for (mode, positions) in ¤t.key_positions { + let Some(slots) = current.keys.get(mode) else { + continue; + }; + for (position, slot) in positions.iter().zip(slots) { + current_key_slots.insert(position.id.as_str(), slot); + } + } + + for (mode, keys) in &candidate.keys { + for (slot_index, slot) in keys.iter().enumerate() { + let current_slot = match keying { + GrandfatherKeying::StableId => candidate + .key_positions + .get(mode) + .and_then(|positions| positions.get(slot_index)) + .and_then(|position| current_key_slots.get(position.id.as_str())) + .copied(), + #[cfg(test)] + GrandfatherKeying::ModeIndex => current + .keys + .get(mode) + .and_then(|slots| slots.get(slot_index)), + GrandfatherKeying::LegacyPresetModeIndex => current + .keys + .get(mode) + .and_then(|slots| slots.get(slot_index)), + }; + validate_key_slot_label_limits(mode, slot_index, current_slot, slot)?; + } + } + + let current_groups = current + .layer_groups + .iter() + .flat_map(|(mode, groups)| { + groups + .iter() + .map(move |group| ((mode.as_str(), group.id.as_str()), group)) + }) + .collect::>(); + for (mode, groups) in &candidate.layer_groups { + for (index, group) in groups.iter().enumerate() { + let current_group = current_groups + .get(&(mode.as_str(), group.id.as_str())) + .copied(); + validate_count_limit( + "GROUP_ID_TOO_LONG", + &format!("layer group id {mode}[{index}] byte length"), + current_group.map_or(0, |group| group.id.len()), + group.id.len(), + MAX_GROUP_ID_BYTES, + )?; + validate_count_limit( + "GROUP_NAME_TOO_LONG", + &format!("layer group name {mode}[{index}] byte length"), + current_group.map_or(0, |group| group.name.len()), + group.name.len(), + MAX_GROUP_NAME_BYTES, + )?; + } + } + + let current_key_positions = current + .key_positions + .values() + .flatten() + .map(|position| (position.id.as_str(), position)) + .collect::>(); + for (mode, positions) in &candidate.key_positions { + for (index, position) in positions.iter().enumerate() { + validate_position_metrics( + "keyPositions", + mode, + index, + grandfather_counterpart( + keying, + ¤t_key_positions, + ¤t.key_positions, + mode, + index, + &position.id, + |position| position, + ), + position, + keying == GrandfatherKeying::LegacyPresetModeIndex, + )?; + } + } + + let current_stat_positions = current + .stat_positions + .values() + .flatten() + .map(|position| (position.position.id.as_str(), position)) + .collect::>(); + for (mode, positions) in &candidate.stat_positions { + for (index, position) in positions.iter().enumerate() { + validate_position_metrics( + "statPositions", + mode, + index, + grandfather_counterpart( + keying, + ¤t_stat_positions, + ¤t.stat_positions, + mode, + index, + &position.position.id, + |element| &element.position, + ), + &position.position, + keying == GrandfatherKeying::LegacyPresetModeIndex, + )?; + } + } + + let current_graph_positions = current + .graph_positions + .values() + .flatten() + .map(|position| (position.position.id.as_str(), position)) + .collect::>(); + for (mode, positions) in &candidate.graph_positions { + for (index, position) in positions.iter().enumerate() { + validate_position_metrics( + "graphPositions", + mode, + index, + grandfather_counterpart( + keying, + ¤t_graph_positions, + ¤t.graph_positions, + mode, + index, + &position.position.id, + |element| &element.position, + ), + &position.position, + keying == GrandfatherKeying::LegacyPresetModeIndex, + )?; + } + } + + let current_knob_positions = current + .knob_positions + .values() + .flatten() + .map(|position| (position.position.id.as_str(), position)) + .collect::>(); + for (mode, positions) in &candidate.knob_positions { + for (index, position) in positions.iter().enumerate() { + validate_position_metrics( + "knobPositions", + mode, + index, + grandfather_counterpart( + keying, + ¤t_knob_positions, + ¤t.knob_positions, + mode, + index, + &position.position.id, + |element| &element.position, + ), + &position.position, + keying == GrandfatherKeying::LegacyPresetModeIndex, + )?; + } + } + + Ok(()) +} + +fn validate_key_slot_label_limits( + mode: &str, + slot_index: usize, + current: Option<&KeySlot>, + candidate: &KeySlot, +) -> Result<(), EditorCommitError> { + let mut grandfathered_lengths = current + .into_iter() + .flat_map(KeySlot::members) + .map(String::len) + .filter(|length| *length > MAX_KEY_LABEL_BYTES) + .collect::>(); + grandfathered_lengths.sort_unstable(); + + for (member_index, member) in candidate.members().enumerate() { + if member.len() <= MAX_KEY_LABEL_BYTES { + continue; + } + let Some(budget_index) = grandfathered_lengths + .iter() + .position(|length| member.len() <= *length) + else { + return Err(EditorCommitError::validation( + "KEY_LABEL_TOO_LONG", + format!( + "key label {mode}[{slot_index}].members[{member_index}] byte length exceeds {MAX_KEY_LABEL_BYTES} and has no matching stored allowance" + ), + )); + }; + grandfathered_lengths.remove(budget_index); + } + + Ok(()) +} + +fn editor_modes(document: &EditorDocumentV1) -> BTreeSet { + document + .keys + .keys() + .chain(document.key_positions.keys()) + .chain(document.stat_positions.keys()) + .chain(document.graph_positions.keys()) + .chain(document.knob_positions.keys()) + .chain(document.layer_groups.keys()) + .cloned() + .collect() +} + +fn render_item_count(document: &EditorDocumentV1) -> usize { + document.key_positions.values().map(Vec::len).sum::() + + document + .stat_positions + .values() + .map(Vec::len) + .sum::() + + document + .graph_positions + .values() + .map(Vec::len) + .sum::() + + document + .knob_positions + .values() + .map(Vec::len) + .sum::() +} + +fn validate_collection_limits( + field: &str, + current: &HashMap>, + candidate: &HashMap>, +) -> Result<(), EditorCommitError> { + for (mode, values) in candidate { + validate_count_limit( + "COLLECTION_TOO_LARGE", + &format!("{field} mode '{mode}' item count"), + current.get(mode).map_or(0, Vec::len), + values.len(), + MAX_ITEMS_PER_MODE, + )?; + } + Ok(()) +} + +fn validate_key_member_fanout( + current: &KeyMappings, + candidate: &KeyMappings, +) -> Result<(), EditorCommitError> { + for (mode, slots) in candidate { + let current_counts = current + .get(mode) + .map(|slots| member_slot_counts(slots)) + .unwrap_or_default(); + for (member, count) in member_slot_counts(slots) { + validate_count_limit( + "TOO_MANY_SLOTS_PER_MEMBER", + &format!("key member '{member}' slot count in mode '{mode}'"), + current_counts.get(&member).copied().unwrap_or_default(), + count, + MAX_SLOTS_PER_MEMBER, + )?; + } + } + Ok(()) +} + +fn member_slot_counts(slots: &[KeySlot]) -> HashMap { + let mut counts = HashMap::new(); + for member in slots + .iter() + .flat_map(KeySlot::members) + .filter(|member| !member.is_empty()) + { + *counts.entry(member.clone()).or_default() += 1; + } + counts +} + +fn validate_count_limit( + code: &'static str, + label: &str, + current: usize, + candidate: usize, + limit: usize, +) -> Result<(), EditorCommitError> { + if candidate <= limit || (current > limit && candidate <= current) { + return Ok(()); + } + Err(EditorCommitError::validation( + code, + format!("{label} exceeds {limit} and increases the stored metric"), + )) +} + +fn validate_position_metrics( + field: &str, + mode: &str, + index: usize, + current: Option<&KeyPosition>, + candidate: &KeyPosition, + allow_legacy_finite_bounds: bool, +) -> Result<(), EditorCommitError> { + validate_bounds_metrics( + &format!("{field} {mode}[{index}]"), + current.map(position_bounds), + position_bounds(candidate), + allow_legacy_finite_bounds, + ) +} + +pub(super) fn position_bounds(position: &KeyPosition) -> EditorBoundsV1 { + EditorBoundsV1 { + dx: position.dx, + dy: position.dy, + width: position.width, + height: position.height, + } +} + +pub(crate) fn validate_editor_op_bounds( + op_index: usize, + current: Option<&KeyPosition>, + bounds: EditorBoundsV1, +) -> Result<(), EditorCommitError> { + validate_bounds_metrics( + &format!("editor op {op_index}.bounds"), + current.map(position_bounds), + bounds, + false, + ) +} + +pub(crate) fn validate_editor_op_target_type( + op_index: usize, + requested: EditorElementTypeV1, + actual: EditorElementTypeV1, +) -> Result<(), EditorCommitError> { + if requested == actual { + return Ok(()); + } + Err(EditorCommitError::validation( + "ELEMENT_TYPE_MISMATCH", + format!("editor op {op_index} targets a {actual:?} element as {requested:?}"), + )) +} + +fn validate_bounds_metrics( + label: &str, + current: Option, + candidate: EditorBoundsV1, + allow_legacy_finite_bounds: bool, +) -> Result<(), EditorCommitError> { + for (name, current, candidate) in [ + ("dx", current.map(|bounds| bounds.dx), candidate.dx), + ("dy", current.map(|bounds| bounds.dy), candidate.dy), + ] { + if (allow_legacy_finite_bounds && candidate.is_finite()) + || coordinate_within_limit(candidate) + || current.is_some_and(|value| { + !coordinate_within_limit(value) + && numeric_metric_non_increasing(value, candidate, false) + }) + { + continue; + } + return Err(EditorCommitError::validation( + "COORDINATE_OUT_OF_RANGE", + format!("{label}.{name} exceeds ±{MAX_ABS_COORDINATE}"), + )); + } + + for (name, current, candidate) in [ + ("width", current.map(|bounds| bounds.width), candidate.width), + ( + "height", + current.map(|bounds| bounds.height), + candidate.height, + ), + ] { + // 과거 프리셋의 범위 초과 치수는 관용하되 0·음수는 어떤 경로로도 허용하지 않는다 + if (allow_legacy_finite_bounds && candidate.is_finite() && candidate > 0.0) + || dimension_within_limit(candidate) + || current.is_some_and(|value| { + !dimension_within_limit(value) + && numeric_metric_non_increasing(value, candidate, true) + }) + { + continue; + } + return Err(EditorCommitError::validation( + "DIMENSION_OUT_OF_RANGE", + format!("{label}.{name} must satisfy 0 < value <= {MAX_DIMENSION}"), + )); + } + Ok(()) +} + +fn coordinate_within_limit(value: f64) -> bool { + value.is_finite() && value.abs() <= MAX_ABS_COORDINATE +} + +fn dimension_within_limit(value: f64) -> bool { + value.is_finite() && value > 0.0 && value <= MAX_DIMENSION +} + +fn numeric_metric_non_increasing(current: f64, candidate: f64, dimension: bool) -> bool { + if !current.is_finite() || !candidate.is_finite() { + return current.to_bits() == candidate.to_bits(); + } + if dimension && (current <= 0.0 || candidate <= 0.0) { + return current.to_bits() == candidate.to_bits(); + } + if dimension { + candidate <= current + } else { + candidate.abs() <= current.abs() + } +} From 42b65795a6740be9ff3370c5cdaa8fcb0da1e675 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 18:46:19 +0900 Subject: [PATCH 196/245] =?UTF-8?q?test:=20=EC=82=AC=EC=9A=B4=EB=93=9C=20?= =?UTF-8?q?=ED=8A=B8=EB=A6=BC=20=ED=8C=8C=ED=98=95=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../managers/SoundTrimModal.runtime.test.tsx | 366 +++++++++++++++++- 1 file changed, 359 insertions(+), 7 deletions(-) diff --git a/src/renderer/components/main/Modal/content/managers/SoundTrimModal.runtime.test.tsx b/src/renderer/components/main/Modal/content/managers/SoundTrimModal.runtime.test.tsx index bd0054d16..a8aab95ba 100644 --- a/src/renderer/components/main/Modal/content/managers/SoundTrimModal.runtime.test.tsx +++ b/src/renderer/components/main/Modal/content/managers/SoundTrimModal.runtime.test.tsx @@ -9,6 +9,14 @@ const soundTrimMocks = vi.hoisted(() => ({ decodeAudioFromArrayBuffer: vi.fn(), drawWaveform: vi.fn(), extractWaveformPeaks: vi.fn(), + schedulerPush: vi.fn(), + schedulerFlush: vi.fn(), + schedulerCancel: vi.fn(), + beginDragCursor: vi.fn(), + endDragCursor: vi.fn(), + setCustomCursorHover: vi.fn(), + lockCustomCursor: vi.fn(), + unlockCustomCursor: vi.fn(), })); vi.mock('@contexts/useTranslation', () => ({ @@ -18,15 +26,48 @@ vi.mock('@api/modules/resourceApi', () => ({ soundApi: {}, })); vi.mock('@utils/core/dragCursor', () => ({ - beginDragCursor: vi.fn(), - endDragCursor: vi.fn(), + beginDragCursor: soundTrimMocks.beginDragCursor, + endDragCursor: soundTrimMocks.endDragCursor, })); vi.mock('@utils/grid/cursorUtils', () => ({ getCursor: () => 'ew-resize', - setCustomCursorHover: vi.fn(), - lockCustomCursor: vi.fn(), - unlockCustomCursor: vi.fn(), + setCustomCursorHover: soundTrimMocks.setCustomCursorHover, + lockCustomCursor: soundTrimMocks.lockCustomCursor, + unlockCustomCursor: soundTrimMocks.unlockCustomCursor, })); +vi.mock('@utils/animation/rafLatestScheduler', async (importOriginal) => { + const original = await importOriginal< + typeof import('@utils/animation/rafLatestScheduler') + >(); + return { + ...original, + createRafLatestScheduler: ( + apply: (value: T) => void, + strategy?: import('@utils/animation/rafLatestScheduler').ContinuousInputStrategy, + frames?: Pick, + ) => { + const scheduler = original.createRafLatestScheduler( + apply, + strategy, + frames, + ); + return { + push: (value: T) => { + soundTrimMocks.schedulerPush(value); + scheduler.push(value); + }, + flush: () => { + soundTrimMocks.schedulerFlush(); + scheduler.flush(); + }, + cancel: () => { + soundTrimMocks.schedulerCancel(); + scheduler.cancel(); + }, + }; + }, + }; +}); vi.mock('./soundTrimModel', async (importOriginal) => ({ ...(await importOriginal()), createAudioContext: soundTrimMocks.createAudioContext, @@ -131,7 +172,10 @@ describe('SoundTrimModal playback·waveform runtime', () => { }); }; - const renderOpen = async (previewVolume = 100) => { + const renderOpen = async ( + previewVolume = 100, + continuousInputStrategy: 'legacy' | 'frame' = 'legacy', + ) => { await act(async () => { root.render( { onSaved={() => undefined} previewVolume={previewVolume} initialFile={INITIAL_FILE} - continuousInputStrategy="legacy" + continuousInputStrategy={continuousInputStrategy} />, ); await Promise.resolve(); @@ -160,6 +204,62 @@ describe('SoundTrimModal playback·waveform runtime', () => { const waveformHost = (): HTMLDivElement => document.querySelector('[data-sound-waveform="true"]')!; + const setWaveformRect = (waveform: HTMLDivElement) => { + vi.spyOn(waveform, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 224, + bottom: 100, + width: 224, + height: 100, + toJSON: () => ({}), + }); + }; + + const dispatchMouse = ( + target: EventTarget, + type: string, + init: MouseEventInit, + ) => { + const event = new MouseEvent(type, { + bubbles: true, + cancelable: true, + ...init, + }); + act(() => target.dispatchEvent(event)); + return event; + }; + + const dispatchWheel = ( + target: EventTarget, + init: WheelEventInit, + ): WheelEvent => { + const event = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + ...init, + }); + act(() => target.dispatchEvent(event)); + return event; + }; + + const runLatestRaf = () => { + const frame = [...rafCallbacks.keys()].at(-1); + expect(frame).toBeDefined(); + act(() => runRaf(frame!)); + }; + + const expectCalledBefore = ( + before: ReturnType, + after: ReturnType, + ) => { + expect(before.mock.invocationCallOrder.at(-1)).toBeLessThan( + after.mock.invocationCallOrder.at(0) ?? Number.POSITIVE_INFINITY, + ); + }; + const eventCallCount = ( spy: ReturnType, target: EventTarget, @@ -169,6 +269,19 @@ describe('SoundTrimModal playback·waveform runtime', () => { (call, index) => spy.mock.instances[index] === target && call[0] === type, ).length; + const firstEventCallOrder = ( + spy: ReturnType, + types: string[], + ): number => { + const orders = spy.mock.calls.flatMap((call, index) => + types.includes(String(call[0])) + ? [spy.mock.invocationCallOrder[index]] + : [], + ); + expect(orders.length).toBeGreaterThan(0); + return Math.min(...orders); + }; + beforeEach(() => { host = document.createElement('div'); document.body.appendChild(host); @@ -367,6 +480,245 @@ describe('SoundTrimModal playback·waveform runtime', () => { ).toBe(true); }); + it('frame 휠 입력은 delta를 누적하고 anchor 기준으로 zoom·pan을 min/max에 고정한다', async () => { + await renderOpen(100, 'frame'); + const waveform = waveformHost(); + setWaveformRect(waveform); + soundTrimMocks.schedulerPush.mockClear(); + + const first = dispatchWheel(waveform, { + clientX: 112, + deltaY: -2_000, + }); + const second = dispatchWheel(waveform, { + clientX: 112, + deltaY: -3_000, + }); + expect(first.defaultPrevented).toBe(true); + expect(second.defaultPrevented).toBe(true); + expect(soundTrimMocks.schedulerPush).toHaveBeenCalledTimes(2); + + runLatestRaf(); + expect(soundTrimMocks.drawWaveform).toHaveBeenLastCalledWith( + expect.any(HTMLCanvasElement), + expect.any(Float32Array), + 0, + 1, + null, + 0.46875, + 0.53125, + ); + + dispatchWheel(waveform, { clientX: 12, deltaY: 10_000 }); + runLatestRaf(); + expect(soundTrimMocks.drawWaveform).toHaveBeenLastCalledWith( + expect.any(HTMLCanvasElement), + expect.any(Float32Array), + 0, + 1, + null, + 0, + 1, + ); + }); + + it('middle pan 종료는 최종 frame을 flush한 뒤 cancel·cursor unlock·listener 해제를 수행한다', async () => { + await renderOpen(100, 'frame'); + const waveform = waveformHost(); + setWaveformRect(waveform); + + dispatchWheel(waveform, { clientX: 112, deltaY: -10_000 }); + runLatestRaf(); + soundTrimMocks.schedulerPush.mockClear(); + soundTrimMocks.schedulerFlush.mockClear(); + soundTrimMocks.schedulerCancel.mockClear(); + soundTrimMocks.endDragCursor.mockClear(); + removeEventListenerSpy.mockClear(); + windowRemoveEventListenerSpy.mockClear(); + + dispatchMouse(waveform, 'mousedown', { button: 1, clientX: 112 }); + expect(soundTrimMocks.beginDragCursor).toHaveBeenLastCalledWith('grabbing'); + expect(waveform.style.cursor).toBe('grabbing'); + dispatchMouse(document, 'mousemove', { clientX: 162 }); + dispatchMouse(document, 'mouseup', {}); + + expect(soundTrimMocks.drawWaveform.mock.calls.at(-1)?.[5]).toBeCloseTo( + 0.453125, + ); + expect(waveform.style.cursor).toBe(''); + expectCalledBefore( + soundTrimMocks.schedulerFlush, + soundTrimMocks.schedulerCancel, + ); + expectCalledBefore( + soundTrimMocks.schedulerCancel, + soundTrimMocks.endDragCursor, + ); + expect( + soundTrimMocks.endDragCursor.mock.invocationCallOrder[0], + ).toBeLessThan( + firstEventCallOrder(removeEventListenerSpy, ['mousemove', 'mouseup']), + ); + expect( + soundTrimMocks.endDragCursor.mock.invocationCallOrder[0], + ).toBeLessThan( + firstEventCallOrder(windowRemoveEventListenerSpy, [ + 'blur', + 'pointercancel', + ]), + ); + }); + + it.each(['pointerup', 'pointercancel', 'blur'])( + '%s 종료는 보류 중인 handle frame을 flush한 뒤 cursor와 listener를 정리한다', + async (terminalEvent) => { + await renderOpen(100, 'frame'); + const waveform = waveformHost(); + setWaveformRect(waveform); + soundTrimMocks.schedulerFlush.mockClear(); + soundTrimMocks.schedulerCancel.mockClear(); + soundTrimMocks.unlockCustomCursor.mockClear(); + windowRemoveEventListenerSpy.mockClear(); + + dispatchMouse(waveform, 'pointerdown', { button: 0, clientX: 12 }); + expect(soundTrimMocks.lockCustomCursor).toHaveBeenCalledWith( + 'ew-resize', + expect.any(MouseEvent), + ); + dispatchMouse(window, 'pointermove', { clientX: 150 }); + dispatchMouse(window, terminalEvent, {}); + + expect(soundTrimMocks.drawWaveform.mock.calls.at(-1)?.[2]).toBeCloseTo( + 0.69, + ); + expectCalledBefore( + soundTrimMocks.schedulerFlush, + soundTrimMocks.schedulerCancel, + ); + expectCalledBefore( + soundTrimMocks.schedulerCancel, + soundTrimMocks.unlockCustomCursor, + ); + expect( + soundTrimMocks.unlockCustomCursor.mock.invocationCallOrder[0], + ).toBeLessThan( + firstEventCallOrder(windowRemoveEventListenerSpy, [ + 'pointermove', + 'pointerup', + 'pointercancel', + 'blur', + ]), + ); + for (const type of [ + 'pointermove', + 'pointerup', + 'pointercancel', + 'blur', + ]) { + expect(eventCallCount(windowRemoveEventListenerSpy, window, type)).toBe( + 1, + ); + } + }, + ); + + it('handle press는 가까운 핸들을 고르고 이동 ratio를 trim 범위에 clamp한다', async () => { + await renderOpen(); + const waveform = waveformHost(); + setWaveformRect(waveform); + + dispatchMouse(waveform, 'pointerdown', { button: 0, clientX: 100 }); + dispatchMouse(window, 'pointermove', { clientX: 300 }); + dispatchMouse(window, 'pointerup', {}); + + expect(soundTrimMocks.drawWaveform).toHaveBeenLastCalledWith( + expect.any(HTMLCanvasElement), + expect.any(Float32Array), + 1, + 1, + null, + 0, + 1, + ); + }); + + it('두 handle hit 영역이 겹치면 midpoint 동률은 end, 왼쪽은 start를 고른다', async () => { + await renderOpen(); + const waveform = waveformHost(); + setWaveformRect(waveform); + + dispatchMouse(waveform, 'pointerdown', { button: 0, clientX: 12 }); + dispatchMouse(window, 'pointermove', { clientX: 204 }); + dispatchMouse(window, 'pointerup', {}); + + dispatchMouse(waveform, 'pointerdown', { button: 0, clientX: 208 }); + dispatchMouse(window, 'pointermove', { clientX: 150 }); + dispatchMouse(window, 'pointerup', {}); + expect(soundTrimMocks.drawWaveform.mock.calls.at(-1)?.slice(2, 4)).toEqual([ + 0.96, 0.96, + ]); + + dispatchMouse(waveform, 'pointerdown', { button: 0, clientX: 204 }); + dispatchMouse(window, 'pointermove', { clientX: 212 }); + dispatchMouse(window, 'pointerup', {}); + dispatchMouse(waveform, 'pointerdown', { button: 0, clientX: 207 }); + dispatchMouse(window, 'pointermove', { clientX: 150 }); + dispatchMouse(window, 'pointerup', {}); + expect(soundTrimMocks.drawWaveform.mock.calls.at(-1)?.slice(2, 4)).toEqual([ + 0.69, 1, + ]); + }); + + it('close reset 뒤 재오픈은 기존 host listener를 해제하고 새 host의 view·trim 기본값을 사용한다', async () => { + await renderOpen(100, 'frame'); + const firstWaveform = waveformHost(); + setWaveformRect(firstWaveform); + dispatchWheel(firstWaveform, { clientX: 112, deltaY: -10_000 }); + runLatestRaf(); + dispatchMouse(firstWaveform, 'pointerdown', { + button: 0, + clientX: 100, + }); + dispatchMouse(window, 'pointerup', {}); + + await act(async () => { + root.render( + undefined} + onSaved={() => undefined} + initialFile={INITIAL_FILE} + continuousInputStrategy="frame" + />, + ); + }); + expect(eventCallCount(removeEventListenerSpy, firstWaveform, 'wheel')).toBe( + 1, + ); + + await renderOpen(100, 'frame'); + for (const frame of [...rafCallbacks.keys()]) { + act(() => runRaf(frame)); + } + await act(async () => { + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); + const secondWaveform = waveformHost(); + expect(secondWaveform).not.toBe(firstWaveform); + expect(eventCallCount(addEventListenerSpy, secondWaveform, 'wheel')).toBe( + 1, + ); + expect(soundTrimMocks.drawWaveform).toHaveBeenLastCalledWith( + expect.any(HTMLCanvasElement), + expect.any(Float32Array), + 0, + 1, + null, + 0, + 1, + ); + }); + it('close 전환은 playback·pointer를 reset한다', async () => { await renderOpen(); act(() => playbackButton().click()); From d549eaa6ee0f9b8793510819736fa9c4621c8be9 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 18:46:19 +0900 Subject: [PATCH 197/245] =?UTF-8?q?refactor:=20=EC=82=AC=EC=9A=B4=EB=93=9C?= =?UTF-8?q?=20=ED=8A=B8=EB=A6=BC=20=ED=8C=8C=ED=98=95=20=EC=84=B8=EC=85=98?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Modal/content/managers/SoundTrimModal.tsx | 329 ++--------------- .../content/managers/soundTrimModel.test.ts | 72 ++++ .../Modal/content/managers/soundTrimModel.ts | 147 ++++++++ .../managers/useSoundTrimWaveformSession.ts | 348 ++++++++++++++++++ 4 files changed, 597 insertions(+), 299 deletions(-) create mode 100644 src/renderer/components/main/Modal/content/managers/useSoundTrimWaveformSession.ts diff --git a/src/renderer/components/main/Modal/content/managers/SoundTrimModal.tsx b/src/renderer/components/main/Modal/content/managers/SoundTrimModal.tsx index 3d23c64d4..7a3492f5e 100644 --- a/src/renderer/components/main/Modal/content/managers/SoundTrimModal.tsx +++ b/src/renderer/components/main/Modal/content/managers/SoundTrimModal.tsx @@ -1,33 +1,24 @@ -import { beginDragCursor, endDragCursor } from '@utils/core/dragCursor'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from '@contexts/useTranslation'; import FullSurfaceModalLayout from '@components/main/Modal/FullSurfaceModalLayout'; import { isTopmostPopupLayer } from '@components/main/Modal/popupLayer'; import IconSwap from '@components/main/common/IconSwap'; -import { - getCursor, - setCustomCursorHover, - lockCustomCursor, - unlockCustomCursor, -} from '@utils/grid/cursorUtils'; -import { - createRafLatestScheduler, - type ContinuousInputStrategy, -} from '@utils/animation/rafLatestScheduler'; +import { setCustomCursorHover } from '@utils/grid/cursorUtils'; +import { type ContinuousInputStrategy } from '@utils/animation/rafLatestScheduler'; import { soundApi } from '@api/modules/resourceApi'; import { - WAVEFORM_PAD_X, arrayBufferToBase64, base64ToArrayBuffer, - clamp, decodeAudioFromArrayBuffer, drawWaveform, encodeWavBase64, extractWaveformPeaks, formatSecLabel, stripExtension, + type SoundTrimDragTarget, } from './soundTrimModel'; import { createSoundTrimPlaybackRuntime } from './soundTrimPlaybackRuntime'; +import { useSoundTrimWaveformSession } from './useSoundTrimWaveformSession'; interface SoundTrimModalProps { isOpen: boolean; @@ -43,12 +34,6 @@ interface SoundTrimModalProps { continuousInputStrategy?: ContinuousInputStrategy; } -type DragTarget = 'start' | 'end' | null; - -const HANDLE_PICK_PX = 10; -const MIN_VIEW_ZOOM = 1; -const MAX_VIEW_ZOOM = 16; - const SoundTrimModal = ({ isOpen, onClose, @@ -73,7 +58,7 @@ const SoundTrimModal = ({ waveformRef.current = node; setWaveformHost(node); }, []); - const dragTargetRef = useRef(null); + const dragTargetRef = useRef(null); const isEditMode = !!editingSoundPath; @@ -386,285 +371,31 @@ const SoundTrimModal = ({ void processFile(file); }; - // 휠 줌: 마우스 위치 기준 줌 인/아웃 - const viewZoomRef = useRef(viewZoom); - viewZoomRef.current = viewZoom; - const viewPanRatioRef = useRef(viewPanRatio); - viewPanRatioRef.current = viewPanRatio; - const wheelDeltaRef = useRef(0); - - // 최신 값은 전부 ref로 읽으므로 노드가 붙을 때만 다시 건다. 매 렌더 재등록하면 - // 정리 시 스케줄러가 아직 안 흘린 휠 델타를 버린다 - useEffect(() => { - if (!isOpen) return; - const node = waveformHost; - if (!node) return; - const applyWheel = (e: WheelEvent, deltaY: number) => { - if (!audioBufferRef.current) return; - - const rect = node.getBoundingClientRect(); - const drawableW = rect.width - WAVEFORM_PAD_X * 2; - const mouseScreenRatio = clamp( - (e.clientX - rect.left - WAVEFORM_PAD_X) / Math.max(1, drawableW), - 0, - 1, - ); - - const curZoom = viewZoomRef.current; - const curPan = viewPanRatioRef.current; - const curViewSpan = 1 / curZoom; - const mouseAudioRatio = curPan + mouseScreenRatio * curViewSpan; - - const zoomFactor = Math.exp(-deltaY * 0.0018); - const newZoom = clamp(curZoom * zoomFactor, MIN_VIEW_ZOOM, MAX_VIEW_ZOOM); - const newViewSpan = 1 / newZoom; - - let newPan = mouseAudioRatio - mouseScreenRatio * newViewSpan; - newPan = clamp(newPan, 0, Math.max(0, 1 - newViewSpan)); - - setViewZoom(newZoom); - setViewPanRatio(newPan); - }; - const scheduler = createRafLatestScheduler((event) => { - const deltaY = wheelDeltaRef.current; - wheelDeltaRef.current = 0; - applyWheel(event, deltaY); - }, continuousInputStrategy); - const handleWheel = (event: WheelEvent) => { - event.preventDefault(); - wheelDeltaRef.current += event.deltaY; - scheduler.push(event); - }; - node.addEventListener('wheel', handleWheel, { passive: false }); - return () => { - scheduler.cancel(); - wheelDeltaRef.current = 0; - node.removeEventListener('wheel', handleWheel); - }; - }, [continuousInputStrategy, isOpen, waveformHost]); - - // 중간 버튼 드래그: 줌 시 수평 패닝 - const handleMiddleDown = useCallback( - (e: MouseEvent) => { - if (e.button !== 1) return; - if (!audioBufferRef.current) return; - e.preventDefault(); - - const host = waveformRef.current; - if (!host) return; - const rect = host.getBoundingClientRect(); - const drawableW = rect.width - WAVEFORM_PAD_X * 2; - - const startX = e.clientX; - const startPan = viewPanRatioRef.current; - const curZoom = viewZoomRef.current; - const viewSpan = 1 / curZoom; - - setCustomCursorHover(null); - const canvas = canvasRef.current; - if (canvas) canvas.style.cursor = ''; - host.style.cursor = 'grabbing'; - // 잡는 동안 전역 유지 - 호스트 밖으로 나가도 복귀하지 않게 - beginDragCursor('grabbing'); - - const applyMouseMove = (moveEvent: MouseEvent) => { - const deltaX = moveEvent.clientX - startX; - let newPan = startPan - (deltaX / Math.max(1, drawableW)) * viewSpan; - newPan = clamp(newPan, 0, Math.max(0, 1 - viewSpan)); - setViewPanRatio(newPan); - }; - const moveScheduler = createRafLatestScheduler( - applyMouseMove, - continuousInputStrategy, - ); - const handleMouseMove = (moveEvent: MouseEvent) => - moveScheduler.push(moveEvent); - - const cleanup = () => { - moveScheduler.flush(); - moveScheduler.cancel(); - endDragCursor(); - host.style.cursor = ''; - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', cleanup); - window.removeEventListener('blur', cleanup); - window.removeEventListener('pointercancel', cleanup); - middleDragCleanupRef.current = null; - }; - - middleDragCleanupRef.current = cleanup; - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', cleanup); - // 드래그 중 포커스 상실·포인터 취소 시에도 커서 복구 - window.addEventListener('blur', cleanup); - window.addEventListener('pointercancel', cleanup); - }, - [continuousInputStrategy], - ); - - useEffect(() => { - if (!isOpen) return; - const node = waveformHost; - if (!node) return; - node.addEventListener('mousedown', handleMiddleDown); - return () => { - node.removeEventListener('mousedown', handleMiddleDown); - middleDragCleanupRef.current?.(); - }; - }, [handleMiddleDown, isOpen, waveformHost]); - - // 커서 overlay 루트가 모달 포털보다 DOM 순서상 위에 위치하도록 보장 - useEffect(() => { - if (!isOpen) return; - const overlay = document.getElementById('dmn-cursor-overlay'); - if (overlay?.parentNode) { - overlay.parentNode.appendChild(overlay); - } - }, [isOpen]); - - // 캔버스 커서 hover: macOS overlay 커서용 네이티브 addEventListener - useEffect(() => { - if (!isOpen || !audioBuffer) return; - const canvas = canvasRef.current; - const host = waveformHost; - if (!canvas || !host) return; - - const handleMouseMove = (e: MouseEvent) => { - if (dragTargetRef.current || middleDragCleanupRef.current) return; - const rect = host.getBoundingClientRect(); - const drawableW = rect.width - WAVEFORM_PAD_X * 2; - const x = e.clientX - rect.left; - const vStart = viewStartRef.current; - const vEnd = viewEndRef.current; - const viewSpan = vEnd - vStart; - const sR = startRatioRef.current; - const eR = endRatioRef.current; - const startHandleX = - WAVEFORM_PAD_X + ((sR - vStart) / viewSpan) * drawableW; - const endHandleX = - WAVEFORM_PAD_X + ((eR - vStart) / viewSpan) * drawableW; - const nearHandle = - Math.abs(x - startHandleX) <= HANDLE_PICK_PX || - Math.abs(x - endHandleX) <= HANDLE_PICK_PX; - - // macOS: SVG overlay 커서 / Windows: getCursor CSS 폴백 - setCustomCursorHover(nearHandle ? 'ew-resize' : null, e); - canvas.style.cursor = nearHandle ? getCursor('ew-resize') : ''; - }; - - const handleMouseLeave = () => { - if (!dragTargetRef.current && !middleDragCleanupRef.current) { - setCustomCursorHover(null); - canvas.style.cursor = ''; - } - }; - - canvas.addEventListener('mousemove', handleMouseMove); - canvas.addEventListener('mouseleave', handleMouseLeave); - return () => { - canvas.removeEventListener('mousemove', handleMouseMove); - canvas.removeEventListener('mouseleave', handleMouseLeave); - setCustomCursorHover(null); - }; - }, [isOpen, audioBuffer, waveformHost]); - - const updateFromClientX = (clientX: number, target: DragTarget) => { - const host = waveformRef.current; - if (!host || !target) return; - const rect = host.getBoundingClientRect(); - const drawableW = rect.width - WAVEFORM_PAD_X * 2; - const screenRatio = - (clientX - rect.left - WAVEFORM_PAD_X) / Math.max(1, drawableW); - const vStart = viewStartRef.current; - const vEnd = viewEndRef.current; - const ratio = clamp(vStart + screenRatio * (vEnd - vStart), 0, 1); - - if (target === 'start') { - setStartRatio(clamp(ratio, 0, endRatioRef.current)); - } else if (target === 'end') { - setEndRatio(clamp(ratio, startRatioRef.current, 1)); - } - }; - - const handlePointerMove = (event: PointerEvent) => { - const target = dragTargetRef.current; - if (!target) return; - updateFromClientX(event.clientX, target); - }; - - const handlePointerUp = () => { - handleDragCleanupRef.current?.(); - }; - - const handleWaveformPointerDown = ( - event: React.PointerEvent, - ) => { - if (event.button !== 0) return; - if (!audioBuffer) return; - const host = waveformRef.current; - if (!host) return; - - // 핸들 드래그 시 재생 중지 및 일시정지 위치 초기화 - if (playSourceRef.current) { - teardownAudio(); - } - pausedAtRatioRef.current = null; - - const rect = host.getBoundingClientRect(); - const drawableW = rect.width - WAVEFORM_PAD_X * 2; - const x = clamp(event.clientX - rect.left, 0, rect.width); - const vStart = viewStartRef.current; - const vEnd = viewEndRef.current; - const viewSpan = vEnd - vStart; - const sR = startRatioRef.current; - const eR = endRatioRef.current; - const startX = WAVEFORM_PAD_X + ((sR - vStart) / viewSpan) * drawableW; - const endX = WAVEFORM_PAD_X + ((eR - vStart) / viewSpan) * drawableW; - - const pickStart = Math.abs(x - startX) <= HANDLE_PICK_PX; - const pickEnd = Math.abs(x - endX) <= HANDLE_PICK_PX; - - let nextTarget: DragTarget; - if (pickStart && pickEnd) { - nextTarget = x < (startX + endX) / 2 ? 'start' : 'end'; - } else if (pickStart) { - nextTarget = 'start'; - } else if (pickEnd) { - nextTarget = 'end'; - } else { - nextTarget = Math.abs(x - startX) < Math.abs(x - endX) ? 'start' : 'end'; - } - - dragTargetRef.current = nextTarget; - lockCustomCursor('ew-resize', event.nativeEvent as unknown as MouseEvent); - updateFromClientX(event.clientX, nextTarget); - - const moveScheduler = createRafLatestScheduler( - handlePointerMove, - continuousInputStrategy, - ); - const registeredMove = (moveEvent: PointerEvent) => - moveScheduler.push(moveEvent); - const registeredUp = handlePointerUp; - - handleDragCleanupRef.current = () => { - moveScheduler.flush(); - moveScheduler.cancel(); - dragTargetRef.current = null; - unlockCustomCursor(); - window.removeEventListener('pointermove', registeredMove); - window.removeEventListener('pointerup', registeredUp); - window.removeEventListener('pointercancel', registeredUp); - window.removeEventListener('blur', registeredUp); - handleDragCleanupRef.current = null; - }; - - window.addEventListener('pointermove', registeredMove); - window.addEventListener('pointerup', registeredUp); - // 드래그 중 포커스 상실·포인터 취소 시에도 커서·드래그 상태 복구 - window.addEventListener('pointercancel', registeredUp); - window.addEventListener('blur', registeredUp); - }; + const { handleWaveformPointerDown } = useSoundTrimWaveformSession({ + isOpen, + audioBuffer, + waveformHost, + waveformRef, + canvasRef, + audioBufferRef, + startRatioRef, + endRatioRef, + viewStartRef, + viewEndRef, + dragTargetRef, + middleDragCleanupRef, + handleDragCleanupRef, + playSourceRef, + pausedAtRatioRef, + viewZoom, + viewPanRatio, + setViewZoom, + setViewPanRatio, + setStartRatio, + setEndRatio, + teardownAudio, + continuousInputStrategy, + }); const handleSave = async () => { if (!audioBuffer || savingRef.current || isDecoding) return; diff --git a/src/renderer/components/main/Modal/content/managers/soundTrimModel.test.ts b/src/renderer/components/main/Modal/content/managers/soundTrimModel.test.ts index 1ea004918..96a4b0deb 100644 --- a/src/renderer/components/main/Modal/content/managers/soundTrimModel.test.ts +++ b/src/renderer/components/main/Modal/content/managers/soundTrimModel.test.ts @@ -6,6 +6,12 @@ import { encodeWavBase64, extractWaveformPeaks, formatSecLabel, + getSoundTrimHandleGeometry, + getSoundTrimRatioFromClientX, + isNearSoundTrimHandle, + pickSoundTrimDragTarget, + planSoundTrimMiddlePan, + planSoundTrimWheelViewport, stripExtension, } from './soundTrimModel'; @@ -51,4 +57,70 @@ describe('soundTrimModel', () => { expect(view.getInt16(44, true)).toBe(16_383); expect(view.getInt16(46, true)).toBe(32_767); }); + + it('wheel anchor와 middle pan을 동일한 viewport 범위에 고정한다', () => { + expect( + planSoundTrimWheelViewport({ + clientX: 112, + rectLeft: 0, + rectWidth: 224, + deltaY: -10_000, + viewZoom: 1, + viewPanRatio: 0, + }), + ).toEqual({ viewZoom: 16, viewPanRatio: 0.46875 }); + expect( + planSoundTrimWheelViewport({ + clientX: 12, + rectLeft: 0, + rectWidth: 224, + deltaY: 10_000, + viewZoom: 16, + viewPanRatio: 0.46875, + }), + ).toEqual({ viewZoom: 1, viewPanRatio: 0 }); + expect( + planSoundTrimMiddlePan({ + clientX: 162, + startClientX: 112, + startPanRatio: 0.46875, + drawableWidth: 200, + viewSpan: 0.0625, + }), + ).toBe(0.453125); + }); + + it('handle geometry·hit·동률 선택과 client ratio clamp를 보존한다', () => { + const geometry = getSoundTrimHandleGeometry({ + rectWidth: 224, + viewStart: 0, + viewEnd: 1, + startRatio: 0.96, + endRatio: 1, + }); + expect(geometry).toEqual({ + drawableWidth: 200, + startX: 204, + endX: 212, + }); + expect(isNearSoundTrimHandle(208, geometry.startX, geometry.endX)).toBe( + true, + ); + expect(pickSoundTrimDragTarget(208, geometry.startX, geometry.endX)).toBe( + 'end', + ); + expect(pickSoundTrimDragTarget(207, geometry.startX, geometry.endX)).toBe( + 'start', + ); + expect(pickSoundTrimDragTarget(100, 12, 212)).toBe('start'); + expect( + getSoundTrimRatioFromClientX({ + clientX: 300, + rectLeft: 0, + rectWidth: 224, + viewStart: 0, + viewEnd: 1, + }), + ).toBe(1); + }); }); diff --git a/src/renderer/components/main/Modal/content/managers/soundTrimModel.ts b/src/renderer/components/main/Modal/content/managers/soundTrimModel.ts index 88c333072..72abad277 100644 --- a/src/renderer/components/main/Modal/content/managers/soundTrimModel.ts +++ b/src/renderer/components/main/Modal/content/managers/soundTrimModel.ts @@ -1,5 +1,43 @@ export const WAVEFORM_PEAK_COUNT = 1600; export const WAVEFORM_PAD_X = 12; +export const SOUND_TRIM_HANDLE_PICK_PX = 10; +export const SOUND_TRIM_MIN_VIEW_ZOOM = 1; +export const SOUND_TRIM_MAX_VIEW_ZOOM = 16; + +export type SoundTrimDragTarget = 'start' | 'end' | null; + +interface SoundTrimWheelViewportInput { + clientX: number; + rectLeft: number; + rectWidth: number; + deltaY: number; + viewZoom: number; + viewPanRatio: number; +} + +interface SoundTrimMiddlePanInput { + clientX: number; + startClientX: number; + startPanRatio: number; + drawableWidth: number; + viewSpan: number; +} + +interface SoundTrimHandleGeometryInput { + rectWidth: number; + viewStart: number; + viewEnd: number; + startRatio: number; + endRatio: number; +} + +interface SoundTrimClientRatioInput { + clientX: number; + rectLeft: number; + rectWidth: number; + viewStart: number; + viewEnd: number; +} export const formatSecLabel = (ms: number): string => `${(ms / 1000).toFixed(2)}s`; @@ -10,6 +48,115 @@ export const clamp = (value: number, min: number, max: number): number => { return value; }; +export const planSoundTrimWheelViewport = ({ + clientX, + rectLeft, + rectWidth, + deltaY, + viewZoom, + viewPanRatio, +}: SoundTrimWheelViewportInput): { + viewZoom: number; + viewPanRatio: number; +} => { + const drawableWidth = rectWidth - WAVEFORM_PAD_X * 2; + const mouseScreenRatio = clamp( + (clientX - rectLeft - WAVEFORM_PAD_X) / Math.max(1, drawableWidth), + 0, + 1, + ); + const viewSpan = 1 / viewZoom; + const mouseAudioRatio = viewPanRatio + mouseScreenRatio * viewSpan; + const zoomFactor = Math.exp(-deltaY * 0.0018); + const nextViewZoom = clamp( + viewZoom * zoomFactor, + SOUND_TRIM_MIN_VIEW_ZOOM, + SOUND_TRIM_MAX_VIEW_ZOOM, + ); + const nextViewSpan = 1 / nextViewZoom; + const nextViewPanRatio = clamp( + mouseAudioRatio - mouseScreenRatio * nextViewSpan, + 0, + Math.max(0, 1 - nextViewSpan), + ); + + return { + viewZoom: nextViewZoom, + viewPanRatio: nextViewPanRatio, + }; +}; + +export const planSoundTrimMiddlePan = ({ + clientX, + startClientX, + startPanRatio, + drawableWidth, + viewSpan, +}: SoundTrimMiddlePanInput): number => { + const deltaX = clientX - startClientX; + const nextPanRatio = + startPanRatio - (deltaX / Math.max(1, drawableWidth)) * viewSpan; + return clamp(nextPanRatio, 0, Math.max(0, 1 - viewSpan)); +}; + +export const getSoundTrimHandleGeometry = ({ + rectWidth, + viewStart, + viewEnd, + startRatio, + endRatio, +}: SoundTrimHandleGeometryInput): { + drawableWidth: number; + startX: number; + endX: number; +} => { + const drawableWidth = rectWidth - WAVEFORM_PAD_X * 2; + const viewSpan = viewEnd - viewStart; + return { + drawableWidth, + startX: + WAVEFORM_PAD_X + ((startRatio - viewStart) / viewSpan) * drawableWidth, + endX: WAVEFORM_PAD_X + ((endRatio - viewStart) / viewSpan) * drawableWidth, + }; +}; + +export const isNearSoundTrimHandle = ( + x: number, + startX: number, + endX: number, +): boolean => + Math.abs(x - startX) <= SOUND_TRIM_HANDLE_PICK_PX || + Math.abs(x - endX) <= SOUND_TRIM_HANDLE_PICK_PX; + +export const pickSoundTrimDragTarget = ( + x: number, + startX: number, + endX: number, +): Exclude => { + const pickStart = Math.abs(x - startX) <= SOUND_TRIM_HANDLE_PICK_PX; + const pickEnd = Math.abs(x - endX) <= SOUND_TRIM_HANDLE_PICK_PX; + + if (pickStart && pickEnd) { + return x < (startX + endX) / 2 ? 'start' : 'end'; + } + if (pickStart) return 'start'; + if (pickEnd) return 'end'; + return Math.abs(x - startX) < Math.abs(x - endX) ? 'start' : 'end'; +}; + +export const getSoundTrimRatioFromClientX = ({ + clientX, + rectLeft, + rectWidth, + viewStart, + viewEnd, +}: SoundTrimClientRatioInput): number => { + const drawableWidth = rectWidth - WAVEFORM_PAD_X * 2; + const screenRatio = + (clientX - rectLeft - WAVEFORM_PAD_X) / Math.max(1, drawableWidth); + return clamp(viewStart + screenRatio * (viewEnd - viewStart), 0, 1); +}; + export const createAudioContext = (): AudioContext => { const ctor = window.AudioContext || diff --git a/src/renderer/components/main/Modal/content/managers/useSoundTrimWaveformSession.ts b/src/renderer/components/main/Modal/content/managers/useSoundTrimWaveformSession.ts new file mode 100644 index 000000000..207769073 --- /dev/null +++ b/src/renderer/components/main/Modal/content/managers/useSoundTrimWaveformSession.ts @@ -0,0 +1,348 @@ +import { useCallback, useEffect, useRef } from 'react'; +import type { + Dispatch, + MutableRefObject, + PointerEvent as ReactPointerEvent, + RefObject, + SetStateAction, +} from 'react'; +import { beginDragCursor, endDragCursor } from '@utils/core/dragCursor'; +import { + getCursor, + lockCustomCursor, + setCustomCursorHover, + unlockCustomCursor, +} from '@utils/grid/cursorUtils'; +import { + createRafLatestScheduler, + type ContinuousInputStrategy, +} from '@utils/animation/rafLatestScheduler'; +import { + WAVEFORM_PAD_X, + clamp, + getSoundTrimHandleGeometry, + getSoundTrimRatioFromClientX, + isNearSoundTrimHandle, + pickSoundTrimDragTarget, + planSoundTrimMiddlePan, + planSoundTrimWheelViewport, + type SoundTrimDragTarget, +} from './soundTrimModel'; + +interface UseSoundTrimWaveformSessionOptions { + isOpen: boolean; + audioBuffer: AudioBuffer | null; + waveformHost: HTMLDivElement | null; + waveformRef: RefObject; + canvasRef: RefObject; + audioBufferRef: RefObject; + startRatioRef: RefObject; + endRatioRef: RefObject; + viewStartRef: RefObject; + viewEndRef: RefObject; + dragTargetRef: MutableRefObject; + middleDragCleanupRef: MutableRefObject<(() => void) | null>; + handleDragCleanupRef: MutableRefObject<(() => void) | null>; + playSourceRef: RefObject; + pausedAtRatioRef: MutableRefObject; + viewZoom: number; + viewPanRatio: number; + setViewZoom: Dispatch>; + setViewPanRatio: Dispatch>; + setStartRatio: Dispatch>; + setEndRatio: Dispatch>; + teardownAudio: () => void; + continuousInputStrategy: ContinuousInputStrategy; +} + +export const useSoundTrimWaveformSession = ({ + isOpen, + audioBuffer, + waveformHost, + waveformRef, + canvasRef, + audioBufferRef, + startRatioRef, + endRatioRef, + viewStartRef, + viewEndRef, + dragTargetRef, + middleDragCleanupRef, + handleDragCleanupRef, + playSourceRef, + pausedAtRatioRef, + viewZoom, + viewPanRatio, + setViewZoom, + setViewPanRatio, + setStartRatio, + setEndRatio, + teardownAudio, + continuousInputStrategy, +}: UseSoundTrimWaveformSessionOptions): { + handleWaveformPointerDown: (event: ReactPointerEvent) => void; +} => { + const viewZoomRef = useRef(viewZoom); + viewZoomRef.current = viewZoom; + const viewPanRatioRef = useRef(viewPanRatio); + viewPanRatioRef.current = viewPanRatio; + const wheelDeltaRef = useRef(0); + + // 최신 값은 전부 ref로 읽으므로 노드가 붙을 때만 다시 건다. 매 렌더 재등록하면 + // 정리 시 스케줄러가 아직 안 흘린 휠 델타를 버린다 + useEffect(() => { + if (!isOpen) return; + const node = waveformHost; + if (!node) return; + const applyWheel = (event: WheelEvent, deltaY: number) => { + if (!audioBufferRef.current) return; + + const rect = node.getBoundingClientRect(); + const nextViewport = planSoundTrimWheelViewport({ + clientX: event.clientX, + rectLeft: rect.left, + rectWidth: rect.width, + deltaY, + viewZoom: viewZoomRef.current, + viewPanRatio: viewPanRatioRef.current, + }); + setViewZoom(nextViewport.viewZoom); + setViewPanRatio(nextViewport.viewPanRatio); + }; + const scheduler = createRafLatestScheduler((event) => { + const deltaY = wheelDeltaRef.current; + wheelDeltaRef.current = 0; + applyWheel(event, deltaY); + }, continuousInputStrategy); + const handleWheel = (event: WheelEvent) => { + event.preventDefault(); + wheelDeltaRef.current += event.deltaY; + scheduler.push(event); + }; + node.addEventListener('wheel', handleWheel, { passive: false }); + return () => { + scheduler.cancel(); + wheelDeltaRef.current = 0; + node.removeEventListener('wheel', handleWheel); + }; + // ref와 React setter는 안정적이며 기존 노드·전략 재등록 경계 유지 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [continuousInputStrategy, isOpen, waveformHost]); + + // 중간 버튼 드래그: 줌 시 수평 패닝 + const handleMiddleDown = useCallback( + (event: MouseEvent) => { + if (event.button !== 1) return; + if (!audioBufferRef.current) return; + event.preventDefault(); + + const host = waveformRef.current; + if (!host) return; + const rect = host.getBoundingClientRect(); + const drawableWidth = rect.width - WAVEFORM_PAD_X * 2; + + const startClientX = event.clientX; + const startPanRatio = viewPanRatioRef.current; + const viewSpan = 1 / viewZoomRef.current; + + setCustomCursorHover(null); + const canvas = canvasRef.current; + if (canvas) canvas.style.cursor = ''; + host.style.cursor = 'grabbing'; + // 잡는 동안 전역 유지 - 호스트 밖으로 나가도 복귀하지 않게 + beginDragCursor('grabbing'); + + const applyMouseMove = (moveEvent: MouseEvent) => { + setViewPanRatio( + planSoundTrimMiddlePan({ + clientX: moveEvent.clientX, + startClientX, + startPanRatio, + drawableWidth, + viewSpan, + }), + ); + }; + const moveScheduler = createRafLatestScheduler( + applyMouseMove, + continuousInputStrategy, + ); + const handleMouseMove = (moveEvent: MouseEvent) => + moveScheduler.push(moveEvent); + + const cleanup = () => { + moveScheduler.flush(); + moveScheduler.cancel(); + endDragCursor(); + host.style.cursor = ''; + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', cleanup); + window.removeEventListener('blur', cleanup); + window.removeEventListener('pointercancel', cleanup); + middleDragCleanupRef.current = null; + }; + + middleDragCleanupRef.current = cleanup; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', cleanup); + // 드래그 중 포커스 상실·포인터 취소 시에도 커서 복구 + window.addEventListener('blur', cleanup); + window.addEventListener('pointercancel', cleanup); + }, + // ref와 React setter는 안정적이며 기존 전략 교체 경계 유지 + // eslint-disable-next-line react-hooks/exhaustive-deps + [continuousInputStrategy], + ); + + useEffect(() => { + if (!isOpen) return; + const node = waveformHost; + if (!node) return; + node.addEventListener('mousedown', handleMiddleDown); + return () => { + node.removeEventListener('mousedown', handleMiddleDown); + middleDragCleanupRef.current?.(); + }; + // cleanup ref는 안정적이며 기존 재등록 경계 유지 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [handleMiddleDown, isOpen, waveformHost]); + + // 커서 overlay 루트가 모달 포털보다 DOM 순서상 위에 위치하도록 보장 + useEffect(() => { + if (!isOpen) return; + const overlay = document.getElementById('dmn-cursor-overlay'); + if (overlay?.parentNode) { + overlay.parentNode.appendChild(overlay); + } + }, [isOpen]); + + // 캔버스 커서 hover: macOS overlay 커서용 네이티브 addEventListener + useEffect(() => { + if (!isOpen || !audioBuffer) return; + const canvas = canvasRef.current; + const host = waveformHost; + if (!canvas || !host) return; + + const handleMouseMove = (event: MouseEvent) => { + if (dragTargetRef.current || middleDragCleanupRef.current) return; + const rect = host.getBoundingClientRect(); + const x = event.clientX - rect.left; + const { startX, endX } = getSoundTrimHandleGeometry({ + rectWidth: rect.width, + viewStart: viewStartRef.current, + viewEnd: viewEndRef.current, + startRatio: startRatioRef.current, + endRatio: endRatioRef.current, + }); + const nearHandle = isNearSoundTrimHandle(x, startX, endX); + + // macOS: SVG overlay 커서 / Windows: getCursor CSS 폴백 + setCustomCursorHover(nearHandle ? 'ew-resize' : null, event); + canvas.style.cursor = nearHandle ? getCursor('ew-resize') : ''; + }; + + const handleMouseLeave = () => { + if (!dragTargetRef.current && !middleDragCleanupRef.current) { + setCustomCursorHover(null); + canvas.style.cursor = ''; + } + }; + + canvas.addEventListener('mousemove', handleMouseMove); + canvas.addEventListener('mouseleave', handleMouseLeave); + return () => { + canvas.removeEventListener('mousemove', handleMouseMove); + canvas.removeEventListener('mouseleave', handleMouseLeave); + setCustomCursorHover(null); + }; + // ref는 안정적이며 audio/host 교체 시점의 기존 재등록 경계 유지 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen, audioBuffer, waveformHost]); + + const updateFromClientX = (clientX: number, target: SoundTrimDragTarget) => { + const host = waveformRef.current; + if (!host || !target) return; + const rect = host.getBoundingClientRect(); + const ratio = getSoundTrimRatioFromClientX({ + clientX, + rectLeft: rect.left, + rectWidth: rect.width, + viewStart: viewStartRef.current, + viewEnd: viewEndRef.current, + }); + + if (target === 'start') { + setStartRatio(clamp(ratio, 0, endRatioRef.current)); + } else if (target === 'end') { + setEndRatio(clamp(ratio, startRatioRef.current, 1)); + } + }; + + const handlePointerMove = (event: PointerEvent) => { + const target = dragTargetRef.current; + if (!target) return; + updateFromClientX(event.clientX, target); + }; + + const handlePointerUp = () => { + handleDragCleanupRef.current?.(); + }; + + const handleWaveformPointerDown = ( + event: ReactPointerEvent, + ) => { + if (event.button !== 0) return; + if (!audioBuffer) return; + const host = waveformRef.current; + if (!host) return; + + // 핸들 드래그 시 재생 중지 및 일시정지 위치 초기화 + if (playSourceRef.current) { + teardownAudio(); + } + pausedAtRatioRef.current = null; + + const rect = host.getBoundingClientRect(); + const x = clamp(event.clientX - rect.left, 0, rect.width); + const { startX, endX } = getSoundTrimHandleGeometry({ + rectWidth: rect.width, + viewStart: viewStartRef.current, + viewEnd: viewEndRef.current, + startRatio: startRatioRef.current, + endRatio: endRatioRef.current, + }); + const nextTarget = pickSoundTrimDragTarget(x, startX, endX); + + dragTargetRef.current = nextTarget; + lockCustomCursor('ew-resize', event.nativeEvent as unknown as MouseEvent); + updateFromClientX(event.clientX, nextTarget); + + const moveScheduler = createRafLatestScheduler( + handlePointerMove, + continuousInputStrategy, + ); + const registeredMove = (moveEvent: PointerEvent) => + moveScheduler.push(moveEvent); + const registeredUp = handlePointerUp; + + handleDragCleanupRef.current = () => { + moveScheduler.flush(); + moveScheduler.cancel(); + dragTargetRef.current = null; + unlockCustomCursor(); + window.removeEventListener('pointermove', registeredMove); + window.removeEventListener('pointerup', registeredUp); + window.removeEventListener('pointercancel', registeredUp); + window.removeEventListener('blur', registeredUp); + handleDragCleanupRef.current = null; + }; + + window.addEventListener('pointermove', registeredMove); + window.addEventListener('pointerup', registeredUp); + // 드래그 중 포커스 상실·포인터 취소 시에도 커서·드래그 상태 복구 + window.addEventListener('pointercancel', registeredUp); + window.addEventListener('blur', registeredUp); + }; + + return { handleWaveformPointerDown }; +}; From e4578a8db617741ab01d035e5d1a6755c065461a Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 18:50:43 +0900 Subject: [PATCH 198/245] =?UTF-8?q?refactor:=20=EB=84=A4=EC=9D=B4=ED=8B=B0?= =?UTF-8?q?=EB=B8=8C=20=EC=9A=94=EC=86=8C=20v1=20=EC=96=B4=EB=8C=91?= =?UTF-8?q?=ED=84=B0=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/state/native_element_id.rs | 654 +----------------- .../src/state/native_element_id/v1_adapter.rs | 650 +++++++++++++++++ 2 files changed, 655 insertions(+), 649 deletions(-) create mode 100644 src-tauri/src/state/native_element_id/v1_adapter.rs diff --git a/src-tauri/src/state/native_element_id.rs b/src-tauri/src/state/native_element_id.rs index 9b6e7ea80..52697893e 100644 --- a/src-tauri/src/state/native_element_id.rs +++ b/src-tauri/src/state/native_element_id.rs @@ -11,6 +11,11 @@ use crate::{ }, }; +mod v1_adapter; + +use v1_adapter::collect_collection_ids_outside_target; +pub(crate) use v1_adapter::{prepare_commit_patch_element_ids, validate_document_element_ids}; + pub(crate) const INVALID_ELEMENT_ID: &str = "INVALID_ELEMENT_ID"; pub(crate) const MISSING_ELEMENT_ID: &str = "MISSING_ELEMENT_ID"; pub(crate) const DUPLICATE_ELEMENT_ID: &str = "DUPLICATE_ELEMENT_ID"; @@ -317,654 +322,5 @@ pub(crate) fn backfill_mode_element_ids_for_collections( outcome } -// 백필 대상(선택된 컬렉션 × 대상 모드) 밖의 id만 모은다 -fn collect_collection_ids_outside_target( - collection: &HashMap>, - target_mode: &str, - targeted: bool, - ids: &mut HashSet, -) { - for (mode, elements) in collection { - if targeted && mode == target_mode { - continue; - } - for element in elements { - if is_valid_element_id(&element.position().id) { - ids.insert(element.position().id.clone()); - } - } - } -} - -fn validate_supplied_collection_ids( - collection: &HashMap>, - require_id: bool, - seen: &mut HashSet, -) -> Result<(), EditorCommitError> { - for mode in sorted_modes(collection) { - let Some(elements) = collection.get(&mode) else { - continue; - }; - for (index, element) in elements.iter().enumerate() { - let id = &element.position().id; - if id.is_empty() { - if require_id { - return Err(EditorCommitError::validation( - MISSING_ELEMENT_ID, - format!("native element {mode}[{index}] is missing an ID"), - )); - } - continue; - } - if !is_valid_element_id(id) { - return Err(EditorCommitError::validation( - INVALID_ELEMENT_ID, - format!("native element {mode}[{index}] has an invalid ID"), - )); - } - if !seen.insert(id.clone()) { - return Err(EditorCommitError::validation( - DUPLICATE_ELEMENT_ID, - format!("native element ID {id} appears more than once in the commit"), - )); - } - } - } - Ok(()) -} - -fn validate_supplied_patch_ids( - patch: &EditorPatchV1, - require_id: bool, -) -> Result, EditorCommitError> { - let mut seen = HashSet::new(); - if let Some(collection) = patch.key_positions.as_ref() { - validate_supplied_collection_ids(collection, require_id, &mut seen)?; - } - if let Some(collection) = patch.stat_positions.as_ref() { - validate_supplied_collection_ids(collection, require_id, &mut seen)?; - } - if let Some(collection) = patch.graph_positions.as_ref() { - validate_supplied_collection_ids(collection, require_id, &mut seen)?; - } - if let Some(collection) = patch.knob_positions.as_ref() { - validate_supplied_collection_ids(collection, require_id, &mut seen)?; - } - Ok(seen) -} - -// v1은 id를 모르는 입력을 계속 받는 계약이라, 형식이 유효한데 소유가 어긋나거나 -// (다른 컬렉션·삭제된 신원) 패치 안에서 중복된 id는 거부 대신 비워서 뒤의 승계· -// 신규 발급 경로로 넘긴다. 중복은 canonical 자리를 원본으로 보고 사본만 비운다 -fn sanitize_v1_supplied_collection_ids( - candidate: &mut HashMap>, - current: &HashMap>, -) -> Result<(), EditorCommitError> { - let mut owned = HashMap::new(); - for mode in sorted_modes(current) { - let Some(elements) = current.get(&mode) else { - continue; - }; - for (index, element) in elements.iter().enumerate() { - let id = &element.position().id; - if !id.is_empty() { - owned.insert(id.clone(), (mode.clone(), index)); - } - } - } - - let mut occurrences: HashMap> = HashMap::new(); - for mode in sorted_modes(candidate) { - let Some(elements) = candidate.get(&mode) else { - continue; - }; - for (index, element) in elements.iter().enumerate() { - let id = &element.position().id; - if id.is_empty() { - continue; - } - if !is_valid_element_id(id) { - return Err(EditorCommitError::validation( - INVALID_ELEMENT_ID, - format!("native element {mode}[{index}] has an invalid ID"), - )); - } - occurrences - .entry(id.clone()) - .or_default() - .push((mode.clone(), index)); - } - } - - let mut cleared = HashSet::new(); - for (id, slots) in &occurrences { - let Some(canonical_slot) = owned.get(id) else { - cleared.extend(slots.iter().cloned()); - continue; - }; - if slots.len() == 1 { - continue; - } - let keeper = slots - .iter() - .find(|slot| *slot == canonical_slot) - .unwrap_or(&slots[0]); - for slot in slots { - if slot != keeper { - cleared.insert(slot.clone()); - } - } - } - - for (mode, index) in cleared { - if let Some(element) = candidate - .get_mut(&mode) - .and_then(|elements| elements.get_mut(index)) - { - element.position_mut().id.clear(); - } - } - Ok(()) -} - -fn sanitize_v1_supplied_patch_ids( - store: &AppStoreData, - patch: &mut EditorPatchV1, -) -> Result<(), EditorCommitError> { - if let Some(collection) = patch.key_positions.as_mut() { - sanitize_v1_supplied_collection_ids(collection, &store.key_positions)?; - } - if let Some(collection) = patch.stat_positions.as_mut() { - sanitize_v1_supplied_collection_ids(collection, &store.stat_positions)?; - } - if let Some(collection) = patch.graph_positions.as_mut() { - sanitize_v1_supplied_collection_ids(collection, &store.graph_positions)?; - } - if let Some(collection) = patch.knob_positions.as_mut() { - sanitize_v1_supplied_collection_ids(collection, &store.knob_positions)?; - } - Ok(()) -} - -/// id를 비운 값 사본. 비교마다 양쪽을 clone하지 않도록, 스캔 전에 한 번씩만 -/// 만들어 재사용한다 (승계 웨이브의 순서·우선순위는 그대로 유지) -fn value_without_id(element: &T) -> T { - let mut copy = element.clone(); - copy.position_mut().id.clear(); - copy -} - -fn ordered_current_elements(collection: &HashMap>) -> Vec { - let mut elements = Vec::new(); - for mode in sorted_modes(collection) { - if let Some(mode_elements) = collection.get(&mode) { - elements.extend(mode_elements.iter().cloned()); - } - } - elements -} - -fn keep_or_rekey_supplied_ids( - candidate: &mut HashMap>, - canonical_ids: &HashSet, - consumed_current_ids: &mut HashSet, - reserved: &mut HashSet, -) { - for mode in sorted_modes(candidate) { - let Some(elements) = candidate.get_mut(&mode) else { - continue; - }; - for element in elements { - let id = element.position().id.clone(); - if id.is_empty() { - continue; - } - if canonical_ids.contains(&id) { - consumed_current_ids.insert(id); - } else { - element.position_mut().id = new_unique_id(reserved); - } - } - } -} - -// 같은 모드 안에서 빈 ID를 값으로 승계한다. 모드를 넘는 승계는 무관한 모드의 -// 신원을 빼앗으므로 이 패스에서 제외한다 -fn inherit_ids_by_value_within_mode( - current: &HashMap>, - candidate: &mut HashMap>, - consumed_current_ids: &mut HashSet, -) { - for mode in sorted_modes(candidate) { - let Some(elements) = candidate.get_mut(&mode) else { - continue; - }; - let Some(current_elements) = current.get(&mode) else { - continue; - }; - let normalized_current: Vec = current_elements.iter().map(value_without_id).collect(); - for element in elements { - if !element.position().id.is_empty() { - continue; - } - let target = value_without_id(&*element); - let inherited = current_elements - .iter() - .zip(&normalized_current) - .find(|(current_element, normalized)| { - let current_id = ¤t_element.position().id; - !consumed_current_ids.contains(current_id) && **normalized == target - }) - .map(|(current_element, _)| current_element); - if let Some(current_element) = inherited { - let id = current_element.position().id.clone(); - consumed_current_ids.insert(id.clone()); - element.position_mut().id = id; - } - } - } -} - -fn inherit_ids_by_value( - current_elements: &[T], - candidate: &mut HashMap>, - consumed_current_ids: &mut HashSet, - reserved: &mut HashSet, -) { - let normalized_current: Vec = current_elements.iter().map(value_without_id).collect(); - for mode in sorted_modes(candidate) { - let Some(elements) = candidate.get_mut(&mode) else { - continue; - }; - for element in elements { - if !element.position().id.is_empty() { - continue; - } - let target = value_without_id(&*element); - let inherited = current_elements - .iter() - .zip(&normalized_current) - .find(|(current_element, normalized)| { - let current_id = ¤t_element.position().id; - !consumed_current_ids.contains(current_id) && **normalized == target - }) - .map(|(current_element, _)| current_element); - if let Some(current_element) = inherited { - let id = current_element.position().id.clone(); - consumed_current_ids.insert(id.clone()); - element.position_mut().id = id; - } else { - element.position_mut().id = new_unique_id(reserved); - } - } - } -} - -// keys를 동반하지 않는 keyPositions 패치에서만 쓴다. 그 경우 candidate는 -// validate_paired_update가 형태 불변을 강제하므로 같은 길이 = 같은 슬롯 집합의 -// 제자리 편집이고, index가 곧 신원이다(master 의미론). 형태 제약이 없는 -// stat/graph/knob에 쓰면 같은 길이의 삭제+추가에서 새 요소가 삭제된 요소의 -// 신원을 물려받는다 -fn inherit_ids_by_index( - current: &HashMap>, - candidate: &mut HashMap>, - consumed_current_ids: &mut HashSet, -) { - for mode in sorted_modes(candidate) { - let Some(elements) = candidate.get_mut(&mode) else { - continue; - }; - let Some(current_elements) = current.get(&mode) else { - continue; - }; - if current_elements.len() != elements.len() { - continue; - } - for (element, current_element) in elements.iter_mut().zip(current_elements) { - if !element.id.is_empty() { - continue; - } - let id = current_element.id.clone(); - if id.is_empty() || consumed_current_ids.contains(&id) { - continue; - } - consumed_current_ids.insert(id.clone()); - element.id = id; - } - } -} - -fn adapt_v1_collection( - current: &HashMap>, - candidate: &mut HashMap>, - canonical_ids: &HashSet, - consumed_current_ids: &mut HashSet, - reserved: &mut HashSet, -) { - keep_or_rekey_supplied_ids(candidate, canonical_ids, consumed_current_ids, reserved); - // 승계 순서: 같은 모드 값 → 전역 값 폴백. index 승계는 형태가 고정된 - // keyPositions 단독 패치 전용이라 여기서는 쓰지 않는다 - inherit_ids_by_value_within_mode(current, candidate, consumed_current_ids); - inherit_ids_by_value( - &ordered_current_elements(current), - candidate, - consumed_current_ids, - reserved, - ); -} - -struct SlotPairedPosition { - mode: String, - index: usize, - slot: Option, - position: KeyPosition, -} - -fn slot_paired_current_positions( - keys: &KeyMappings, - positions: &HashMap>, -) -> Vec { - let mut pairs = Vec::new(); - for mode in sorted_modes(positions) { - let Some(elements) = positions.get(&mode) else { - continue; - }; - let slots = keys.get(&mode); - for (index, element) in elements.iter().enumerate() { - pairs.push(SlotPairedPosition { - mode: mode.clone(), - index, - slot: slots.and_then(|slots| slots.get(index)).cloned(), - position: element.clone(), - }); - } - } - pairs -} - -// 한 웨이브: 아직 빈 ID인 후보에 조건(슬롯 일치 여부, 같은 모드 한정 여부)을 -// 만족하는 미소진 현재 요소의 ID를 승계한다. 슬롯은 canonical 문자열이 아니라 -// 구조적 동등성으로 비교한다 - Single("A+B")와 Multi([A,B], all)는 canonical이 -// 같아도 다른 슬롯이다 -fn consume_slot_paired_ids( - candidate: &mut HashMap>, - patch_keys: &KeyMappings, - current_pairs: &[SlotPairedPosition], - consumed_current_ids: &mut HashSet, - match_slot: bool, - same_mode_only: bool, -) { - let normalized_pairs: Vec = current_pairs - .iter() - .map(|pair| value_without_id(&pair.position)) - .collect(); - for mode in sorted_modes(candidate) { - let Some(elements) = candidate.get_mut(&mode) else { - continue; - }; - let slots = patch_keys.get(&mode); - for (index, element) in elements.iter_mut().enumerate() { - if !element.id.is_empty() { - continue; - } - let slot = slots.and_then(|slots| slots.get(index)); - if match_slot && slot.is_none() { - continue; - } - let target = value_without_id(&*element); - let inherited = current_pairs - .iter() - .zip(&normalized_pairs) - .find(|(pair, normalized)| { - (!same_mode_only || pair.mode == mode) - && (!match_slot || pair.slot.as_ref() == slot) - && !consumed_current_ids.contains(&pair.position.id) - && **normalized == target - }) - .map(|(pair, _)| pair); - if let Some(pair) = inherited { - let id = pair.position.id.clone(); - consumed_current_ids.insert(id.clone()); - element.id = id; - } - } - } -} - -// 값을 무시하고 같은 모드·같은 index의 슬롯 일치만으로 승계한다. 값 일치를 -// 요구하는 앞 웨이브들이 모두 실패하는 경우 - 슬롯은 그대로인데 위치 값만 -// 바뀐 이동 - 를 담당한다. index를 고정해 중복 슬롯끼리 교차 승계하지 않는다 -fn consume_slot_only_ids( - candidate: &mut HashMap>, - patch_keys: &KeyMappings, - current_pairs: &[SlotPairedPosition], - consumed_current_ids: &mut HashSet, -) { - for mode in sorted_modes(candidate) { - let Some(elements) = candidate.get_mut(&mode) else { - continue; - }; - let slots = patch_keys.get(&mode); - for (index, element) in elements.iter_mut().enumerate() { - if !element.id.is_empty() { - continue; - } - let Some(slot) = slots.and_then(|slots| slots.get(index)) else { - continue; - }; - let inherited = current_pairs.iter().find(|pair| { - pair.mode == mode - && pair.index == index - && pair.slot.as_ref() == Some(slot) - && !consumed_current_ids.contains(&pair.position.id) - }); - if let Some(pair) = inherited { - let id = pair.position.id.clone(); - consumed_current_ids.insert(id.clone()); - element.id = id; - } - } - } -} - -// v1 paired patch는 keys[i]-keyPositions[i] 결합이 신원 단서다. 값이 같은 -// 위치가 여럿일 때 값만으로 승계하면 재정렬에서 ID가 다른 키 슬롯에 붙으므로 -// 같은 모드의 (슬롯, 값) 정확 일치부터 소진하고, 모드 이동·재바인딩은 -// 뒤 웨이브로 미뤄 무관한 모드의 ID를 먼저 빼앗지 않게 한다 -fn adapt_v1_key_position_ids( - store: &AppStoreData, - patch_keys: Option<&KeyMappings>, - candidate: &mut HashMap>, - canonical_ids: &HashSet, - consumed_current_ids: &mut HashSet, - reserved: &mut HashSet, -) { - let Some(patch_keys) = patch_keys else { - // keys 미동반 패치는 형태가 고정된다 - index가 신원이므로 값 승계보다 - // 먼저 제자리 id를 확정해 값 편집이 신원 회전으로 번지지 않게 한다 - keep_or_rekey_supplied_ids(candidate, canonical_ids, consumed_current_ids, reserved); - inherit_ids_by_index(&store.key_positions, candidate, consumed_current_ids); - inherit_ids_by_value_within_mode(&store.key_positions, candidate, consumed_current_ids); - inherit_ids_by_value( - &ordered_current_elements(&store.key_positions), - candidate, - consumed_current_ids, - reserved, - ); - return; - }; - - keep_or_rekey_supplied_ids(candidate, canonical_ids, consumed_current_ids, reserved); - - let current_pairs = slot_paired_current_positions(&store.keys, &store.key_positions); - // 웨이브 순서: 같은 모드 슬롯+값 → 모드 간 슬롯+값(모드 이동) → - // 같은 모드 index 슬롯(값 변경 이동) → 같은 모드 값(재바인딩) → - // 마지막 전역 값 폴백과 신규 발급. - // 슬롯 고정이 우연한 값 일치보다 강한 신원 단서라 값 웨이브보다 앞선다 - - // 그래야 두 키의 좌표를 맞바꾸는 패치가 keys 동반 여부와 무관하게 같은 - // 결과(제자리 값 편집)를 낸다 - for (match_slot, same_mode_only) in [(true, true), (true, false)] { - consume_slot_paired_ids( - candidate, - patch_keys, - ¤t_pairs, - consumed_current_ids, - match_slot, - same_mode_only, - ); - } - consume_slot_only_ids(candidate, patch_keys, ¤t_pairs, consumed_current_ids); - consume_slot_paired_ids( - candidate, - patch_keys, - ¤t_pairs, - consumed_current_ids, - false, - true, - ); - - inherit_ids_by_value_within_mode(&store.key_positions, candidate, consumed_current_ids); - inherit_ids_by_value( - &ordered_current_elements(&store.key_positions), - candidate, - consumed_current_ids, - reserved, - ); -} - -fn adapt_v1_patch_ids( - store: &AppStoreData, - patch: &mut EditorPatchV1, -) -> Result<(), EditorCommitError> { - sanitize_v1_supplied_patch_ids(store, patch)?; - // 정화 후에는 남은 id가 모두 자기 컬렉션 소유이자 유일 - 이 호출은 수집과 - // 불변식 확인을 겸한다 - let supplied_ids = validate_supplied_patch_ids(patch, false)?; - let canonical_ids = collect_store_ids(store); - let mut consumed_current_ids = supplied_ids - .iter() - .filter(|id| canonical_ids.contains(*id)) - .cloned() - .collect::>(); - let mut reserved = canonical_ids.clone(); - reserved.extend(supplied_ids); - - let patch_keys = patch.keys.clone(); - if let Some(collection) = patch.key_positions.as_mut() { - adapt_v1_key_position_ids( - store, - patch_keys.as_ref(), - collection, - &canonical_ids, - &mut consumed_current_ids, - &mut reserved, - ); - } - if let Some(collection) = patch.stat_positions.as_mut() { - adapt_v1_collection( - &store.stat_positions, - collection, - &canonical_ids, - &mut consumed_current_ids, - &mut reserved, - ); - } - if let Some(collection) = patch.graph_positions.as_mut() { - adapt_v1_collection( - &store.graph_positions, - collection, - &canonical_ids, - &mut consumed_current_ids, - &mut reserved, - ); - } - if let Some(collection) = patch.knob_positions.as_mut() { - adapt_v1_collection( - &store.knob_positions, - collection, - &canonical_ids, - &mut consumed_current_ids, - &mut reserved, - ); - } - Ok(()) -} - -fn validate_document_collection_ids( - collection: &HashMap>, - seen: &mut HashSet, -) -> Result<(), EditorCommitError> { - for mode in sorted_modes(collection) { - let Some(elements) = collection.get(&mode) else { - continue; - }; - for (index, element) in elements.iter().enumerate() { - let id = &element.position().id; - if id.is_empty() { - return Err(EditorCommitError::validation( - MISSING_ELEMENT_ID, - format!("native element {mode}[{index}] is missing an ID"), - )); - } - if !is_valid_element_id(id) { - return Err(EditorCommitError::validation( - INVALID_ELEMENT_ID, - format!("native element {mode}[{index}] has an invalid ID"), - )); - } - if !seen.insert(id.clone()) { - return Err(EditorCommitError::validation( - DUPLICATE_ELEMENT_ID, - format!("native element ID {id} is not globally unique"), - )); - } - } - } - Ok(()) -} - -pub(crate) fn validate_document_element_ids( - document: &EditorDocumentV1, -) -> Result<(), EditorCommitError> { - let mut seen = HashSet::new(); - validate_document_collection_ids(&document.key_positions, &mut seen)?; - validate_document_collection_ids(&document.stat_positions, &mut seen)?; - validate_document_collection_ids(&document.graph_positions, &mut seen)?; - validate_document_collection_ids(&document.knob_positions, &mut seen) -} - -fn patch_includes_native_elements(patch: &EditorPatchV1) -> bool { - patch.key_positions.is_some() - || patch.stat_positions.is_some() - || patch.graph_positions.is_some() - || patch.knob_positions.is_some() -} - -pub(crate) fn prepare_commit_patch_element_ids( - store: &AppStoreData, - patch: &mut EditorPatchV1, -) -> Result<(), EditorCommitError> { - if !patch_includes_native_elements(patch) { - return Ok(()); - } - - match patch.schema_version { - EDITOR_SCHEMA_VERSION => adapt_v1_patch_ids(store, patch)?, - EDITOR_COMMIT_SCHEMA_VERSION_V2 => { - validate_supplied_patch_ids(patch, true)?; - } - _ => { - return Err(EditorCommitError::validation( - "UNSUPPORTED_SCHEMA_VERSION", - format!("unsupported editor schema version {}", patch.schema_version), - )); - } - } - - let mut candidate = EditorDocumentV1::from_store(store); - candidate.apply_patch(patch); - validate_document_element_ids(&candidate) -} - #[cfg(test)] mod tests; diff --git a/src-tauri/src/state/native_element_id/v1_adapter.rs b/src-tauri/src/state/native_element_id/v1_adapter.rs new file mode 100644 index 000000000..dd8252086 --- /dev/null +++ b/src-tauri/src/state/native_element_id/v1_adapter.rs @@ -0,0 +1,650 @@ +use super::*; + +// 백필 대상(선택된 컬렉션 × 대상 모드) 밖의 id만 모은다 +pub(super) fn collect_collection_ids_outside_target( + collection: &HashMap>, + target_mode: &str, + targeted: bool, + ids: &mut HashSet, +) { + for (mode, elements) in collection { + if targeted && mode == target_mode { + continue; + } + for element in elements { + if is_valid_element_id(&element.position().id) { + ids.insert(element.position().id.clone()); + } + } + } +} + +fn validate_supplied_collection_ids( + collection: &HashMap>, + require_id: bool, + seen: &mut HashSet, +) -> Result<(), EditorCommitError> { + for mode in sorted_modes(collection) { + let Some(elements) = collection.get(&mode) else { + continue; + }; + for (index, element) in elements.iter().enumerate() { + let id = &element.position().id; + if id.is_empty() { + if require_id { + return Err(EditorCommitError::validation( + MISSING_ELEMENT_ID, + format!("native element {mode}[{index}] is missing an ID"), + )); + } + continue; + } + if !is_valid_element_id(id) { + return Err(EditorCommitError::validation( + INVALID_ELEMENT_ID, + format!("native element {mode}[{index}] has an invalid ID"), + )); + } + if !seen.insert(id.clone()) { + return Err(EditorCommitError::validation( + DUPLICATE_ELEMENT_ID, + format!("native element ID {id} appears more than once in the commit"), + )); + } + } + } + Ok(()) +} + +fn validate_supplied_patch_ids( + patch: &EditorPatchV1, + require_id: bool, +) -> Result, EditorCommitError> { + let mut seen = HashSet::new(); + if let Some(collection) = patch.key_positions.as_ref() { + validate_supplied_collection_ids(collection, require_id, &mut seen)?; + } + if let Some(collection) = patch.stat_positions.as_ref() { + validate_supplied_collection_ids(collection, require_id, &mut seen)?; + } + if let Some(collection) = patch.graph_positions.as_ref() { + validate_supplied_collection_ids(collection, require_id, &mut seen)?; + } + if let Some(collection) = patch.knob_positions.as_ref() { + validate_supplied_collection_ids(collection, require_id, &mut seen)?; + } + Ok(seen) +} + +// v1은 id를 모르는 입력을 계속 받는 계약이라, 형식이 유효한데 소유가 어긋나거나 +// (다른 컬렉션·삭제된 신원) 패치 안에서 중복된 id는 거부 대신 비워서 뒤의 승계· +// 신규 발급 경로로 넘긴다. 중복은 canonical 자리를 원본으로 보고 사본만 비운다 +fn sanitize_v1_supplied_collection_ids( + candidate: &mut HashMap>, + current: &HashMap>, +) -> Result<(), EditorCommitError> { + let mut owned = HashMap::new(); + for mode in sorted_modes(current) { + let Some(elements) = current.get(&mode) else { + continue; + }; + for (index, element) in elements.iter().enumerate() { + let id = &element.position().id; + if !id.is_empty() { + owned.insert(id.clone(), (mode.clone(), index)); + } + } + } + + let mut occurrences: HashMap> = HashMap::new(); + for mode in sorted_modes(candidate) { + let Some(elements) = candidate.get(&mode) else { + continue; + }; + for (index, element) in elements.iter().enumerate() { + let id = &element.position().id; + if id.is_empty() { + continue; + } + if !is_valid_element_id(id) { + return Err(EditorCommitError::validation( + INVALID_ELEMENT_ID, + format!("native element {mode}[{index}] has an invalid ID"), + )); + } + occurrences + .entry(id.clone()) + .or_default() + .push((mode.clone(), index)); + } + } + + let mut cleared = HashSet::new(); + for (id, slots) in &occurrences { + let Some(canonical_slot) = owned.get(id) else { + cleared.extend(slots.iter().cloned()); + continue; + }; + if slots.len() == 1 { + continue; + } + let keeper = slots + .iter() + .find(|slot| *slot == canonical_slot) + .unwrap_or(&slots[0]); + for slot in slots { + if slot != keeper { + cleared.insert(slot.clone()); + } + } + } + + for (mode, index) in cleared { + if let Some(element) = candidate + .get_mut(&mode) + .and_then(|elements| elements.get_mut(index)) + { + element.position_mut().id.clear(); + } + } + Ok(()) +} + +fn sanitize_v1_supplied_patch_ids( + store: &AppStoreData, + patch: &mut EditorPatchV1, +) -> Result<(), EditorCommitError> { + if let Some(collection) = patch.key_positions.as_mut() { + sanitize_v1_supplied_collection_ids(collection, &store.key_positions)?; + } + if let Some(collection) = patch.stat_positions.as_mut() { + sanitize_v1_supplied_collection_ids(collection, &store.stat_positions)?; + } + if let Some(collection) = patch.graph_positions.as_mut() { + sanitize_v1_supplied_collection_ids(collection, &store.graph_positions)?; + } + if let Some(collection) = patch.knob_positions.as_mut() { + sanitize_v1_supplied_collection_ids(collection, &store.knob_positions)?; + } + Ok(()) +} + +/// id를 비운 값 사본. 비교마다 양쪽을 clone하지 않도록, 스캔 전에 한 번씩만 +/// 만들어 재사용한다 (승계 웨이브의 순서·우선순위는 그대로 유지) +fn value_without_id(element: &T) -> T { + let mut copy = element.clone(); + copy.position_mut().id.clear(); + copy +} + +fn ordered_current_elements(collection: &HashMap>) -> Vec { + let mut elements = Vec::new(); + for mode in sorted_modes(collection) { + if let Some(mode_elements) = collection.get(&mode) { + elements.extend(mode_elements.iter().cloned()); + } + } + elements +} + +fn keep_or_rekey_supplied_ids( + candidate: &mut HashMap>, + canonical_ids: &HashSet, + consumed_current_ids: &mut HashSet, + reserved: &mut HashSet, +) { + for mode in sorted_modes(candidate) { + let Some(elements) = candidate.get_mut(&mode) else { + continue; + }; + for element in elements { + let id = element.position().id.clone(); + if id.is_empty() { + continue; + } + if canonical_ids.contains(&id) { + consumed_current_ids.insert(id); + } else { + element.position_mut().id = new_unique_id(reserved); + } + } + } +} + +// 같은 모드 안에서 빈 ID를 값으로 승계한다. 모드를 넘는 승계는 무관한 모드의 +// 신원을 빼앗으므로 이 패스에서 제외한다 +fn inherit_ids_by_value_within_mode( + current: &HashMap>, + candidate: &mut HashMap>, + consumed_current_ids: &mut HashSet, +) { + for mode in sorted_modes(candidate) { + let Some(elements) = candidate.get_mut(&mode) else { + continue; + }; + let Some(current_elements) = current.get(&mode) else { + continue; + }; + let normalized_current: Vec = current_elements.iter().map(value_without_id).collect(); + for element in elements { + if !element.position().id.is_empty() { + continue; + } + let target = value_without_id(&*element); + let inherited = current_elements + .iter() + .zip(&normalized_current) + .find(|(current_element, normalized)| { + let current_id = ¤t_element.position().id; + !consumed_current_ids.contains(current_id) && **normalized == target + }) + .map(|(current_element, _)| current_element); + if let Some(current_element) = inherited { + let id = current_element.position().id.clone(); + consumed_current_ids.insert(id.clone()); + element.position_mut().id = id; + } + } + } +} + +fn inherit_ids_by_value( + current_elements: &[T], + candidate: &mut HashMap>, + consumed_current_ids: &mut HashSet, + reserved: &mut HashSet, +) { + let normalized_current: Vec = current_elements.iter().map(value_without_id).collect(); + for mode in sorted_modes(candidate) { + let Some(elements) = candidate.get_mut(&mode) else { + continue; + }; + for element in elements { + if !element.position().id.is_empty() { + continue; + } + let target = value_without_id(&*element); + let inherited = current_elements + .iter() + .zip(&normalized_current) + .find(|(current_element, normalized)| { + let current_id = ¤t_element.position().id; + !consumed_current_ids.contains(current_id) && **normalized == target + }) + .map(|(current_element, _)| current_element); + if let Some(current_element) = inherited { + let id = current_element.position().id.clone(); + consumed_current_ids.insert(id.clone()); + element.position_mut().id = id; + } else { + element.position_mut().id = new_unique_id(reserved); + } + } + } +} + +// keys를 동반하지 않는 keyPositions 패치에서만 쓴다. 그 경우 candidate는 +// validate_paired_update가 형태 불변을 강제하므로 같은 길이 = 같은 슬롯 집합의 +// 제자리 편집이고, index가 곧 신원이다(master 의미론). 형태 제약이 없는 +// stat/graph/knob에 쓰면 같은 길이의 삭제+추가에서 새 요소가 삭제된 요소의 +// 신원을 물려받는다 +fn inherit_ids_by_index( + current: &HashMap>, + candidate: &mut HashMap>, + consumed_current_ids: &mut HashSet, +) { + for mode in sorted_modes(candidate) { + let Some(elements) = candidate.get_mut(&mode) else { + continue; + }; + let Some(current_elements) = current.get(&mode) else { + continue; + }; + if current_elements.len() != elements.len() { + continue; + } + for (element, current_element) in elements.iter_mut().zip(current_elements) { + if !element.id.is_empty() { + continue; + } + let id = current_element.id.clone(); + if id.is_empty() || consumed_current_ids.contains(&id) { + continue; + } + consumed_current_ids.insert(id.clone()); + element.id = id; + } + } +} + +fn adapt_v1_collection( + current: &HashMap>, + candidate: &mut HashMap>, + canonical_ids: &HashSet, + consumed_current_ids: &mut HashSet, + reserved: &mut HashSet, +) { + keep_or_rekey_supplied_ids(candidate, canonical_ids, consumed_current_ids, reserved); + // 승계 순서: 같은 모드 값 → 전역 값 폴백. index 승계는 형태가 고정된 + // keyPositions 단독 패치 전용이라 여기서는 쓰지 않는다 + inherit_ids_by_value_within_mode(current, candidate, consumed_current_ids); + inherit_ids_by_value( + &ordered_current_elements(current), + candidate, + consumed_current_ids, + reserved, + ); +} + +struct SlotPairedPosition { + mode: String, + index: usize, + slot: Option, + position: KeyPosition, +} + +fn slot_paired_current_positions( + keys: &KeyMappings, + positions: &HashMap>, +) -> Vec { + let mut pairs = Vec::new(); + for mode in sorted_modes(positions) { + let Some(elements) = positions.get(&mode) else { + continue; + }; + let slots = keys.get(&mode); + for (index, element) in elements.iter().enumerate() { + pairs.push(SlotPairedPosition { + mode: mode.clone(), + index, + slot: slots.and_then(|slots| slots.get(index)).cloned(), + position: element.clone(), + }); + } + } + pairs +} + +// 한 웨이브: 아직 빈 ID인 후보에 조건(슬롯 일치 여부, 같은 모드 한정 여부)을 +// 만족하는 미소진 현재 요소의 ID를 승계한다. 슬롯은 canonical 문자열이 아니라 +// 구조적 동등성으로 비교한다 - Single("A+B")와 Multi([A,B], all)는 canonical이 +// 같아도 다른 슬롯이다 +fn consume_slot_paired_ids( + candidate: &mut HashMap>, + patch_keys: &KeyMappings, + current_pairs: &[SlotPairedPosition], + consumed_current_ids: &mut HashSet, + match_slot: bool, + same_mode_only: bool, +) { + let normalized_pairs: Vec = current_pairs + .iter() + .map(|pair| value_without_id(&pair.position)) + .collect(); + for mode in sorted_modes(candidate) { + let Some(elements) = candidate.get_mut(&mode) else { + continue; + }; + let slots = patch_keys.get(&mode); + for (index, element) in elements.iter_mut().enumerate() { + if !element.id.is_empty() { + continue; + } + let slot = slots.and_then(|slots| slots.get(index)); + if match_slot && slot.is_none() { + continue; + } + let target = value_without_id(&*element); + let inherited = current_pairs + .iter() + .zip(&normalized_pairs) + .find(|(pair, normalized)| { + (!same_mode_only || pair.mode == mode) + && (!match_slot || pair.slot.as_ref() == slot) + && !consumed_current_ids.contains(&pair.position.id) + && **normalized == target + }) + .map(|(pair, _)| pair); + if let Some(pair) = inherited { + let id = pair.position.id.clone(); + consumed_current_ids.insert(id.clone()); + element.id = id; + } + } + } +} + +// 값을 무시하고 같은 모드·같은 index의 슬롯 일치만으로 승계한다. 값 일치를 +// 요구하는 앞 웨이브들이 모두 실패하는 경우 - 슬롯은 그대로인데 위치 값만 +// 바뀐 이동 - 를 담당한다. index를 고정해 중복 슬롯끼리 교차 승계하지 않는다 +fn consume_slot_only_ids( + candidate: &mut HashMap>, + patch_keys: &KeyMappings, + current_pairs: &[SlotPairedPosition], + consumed_current_ids: &mut HashSet, +) { + for mode in sorted_modes(candidate) { + let Some(elements) = candidate.get_mut(&mode) else { + continue; + }; + let slots = patch_keys.get(&mode); + for (index, element) in elements.iter_mut().enumerate() { + if !element.id.is_empty() { + continue; + } + let Some(slot) = slots.and_then(|slots| slots.get(index)) else { + continue; + }; + let inherited = current_pairs.iter().find(|pair| { + pair.mode == mode + && pair.index == index + && pair.slot.as_ref() == Some(slot) + && !consumed_current_ids.contains(&pair.position.id) + }); + if let Some(pair) = inherited { + let id = pair.position.id.clone(); + consumed_current_ids.insert(id.clone()); + element.id = id; + } + } + } +} + +// v1 paired patch는 keys[i]-keyPositions[i] 결합이 신원 단서다. 값이 같은 +// 위치가 여럿일 때 값만으로 승계하면 재정렬에서 ID가 다른 키 슬롯에 붙으므로 +// 같은 모드의 (슬롯, 값) 정확 일치부터 소진하고, 모드 이동·재바인딩은 +// 뒤 웨이브로 미뤄 무관한 모드의 ID를 먼저 빼앗지 않게 한다 +fn adapt_v1_key_position_ids( + store: &AppStoreData, + patch_keys: Option<&KeyMappings>, + candidate: &mut HashMap>, + canonical_ids: &HashSet, + consumed_current_ids: &mut HashSet, + reserved: &mut HashSet, +) { + let Some(patch_keys) = patch_keys else { + // keys 미동반 패치는 형태가 고정된다 - index가 신원이므로 값 승계보다 + // 먼저 제자리 id를 확정해 값 편집이 신원 회전으로 번지지 않게 한다 + keep_or_rekey_supplied_ids(candidate, canonical_ids, consumed_current_ids, reserved); + inherit_ids_by_index(&store.key_positions, candidate, consumed_current_ids); + inherit_ids_by_value_within_mode(&store.key_positions, candidate, consumed_current_ids); + inherit_ids_by_value( + &ordered_current_elements(&store.key_positions), + candidate, + consumed_current_ids, + reserved, + ); + return; + }; + + keep_or_rekey_supplied_ids(candidate, canonical_ids, consumed_current_ids, reserved); + + let current_pairs = slot_paired_current_positions(&store.keys, &store.key_positions); + // 웨이브 순서: 같은 모드 슬롯+값 → 모드 간 슬롯+값(모드 이동) → + // 같은 모드 index 슬롯(값 변경 이동) → 같은 모드 값(재바인딩) → + // 마지막 전역 값 폴백과 신규 발급. + // 슬롯 고정이 우연한 값 일치보다 강한 신원 단서라 값 웨이브보다 앞선다 - + // 그래야 두 키의 좌표를 맞바꾸는 패치가 keys 동반 여부와 무관하게 같은 + // 결과(제자리 값 편집)를 낸다 + for (match_slot, same_mode_only) in [(true, true), (true, false)] { + consume_slot_paired_ids( + candidate, + patch_keys, + ¤t_pairs, + consumed_current_ids, + match_slot, + same_mode_only, + ); + } + consume_slot_only_ids(candidate, patch_keys, ¤t_pairs, consumed_current_ids); + consume_slot_paired_ids( + candidate, + patch_keys, + ¤t_pairs, + consumed_current_ids, + false, + true, + ); + + inherit_ids_by_value_within_mode(&store.key_positions, candidate, consumed_current_ids); + inherit_ids_by_value( + &ordered_current_elements(&store.key_positions), + candidate, + consumed_current_ids, + reserved, + ); +} + +fn adapt_v1_patch_ids( + store: &AppStoreData, + patch: &mut EditorPatchV1, +) -> Result<(), EditorCommitError> { + sanitize_v1_supplied_patch_ids(store, patch)?; + // 정화 후에는 남은 id가 모두 자기 컬렉션 소유이자 유일 - 이 호출은 수집과 + // 불변식 확인을 겸한다 + let supplied_ids = validate_supplied_patch_ids(patch, false)?; + let canonical_ids = collect_store_ids(store); + let mut consumed_current_ids = supplied_ids + .iter() + .filter(|id| canonical_ids.contains(*id)) + .cloned() + .collect::>(); + let mut reserved = canonical_ids.clone(); + reserved.extend(supplied_ids); + + let patch_keys = patch.keys.clone(); + if let Some(collection) = patch.key_positions.as_mut() { + adapt_v1_key_position_ids( + store, + patch_keys.as_ref(), + collection, + &canonical_ids, + &mut consumed_current_ids, + &mut reserved, + ); + } + if let Some(collection) = patch.stat_positions.as_mut() { + adapt_v1_collection( + &store.stat_positions, + collection, + &canonical_ids, + &mut consumed_current_ids, + &mut reserved, + ); + } + if let Some(collection) = patch.graph_positions.as_mut() { + adapt_v1_collection( + &store.graph_positions, + collection, + &canonical_ids, + &mut consumed_current_ids, + &mut reserved, + ); + } + if let Some(collection) = patch.knob_positions.as_mut() { + adapt_v1_collection( + &store.knob_positions, + collection, + &canonical_ids, + &mut consumed_current_ids, + &mut reserved, + ); + } + Ok(()) +} + +fn validate_document_collection_ids( + collection: &HashMap>, + seen: &mut HashSet, +) -> Result<(), EditorCommitError> { + for mode in sorted_modes(collection) { + let Some(elements) = collection.get(&mode) else { + continue; + }; + for (index, element) in elements.iter().enumerate() { + let id = &element.position().id; + if id.is_empty() { + return Err(EditorCommitError::validation( + MISSING_ELEMENT_ID, + format!("native element {mode}[{index}] is missing an ID"), + )); + } + if !is_valid_element_id(id) { + return Err(EditorCommitError::validation( + INVALID_ELEMENT_ID, + format!("native element {mode}[{index}] has an invalid ID"), + )); + } + if !seen.insert(id.clone()) { + return Err(EditorCommitError::validation( + DUPLICATE_ELEMENT_ID, + format!("native element ID {id} is not globally unique"), + )); + } + } + } + Ok(()) +} + +pub(crate) fn validate_document_element_ids( + document: &EditorDocumentV1, +) -> Result<(), EditorCommitError> { + let mut seen = HashSet::new(); + validate_document_collection_ids(&document.key_positions, &mut seen)?; + validate_document_collection_ids(&document.stat_positions, &mut seen)?; + validate_document_collection_ids(&document.graph_positions, &mut seen)?; + validate_document_collection_ids(&document.knob_positions, &mut seen) +} + +fn patch_includes_native_elements(patch: &EditorPatchV1) -> bool { + patch.key_positions.is_some() + || patch.stat_positions.is_some() + || patch.graph_positions.is_some() + || patch.knob_positions.is_some() +} + +pub(crate) fn prepare_commit_patch_element_ids( + store: &AppStoreData, + patch: &mut EditorPatchV1, +) -> Result<(), EditorCommitError> { + if !patch_includes_native_elements(patch) { + return Ok(()); + } + + match patch.schema_version { + EDITOR_SCHEMA_VERSION => adapt_v1_patch_ids(store, patch)?, + EDITOR_COMMIT_SCHEMA_VERSION_V2 => { + validate_supplied_patch_ids(patch, true)?; + } + _ => { + return Err(EditorCommitError::validation( + "UNSUPPORTED_SCHEMA_VERSION", + format!("unsupported editor schema version {}", patch.schema_version), + )); + } + } + + let mut candidate = EditorDocumentV1::from_store(store); + candidate.apply_patch(patch); + validate_document_element_ids(&candidate) +} From d2989ac28dc90cebd0d0c3d089747a1d68121738 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 18:55:01 +0900 Subject: [PATCH 199/245] =?UTF-8?q?test:=20=ED=94=8C=EB=9F=AC=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=9A=94=EC=86=8C=20=EC=B8=A1=EC=A0=95=20=EC=88=98?= =?UTF-8?q?=EB=AA=85=EC=A3=BC=EA=B8=B0=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PluginElement.measurementRuntime.test.tsx | 411 ++++++++++++++++++ 1 file changed, 411 insertions(+) create mode 100644 src/renderer/components/shared/PluginElement.measurementRuntime.test.tsx diff --git a/src/renderer/components/shared/PluginElement.measurementRuntime.test.tsx b/src/renderer/components/shared/PluginElement.measurementRuntime.test.tsx new file mode 100644 index 000000000..db1274da3 --- /dev/null +++ b/src/renderer/components/shared/PluginElement.measurementRuntime.test.tsx @@ -0,0 +1,411 @@ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { PluginElement } from '@components/shared/PluginElement'; +import { I18nContext } from '@contexts/I18nContextDef'; +import { useGraphItemStore } from '@stores/data/useGraphItemStore'; +import { useKeyStore } from '@stores/data/useKeyStore'; +import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; +import { useStatItemStore } from '@stores/data/useStatItemStore'; +import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; +import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; +import type { + PluginDefinitionInternal, + PluginDisplayElementInternal, +} from '@src/types/plugin/api'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const measurementMocks = vi.hoisted(() => ({ + attachInteractions: vi.fn(), + detachInteractions: [] as Array>, + measure: vi.fn(), + updateElement: vi.fn(), +})); + +vi.mock('@utils/plugin/pluginElementMeasurement', async (importOriginal) => ({ + ...(await importOriginal< + typeof import('@utils/plugin/pluginElementMeasurement') + >()), + measureConnectedPluginElement: measurementMocks.measure, +})); + +vi.mock('@utils/plugin/pluginDomInteractions', () => ({ + attachPluginDomInteractions: measurementMocks.attachInteractions, +})); + +const DEFINITION_ID = 'measurement-plugin'; +const FULL_ID = `${DEFINITION_ID}::11111111-1111-4111-8111-111111111111`; +const originalPluginStore = usePluginDisplayElementStore.getState(); + +const makeElement = ( + updates: Partial = {}, +): PluginDisplayElementInternal => + ({ + id: FULL_ID.split('::')[1], + fullId: FULL_ID, + pluginId: DEFINITION_ID, + definitionId: DEFINITION_ID, + position: { x: 10, y: 20 }, + resizeAnchor: 'top-left', + settings: { mode: 'a' }, + state: { label: 'a' }, + tabId: '4key', + ...updates, + } as PluginDisplayElementInternal); + +const makeDefinition = ( + updates: Partial = {}, +): PluginDefinitionInternal => ({ + id: DEFINITION_ID, + pluginId: DEFINITION_ID, + name: 'Measurement Plugin', + template: (state) => `${String(state.label)}`, + ...updates, +}); + +const seedBaseStores = () => { + useKeyStore.setState({ + selectedKeyType: '4key', + positions: { '4key': [] }, + canonicalPositions: { '4key': [] }, + }); + useStatItemStore.setState({ positions: {} }); + useGraphItemStore.setState({ positions: {} }); + useKnobItemStore.setState({ positions: {} }); + useLayerGroupStore.setState({ layerGroups: {} }); + useGridSelectionStore.setState({ + selectedElements: [], + selectedGroupIds: [], + lastSelectedKeyBounds: null, + }); +}; + +describe('PluginElement 측정·설정 크기 수명주기', () => { + let container: HTMLDivElement; + let root: Root; + let frameId: number; + let frameCallbacks: Map; + let cancelAnimationFrameMock: ReturnType; + + const renderElement = ( + element: PluginDisplayElementInternal, + definition: PluginDefinitionInternal, + zoom = 1, + ): HTMLDivElement => { + act(() => { + usePluginDisplayElementStore.setState({ + elements: [element], + definitions: new Map([[definition.id, definition]]), + updateElement: measurementMocks.updateElement, + }); + root.render( + {}, t: (key) => key }} + > + + , + ); + }); + const node = container.querySelector( + `[data-plugin-element="${FULL_ID}"]`, + ); + expect(node).not.toBeNull(); + return node!; + }; + + const runLatestFrame = () => { + const id = [...frameCallbacks.keys()].at(-1); + expect(id).toBeDefined(); + const callback = frameCallbacks.get(id!); + frameCallbacks.delete(id!); + act(() => callback?.(performance.now())); + return id!; + }; + + beforeEach(() => { + vi.clearAllMocks(); + measurementMocks.detachInteractions.length = 0; + measurementMocks.attachInteractions.mockImplementation(() => { + const detach = vi.fn(); + measurementMocks.detachInteractions.push(detach); + return detach; + }); + measurementMocks.measure.mockReturnValue({ width: 100, height: 50 }); + frameId = 0; + frameCallbacks = new Map(); + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + const id = ++frameId; + frameCallbacks.set(id, callback); + return id; + }), + ); + cancelAnimationFrameMock = vi.fn((id: number) => { + frameCallbacks.delete(id); + }); + vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrameMock); + + window.__dmn_window_type = 'main'; + usePluginDisplayElementStore.setState({ + elements: [], + definitions: new Map(), + updateElement: measurementMocks.updateElement, + updateElementBatched: originalPluginStore.updateElementBatched, + }); + seedBaseStores(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + usePluginDisplayElementStore.setState({ + elements: [], + definitions: new Map(), + updateElement: originalPluginStore.updateElement, + updateElementBatched: originalPluginStore.updateElementBatched, + }); + delete window.__dmn_window_type; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('첫 측정은 저장하고 동일 크기 재측정은 update를 생략한다', () => { + const definition = makeDefinition(); + const initial = makeElement(); + renderElement(initial, definition); + runLatestFrame(); + + expect(measurementMocks.updateElement).toHaveBeenCalledWith(FULL_ID, { + measuredSize: { width: 100, height: 50 }, + }); + + measurementMocks.updateElement.mockClear(); + renderElement( + makeElement({ measuredSize: { width: 100, height: 50 } }), + definition, + ); + runLatestFrame(); + + expect(measurementMocks.measure).toHaveBeenCalledTimes(2); + expect(measurementMocks.updateElement).not.toHaveBeenCalled(); + }); + + it('settings A→B→A에서 각 설정의 마지막 크기를 복원한다', () => { + const definition = makeDefinition({ + resizable: true, + preserveAxis: 'none', + }); + renderElement( + makeElement({ measuredSize: { width: 100, height: 50 } }), + definition, + ); + + measurementMocks.measure.mockReturnValueOnce({ width: 140, height: 80 }); + renderElement( + makeElement({ + settings: { mode: 'b' }, + measuredSize: { width: 100, height: 50 }, + }), + definition, + ); + runLatestFrame(); + + renderElement( + makeElement({ + settings: { mode: 'b' }, + measuredSize: { width: 140, height: 80 }, + }), + definition, + ); + measurementMocks.updateElement.mockClear(); + renderElement( + makeElement({ + settings: { mode: 'a' }, + measuredSize: { width: 140, height: 80 }, + }), + definition, + ); + + expect(measurementMocks.updateElement).toHaveBeenCalledOnce(); + expect(measurementMocks.updateElement).toHaveBeenCalledWith(FULL_ID, { + measuredSize: { width: 100, height: 50 }, + width: 100, + height: 50, + position: { x: 10, y: 20 }, + }); + }); + + it.each([ + ['none', 'auto', 'auto', 140, 80, true], + ['width', '100px', 'auto', 100, 80, true], + ['height', 'auto', '50px', 140, 50, true], + ['both', '100px', '50px', 100, 50, false], + ] as const)( + 'preserveAxis=%s는 측정 제약과 최종 축 크기를 보존한다', + ( + preserveAxis, + measuredWidthStyle, + measuredHeightStyle, + width, + height, + shouldUpdate, + ) => { + const definition = makeDefinition({ resizable: true, preserveAxis }); + const node = renderElement( + makeElement({ measuredSize: { width: 100, height: 50 } }), + definition, + ); + let widthDuringMeasure = ''; + let heightDuringMeasure = ''; + measurementMocks.measure.mockImplementationOnce((target: HTMLElement) => { + widthDuringMeasure = target.style.width; + heightDuringMeasure = target.style.height; + return { width: 140, height: 80 }; + }); + + renderElement( + makeElement({ + settings: { mode: 'b' }, + measuredSize: { width: 100, height: 50 }, + }), + definition, + ); + runLatestFrame(); + + expect(widthDuringMeasure).toBe(measuredWidthStyle); + expect(heightDuringMeasure).toBe(measuredHeightStyle); + expect(node.style.width).toBe('100px'); + expect(node.style.height).toBe('50px'); + if (shouldUpdate) { + expect(measurementMocks.updateElement).toHaveBeenLastCalledWith( + FULL_ID, + { measuredSize: { width, height } }, + ); + } else { + expect(measurementMocks.updateElement).not.toHaveBeenCalled(); + } + }, + ); + + it('콘텐츠 크기 변경은 resize anchor offset과 크기를 한 update에 반영한다', () => { + const definition = makeDefinition({ + resizable: true, + preserveAxis: 'none', + resizeAnchor: 'bottom-right', + }); + renderElement( + makeElement({ + resizeAnchor: 'bottom-right', + measuredSize: { width: 100, height: 50 }, + }), + definition, + ); + measurementMocks.measure.mockReturnValueOnce({ width: 140, height: 80 }); + + renderElement( + makeElement({ + resizeAnchor: 'bottom-right', + settings: { mode: 'b' }, + measuredSize: { width: 100, height: 50 }, + }), + definition, + ); + runLatestFrame(); + + expect(measurementMocks.updateElement).toHaveBeenLastCalledWith(FULL_ID, { + position: { x: -30, y: -10 }, + measuredSize: { width: 140, height: 80 }, + }); + }); + + it('zoom만 바뀐 측정은 anchor 위치 보정을 생략한다', () => { + const definition = makeDefinition({ resizeAnchor: 'bottom-right' }); + const element = makeElement({ + resizeAnchor: 'bottom-right', + measuredSize: { width: 100, height: 50 }, + }); + renderElement(element, definition, 1); + runLatestFrame(); + measurementMocks.updateElement.mockClear(); + measurementMocks.measure.mockReturnValueOnce({ width: 140, height: 80 }); + + renderElement(element, definition, 2); + runLatestFrame(); + + expect(measurementMocks.updateElement).toHaveBeenCalledWith(FULL_ID, { + measuredSize: { width: 140, height: 80 }, + }); + }); + + it('renderedContent 변경은 동일 target을 다시 측정한다', () => { + const definition = makeDefinition(); + const initial = makeElement({ measuredSize: { width: 100, height: 50 } }); + renderElement(initial, definition); + runLatestFrame(); + measurementMocks.updateElement.mockClear(); + measurementMocks.measure.mockReturnValueOnce({ width: 120, height: 60 }); + + renderElement( + makeElement({ + measuredSize: initial.measuredSize, + state: { label: 'b' }, + }), + definition, + ); + runLatestFrame(); + + expect(measurementMocks.measure).toHaveBeenCalledTimes(2); + expect(measurementMocks.updateElement).toHaveBeenCalledWith(FULL_ID, { + measuredSize: { width: 120, height: 60 }, + }); + }); + + it('scoped 전환은 container interaction을 detach하고 shadow root에 다시 attach한다', () => { + const definition = makeDefinition({ resizable: true }); + const node = renderElement( + makeElement({ measuredSize: { width: 100, height: 50 } }), + definition, + ); + const containerDetach = measurementMocks.detachInteractions[0]; + expect(measurementMocks.attachInteractions).toHaveBeenLastCalledWith(node); + + renderElement( + makeElement({ + scoped: true, + measuredSize: { width: 100, height: 50 }, + }), + definition, + ); + + expect(containerDetach).toHaveBeenCalledOnce(); + expect(node.shadowRoot).not.toBeNull(); + expect(measurementMocks.attachInteractions).toHaveBeenLastCalledWith( + node.shadowRoot, + ); + }); + + it('pending 측정 cleanup은 RAF cancel→interaction detach→새 attach 순서를 유지한다', () => { + const definition = makeDefinition(); + const initial = makeElement(); + renderElement(initial, definition); + const pendingFrame = [...frameCallbacks.keys()].at(-1)!; + const firstDetach = measurementMocks.detachInteractions[0]; + + renderElement(makeElement({ state: { label: 'b' } }), definition, 2); + + expect(cancelAnimationFrameMock).toHaveBeenCalledWith(pendingFrame); + expect(cancelAnimationFrameMock.mock.invocationCallOrder[0]).toBeLessThan( + firstDetach.mock.invocationCallOrder[0], + ); + expect(firstDetach.mock.invocationCallOrder[0]).toBeLessThan( + measurementMocks.attachInteractions.mock.invocationCallOrder[1], + ); + }); +}); From bd42e00d53e9680d47c53a0c8767f9fe53ac9f3d Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 18:55:01 +0900 Subject: [PATCH 200/245] =?UTF-8?q?refactor:=20=ED=94=8C=EB=9F=AC=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=9A=94=EC=86=8C=20=EC=B8=A1=EC=A0=95=20=EB=9F=B0?= =?UTF-8?q?=ED=83=80=EC=9E=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/shared/PluginElement.tsx | 325 +--------------- .../usePluginElementMeasurementRuntime.ts | 353 ++++++++++++++++++ 2 files changed, 362 insertions(+), 316 deletions(-) create mode 100644 src/renderer/components/shared/usePluginElementMeasurementRuntime.ts diff --git a/src/renderer/components/shared/PluginElement.tsx b/src/renderer/components/shared/PluginElement.tsx index e733bfb47..2cb438fa6 100644 --- a/src/renderer/components/shared/PluginElement.tsx +++ b/src/renderer/components/shared/PluginElement.tsx @@ -3,7 +3,6 @@ import { createPortal } from 'react-dom'; import { isMac } from '@utils/core/platform'; import { PluginDisplayElementInternal, - ElementResizeAnchor, DisplayElementTemplateHelpers, } from '@src/types/plugin/api'; import { useDraggable } from '@hooks/Grid'; @@ -21,7 +20,6 @@ import { useKeyStore } from '@stores/data/useKeyStore'; import { useTranslation } from '@contexts/useTranslation'; import { html, styleMap, css } from '@utils/core/templateEngine'; import { translatePluginMessage } from '@utils/plugin/pluginI18n'; -import { measureConnectedPluginElement } from '@utils/plugin/pluginElementMeasurement'; import { beginPluginInstancesEditSession, endPluginInstancesEditSession, @@ -30,11 +28,10 @@ import { expandGroupSelectionFromStores } from '@utils/grid/groupSelection'; import { buildPluginElementStyle, buildPluginOverlayHitStyle, - calculatePluginAnchorOffset, resolvePluginElementPosition, } from '@utils/plugin/pluginElementLayout'; -import { attachPluginDomInteractions } from '@utils/plugin/pluginDomInteractions'; import { usePluginElementContextMenu } from './usePluginElementContextMenu'; +import { usePluginElementMeasurementRuntime } from './usePluginElementMeasurementRuntime'; import { usePluginElementOverlayRuntime } from './usePluginElementOverlayRuntime'; const DEFAULT_POSITION_OFFSET = { x: 0, y: 0 }; @@ -115,47 +112,6 @@ const PluginElementImpl: React.FC = ({ fallback, }); - // 이전 크기를 추적하여 리사이즈 앵커 기반 위치 보정에 사용 - // 초기값으로 element.measuredSize를 사용하여 리로드 후에도 올바르게 동작 - const prevMeasuredSizeRef = useRef<{ width: number; height: number } | null>( - element.measuredSize ? { ...element.measuredSize } : null, - ); - - // 이전 앵커를 추적하여 앵커 변경 시 prevMeasuredSizeRef 리셋 - const prevAnchorRef = useRef( - element.resizeAnchor || definition?.resizeAnchor || 'top-left', - ); - - // 이전 줌 값을 추적하여 줌 변경 시 위치 보정을 스킵 - const prevZoomRef = useRef(zoom); - - // 앵커가 변경되면 prevMeasuredSizeRef를 현재 크기로 리셋 - // 이렇게 하면 앵커 변경 직후의 크기 변화에서 불필요한 위치 보정이 발생하지 않음 - useEffect(() => { - const currentAnchor = - element.resizeAnchor || definition?.resizeAnchor || 'top-left'; - if (prevAnchorRef.current !== currentAnchor) { - // 앵커가 변경됨 - 현재 측정된 크기로 리셋 - if (element.measuredSize) { - prevMeasuredSizeRef.current = { ...element.measuredSize }; - } - prevAnchorRef.current = currentAnchor; - } - }, [element.resizeAnchor, definition?.resizeAnchor, element.measuredSize]); - - // element.measuredSize가 외부에서 변경될 때(리사이즈 등) userPreservedSizeRef 업데이트 - // 단, needsRemeasure 상태가 아닐 때만 (설정 변경으로 인한 재측정 중에는 스킵) - useEffect(() => { - if ( - windowType === 'main' && - definition?.resizable && - element.measuredSize && - !needsRemeasureRef.current - ) { - userPreservedSizeRef.current = { ...element.measuredSize }; - } - }, [windowType, definition?.resizable, element.measuredSize]); - useEffect(() => { localeRef.current = locale; }, [locale]); @@ -232,106 +188,6 @@ const PluginElementImpl: React.FC = ({ } }, [windowType, element.settings]); - // Settings 변경 시 measuredSize 리셋 (main 윈도우, resizable 요소만) - // 설정 변경으로 UI가 변할 수 있으므로 새로 측정하도록 함 - const prevSettingsForResizeRef = useRef | null>(null); - // 설정 변경으로 재측정이 필요한 상태인지 플래그 - const needsRemeasureRef = useRef(false); - // 사용자가 설정한(또는 초기 측정된) preserveAxis 축의 크기 - // 이 값은 리사이즈나 초기 측정 시에만 업데이트되고, 설정 변경 시에는 유지됨 - const userPreservedSizeRef = useRef<{ - width: number; - height: number; - } | null>(null); - // 설정별 크기 히스토리 (설정 JSON -> 크기 매핑) - const settingsSizeHistoryRef = useRef< - Map - >(new Map()); - - useEffect(() => { - if (windowType !== 'main') return; - if (!definition?.resizable) return; - - const currentSettings = element.settings || {}; - const prevSettings = prevSettingsForResizeRef.current; - - // 최초 마운트 시에는 이전 설정 저장만 - if (prevSettings === null) { - prevSettingsForResizeRef.current = { ...currentSettings }; - // 초기 설정에 대한 크기 저장 - if (element.measuredSize) { - const settingsKey = JSON.stringify(currentSettings); - settingsSizeHistoryRef.current.set(settingsKey, { - ...element.measuredSize, - }); - } - return; - } - - // 설정이 실제로 변경되었는지 확인 (JSON 문자열 비교) - const currentStr = JSON.stringify(currentSettings); - const prevStr = JSON.stringify(prevSettings); - const hasChanged = currentStr !== prevStr; - - if (hasChanged) { - // 이전 설정에 대한 현재 크기 저장 (나중에 복원용) - if (element.measuredSize) { - settingsSizeHistoryRef.current.set(prevStr, { - ...element.measuredSize, - }); - } - - // 현재 설정에 대해 저장된 크기가 있으면 복원, 없으면 재측정 - const savedSize = settingsSizeHistoryRef.current.get(currentStr); - if (savedSize) { - // 저장된 크기로 즉시 복원 (width, height도 함께 업데이트) - const currentSize = element.measuredSize; - const resizeAnchor: ElementResizeAnchor = - element.resizeAnchor || definition?.resizeAnchor || 'top-left'; - - // 앵커 기반 위치 보정 계산 - let newPosition = element.position; - if (currentSize && resizeAnchor !== 'top-left') { - const { dx, dy } = calculatePluginAnchorOffset( - resizeAnchor, - currentSize, - savedSize, - ); - if (dx !== 0 || dy !== 0) { - newPosition = { - x: element.position.x + dx, - y: element.position.y + dy, - }; - } - } - - updateElement(element.fullId, { - measuredSize: savedSize, - width: savedSize.width, - height: savedSize.height, - position: newPosition, - }); - prevMeasuredSizeRef.current = savedSize; - } else { - // 재측정 필요 플래그 설정 - needsRemeasureRef.current = true; - } - - // 이전 설정 업데이트 - prevSettingsForResizeRef.current = { ...currentSettings }; - } - }, [ - windowType, - definition?.resizable, - definition?.resizeAnchor, - element.settings, - element.fullId, - element.measuredSize, - element.position, - element.resizeAnchor, - updateElement, - ]); - const calculatedPosition = resolvePluginElementPosition({ element, positions, @@ -488,179 +344,16 @@ const PluginElementImpl: React.FC = ({ return null; })(); - // 이벤트 위임 (메인 윈도우에서만) - useEffect(() => { - const target = element.scoped ? shadowRoot : containerRef.current; - if (!target) return; - - let measurementFrame: number | null = null; - - // 메인 윈도우에서만 실제 크기 측정 후 store 업데이트 - // resizable인 경우: 이미 measuredSize가 있고 재측정이 필요하지 않으면 스킵 - const isResizableWithSize = - definition?.resizable && - element.measuredSize && - !needsRemeasureRef.current; - - if (windowType === 'main' && containerRef.current && !isResizableWithSize) { - const measurementTarget = containerRef.current; - measurementFrame = requestAnimationFrame(() => { - measurementFrame = null; - if (containerRef.current === measurementTarget) { - // 재측정이 필요한 경우, 일시적으로 크기 제약을 풀어 자연스러운 콘텐츠 크기 측정 - const needsRemeasure = - needsRemeasureRef.current && definition?.resizable; - const preserveAxis = definition?.preserveAxis || 'both'; - - let originalWidth = ''; - let originalHeight = ''; - - if (needsRemeasure) { - originalWidth = containerRef.current.style.width; - originalHeight = containerRef.current.style.height; - - // preserveAxis에 따라 해제할 축 결정 - if (preserveAxis !== 'width' && preserveAxis !== 'both') { - containerRef.current.style.width = 'auto'; - } - if (preserveAxis !== 'height' && preserveAxis !== 'both') { - containerRef.current.style.height = 'auto'; - } - } - - const measuredSize = measureConnectedPluginElement( - measurementTarget, - zoom, - ); - - // 스타일 복원 - if (needsRemeasure) { - containerRef.current.style.width = originalWidth; - containerRef.current.style.height = originalHeight; - } - - if (!measuredSize) return; - - const measuredWidth = measuredSize.width; - const measuredHeight = measuredSize.height; - - // 설정 변경으로 인한 재측정인 경우, preserveAxis에 해당하는 축은 유지 - let finalWidth = measuredWidth; - let finalHeight = measuredHeight; - const userPreservedSize = userPreservedSizeRef.current; - - if (definition?.resizable && needsRemeasureRef.current) { - // preserveAxis에 따라 각 축 유지 여부 결정 - const shouldPreserveWidth = - preserveAxis === 'width' || preserveAxis === 'both'; - const shouldPreserveHeight = - preserveAxis === 'height' || preserveAxis === 'both'; - - // 가로: 유지 설정이고 저장된 값이 있으면 그 값 사용 - if (shouldPreserveWidth && userPreservedSize) { - finalWidth = userPreservedSize.width; - } - // 세로: 유지 설정이고 저장된 값이 있으면 그 값 사용 - if (shouldPreserveHeight && userPreservedSize) { - finalHeight = userPreservedSize.height; - } - needsRemeasureRef.current = false; - } - - // 초기 측정 시 또는 콘텐츠가 커진 경우 userPreservedSizeRef 업데이트 - if (!userPreservedSize) { - userPreservedSizeRef.current = { - width: finalWidth, - height: finalHeight, - }; - } - - const newSize = { width: finalWidth, height: finalHeight }; - - // 줌이 변경되었는지 확인 - const zoomChanged = prevZoomRef.current !== zoom; - prevZoomRef.current = zoom; - - // 현재 크기와 이전 크기 비교 - const prevSize = prevMeasuredSizeRef.current; - const sizeChanged = - !element.measuredSize || - element.measuredSize.width !== finalWidth || - element.measuredSize.height !== finalHeight; - - if (sizeChanged) { - // 리사이즈 앵커 결정 (우선순위: element > definition > default) - const resizeAnchor: ElementResizeAnchor = - element.resizeAnchor || definition?.resizeAnchor || 'top-left'; - - // 줌 변경으로 인한 크기 측정 차이는 위치 보정하지 않음 - // 실제 콘텐츠 변화에 의한 크기 변경만 위치 보정 - const shouldAdjustPosition = - prevSize && resizeAnchor !== 'top-left' && !zoomChanged; - - if (shouldAdjustPosition) { - const { dx, dy } = calculatePluginAnchorOffset( - resizeAnchor, - prevSize, - newSize, - ); - - if (dx !== 0 || dy !== 0) { - // 위치와 크기를 함께 업데이트 - updateElement(element.fullId, { - position: { - x: element.position.x + dx, - y: element.position.y + dy, - }, - measuredSize: newSize, - }); - } else { - updateElement(element.fullId, { - measuredSize: newSize, - }); - } - } else { - // 첫 측정이거나 top-left 앵커이거나 줌 변경인 경우 크기만 업데이트 - updateElement(element.fullId, { - measuredSize: newSize, - }); - } - - // 이전 크기 저장 - prevMeasuredSizeRef.current = newSize; - } - } - }); - } - - // data-plugin-handler 이벤트 위임 (메인 윈도우에서만) - if (windowType === 'main') { - const detachInteractions = attachPluginDomInteractions(target); - - return () => { - if (measurementFrame !== null) { - cancelAnimationFrame(measurementFrame); - } - detachInteractions(); - }; - } - - return undefined; - }, [ - element.scoped, - element.fullId, - element.position, - element.resizeAnchor, - updateElement, - windowType, + usePluginElementMeasurementRuntime({ + containerRef, shadowRoot, - renderedContent, // 컨텐츠 변경 시 크기 재측정 + renderedContent, + element, + definition, + windowType, zoom, - definition?.preserveAxis, - definition?.resizable, - definition?.resizeAnchor, - element.measuredSize, - ]); + updateElement, + }); usePluginElementOverlayRuntime({ windowType, diff --git a/src/renderer/components/shared/usePluginElementMeasurementRuntime.ts b/src/renderer/components/shared/usePluginElementMeasurementRuntime.ts new file mode 100644 index 000000000..fa5796809 --- /dev/null +++ b/src/renderer/components/shared/usePluginElementMeasurementRuntime.ts @@ -0,0 +1,353 @@ +import { useEffect, useRef } from 'react'; +import type { RefObject } from 'react'; +import type { + ElementResizeAnchor, + PluginDefinitionInternal, + PluginDisplayElementInternal, +} from '@src/types/plugin/api'; +import { measureConnectedPluginElement } from '@utils/plugin/pluginElementMeasurement'; +import { calculatePluginAnchorOffset } from '@utils/plugin/pluginElementLayout'; +import { attachPluginDomInteractions } from '@utils/plugin/pluginDomInteractions'; + +interface UsePluginElementMeasurementRuntimeOptions { + containerRef: RefObject; + shadowRoot: ShadowRoot | null; + renderedContent: unknown; + element: PluginDisplayElementInternal; + definition: PluginDefinitionInternal | undefined; + windowType: 'main' | 'overlay'; + zoom: number; + updateElement: ( + fullId: string, + updates: Partial, + options?: { skipSync?: boolean }, + ) => void; +} + +export const usePluginElementMeasurementRuntime = ({ + containerRef, + shadowRoot, + renderedContent, + element, + definition, + windowType, + zoom, + updateElement, +}: UsePluginElementMeasurementRuntimeOptions): void => { + // 이전 크기를 추적하여 리사이즈 앵커 기반 위치 보정에 사용 + // 초기값으로 element.measuredSize를 사용하여 리로드 후에도 올바르게 동작 + const prevMeasuredSizeRef = useRef<{ width: number; height: number } | null>( + element.measuredSize ? { ...element.measuredSize } : null, + ); + + // 이전 앵커를 추적하여 앵커 변경 시 prevMeasuredSizeRef 리셋 + const prevAnchorRef = useRef( + element.resizeAnchor || definition?.resizeAnchor || 'top-left', + ); + + // 이전 줌 값을 추적하여 줌 변경 시 위치 보정을 스킵 + const prevZoomRef = useRef(zoom); + + // Settings 변경 시 measuredSize 리셋 (main 윈도우, resizable 요소만) + // 설정 변경으로 UI가 변할 수 있으므로 새로 측정하도록 함 + const prevSettingsForResizeRef = useRef | null>(null); + // 설정 변경으로 재측정이 필요한 상태인지 플래그 + const needsRemeasureRef = useRef(false); + // 사용자가 설정한(또는 초기 측정된) preserveAxis 축의 크기 + // 이 값은 리사이즈나 초기 측정 시에만 업데이트되고, 설정 변경 시에는 유지됨 + const userPreservedSizeRef = useRef<{ + width: number; + height: number; + } | null>(null); + // 설정별 크기 히스토리 (설정 JSON -> 크기 매핑) + const settingsSizeHistoryRef = useRef< + Map + >(new Map()); + + // 앵커가 변경되면 prevMeasuredSizeRef를 현재 크기로 리셋 + // 이렇게 하면 앵커 변경 직후의 크기 변화에서 불필요한 위치 보정이 발생하지 않음 + useEffect(() => { + const currentAnchor = + element.resizeAnchor || definition?.resizeAnchor || 'top-left'; + if (prevAnchorRef.current !== currentAnchor) { + // 앵커가 변경됨 - 현재 측정된 크기로 리셋 + if (element.measuredSize) { + prevMeasuredSizeRef.current = { ...element.measuredSize }; + } + prevAnchorRef.current = currentAnchor; + } + }, [element.resizeAnchor, definition?.resizeAnchor, element.measuredSize]); + + // element.measuredSize가 외부에서 변경될 때(리사이즈 등) userPreservedSizeRef 업데이트 + // 단, needsRemeasure 상태가 아닐 때만 (설정 변경으로 인한 재측정 중에는 스킵) + useEffect(() => { + if ( + windowType === 'main' && + definition?.resizable && + element.measuredSize && + !needsRemeasureRef.current + ) { + userPreservedSizeRef.current = { ...element.measuredSize }; + } + }, [windowType, definition?.resizable, element.measuredSize]); + + useEffect(() => { + if (windowType !== 'main') return; + if (!definition?.resizable) return; + + const currentSettings = element.settings || {}; + const prevSettings = prevSettingsForResizeRef.current; + + // 최초 마운트 시에는 이전 설정 저장만 + if (prevSettings === null) { + prevSettingsForResizeRef.current = { ...currentSettings }; + // 초기 설정에 대한 크기 저장 + if (element.measuredSize) { + const settingsKey = JSON.stringify(currentSettings); + settingsSizeHistoryRef.current.set(settingsKey, { + ...element.measuredSize, + }); + } + return; + } + + // 설정이 실제로 변경되었는지 확인 (JSON 문자열 비교) + const currentStr = JSON.stringify(currentSettings); + const prevStr = JSON.stringify(prevSettings); + const hasChanged = currentStr !== prevStr; + + if (hasChanged) { + // 이전 설정에 대한 현재 크기 저장 (나중에 복원용) + if (element.measuredSize) { + settingsSizeHistoryRef.current.set(prevStr, { + ...element.measuredSize, + }); + } + + // 현재 설정에 대해 저장된 크기가 있으면 복원, 없으면 재측정 + const savedSize = settingsSizeHistoryRef.current.get(currentStr); + if (savedSize) { + // 저장된 크기로 즉시 복원 (width, height도 함께 업데이트) + const currentSize = element.measuredSize; + const resizeAnchor: ElementResizeAnchor = + element.resizeAnchor || definition?.resizeAnchor || 'top-left'; + + // 앵커 기반 위치 보정 계산 + let newPosition = element.position; + if (currentSize && resizeAnchor !== 'top-left') { + const { dx, dy } = calculatePluginAnchorOffset( + resizeAnchor, + currentSize, + savedSize, + ); + if (dx !== 0 || dy !== 0) { + newPosition = { + x: element.position.x + dx, + y: element.position.y + dy, + }; + } + } + + updateElement(element.fullId, { + measuredSize: savedSize, + width: savedSize.width, + height: savedSize.height, + position: newPosition, + }); + prevMeasuredSizeRef.current = savedSize; + } else { + // 재측정 필요 플래그 설정 + needsRemeasureRef.current = true; + } + + // 이전 설정 업데이트 + prevSettingsForResizeRef.current = { ...currentSettings }; + } + }, [ + windowType, + definition?.resizable, + definition?.resizeAnchor, + element.settings, + element.fullId, + element.measuredSize, + element.position, + element.resizeAnchor, + updateElement, + ]); + + // 이벤트 위임 (메인 윈도우에서만) + useEffect(() => { + const target = element.scoped ? shadowRoot : containerRef.current; + if (!target) return; + + let measurementFrame: number | null = null; + + // 메인 윈도우에서만 실제 크기 측정 후 store 업데이트 + // resizable인 경우: 이미 measuredSize가 있고 재측정이 필요하지 않으면 스킵 + const isResizableWithSize = + definition?.resizable && + element.measuredSize && + !needsRemeasureRef.current; + + if (windowType === 'main' && containerRef.current && !isResizableWithSize) { + const measurementTarget = containerRef.current; + measurementFrame = requestAnimationFrame(() => { + measurementFrame = null; + if (containerRef.current === measurementTarget) { + // 재측정이 필요한 경우, 일시적으로 크기 제약을 풀어 자연스러운 콘텐츠 크기 측정 + const needsRemeasure = + needsRemeasureRef.current && definition?.resizable; + const preserveAxis = definition?.preserveAxis || 'both'; + + let originalWidth = ''; + let originalHeight = ''; + + if (needsRemeasure) { + originalWidth = containerRef.current.style.width; + originalHeight = containerRef.current.style.height; + + // preserveAxis에 따라 해제할 축 결정 + if (preserveAxis !== 'width' && preserveAxis !== 'both') { + containerRef.current.style.width = 'auto'; + } + if (preserveAxis !== 'height' && preserveAxis !== 'both') { + containerRef.current.style.height = 'auto'; + } + } + + const measuredSize = measureConnectedPluginElement( + measurementTarget, + zoom, + ); + + // 스타일 복원 + if (needsRemeasure) { + containerRef.current.style.width = originalWidth; + containerRef.current.style.height = originalHeight; + } + + if (!measuredSize) return; + + const measuredWidth = measuredSize.width; + const measuredHeight = measuredSize.height; + + // 설정 변경으로 인한 재측정인 경우, preserveAxis에 해당하는 축은 유지 + let finalWidth = measuredWidth; + let finalHeight = measuredHeight; + const userPreservedSize = userPreservedSizeRef.current; + + if (definition?.resizable && needsRemeasureRef.current) { + // preserveAxis에 따라 각 축 유지 여부 결정 + const shouldPreserveWidth = + preserveAxis === 'width' || preserveAxis === 'both'; + const shouldPreserveHeight = + preserveAxis === 'height' || preserveAxis === 'both'; + + // 가로: 유지 설정이고 저장된 값이 있으면 그 값 사용 + if (shouldPreserveWidth && userPreservedSize) { + finalWidth = userPreservedSize.width; + } + // 세로: 유지 설정이고 저장된 값이 있으면 그 값 사용 + if (shouldPreserveHeight && userPreservedSize) { + finalHeight = userPreservedSize.height; + } + needsRemeasureRef.current = false; + } + + // 초기 측정 시 또는 콘텐츠가 커진 경우 userPreservedSizeRef 업데이트 + if (!userPreservedSize) { + userPreservedSizeRef.current = { + width: finalWidth, + height: finalHeight, + }; + } + + const newSize = { width: finalWidth, height: finalHeight }; + + // 줌이 변경되었는지 확인 + const zoomChanged = prevZoomRef.current !== zoom; + prevZoomRef.current = zoom; + + // 현재 크기와 이전 크기 비교 + const prevSize = prevMeasuredSizeRef.current; + const sizeChanged = + !element.measuredSize || + element.measuredSize.width !== finalWidth || + element.measuredSize.height !== finalHeight; + + if (sizeChanged) { + // 리사이즈 앵커 결정 (우선순위: element > definition > default) + const resizeAnchor: ElementResizeAnchor = + element.resizeAnchor || definition?.resizeAnchor || 'top-left'; + + // 줌 변경으로 인한 크기 측정 차이는 위치 보정하지 않음 + // 실제 콘텐츠 변화에 의한 크기 변경만 위치 보정 + const shouldAdjustPosition = + prevSize && resizeAnchor !== 'top-left' && !zoomChanged; + + if (shouldAdjustPosition) { + const { dx, dy } = calculatePluginAnchorOffset( + resizeAnchor, + prevSize, + newSize, + ); + + if (dx !== 0 || dy !== 0) { + // 위치와 크기를 함께 업데이트 + updateElement(element.fullId, { + position: { + x: element.position.x + dx, + y: element.position.y + dy, + }, + measuredSize: newSize, + }); + } else { + updateElement(element.fullId, { + measuredSize: newSize, + }); + } + } else { + // 첫 측정이거나 top-left 앵커이거나 줌 변경인 경우 크기만 업데이트 + updateElement(element.fullId, { + measuredSize: newSize, + }); + } + + // 이전 크기 저장 + prevMeasuredSizeRef.current = newSize; + } + } + }); + } + + // data-plugin-handler 이벤트 위임 (메인 윈도우에서만) + if (windowType === 'main') { + const detachInteractions = attachPluginDomInteractions(target); + + return () => { + if (measurementFrame !== null) { + cancelAnimationFrame(measurementFrame); + } + detachInteractions(); + }; + } + + return undefined; + // 전달된 container ref identity는 안정적이며 기존 재등록 경계 유지 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + element.scoped, + element.fullId, + element.position, + element.resizeAnchor, + updateElement, + windowType, + shadowRoot, + renderedContent, // 컨텐츠 변경 시 크기 재측정 + zoom, + definition?.preserveAxis, + definition?.resizable, + definition?.resizeAnchor, + element.measuredSize, + ]); +}; From 5bec6dd35cb2de848ead67e37b43d56c14e7f6fc Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 18:59:02 +0900 Subject: [PATCH 201/245] =?UTF-8?q?test:=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=A0=95=EA=B7=9C=ED=99=94=20?= =?UTF-8?q?=EC=88=98=EB=A0=B4=20=EA=B3=84=EC=95=BD=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/state/migration/tests.rs | 63 ++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/src-tauri/src/state/migration/tests.rs b/src-tauri/src/state/migration/tests.rs index 7a65b387a..e852bfe52 100644 --- a/src-tauri/src/state/migration/tests.rs +++ b/src-tauri/src/state/migration/tests.rs @@ -876,6 +876,69 @@ fn noncanonical_gradient_store_repersist_and_reload_is_idempotent() { let _ = std::fs::remove_file(path); } +#[test] +fn combined_normalization_pipeline_preserves_order_and_converges_after_one_persist() { + let path = std::env::temp_dir().join(format!( + "dmnote-combined-normalization-order-{}.json", + uuid::Uuid::new_v4() + )); + let mut data = normalize_state(AppStoreData { + keys: default_keys().clone(), + key_positions: default_positions().clone(), + ..AppStoreData::default() + }); + crate::state::native_element_id::backfill_store_element_ids(&mut data); + let preserved_id = data.key_positions["4key"][0].id.clone(); + + data.selected_key_type = "missing-mode".to_string(); + data.keys.get_mut("4key").unwrap().push(KeySlot::from("F5")); + data.key_counters + .entry("4key".to_string()) + .or_default() + .insert("STALE".to_string(), 99); + let position = &mut data.key_positions.get_mut("4key").unwrap()[0]; + position.group_id = Some("missing-group".to_string()); + position.font_color = Some(" ".to_string()); + position.image_mode = Some(crate::models::ImageMode::Replace); + position.background_color = Some("#BADBAD".to_string()); + position.background_gradient = serde_json::from_value(serde_json::json!({ + "angle": 90, + "stops": [ + { "color": "#112233", "pos": 0 }, + { "color": "#445566", "pos": 1 } + ] + })) + .unwrap(); + std::fs::write(&path, serde_json::to_vec_pretty(&data).unwrap()).unwrap(); + + let first = load_store_from_path(&path).unwrap(); + assert!(first.needs_persist); + assert!(first.repaired); + assert_eq!(first.data.selected_key_type, "4key"); + assert_eq!(first.data.key_positions["4key"][0].id, preserved_id); + assert_eq!( + first.data.keys["4key"].len(), + first.data.key_positions["4key"].len() + ); + assert!(crate::state::native_element_id::is_valid_element_id( + &first.data.key_positions["4key"].last().unwrap().id + )); + let position = &first.data.key_positions["4key"][0]; + assert!(position.group_id.is_none()); + assert!(position.font_color.is_none()); + assert!(position.image_mode.is_none()); + assert_eq!(position.background_color.as_deref(), Some("#112233")); + assert!(!first.data.key_counters["4key"].contains_key("STALE")); + assert_eq!(first.data.key_counters["4key"]["F5"], 0); + + std::fs::write(&path, serde_json::to_vec_pretty(&first.data).unwrap()).unwrap(); + let second = load_store_from_path(&path).unwrap(); + assert!(!second.needs_persist); + assert!(!second.repaired); + assert_eq!(second.data, first.data); + let _ = std::fs::remove_file(path); +} + #[test] fn blank_font_color_normalization_requests_one_canonical_persist() { let path = std::env::temp_dir().join(format!( From cdc85f57384929025a6c1572afce23ad997db890 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 18:59:02 +0900 Subject: [PATCH 202/245] =?UTF-8?q?refactor:=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=A0=95=EA=B7=9C=ED=99=94=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/state/migration.rs | 644 +----------------- .../src/state/migration/normalization.rs | 623 +++++++++++++++++ 2 files changed, 644 insertions(+), 623 deletions(-) create mode 100644 src-tauri/src/state/migration/normalization.rs diff --git a/src-tauri/src/state/migration.rs b/src-tauri/src/state/migration.rs index da5cbb851..211460411 100644 --- a/src-tauri/src/state/migration.rs +++ b/src-tauri/src/state/migration.rs @@ -30,7 +30,10 @@ use crate::{ }, }; +use super::{editor, native_element_id}; + mod assets; +mod normalization; mod recovery; #[cfg(test)] @@ -44,6 +47,24 @@ pub(crate) use assets::{ }; use assets::{parse_portable_asset_reference, AssetCategory}; #[cfg(test)] +use normalization::rgba_to_hex; +pub(crate) use normalization::{ + canonicalize_gradient_pairs, canonicalize_image_modes, clear_dangling_group_ids, + normalize_state, +}; +#[allow(unused_imports)] +pub(crate) use normalization::{ + clear_dangling_group_ids_in, migrate_legacy_font_weight_state, pad_key_position_lengths, +}; +use normalization::{ + current_unix_millis, default_store_note_gradient_multipliers, + has_convertible_note_border_color, has_explicit_invalid_element_id, + has_legacy_font_weight_state, has_valid_selected_key_type, key_position_lengths_mismatch, + migrate_legacy_knob_sensitivity, migrate_sound_library_enabled, normalize_blank_font_colors, + remove_legacy_panel_detach_setting, repair_editor_revision, repair_image_transforms, + repair_semantic_identities, +}; +#[cfg(test)] use recovery::{ recover_key_mapping_entries, recover_local_font_enabled, recover_sound_library_entries, }; @@ -204,124 +225,6 @@ pub(crate) fn load_store_from_path(path: &Path) -> Result { }) } -fn default_store_note_gradient_multipliers(value: &mut Value) -> bool { - let mut changed = false; - for collection in POSITION_COLLECTION_FIELDS { - let Some(modes) = value.get_mut(collection).and_then(Value::as_object_mut) else { - continue; - }; - for position in modes.values_mut().filter_map(Value::as_array_mut).flatten() { - changed |= default_missing_note_gradient_multipliers(position); - } - } - changed -} - -fn has_explicit_invalid_element_id(value: &Value) -> bool { - POSITION_COLLECTION_FIELDS - .into_iter() - .filter_map(|field| value.get(field).and_then(Value::as_object)) - .flat_map(|modes| modes.values()) - .filter_map(Value::as_array) - .flatten() - .filter_map(Value::as_object) - .filter_map(|element| element.get("id")) - .any(|id| { - id.as_str() - .is_none_or(|id| !super::native_element_id::is_valid_element_id(id)) - }) -} - -fn current_unix_millis() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)) - .unwrap_or(0) -} - -fn migrate_sound_library_enabled(value: &mut Value) -> bool { - let Some(sound_library) = value.get_mut("soundLibrary").and_then(Value::as_object_mut) else { - return false; - }; - - let mut changed = false; - for entry in sound_library.values_mut().filter_map(Value::as_object_mut) { - let Some(enabled) = entry.remove("enabled") else { - continue; - }; - - changed = true; - if !entry.contains_key("hidden") { - if let Some(enabled) = enabled.as_bool() { - entry.insert("hidden".to_string(), Value::Bool(!enabled)); - } - } - } - changed -} - -/// 레거시 노브 민감도 마이그레이션: 도수/카운트(구 기본 1.40625) → 순수 배율(1.0) -/// 배율 의미 전환 이전에 저장된 기본값만 1로 재매핑 (사용자 지정값은 유지) -fn migrate_legacy_knob_sensitivity(data: &mut AppStoreData) -> bool { - const LEGACY_DEFAULT: f64 = 1.40625; // 360 / 256 - let mut changed = false; - for positions in data.knob_positions.values_mut() { - for knob in positions.iter_mut() { - if knob.sensitivity == LEGACY_DEFAULT { - knob.sensitivity = 1.0; - changed = true; - } - } - } - changed -} - -/// 타입은 맞지만 실제 식별자로 쓸 수 없는 항목을 로드 경계에서만 정리 -fn repair_semantic_identities(data: &mut AppStoreData) -> bool { - let mut changed = false; - - let original_font_len = data.font_settings.custom_fonts.len(); - data.font_settings - .custom_fonts - .retain(|font| !font.id.trim().is_empty() && !font.name.trim().is_empty()); - changed |= data.font_settings.custom_fonts.len() != original_font_len; - - for font in data.font_settings.custom_fonts.iter_mut() { - let original_range_len = font.weight_ranges.len(); - font.weight_ranges.retain(|range| { - (1..=1000).contains(&range.min) - && (1..=1000).contains(&range.max) - && range.min <= range.max - }); - changed |= font.weight_ranges.len() != original_range_len; - - if font.font_type != FontType::Local { - continue; - } - - let invalid_path = font.local_path.as_ref().is_some_and(|path| { - path.trim().is_empty() - || (!Path::new(path.trim()).is_absolute() - && parse_portable_asset_reference(path).is_none()) - }); - if invalid_path { - font.local_path = None; - changed = true; - } - - let has_embedded_source = font - .css_content - .as_deref() - .is_some_and(|content| !content.trim().is_empty()); - if font.local_path.is_none() && !has_embedded_source && font.enabled { - font.enabled = false; - changed = true; - } - } - - changed -} - /// 레거시 store 파일 경로 탐색 pub(crate) fn find_legacy_store_file() -> Option { // 고정된 레거시 경로: %APPDATA%/dm-note/config.json @@ -334,511 +237,6 @@ pub(crate) fn find_legacy_store_file() -> Option { } } -/// `rgba(r, g, b, a)` 문자열을 `#RRGGBB`로 변환. rgba 형식이 아니거나 파싱 실패 시 None -fn rgba_to_hex(color: &str) -> Option { - let inner = color.trim().strip_prefix("rgba(")?.strip_suffix(')')?; - let mut parts = inner.split(','); - let r: u8 = parts.next()?.trim().parse().ok()?; - let g: u8 = parts.next()?.trim().parse().ok()?; - let b: u8 = parts.next()?.trim().parse().ok()?; - Some(format!("#{r:02X}{g:02X}{b:02X}")) -} - -/// noteBorderColor가 변환 가능한 rgba면 #RRGGBB로 교체 (그 외 입력은 그대로) -fn migrate_note_border_color(color: &mut Option) { - if let Some(hex) = color.as_deref().and_then(rgba_to_hex) { - *color = Some(hex); - } -} - -/// store에 #RRGGBB로 변환 가능한 rgba noteBorderColor가 남아 있는지 (디스크 영속 판단용) -fn has_convertible_note_border_color(data: &AppStoreData) -> bool { - let convertible = |color: &Option| color.as_deref().and_then(rgba_to_hex).is_some(); - data.key_positions - .values() - .flatten() - .any(|pos| convertible(&pos.note_border_color)) - || data - .stat_positions - .values() - .flatten() - .any(|stat| convertible(&stat.position.note_border_color)) - || data - .graph_positions - .values() - .flatten() - .any(|graph| convertible(&graph.position.note_border_color)) - || data - .knob_positions - .values() - .flatten() - .any(|knob| convertible(&knob.position.note_border_color)) -} - -/// store 데이터 정규화 및 레거시 마이그레이션 적용 -pub(crate) fn normalize_state(mut data: AppStoreData) -> AppStoreData { - remove_legacy_panel_detach_setting(&mut data); - normalize_custom_css_history(&mut data.custom_css_history); - repair_editor_revision(&mut data); - normalize_blank_font_colors(&mut data); - canonicalize_image_modes(&mut data); - - if data.keys.is_empty() { - data.keys = default_keys().clone(); - } else { - merge_default_modes(&mut data.keys, default_keys()); - } - - if data.key_positions.is_empty() { - data.key_positions = default_positions().clone(); - } else { - merge_default_positions(&mut data.key_positions, default_positions()); - } - - if !has_valid_selected_key_type(&data) { - data.selected_key_type = "4key".to_string(); - } - - pad_key_position_lengths(&mut data.keys, &mut data.key_positions); - - // 레거시 마이그레이션: 전역 noteSettings.borderRadius → 키별 noteBorderRadius - if let Some(legacy_border_radius) = data.note_settings.border_radius.take() { - for positions in data.key_positions.values_mut() { - for pos in positions.iter_mut() { - pos.note_border_radius = Some(legacy_border_radius as f64); - } - } - } - - // 마이그레이션: noteBorderColor에 잘못 저장된 rgba(...) → #RRGGBB 복구 (이슈 #73) - // 배치 편집 경로가 정규화 없이 rgba 문자열을 저장해 오버레이에서 초록색으로 깨짐 - for positions in data.key_positions.values_mut() { - for pos in positions.iter_mut() { - migrate_note_border_color(&mut pos.note_border_color); - } - } - for positions in data.stat_positions.values_mut() { - for stat in positions.iter_mut() { - migrate_note_border_color(&mut stat.position.note_border_color); - } - } - for positions in data.graph_positions.values_mut() { - for graph in positions.iter_mut() { - migrate_note_border_color(&mut graph.position.note_border_color); - } - } - for positions in data.knob_positions.values_mut() { - for knob in positions.iter_mut() { - migrate_note_border_color(&mut knob.position.note_border_color); - } - } - - // 레거시 마이그레이션: fadePosition enum → 방향별 픽셀 fade 값 - data.note_settings.migrate_fade_position(); - for tab in data.tab_note_overrides.values_mut() { - tab.migrate_fade_position(); - } - - merge_default_counters(&mut data.key_counters, &data.keys); - - data.counter_animation_presets = - crate::models::normalize_user_counter_animation_presets(data.counter_animation_presets); - - // 마이그레이션: 기존 기본 카운터 설정의 반전된 active 색상 보정 - // (레거시 패턴에 해당하는 경우만 적용, 사용자 커스텀은 유지) - for positions in data.key_positions.values_mut() { - for pos in positions.iter_mut() { - pos.counter.migrate_legacy_defaults(); - } - } - for positions in data.stat_positions.values_mut() { - for pos in positions.iter_mut() { - pos.position.counter.migrate_legacy_defaults(); - } - } - for positions in data.graph_positions.values_mut() { - for pos in positions.iter_mut() { - pos.position.counter.migrate_legacy_defaults(); - } - } - - migrate_legacy_font_weight_state(&mut data); - - let _ = data.custom_js.normalize(); - - data -} - -fn repair_image_transforms(data: &mut AppStoreData) -> bool { - fn repair_transform(transform: &mut Option) -> bool { - let Some(transform) = transform else { - return false; - }; - let identity = ImageTransform::default(); - let mut repaired = false; - for (value, fallback, minimum, maximum) in [ - ( - &mut transform.offset_x, - identity.offset_x, - IMAGE_TRANSFORM_OFFSET_MIN, - IMAGE_TRANSFORM_OFFSET_MAX, - ), - ( - &mut transform.offset_y, - identity.offset_y, - IMAGE_TRANSFORM_OFFSET_MIN, - IMAGE_TRANSFORM_OFFSET_MAX, - ), - ( - &mut transform.rotation, - identity.rotation, - IMAGE_TRANSFORM_ROTATION_MIN, - IMAGE_TRANSFORM_ROTATION_MAX, - ), - ( - &mut transform.scale, - identity.scale, - IMAGE_TRANSFORM_SCALE_MIN, - IMAGE_TRANSFORM_SCALE_MAX, - ), - ] { - if !value.is_finite() || !(minimum..=maximum).contains(value) { - *value = fallback; - repaired = true; - } - } - repaired - } - - fn repair_position(position: &mut KeyPosition) -> bool { - repair_transform(&mut position.idle_image_transform) - | repair_transform(&mut position.active_image_transform) - } - - let mut repaired = false; - for position in data.key_positions.values_mut().flatten() { - repaired |= repair_position(position); - } - for position in data.stat_positions.values_mut().flatten() { - repaired |= repair_position(&mut position.position); - } - for position in data.graph_positions.values_mut().flatten() { - repaired |= repair_position(&mut position.position); - } - for position in data.knob_positions.values_mut().flatten() { - repaired |= repair_position(&mut position.position); - } - repaired -} - -fn has_legacy_font_weight_state(data: &AppStoreData) -> bool { - data.key_positions - .values() - .flatten() - .chain( - data.stat_positions - .values() - .flatten() - .map(|position| &position.position), - ) - .chain( - data.graph_positions - .values() - .flatten() - .map(|position| &position.position), - ) - .chain( - data.knob_positions - .values() - .flatten() - .map(|position| &position.position), - ) - .any(|position| { - (position.font_bold.is_none() && position.font_weight == Some(700)) - || (position.counter.font_bold.is_none() && position.counter.font_weight == 700) - }) -} - -pub(crate) fn migrate_legacy_font_weight_state(data: &mut AppStoreData) -> bool { - let mut changed = false; - for position in data.key_positions.values_mut().flatten() { - changed |= position.migrate_legacy_font_weight(); - } - for position in data.stat_positions.values_mut().flatten() { - changed |= position.position.migrate_legacy_font_weight(); - } - for position in data.graph_positions.values_mut().flatten() { - changed |= position.position.migrate_legacy_font_weight(); - } - for position in data.knob_positions.values_mut().flatten() { - changed |= position.position.migrate_legacy_font_weight(); - } - changed -} - -fn normalize_blank_font_colors(data: &mut AppStoreData) -> bool { - fn normalize_position(position: &mut KeyPosition) -> bool { - let mut changed = false; - for color in [&mut position.font_color, &mut position.active_font_color] { - if color - .as_deref() - .is_some_and(|value| value.trim().is_empty()) - { - *color = None; - changed = true; - } - } - changed - } - - let mut changed = false; - for position in data.key_positions.values_mut().flatten() { - changed |= normalize_position(position); - } - for stat in data.stat_positions.values_mut().flatten() { - changed |= normalize_position(&mut stat.position); - } - for graph in data.graph_positions.values_mut().flatten() { - changed |= normalize_position(&mut graph.position); - } - for knob in data.knob_positions.values_mut().flatten() { - changed |= normalize_position(&mut knob.position); - } - changed -} - -// image_mode replace 표현을 None으로 접는다 (KeyPosition::canonicalize_image_mode 참조) -pub(crate) fn canonicalize_image_modes(data: &mut AppStoreData) -> bool { - let mut changed = false; - for position in data.key_positions.values_mut().flatten() { - changed |= position.canonicalize_image_mode(); - } - for stat in data.stat_positions.values_mut().flatten() { - changed |= stat.position.canonicalize_image_mode(); - } - for graph in data.graph_positions.values_mut().flatten() { - changed |= graph.position.canonicalize_image_mode(); - } - for knob in data.knob_positions.values_mut().flatten() { - changed |= knob.position.canonicalize_image_mode(); - } - changed -} - -fn remove_legacy_panel_detach_setting(data: &mut AppStoreData) -> bool { - data.plugin_data - .remove(LEGACY_PANEL_DETACH_ENABLED_KEY) - .is_some() -} - -pub(crate) fn canonicalize_gradient_pairs(data: &mut AppStoreData) -> (bool, bool) { - let mut changed = false; - let mut pair_repaired = false; - - for position in data.key_positions.values_mut().flatten() { - let (position_changed, position_pair_repaired) = position.canonicalize_gradient_pairs(); - changed |= position_changed; - pair_repaired |= position_pair_repaired; - } - for stat in data.stat_positions.values_mut().flatten() { - let (position_changed, position_pair_repaired) = - stat.position.canonicalize_gradient_pairs(); - changed |= position_changed; - pair_repaired |= position_pair_repaired; - } - for graph in data.graph_positions.values_mut().flatten() { - let (position_changed, position_pair_repaired) = - graph.position.canonicalize_gradient_pairs(); - changed |= position_changed; - pair_repaired |= position_pair_repaired; - } - for knob in data.knob_positions.values_mut().flatten() { - let (position_changed, position_pair_repaired) = - knob.position.canonicalize_gradient_pairs(); - changed |= position_changed; - pair_repaired |= position_pair_repaired; - } - - (changed, pair_repaired) -} - -fn repair_editor_revision(data: &mut AppStoreData) -> bool { - if data.editor_revision <= super::editor::MAX_SAFE_WIRE_REVISION { - return false; - } - - log::warn!( - "[Store] Resetting unsafe editorRevision {} to 0 during load recovery", - data.editor_revision - ); - data.editor_revision = 0; - true -} - -fn has_valid_selected_key_type(data: &AppStoreData) -> bool { - default_keys().contains_key(&data.selected_key_type) - || (data.keys.contains_key(&data.selected_key_type) - && data - .custom_tabs - .iter() - .any(|tab| tab.id == data.selected_key_type)) -} - -/// 그룹 정의가 사라진 요소의 groupId를 해제 — dangling 참조는 UI에서 해석 불가 -/// 정리 발생 여부를 반환 (로드 경로에서 needs_persist 판단에 사용) -/// -/// 파일 로드·프리셋 임포트처럼 정의와 참조가 함께 확정되는 경계에서만 호출할 것. -/// 프론트는 positions(groupId 포함)를 먼저 저장하고 layerGroups를 나중에 저장하므로, -/// normalize_state 같은 부분 저장 경로에서 돌리면 새 그룹 생성이 파괴된다. -pub(crate) fn clear_dangling_group_ids(data: &mut AppStoreData) -> bool { - clear_dangling_group_ids_in( - &mut data.key_positions, - &mut data.stat_positions, - &mut data.graph_positions, - &mut data.knob_positions, - &data.layer_groups, - ) -} - -pub(crate) fn clear_dangling_group_ids_in( - key_positions: &mut KeyPositions, - stat_positions: &mut StatPositions, - graph_positions: &mut GraphPositions, - knob_positions: &mut KnobPositions, - layer_groups: &LayerGroups, -) -> bool { - let valid_ids: HashMap<&str, HashSet<&str>> = layer_groups - .iter() - .map(|(tab, groups)| { - ( - tab.as_str(), - groups.iter().map(|group| group.id.as_str()).collect(), - ) - }) - .collect(); - - let is_dangling = |tab: &str, group_id: &Option| { - group_id.as_ref().is_some_and(|id| { - !valid_ids - .get(tab) - .is_some_and(|ids| ids.contains(id.as_str())) - }) - }; - - let mut changed = false; - for (tab, positions) in key_positions.iter_mut() { - for position in positions.iter_mut() { - if is_dangling(tab, &position.group_id) { - position.group_id = None; - changed = true; - } - } - } - for (tab, positions) in stat_positions.iter_mut() { - for stat in positions.iter_mut() { - if is_dangling(tab, &stat.position.group_id) { - stat.position.group_id = None; - changed = true; - } - } - } - for (tab, positions) in graph_positions.iter_mut() { - for graph in positions.iter_mut() { - if is_dangling(tab, &graph.position.group_id) { - graph.position.group_id = None; - changed = true; - } - } - } - for (tab, positions) in knob_positions.iter_mut() { - for knob in positions.iter_mut() { - if is_dangling(tab, &knob.position.group_id) { - knob.position.group_id = None; - changed = true; - } - } - } - changed -} - -/// 누락된 모드만 기본값으로 채움 -fn merge_default_modes(target: &mut KeyMappings, defaults: &KeyMappings) { - for (mode, value) in defaults.iter() { - target.entry(mode.clone()).or_insert_with(|| value.clone()); - } -} - -/// 누락된 모드만 기본값으로 채움 -fn merge_default_positions(target: &mut KeyPositions, defaults: &KeyPositions) { - for (mode, positions) in defaults.iter() { - target - .entry(mode.clone()) - .or_insert_with(|| positions.clone()); - } -} - -/// 인덱스 결합 배열의 길이 보정 -pub(crate) fn pad_key_position_lengths( - keys: &mut KeyMappings, - positions: &mut KeyPositions, -) -> bool { - let modes = keys - .keys() - .chain(positions.keys()) - .cloned() - .collect::>(); - let mut changed = false; - - for mode in modes { - let key_count = keys.get(&mode).map_or(0, Vec::len); - let position_count = positions.get(&mode).map_or(0, Vec::len); - if key_count > position_count { - log::warn!( - "[Store] Padding keyPositions mode '{mode}' from {position_count} to {key_count} entries" - ); - positions - .entry(mode) - .or_default() - .resize(key_count, KeyPosition::default()); - changed = true; - } else if position_count > key_count { - log::warn!( - "[Store] Padding keys mode '{mode}' from {key_count} to {position_count} entries" - ); - keys.entry(mode) - .or_default() - .resize(position_count, KeySlot::default()); - changed = true; - } - } - - changed -} - -fn key_position_lengths_mismatch(keys: &KeyMappings, positions: &KeyPositions) -> bool { - keys.keys() - .chain(positions.keys()) - .any(|mode| keys.get(mode).map_or(0, Vec::len) != positions.get(mode).map_or(0, Vec::len)) -} - -/// 키 카운터 기본값 병합 -fn merge_default_counters(target: &mut KeyCounters, keys: &KeyMappings) { - for (mode, key_list) in keys.iter() { - let entry = target.entry(mode.clone()).or_default(); - let canonical_keys = key_list - .iter() - .map(KeySlot::canonical) - .collect::>(); - entry.retain(|key, _| canonical_keys.contains(key)); - for key in canonical_keys { - entry.entry(key).or_insert(0); - } - } - - let available_modes: std::collections::HashSet<_> = keys.keys().cloned().collect(); - target.retain(|mode, _| available_modes.contains(mode)); -} - #[derive(Deserialize)] struct LegacyOverlayPosition { x: f64, diff --git a/src-tauri/src/state/migration/normalization.rs b/src-tauri/src/state/migration/normalization.rs new file mode 100644 index 000000000..b0650102d --- /dev/null +++ b/src-tauri/src/state/migration/normalization.rs @@ -0,0 +1,623 @@ +use super::*; + +pub(super) fn default_store_note_gradient_multipliers(value: &mut Value) -> bool { + let mut changed = false; + for collection in POSITION_COLLECTION_FIELDS { + let Some(modes) = value.get_mut(collection).and_then(Value::as_object_mut) else { + continue; + }; + for position in modes.values_mut().filter_map(Value::as_array_mut).flatten() { + changed |= default_missing_note_gradient_multipliers(position); + } + } + changed +} + +pub(super) fn has_explicit_invalid_element_id(value: &Value) -> bool { + POSITION_COLLECTION_FIELDS + .into_iter() + .filter_map(|field| value.get(field).and_then(Value::as_object)) + .flat_map(|modes| modes.values()) + .filter_map(Value::as_array) + .flatten() + .filter_map(Value::as_object) + .filter_map(|element| element.get("id")) + .any(|id| { + id.as_str() + .is_none_or(|id| !super::native_element_id::is_valid_element_id(id)) + }) +} + +pub(super) fn current_unix_millis() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)) + .unwrap_or(0) +} + +pub(super) fn migrate_sound_library_enabled(value: &mut Value) -> bool { + let Some(sound_library) = value.get_mut("soundLibrary").and_then(Value::as_object_mut) else { + return false; + }; + + let mut changed = false; + for entry in sound_library.values_mut().filter_map(Value::as_object_mut) { + let Some(enabled) = entry.remove("enabled") else { + continue; + }; + + changed = true; + if !entry.contains_key("hidden") { + if let Some(enabled) = enabled.as_bool() { + entry.insert("hidden".to_string(), Value::Bool(!enabled)); + } + } + } + changed +} + +/// 레거시 노브 민감도 마이그레이션: 도수/카운트(구 기본 1.40625) → 순수 배율(1.0) +/// 배율 의미 전환 이전에 저장된 기본값만 1로 재매핑 (사용자 지정값은 유지) +pub(super) fn migrate_legacy_knob_sensitivity(data: &mut AppStoreData) -> bool { + const LEGACY_DEFAULT: f64 = 1.40625; // 360 / 256 + let mut changed = false; + for positions in data.knob_positions.values_mut() { + for knob in positions.iter_mut() { + if knob.sensitivity == LEGACY_DEFAULT { + knob.sensitivity = 1.0; + changed = true; + } + } + } + changed +} + +/// 타입은 맞지만 실제 식별자로 쓸 수 없는 항목을 로드 경계에서만 정리 +pub(super) fn repair_semantic_identities(data: &mut AppStoreData) -> bool { + let mut changed = false; + + let original_font_len = data.font_settings.custom_fonts.len(); + data.font_settings + .custom_fonts + .retain(|font| !font.id.trim().is_empty() && !font.name.trim().is_empty()); + changed |= data.font_settings.custom_fonts.len() != original_font_len; + + for font in data.font_settings.custom_fonts.iter_mut() { + let original_range_len = font.weight_ranges.len(); + font.weight_ranges.retain(|range| { + (1..=1000).contains(&range.min) + && (1..=1000).contains(&range.max) + && range.min <= range.max + }); + changed |= font.weight_ranges.len() != original_range_len; + + if font.font_type != FontType::Local { + continue; + } + + let invalid_path = font.local_path.as_ref().is_some_and(|path| { + path.trim().is_empty() + || (!Path::new(path.trim()).is_absolute() + && parse_portable_asset_reference(path).is_none()) + }); + if invalid_path { + font.local_path = None; + changed = true; + } + + let has_embedded_source = font + .css_content + .as_deref() + .is_some_and(|content| !content.trim().is_empty()); + if font.local_path.is_none() && !has_embedded_source && font.enabled { + font.enabled = false; + changed = true; + } + } + + changed +} +/// `rgba(r, g, b, a)` 문자열을 `#RRGGBB`로 변환. rgba 형식이 아니거나 파싱 실패 시 None +pub(super) fn rgba_to_hex(color: &str) -> Option { + let inner = color.trim().strip_prefix("rgba(")?.strip_suffix(')')?; + let mut parts = inner.split(','); + let r: u8 = parts.next()?.trim().parse().ok()?; + let g: u8 = parts.next()?.trim().parse().ok()?; + let b: u8 = parts.next()?.trim().parse().ok()?; + Some(format!("#{r:02X}{g:02X}{b:02X}")) +} + +/// noteBorderColor가 변환 가능한 rgba면 #RRGGBB로 교체 (그 외 입력은 그대로) +fn migrate_note_border_color(color: &mut Option) { + if let Some(hex) = color.as_deref().and_then(rgba_to_hex) { + *color = Some(hex); + } +} + +/// store에 #RRGGBB로 변환 가능한 rgba noteBorderColor가 남아 있는지 (디스크 영속 판단용) +pub(super) fn has_convertible_note_border_color(data: &AppStoreData) -> bool { + let convertible = |color: &Option| color.as_deref().and_then(rgba_to_hex).is_some(); + data.key_positions + .values() + .flatten() + .any(|pos| convertible(&pos.note_border_color)) + || data + .stat_positions + .values() + .flatten() + .any(|stat| convertible(&stat.position.note_border_color)) + || data + .graph_positions + .values() + .flatten() + .any(|graph| convertible(&graph.position.note_border_color)) + || data + .knob_positions + .values() + .flatten() + .any(|knob| convertible(&knob.position.note_border_color)) +} + +/// store 데이터 정규화 및 레거시 마이그레이션 적용 +pub(crate) fn normalize_state(mut data: AppStoreData) -> AppStoreData { + remove_legacy_panel_detach_setting(&mut data); + normalize_custom_css_history(&mut data.custom_css_history); + repair_editor_revision(&mut data); + normalize_blank_font_colors(&mut data); + canonicalize_image_modes(&mut data); + + if data.keys.is_empty() { + data.keys = default_keys().clone(); + } else { + merge_default_modes(&mut data.keys, default_keys()); + } + + if data.key_positions.is_empty() { + data.key_positions = default_positions().clone(); + } else { + merge_default_positions(&mut data.key_positions, default_positions()); + } + + if !has_valid_selected_key_type(&data) { + data.selected_key_type = "4key".to_string(); + } + + pad_key_position_lengths(&mut data.keys, &mut data.key_positions); + + // 레거시 마이그레이션: 전역 noteSettings.borderRadius → 키별 noteBorderRadius + if let Some(legacy_border_radius) = data.note_settings.border_radius.take() { + for positions in data.key_positions.values_mut() { + for pos in positions.iter_mut() { + pos.note_border_radius = Some(legacy_border_radius as f64); + } + } + } + + // 마이그레이션: noteBorderColor에 잘못 저장된 rgba(...) → #RRGGBB 복구 (이슈 #73) + // 배치 편집 경로가 정규화 없이 rgba 문자열을 저장해 오버레이에서 초록색으로 깨짐 + for positions in data.key_positions.values_mut() { + for pos in positions.iter_mut() { + migrate_note_border_color(&mut pos.note_border_color); + } + } + for positions in data.stat_positions.values_mut() { + for stat in positions.iter_mut() { + migrate_note_border_color(&mut stat.position.note_border_color); + } + } + for positions in data.graph_positions.values_mut() { + for graph in positions.iter_mut() { + migrate_note_border_color(&mut graph.position.note_border_color); + } + } + for positions in data.knob_positions.values_mut() { + for knob in positions.iter_mut() { + migrate_note_border_color(&mut knob.position.note_border_color); + } + } + + // 레거시 마이그레이션: fadePosition enum → 방향별 픽셀 fade 값 + data.note_settings.migrate_fade_position(); + for tab in data.tab_note_overrides.values_mut() { + tab.migrate_fade_position(); + } + + merge_default_counters(&mut data.key_counters, &data.keys); + + data.counter_animation_presets = + crate::models::normalize_user_counter_animation_presets(data.counter_animation_presets); + + // 마이그레이션: 기존 기본 카운터 설정의 반전된 active 색상 보정 + // (레거시 패턴에 해당하는 경우만 적용, 사용자 커스텀은 유지) + for positions in data.key_positions.values_mut() { + for pos in positions.iter_mut() { + pos.counter.migrate_legacy_defaults(); + } + } + for positions in data.stat_positions.values_mut() { + for pos in positions.iter_mut() { + pos.position.counter.migrate_legacy_defaults(); + } + } + for positions in data.graph_positions.values_mut() { + for pos in positions.iter_mut() { + pos.position.counter.migrate_legacy_defaults(); + } + } + + migrate_legacy_font_weight_state(&mut data); + + let _ = data.custom_js.normalize(); + + data +} + +pub(super) fn repair_image_transforms(data: &mut AppStoreData) -> bool { + fn repair_transform(transform: &mut Option) -> bool { + let Some(transform) = transform else { + return false; + }; + let identity = ImageTransform::default(); + let mut repaired = false; + for (value, fallback, minimum, maximum) in [ + ( + &mut transform.offset_x, + identity.offset_x, + IMAGE_TRANSFORM_OFFSET_MIN, + IMAGE_TRANSFORM_OFFSET_MAX, + ), + ( + &mut transform.offset_y, + identity.offset_y, + IMAGE_TRANSFORM_OFFSET_MIN, + IMAGE_TRANSFORM_OFFSET_MAX, + ), + ( + &mut transform.rotation, + identity.rotation, + IMAGE_TRANSFORM_ROTATION_MIN, + IMAGE_TRANSFORM_ROTATION_MAX, + ), + ( + &mut transform.scale, + identity.scale, + IMAGE_TRANSFORM_SCALE_MIN, + IMAGE_TRANSFORM_SCALE_MAX, + ), + ] { + if !value.is_finite() || !(minimum..=maximum).contains(value) { + *value = fallback; + repaired = true; + } + } + repaired + } + + fn repair_position(position: &mut KeyPosition) -> bool { + repair_transform(&mut position.idle_image_transform) + | repair_transform(&mut position.active_image_transform) + } + + let mut repaired = false; + for position in data.key_positions.values_mut().flatten() { + repaired |= repair_position(position); + } + for position in data.stat_positions.values_mut().flatten() { + repaired |= repair_position(&mut position.position); + } + for position in data.graph_positions.values_mut().flatten() { + repaired |= repair_position(&mut position.position); + } + for position in data.knob_positions.values_mut().flatten() { + repaired |= repair_position(&mut position.position); + } + repaired +} + +pub(super) fn has_legacy_font_weight_state(data: &AppStoreData) -> bool { + data.key_positions + .values() + .flatten() + .chain( + data.stat_positions + .values() + .flatten() + .map(|position| &position.position), + ) + .chain( + data.graph_positions + .values() + .flatten() + .map(|position| &position.position), + ) + .chain( + data.knob_positions + .values() + .flatten() + .map(|position| &position.position), + ) + .any(|position| { + (position.font_bold.is_none() && position.font_weight == Some(700)) + || (position.counter.font_bold.is_none() && position.counter.font_weight == 700) + }) +} + +pub(crate) fn migrate_legacy_font_weight_state(data: &mut AppStoreData) -> bool { + let mut changed = false; + for position in data.key_positions.values_mut().flatten() { + changed |= position.migrate_legacy_font_weight(); + } + for position in data.stat_positions.values_mut().flatten() { + changed |= position.position.migrate_legacy_font_weight(); + } + for position in data.graph_positions.values_mut().flatten() { + changed |= position.position.migrate_legacy_font_weight(); + } + for position in data.knob_positions.values_mut().flatten() { + changed |= position.position.migrate_legacy_font_weight(); + } + changed +} + +pub(super) fn normalize_blank_font_colors(data: &mut AppStoreData) -> bool { + fn normalize_position(position: &mut KeyPosition) -> bool { + let mut changed = false; + for color in [&mut position.font_color, &mut position.active_font_color] { + if color + .as_deref() + .is_some_and(|value| value.trim().is_empty()) + { + *color = None; + changed = true; + } + } + changed + } + + let mut changed = false; + for position in data.key_positions.values_mut().flatten() { + changed |= normalize_position(position); + } + for stat in data.stat_positions.values_mut().flatten() { + changed |= normalize_position(&mut stat.position); + } + for graph in data.graph_positions.values_mut().flatten() { + changed |= normalize_position(&mut graph.position); + } + for knob in data.knob_positions.values_mut().flatten() { + changed |= normalize_position(&mut knob.position); + } + changed +} + +// image_mode replace 표현을 None으로 접는다 (KeyPosition::canonicalize_image_mode 참조) +pub(crate) fn canonicalize_image_modes(data: &mut AppStoreData) -> bool { + let mut changed = false; + for position in data.key_positions.values_mut().flatten() { + changed |= position.canonicalize_image_mode(); + } + for stat in data.stat_positions.values_mut().flatten() { + changed |= stat.position.canonicalize_image_mode(); + } + for graph in data.graph_positions.values_mut().flatten() { + changed |= graph.position.canonicalize_image_mode(); + } + for knob in data.knob_positions.values_mut().flatten() { + changed |= knob.position.canonicalize_image_mode(); + } + changed +} + +pub(super) fn remove_legacy_panel_detach_setting(data: &mut AppStoreData) -> bool { + data.plugin_data + .remove(LEGACY_PANEL_DETACH_ENABLED_KEY) + .is_some() +} + +pub(crate) fn canonicalize_gradient_pairs(data: &mut AppStoreData) -> (bool, bool) { + let mut changed = false; + let mut pair_repaired = false; + + for position in data.key_positions.values_mut().flatten() { + let (position_changed, position_pair_repaired) = position.canonicalize_gradient_pairs(); + changed |= position_changed; + pair_repaired |= position_pair_repaired; + } + for stat in data.stat_positions.values_mut().flatten() { + let (position_changed, position_pair_repaired) = + stat.position.canonicalize_gradient_pairs(); + changed |= position_changed; + pair_repaired |= position_pair_repaired; + } + for graph in data.graph_positions.values_mut().flatten() { + let (position_changed, position_pair_repaired) = + graph.position.canonicalize_gradient_pairs(); + changed |= position_changed; + pair_repaired |= position_pair_repaired; + } + for knob in data.knob_positions.values_mut().flatten() { + let (position_changed, position_pair_repaired) = + knob.position.canonicalize_gradient_pairs(); + changed |= position_changed; + pair_repaired |= position_pair_repaired; + } + + (changed, pair_repaired) +} + +pub(super) fn repair_editor_revision(data: &mut AppStoreData) -> bool { + if data.editor_revision <= super::editor::MAX_SAFE_WIRE_REVISION { + return false; + } + + log::warn!( + "[Store] Resetting unsafe editorRevision {} to 0 during load recovery", + data.editor_revision + ); + data.editor_revision = 0; + true +} + +pub(super) fn has_valid_selected_key_type(data: &AppStoreData) -> bool { + default_keys().contains_key(&data.selected_key_type) + || (data.keys.contains_key(&data.selected_key_type) + && data + .custom_tabs + .iter() + .any(|tab| tab.id == data.selected_key_type)) +} + +/// 그룹 정의가 사라진 요소의 groupId를 해제 — dangling 참조는 UI에서 해석 불가 +/// 정리 발생 여부를 반환 (로드 경로에서 needs_persist 판단에 사용) +/// +/// 파일 로드·프리셋 임포트처럼 정의와 참조가 함께 확정되는 경계에서만 호출할 것. +/// 프론트는 positions(groupId 포함)를 먼저 저장하고 layerGroups를 나중에 저장하므로, +/// normalize_state 같은 부분 저장 경로에서 돌리면 새 그룹 생성이 파괴된다. +pub(crate) fn clear_dangling_group_ids(data: &mut AppStoreData) -> bool { + clear_dangling_group_ids_in( + &mut data.key_positions, + &mut data.stat_positions, + &mut data.graph_positions, + &mut data.knob_positions, + &data.layer_groups, + ) +} + +pub(crate) fn clear_dangling_group_ids_in( + key_positions: &mut KeyPositions, + stat_positions: &mut StatPositions, + graph_positions: &mut GraphPositions, + knob_positions: &mut KnobPositions, + layer_groups: &LayerGroups, +) -> bool { + let valid_ids: HashMap<&str, HashSet<&str>> = layer_groups + .iter() + .map(|(tab, groups)| { + ( + tab.as_str(), + groups.iter().map(|group| group.id.as_str()).collect(), + ) + }) + .collect(); + + let is_dangling = |tab: &str, group_id: &Option| { + group_id.as_ref().is_some_and(|id| { + !valid_ids + .get(tab) + .is_some_and(|ids| ids.contains(id.as_str())) + }) + }; + + let mut changed = false; + for (tab, positions) in key_positions.iter_mut() { + for position in positions.iter_mut() { + if is_dangling(tab, &position.group_id) { + position.group_id = None; + changed = true; + } + } + } + for (tab, positions) in stat_positions.iter_mut() { + for stat in positions.iter_mut() { + if is_dangling(tab, &stat.position.group_id) { + stat.position.group_id = None; + changed = true; + } + } + } + for (tab, positions) in graph_positions.iter_mut() { + for graph in positions.iter_mut() { + if is_dangling(tab, &graph.position.group_id) { + graph.position.group_id = None; + changed = true; + } + } + } + for (tab, positions) in knob_positions.iter_mut() { + for knob in positions.iter_mut() { + if is_dangling(tab, &knob.position.group_id) { + knob.position.group_id = None; + changed = true; + } + } + } + changed +} + +/// 누락된 모드만 기본값으로 채움 +fn merge_default_modes(target: &mut KeyMappings, defaults: &KeyMappings) { + for (mode, value) in defaults.iter() { + target.entry(mode.clone()).or_insert_with(|| value.clone()); + } +} + +/// 누락된 모드만 기본값으로 채움 +fn merge_default_positions(target: &mut KeyPositions, defaults: &KeyPositions) { + for (mode, positions) in defaults.iter() { + target + .entry(mode.clone()) + .or_insert_with(|| positions.clone()); + } +} + +/// 인덱스 결합 배열의 길이 보정 +pub(crate) fn pad_key_position_lengths( + keys: &mut KeyMappings, + positions: &mut KeyPositions, +) -> bool { + let modes = keys + .keys() + .chain(positions.keys()) + .cloned() + .collect::>(); + let mut changed = false; + + for mode in modes { + let key_count = keys.get(&mode).map_or(0, Vec::len); + let position_count = positions.get(&mode).map_or(0, Vec::len); + if key_count > position_count { + log::warn!( + "[Store] Padding keyPositions mode '{mode}' from {position_count} to {key_count} entries" + ); + positions + .entry(mode) + .or_default() + .resize(key_count, KeyPosition::default()); + changed = true; + } else if position_count > key_count { + log::warn!( + "[Store] Padding keys mode '{mode}' from {key_count} to {position_count} entries" + ); + keys.entry(mode) + .or_default() + .resize(position_count, KeySlot::default()); + changed = true; + } + } + + changed +} + +pub(super) fn key_position_lengths_mismatch(keys: &KeyMappings, positions: &KeyPositions) -> bool { + keys.keys() + .chain(positions.keys()) + .any(|mode| keys.get(mode).map_or(0, Vec::len) != positions.get(mode).map_or(0, Vec::len)) +} + +/// 키 카운터 기본값 병합 +fn merge_default_counters(target: &mut KeyCounters, keys: &KeyMappings) { + for (mode, key_list) in keys.iter() { + let entry = target.entry(mode.clone()).or_default(); + let canonical_keys = key_list + .iter() + .map(KeySlot::canonical) + .collect::>(); + entry.retain(|key, _| canonical_keys.contains(key)); + for key in canonical_keys { + entry.entry(key).or_insert(0); + } + } + + let available_modes: std::collections::HashSet<_> = keys.keys().cloned().collect(); + target.retain(|mode, _| available_modes.contains(mode)); +} From 2ed0cf7d0cbd102cf008edcb5dfa84b964abdc09 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 19:03:48 +0900 Subject: [PATCH 203/245] =?UTF-8?q?test:=20=ED=94=84=EB=A1=9C=ED=8D=BC?= =?UTF-8?q?=ED=8B=B0=20=EC=9E=85=EB=A0=A5=20=ED=8C=8C=EC=82=AC=EB=93=9C=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PropertiesPanel/PropertyInputs.test.tsx | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/src/renderer/components/main/Grid/PropertiesPanel/PropertyInputs.test.tsx b/src/renderer/components/main/Grid/PropertiesPanel/PropertyInputs.test.tsx index fc71e5df5..dc5b3f77e 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/PropertyInputs.test.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/PropertyInputs.test.tsx @@ -60,15 +60,26 @@ vi.mock('@hooks/pickers/useGradientColorState', () => ({ })); import { + CloseIcon, ColorInput, FontStyleToggle, + ModeToggleIcon, NumberInput, OptionalNumberInput, + PropertyRow, + PropertySection, + Tabs, TextInput, } from './PropertyInputs'; import { createFontStyleToggleHandlers } from './fontStyleToggleHandlers'; +import { TABS } from './types'; import { MAX_EXPRESSION_LENGTH } from '@utils/core/arithmeticExpression'; import { finalizeEditorDraftForLifecycle } from '@src/renderer/editor/runtime/lifecycleEditorDraft'; +import { + FORM_LABEL_CLASS, + FORM_ROW_CLASS, + SECTION_CARD_CLASS, +} from '@utils/cardRecipes'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -121,6 +132,111 @@ const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => resolve())), ); +describe('PropertyInputs façade·layout·tabs 계약', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + window.setTimeout(() => callback(performance.now()), 0), + ); + vi.stubGlobal('cancelAnimationFrame', (id: number) => { + window.clearTimeout(id); + }); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + vi.unstubAllGlobals(); + container.remove(); + }); + + it('기존 façade의 모든 named production export를 유지한다', async () => { + const exports = await import('./PropertyInputs'); + + expect(Object.keys(exports).sort()).toEqual( + [ + 'CloseIcon', + 'ColorInput', + 'FontStyleToggle', + 'ModeToggleIcon', + 'NumberInput', + 'OptionalNumberInput', + 'PropertyRow', + 'PropertySection', + 'Tabs', + 'TextInput', + ].sort(), + ); + expect(exports.CloseIcon).toBe(CloseIcon); + expect(exports.ModeToggleIcon).toBe(ModeToggleIcon); + }); + + it('PropertySection→PropertyRow의 표식·클래스·label/control DOM 순서를 유지한다', () => { + act(() => + root.render( + + + + + , + ), + ); + + const section = container.firstElementChild as HTMLDivElement; + const row = section.firstElementChild as HTMLDivElement; + expect(section.dataset.dmnSection).toBe('true'); + expect(section.className).toBe(SECTION_CARD_CLASS); + expect(row.className).toBe(FORM_ROW_CLASS); + expect(row.children).toHaveLength(2); + expect(row.children[0].tagName).toBe('P'); + expect(row.children[0].className).toBe(FORM_LABEL_CLASS); + expect(row.children[0].textContent).toBe('Label'); + expect(row.children[1].className).toBe('flex items-center gap-[8px]'); + expect(row.children[1].textContent).toBe('Control'); + }); + + it('Tabs는 available 순서·번역·active thumb DOM과 after-paint commit을 유지한다', async () => { + const onTabChange = vi.fn(); + act(() => + root.render( + `translated:${key}`} + />, + ), + ); + + const buttons = [...container.querySelectorAll('button')]; + const thumb = container.querySelector('.dmn-segment-thumb')!; + expect(buttons.map((button) => button.dataset.tabId)).toEqual([ + TABS.NOTE, + TABS.STYLE, + ]); + expect(buttons.map((button) => button.textContent)).toEqual([ + 'translated:propertiesPanel.tabNote', + 'translated:propertiesPanel.tabStyle', + ]); + expect( + buttons.map((button) => button.getAttribute('aria-pressed')), + ).toEqual(['false', 'true']); + expect(thumb.getAttribute('aria-hidden')).toBe('true'); + expect(thumb.style.width).toBe('calc(0.5 * (100% - 4px))'); + expect(thumb.style.transform).toBe('translate3d(100%, 0, 0)'); + + act(() => buttons[0].click()); + expect(buttons[0].getAttribute('aria-pressed')).toBe('true'); + expect(onTabChange).not.toHaveBeenCalled(); + await flushAfterPaintCommit(); + expect(onTabChange).toHaveBeenCalledWith(TABS.NOTE); + }); +}); + describe('NumberInput visual-first commit', () => { let container: HTMLDivElement; let root: Root; From 0cf53038a22a02b4de95041806ffda2c6bffd3a1 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 19:03:48 +0900 Subject: [PATCH 204/245] =?UTF-8?q?refactor:=20=ED=94=84=EB=A1=9C=ED=8D=BC?= =?UTF-8?q?=ED=8B=B0=20=EC=9E=85=EB=A0=A5=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/Grid/PropertiesPanel/ColorInput.tsx | 350 +++++++ .../Grid/PropertiesPanel/FontStyleToggle.tsx | 185 ++++ .../Grid/PropertiesPanel/PropertyInputs.tsx | 872 +----------------- .../Grid/PropertiesPanel/PropertyLayout.tsx | 31 + .../Grid/PropertiesPanel/PropertyTabs.tsx | 136 +++ .../main/Grid/PropertiesPanel/TextInput.tsx | 155 ++++ 6 files changed, 862 insertions(+), 867 deletions(-) create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/ColorInput.tsx create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/FontStyleToggle.tsx create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/PropertyLayout.tsx create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/PropertyTabs.tsx create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/TextInput.tsx diff --git a/src/renderer/components/main/Grid/PropertiesPanel/ColorInput.tsx b/src/renderer/components/main/Grid/PropertiesPanel/ColorInput.tsx new file mode 100644 index 000000000..e7a2ddca2 --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/ColorInput.tsx @@ -0,0 +1,350 @@ +/* eslint-disable react-hooks/set-state-in-effect */ +import React, { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import type { ColorInputProps } from './types'; +import { I18nContext } from '@contexts/I18nContextDef'; +import ColorPicker from '@components/main/Modal/content/pickers/ColorPicker'; +import PopupExit from '@components/main/Modal/PopupExit'; +import { ColorSwatchButton } from '@components/main/Modal/content/pickers/ColorSwatch'; +import { useGradientColorState } from '@hooks/pickers/useGradientColorState'; +import { gradientToCss } from '@src/types/color'; + +export const ColorInput: React.FC = ({ + value, + onChange, + onPreview, + pickerMountStrategy = 'after-paint', + onChangeComplete, + activeValue, + onActiveChange, + onActivePreview, + onActiveChangeComplete, + showStateTabs = false, + stateMode: externalStateMode, + onStateModeChange: externalOnStateModeChange, + colorId, + solidOnly = true, + panelElement, + isOpen: externalIsOpen, + onToggle: externalOnToggle, + gradientValue, + activeGradientValue, + onModeCommit, + onModePreview, + onCancel, + canvasAnchor, + gradientSurface = 'background', + hexMixed = false, + alphaMixed = false, +}) => { + const i18n = React.useContext(I18nContext); + // 외부 제어 모드인지 확인 + const isControlled = + externalIsOpen !== undefined && externalOnToggle !== undefined; + + const [internalOpen, setInternalOpen] = useState(false); + const open = isControlled ? externalIsOpen : internalOpen; + const [internalPickerMounted, setInternalPickerMounted] = useState(false); + const pickerMountFrameRef = useRef(null); + const pickerMountTimerRef = useRef(null); + + const cancelPendingPickerMount = useCallback(() => { + if (pickerMountFrameRef.current !== null) { + cancelAnimationFrame(pickerMountFrameRef.current); + pickerMountFrameRef.current = null; + } + if (pickerMountTimerRef.current !== null) { + window.clearTimeout(pickerMountTimerRef.current); + pickerMountTimerRef.current = null; + } + }, []); + + const schedulePickerMount = useCallback(() => { + cancelPendingPickerMount(); + if (pickerMountStrategy === 'sync') { + setInternalPickerMounted(true); + return; + } + pickerMountFrameRef.current = requestAnimationFrame(() => { + pickerMountFrameRef.current = null; + pickerMountTimerRef.current = window.setTimeout(() => { + pickerMountTimerRef.current = null; + setInternalPickerMounted(true); + }, 0); + }); + }, [cancelPendingPickerMount, pickerMountStrategy]); + + const closeInternalPicker = useCallback(() => { + cancelPendingPickerMount(); + setInternalPickerMounted(false); + setInternalOpen(false); + }, [cancelPendingPickerMount]); + + useEffect( + () => () => { + cancelPendingPickerMount(); + }, + [cancelPendingPickerMount], + ); + + const pickerMounted = isControlled ? open : internalPickerMounted; + + const isStateControlled = + externalStateMode !== undefined && externalOnStateModeChange !== undefined; + const [internalStateMode, setInternalStateMode] = useState<'idle' | 'active'>( + 'idle', + ); + const stateMode = + showStateTabs && isStateControlled + ? externalStateMode + : showStateTabs + ? internalStateMode + : 'idle'; + + useEffect(() => { + if (!showStateTabs) { + setInternalStateMode('idle'); + if (!isControlled) closeInternalPicker(); + } + }, [closeInternalPicker, showStateTabs, isControlled]); + + const buttonRef = useRef(null); + + // 로컬 색상 상태 (드래그 중 UI 업데이트용) + const [localColor, setLocalColor] = useState(value || '#FFFFFF'); + const [localActiveColor, setLocalActiveColor] = useState( + activeValue ?? value ?? '#FFFFFF', + ); + + // 피커가 닫혀있을 때만 외부 prop과 동기화 + useEffect(() => { + if (!open) { + setLocalColor(value || '#FFFFFF'); + setLocalActiveColor(activeValue ?? value ?? '#FFFFFF'); + } + }, [value, activeValue, open]); + + // colorId가 없으면 value 기반으로 생성 + const _stableId = + colorId || `color-input-${value?.replace(/[^a-zA-Z0-9]/g, '')}`; + + const interactiveRefs = [buttonRef]; + + // 새로 열 때는 항상 대기 탭에서 시작 - 열림과 같은 이벤트에서 리셋해 + // 첫 렌더·최초 발행부터 이전 "입력" 선택이 새지 않는다 + const resetStateModeToIdle = () => { + if (!showStateTabs) return; + if (isStateControlled) { + externalOnStateModeChange('idle'); + } else { + setInternalStateMode('idle'); + } + }; + + const handleToggle = () => { + if (isControlled) { + if (!open) resetStateModeToIdle(); + externalOnToggle(); + } else if (internalOpen) { + closeInternalPicker(); + } else { + resetStateModeToIdle(); + setInternalOpen(true); + schedulePickerMount(); + } + }; + + // controlled open을 부모가 토글 핸들러 없이 직접 여는 경로도 대기 시작 보장 + // layout effect라 리셋 전 상태가 화면·프리뷰에 새지 않는다 (핸들러 리셋과 중복 무해) + const wasOpenRef = useRef(open); + useLayoutEffect(() => { + if (open && !wasOpenRef.current) resetStateModeToIdle(); + wasOpenRef.current = open; + }); + + const handleClose = () => { + if (isControlled) { + externalOnToggle(); + } else { + closeInternalPicker(); + } + }; + + const setLocalColorForState = (color: string) => { + if (showStateTabs && stateMode === 'active') { + setLocalActiveColor(color); + return; + } + setLocalColor(color); + }; + + // 드래그와 텍스트 입력은 같은 preview 채널을 쓴다 + const handleColorPreview = (color: string) => { + setLocalColorForState(color); + if (showStateTabs && stateMode === 'active') { + onActivePreview?.(color); + return; + } + onPreview?.(color); + }; + + // 드래그 완료 시 부모에게 전달 + const handleColorChangeComplete = (color: string) => { + if (showStateTabs && stateMode === 'active') { + setLocalActiveColor(color); + onActiveChange?.(color); + onActiveChangeComplete?.(color); + return; + } + + setLocalColor(color); + onChange?.(color); + onChangeComplete?.(color); + }; + + const handleStateModeChange = (nextMode: 'idle' | 'active') => { + if (!showStateTabs) return; + if (isStateControlled) { + externalOnStateModeChange(nextMode); + return; + } + setInternalStateMode(nextMode); + }; + + const getDisplayColor = (color: string): string => { + if (!color) return '#ffffff'; + if (color.startsWith('rgba') || color.startsWith('rgb')) return color; + if (color.startsWith('#')) return color; + return '#ffffff'; + }; + + // ── gradient 배선 — onModeCommit이 주어진 경우에만 활성화 ── + const supportsGradient = onModeCommit !== undefined; + const storedGradient = + stateMode === 'active' + ? activeGradientValue ?? null + : gradientValue ?? null; + + const gradientState = useGradientColorState({ + pair: supportsGradient + ? { + color: + showStateTabs && stateMode === 'active' + ? localActiveColor + : localColor, + gradient: storedGradient, + } + : {}, + fallbackColor: '#ffffff', + contextKey: `${_stableId}:${stateMode}`, + // 패널이 분리돼 있어도 캔버스 핸들은 메인 캔버스에 그려진다 (같은 React 트리) + canvasAnchor: pickerMounted ? canvasAnchor : undefined, + canvasSurface: gradientSurface, + canvasState: stateMode, + onPreview: (modeValue) => { + if (modeValue.mode === 'solid') setLocalColorForState(modeValue.color); + onModePreview?.(stateMode, modeValue); + }, + onCancel, + onCommit: (modeValue) => { + if (modeValue.mode === 'solid') { + // 단색 확정은 일반 완료 파이프라인도 통과 - 부모의 드래그 정산 계약 유지 + handleColorChangeComplete(modeValue.color); + } else { + const base = modeValue.spec.stops[0]?.color ?? '#ffffff'; + if (showStateTabs && stateMode === 'active') setLocalActiveColor(base); + else setLocalColor(base); + } + onModeCommit?.(stateMode, modeValue); + }, + }); + + const handleInputCancel = ( + _target: 'solid' | 'top' | 'bottom', + restoredColor: string | { type: 'gradient'; top: string; bottom: string }, + ) => { + gradientState.cancelPreview(); + if (typeof restoredColor === 'string') { + setLocalColorForState(restoredColor); + } + onCancel?.(); + }; + + return ( + <> + + + {pickerMounted ? ( + gradientState.handlePickerColorChange(c, false) + : handleColorPreview + } + onColorChangeComplete={ + supportsGradient + ? (c: string) => gradientState.handlePickerColorChange(c, true) + : handleColorChangeComplete + } + onClose={handleClose} + onInputCancel={handleInputCancel} + interactiveRefs={interactiveRefs} + solidOnly={solidOnly} + hexMixed={hexMixed} + opacityPercentMixed={alphaMixed} + stateMode={showStateTabs ? stateMode : undefined} + onStateModeChange={ + showStateTabs ? handleStateModeChange : undefined + } + headerSlot={supportsGradient ? gradientState.headerSlot : undefined} + footerSlot={ + supportsGradient ? <>{gradientState.footerSlot} : undefined + } + gradientSpec={ + supportsGradient ? gradientState.paletteGradientSpec : undefined + } + onGradientSpecSelect={ + supportsGradient + ? gradientState.handleGradientSpecSelect + : undefined + } + /> + ) : null} + + + ); +}; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/FontStyleToggle.tsx b/src/renderer/components/main/Grid/PropertiesPanel/FontStyleToggle.tsx new file mode 100644 index 000000000..79796a148 --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/FontStyleToggle.tsx @@ -0,0 +1,185 @@ +import React, { useRef } from 'react'; +import type { FontStyleToggleProps } from './types'; +import { useOptimisticBooleanCommit } from '@hooks/useOptimisticBooleanCommit'; + +// ============================================================================ +// 글꼴 스타일 아이콘 +// ============================================================================ + +// 렌더 크기가 viewBox보다 1px 작아 스트로크 값이 제각각이다 - 넷 다 화면상 1.2 + +const BoldIcon: React.FC = () => ( + + + + +); + +const ItalicIcon: React.FC = () => ( + + + + + +); + +const UnderlineIcon: React.FC = () => ( + + + + +); + +const StrikethroughIcon: React.FC = () => ( + + + + + +); + +// ============================================================================ +// 글꼴 스타일 토글 +// ============================================================================ + +interface FontStyleButtonProps { + active: boolean; + title: string; + onChange: (active: boolean) => void; + children: React.ReactNode; +} + +const FontStyleButton = ({ + active, + title, + onChange, + children, +}: FontStyleButtonProps) => { + const buttonRef = useRef(null); + const { value: visualActive, toggle } = useOptimisticBooleanCommit({ + canonicalValue: active, + onCommit: onChange, + frameHostRef: buttonRef, + }); + const buttonClass = visualActive + ? 'bg-fill-hover text-fg' + : 'text-fg-faint hover:bg-fill hover:text-fg-muted'; + + return ( + + ); +}; + +export const FontStyleToggle: React.FC = ({ + isBold, + isItalic, + isUnderline, + isStrikethrough, + onBoldChange, + onItalicChange, + onUnderlineChange, + onStrikethroughChange, +}) => { + return ( +
+ + + + + + + + + + + + +
+ ); +}; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/PropertyInputs.tsx b/src/renderer/components/main/Grid/PropertiesPanel/PropertyInputs.tsx index 23e26283c..fd3a4f069 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/PropertyInputs.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/PropertyInputs.tsx @@ -1,871 +1,9 @@ -/* eslint-disable react-hooks/set-state-in-effect */ -import React, { - useCallback, - useState, - useEffect, - useLayoutEffect, - useRef, -} from 'react'; -import type { - PropertyRowProps, - TextInputProps, - ColorInputProps, - TabsProps, - FontStyleToggleProps, -} from './types'; -import { TABS } from './types'; -import { - SECTION_CARD_CLASS, - FORM_ROW_CLASS, - FORM_LABEL_CLASS, -} from '@utils/cardRecipes'; -import { I18nContext } from '@contexts/I18nContextDef'; -import ColorPicker from '@components/main/Modal/content/pickers/ColorPicker'; -import PopupExit from '@components/main/Modal/PopupExit'; -import { ColorSwatchButton } from '@components/main/Modal/content/pickers/ColorSwatch'; -import { useGradientColorState } from '@hooks/pickers/useGradientColorState'; -import { gradientToCss } from '@src/types/color'; -import { registerEditorDraftForLifecycle } from '@src/renderer/editor/runtime/lifecycleEditorDraft'; -import { useAfterPaintValueCommit } from '@hooks/useAfterPaintValueCommit'; -import { useOptimisticBooleanCommit } from '@hooks/useOptimisticBooleanCommit'; -import TabSwitch from '@components/main/common/TabSwitch'; - -// ============================================================================ -// 속성 행 -// ============================================================================ - -export const PropertyRow: React.FC = ({ - label, - children, -}) => ( -
-

{label}

-
{children}
-
-); - -// 그룹 카드 — 관련 속성 행을 하나의 면으로 묶는 섹션 컨테이너. -// data 표식은 분리 창 피커가 좌우 정렬·폭을 맞추는 기준 -export const PropertySection: React.FC<{ children: React.ReactNode }> = ({ - children, -}) => ( -
- {children} -
-); - -// ============================================================================ -// 숫자 입력 -// ============================================================================ - +export { PropertyRow, PropertySection } from './PropertyLayout'; export { NumberInput, OptionalNumberInput, } from '@components/main/common/NumberInput'; - -// ============================================================================ -// 텍스트 입력 -// ============================================================================ - -export const TextInput: React.FC = ({ - value, - onChange, - commitStrategy = 'after-paint', - onBlur, - onPreview, - onCancel, - placeholder, - width = '90px', - isMixed = false, -}) => { - const [localValue, setLocalValue] = useState(value); - const [isFocused, setIsFocused] = useState(false); - // Escape로 blur된 경우 확정 없이 원복 - const escapedRef = useRef(false); - const previewedRef = useRef(false); - // 이번 편집에서 값을 내보냈는지. 되돌릴 게 없으면 취소가 호출부를 건드리면 안 된다 - const emittedRef = useRef(false); - const committedValueRef = useRef(value); - const committedMixedRef = useRef(isMixed); - const inputRef = useRef(null); - const sessionActiveRef = useRef(false); - const unregisterLifecycleRef = useRef<(() => void) | null>(null); - const finalizeRef = useRef<(finalValue: string) => void>(() => undefined); - const liveCommit = onPreview ?? onChange; - const { scheduleCommit, flushPendingCommit, cancelPendingCommit } = - useAfterPaintValueCommit({ - onCommit: liveCommit, - strategy: commitStrategy, - frameHostRef: inputRef, - }); - - useEffect(() => { - if (!isFocused) { - setLocalValue(value); - } - }, [value, isFocused]); - - const handleChange = (e: React.ChangeEvent) => { - setLocalValue(e.target.value); - if (onPreview) previewedRef.current = true; - emittedRef.current = true; - scheduleCommit(e.target.value); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - // Enter는 blur를 통해 확정, Escape는 확정 없이 원복 - if (e.key === 'Enter') { - e.currentTarget.blur(); - } else if (e.key === 'Escape') { - // 되돌릴 게 있을 때만 이 필드가 Escape를 소비한다. - // 팝업과 모달은 defaultPrevented로 한 겹씩 닫으므로, 손대지 않은 필드가 삼키면 - // 첫 Escape에 창이 안 닫힌다 - if (emittedRef.current) e.preventDefault(); - escapedRef.current = true; - e.currentTarget.blur(); - } - }; - - const clearLifecycleRegistration = () => { - unregisterLifecycleRef.current?.(); - unregisterLifecycleRef.current = null; - }; - - const finalize = (finalValue: string) => { - if (!sessionActiveRef.current) return; - sessionActiveRef.current = false; - clearLifecycleRegistration(); - setIsFocused(false); - if (escapedRef.current) { - escapedRef.current = false; - cancelPendingCommit(); - previewedRef.current = false; - setLocalValue(committedValueRef.current); - // 취소는 값을 내보낸 것과 같은 채널로 되돌린다. onPreview가 없는 입력은 - // 타이핑이 onChange로 이미 저장까지 갔으므로 되돌릴 길이 그것뿐이다. - // Mixed는 되돌릴 값이 하나가 아니라 대표값을 쓰면 요소별 값이 사라진다 - // 취소 의미이므로 commit 성격의 onBlur는 호출하지 않음 - if (onCancel) { - onCancel(); - } else if (emittedRef.current && !committedMixedRef.current && !isMixed) { - (onPreview ?? onChange)(committedValueRef.current); - } - emittedRef.current = false; - return; - } - // 확정은 입력 컴포넌트의 최종값 기준 (부모 store 재조회 금지) - if (onPreview && previewedRef.current) { - cancelPendingCommit(); - onChange(finalValue); - } else { - flushPendingCommit(); - } - previewedRef.current = false; - emittedRef.current = false; - onBlur?.(finalValue); - }; - - useLayoutEffect(() => { - finalizeRef.current = finalize; - }); - - useEffect( - () => () => { - sessionActiveRef.current = false; - clearLifecycleRegistration(); - }, - [], - ); - - const handleFocus = () => { - setIsFocused(true); - escapedRef.current = false; - previewedRef.current = false; - emittedRef.current = false; - committedValueRef.current = value; - committedMixedRef.current = isMixed; - sessionActiveRef.current = true; - clearLifecycleRegistration(); - unregisterLifecycleRef.current = registerEditorDraftForLifecycle(() => { - finalizeRef.current(inputRef.current?.value ?? value); - }); - }; - - const handleBlur = (event: React.FocusEvent) => { - finalize(event.currentTarget.value); - }; - - return ( - - ); -}; - -// ============================================================================ -// 컬러 입력 -// ============================================================================ - -export const ColorInput: React.FC = ({ - value, - onChange, - onPreview, - pickerMountStrategy = 'after-paint', - onChangeComplete, - activeValue, - onActiveChange, - onActivePreview, - onActiveChangeComplete, - showStateTabs = false, - stateMode: externalStateMode, - onStateModeChange: externalOnStateModeChange, - colorId, - solidOnly = true, - panelElement, - isOpen: externalIsOpen, - onToggle: externalOnToggle, - gradientValue, - activeGradientValue, - onModeCommit, - onModePreview, - onCancel, - canvasAnchor, - gradientSurface = 'background', - hexMixed = false, - alphaMixed = false, -}) => { - const i18n = React.useContext(I18nContext); - // 외부 제어 모드인지 확인 - const isControlled = - externalIsOpen !== undefined && externalOnToggle !== undefined; - - const [internalOpen, setInternalOpen] = useState(false); - const open = isControlled ? externalIsOpen : internalOpen; - const [internalPickerMounted, setInternalPickerMounted] = useState(false); - const pickerMountFrameRef = useRef(null); - const pickerMountTimerRef = useRef(null); - - const cancelPendingPickerMount = useCallback(() => { - if (pickerMountFrameRef.current !== null) { - cancelAnimationFrame(pickerMountFrameRef.current); - pickerMountFrameRef.current = null; - } - if (pickerMountTimerRef.current !== null) { - window.clearTimeout(pickerMountTimerRef.current); - pickerMountTimerRef.current = null; - } - }, []); - - const schedulePickerMount = useCallback(() => { - cancelPendingPickerMount(); - if (pickerMountStrategy === 'sync') { - setInternalPickerMounted(true); - return; - } - pickerMountFrameRef.current = requestAnimationFrame(() => { - pickerMountFrameRef.current = null; - pickerMountTimerRef.current = window.setTimeout(() => { - pickerMountTimerRef.current = null; - setInternalPickerMounted(true); - }, 0); - }); - }, [cancelPendingPickerMount, pickerMountStrategy]); - - const closeInternalPicker = useCallback(() => { - cancelPendingPickerMount(); - setInternalPickerMounted(false); - setInternalOpen(false); - }, [cancelPendingPickerMount]); - - useEffect( - () => () => { - cancelPendingPickerMount(); - }, - [cancelPendingPickerMount], - ); - - const pickerMounted = isControlled ? open : internalPickerMounted; - - const isStateControlled = - externalStateMode !== undefined && externalOnStateModeChange !== undefined; - const [internalStateMode, setInternalStateMode] = useState<'idle' | 'active'>( - 'idle', - ); - const stateMode = - showStateTabs && isStateControlled - ? externalStateMode - : showStateTabs - ? internalStateMode - : 'idle'; - - useEffect(() => { - if (!showStateTabs) { - setInternalStateMode('idle'); - if (!isControlled) closeInternalPicker(); - } - }, [closeInternalPicker, showStateTabs, isControlled]); - - const buttonRef = useRef(null); - - // 로컬 색상 상태 (드래그 중 UI 업데이트용) - const [localColor, setLocalColor] = useState(value || '#FFFFFF'); - const [localActiveColor, setLocalActiveColor] = useState( - activeValue ?? value ?? '#FFFFFF', - ); - - // 피커가 닫혀있을 때만 외부 prop과 동기화 - useEffect(() => { - if (!open) { - setLocalColor(value || '#FFFFFF'); - setLocalActiveColor(activeValue ?? value ?? '#FFFFFF'); - } - }, [value, activeValue, open]); - - // colorId가 없으면 value 기반으로 생성 - const _stableId = - colorId || `color-input-${value?.replace(/[^a-zA-Z0-9]/g, '')}`; - - const interactiveRefs = [buttonRef]; - - // 새로 열 때는 항상 대기 탭에서 시작 - 열림과 같은 이벤트에서 리셋해 - // 첫 렌더·최초 발행부터 이전 "입력" 선택이 새지 않는다 - const resetStateModeToIdle = () => { - if (!showStateTabs) return; - if (isStateControlled) { - externalOnStateModeChange('idle'); - } else { - setInternalStateMode('idle'); - } - }; - - const handleToggle = () => { - if (isControlled) { - if (!open) resetStateModeToIdle(); - externalOnToggle(); - } else if (internalOpen) { - closeInternalPicker(); - } else { - resetStateModeToIdle(); - setInternalOpen(true); - schedulePickerMount(); - } - }; - - // controlled open을 부모가 토글 핸들러 없이 직접 여는 경로도 대기 시작 보장 - // layout effect라 리셋 전 상태가 화면·프리뷰에 새지 않는다 (핸들러 리셋과 중복 무해) - const wasOpenRef = useRef(open); - useLayoutEffect(() => { - if (open && !wasOpenRef.current) resetStateModeToIdle(); - wasOpenRef.current = open; - }); - - const handleClose = () => { - if (isControlled) { - externalOnToggle(); - } else { - closeInternalPicker(); - } - }; - - const setLocalColorForState = (color: string) => { - if (showStateTabs && stateMode === 'active') { - setLocalActiveColor(color); - return; - } - setLocalColor(color); - }; - - // 드래그와 텍스트 입력은 같은 preview 채널을 쓴다 - const handleColorPreview = (color: string) => { - setLocalColorForState(color); - if (showStateTabs && stateMode === 'active') { - onActivePreview?.(color); - return; - } - onPreview?.(color); - }; - - // 드래그 완료 시 부모에게 전달 - const handleColorChangeComplete = (color: string) => { - if (showStateTabs && stateMode === 'active') { - setLocalActiveColor(color); - onActiveChange?.(color); - onActiveChangeComplete?.(color); - return; - } - - setLocalColor(color); - onChange?.(color); - onChangeComplete?.(color); - }; - - const handleStateModeChange = (nextMode: 'idle' | 'active') => { - if (!showStateTabs) return; - if (isStateControlled) { - externalOnStateModeChange(nextMode); - return; - } - setInternalStateMode(nextMode); - }; - - const getDisplayColor = (color: string): string => { - if (!color) return '#ffffff'; - if (color.startsWith('rgba') || color.startsWith('rgb')) return color; - if (color.startsWith('#')) return color; - return '#ffffff'; - }; - - // ── gradient 배선 — onModeCommit이 주어진 경우에만 활성화 ── - const supportsGradient = onModeCommit !== undefined; - const storedGradient = - stateMode === 'active' - ? activeGradientValue ?? null - : gradientValue ?? null; - - const gradientState = useGradientColorState({ - pair: supportsGradient - ? { - color: - showStateTabs && stateMode === 'active' - ? localActiveColor - : localColor, - gradient: storedGradient, - } - : {}, - fallbackColor: '#ffffff', - contextKey: `${_stableId}:${stateMode}`, - // 패널이 분리돼 있어도 캔버스 핸들은 메인 캔버스에 그려진다 (같은 React 트리) - canvasAnchor: pickerMounted ? canvasAnchor : undefined, - canvasSurface: gradientSurface, - canvasState: stateMode, - onPreview: (modeValue) => { - if (modeValue.mode === 'solid') setLocalColorForState(modeValue.color); - onModePreview?.(stateMode, modeValue); - }, - onCancel, - onCommit: (modeValue) => { - if (modeValue.mode === 'solid') { - // 단색 확정은 일반 완료 파이프라인도 통과 - 부모의 드래그 정산 계약 유지 - handleColorChangeComplete(modeValue.color); - } else { - const base = modeValue.spec.stops[0]?.color ?? '#ffffff'; - if (showStateTabs && stateMode === 'active') setLocalActiveColor(base); - else setLocalColor(base); - } - onModeCommit?.(stateMode, modeValue); - }, - }); - - const handleInputCancel = ( - _target: 'solid' | 'top' | 'bottom', - restoredColor: string | { type: 'gradient'; top: string; bottom: string }, - ) => { - gradientState.cancelPreview(); - if (typeof restoredColor === 'string') { - setLocalColorForState(restoredColor); - } - onCancel?.(); - }; - - return ( - <> - - - {pickerMounted ? ( - gradientState.handlePickerColorChange(c, false) - : handleColorPreview - } - onColorChangeComplete={ - supportsGradient - ? (c: string) => gradientState.handlePickerColorChange(c, true) - : handleColorChangeComplete - } - onClose={handleClose} - onInputCancel={handleInputCancel} - interactiveRefs={interactiveRefs} - solidOnly={solidOnly} - hexMixed={hexMixed} - opacityPercentMixed={alphaMixed} - stateMode={showStateTabs ? stateMode : undefined} - onStateModeChange={ - showStateTabs ? handleStateModeChange : undefined - } - headerSlot={supportsGradient ? gradientState.headerSlot : undefined} - footerSlot={ - supportsGradient ? <>{gradientState.footerSlot} : undefined - } - gradientSpec={ - supportsGradient ? gradientState.paletteGradientSpec : undefined - } - onGradientSpecSelect={ - supportsGradient - ? gradientState.handleGradientSpecSelect - : undefined - } - /> - ) : null} - - - ); -}; - -// ============================================================================ -// 글꼴 스타일 아이콘 -// ============================================================================ - -// 렌더 크기가 viewBox보다 1px 작아 스트로크 값이 제각각이다 - 넷 다 화면상 1.2 - -const BoldIcon: React.FC = () => ( - - - - -); - -const ItalicIcon: React.FC = () => ( - - - - - -); - -const UnderlineIcon: React.FC = () => ( - - - - -); - -const StrikethroughIcon: React.FC = () => ( - - - - - -); - -// ============================================================================ -// 글꼴 스타일 토글 -// ============================================================================ - -interface FontStyleButtonProps { - active: boolean; - title: string; - onChange: (active: boolean) => void; - children: React.ReactNode; -} - -const FontStyleButton = ({ - active, - title, - onChange, - children, -}: FontStyleButtonProps) => { - const buttonRef = useRef(null); - const { value: visualActive, toggle } = useOptimisticBooleanCommit({ - canonicalValue: active, - onCommit: onChange, - frameHostRef: buttonRef, - }); - const buttonClass = visualActive - ? 'bg-fill-hover text-fg' - : 'text-fg-faint hover:bg-fill hover:text-fg-muted'; - - return ( - - ); -}; - -export const FontStyleToggle: React.FC = ({ - isBold, - isItalic, - isUnderline, - isStrikethrough, - onBoldChange, - onItalicChange, - onUnderlineChange, - onStrikethroughChange, -}) => { - return ( -
- - - - - - - - - - - - -
- ); -}; - -// ============================================================================ -// 탭 버튼 & 탭 -// ============================================================================ - -export const Tabs: React.FC = ({ - activeTab, - onTabChange, - t, - availableTabs, -}) => { - const tabs = availableTabs?.length - ? availableTabs - : [TABS.STYLE, TABS.NOTE, TABS.COUNTER]; - - const labels: Record = { - [TABS.STYLE]: t('propertiesPanel.tabStyle') || '키', - [TABS.NOTE]: t('propertiesPanel.tabNote') || '노트', - [TABS.COUNTER]: t('propertiesPanel.tabCounter') || '카운터', - }; - - return ( - ({ id: tab, label: labels[tab] }))} - activeTab={activeTab} - onTabChange={onTabChange} - commitStrategy="after-paint" - /> - ); -}; - -// ============================================================================ -// 아이콘 컴포넌트 -// ============================================================================ - -export const CloseIcon: React.FC = () => ( - - - -); - -// 레이어/속성 모드 전환 토글 아이콘 -export const ModeToggleIcon: React.FC<{ - mode: 'layer' | 'property'; - disabled?: boolean; -}> = ({ mode, disabled = false }) => { - const strokeColor = 'currentColor'; - const fillColor = 'currentColor'; - const disabledClass = disabled ? 'text-fg-disabled' : undefined; - - if (mode === 'layer') { - // 레이어 아이콘 (쌓인 레이어) - return ( - - - - - - ); - } - - // 속성 아이콘 (슬라이더/설정) - return ( - - - - - - - - - ); -}; +export { TextInput } from './TextInput'; +export { ColorInput } from './ColorInput'; +export { FontStyleToggle } from './FontStyleToggle'; +export { Tabs, CloseIcon, ModeToggleIcon } from './PropertyTabs'; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/PropertyLayout.tsx b/src/renderer/components/main/Grid/PropertiesPanel/PropertyLayout.tsx new file mode 100644 index 000000000..0abf7d072 --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/PropertyLayout.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import type { PropertyRowProps } from './types'; +import { + FORM_LABEL_CLASS, + FORM_ROW_CLASS, + SECTION_CARD_CLASS, +} from '@utils/cardRecipes'; + +// ============================================================================ +// 속성 행 +// ============================================================================ + +export const PropertyRow: React.FC = ({ + label, + children, +}) => ( +
+

{label}

+
{children}
+
+); + +// 그룹 카드 — 관련 속성 행을 하나의 면으로 묶는 섹션 컨테이너. +// data 표식은 분리 창 피커가 좌우 정렬·폭을 맞추는 기준 +export const PropertySection: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => ( +
+ {children} +
+); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/PropertyTabs.tsx b/src/renderer/components/main/Grid/PropertiesPanel/PropertyTabs.tsx new file mode 100644 index 000000000..e994b2fa7 --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/PropertyTabs.tsx @@ -0,0 +1,136 @@ +import React from 'react'; +import type { TabsProps } from './types'; +import { TABS } from './types'; +import TabSwitch from '@components/main/common/TabSwitch'; + +// ============================================================================ +// 탭 버튼 & 탭 +// ============================================================================ + +export const Tabs: React.FC = ({ + activeTab, + onTabChange, + t, + availableTabs, +}) => { + const tabs = availableTabs?.length + ? availableTabs + : [TABS.STYLE, TABS.NOTE, TABS.COUNTER]; + + const labels: Record = { + [TABS.STYLE]: t('propertiesPanel.tabStyle') || '키', + [TABS.NOTE]: t('propertiesPanel.tabNote') || '노트', + [TABS.COUNTER]: t('propertiesPanel.tabCounter') || '카운터', + }; + + return ( + ({ id: tab, label: labels[tab] }))} + activeTab={activeTab} + onTabChange={onTabChange} + commitStrategy="after-paint" + /> + ); +}; + +// ============================================================================ +// 아이콘 컴포넌트 +// ============================================================================ + +export const CloseIcon: React.FC = () => ( + + + +); + +// 레이어/속성 모드 전환 토글 아이콘 +export const ModeToggleIcon: React.FC<{ + mode: 'layer' | 'property'; + disabled?: boolean; +}> = ({ mode, disabled = false }) => { + const strokeColor = 'currentColor'; + const fillColor = 'currentColor'; + const disabledClass = disabled ? 'text-fg-disabled' : undefined; + + if (mode === 'layer') { + // 레이어 아이콘 (쌓인 레이어) + return ( + + + + + + ); + } + + // 속성 아이콘 (슬라이더/설정) + return ( + + + + + + + + + ); +}; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/TextInput.tsx b/src/renderer/components/main/Grid/PropertiesPanel/TextInput.tsx new file mode 100644 index 000000000..ec4c27e62 --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/TextInput.tsx @@ -0,0 +1,155 @@ +/* eslint-disable react-hooks/set-state-in-effect */ +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import type { TextInputProps } from './types'; +import { registerEditorDraftForLifecycle } from '@src/renderer/editor/runtime/lifecycleEditorDraft'; +import { useAfterPaintValueCommit } from '@hooks/useAfterPaintValueCommit'; + +export const TextInput: React.FC = ({ + value, + onChange, + commitStrategy = 'after-paint', + onBlur, + onPreview, + onCancel, + placeholder, + width = '90px', + isMixed = false, +}) => { + const [localValue, setLocalValue] = useState(value); + const [isFocused, setIsFocused] = useState(false); + // Escape로 blur된 경우 확정 없이 원복 + const escapedRef = useRef(false); + const previewedRef = useRef(false); + // 이번 편집에서 값을 내보냈는지. 되돌릴 게 없으면 취소가 호출부를 건드리면 안 된다 + const emittedRef = useRef(false); + const committedValueRef = useRef(value); + const committedMixedRef = useRef(isMixed); + const inputRef = useRef(null); + const sessionActiveRef = useRef(false); + const unregisterLifecycleRef = useRef<(() => void) | null>(null); + const finalizeRef = useRef<(finalValue: string) => void>(() => undefined); + const liveCommit = onPreview ?? onChange; + const { scheduleCommit, flushPendingCommit, cancelPendingCommit } = + useAfterPaintValueCommit({ + onCommit: liveCommit, + strategy: commitStrategy, + frameHostRef: inputRef, + }); + + useEffect(() => { + if (!isFocused) { + setLocalValue(value); + } + }, [value, isFocused]); + + const handleChange = (e: React.ChangeEvent) => { + setLocalValue(e.target.value); + if (onPreview) previewedRef.current = true; + emittedRef.current = true; + scheduleCommit(e.target.value); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + // Enter는 blur를 통해 확정, Escape는 확정 없이 원복 + if (e.key === 'Enter') { + e.currentTarget.blur(); + } else if (e.key === 'Escape') { + // 되돌릴 게 있을 때만 이 필드가 Escape를 소비한다. + // 팝업과 모달은 defaultPrevented로 한 겹씩 닫으므로, 손대지 않은 필드가 삼키면 + // 첫 Escape에 창이 안 닫힌다 + if (emittedRef.current) e.preventDefault(); + escapedRef.current = true; + e.currentTarget.blur(); + } + }; + + const clearLifecycleRegistration = () => { + unregisterLifecycleRef.current?.(); + unregisterLifecycleRef.current = null; + }; + + const finalize = (finalValue: string) => { + if (!sessionActiveRef.current) return; + sessionActiveRef.current = false; + clearLifecycleRegistration(); + setIsFocused(false); + if (escapedRef.current) { + escapedRef.current = false; + cancelPendingCommit(); + previewedRef.current = false; + setLocalValue(committedValueRef.current); + // 취소는 값을 내보낸 것과 같은 채널로 되돌린다. onPreview가 없는 입력은 + // 타이핑이 onChange로 이미 저장까지 갔으므로 되돌릴 길이 그것뿐이다. + // Mixed는 되돌릴 값이 하나가 아니라 대표값을 쓰면 요소별 값이 사라진다 + // 취소 의미이므로 commit 성격의 onBlur는 호출하지 않음 + if (onCancel) { + onCancel(); + } else if (emittedRef.current && !committedMixedRef.current && !isMixed) { + (onPreview ?? onChange)(committedValueRef.current); + } + emittedRef.current = false; + return; + } + // 확정은 입력 컴포넌트의 최종값 기준 (부모 store 재조회 금지) + if (onPreview && previewedRef.current) { + cancelPendingCommit(); + onChange(finalValue); + } else { + flushPendingCommit(); + } + previewedRef.current = false; + emittedRef.current = false; + onBlur?.(finalValue); + }; + + useLayoutEffect(() => { + finalizeRef.current = finalize; + }); + + useEffect( + () => () => { + sessionActiveRef.current = false; + clearLifecycleRegistration(); + }, + [], + ); + + const handleFocus = () => { + setIsFocused(true); + escapedRef.current = false; + previewedRef.current = false; + emittedRef.current = false; + committedValueRef.current = value; + committedMixedRef.current = isMixed; + sessionActiveRef.current = true; + clearLifecycleRegistration(); + unregisterLifecycleRef.current = registerEditorDraftForLifecycle(() => { + finalizeRef.current(inputRef.current?.value ?? value); + }); + }; + + const handleBlur = (event: React.FocusEvent) => { + finalize(event.currentTarget.value); + }; + + return ( + + ); +}; From 050965a2bdec655070ac04dba1fd5f13fd1b0617 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 19:06:19 +0900 Subject: [PATCH 205/245] =?UTF-8?q?test:=20=ED=94=84=EB=A6=AC=EC=85=8B=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EA=B2=80=EC=A6=9D=20=EC=9A=B0=EC=84=A0?= =?UTF-8?q?=EC=88=9C=EC=9C=84=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/preset/load/tests.rs | 63 +++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/src-tauri/src/commands/preset/load/tests.rs b/src-tauri/src/commands/preset/load/tests.rs index 578e3d936..ec80451b6 100644 --- a/src-tauri/src/commands/preset/load/tests.rs +++ b/src-tauri/src/commands/preset/load/tests.rs @@ -359,6 +359,69 @@ fn preset_rejects_out_of_range_image_transform_with_path() { assert_eq!(invalid_position_style_detail(&valid), None); } +#[test] +fn position_style_validation_first_error_order_is_stable() { + let damaged_gradient = serde_json::json!({ + "angle": 45, + "stops": [ + { "color": " ", "pos": 0 }, + { "color": "#445566", "pos": 1 } + ] + }); + let mut preset = serde_json::json!({ + "keyPositions": { + "later mode": [{ + "backgroundGradient": damaged_gradient.clone(), + "fontGradient": damaged_gradient.clone(), + "shadow": { + "enabled": "yes", + "color": "", + "offsetX": 101, + "offsetY": 101, + "blur": 101 + }, + "idleImageTransform": { + "offsetX": 501, + "offsetY": 501, + "rotation": 181, + "scale": 0 + } + }] + }, + "statPositions": { + "earlier mode": [{ "backgroundGradient": damaged_gradient }] + } + }); + + assert_eq!( + invalid_position_style_detail(&preset).as_deref(), + Some( + "keyPositions[\"later mode\"][0].backgroundGradient: stops[0].color must not be blank" + ) + ); + + let entry = preset["keyPositions"]["later mode"][0] + .as_object_mut() + .unwrap(); + entry.remove("backgroundGradient"); + entry.remove("fontGradient"); + assert_eq!( + invalid_position_style_detail(&preset).as_deref(), + Some("keyPositions[\"later mode\"][0].shadow.enabled: must be a boolean") + ); + + preset["keyPositions"]["later mode"][0] + .as_object_mut() + .unwrap() + .remove("shadow"); + assert_eq!( + invalid_position_style_detail(&preset).as_deref(), + Some( + "keyPositions[\"later mode\"][0].idleImageTransform.offsetX: must be a finite number between -500 and 500" + ) + ); +} + #[test] fn general_gradient_preset_rejects_blank_stop_color_with_index() { // 공백 stop은 로드 복구가 수렴하지 않는 손상 값 - 문에서 거부 From c857217591f86bad7b24f8f1296923106935ae2e Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 19:06:19 +0900 Subject: [PATCH 206/245] =?UTF-8?q?refactor:=20=ED=94=84=EB=A6=AC=EC=85=8B?= =?UTF-8?q?=20=EB=A1=9C=EB=93=9C=20=EA=B2=80=EC=A6=9D=20=EA=B2=BD=EA=B3=84?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/preset/load.rs | 251 +----------------- .../src/commands/preset/load/validation.rs | 248 +++++++++++++++++ 2 files changed, 253 insertions(+), 246 deletions(-) create mode 100644 src-tauri/src/commands/preset/load/validation.rs diff --git a/src-tauri/src/commands/preset/load.rs b/src-tauri/src/commands/preset/load.rs index 5d5934c1a..cd6838c7e 100644 --- a/src-tauri/src/commands/preset/load.rs +++ b/src-tauri/src/commands/preset/load.rs @@ -42,6 +42,7 @@ use super::{ }; mod imported_assets; +mod validation; use imported_assets::{ merge_prepared_tab_preset_fonts, migrate_imported_font_weights, prepare_tab_preset_fonts, @@ -52,253 +53,11 @@ use imported_assets::{ merge_tab_preset_fonts, restore_position_image_reference, restore_preset_local_fonts_in_dir, restore_preset_local_sounds_in_dir, }; - -fn read_preset_file(path: &Path) -> CmdResult { - let content = fs::read_to_string(path)?; - let mut value: serde_json::Value = - serde_json::from_str(&content).map_err(|_| CommandError::msg("invalid-preset"))?; - scrub_removed_text_outline_fields(&mut value); - if let Some(detail) = invalid_position_style_detail(&value) { - return Err(CommandError::msg(format!("invalid-preset: {detail}"))); - } - default_preset_note_gradient_multipliers(&mut value); - serde_json::from_value(value).map_err(|_| CommandError::msg("invalid-preset")) -} - -fn default_preset_note_gradient_multipliers(value: &mut serde_json::Value) { - for collection in POSITION_COLLECTION_FIELDS { - let Some(modes) = value - .get_mut(collection) - .and_then(serde_json::Value::as_object_mut) - else { - continue; - }; - for position in modes - .values_mut() - .filter_map(serde_json::Value::as_array_mut) - .flatten() - { - default_missing_note_gradient_multipliers(position); - } - } -} - -fn invalid_position_style_detail(preset: &serde_json::Value) -> Option { - const ELEMENT_FIELDS: [&str; 9] = [ - "backgroundGradient", - "activeBackgroundGradient", - "borderGradient", - "activeBorderGradient", - "fontGradient", - "activeFontGradient", - "noteBorderGradient", - "noteGradient", - "noteGlowGradient", - ]; - const COUNTER_FIELDS: [&str; 2] = ["fillIdleGradient", "fillActiveGradient"]; - const SHADOW_FIELDS: [&str; 2] = ["shadow", "activeShadow"]; - const IMAGE_TRANSFORM_FIELDS: [&str; 2] = ["idleImageTransform", "activeImageTransform"]; - - for collection_name in POSITION_COLLECTION_FIELDS { - let Some(modes) = preset - .get(collection_name) - .and_then(serde_json::Value::as_object) - else { - continue; - }; - for (mode, entries) in modes { - let Some(entries) = entries.as_array() else { - continue; - }; - for (index, entry) in entries.iter().enumerate() { - let Some(entry) = entry.as_object() else { - continue; - }; - for field in ELEMENT_FIELDS { - let error = match field { - "noteBorderGradient" => invalid_note_gradient_error( - entry.get(field), - "unsupported note border color", - ), - "noteGradient" | "noteGlowGradient" => invalid_note_gradient_error( - entry.get(field), - "unsupported note gradient color", - ), - _ => invalid_gradient_error(entry.get(field)), - }; - if let Some(error) = error { - return Some(format!( - "{collection_name}[{mode:?}][{index}].{field}: {error}" - )); - } - } - for field in SHADOW_FIELDS { - // null은 Option 역직렬화와 동일하게 "값 없음" 취급 - let Some(value) = entry.get(field).filter(|value| !value.is_null()) else { - continue; - }; - if let Some((suffix, error)) = invalid_shadow_error(value) { - return Some(format!( - "{collection_name}[{mode:?}][{index}].{field}{suffix}: {error}" - )); - } - } - for field in IMAGE_TRANSFORM_FIELDS { - let Some(value) = entry.get(field).filter(|value| !value.is_null()) else { - continue; - }; - if let Some((suffix, error)) = invalid_image_transform_error(value) { - return Some(format!( - "{collection_name}[{mode:?}][{index}].{field}{suffix}: {error}" - )); - } - } - let Some(counter) = entry.get("counter").and_then(serde_json::Value::as_object) - else { - continue; - }; - for field in COUNTER_FIELDS { - if let Some(error) = invalid_gradient_error(counter.get(field)) { - return Some(format!( - "{collection_name}[{mode:?}][{index}].counter.{field}: {error}" - )); - } - } - } - } - } - None -} - -fn invalid_gradient_error(value: Option<&serde_json::Value>) -> Option { - let value = value?; - let gradient = match serde_json::from_value::>(value.clone()) { - Ok(gradient) => gradient, - Err(error) => return Some(error.to_string()), - }?; - // 공백 stop 색은 로드 복구가 수렴하지 않는 손상 값이라 문에서 거부 - gradient - .stops - .iter() - .position(|stop| stop.color.trim().is_empty()) - .map(|index| format!("stops[{index}].color must not be blank")) -} - -fn invalid_note_gradient_error( - value: Option<&serde_json::Value>, - color_error: &str, -) -> Option { - let value = value?; - let gradient = match serde_json::from_value::>(value.clone()) { - Ok(gradient) => gradient, - Err(error) => return Some(error.to_string()), - }?; - gradient - .note_border_invalid_stop_index() - .map(|index| format!("stops[{index}].color contains an {color_error}")) -} - -fn invalid_shadow_error(value: &serde_json::Value) -> Option<(&'static str, &'static str)> { - let Some(shadow) = value.as_object() else { - return Some(("", "must be an object")); - }; - if !shadow - .get("enabled") - .is_some_and(serde_json::Value::is_boolean) - { - return Some((".enabled", "must be a boolean")); - } - if shadow - .get("color") - .and_then(serde_json::Value::as_str) - .is_none_or(str::is_empty) - { - return Some((".color", "must be a non-empty string")); - } - for field in ["offsetX", "offsetY"] { - if !shadow - .get(field) - .and_then(serde_json::Value::as_f64) - .is_some_and(|value| { - value.is_finite() && (SHADOW_OFFSET_MIN..=SHADOW_OFFSET_MAX).contains(&value) - }) - { - let suffix = if field == "offsetX" { - ".offsetX" - } else { - ".offsetY" - }; - return Some((suffix, "must be a finite number between -100 and 100")); - } - } - if !shadow - .get("blur") - .and_then(serde_json::Value::as_f64) - .is_some_and(|value| { - value.is_finite() && (SHADOW_BLUR_MIN..=SHADOW_BLUR_MAX).contains(&value) - }) - { - return Some((".blur", "must be a finite number between 0 and 100")); - } - None -} - -// 이미지 변환은 그림자와 같은 정책으로 문에서 거부한다 - 문서 검증(editor.rs)과 같은 범위 -fn invalid_image_transform_error( - value: &serde_json::Value, -) -> Option<(&'static str, &'static str)> { - use crate::models::{ - IMAGE_TRANSFORM_OFFSET_MAX, IMAGE_TRANSFORM_OFFSET_MIN, IMAGE_TRANSFORM_ROTATION_MAX, - IMAGE_TRANSFORM_ROTATION_MIN, IMAGE_TRANSFORM_SCALE_MAX, IMAGE_TRANSFORM_SCALE_MIN, - }; - let Some(transform) = value.as_object() else { - return Some(("", "must be an object")); - }; - for (field, suffix, min, max, error) in [ - ( - "offsetX", - ".offsetX", - IMAGE_TRANSFORM_OFFSET_MIN, - IMAGE_TRANSFORM_OFFSET_MAX, - "must be a finite number between -500 and 500", - ), - ( - "offsetY", - ".offsetY", - IMAGE_TRANSFORM_OFFSET_MIN, - IMAGE_TRANSFORM_OFFSET_MAX, - "must be a finite number between -500 and 500", - ), - ( - "rotation", - ".rotation", - IMAGE_TRANSFORM_ROTATION_MIN, - IMAGE_TRANSFORM_ROTATION_MAX, - "must be a finite number between -180 and 180", - ), - ( - "scale", - ".scale", - IMAGE_TRANSFORM_SCALE_MIN, - IMAGE_TRANSFORM_SCALE_MAX, - "must be a finite number between 0.1 and 10", - ), - ] { - if !transform - .get(field) - .and_then(serde_json::Value::as_f64) - .is_some_and(|value| value.is_finite() && (min..=max).contains(&value)) - { - return Some((suffix, error)); - } - } - None -} - #[cfg(test)] -pub(crate) fn read_preset_file_for_simulation(path: &Path) -> CmdResult { - read_preset_file(path) -} +use validation::invalid_position_style_detail; +use validation::read_preset_file; +#[cfg(test)] +pub(crate) use validation::read_preset_file_for_simulation; struct ResolvedFullPresetSettings { background_color: String, diff --git a/src-tauri/src/commands/preset/load/validation.rs b/src-tauri/src/commands/preset/load/validation.rs new file mode 100644 index 000000000..783fbbd4b --- /dev/null +++ b/src-tauri/src/commands/preset/load/validation.rs @@ -0,0 +1,248 @@ +use super::*; + +pub(super) fn read_preset_file(path: &Path) -> CmdResult { + let content = fs::read_to_string(path)?; + let mut value: serde_json::Value = + serde_json::from_str(&content).map_err(|_| CommandError::msg("invalid-preset"))?; + scrub_removed_text_outline_fields(&mut value); + if let Some(detail) = invalid_position_style_detail(&value) { + return Err(CommandError::msg(format!("invalid-preset: {detail}"))); + } + default_preset_note_gradient_multipliers(&mut value); + serde_json::from_value(value).map_err(|_| CommandError::msg("invalid-preset")) +} + +fn default_preset_note_gradient_multipliers(value: &mut serde_json::Value) { + for collection in POSITION_COLLECTION_FIELDS { + let Some(modes) = value + .get_mut(collection) + .and_then(serde_json::Value::as_object_mut) + else { + continue; + }; + for position in modes + .values_mut() + .filter_map(serde_json::Value::as_array_mut) + .flatten() + { + default_missing_note_gradient_multipliers(position); + } + } +} + +pub(super) fn invalid_position_style_detail(preset: &serde_json::Value) -> Option { + const ELEMENT_FIELDS: [&str; 9] = [ + "backgroundGradient", + "activeBackgroundGradient", + "borderGradient", + "activeBorderGradient", + "fontGradient", + "activeFontGradient", + "noteBorderGradient", + "noteGradient", + "noteGlowGradient", + ]; + const COUNTER_FIELDS: [&str; 2] = ["fillIdleGradient", "fillActiveGradient"]; + const SHADOW_FIELDS: [&str; 2] = ["shadow", "activeShadow"]; + const IMAGE_TRANSFORM_FIELDS: [&str; 2] = ["idleImageTransform", "activeImageTransform"]; + + for collection_name in POSITION_COLLECTION_FIELDS { + let Some(modes) = preset + .get(collection_name) + .and_then(serde_json::Value::as_object) + else { + continue; + }; + for (mode, entries) in modes { + let Some(entries) = entries.as_array() else { + continue; + }; + for (index, entry) in entries.iter().enumerate() { + let Some(entry) = entry.as_object() else { + continue; + }; + for field in ELEMENT_FIELDS { + let error = match field { + "noteBorderGradient" => invalid_note_gradient_error( + entry.get(field), + "unsupported note border color", + ), + "noteGradient" | "noteGlowGradient" => invalid_note_gradient_error( + entry.get(field), + "unsupported note gradient color", + ), + _ => invalid_gradient_error(entry.get(field)), + }; + if let Some(error) = error { + return Some(format!( + "{collection_name}[{mode:?}][{index}].{field}: {error}" + )); + } + } + for field in SHADOW_FIELDS { + // null은 Option 역직렬화와 동일하게 "값 없음" 취급 + let Some(value) = entry.get(field).filter(|value| !value.is_null()) else { + continue; + }; + if let Some((suffix, error)) = invalid_shadow_error(value) { + return Some(format!( + "{collection_name}[{mode:?}][{index}].{field}{suffix}: {error}" + )); + } + } + for field in IMAGE_TRANSFORM_FIELDS { + let Some(value) = entry.get(field).filter(|value| !value.is_null()) else { + continue; + }; + if let Some((suffix, error)) = invalid_image_transform_error(value) { + return Some(format!( + "{collection_name}[{mode:?}][{index}].{field}{suffix}: {error}" + )); + } + } + let Some(counter) = entry.get("counter").and_then(serde_json::Value::as_object) + else { + continue; + }; + for field in COUNTER_FIELDS { + if let Some(error) = invalid_gradient_error(counter.get(field)) { + return Some(format!( + "{collection_name}[{mode:?}][{index}].counter.{field}: {error}" + )); + } + } + } + } + } + None +} + +fn invalid_gradient_error(value: Option<&serde_json::Value>) -> Option { + let value = value?; + let gradient = match serde_json::from_value::>(value.clone()) { + Ok(gradient) => gradient, + Err(error) => return Some(error.to_string()), + }?; + // 공백 stop 색은 로드 복구가 수렴하지 않는 손상 값이라 문에서 거부 + gradient + .stops + .iter() + .position(|stop| stop.color.trim().is_empty()) + .map(|index| format!("stops[{index}].color must not be blank")) +} + +fn invalid_note_gradient_error( + value: Option<&serde_json::Value>, + color_error: &str, +) -> Option { + let value = value?; + let gradient = match serde_json::from_value::>(value.clone()) { + Ok(gradient) => gradient, + Err(error) => return Some(error.to_string()), + }?; + gradient + .note_border_invalid_stop_index() + .map(|index| format!("stops[{index}].color contains an {color_error}")) +} + +fn invalid_shadow_error(value: &serde_json::Value) -> Option<(&'static str, &'static str)> { + let Some(shadow) = value.as_object() else { + return Some(("", "must be an object")); + }; + if !shadow + .get("enabled") + .is_some_and(serde_json::Value::is_boolean) + { + return Some((".enabled", "must be a boolean")); + } + if shadow + .get("color") + .and_then(serde_json::Value::as_str) + .is_none_or(str::is_empty) + { + return Some((".color", "must be a non-empty string")); + } + for field in ["offsetX", "offsetY"] { + if !shadow + .get(field) + .and_then(serde_json::Value::as_f64) + .is_some_and(|value| { + value.is_finite() && (SHADOW_OFFSET_MIN..=SHADOW_OFFSET_MAX).contains(&value) + }) + { + let suffix = if field == "offsetX" { + ".offsetX" + } else { + ".offsetY" + }; + return Some((suffix, "must be a finite number between -100 and 100")); + } + } + if !shadow + .get("blur") + .and_then(serde_json::Value::as_f64) + .is_some_and(|value| { + value.is_finite() && (SHADOW_BLUR_MIN..=SHADOW_BLUR_MAX).contains(&value) + }) + { + return Some((".blur", "must be a finite number between 0 and 100")); + } + None +} + +// 이미지 변환은 그림자와 같은 정책으로 문에서 거부한다 - 문서 검증(editor.rs)과 같은 범위 +fn invalid_image_transform_error( + value: &serde_json::Value, +) -> Option<(&'static str, &'static str)> { + use crate::models::{ + IMAGE_TRANSFORM_OFFSET_MAX, IMAGE_TRANSFORM_OFFSET_MIN, IMAGE_TRANSFORM_ROTATION_MAX, + IMAGE_TRANSFORM_ROTATION_MIN, IMAGE_TRANSFORM_SCALE_MAX, IMAGE_TRANSFORM_SCALE_MIN, + }; + let Some(transform) = value.as_object() else { + return Some(("", "must be an object")); + }; + for (field, suffix, min, max, error) in [ + ( + "offsetX", + ".offsetX", + IMAGE_TRANSFORM_OFFSET_MIN, + IMAGE_TRANSFORM_OFFSET_MAX, + "must be a finite number between -500 and 500", + ), + ( + "offsetY", + ".offsetY", + IMAGE_TRANSFORM_OFFSET_MIN, + IMAGE_TRANSFORM_OFFSET_MAX, + "must be a finite number between -500 and 500", + ), + ( + "rotation", + ".rotation", + IMAGE_TRANSFORM_ROTATION_MIN, + IMAGE_TRANSFORM_ROTATION_MAX, + "must be a finite number between -180 and 180", + ), + ( + "scale", + ".scale", + IMAGE_TRANSFORM_SCALE_MIN, + IMAGE_TRANSFORM_SCALE_MAX, + "must be a finite number between 0.1 and 10", + ), + ] { + if !transform + .get(field) + .and_then(serde_json::Value::as_f64) + .is_some_and(|value| value.is_finite() && (min..=max).contains(&value)) + { + return Some((suffix, error)); + } + } + None +} + +#[cfg(test)] +pub(crate) fn read_preset_file_for_simulation(path: &Path) -> CmdResult { + read_preset_file(path) +} From b98866447e23fc6021face33e7f2ae5ea384cfe4 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 19:10:41 +0900 Subject: [PATCH 207/245] =?UTF-8?q?test:=20OBS=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EA=B3=84=EC=95=BD=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/Settings.obsController.test.tsx | 514 ++++++++++++++++++ 1 file changed, 514 insertions(+) create mode 100644 src/renderer/components/main/Settings.obsController.test.tsx diff --git a/src/renderer/components/main/Settings.obsController.test.tsx b/src/renderer/components/main/Settings.obsController.test.tsx new file mode 100644 index 000000000..061180346 --- /dev/null +++ b/src/renderer/components/main/Settings.obsController.test.tsx @@ -0,0 +1,514 @@ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ObsStatus } from '@src/types/obs'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const obsHarness = vi.hoisted(() => ({ + clearSelection: vi.fn(), + copyText: vi.fn(), + onStatus: vi.fn(), + regenerateToken: vi.fn(), + setAngleMode: vi.fn(), + setAutoUpdateEnabled: vi.fn(), + setDeveloperModeEnabled: vi.fn(), + setHardwareAcceleration: vi.fn(), + setKeyCounterEnabled: vi.fn(), + setLanguage: vi.fn(), + setNoteEffect: vi.fn(), + setOverlayLocked: vi.fn(), + setOverlayResizeAnchor: vi.fn(), + setShortcuts: vi.fn(), + setTrayEnabled: vi.fn(), + setUseCustomCSS: vi.fn(), + setUseCustomJS: vi.fn(), + setAlwaysOnTop: vi.fn(), + settingsUpdate: vi.fn(), + start: vi.fn(), + status: vi.fn(), + statusListener: null as null | ((status: ObsStatus) => void), + stop: vi.fn(), + unsubscribe: vi.fn(), +})); + +vi.mock('@hooks/useLenis', () => ({ + useLenis: () => ({ scrollContainerRef: { current: null } }), +})); +vi.mock('@contexts/useTranslation', () => ({ + useTranslation: () => ({ + t: (key: string, params?: { count?: number }) => + params?.count === undefined ? key : `${key}:${params.count}`, + i18n: { changeLanguage: vi.fn(), language: 'ko' }, + }), +})); +vi.mock('@stores/useSettingsStore', () => ({ + useSettingsStore: () => ({ + hardwareAcceleration: true, + setHardwareAcceleration: obsHarness.setHardwareAcceleration, + alwaysOnTop: false, + setAlwaysOnTop: obsHarness.setAlwaysOnTop, + overlayLocked: false, + setOverlayLocked: obsHarness.setOverlayLocked, + angleMode: 'd3d11', + setAngleMode: obsHarness.setAngleMode, + noteEffect: true, + setNoteEffect: obsHarness.setNoteEffect, + trayEnabled: true, + setTrayEnabled: obsHarness.setTrayEnabled, + autoUpdateEnabled: true, + setAutoUpdateEnabled: obsHarness.setAutoUpdateEnabled, + developerModeEnabled: false, + setDeveloperModeEnabled: obsHarness.setDeveloperModeEnabled, + useCustomCSS: false, + setUseCustomCSS: obsHarness.setUseCustomCSS, + customCSSPath: '', + useCustomJS: false, + setUseCustomJS: obsHarness.setUseCustomJS, + jsPlugins: [], + language: 'ko', + setLanguage: obsHarness.setLanguage, + overlayResizeAnchor: 'top-left', + setOverlayResizeAnchor: obsHarness.setOverlayResizeAnchor, + keyCounterEnabled: true, + setKeyCounterEnabled: obsHarness.setKeyCounterEnabled, + shortcuts: {}, + setShortcuts: obsHarness.setShortcuts, + }), +})); +vi.mock('@stores/data/useKeyStore', () => ({ + useKeyStore: { + getState: () => ({ positions: {}, keyMappings: {} }), + setState: vi.fn(), + }, +})); +vi.mock('@stores/grid/useGridSelectionStore', () => ({ + useGridSelectionStore: { + getState: () => ({ clearSelection: obsHarness.clearSelection }), + }, +})); +vi.mock('@stores/data/useStatItemStore', () => ({ + useStatItemStore: { getState: () => ({ positions: {} }) }, +})); +vi.mock('@stores/data/useGraphItemStore', () => ({ + useGraphItemStore: { getState: () => ({ positions: {} }) }, +})); +vi.mock('@stores/data/useKnobItemStore', () => ({ + useKnobItemStore: { getState: () => ({ positions: {} }) }, +})); +vi.mock('@stores/data/useLayerGroupStore', () => ({ + useLayerGroupStore: { getState: () => ({ layerGroups: {} }) }, +})); +vi.mock('@components/main/common/Dropdown', () => ({ + default: () => null, +})); +vi.mock('@components/main/common/ReloadButton', () => ({ + default: (props: React.ButtonHTMLAttributes) => ( + -
- )} -
+
{ | 'handleRenameCommit' | 'handleRenameCancel' | 'handleRenameStart' + | 't' > > = {}, ) => { @@ -289,6 +290,16 @@ describe('SingleKnobPanel 이름 변경 헤더 계약', () => { }); }); + it('이름 편집 중에는 rename 번역을 평가하지 않는다', () => { + const t = vi.fn((key: string) => key); + render({ isRenaming: true, t: t as never }); + expect(t).not.toHaveBeenCalledWith('contextMenu.rename'); + + t.mockClear(); + render({ isRenaming: false, t: t as never }); + expect(t).toHaveBeenCalledWith('contextMenu.rename'); + }); + it('Enter는 blur 뒤 현재 값을 커밋하고 cancel ref를 초기화한다', () => { const order: string[] = []; const renameCancelledRef = { current: false }; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleKnobPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleKnobPanel.tsx index d5bac8f97..d5921bd33 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleKnobPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleKnobPanel.tsx @@ -18,7 +18,7 @@ import { elementShadowLeafFromPartial, resolveElementShadowForPosition, } from '@src/types/key/shadows'; -import { PANEL_ROOT_CLASS, PANEL_HEADER_CLASS } from '../panelChrome'; +import { PANEL_ROOT_CLASS } from '../panelChrome'; import { PropertyRow, NumberInput, @@ -41,10 +41,10 @@ import type { EditorStylePropertyPreviewPatchV1, EditorElementPropertyPatchV1, } from '@src/types/editor'; -import RenameIcon from './RenameIcon'; import SingleGeometrySection from './SingleGeometrySection'; import SingleImagePickerPopup from './SingleImagePickerPopup'; import { useSingleStyleColorController } from './useSingleStyleColorController'; +import SinglePanelRenameHeader from './SinglePanelRenameHeader'; // ============================================================================ // Single Knob Selection Panel @@ -240,49 +240,21 @@ export const SingleKnobPanel: React.FC = ({ return (
-
- {isRenaming ? ( - setRenameValue(e.target.value)} - onBlur={() => { - if (!renameCancelledRef.current) { - handleRenameCommit(renameValue); - } - renameCancelledRef.current = false; - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - (e.target as HTMLInputElement).blur(); - } else if (e.key === 'Escape') { - e.preventDefault(); - handleRenameCancel(); - } - }} - /> - ) : ( -
- - {knobTitle} - - -
- )} -
+
; + renameValue: string; + setRenameValue: (value: string) => void; + renameCancelledRef: React.MutableRefObject; + handleRenameCommit: (value: string) => void; + handleRenameCancel: () => void; + handleRenameStart: () => void; +} + +const SinglePanelRenameHeader = ({ + title, + titleClassName, + renameButtonTitle, + isRenaming, + renameInputRef, + renameValue, + setRenameValue, + renameCancelledRef, + handleRenameCommit, + handleRenameCancel, + handleRenameStart, +}: SinglePanelRenameHeaderProps) => { + return ( +
+ {isRenaming ? ( + setRenameValue(e.target.value)} + onBlur={() => { + if (!renameCancelledRef.current) { + handleRenameCommit(renameValue); + } + renameCancelledRef.current = false; + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + (e.target as HTMLInputElement).blur(); + } else if (e.key === 'Escape') { + e.preventDefault(); + handleRenameCancel(); + } + }} + /> + ) : ( +
+ + {title} + + +
+ )} +
+ ); +}; + +export default SinglePanelRenameHeader; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx index 6a7159bed..964be86ad 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx @@ -47,8 +47,8 @@ import type { EditorPreviewStylePropertyPatchV1, EditorStylePropertyPreviewPatchV1, } from '@src/types/editor'; -import RenameIcon from './RenameIcon'; import { getStatTypeLabel } from '@utils/grid/statTypeLabel'; +import SinglePanelRenameHeader from './SinglePanelRenameHeader'; // ============================================================================ // Plugin Selection Panel @@ -490,49 +490,21 @@ export const SingleKeyStatPanel: React.FC = ({ {/* 헤더 + 탭 영역 */}
{/* 헤더 */} -
- {isRenaming ? ( - setRenameValue(e.target.value)} - onBlur={() => { - if (!renameCancelledRef.current) { - handleRenameCommit(renameValue); - } - renameCancelledRef.current = false; - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - (e.target as HTMLInputElement).blur(); - } else if (e.key === 'Escape') { - e.preventDefault(); - handleRenameCancel(); - } - }} - /> - ) : ( -
- - {keyLikeTitle} - - -
- )} -
+ {/* 탭 */}
From f17ccc4adadf40503fe60a47c53e1c83d240ba33 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 21:54:55 +0900 Subject: [PATCH 220/245] =?UTF-8?q?refactor:=20=ED=8C=A8=EB=84=90=20?= =?UTF-8?q?=EC=B0=BD=20=EC=9C=84=EC=B9=98=20=EC=A0=80=EC=9E=A5=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/state/app_state/window_geometry.rs | 501 +---------------- .../app_state/window_geometry/persistence.rs | 527 ++++++++++++++++++ 2 files changed, 536 insertions(+), 492 deletions(-) create mode 100644 src-tauri/src/state/app_state/window_geometry/persistence.rs diff --git a/src-tauri/src/state/app_state/window_geometry.rs b/src-tauri/src/state/app_state/window_geometry.rs index 197936f3c..bae6d3ed3 100644 --- a/src-tauri/src/state/app_state/window_geometry.rs +++ b/src-tauri/src/state/app_state/window_geometry.rs @@ -1,64 +1,14 @@ use super::*; -#[derive(Debug, Clone, Copy, PartialEq)] -pub(super) struct PanelBoundsSample { - pub(super) position: PhysicalPosition, - pub(super) position_scale_factor: f64, - pub(super) size: PhysicalSize, - pub(super) size_scale_factor: f64, - pub(super) current_scale_factor: f64, -} - -#[derive(Debug, Clone, Copy)] -pub(super) enum PanelBoundsChange { - Snapshot(PanelBoundsSample), - Moved(PhysicalPosition), - Resized(PhysicalSize), - ScaleFactorChanged { - position: Option>, - size: PhysicalSize, - scale_factor: f64, - }, -} - -impl PanelBoundsChange { - // 복원에 쓰는 값은 높이뿐이라 이동은 디스크로 가지 않는다. - // x/y는 store 호환을 위해 계속 기록만 되고 창 배치에는 쓰이지 않음 - pub(super) fn changes_persisted_bounds(self) -> bool { - !matches!(self, Self::Moved(_)) - } -} - -#[derive(Default)] -pub(super) struct PanelBoundsPersistenceState { - pub(super) latest: Option, - pub(super) window: Option, - pub(super) applied_max_height: Option, - // 초기화로 발생한 resize가 비운 저장값을 되살리지 않게 하는 기본 높이 추적 - pub(super) unpersisted_default_height: Option, - pub(super) default_height_pending: bool, - pub(super) session: u64, - pub(super) generation: u64, - pub(super) worker_running: bool, - // dirty는 워커가 처리할 변경이 남았는지, persist_dirty는 그중 저장까지 필요한지 - pub(super) dirty: bool, - pub(super) persist_dirty: bool, - pub(super) active: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub(super) struct PanelBoundsPersistWork { - pub(super) session: u64, - pub(super) generation: u64, - pub(super) sample: PanelBoundsSample, - pub(super) persist: bool, -} - -pub(super) struct PanelBoundsPersistenceController { - store: Arc, - state: Mutex, - persist_lock: Mutex<()>, -} +mod persistence; +#[cfg(test)] +pub(super) use persistence::{ + apply_panel_bounds_change, changed_panel_max_height, panel_bounds_from_sample, + PanelBoundsPersistenceState, PanelBoundsSample, +}; +pub(super) use persistence::{ + panel_bounds_sample_from_window, PanelBoundsChange, PanelBoundsPersistenceController, +}; pub(super) fn convert_physical_bounds_to_logical( bounds: &OverlayBounds, @@ -418,439 +368,6 @@ pub(super) fn resolve_panel_window_layout( } } -pub(super) fn changed_panel_max_height(previous: Option, next: f64) -> Option { - if !next.is_finite() || next <= 0.0 { - return None; - } - if previous.is_some_and(|value| (value - next).abs() < 0.5) { - return None; - } - Some(next) -} - -pub(super) fn panel_bounds_sample_from_window(window: &WebviewWindow) -> Result { - let scale_factor = window.scale_factor().unwrap_or(1.0); - Ok(PanelBoundsSample { - position: window.outer_position()?, - position_scale_factor: scale_factor, - size: window.inner_size()?, - size_scale_factor: scale_factor, - current_scale_factor: scale_factor, - }) -} - -pub(super) fn valid_panel_scale_factor(scale_factor: f64) -> f64 { - if scale_factor.is_finite() && scale_factor > 0.0 { - scale_factor - } else { - 1.0 - } -} - -pub(super) fn panel_bounds_from_sample(sample: PanelBoundsSample) -> PanelBounds { - let position = sample - .position - .to_logical::(valid_panel_scale_factor(sample.position_scale_factor)); - let size = sample - .size - .to_logical::(valid_panel_scale_factor(sample.size_scale_factor)); - PanelBounds { - x: position.x, - y: position.y, - height: size.height, - } -} - -pub(super) fn apply_panel_bounds_change(sample: &mut PanelBoundsSample, change: PanelBoundsChange) { - match change { - PanelBoundsChange::Snapshot(snapshot) => *sample = snapshot, - PanelBoundsChange::Moved(position) => { - sample.position = position; - sample.position_scale_factor = sample.current_scale_factor; - } - PanelBoundsChange::Resized(size) => { - sample.size = size; - sample.size_scale_factor = sample.current_scale_factor; - } - PanelBoundsChange::ScaleFactorChanged { - position, - size, - scale_factor, - } => { - let scale_factor = valid_panel_scale_factor(scale_factor); - sample.current_scale_factor = scale_factor; - if let Some(position) = position { - sample.position = position; - sample.position_scale_factor = scale_factor; - } - sample.size = size; - sample.size_scale_factor = scale_factor; - } - } -} - -impl PanelBoundsPersistenceController { - pub(super) fn new(store: Arc) -> Self { - Self { - store, - state: Mutex::new(PanelBoundsPersistenceState::default()), - persist_lock: Mutex::new(()), - } - } - - pub(super) fn attach(&self, window: &WebviewWindow, max_height: f64) -> u64 { - let latest = panel_bounds_sample_from_window(window).ok(); - let mut state = self.state.lock(); - state.session = state.session.wrapping_add(1); - state.latest = latest; - state.window = Some(window.clone()); - state.applied_max_height = Some(max_height); - state.unpersisted_default_height = None; - state.default_height_pending = false; - state.generation = state.generation.wrapping_add(1); - state.dirty = false; - state.persist_dirty = false; - state.active = true; - state.session - } - - pub(super) fn record_event(self: &Arc, session: u64, change: PanelBoundsChange) { - let should_spawn = { - let mut state = self.state.lock(); - if !state.active || state.session != session { - return; - } - if state.latest.is_none() { - state.latest = state - .window - .as_ref() - .and_then(|window| panel_bounds_sample_from_window(window).ok()); - } - Self::record_change(&mut state, session, change) - }; - if should_spawn { - self.spawn_worker(); - } - } - - pub(super) fn record_change( - state: &mut PanelBoundsPersistenceState, - session: u64, - change: PanelBoundsChange, - ) -> bool { - if !state.active || state.session != session { - return false; - } - let persist = change.changes_persisted_bounds(); - if let PanelBoundsChange::Snapshot(snapshot) = change { - state.latest = Some(snapshot); - } else { - let Some(latest) = state.latest.as_mut() else { - return false; - }; - apply_panel_bounds_change(latest, change); - } - let persist = Self::should_persist_change(state, persist); - Self::mark_dirty(state, persist) - } - - pub(super) fn should_persist_change( - state: &mut PanelBoundsPersistenceState, - persist: bool, - ) -> bool { - if !persist { - return false; - } - let Some(default_height) = state.unpersisted_default_height else { - return true; - }; - let Some(sample) = state.latest else { - state.unpersisted_default_height = None; - state.default_height_pending = false; - return true; - }; - let height = panel_bounds_from_sample(sample).height; - if (height - default_height).abs() < 0.5 { - state.default_height_pending = false; - return false; - } - if state.default_height_pending { - return false; - } - state.unpersisted_default_height = None; - true - } - - // 이동만 바뀌어도 워커는 깨운다 - 모니터가 바뀌면 높이 한계를 다시 걸어야 하기 때문 - pub(super) fn mark_dirty(state: &mut PanelBoundsPersistenceState, persist: bool) -> bool { - state.generation = state.generation.wrapping_add(1); - state.dirty = true; - state.persist_dirty |= persist; - let should_spawn = !state.worker_running; - state.worker_running = true; - should_spawn - } - - pub(super) fn take_dirty_work( - state: &mut PanelBoundsPersistenceState, - ) -> Option { - let sample = state.latest.filter(|_| state.active && state.dirty)?; - let persist = state.persist_dirty; - state.dirty = false; - state.persist_dirty = false; - Some(PanelBoundsPersistWork { - session: state.session, - generation: state.generation, - sample, - persist, - }) - } - - pub(super) fn work_is_current( - state: &PanelBoundsPersistenceState, - work: &PanelBoundsPersistWork, - ) -> bool { - state.active && state.session == work.session && state.generation == work.generation - } - - pub(super) fn restore_failed_work( - state: &mut PanelBoundsPersistenceState, - work: &PanelBoundsPersistWork, - ) -> bool { - if !Self::work_is_current(state, work) { - return false; - } - state.dirty = true; - state.persist_dirty |= work.persist; - true - } - - pub(super) fn persist_sample(&self, sample: PanelBoundsSample) -> Result<()> { - let bounds = panel_bounds_from_sample(sample); - self.store - .update_deferred(move |data| { - data.panel_bounds = Some(bounds); - }) - .context("failed to capture settled panel bounds")?; - self.store - .flush() - .context("failed to flush settled panel bounds") - } - - pub(super) fn clear_saved_bounds(&self, layout: Option<&PanelWindowLayout>) -> Result<()> { - let _persist_guard = self.persist_lock.lock(); - self.store - .update_deferred(|data| data.panel_bounds = None) - .context("failed to clear saved panel bounds")?; - self.store - .flush() - .context("failed to flush cleared panel bounds")?; - - let mut state = self.state.lock(); - let default_height = layout.map(|value| value.height); - let current_height = state - .latest - .map(panel_bounds_from_sample) - .map(|value| value.height); - state.unpersisted_default_height = default_height; - state.default_height_pending = default_height - .zip(current_height) - .is_none_or(|(default, current)| (default - current).abs() >= 0.5); - state.applied_max_height = layout.map(|value| value.max_height); - state.generation = state.generation.wrapping_add(1); - state.dirty = false; - state.persist_dirty = false; - Ok(()) - } - - pub(super) fn persist_worker_work(&self, work: PanelBoundsPersistWork) -> Result { - // 이동만 바뀐 구간은 디스크를 건드리지 않는다 - 저장 값은 높이뿐이라 쓸 이유가 없다 - if !work.persist { - return Ok(false); - } - let _persist_guard = self.persist_lock.lock(); - if !Self::work_is_current(&self.state.lock(), &work) { - return Ok(false); - } - self.persist_sample(work.sample)?; - Ok(true) - } - - pub(super) fn spawn_worker(self: &Arc) { - let controller = Arc::clone(self); - tauri::async_runtime::spawn(async move { - loop { - tokio::time::sleep(Duration::from_millis(PANEL_BOUNDS_DEBOUNCE_MS)).await; - let (work, window) = { - let mut state = controller.state.lock(); - let Some(work) = Self::take_dirty_work(&mut state) else { - state.worker_running = false; - return; - }; - (work, state.window.clone()) - }; - - let persist_result = controller.persist_worker_work(work); - if let Err(err) = &persist_result { - log::warn!("failed to persist settled panel bounds: {err:#}"); - } - - if let Some(window) = window { - let constraint_controller = Arc::clone(&controller); - let constraint_window = window.clone(); - if let Err(err) = window.app_handle().run_on_main_thread(move || { - if let Err(err) = constraint_controller.apply_monitor_constraints( - &constraint_window, - work.session, - work.generation, - ) { - log::warn!("failed to update settled panel monitor constraints: {err}"); - } - }) { - log::warn!("failed to schedule settled panel constraints: {err}"); - } - } - - let mut state = controller.state.lock(); - if persist_result.is_err() && Self::restore_failed_work(&mut state, &work) { - state.worker_running = false; - return; - } - if !state.active || !state.dirty { - state.worker_running = false; - return; - } - } - }); - } - - pub(super) fn apply_monitor_constraints( - &self, - window: &WebviewWindow, - session: u64, - generation: u64, - ) -> Result<()> { - let Some((min_height, monitor_max_height)) = window - .current_monitor()? - .and_then(MonitorSpec::from_monitor) - .map(|monitor| panel_height_bounds(Some(monitor.logical_height))) - else { - return Ok(()); - }; - let max_height = { - let state = self.state.lock(); - if !state.active || state.session != session || state.generation != generation { - return Ok(()); - } - changed_panel_max_height(state.applied_max_height, monitor_max_height) - }; - let Some(max_height) = max_height else { - return Ok(()); - }; - // 좁은 모니터로 옮겨가면 하한도 함께 내려야 창이 화면 밖으로 나가지 않음 - window.set_min_size(Some(tauri::Size::Logical(tauri::LogicalSize::new( - PANEL_WIDTH, - min_height, - ))))?; - window.set_max_size(Some(tauri::Size::Logical(tauri::LogicalSize::new( - PANEL_WIDTH, - max_height, - ))))?; - let mut state = self.state.lock(); - if state.active && state.session == session && state.generation == generation { - state.applied_max_height = Some(max_height); - } - Ok(()) - } - - pub(super) fn flush_now(&self, window: &WebviewWindow) -> Result<()> { - let _persist_guard = self.persist_lock.lock(); - let generation_before_sample = self.state.lock().generation; - let sampled = panel_bounds_sample_from_window(window); - let persisted = - Self::flush_samples(&self.state, generation_before_sample, sampled, |sample| { - self.persist_sample(sample) - })?; - if !persisted { - self.store - .flush() - .context("failed to flush store with cleared panel bounds")?; - } - Ok(()) - } - - pub(super) fn flush_samples( - state_mutex: &Mutex, - generation_before_sample: u64, - sampled: Result, - mut persist: impl FnMut(PanelBoundsSample) -> Result<()>, - ) -> Result { - let mut work = { - let mut state = state_mutex.lock(); - let sampled = match sampled { - Ok(sample) => sample, - Err(error) => state.latest.ok_or(error)?, - }; - let sample = if state.generation != generation_before_sample { - state.latest.unwrap_or(sampled) - } else { - sampled - }; - state.latest = Some(sample); - state.generation = state.generation.wrapping_add(1); - state.dirty = false; - state.persist_dirty = false; - let persist = Self::should_persist_change(&mut state, true); - PanelBoundsPersistWork { - session: state.session, - generation: state.generation, - sample, - persist, - } - }; - let mut persisted = false; - - loop { - if work.persist { - if let Err(error) = persist(work.sample) { - Self::restore_failed_work(&mut state_mutex.lock(), &work); - return Err(error); - } - persisted = true; - } - let next = { - let mut state = state_mutex.lock(); - let Some(mut next) = Self::take_dirty_work(&mut state) else { - return Ok(persisted); - }; - next.persist = Self::should_persist_change(&mut state, true); - next - }; - work = next; - } - } - - pub(super) fn deactivate(&self, session: u64) { - let mut state = self.state.lock(); - Self::deactivate_state(&mut state, session); - } - - pub(super) fn deactivate_state(state: &mut PanelBoundsPersistenceState, session: u64) -> bool { - if state.session != session { - return false; - } - state.active = false; - state.window = None; - state.applied_max_height = None; - state.unpersisted_default_height = None; - state.default_height_pending = false; - state.generation = state.generation.wrapping_add(1); - state.dirty = false; - state.persist_dirty = false; - true - } -} - pub(super) fn compute_overlay_position( bounds: &OverlayBounds, had_stored_bounds: bool, diff --git a/src-tauri/src/state/app_state/window_geometry/persistence.rs b/src-tauri/src/state/app_state/window_geometry/persistence.rs new file mode 100644 index 000000000..1517006c5 --- /dev/null +++ b/src-tauri/src/state/app_state/window_geometry/persistence.rs @@ -0,0 +1,527 @@ +use super::*; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(in crate::state::app_state) struct PanelBoundsSample { + pub(in crate::state::app_state) position: PhysicalPosition, + pub(in crate::state::app_state) position_scale_factor: f64, + pub(in crate::state::app_state) size: PhysicalSize, + pub(in crate::state::app_state) size_scale_factor: f64, + pub(in crate::state::app_state) current_scale_factor: f64, +} + +#[derive(Debug, Clone, Copy)] +pub(in crate::state::app_state) enum PanelBoundsChange { + Snapshot(PanelBoundsSample), + Moved(PhysicalPosition), + Resized(PhysicalSize), + ScaleFactorChanged { + position: Option>, + size: PhysicalSize, + scale_factor: f64, + }, +} + +impl PanelBoundsChange { + // 복원에 쓰는 값은 높이뿐이라 이동은 디스크로 가지 않는다. + // x/y는 store 호환을 위해 계속 기록만 되고 창 배치에는 쓰이지 않음 + pub(in crate::state::app_state) fn changes_persisted_bounds(self) -> bool { + !matches!(self, Self::Moved(_)) + } +} + +#[derive(Default)] +pub(in crate::state::app_state) struct PanelBoundsPersistenceState { + pub(in crate::state::app_state) latest: Option, + pub(in crate::state::app_state) window: Option, + pub(in crate::state::app_state) applied_max_height: Option, + // 초기화로 발생한 resize가 비운 저장값을 되살리지 않게 하는 기본 높이 추적 + pub(in crate::state::app_state) unpersisted_default_height: Option, + pub(in crate::state::app_state) default_height_pending: bool, + pub(in crate::state::app_state) session: u64, + pub(in crate::state::app_state) generation: u64, + pub(in crate::state::app_state) worker_running: bool, + // dirty는 워커가 처리할 변경이 남았는지, persist_dirty는 그중 저장까지 필요한지 + pub(in crate::state::app_state) dirty: bool, + pub(in crate::state::app_state) persist_dirty: bool, + pub(in crate::state::app_state) active: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(in crate::state::app_state) struct PanelBoundsPersistWork { + pub(in crate::state::app_state) session: u64, + pub(in crate::state::app_state) generation: u64, + pub(in crate::state::app_state) sample: PanelBoundsSample, + pub(in crate::state::app_state) persist: bool, +} + +pub(in crate::state::app_state) struct PanelBoundsPersistenceController { + store: Arc, + state: Mutex, + persist_lock: Mutex<()>, +} + +pub(in crate::state::app_state) fn changed_panel_max_height( + previous: Option, + next: f64, +) -> Option { + if !next.is_finite() || next <= 0.0 { + return None; + } + if previous.is_some_and(|value| (value - next).abs() < 0.5) { + return None; + } + Some(next) +} + +pub(in crate::state::app_state) fn panel_bounds_sample_from_window( + window: &WebviewWindow, +) -> Result { + let scale_factor = window.scale_factor().unwrap_or(1.0); + Ok(PanelBoundsSample { + position: window.outer_position()?, + position_scale_factor: scale_factor, + size: window.inner_size()?, + size_scale_factor: scale_factor, + current_scale_factor: scale_factor, + }) +} + +pub(in crate::state::app_state) fn valid_panel_scale_factor(scale_factor: f64) -> f64 { + if scale_factor.is_finite() && scale_factor > 0.0 { + scale_factor + } else { + 1.0 + } +} + +pub(in crate::state::app_state) fn panel_bounds_from_sample( + sample: PanelBoundsSample, +) -> PanelBounds { + let position = sample + .position + .to_logical::(valid_panel_scale_factor(sample.position_scale_factor)); + let size = sample + .size + .to_logical::(valid_panel_scale_factor(sample.size_scale_factor)); + PanelBounds { + x: position.x, + y: position.y, + height: size.height, + } +} + +pub(in crate::state::app_state) fn apply_panel_bounds_change( + sample: &mut PanelBoundsSample, + change: PanelBoundsChange, +) { + match change { + PanelBoundsChange::Snapshot(snapshot) => *sample = snapshot, + PanelBoundsChange::Moved(position) => { + sample.position = position; + sample.position_scale_factor = sample.current_scale_factor; + } + PanelBoundsChange::Resized(size) => { + sample.size = size; + sample.size_scale_factor = sample.current_scale_factor; + } + PanelBoundsChange::ScaleFactorChanged { + position, + size, + scale_factor, + } => { + let scale_factor = valid_panel_scale_factor(scale_factor); + sample.current_scale_factor = scale_factor; + if let Some(position) = position { + sample.position = position; + sample.position_scale_factor = scale_factor; + } + sample.size = size; + sample.size_scale_factor = scale_factor; + } + } +} + +impl PanelBoundsPersistenceController { + pub(in crate::state::app_state) fn new(store: Arc) -> Self { + Self { + store, + state: Mutex::new(PanelBoundsPersistenceState::default()), + persist_lock: Mutex::new(()), + } + } + + pub(in crate::state::app_state) fn attach( + &self, + window: &WebviewWindow, + max_height: f64, + ) -> u64 { + let latest = panel_bounds_sample_from_window(window).ok(); + let mut state = self.state.lock(); + state.session = state.session.wrapping_add(1); + state.latest = latest; + state.window = Some(window.clone()); + state.applied_max_height = Some(max_height); + state.unpersisted_default_height = None; + state.default_height_pending = false; + state.generation = state.generation.wrapping_add(1); + state.dirty = false; + state.persist_dirty = false; + state.active = true; + state.session + } + + pub(in crate::state::app_state) fn record_event( + self: &Arc, + session: u64, + change: PanelBoundsChange, + ) { + let should_spawn = { + let mut state = self.state.lock(); + if !state.active || state.session != session { + return; + } + if state.latest.is_none() { + state.latest = state + .window + .as_ref() + .and_then(|window| panel_bounds_sample_from_window(window).ok()); + } + Self::record_change(&mut state, session, change) + }; + if should_spawn { + self.spawn_worker(); + } + } + + pub(in crate::state::app_state) fn record_change( + state: &mut PanelBoundsPersistenceState, + session: u64, + change: PanelBoundsChange, + ) -> bool { + if !state.active || state.session != session { + return false; + } + let persist = change.changes_persisted_bounds(); + if let PanelBoundsChange::Snapshot(snapshot) = change { + state.latest = Some(snapshot); + } else { + let Some(latest) = state.latest.as_mut() else { + return false; + }; + apply_panel_bounds_change(latest, change); + } + let persist = Self::should_persist_change(state, persist); + Self::mark_dirty(state, persist) + } + + pub(in crate::state::app_state) fn should_persist_change( + state: &mut PanelBoundsPersistenceState, + persist: bool, + ) -> bool { + if !persist { + return false; + } + let Some(default_height) = state.unpersisted_default_height else { + return true; + }; + let Some(sample) = state.latest else { + state.unpersisted_default_height = None; + state.default_height_pending = false; + return true; + }; + let height = panel_bounds_from_sample(sample).height; + if (height - default_height).abs() < 0.5 { + state.default_height_pending = false; + return false; + } + if state.default_height_pending { + return false; + } + state.unpersisted_default_height = None; + true + } + + // 이동만 바뀌어도 워커는 깨운다 - 모니터가 바뀌면 높이 한계를 다시 걸어야 하기 때문 + pub(in crate::state::app_state) fn mark_dirty( + state: &mut PanelBoundsPersistenceState, + persist: bool, + ) -> bool { + state.generation = state.generation.wrapping_add(1); + state.dirty = true; + state.persist_dirty |= persist; + let should_spawn = !state.worker_running; + state.worker_running = true; + should_spawn + } + + pub(in crate::state::app_state) fn take_dirty_work( + state: &mut PanelBoundsPersistenceState, + ) -> Option { + let sample = state.latest.filter(|_| state.active && state.dirty)?; + let persist = state.persist_dirty; + state.dirty = false; + state.persist_dirty = false; + Some(PanelBoundsPersistWork { + session: state.session, + generation: state.generation, + sample, + persist, + }) + } + + pub(in crate::state::app_state) fn work_is_current( + state: &PanelBoundsPersistenceState, + work: &PanelBoundsPersistWork, + ) -> bool { + state.active && state.session == work.session && state.generation == work.generation + } + + pub(in crate::state::app_state) fn restore_failed_work( + state: &mut PanelBoundsPersistenceState, + work: &PanelBoundsPersistWork, + ) -> bool { + if !Self::work_is_current(state, work) { + return false; + } + state.dirty = true; + state.persist_dirty |= work.persist; + true + } + + pub(in crate::state::app_state) fn persist_sample( + &self, + sample: PanelBoundsSample, + ) -> Result<()> { + let bounds = panel_bounds_from_sample(sample); + self.store + .update_deferred(move |data| { + data.panel_bounds = Some(bounds); + }) + .context("failed to capture settled panel bounds")?; + self.store + .flush() + .context("failed to flush settled panel bounds") + } + + pub(in crate::state::app_state) fn clear_saved_bounds( + &self, + layout: Option<&PanelWindowLayout>, + ) -> Result<()> { + let _persist_guard = self.persist_lock.lock(); + self.store + .update_deferred(|data| data.panel_bounds = None) + .context("failed to clear saved panel bounds")?; + self.store + .flush() + .context("failed to flush cleared panel bounds")?; + + let mut state = self.state.lock(); + let default_height = layout.map(|value| value.height); + let current_height = state + .latest + .map(panel_bounds_from_sample) + .map(|value| value.height); + state.unpersisted_default_height = default_height; + state.default_height_pending = default_height + .zip(current_height) + .is_none_or(|(default, current)| (default - current).abs() >= 0.5); + state.applied_max_height = layout.map(|value| value.max_height); + state.generation = state.generation.wrapping_add(1); + state.dirty = false; + state.persist_dirty = false; + Ok(()) + } + + pub(in crate::state::app_state) fn persist_worker_work( + &self, + work: PanelBoundsPersistWork, + ) -> Result { + // 이동만 바뀐 구간은 디스크를 건드리지 않는다 - 저장 값은 높이뿐이라 쓸 이유가 없다 + if !work.persist { + return Ok(false); + } + let _persist_guard = self.persist_lock.lock(); + if !Self::work_is_current(&self.state.lock(), &work) { + return Ok(false); + } + self.persist_sample(work.sample)?; + Ok(true) + } + + pub(in crate::state::app_state) fn spawn_worker(self: &Arc) { + let controller = Arc::clone(self); + tauri::async_runtime::spawn(async move { + loop { + tokio::time::sleep(Duration::from_millis(PANEL_BOUNDS_DEBOUNCE_MS)).await; + let (work, window) = { + let mut state = controller.state.lock(); + let Some(work) = Self::take_dirty_work(&mut state) else { + state.worker_running = false; + return; + }; + (work, state.window.clone()) + }; + + let persist_result = controller.persist_worker_work(work); + if let Err(err) = &persist_result { + log::warn!("failed to persist settled panel bounds: {err:#}"); + } + + if let Some(window) = window { + let constraint_controller = Arc::clone(&controller); + let constraint_window = window.clone(); + if let Err(err) = window.app_handle().run_on_main_thread(move || { + if let Err(err) = constraint_controller.apply_monitor_constraints( + &constraint_window, + work.session, + work.generation, + ) { + log::warn!("failed to update settled panel monitor constraints: {err}"); + } + }) { + log::warn!("failed to schedule settled panel constraints: {err}"); + } + } + + let mut state = controller.state.lock(); + if persist_result.is_err() && Self::restore_failed_work(&mut state, &work) { + state.worker_running = false; + return; + } + if !state.active || !state.dirty { + state.worker_running = false; + return; + } + } + }); + } + + pub(in crate::state::app_state) fn apply_monitor_constraints( + &self, + window: &WebviewWindow, + session: u64, + generation: u64, + ) -> Result<()> { + let Some((min_height, monitor_max_height)) = window + .current_monitor()? + .and_then(MonitorSpec::from_monitor) + .map(|monitor| panel_height_bounds(Some(monitor.logical_height))) + else { + return Ok(()); + }; + let max_height = { + let state = self.state.lock(); + if !state.active || state.session != session || state.generation != generation { + return Ok(()); + } + changed_panel_max_height(state.applied_max_height, monitor_max_height) + }; + let Some(max_height) = max_height else { + return Ok(()); + }; + // 좁은 모니터로 옮겨가면 하한도 함께 내려야 창이 화면 밖으로 나가지 않음 + window.set_min_size(Some(tauri::Size::Logical(tauri::LogicalSize::new( + PANEL_WIDTH, + min_height, + ))))?; + window.set_max_size(Some(tauri::Size::Logical(tauri::LogicalSize::new( + PANEL_WIDTH, + max_height, + ))))?; + let mut state = self.state.lock(); + if state.active && state.session == session && state.generation == generation { + state.applied_max_height = Some(max_height); + } + Ok(()) + } + + pub(in crate::state::app_state) fn flush_now(&self, window: &WebviewWindow) -> Result<()> { + let _persist_guard = self.persist_lock.lock(); + let generation_before_sample = self.state.lock().generation; + let sampled = panel_bounds_sample_from_window(window); + let persisted = + Self::flush_samples(&self.state, generation_before_sample, sampled, |sample| { + self.persist_sample(sample) + })?; + if !persisted { + self.store + .flush() + .context("failed to flush store with cleared panel bounds")?; + } + Ok(()) + } + + pub(in crate::state::app_state) fn flush_samples( + state_mutex: &Mutex, + generation_before_sample: u64, + sampled: Result, + mut persist: impl FnMut(PanelBoundsSample) -> Result<()>, + ) -> Result { + let mut work = { + let mut state = state_mutex.lock(); + let sampled = match sampled { + Ok(sample) => sample, + Err(error) => state.latest.ok_or(error)?, + }; + let sample = if state.generation != generation_before_sample { + state.latest.unwrap_or(sampled) + } else { + sampled + }; + state.latest = Some(sample); + state.generation = state.generation.wrapping_add(1); + state.dirty = false; + state.persist_dirty = false; + let persist = Self::should_persist_change(&mut state, true); + PanelBoundsPersistWork { + session: state.session, + generation: state.generation, + sample, + persist, + } + }; + let mut persisted = false; + + loop { + if work.persist { + if let Err(error) = persist(work.sample) { + Self::restore_failed_work(&mut state_mutex.lock(), &work); + return Err(error); + } + persisted = true; + } + let next = { + let mut state = state_mutex.lock(); + let Some(mut next) = Self::take_dirty_work(&mut state) else { + return Ok(persisted); + }; + next.persist = Self::should_persist_change(&mut state, true); + next + }; + work = next; + } + } + + pub(in crate::state::app_state) fn deactivate(&self, session: u64) { + let mut state = self.state.lock(); + Self::deactivate_state(&mut state, session); + } + + pub(in crate::state::app_state) fn deactivate_state( + state: &mut PanelBoundsPersistenceState, + session: u64, + ) -> bool { + if state.session != session { + return false; + } + state.active = false; + state.window = None; + state.applied_max_height = None; + state.unpersisted_default_height = None; + state.default_height_pending = false; + state.generation = state.generation.wrapping_add(1); + state.dirty = false; + state.persist_dirty = false; + true + } +} From b0dfc78f035c2a19a0242e6e736a5ef1fce6588a Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 22:02:01 +0900 Subject: [PATCH 221/245] =?UTF-8?q?test:=20=EB=B0=B0=EC=B9=98=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=20=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD=20=EA=B3=84?= =?UTF-8?q?=EC=95=BD=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../batch/BatchPanelHeader.test.tsx | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/batch/BatchPanelHeader.test.tsx diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchPanelHeader.test.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchPanelHeader.test.tsx new file mode 100644 index 000000000..23c965b88 --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchPanelHeader.test.tsx @@ -0,0 +1,185 @@ +import React, { act, createRef } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { PANEL_HEADER_CLASS } from '../panelChrome'; +import BatchPanelHeader from './BatchPanelHeader'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +describe('BatchPanelHeader 이름 변경 계약', () => { + let container: HTMLDivElement; + let root: Root; + + const render = ( + overrides: Partial> = {}, + ) => { + const props: React.ComponentProps = { + totalCount: 4, + selectedGroupInfo: { + id: 'group-a', + name: 'Named group', + memberCount: 3, + }, + isRenaming: false, + renameInputRef: createRef(), + renameValue: '', + setRenameValue: vi.fn(), + renameCancelledRef: { current: false }, + handleRenameCommit: vi.fn(), + handleRenameCancel: vi.fn(), + handleRenameStart: vi.fn(), + t: (key) => key, + ...overrides, + }; + + act(() => root.render()); + }; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('그룹과 다중 선택 DOM·제목·count 계약을 유지한다', () => { + render(); + + const header = container.firstElementChild as HTMLDivElement; + const groupTitle = container.querySelector( + 'span[title="Named group"]', + )!; + expect(header.className).toBe(PANEL_HEADER_CLASS); + expect(groupTitle.textContent).toBe('Named group'); + expect(groupTitle.className).toBe( + 'text-fg text-label leading-none cursor-default truncate max-w-[110px]', + ); + expect( + container.querySelector( + 'button[title="contextMenu.rename"]', + )?.className, + ).toBe( + 'w-[18px] h-[18px] flex items-center justify-center text-fg-faint hover:text-fg transition-colors flex-shrink-0', + ); + expect(container.textContent).not.toContain('(4)'); + + render({ selectedGroupInfo: null }); + + const labels = container.querySelectorAll('span'); + expect(labels).toHaveLength(2); + expect(labels[0]?.textContent).toBe('propertiesPanel.multiSelection'); + expect(labels[0]?.className).toBe('text-fg text-label leading-none'); + expect(labels[1]?.textContent).toBe('(4)'); + expect(labels[1]?.className).toBe('text-fg-faint text-body'); + expect(container.querySelector('button')).toBeNull(); + }); + + it('번역은 현재 표시 분기에 필요한 key만 평가한다', () => { + const t = vi.fn((key: string) => key); + render({ t }); + expect(t.mock.calls).toEqual([['contextMenu.rename']]); + + t.mockClear(); + render({ isRenaming: true, t }); + expect(t).not.toHaveBeenCalled(); + + render({ selectedGroupInfo: null, t }); + expect(t.mock.calls).toEqual([['propertiesPanel.multiSelection']]); + }); + + it('제목 더블클릭과 버튼 클릭 이벤트를 시작 handler에 그대로 전달한다', () => { + const handleRenameStart = vi.fn(); + render({ handleRenameStart }); + + const title = container.querySelector( + 'span[title="Named group"]', + )!; + const button = container.querySelector( + 'button[title="contextMenu.rename"]', + )!; + + act(() => + title.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })), + ); + act(() => button.click()); + + expect(handleRenameStart).toHaveBeenCalledTimes(2); + expect(handleRenameStart.mock.calls[0]?.[0]).toMatchObject({ + type: 'dblclick', + }); + expect(handleRenameStart.mock.calls[1]?.[0]).toMatchObject({ + type: 'click', + }); + }); + + it('Enter는 blur 뒤 현재 값을 커밋하고 cancel ref를 초기화한다', () => { + const order: string[] = []; + const renameCancelledRef = { current: false }; + const renameInputRef = createRef(); + const handleRenameCommit = vi.fn((value: string) => { + order.push(`commit:${value}`); + }); + render({ + isRenaming: true, + renameInputRef, + renameValue: 'Draft group', + renameCancelledRef, + handleRenameCommit, + }); + const input = container.querySelector('input')!; + expect(renameInputRef.current).toBe(input); + input.focus(); + const nativeBlur = input.blur.bind(input); + vi.spyOn(input, 'blur').mockImplementation(() => { + order.push('blur'); + nativeBlur(); + }); + + const event = new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + cancelable: true, + }); + act(() => input.dispatchEvent(event)); + + expect(event.defaultPrevented).toBe(true); + expect(order).toEqual(['blur', 'commit:Draft group']); + expect(handleRenameCommit).toHaveBeenCalledWith('Draft group'); + expect(renameCancelledRef.current).toBe(false); + }); + + it('Escape는 취소하고 후속 blur는 커밋 없이 cancel ref만 초기화한다', () => { + const renameCancelledRef = { current: true }; + const handleRenameCommit = vi.fn(); + const handleRenameCancel = vi.fn(); + render({ + isRenaming: true, + renameValue: 'Cancelled group', + renameCancelledRef, + handleRenameCommit, + handleRenameCancel, + }); + const input = container.querySelector('input')!; + input.focus(); + + const event = new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true, + }); + act(() => input.dispatchEvent(event)); + + expect(event.defaultPrevented).toBe(true); + expect(handleRenameCancel).toHaveBeenCalledTimes(1); + expect(handleRenameCancel).toHaveBeenCalledWith(); + expect(handleRenameCommit).not.toHaveBeenCalled(); + act(() => input.blur()); + expect(handleRenameCommit).not.toHaveBeenCalled(); + expect(renameCancelledRef.current).toBe(false); + }); +}); From 0866ccf5ed1a10f99527c905c0d9bddb6435e95d Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 22:02:01 +0900 Subject: [PATCH 222/245] =?UTF-8?q?refactor:=20=ED=8C=A8=EB=84=90=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD=20=EC=BB=A8=ED=8A=B8?= =?UTF-8?q?=EB=A1=A4=20=EA=B3=B5=EC=9A=A9=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PropertiesPanel/PanelRenameControl.tsx | 96 +++++++++++++++++++ .../batch/BatchPanelHeader.tsx | 80 ++++------------ .../PropertiesPanel/single/RenameIcon.tsx | 26 ----- .../single/SinglePanelRenameHeader.tsx | 56 +++-------- 4 files changed, 126 insertions(+), 132 deletions(-) create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/PanelRenameControl.tsx delete mode 100644 src/renderer/components/main/Grid/PropertiesPanel/single/RenameIcon.tsx diff --git a/src/renderer/components/main/Grid/PropertiesPanel/PanelRenameControl.tsx b/src/renderer/components/main/Grid/PropertiesPanel/PanelRenameControl.tsx new file mode 100644 index 000000000..62d70c709 --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/PanelRenameControl.tsx @@ -0,0 +1,96 @@ +import React from 'react'; + +interface PanelRenameControlProps { + title: string; + titleClassName: string; + renameButtonTitle: string; + isRenaming: boolean; + renameInputRef: React.RefObject; + renameValue: string; + setRenameValue: (value: string) => void; + renameCancelledRef: React.MutableRefObject; + handleRenameCommit: (value: string) => void; + handleRenameCancel: () => void; + handleRenameStart: () => void; +} + +const RenameIcon = () => ( + +); + +const PanelRenameControl = ({ + title, + titleClassName, + renameButtonTitle, + isRenaming, + renameInputRef, + renameValue, + setRenameValue, + renameCancelledRef, + handleRenameCommit, + handleRenameCancel, + handleRenameStart, +}: PanelRenameControlProps) => { + return isRenaming ? ( + setRenameValue(event.target.value)} + onBlur={() => { + if (!renameCancelledRef.current) handleRenameCommit(renameValue); + renameCancelledRef.current = false; + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + (event.target as HTMLInputElement).blur(); + } else if (event.key === 'Escape') { + event.preventDefault(); + handleRenameCancel(); + } + }} + /> + ) : ( +
+ + {title} + + +
+ ); +}; + +export default PanelRenameControl; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchPanelHeader.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchPanelHeader.tsx index f951ae2ce..dc71da8d1 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchPanelHeader.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchPanelHeader.tsx @@ -1,31 +1,7 @@ import React from 'react'; +import PanelRenameControl from '../PanelRenameControl'; import { PANEL_HEADER_CLASS } from '../panelChrome'; -const RenameIcon = () => ( - -); - interface BatchPanelHeaderProps { totalCount: number; selectedGroupInfo: { id: string; name: string; memberCount: number } | null; @@ -56,45 +32,21 @@ const BatchPanelHeader = ({
{selectedGroupInfo ? ( - isRenaming ? ( - setRenameValue(event.target.value)} - onBlur={() => { - if (!renameCancelledRef.current) handleRenameCommit(renameValue); - renameCancelledRef.current = false; - }} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault(); - (event.target as HTMLInputElement).blur(); - } else if (event.key === 'Escape') { - event.preventDefault(); - handleRenameCancel(); - } - }} - /> - ) : ( -
- - {selectedGroupInfo.name} - - -
- ) + ) : ( {t('propertiesPanel.multiSelection') || '다중 선택'} diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/RenameIcon.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/RenameIcon.tsx deleted file mode 100644 index 43caaef6b..000000000 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/RenameIcon.tsx +++ /dev/null @@ -1,26 +0,0 @@ -const RenameIcon = () => ( - -); - -export default RenameIcon; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SinglePanelRenameHeader.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SinglePanelRenameHeader.tsx index c33375fcb..4c5868872 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SinglePanelRenameHeader.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SinglePanelRenameHeader.tsx @@ -1,6 +1,6 @@ import React from 'react'; +import PanelRenameControl from '../PanelRenameControl'; import { PANEL_HEADER_CLASS } from '../panelChrome'; -import RenameIcon from './RenameIcon'; interface SinglePanelRenameHeaderProps { title: string; @@ -31,47 +31,19 @@ const SinglePanelRenameHeader = ({ }: SinglePanelRenameHeaderProps) => { return (
- {isRenaming ? ( - setRenameValue(e.target.value)} - onBlur={() => { - if (!renameCancelledRef.current) { - handleRenameCommit(renameValue); - } - renameCancelledRef.current = false; - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - (e.target as HTMLInputElement).blur(); - } else if (e.key === 'Escape') { - e.preventDefault(); - handleRenameCancel(); - } - }} - /> - ) : ( -
- - {title} - - -
- )} +
); }; From cb948a2798002c09f889c6f423669a6343762b88 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 22:02:39 +0900 Subject: [PATCH 223/245] =?UTF-8?q?test:=20=EC=98=A4=EB=B2=84=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=20=EB=84=A4=EC=9D=B4=ED=8B=B0=EB=B8=8C=20=EB=A9=94?= =?UTF-8?q?=EB=89=B4=20=EA=B3=84=EC=95=BD=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../windows/overlay/App.nativeWindow.test.tsx | 387 ++++++++++++++++-- 1 file changed, 360 insertions(+), 27 deletions(-) diff --git a/src/renderer/windows/overlay/App.nativeWindow.test.tsx b/src/renderer/windows/overlay/App.nativeWindow.test.tsx index 5d0e6ddaf..5fd221cbe 100644 --- a/src/renderer/windows/overlay/App.nativeWindow.test.tsx +++ b/src/renderer/windows/overlay/App.nativeWindow.test.tsx @@ -3,6 +3,7 @@ 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 { useSettingsStore } from '@stores/useSettingsStore'; import { resetAllKeySignals } from '@stores/signals/keySignals'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -11,7 +12,14 @@ const mocks = vi.hoisted(() => ({ bootstrap: vi.fn(), resize: vi.fn(), currentMonitor: vi.fn(), + getByLabel: vi.fn(), menuNew: vi.fn(), + setAlwaysOnTop: vi.fn(), + setSelectedKeyType: vi.fn(), + settingsUpdate: vi.fn(), + setVisible: vi.fn(), + showMain: vi.fn(), + quit: vi.fn(), hitContextMenuListeners: [] as ((pos: { x: number; y: number }) => void)[], overlayWindow: { startDragging: vi.fn(() => Promise.resolve()), @@ -26,7 +34,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('@tauri-apps/api/window', () => ({ currentMonitor: mocks.currentMonitor, getCurrentWindow: () => mocks.overlayWindow, - Window: { getByLabel: vi.fn() }, + Window: { getByLabel: mocks.getByLabel }, })); vi.mock('@tauri-apps/api/dpi', () => ({ LogicalPosition: class LogicalPosition { @@ -82,12 +90,15 @@ vi.mock('@stores/data/useKnobItemStore', () => ({ })); vi.mock('@stores/useSettingsStore', async () => { const { create } = await import('zustand'); - const useSettingsStore = create(() => ({ + const useSettingsStore = create((set) => ({ developerModeEnabled: false, backgroundColor: 'transparent', alwaysOnTop: false, trayEnabled: true, - setAlwaysOnTop: vi.fn(), + setAlwaysOnTop: (alwaysOnTop: boolean) => { + mocks.setAlwaysOnTop(alwaysOnTop); + set({ alwaysOnTop }); + }, noteSettings: { frameLimit: 0, speed: 500, @@ -108,10 +119,17 @@ vi.mock('@stores/useSettingsStore', async () => { vi.mock('@api/modules/overlayApi', () => ({ overlayApi: { resize: mocks.resize, - setVisible: vi.fn(() => Promise.resolve()), + setVisible: mocks.setVisible, transitionFade: vi.fn(() => Promise.resolve(true)), }, })); +vi.mock('@api/modules/settingsApi', () => ({ + settingsApi: { update: mocks.settingsUpdate }, +})); +vi.mock('@api/modules/appApi', () => ({ + appApi: { quit: mocks.quit }, + windowApi: { showMain: mocks.showMain }, +})); // 히트 영역 실측은 ResizeObserver·네이티브 IPC에 의존 - 이 파일들의 관심사 밖 vi.mock('@hooks/overlay/useOverlayHitRegions', () => ({ useOverlayHitRegions: () => {}, @@ -148,7 +166,7 @@ const flushAsync = () => }); let container: HTMLDivElement; -let root: Root; +let root: Root | null; let originalApi: Window['api']; let originalRuntime: Window['__dmn_runtime']; @@ -157,7 +175,7 @@ const mount = async () => { document.body.append(container); root = createRoot(container); await act(async () => { - root.render(); + root?.render(); }); await flushAsync(); }; @@ -168,7 +186,18 @@ const setupOverlay = () => { mocks.bootstrap.mockReset().mockResolvedValue({ activeKeys: [] }); mocks.resize.mockReset().mockResolvedValue(undefined); mocks.currentMonitor.mockReset().mockResolvedValue(null); + mocks.getByLabel.mockReset().mockResolvedValue({ + isVisible: vi.fn(() => Promise.resolve(false)), + }); mocks.menuNew.mockReset(); + mocks.setAlwaysOnTop.mockReset(); + mocks.setSelectedKeyType.mockReset().mockImplementation((keyType: string) => { + useKeyStore.setState({ selectedKeyType: keyType }); + }); + mocks.settingsUpdate.mockReset().mockResolvedValue(undefined); + mocks.setVisible.mockReset().mockResolvedValue(undefined); + mocks.showMain.mockReset().mockResolvedValue(undefined); + mocks.quit.mockReset().mockResolvedValue(undefined); mocks.overlayWindow.setPosition.mockClear(); mocks.hitContextMenuListeners = []; window.api = { @@ -183,12 +212,17 @@ const setupOverlay = () => { canonicalPositions: { '4key': [] }, isBootstrapped: true, isLocalUpdateInProgress: false, + setSelectedKeyType: mocks.setSelectedKeyType, }); + useSettingsStore.setState({ alwaysOnTop: false, trayEnabled: true }); resetAllKeySignals(); }; const teardownOverlay = () => { - act(() => root.unmount()); + if (root) { + act(() => root?.unmount()); + root = null; + } container.remove(); resetAllKeySignals(); window.api = originalApi; @@ -197,6 +231,293 @@ const teardownOverlay = () => { vi.restoreAllMocks(); }; +interface NativeMenuItem { + id?: string; + item?: string; + text?: string; + checked?: boolean; + enabled?: boolean; + action?: () => void; + items?: NativeMenuItem[]; +} + +interface RecordedMenu { + items: NativeMenuItem[]; + popup: ReturnType; + close: ReturnType; +} + +const installMenuRecorder = ( + popupForCall: (callIndex: number) => Promise = () => Promise.resolve(), +) => { + const records: RecordedMenu[] = []; + mocks.menuNew.mockImplementation((options: { items: NativeMenuItem[] }) => { + const callIndex = records.length; + const record = { + items: options.items, + popup: vi.fn(() => popupForCall(callIndex)), + close: vi.fn(() => Promise.resolve()), + }; + records.push(record); + return Promise.resolve(record); + }); + return records; +}; + +const emitContextMenu = async (x = 0, y = 0) => { + await act(async () => { + mocks.hitContextMenuListeners.forEach((listener) => listener({ x, y })); + }); + await flushAsync(); +}; + +const menuItem = (record: RecordedMenu, id: string): NativeMenuItem => { + const item = record.items.find((candidate) => candidate.id === id); + if (!item) throw new Error(`Missing menu item: ${id}`); + return item; +}; + +describe('오버레이 네이티브 메뉴 런타임', () => { + beforeEach(setupOverlay); + afterEach(teardownOverlay); + + it('항목 순서·상태·좌표와 탭 action 계약을 유지한다', async () => { + useKeyStore.setState({ + customTabs: [{ id: 'custom-a', name: 'Custom A' }], + }); + const records = installMenuRecorder(); + + await mount(); + await emitContextMenu(10.4, 20.6); + + expect(records).toHaveLength(1); + expect(records[0].items.map((item) => item.id ?? item.item)).toEqual([ + 'toggleAlwaysOnTop', + 'Separator', + 'selectTab', + 'closeOverlay', + 'snapToEdge', + 'Separator', + 'openSettingsWindow', + 'Separator', + 'quitApplication', + ]); + expect(menuItem(records[0], 'toggleAlwaysOnTop')).toMatchObject({ + text: 'settings.alwaysOnTop', + checked: false, + }); + expect(menuItem(records[0], 'openSettingsWindow')).toMatchObject({ + text: 'tooltip.settings', + enabled: true, + }); + const tabItems = menuItem(records[0], 'selectTab').items!; + expect( + tabItems.map(({ id, text, checked }) => ({ id, text, checked })), + ).toEqual([ + { id: 'selectTab-4key', text: 'mode.button4', checked: true }, + { id: 'selectTab-5key', text: 'mode.button5', checked: false }, + { id: 'selectTab-6key', text: 'mode.button6', checked: false }, + { id: 'selectTab-8key', text: 'mode.button8', checked: false }, + { id: 'selectTab-custom-a', text: 'Custom A', checked: false }, + ]); + expect(records[0].popup).toHaveBeenCalledWith( + expect.objectContaining({ x: 10, y: 21 }), + mocks.overlayWindow, + ); + expect(records[0].close).toHaveBeenCalledTimes(1); + + act(() => tabItems.at(-1)?.action?.()); + expect(mocks.setSelectedKeyType).toHaveBeenCalledWith('custom-a'); + expect(useKeyStore.getState().selectedKeyType).toBe('custom-a'); + }); + + it('rerender 뒤 최신 checked·탭·tray 상태로 다음 메뉴를 만든다', async () => { + const records = installMenuRecorder(); + await mount(); + await emitContextMenu(); + + act(() => { + useSettingsStore.setState({ alwaysOnTop: true, trayEnabled: false }); + useKeyStore.setState({ + selectedKeyType: 'custom-b', + customTabs: [{ id: 'custom-b', name: 'Custom B' }], + }); + }); + await flushAsync(); + await emitContextMenu(); + + expect(records).toHaveLength(2); + expect(menuItem(records[1], 'toggleAlwaysOnTop').checked).toBe(true); + expect(menuItem(records[1], 'openSettingsWindow').enabled).toBe(false); + expect(menuItem(records[1], 'selectTab').items?.at(-1)).toMatchObject({ + id: 'selectTab-custom-b', + text: 'Custom B', + checked: true, + }); + expect(mocks.getByLabel).toHaveBeenCalledTimes(1); + }); + + it('동시 emit은 한 메뉴만 열고 정산 뒤 다음 emit을 허용한다', async () => { + let resolvePopup: (() => void) | undefined; + const popupPending = new Promise((resolve) => { + resolvePopup = resolve; + }); + const records = installMenuRecorder((callIndex) => + callIndex === 0 ? popupPending : Promise.resolve(), + ); + await mount(); + + act(() => { + mocks.hitContextMenuListeners.forEach((listener) => { + listener({ x: 1, y: 2 }); + listener({ x: 3, y: 4 }); + }); + }); + await flushAsync(); + expect(records).toHaveLength(1); + + resolvePopup?.(); + await flushAsync(); + expect(records[0].close).toHaveBeenCalledTimes(1); + await emitContextMenu(5, 6); + expect(records).toHaveLength(2); + }); + + it('popup 실패도 menu close와 gate 재개방을 finally 순서로 정산한다', async () => { + const error = new Error('popup failed'); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const records = installMenuRecorder((callIndex) => + callIndex === 0 ? Promise.reject(error) : Promise.resolve(), + ); + await mount(); + + await emitContextMenu(); + expect(errorSpy).toHaveBeenCalledWith( + 'Failed to open native overlay context menu', + error, + ); + expect(records[0].close).toHaveBeenCalledTimes(1); + + await emitContextMenu(); + expect(records).toHaveLength(2); + expect(records[1].popup).toHaveBeenCalledTimes(1); + }); + + it('unmount는 hit-context 구독을 해제하고 이후 좌표를 무시한다', async () => { + installMenuRecorder(); + await mount(); + expect(mocks.hitContextMenuListeners).toHaveLength(1); + + act(() => root?.unmount()); + root = null; + expect(mocks.hitContextMenuListeners).toHaveLength(0); + mocks.hitContextMenuListeners.forEach((listener) => + listener({ x: 1, y: 2 }), + ); + await flushAsync(); + + expect(mocks.menuNew).not.toHaveBeenCalled(); + }); + + it('각 메뉴 action 성공 경로를 기존 API와 store 순서로 실행한다', async () => { + const records = installMenuRecorder(); + mocks.currentMonitor.mockResolvedValue({ + position: { x: 0, y: 0 }, + size: { width: 1000, height: 800 }, + }); + mocks.overlayWindow.outerPosition.mockResolvedValue({ x: 900, y: 700 }); + mocks.overlayWindow.outerSize.mockResolvedValue({ + width: 100, + height: 100, + }); + await mount(); + await emitContextMenu(); + + act(() => menuItem(records[0], 'toggleAlwaysOnTop').action?.()); + await flushAsync(); + expect(mocks.setAlwaysOnTop.mock.calls).toEqual([[true]]); + expect(mocks.settingsUpdate).toHaveBeenCalledWith({ alwaysOnTop: true }); + + act(() => menuItem(records[0], 'closeOverlay').action?.()); + act(() => menuItem(records[0], 'snapToEdge').action?.()); + act(() => menuItem(records[0], 'openSettingsWindow').action?.()); + act(() => menuItem(records[0], 'quitApplication').action?.()); + await flushAsync(); + + expect(mocks.setVisible).toHaveBeenCalledWith(false); + expect(mocks.overlayWindow.setPosition).toHaveBeenCalledWith( + expect.objectContaining({ x: 900, y: 700 }), + ); + expect(mocks.showMain).toHaveBeenCalledTimes(1); + expect(mocks.quit).toHaveBeenCalledTimes(1); + }); + + it('각 메뉴 action 실패를 격리하고 always-on-top을 rollback한다', async () => { + const errors = { + always: new Error('always failed'), + close: new Error('close failed'), + snap: new Error('snap failed'), + settings: new Error('settings failed'), + quit: new Error('quit failed'), + }; + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const records = installMenuRecorder(); + mocks.settingsUpdate.mockRejectedValueOnce(errors.always); + mocks.setVisible.mockRejectedValueOnce(errors.close); + mocks.currentMonitor.mockRejectedValueOnce(errors.snap); + mocks.showMain.mockRejectedValueOnce(errors.settings); + mocks.quit.mockRejectedValueOnce(errors.quit); + await mount(); + await emitContextMenu(); + + act(() => menuItem(records[0], 'toggleAlwaysOnTop').action?.()); + act(() => menuItem(records[0], 'closeOverlay').action?.()); + act(() => menuItem(records[0], 'snapToEdge').action?.()); + act(() => menuItem(records[0], 'openSettingsWindow').action?.()); + act(() => menuItem(records[0], 'quitApplication').action?.()); + await flushAsync(); + + expect(mocks.setAlwaysOnTop.mock.calls).toEqual([[true], [false]]); + expect(useSettingsStore.getState().alwaysOnTop).toBe(false); + expect(errorSpy).toHaveBeenCalledWith( + 'Failed to toggle always-on-top', + errors.always, + ); + expect(errorSpy).toHaveBeenCalledWith( + 'Failed to close overlay window', + errors.close, + ); + expect(errorSpy).toHaveBeenCalledWith( + 'Failed to snap overlay to edge', + errors.snap, + ); + expect(errorSpy).toHaveBeenCalledWith( + 'Failed to open settings window', + errors.settings, + ); + expect(errorSpy).toHaveBeenCalledWith( + 'Failed to quit application', + errors.quit, + ); + }); + + it('main visibility 조회 실패는 메뉴를 유지하되 설정 action을 비활성화한다', async () => { + const error = new Error('visibility failed'); + vi.spyOn(console, 'error').mockImplementation(() => {}); + mocks.getByLabel.mockRejectedValueOnce(error); + const records = installMenuRecorder(); + await mount(); + + await emitContextMenu(); + + expect(menuItem(records[0], 'openSettingsWindow').enabled).toBe(false); + expect(console.error).toHaveBeenCalledWith( + 'Failed to resolve main window visibility', + error, + ); + }); +}); + describe('모서리 스냅', () => { beforeEach(setupOverlay); afterEach(teardownOverlay); @@ -243,29 +564,41 @@ describe('모서리 스냅', () => { }, }; - it('아래로 붙일 때 독·작업 표시줄 자리까지 내려간다', async () => { - mocks.currentMonitor.mockResolvedValue(monitorWithInsets); - - const applied = await runSnap({ + it.each([ + { + quadrant: '좌상단', + position: { x: 50, y: 20 }, + expected: { x: 0, y: 0 }, + }, + { + quadrant: '우상단', + position: { x: 1500, y: 20 }, + expected: { x: 1620, y: 0 }, + }, + { + quadrant: '좌하단', + position: { x: 50, y: 800 }, + expected: { x: 0, y: 880 }, + }, + { + quadrant: '우하단', position: { x: 1500, y: 800 }, - size: { width: 300, height: 200 }, - }); - - // 작업 영역 기준이면 y=780에서 멈춰 하단 인셋만큼 떠 보인다 - expect(applied).toMatchObject({ x: 1620, y: 880 }); - }); - - it('위로 붙일 때 메뉴 바 자리까지 올라간다', async () => { - mocks.currentMonitor.mockResolvedValue(monitorWithInsets); + expected: { x: 1620, y: 880 }, + }, + ])( + '$quadrant은 전체 화면의 해당 모서리로 붙는다', + async ({ position, expected }) => { + mocks.currentMonitor.mockResolvedValue(monitorWithInsets); - const applied = await runSnap({ - position: { x: 50, y: 20 }, - size: { width: 300, height: 200 }, - }); + const applied = await runSnap({ + position, + size: { width: 300, height: 200 }, + }); - // 작업 영역 기준이면 y=40 - expect(applied).toMatchObject({ x: 0, y: 0 }); - }); + // workArea 기준이면 상단은 y=40, 하단은 y=780에서 멈춘다 + expect(applied).toMatchObject(expected); + }, + ); it('창이 화면보다 크면 화면 위쪽 밖으로 밀어내지 않는다', async () => { mocks.currentMonitor.mockResolvedValue(monitorWithInsets); From 8cddb23c7b2382b242f8f332a32f60da89b9b687 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 22:02:39 +0900 Subject: [PATCH 224/245] =?UTF-8?q?refactor:=20=EC=98=A4=EB=B2=84=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=20=EC=BB=A8=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EB=A9=94?= =?UTF-8?q?=EB=89=B4=20=EB=9F=B0=ED=83=80=EC=9E=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../overlay/useOverlayContextMenuRuntime.ts | 238 ++++++++++++++++++ src/renderer/windows/overlay/App.tsx | 226 +---------------- 2 files changed, 248 insertions(+), 216 deletions(-) create mode 100644 src/renderer/hooks/overlay/useOverlayContextMenuRuntime.ts diff --git a/src/renderer/hooks/overlay/useOverlayContextMenuRuntime.ts b/src/renderer/hooks/overlay/useOverlayContextMenuRuntime.ts new file mode 100644 index 000000000..8e3f6a8e0 --- /dev/null +++ b/src/renderer/hooks/overlay/useOverlayContextMenuRuntime.ts @@ -0,0 +1,238 @@ +import { useEffect, useRef } from 'react'; +import { LogicalPosition, PhysicalPosition } from '@tauri-apps/api/dpi'; +import { Menu } from '@tauri-apps/api/menu'; +import { + currentMonitor, + getCurrentWindow, + Window as TauriWindow, +} from '@tauri-apps/api/window'; +import { appApi, windowApi } from '@api/modules/appApi'; +import { overlayApi } from '@api/modules/overlayApi'; +import { settingsApi } from '@api/modules/settingsApi'; +import { subscribeHitContextMenu } from './useOverlayHitRegions'; +import type { CustomTab } from '@src/types/key/keys'; + +interface UseOverlayContextMenuRuntimeOptions { + alwaysOnTop: boolean; + trayEnabled: boolean; + setAlwaysOnTop: (value: boolean) => void; + selectedKeyType: string; + customTabs: CustomTab[]; + setSelectedKeyType: (keyType: string) => void; + t: (key: string) => string; +} + +export const useOverlayContextMenuRuntime = ({ + alwaysOnTop, + trayEnabled, + setAlwaysOnTop, + selectedKeyType, + customTabs, + setSelectedKeyType, + t, +}: UseOverlayContextMenuRuntimeOptions) => { + const resolveCanOpenMainSettings = async () => { + if (!trayEnabled) { + return false; + } + + try { + const mainWindow = await TauriWindow.getByLabel('main'); + if (!mainWindow) { + return false; + } + const isMainVisible = await mainWindow.isVisible(); + return !isMainVisible; + } catch (error) { + console.error('Failed to resolve main window visibility', error); + return false; + } + }; + + // 탭 목록 (기본 탭 + 커스텀 탭) + const BUILTIN_TABS = ['4key', '5key', '6key', '8key'].map((id) => { + const num = id.replace('key', ''); + return { id, name: t(`mode.button${num}`) }; + }); + + const closeOverlayWindow = async () => { + try { + await overlayApi.setVisible(false); + } catch (error) { + console.error('Failed to close overlay window', error); + } + }; + + const toggleAlwaysOnTop = async () => { + const next = !alwaysOnTop; + setAlwaysOnTop(next); + try { + await settingsApi.update({ alwaysOnTop: next }); + } catch (error) { + console.error('Failed to toggle always-on-top', error); + setAlwaysOnTop(!next); + } + }; + + const openSettingsWindow = async () => { + try { + await windowApi.showMain(); + } catch (error) { + console.error('Failed to open settings window', error); + } + }; + + const quitApplication = async () => { + try { + await appApi.quit(); + } catch (error) { + console.error('Failed to quit application', error); + } + }; + + const snapToNearestEdge = async () => { + try { + const win = getCurrentWindow(); + const [monitor, pos, size] = await Promise.all([ + currentMonitor(), + win.outerPosition(), + win.outerSize(), + ]); + if (!monitor) return; + + // 화면 끝 기준 - 오버레이는 항상 위 표시라 독·작업 표시줄 위에 그려진다 + const monitorPos = monitor.position; + const monitorSize = monitor.size; + + // 창의 중심 좌표 + const centerX = pos.x + size.width / 2; + const centerY = pos.y + size.height / 2; + + // 모니터의 중심 좌표 + const monitorCenterX = monitorPos.x + monitorSize.width / 2; + const monitorCenterY = monitorPos.y + monitorSize.height / 2; + + // 가장 가까운 모서리 결정 + const snapLeft = centerX < monitorCenterX; + const snapTop = centerY < monitorCenterY; + + // 창이 화면보다 크면 우·하단 정렬 값이 시작점보다 앞서므로 좌·상단으로 고정 + const newX = snapLeft + ? monitorPos.x + : Math.max(monitorPos.x, monitorPos.x + monitorSize.width - size.width); + const newY = snapTop + ? monitorPos.y + : Math.max( + monitorPos.y, + monitorPos.y + monitorSize.height - size.height, + ); + + await win.setPosition(new PhysicalPosition(newX, newY)); + } catch (error) { + console.error('Failed to snap overlay to edge', error); + } + }; + + const openOverlayContextMenuAtImpl = useRef< + (x: number, y: number) => Promise + >(async () => {}); + const contextMenuOpenRef = useRef(false); + openOverlayContextMenuAtImpl.current = async (x: number, y: number) => { + const canOpenMainSettings = await resolveCanOpenMainSettings(); + const allTabs = [ + ...BUILTIN_TABS, + ...customTabs.map((tab) => ({ id: tab.id, name: tab.name })), + ]; + + let menu: Menu | null = null; + try { + menu = await Menu.new({ + items: [ + { + id: 'toggleAlwaysOnTop', + text: t('settings.alwaysOnTop'), + checked: alwaysOnTop, + action: () => { + void toggleAlwaysOnTop(); + }, + }, + { item: 'Separator' }, + { + id: 'selectTab', + text: t('contextMenu.selectTab'), + items: allTabs.map((tab) => ({ + id: `selectTab-${tab.id}`, + text: tab.name, + checked: tab.id === selectedKeyType, + action: () => { + setSelectedKeyType(tab.id); + }, + })), + }, + { + id: 'closeOverlay', + text: t('tooltip.overlayClose'), + action: () => { + void closeOverlayWindow(); + }, + }, + { + id: 'snapToEdge', + text: t('contextMenu.snapToEdge'), + action: () => { + void snapToNearestEdge(); + }, + }, + { item: 'Separator' }, + { + id: 'openSettingsWindow', + text: t('tooltip.settings'), + enabled: canOpenMainSettings, + action: () => { + void openSettingsWindow(); + }, + }, + { item: 'Separator' }, + { + id: 'quitApplication', + text: t('contextMenu.quitApp'), + action: () => { + void quitApplication(); + }, + }, + ], + }); + + await menu.popup( + new LogicalPosition(Math.round(x), Math.round(y)), + getCurrentWindow(), + ); + } catch (error) { + console.error('Failed to open native overlay context menu', error); + } finally { + if (menu) { + await menu.close().catch(() => {}); + } + } + }; + const openOverlayContextMenuAt = async (x: number, y: number) => { + if (contextMenuOpenRef.current) return; + contextMenuOpenRef.current = true; + try { + await openOverlayContextMenuAtImpl.current(x, y); + } finally { + contextMenuOpenRef.current = false; + } + }; + + // 본체 창은 상시 클릭 통과라 웹뷰가 우클릭을 못 받는다 - 히트 창이 emit한 + // 좌표로 기존 네이티브 메뉴를 연다 + useEffect(() => { + const unsubscribe = subscribeHitContextMenu(({ x, y }) => { + void openOverlayContextMenuAt(x, y); + }); + return () => { + unsubscribe(); + }; + }, []); +}; diff --git a/src/renderer/windows/overlay/App.tsx b/src/renderer/windows/overlay/App.tsx index 36ba9cdf5..480d27911 100644 --- a/src/renderer/windows/overlay/App.tsx +++ b/src/renderer/windows/overlay/App.tsx @@ -1,11 +1,4 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { - currentMonitor, - getCurrentWindow, - Window as TauriWindow, -} from '@tauri-apps/api/window'; -import { LogicalPosition, PhysicalPosition } from '@tauri-apps/api/dpi'; -import { Menu } from '@tauri-apps/api/menu'; import { useTranslation } from '@contexts/useTranslation'; import { DEFAULT_NOTE_SETTINGS } from '@constants/overlayDefaults'; import { mergeNoteSettings } from '@src/types/settings/noteSettings'; @@ -13,17 +6,13 @@ import { useCustomCssInjection } from '@hooks/app/useCustomCssInjection'; import { useCustomJsInjection } from '@hooks/app/useCustomJsInjection'; import { useBlockBrowserShortcuts } from '@hooks/app/useBlockBrowserShortcuts'; import { useNoteSystem } from '@hooks/overlay/useNoteSystem'; -import { - useOverlayHitRegions, - subscribeHitContextMenu, -} from '@hooks/overlay/useOverlayHitRegions'; +import { useOverlayHitRegions } from '@hooks/overlay/useOverlayHitRegions'; +import { useOverlayContextMenuRuntime } from '@hooks/overlay/useOverlayContextMenuRuntime'; import { useTrackReserveTransition } from '@hooks/overlay/useTrackReserveTransition'; import { useOverlayReveal } from '@hooks/overlay/useOverlayReveal'; import { useOverlayKeyStateRuntime } from '@hooks/overlay/useOverlayKeyStateRuntime'; import { useAppBootstrap } from '@hooks/app/useAppBootstrap'; import { overlayApi } from '@api/modules/overlayApi'; -import { settingsApi } from '@api/modules/settingsApi'; -import { appApi, windowApi } from '@api/modules/appApi'; import { useBuiltinStatsSubscription } from '@hooks/overlay/useBuiltinStatsSubscription'; import { useKeyStore } from '@stores/data/useKeyStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; @@ -123,211 +112,16 @@ export default function App() { ); const customTabs = useKeyStore((state) => state.customTabs); const setSelectedKeyType = useKeyStore((state) => state.setSelectedKeyType); - const resolveCanOpenMainSettings = async () => { - if (!trayEnabled) { - return false; - } - - try { - const mainWindow = await TauriWindow.getByLabel('main'); - if (!mainWindow) { - return false; - } - const isMainVisible = await mainWindow.isVisible(); - return !isMainVisible; - } catch (error) { - console.error('Failed to resolve main window visibility', error); - return false; - } - }; - - // 탭 목록 (기본 탭 + 커스텀 탭) - const BUILTIN_TABS = ['4key', '5key', '6key', '8key'].map((id) => { - const num = id.replace('key', ''); - return { id, name: t(`mode.button${num}`) }; + useOverlayContextMenuRuntime({ + alwaysOnTop, + trayEnabled, + setAlwaysOnTop, + selectedKeyType, + customTabs, + setSelectedKeyType, + t, }); - const closeOverlayWindow = async () => { - try { - await overlayApi.setVisible(false); - } catch (error) { - console.error('Failed to close overlay window', error); - } - }; - - const toggleAlwaysOnTop = async () => { - const next = !alwaysOnTop; - setAlwaysOnTop(next); - try { - await settingsApi.update({ alwaysOnTop: next }); - } catch (error) { - console.error('Failed to toggle always-on-top', error); - setAlwaysOnTop(!next); - } - }; - - const openSettingsWindow = async () => { - try { - await windowApi.showMain(); - } catch (error) { - console.error('Failed to open settings window', error); - } - }; - - const quitApplication = async () => { - try { - await appApi.quit(); - } catch (error) { - console.error('Failed to quit application', error); - } - }; - - const snapToNearestEdge = async () => { - try { - const win = getCurrentWindow(); - const [monitor, pos, size] = await Promise.all([ - currentMonitor(), - win.outerPosition(), - win.outerSize(), - ]); - if (!monitor) return; - - // 화면 끝 기준 - 오버레이는 항상 위 표시라 독·작업 표시줄 위에 그려진다 - const monitorPos = monitor.position; - const monitorSize = monitor.size; - - // 창의 중심 좌표 - const centerX = pos.x + size.width / 2; - const centerY = pos.y + size.height / 2; - - // 모니터의 중심 좌표 - const monitorCenterX = monitorPos.x + monitorSize.width / 2; - const monitorCenterY = monitorPos.y + monitorSize.height / 2; - - // 가장 가까운 모서리 결정 - const snapLeft = centerX < monitorCenterX; - const snapTop = centerY < monitorCenterY; - - // 창이 화면보다 크면 우·하단 정렬 값이 시작점보다 앞서므로 좌·상단으로 고정 - const newX = snapLeft - ? monitorPos.x - : Math.max(monitorPos.x, monitorPos.x + monitorSize.width - size.width); - const newY = snapTop - ? monitorPos.y - : Math.max( - monitorPos.y, - monitorPos.y + monitorSize.height - size.height, - ); - - await win.setPosition(new PhysicalPosition(newX, newY)); - } catch (error) { - console.error('Failed to snap overlay to edge', error); - } - }; - - const openOverlayContextMenuAtImpl = useRef< - (x: number, y: number) => Promise - >(async () => {}); - const contextMenuOpenRef = useRef(false); - openOverlayContextMenuAtImpl.current = async (x: number, y: number) => { - const canOpenMainSettings = await resolveCanOpenMainSettings(); - const allTabs = [ - ...BUILTIN_TABS, - ...customTabs.map((tab) => ({ id: tab.id, name: tab.name })), - ]; - - let menu: Menu | null = null; - try { - menu = await Menu.new({ - items: [ - { - id: 'toggleAlwaysOnTop', - text: t('settings.alwaysOnTop'), - checked: alwaysOnTop, - action: () => { - void toggleAlwaysOnTop(); - }, - }, - { item: 'Separator' }, - { - id: 'selectTab', - text: t('contextMenu.selectTab'), - items: allTabs.map((tab) => ({ - id: `selectTab-${tab.id}`, - text: tab.name, - checked: tab.id === selectedKeyType, - action: () => { - setSelectedKeyType(tab.id); - }, - })), - }, - { - id: 'closeOverlay', - text: t('tooltip.overlayClose'), - action: () => { - void closeOverlayWindow(); - }, - }, - { - id: 'snapToEdge', - text: t('contextMenu.snapToEdge'), - action: () => { - void snapToNearestEdge(); - }, - }, - { item: 'Separator' }, - { - id: 'openSettingsWindow', - text: t('tooltip.settings'), - enabled: canOpenMainSettings, - action: () => { - void openSettingsWindow(); - }, - }, - { item: 'Separator' }, - { - id: 'quitApplication', - text: t('contextMenu.quitApp'), - action: () => { - void quitApplication(); - }, - }, - ], - }); - - await menu.popup( - new LogicalPosition(Math.round(x), Math.round(y)), - getCurrentWindow(), - ); - } catch (error) { - console.error('Failed to open native overlay context menu', error); - } finally { - if (menu) { - await menu.close().catch(() => {}); - } - } - }; - const openOverlayContextMenuAt = async (x: number, y: number) => { - if (contextMenuOpenRef.current) return; - contextMenuOpenRef.current = true; - try { - await openOverlayContextMenuAtImpl.current(x, y); - } finally { - contextMenuOpenRef.current = false; - } - }; - - // 본체 창은 상시 클릭 통과라 웹뷰가 우클릭을 못 받는다 - 히트 창이 emit한 - // 좌표로 기존 네이티브 메뉴를 연다 - useEffect(() => { - const unsubscribe = subscribeHitContextMenu(({ x, y }) => { - void openOverlayContextMenuAt(x, y); - }); - return () => { - unsubscribe(); - }; - }, []); - const { notesRef, subscribe, From 3b13c3cbe6544ca41b1a82ba397659782af4e1ba Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 22:12:21 +0900 Subject: [PATCH 225/245] =?UTF-8?q?test:=20=EB=A0=88=EC=9D=B4=EC=96=B4=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../layer/LayerTabContent.rename.test.tsx | 338 ++++++++++++++++++ 1 file changed, 338 insertions(+) create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.rename.test.tsx diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.rename.test.tsx b/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.rename.test.tsx new file mode 100644 index 000000000..9eb56660e --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.rename.test.tsx @@ -0,0 +1,338 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + actions: undefined as unknown, + handleMouseDown: vi.fn(), + handleGroupMouseDown: vi.fn(), +})); + +vi.mock('@components/main/common/IconSwap', () => ({ + default: ({ active }: { active: boolean }) => ( + + ), +})); + +vi.mock('@contexts/useTranslation', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'ko', changeLanguage: () => {} }, + }), +})); + +vi.mock('@hooks/useLenis', () => ({ + useLenis: () => ({ + scrollContainerRef: () => {}, + lenisInstance: { current: null }, + }), +})); + +vi.mock('./useLayerActions', () => ({ + useLayerActions: () => mocks.actions, +})); + +vi.mock('./useLayerDnD', () => ({ + useLayerDnD: () => ({ + draggedItemId: null, + isDragging: false, + draggedGroupId: null, + dragOverItemDisplayIndex: null, + dragOverIntoGroupId: null, + dragOverTargetGroupId: null, + dragOverDisplayIndex: null, + getDidDrag: () => false, + resetDidDrag: vi.fn(), + getIsDraggingRef: () => false, + handleMouseDown: mocks.handleMouseDown, + handleGroupMouseDown: mocks.handleGroupMouseDown, + }), +})); + +import { useGraphItemStore } from '@stores/data/useGraphItemStore'; +import { useKeyStore } from '@stores/data/useKeyStore'; +import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; +import { useStatItemStore } from '@stores/data/useStatItemStore'; +import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; +import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; +import LayerTabContent from './LayerTabContent'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const ITEM_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const GROUP_ID = 'group-a'; +const INPUT_CLASS = + 'flex-1 text-body bg-transparent border-none p-0 outline-none text-fg min-w-0 caret-accent'; + +interface RenameActions { + events: string[]; + contextMenuOpen: boolean; + contextMenuPosition: { x: number; y: number }; + contextMenuItems: []; + setContextMenuOpen: ReturnType; + setContextMenuGroupId: ReturnType; + renamingItemId: string; + renameValue: string; + setRenameValue: ReturnType; + renameInputRef: { current: HTMLInputElement | null }; + renameCancelledRef: { current: boolean }; + setRenamingItemId: ReturnType; + handleToggleVisibility: ReturnType; + handleToggleGroupVisibility: ReturnType; + handleLayerRenameCommit: ReturnType; + handleGroupRenameCommit: ReturnType; + handleContextMenu: ReturnType; + handleGroupHeaderContextMenu: ReturnType; + handleContextMenuSelect: ReturnType; +} + +const createActions = (renamingItemId: string): RenameActions => ({ + events: [], + contextMenuOpen: false, + contextMenuPosition: { x: 0, y: 0 }, + contextMenuItems: [], + setContextMenuOpen: vi.fn(), + setContextMenuGroupId: vi.fn(), + renamingItemId, + renameValue: 'Before', + setRenameValue: vi.fn(), + renameInputRef: { current: null }, + renameCancelledRef: { current: false }, + setRenamingItemId: vi.fn(), + handleToggleVisibility: vi.fn(), + handleToggleGroupVisibility: vi.fn(), + handleLayerRenameCommit: vi.fn(), + handleGroupRenameCommit: vi.fn(), + handleContextMenu: vi.fn(), + handleGroupHeaderContextMenu: vi.fn(), + handleContextMenuSelect: vi.fn(), +}); + +const trackRenameOrder = (actions: RenameActions) => { + let cancelled = false; + Object.defineProperty(actions.renameCancelledRef, 'current', { + configurable: true, + get: () => { + actions.events.push(`cancelled:get:${String(cancelled)}`); + return cancelled; + }, + set: (value: boolean) => { + cancelled = value; + actions.events.push(`cancelled:set:${String(value)}`); + }, + }); + actions.setRenamingItemId.mockImplementation(() => { + actions.events.push('cancel'); + }); + actions.handleGroupRenameCommit.mockImplementation(() => { + actions.events.push('group:commit'); + }); + actions.handleLayerRenameCommit.mockImplementation(() => { + actions.events.push('item:commit'); + }); +}; + +const setNativeValue = (input: HTMLInputElement, value: string) => { + const setter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )?.set; + setter?.call(input, value); +}; + +describe('LayerTab rename input contract', () => { + let host: HTMLDivElement; + let root: Root; + let actions: RenameActions; + + const mount = async (renamingItemId: string) => { + actions = createActions(renamingItemId); + trackRenameOrder(actions); + mocks.actions = actions; + + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + await act(async () => { + root.render(); + }); + + const input = host.querySelector('input[type="text"]'); + if (!input) throw new Error('rename input not found'); + return input; + }; + + beforeEach(() => { + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + vi.stubGlobal('cancelAnimationFrame', () => {}); + useKeyStore.setState({ + selectedKeyType: '4key', + keyMappings: { '4key': ['A'] }, + positions: { + '4key': [ + { + id: ITEM_ID, + layerName: 'Layer A', + dx: 0, + dy: 0, + width: 60, + height: 60, + zIndex: 1, + groupId: GROUP_ID, + }, + ], + } as never, + }); + useStatItemStore.setState({ positions: {} }); + useGraphItemStore.setState({ positions: {} }); + useKnobItemStore.setState({ positions: {} }); + useLayerGroupStore.setState({ + layerGroups: { '4key': [{ id: GROUP_ID, name: 'Group A' }] }, + collapsedGroups: new Set(), + }); + usePluginDisplayElementStore.setState({ elements: [] }); + useGridSelectionStore.setState({ + selectedElements: [], + selectedGroupIds: [], + }); + }); + + afterEach(async () => { + if (root) await act(async () => root.unmount()); + host?.remove(); + vi.clearAllMocks(); + vi.unstubAllGlobals(); + }); + + it.each([ + { + name: 'group', + renamingItemId: `group:${GROUP_ID}`, + expectedCommit: () => actions.handleGroupRenameCommit, + expectedArgs: [GROUP_ID, 'Before'] as const, + }, + { + name: 'item', + renamingItemId: ITEM_ID, + expectedCommit: () => actions.handleLayerRenameCommit, + expectedArgs: [ + expect.objectContaining({ id: ITEM_ID }), + 'Before', + ] as const, + }, + ])( + '$name 입력은 ref/class/change와 Enter blur commit adapter를 보존한다', + async ({ renamingItemId, expectedCommit, expectedArgs }) => { + const input = await mount(renamingItemId); + + expect(actions.renameInputRef.current).toBe(input); + expect(input.className).toBe(INPUT_CLASS); + expect(input.value).toBe('Before'); + + await act(async () => { + setNativeValue(input, 'After'); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + expect(actions.setRenameValue).toHaveBeenCalledWith('After'); + + input.focus(); + expect(document.activeElement).toBe(input); + const enter = new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + cancelable: true, + }); + await act(async () => { + input.dispatchEvent(enter); + }); + + expect(enter.defaultPrevented).toBe(true); + expect(document.activeElement).not.toBe(input); + expect(expectedCommit()).toHaveBeenCalledTimes(1); + expect(expectedCommit()).toHaveBeenCalledWith(...expectedArgs); + expect(actions.events).toEqual([ + 'cancelled:get:false', + `${renamingItemId.startsWith('group:') ? 'group' : 'item'}:commit`, + 'cancelled:set:false', + ]); + expect(actions.renameCancelledRef.current).toBe(false); + expect(actions.setRenamingItemId).not.toHaveBeenCalled(); + }, + ); + + it.each([ + ['group', `group:${GROUP_ID}`], + ['item', ITEM_ID], + ])( + '%s 입력 Escape는 cancel 후 blur commit을 억제하고 ref를 reset한다', + async (_name, renamingItemId) => { + const input = await mount(renamingItemId); + input.focus(); + const escape = new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true, + }); + + await act(async () => { + input.dispatchEvent(escape); + }); + + expect(escape.defaultPrevented).toBe(true); + expect(actions.setRenamingItemId).toHaveBeenCalledWith(null); + expect(actions.events).toEqual(['cancelled:set:true', 'cancel']); + expect(actions.handleGroupRenameCommit).not.toHaveBeenCalled(); + expect(actions.handleLayerRenameCommit).not.toHaveBeenCalled(); + + await act(async () => input.blur()); + + expect(actions.handleGroupRenameCommit).not.toHaveBeenCalled(); + expect(actions.handleLayerRenameCommit).not.toHaveBeenCalled(); + expect(actions.events).toEqual([ + 'cancelled:set:true', + 'cancel', + 'cancelled:get:true', + 'cancelled:set:false', + ]); + expect(actions.renameCancelledRef.current).toBe(false); + }, + ); + + it.each([ + ['group', `group:${GROUP_ID}`], + ['item', ITEM_ID], + ])( + '%s 입력 mouse/click/doubleclick은 행과 외부로 전파하지 않는다', + async (_name, renamingItemId) => { + const input = await mount(renamingItemId); + const propagated = vi.fn(); + document.body.addEventListener('mousedown', propagated); + document.body.addEventListener('click', propagated); + document.body.addEventListener('dblclick', propagated); + + await act(async () => { + input.dispatchEvent( + new MouseEvent('mousedown', { bubbles: true, cancelable: true }), + ); + input.dispatchEvent( + new MouseEvent('click', { bubbles: true, cancelable: true }), + ); + input.dispatchEvent( + new MouseEvent('dblclick', { bubbles: true, cancelable: true }), + ); + }); + + expect(propagated).not.toHaveBeenCalled(); + expect(mocks.handleMouseDown).not.toHaveBeenCalled(); + expect(mocks.handleGroupMouseDown).not.toHaveBeenCalled(); + document.body.removeEventListener('mousedown', propagated); + document.body.removeEventListener('click', propagated); + document.body.removeEventListener('dblclick', propagated); + }, + ); +}); From d5900d052c52daa1c7ea9c8afb8f05392a9aa134 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 22:12:21 +0900 Subject: [PATCH 226/245] =?UTF-8?q?refactor:=20=EB=A0=88=EC=9D=B4=EC=96=B4?= =?UTF-8?q?=20=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=20=EA=B3=B5=EC=9A=A9=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../layer/LayerRenameInput.tsx | 50 +++++++++++++ .../PropertiesPanel/layer/LayerTabContent.tsx | 71 +++++-------------- 2 files changed, 67 insertions(+), 54 deletions(-) create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/layer/LayerRenameInput.tsx diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerRenameInput.tsx b/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerRenameInput.tsx new file mode 100644 index 000000000..7a2b692c8 --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerRenameInput.tsx @@ -0,0 +1,50 @@ +import React from 'react'; + +interface LayerRenameInputProps { + inputRef: React.RefObject; + value: string; + cancelledRef: React.MutableRefObject; + onChange: (value: string) => void; + onCommit: (value: string) => void; + onCancel: () => void; +} + +const LayerRenameInput = ({ + inputRef, + value, + cancelledRef, + onChange, + onCommit, + onCancel, +}: LayerRenameInputProps) => { + return ( + onChange(event.target.value)} + onBlur={() => { + if (!cancelledRef.current) { + onCommit(value); + } + cancelledRef.current = false; + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + (event.target as HTMLInputElement).blur(); + } else if (event.key === 'Escape') { + event.preventDefault(); + cancelledRef.current = true; + onCancel(); + } + }} + onMouseDown={(event) => event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + /> + ); +}; + +export default LayerRenameInput; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx index a0c4944b7..398e236a5 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx @@ -33,6 +33,7 @@ import { } from './LayerIcons'; import { useLayerActions } from './useLayerActions'; import { useLayerDnD } from './useLayerDnD'; +import LayerRenameInput from './LayerRenameInput'; import { useOptimisticBooleanCommit } from '@hooks/useOptimisticBooleanCommit'; import { layerItemToSelectedElement, @@ -563,34 +564,15 @@ const LayerTabContent: React.FC = ({ {/* 그룹 이름 */} {isRenamingGroup ? ( - actions.setRenameValue(e.target.value)} - onBlur={() => { - if (!actions.renameCancelledRef.current) { - actions.handleGroupRenameCommit( - gh.groupId, - actions.renameValue, - ); - } - actions.renameCancelledRef.current = false; - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - (e.target as HTMLInputElement).blur(); - } else if (e.key === 'Escape') { - e.preventDefault(); - actions.renameCancelledRef.current = true; - actions.setRenamingItemId(null); - } - }} - onMouseDown={(e) => e.stopPropagation()} - onClick={(e) => e.stopPropagation()} - onDoubleClick={(e) => e.stopPropagation()} + cancelledRef={actions.renameCancelledRef} + onChange={actions.setRenameValue} + onCommit={(value) => + actions.handleGroupRenameCommit(gh.groupId, value) + } + onCancel={() => actions.setRenamingItemId(null)} /> ) : ( @@ -671,34 +653,15 @@ const LayerTabContent: React.FC = ({ {/* 이름 */} {actions.renamingItemId === item.id ? ( - actions.setRenameValue(e.target.value)} - onBlur={() => { - if (!actions.renameCancelledRef.current) { - actions.handleLayerRenameCommit( - item, - actions.renameValue, - ); - } - actions.renameCancelledRef.current = false; - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - (e.target as HTMLInputElement).blur(); - } else if (e.key === 'Escape') { - e.preventDefault(); - actions.renameCancelledRef.current = true; - actions.setRenamingItemId(null); - } - }} - onMouseDown={(e) => e.stopPropagation()} - onClick={(e) => e.stopPropagation()} - onDoubleClick={(e) => e.stopPropagation()} + cancelledRef={actions.renameCancelledRef} + onChange={actions.setRenameValue} + onCommit={(value) => + actions.handleLayerRenameCommit(item, value) + } + onCancel={() => actions.setRenamingItemId(null)} /> ) : ( From c06f5ebfcc7b5518bd5cfc530c698c95b45133af Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 22:18:48 +0900 Subject: [PATCH 227/245] =?UTF-8?q?test:=20=EC=98=A4=EB=B2=84=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=20=EB=A6=AC=EC=82=AC=EC=9D=B4=EC=A6=88=20=EC=95=B5?= =?UTF-8?q?=EC=BB=A4=20=ED=81=90=20=EA=B3=84=EC=95=BD=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/Settings.obsController.test.tsx | 221 +++++++++++++++++- 1 file changed, 213 insertions(+), 8 deletions(-) diff --git a/src/renderer/components/main/Settings.obsController.test.tsx b/src/renderer/components/main/Settings.obsController.test.tsx index 061180346..d7fcddc5b 100644 --- a/src/renderer/components/main/Settings.obsController.test.tsx +++ b/src/renderer/components/main/Settings.obsController.test.tsx @@ -6,9 +6,12 @@ import type { ObsStatus } from '@src/types/obs'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; const obsHarness = vi.hoisted(() => ({ + appRestart: vi.fn(), clearSelection: vi.fn(), copyText: vi.fn(), onStatus: vi.fn(), + overlayResizeAnchor: 'top-left', + overlaySetAnchor: vi.fn(), regenerateToken: vi.fn(), setAngleMode: vi.fn(), setAutoUpdateEnabled: vi.fn(), @@ -68,7 +71,7 @@ vi.mock('@stores/useSettingsStore', () => ({ jsPlugins: [], language: 'ko', setLanguage: obsHarness.setLanguage, - overlayResizeAnchor: 'top-left', + overlayResizeAnchor: obsHarness.overlayResizeAnchor, setOverlayResizeAnchor: obsHarness.setOverlayResizeAnchor, keyCounterEnabled: true, setKeyCounterEnabled: obsHarness.setKeyCounterEnabled, @@ -100,7 +103,29 @@ vi.mock('@stores/data/useLayerGroupStore', () => ({ useLayerGroupStore: { getState: () => ({ layerGroups: {} }) }, })); vi.mock('@components/main/common/Dropdown', () => ({ - default: () => null, + default: ({ + options, + value, + onChange, + placeholder, + }: { + options: { value: string; label: string }[]; + value: string; + onChange: (value: string) => void; + placeholder?: string; + }) => ( + + ), })); vi.mock('@components/main/common/ReloadButton', () => ({ default: (props: React.ButtonHTMLAttributes) => ( @@ -193,7 +218,7 @@ vi.mock('@api/modules/obsApi', () => ({ }, })); vi.mock('@api/modules/overlayApi', () => ({ - overlayApi: { setLock: vi.fn(), setAnchor: vi.fn() }, + overlayApi: { setLock: vi.fn(), setAnchor: obsHarness.overlaySetAnchor }, })); vi.mock('@api/modules/cssApi', () => ({ cssApi: { toggle: vi.fn() }, @@ -205,7 +230,7 @@ vi.mock('@api/modules/keysApi', () => ({ keysApi: { resetCounters: vi.fn(), resetAll: vi.fn() }, })); vi.mock('@api/modules/appApi', () => ({ - appApi: { restart: vi.fn() }, + appApi: { restart: obsHarness.appRestart }, windowApi: { openDevtoolsAll: vi.fn() }, })); @@ -260,13 +285,15 @@ const createShowConfirmMock = () => describe('Settings OBS controller surface', () => { let container: HTMLDivElement; - let root: Root; + let root: Root | null; let showAlert: ReturnType; let showConfirm: ReturnType; const renderSettings = async () => { await act(async () => { - root.render(); + root?.render( + , + ); await Promise.resolve(); await Promise.resolve(); }); @@ -283,6 +310,18 @@ describe('Settings OBS controller surface', () => { const regenerateButton = (): HTMLButtonElement => container.querySelector('[title="settings.obsTokenRegen"]')!; + const resizeAnchorSelect = (): HTMLSelectElement => + container.querySelector('[aria-label="settings.selectAnchor"]')!; + + const changeResizeAnchor = (value: string) => { + const select = resizeAnchorSelect(); + Object.getOwnPropertyDescriptor( + HTMLSelectElement.prototype, + 'value', + )?.set?.call(select, value); + select.dispatchEvent(new Event('change', { bubbles: true })); + }; + const settle = async () => { await act(async () => { for (let index = 0; index < 5; index += 1) { @@ -296,6 +335,11 @@ describe('Settings OBS controller surface', () => { vi.clearAllMocks(); vi.stubGlobal('__APP_VERSION__', 'test'); obsHarness.statusListener = null; + obsHarness.overlayResizeAnchor = 'top-left'; + obsHarness.setOverlayResizeAnchor.mockImplementation((value: string) => { + obsHarness.overlayResizeAnchor = value; + }); + obsHarness.overlaySetAnchor.mockResolvedValue(undefined); obsHarness.status.mockResolvedValue(STOPPED_STATUS); obsHarness.onStatus.mockImplementation( (listener: (status: ObsStatus) => void) => { @@ -320,7 +364,10 @@ describe('Settings OBS controller surface', () => { }); afterEach(() => { - act(() => root.unmount()); + if (root) { + act(() => root?.unmount()); + root = null; + } container.remove(); vi.useRealTimers(); vi.unstubAllGlobals(); @@ -367,7 +414,8 @@ describe('Settings OBS controller surface', () => { await act(async () => vi.advanceTimersByTimeAsync(5000)); expect(vi.getTimerCount()).toBeGreaterThan(0); - act(() => root.unmount()); + act(() => root?.unmount()); + root = null; expect(obsHarness.unsubscribe).toHaveBeenCalledOnce(); expect(clearIntervalSpy).toHaveBeenCalledOnce(); expect(obsHarness.unsubscribe.mock.invocationCallOrder[0]).toBeLessThan( @@ -511,4 +559,161 @@ describe('Settings OBS controller surface', () => { confirmText: 'settings.obsTokenRegenConfirm', }); }); + + it('resize anchor 옵션 번역·순서와 직접 적용의 무재시작·무알림 계약을 유지한다', async () => { + await renderSettings(); + + expect( + [...resizeAnchorSelect().options].map(({ value, text }) => ({ + value, + text, + })), + ).toEqual([ + { value: 'top-left', text: 'settings.topLeft' }, + { value: 'bottom-left', text: 'settings.bottomLeft' }, + { value: 'top-right', text: 'settings.topRight' }, + { value: 'bottom-right', text: 'settings.bottomRight' }, + { value: 'center', text: 'settings.center' }, + ]); + + act(() => changeResizeAnchor('bottom-left')); + await settle(); + + expect(obsHarness.setOverlayResizeAnchor).toHaveBeenCalledWith( + 'bottom-left', + ); + expect(obsHarness.overlaySetAnchor).toHaveBeenCalledWith('bottom-left'); + expect(obsHarness.settingsUpdate).not.toHaveBeenCalled(); + expect(obsHarness.appRestart).not.toHaveBeenCalled(); + expect(showAlert).not.toHaveBeenCalled(); + expect(showConfirm).not.toHaveBeenCalled(); + }); + + it('rapid anchor 변경은 첫 요청과 최신 pending만 순서대로 적용한다', async () => { + const first = deferred(); + const latest = deferred(); + obsHarness.overlaySetAnchor + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(latest.promise); + await renderSettings(); + + act(() => { + changeResizeAnchor('top-right'); + changeResizeAnchor('bottom-left'); + changeResizeAnchor('center'); + }); + + expect(obsHarness.setOverlayResizeAnchor.mock.calls).toEqual([ + ['top-right'], + ['bottom-left'], + ['center'], + ]); + expect(obsHarness.overlaySetAnchor.mock.calls).toEqual([['top-right']]); + + first.resolve(); + await settle(); + expect(obsHarness.overlaySetAnchor.mock.calls).toEqual([ + ['top-right'], + ['center'], + ]); + + latest.resolve(); + await settle(); + }); + + it('선행 실패와 최신 pending이 공존하면 중간 rollback 없이 최신 요청을 계속한다', async () => { + const first = deferred(); + const latest = deferred(); + obsHarness.overlaySetAnchor + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(latest.promise); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + await renderSettings(); + + act(() => changeResizeAnchor('top-right')); + act(() => changeResizeAnchor('bottom-right')); + first.reject(new Error('first failed')); + await settle(); + + expect(obsHarness.setOverlayResizeAnchor.mock.calls).toEqual([ + ['top-right'], + ['bottom-right'], + ]); + expect(obsHarness.overlaySetAnchor.mock.calls).toEqual([ + ['top-right'], + ['bottom-right'], + ]); + + latest.resolve(); + await settle(); + }); + + it('최종 실패는 마지막 성공 anchor로 rollback하고 다음 요청 gate를 연다', async () => { + obsHarness.overlaySetAnchor + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('latest failed')) + .mockResolvedValueOnce(undefined); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + await renderSettings(); + + act(() => changeResizeAnchor('top-right')); + await settle(); + act(() => changeResizeAnchor('bottom-left')); + await settle(); + + expect(obsHarness.setOverlayResizeAnchor.mock.calls).toEqual([ + ['top-right'], + ['bottom-left'], + ['top-right'], + ]); + expect(console.error).toHaveBeenCalledWith( + 'Failed to set overlay anchor', + expect.any(Error), + ); + + act(() => changeResizeAnchor('center')); + await settle(); + expect(obsHarness.overlaySetAnchor.mock.calls).toEqual([ + ['top-right'], + ['bottom-left'], + ['center'], + ]); + }); + + it('idle authoritative anchor 변경은 이후 실패 rollback 기준을 갱신한다', async () => { + await renderSettings(); + obsHarness.overlayResizeAnchor = 'center'; + await renderSettings(); + obsHarness.setOverlayResizeAnchor.mockClear(); + obsHarness.overlaySetAnchor.mockRejectedValueOnce( + new Error('set anchor failed'), + ); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + + act(() => changeResizeAnchor('bottom-right')); + await settle(); + + expect(obsHarness.setOverlayResizeAnchor.mock.calls).toEqual([ + ['bottom-right'], + ['center'], + ]); + }); + + it('unmount 뒤 늦은 실패도 기존처럼 마지막 confirmed anchor rollback을 수행한다', async () => { + const pending = deferred(); + obsHarness.overlaySetAnchor.mockReturnValueOnce(pending.promise); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + await renderSettings(); + + act(() => changeResizeAnchor('top-right')); + act(() => root?.unmount()); + root = null; + pending.reject(new Error('late failure')); + await settle(); + + expect(obsHarness.setOverlayResizeAnchor.mock.calls).toEqual([ + ['top-right'], + ['top-left'], + ]); + }); }); From 3cab0c05df5aa02bb6c1ea9bf4e2a27248dc5735 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 22:18:48 +0900 Subject: [PATCH 228/245] =?UTF-8?q?refactor:=20=EC=98=A4=EB=B2=84=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=20=EB=A6=AC=EC=82=AC=EC=9D=B4=EC=A6=88=20=EC=95=B5?= =?UTF-8?q?=EC=BB=A4=20=EC=A0=9C=EC=96=B4=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/components/main/Settings.tsx | 38 ++------------ .../main/useOverlayResizeAnchorController.ts | 49 +++++++++++++++++++ 2 files changed, 54 insertions(+), 33 deletions(-) create mode 100644 src/renderer/components/main/useOverlayResizeAnchorController.ts diff --git a/src/renderer/components/main/Settings.tsx b/src/renderer/components/main/Settings.tsx index 349689ff6..a019d3b87 100644 --- a/src/renderer/components/main/Settings.tsx +++ b/src/renderer/components/main/Settings.tsx @@ -36,6 +36,7 @@ import { getPluginDisplayName } from '@utils/plugin/pluginUtils'; import { isMac } from '@utils/core/platform'; import { useUpdateCheck } from '@hooks/app/useUpdateCheck'; import { useObsSettingsController } from '@components/main/useObsSettingsController'; +import { useOverlayResizeAnchorController } from '@components/main/useOverlayResizeAnchorController'; import type { OverlayResizeAnchor } from '@src/types/settings/settings'; import type { ShortcutsState } from '@src/types/settings/shortcuts'; import type { SupportedLocale } from '@contexts/I18nContextDef'; @@ -129,15 +130,10 @@ const Settings = ({ const removingPluginRef = useRef(null); const resetAllRef = useRef(false); const angleModeChangeRef = useRef(false); - const pendingResizeAnchorRef = useRef(null); - const applyingResizeAnchorRef = useRef(false); - const confirmedResizeAnchorRef = useRef(overlayResizeAnchor); - - useEffect(() => { - if (!applyingResizeAnchorRef.current) { - confirmedResizeAnchorRef.current = overlayResizeAnchor; - } - }, [overlayResizeAnchor]); + const enqueueResizeAnchor = useOverlayResizeAnchorController({ + overlayResizeAnchor, + setOverlayResizeAnchor, + }); // Lenis smooth scroll 적용 (전역 설정 사용) const { scrollContainerRef } = useLenis(); @@ -335,30 +331,6 @@ const Settings = ({ } }; - const enqueueResizeAnchor = (anchor: OverlayResizeAnchor): void => { - pendingResizeAnchorRef.current = anchor; - setOverlayResizeAnchor(anchor); - if (applyingResizeAnchorRef.current) return; - - applyingResizeAnchorRef.current = true; - void (async () => { - while (pendingResizeAnchorRef.current) { - const requested = pendingResizeAnchorRef.current; - pendingResizeAnchorRef.current = null; - try { - await overlayApi.setAnchor(requested); - confirmedResizeAnchorRef.current = requested; - } catch (error) { - console.error('Failed to set overlay anchor', error); - if (!pendingResizeAnchorRef.current) { - setOverlayResizeAnchor(confirmedResizeAnchorRef.current); - } - } - } - applyingResizeAnchorRef.current = false; - })(); - }; - const handleTrayToggle = async (): Promise => { const next: boolean = !trayEnabled; setTrayEnabled(next); diff --git a/src/renderer/components/main/useOverlayResizeAnchorController.ts b/src/renderer/components/main/useOverlayResizeAnchorController.ts new file mode 100644 index 000000000..00b02ff73 --- /dev/null +++ b/src/renderer/components/main/useOverlayResizeAnchorController.ts @@ -0,0 +1,49 @@ +import { useEffect, useRef } from 'react'; +import { overlayApi } from '@api/modules/overlayApi'; +import type { OverlayResizeAnchor } from '@src/types/settings/settings'; + +interface UseOverlayResizeAnchorControllerOptions { + overlayResizeAnchor: OverlayResizeAnchor; + setOverlayResizeAnchor: (anchor: OverlayResizeAnchor) => void; +} + +export const useOverlayResizeAnchorController = ({ + overlayResizeAnchor, + setOverlayResizeAnchor, +}: UseOverlayResizeAnchorControllerOptions) => { + const pendingResizeAnchorRef = useRef(null); + const applyingResizeAnchorRef = useRef(false); + const confirmedResizeAnchorRef = useRef(overlayResizeAnchor); + + useEffect(() => { + if (!applyingResizeAnchorRef.current) { + confirmedResizeAnchorRef.current = overlayResizeAnchor; + } + }, [overlayResizeAnchor]); + + const enqueueResizeAnchor = (anchor: OverlayResizeAnchor): void => { + pendingResizeAnchorRef.current = anchor; + setOverlayResizeAnchor(anchor); + if (applyingResizeAnchorRef.current) return; + + applyingResizeAnchorRef.current = true; + void (async () => { + while (pendingResizeAnchorRef.current) { + const requested = pendingResizeAnchorRef.current; + pendingResizeAnchorRef.current = null; + try { + await overlayApi.setAnchor(requested); + confirmedResizeAnchorRef.current = requested; + } catch (error) { + console.error('Failed to set overlay anchor', error); + if (!pendingResizeAnchorRef.current) { + setOverlayResizeAnchor(confirmedResizeAnchorRef.current); + } + } + } + applyingResizeAnchorRef.current = false; + })(); + }; + + return enqueueResizeAnchor; +}; From 062345094152786b6381a6580edabad06366f38c Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 22:22:48 +0900 Subject: [PATCH 229/245] =?UTF-8?q?test:=20=EC=82=AC=EC=9A=B4=EB=93=9C=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=EA=B8=B0=20=EB=9D=BC=EC=9D=B4=EB=B8=8C?= =?UTF-8?q?=EB=9F=AC=EB=A6=AC=20=EB=9F=B0=ED=83=80=EC=9E=84=20=EA=B3=84?= =?UTF-8?q?=EC=95=BD=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pickers/SoundPicker.runtime.test.tsx | 442 ++++++++++++++++++ 1 file changed, 442 insertions(+) create mode 100644 src/renderer/components/main/Modal/content/pickers/SoundPicker.runtime.test.tsx diff --git a/src/renderer/components/main/Modal/content/pickers/SoundPicker.runtime.test.tsx b/src/renderer/components/main/Modal/content/pickers/SoundPicker.runtime.test.tsx new file mode 100644 index 000000000..f10acd684 --- /dev/null +++ b/src/renderer/components/main/Modal/content/pickers/SoundPicker.runtime.test.tsx @@ -0,0 +1,442 @@ +// @vitest-environment jsdom +import React, { act, useState } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, + type Mock, +} from 'vitest'; +import type { SoundListItem } from '@src/types/plugin/api'; + +interface PickerPageCapture { + open: boolean; + items: SoundListItem[]; + isLoading: boolean; + errorText: string; + onAdd: () => void; + onFilterChange: (value: string) => void; + renderItem: (item: SoundListItem) => React.ReactNode; +} + +interface TrimModalCapture { + isOpen: boolean; + onClose: () => void; + onSaved: (soundPath: string) => void; +} + +const mocks = vi.hoisted(() => ({ + page: null as PickerPageCapture | null, + trim: null as TrimModalCapture | null, + openMenu: null as null | ((key: string) => void), + selectMenuItem: null as null | ((id: string) => void), + menuItems: [] as Array<{ id: string; label: string }>, + menuClose: vi.fn(), +})); + +const apiMocks = vi.hoisted(() => ({ + list: vi.fn(), + confirm: vi.fn(), + remove: vi.fn(), + rename: vi.fn(), + setHidden: vi.fn(), +})); + +vi.mock('@api/modules/resourceApi', () => ({ + soundApi: { + remove: (...args: unknown[]) => apiMocks.remove(...args), + rename: (...args: unknown[]) => apiMocks.rename(...args), + setHidden: (...args: unknown[]) => apiMocks.setHidden(...args), + }, +})); + +vi.mock('@contexts/useTranslation', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock('./CommonListPickerPage', () => ({ + default: (props: PickerPageCapture) => { + mocks.page = props; + return ( +
+ {props.items.map((item) => props.renderItem(item))} +
+ ); + }, +})); + +vi.mock('@hooks/usePickerItemMenu', () => ({ + usePickerItemMenu: () => { + const [menuKey, setMenuKey] = useState(null); + mocks.openMenu = setMenuKey; + const close = () => { + mocks.menuClose(); + setMenuKey(null); + }; + return { + menuKey, + renderKey: menuKey, + renderPosition: null, + capturePressState: vi.fn(), + openFromButton: vi.fn(), + openFromKeyboard: vi.fn(), + openFromContextMenu: vi.fn(), + close, + }; + }, +})); + +vi.mock('@components/main/Modal/ListPopup', () => ({ + default: ({ + items, + onSelect, + }: { + items: Array<{ id: string; label: string }>; + onSelect: (id: string) => void; + }) => { + mocks.menuItems = items; + mocks.selectMenuItem = onSelect; + return null; + }, +})); + +vi.mock('../managers/SoundTrimModal', () => ({ + default: (props: TrimModalCapture) => { + mocks.trim = props; + return null; + }, +})); + +import SoundPicker from './SoundPicker'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const SOUND: SoundListItem = { + soundPath: 'sounds/tick.wav', + fileName: 'tick.wav', + sizeBytes: 100, + displayName: 'tick', + source: 'local', + hidden: false, + enabled: true, + originalPath: 'orig/tick.wav', +}; + +const OTHER_SOUND: SoundListItem = { + soundPath: 'sounds/tock.wav', + fileName: 'tock.wav', + sizeBytes: 200, + displayName: 'tock', + source: 'local', + hidden: false, + enabled: true, + originalPath: 'orig/tock.wav', +}; + +const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +}; + +const setNativeValue = (input: HTMLInputElement, value: string) => { + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )?.set?.call(input, value); +}; + +describe('SoundPicker library runtime contract', () => { + let container: HTMLDivElement; + let root: Root; + let onSoundSelect: Mock<(soundPath: string | null) => void>; + let currentOpen: boolean; + let currentSelectedSound: string | null; + + const settle = async () => { + await act(async () => { + for (let index = 0; index < 6; index += 1) await Promise.resolve(); + }); + }; + + const render = async () => { + await act(async () => { + root.render( + , + ); + await Promise.resolve(); + }); + }; + + const openMenuAction = async (id: string, soundPath = SOUND.soundPath) => { + await act(async () => mocks.openMenu?.(soundPath)); + await act(async () => mocks.selectMenuItem?.(id)); + }; + + const pageItems = () => mocks.page?.items ?? []; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.page = null; + mocks.trim = null; + mocks.openMenu = null; + mocks.selectMenuItem = null; + mocks.menuItems = []; + onSoundSelect = vi.fn(); + currentOpen = true; + currentSelectedSound = SOUND.soundPath; + apiMocks.list.mockResolvedValue([SOUND]); + apiMocks.confirm.mockResolvedValue(false); + apiMocks.remove.mockResolvedValue(undefined); + apiMocks.rename.mockResolvedValue(undefined); + apiMocks.setHidden.mockResolvedValue(undefined); + window.api = { + sound: { list: apiMocks.list }, + ui: { dialog: { confirm: apiMocks.confirm } }, + } as never; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + }); + + it('close/reopen generation은 이전 load 성공을 폐기하고 최신 응답만 반영한다', async () => { + const stale = deferred(); + const latest = deferred(); + apiMocks.list + .mockReset() + .mockReturnValueOnce(stale.promise) + .mockReturnValueOnce(latest.promise); + + await render(); + currentOpen = false; + await render(); + currentOpen = true; + await render(); + + latest.resolve([OTHER_SOUND]); + await settle(); + expect(pageItems()).toEqual([OTHER_SOUND]); + expect(mocks.page?.isLoading).toBe(false); + + stale.resolve([SOUND]); + await settle(); + expect(pageItems()).toEqual([OTHER_SOUND]); + expect(mocks.page?.errorText).toBe(''); + expect(apiMocks.list).toHaveBeenCalledTimes(2); + }); + + it('현재 load 실패만 오류와 loading 정산을 반영한다', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + apiMocks.list.mockReset().mockRejectedValueOnce(new Error('list failed')); + + await render(); + await settle(); + + expect(mocks.page?.errorText).toBe('soundPicker.loadFailed'); + expect(mocks.page?.isLoading).toBe(false); + }); + + it('close는 메뉴만 닫고 rename·trim 상태는 reopen까지 유지한다', async () => { + await render(); + await settle(); + await openMenuAction('rename'); + expect(container.querySelector('input[type="text"]')).not.toBeNull(); + + act(() => mocks.page?.onAdd()); + expect(mocks.trim?.isOpen).toBe(true); + currentOpen = false; + await render(); + + expect(mocks.menuClose).toHaveBeenCalled(); + expect(mocks.trim?.isOpen).toBe(true); + expect(container.querySelector('input[type="text"]')).not.toBeNull(); + + currentOpen = true; + await render(); + expect(container.querySelector('input[type="text"]')).not.toBeNull(); + act(() => mocks.trim?.onClose()); + expect(mocks.trim?.isOpen).toBe(false); + }); + + it('선택 행은 빈 문자열로 해제하고 비선택 행은 raw path를 전달한다', async () => { + await render(); + await settle(); + + act(() => container.querySelector('[title="tick"]')?.click()); + expect(onSoundSelect).toHaveBeenLastCalledWith(''); + + currentSelectedSound = null; + await render(); + act(() => container.querySelector('[title="tick"]')?.click()); + expect(onSoundSelect).toHaveBeenLastCalledWith(SOUND.soundPath); + }); + + it('삭제 확인 pending은 같은 target의 중복 action을 막고 cancel 뒤 해제된다', async () => { + const firstConfirm = deferred(); + apiMocks.confirm + .mockReset() + .mockReturnValueOnce(firstConfirm.promise) + .mockResolvedValueOnce(false); + await render(); + await settle(); + + await openMenuAction('delete'); + await openMenuAction('delete'); + expect(apiMocks.confirm).toHaveBeenCalledTimes(1); + + firstConfirm.resolve(false); + await settle(); + await openMenuAction('delete'); + expect(apiMocks.confirm).toHaveBeenCalledTimes(2); + expect(apiMocks.remove).not.toHaveBeenCalled(); + }); + + it('삭제는 확인→optimistic 제거→API→reload 순서를 보존한다', async () => { + const remove = deferred(); + const events: string[] = []; + apiMocks.confirm.mockImplementation(async () => { + events.push('confirm'); + return true; + }); + apiMocks.remove.mockImplementation(() => { + events.push('remove'); + return remove.promise; + }); + apiMocks.list + .mockReset() + .mockResolvedValueOnce([SOUND]) + .mockImplementationOnce(async () => { + events.push('reload'); + return [OTHER_SOUND]; + }); + await render(); + await settle(); + + await openMenuAction('delete'); + await settle(); + expect(pageItems()).toEqual([]); + expect(events).toEqual(['confirm', 'remove']); + + remove.resolve(); + await settle(); + expect(events).toEqual(['confirm', 'remove', 'reload']); + expect(pageItems()).toEqual([OTHER_SOUND]); + }); + + it('삭제 실패는 authoritative reload 뒤 전용 오류를 표시한다', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + apiMocks.confirm.mockResolvedValueOnce(true); + apiMocks.remove.mockRejectedValueOnce(new Error('remove failed')); + apiMocks.list.mockResolvedValueOnce([SOUND]).mockResolvedValueOnce([SOUND]); + await render(); + await settle(); + await openMenuAction('delete'); + await settle(); + + expect(pageItems()).toEqual([SOUND]); + expect(mocks.page?.errorText).toBe('soundPicker.deleteFailed'); + }); + + it('rename은 focus/current value·trim·optimistic update와 pending 상호 배제를 유지한다', async () => { + const rename = deferred(); + apiMocks.rename.mockReturnValueOnce(rename.promise); + apiMocks.list + .mockResolvedValueOnce([SOUND]) + .mockResolvedValueOnce([{ ...SOUND, displayName: 'renamed' }]); + await render(); + await settle(); + await openMenuAction('rename'); + + const input = + container.querySelector('input[type="text"]')!; + expect(document.activeElement).toBe(input); + expect(input.value).toBe('tick'); + await act(async () => { + setNativeValue(input, ' renamed '); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + await act(async () => { + input.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + cancelable: true, + }), + ); + }); + + expect(apiMocks.rename).toHaveBeenCalledWith(SOUND.soundPath, 'renamed'); + expect(pageItems()[0]?.displayName).toBe('renamed'); + await openMenuAction('toggle-hidden'); + expect(apiMocks.setHidden).not.toHaveBeenCalled(); + + rename.resolve(); + await settle(); + expect(pageItems()[0]?.displayName).toBe('renamed'); + }); + + it('rename 실패는 authoritative reload 뒤 전용 오류를 표시한다', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + apiMocks.rename.mockRejectedValueOnce(new Error('rename failed')); + apiMocks.list.mockResolvedValueOnce([SOUND]).mockResolvedValueOnce([SOUND]); + await render(); + await settle(); + await openMenuAction('rename'); + const input = + container.querySelector('input[type="text"]')!; + await act(async () => { + setNativeValue(input, 'broken'); + input.dispatchEvent(new Event('input', { bubbles: true })); + input.blur(); + }); + await settle(); + + expect(pageItems()).toEqual([SOUND]); + expect(mocks.page?.errorText).toBe('soundPicker.renameFailed'); + }); + + it('hidden 성공은 optimistic filter와 exact boolean을, 실패는 reload 오류를 보존한다', async () => { + const hide = deferred(); + apiMocks.setHidden.mockReturnValueOnce(hide.promise); + apiMocks.list + .mockResolvedValueOnce([SOUND]) + .mockResolvedValueOnce([{ ...SOUND, hidden: true }]); + await render(); + await settle(); + await openMenuAction('toggle-hidden'); + + expect(apiMocks.setHidden).toHaveBeenCalledWith(SOUND.soundPath, true); + expect(pageItems()).toEqual([]); + hide.resolve(); + await settle(); + act(() => mocks.page?.onFilterChange('hidden')); + expect(pageItems()).toEqual([{ ...SOUND, hidden: true }]); + + vi.spyOn(console, 'error').mockImplementation(() => undefined); + apiMocks.setHidden.mockRejectedValueOnce(new Error('unhide failed')); + apiMocks.list.mockResolvedValueOnce([{ ...SOUND, hidden: true }]); + await openMenuAction('toggle-hidden'); + await settle(); + expect(apiMocks.setHidden).toHaveBeenLastCalledWith(SOUND.soundPath, false); + expect(mocks.page?.errorText).toBe('soundPicker.hideFailed'); + }); +}); From a4ace27f768a80b456b6ad1286c89a4513e7aa5c Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 22:22:48 +0900 Subject: [PATCH 230/245] =?UTF-8?q?refactor:=20=EC=82=AC=EC=9A=B4=EB=93=9C?= =?UTF-8?q?=20=EC=84=A0=ED=83=9D=EA=B8=B0=20=EB=9D=BC=EC=9D=B4=EB=B8=8C?= =?UTF-8?q?=EB=9F=AC=EB=A6=AC=20=EB=9F=B0=ED=83=80=EC=9E=84=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Modal/content/pickers/SoundPicker.tsx | 140 ++------------- .../pickers/useSoundPickerLibraryRuntime.ts | 159 ++++++++++++++++++ 2 files changed, 177 insertions(+), 122 deletions(-) create mode 100644 src/renderer/components/main/Modal/content/pickers/useSoundPickerLibraryRuntime.ts diff --git a/src/renderer/components/main/Modal/content/pickers/SoundPicker.tsx b/src/renderer/components/main/Modal/content/pickers/SoundPicker.tsx index 35b911097..5f8031339 100644 --- a/src/renderer/components/main/Modal/content/pickers/SoundPicker.tsx +++ b/src/renderer/components/main/Modal/content/pickers/SoundPicker.tsx @@ -15,8 +15,8 @@ import SoundTrimModal from '../managers/SoundTrimModal'; import { useEditSessionCompletionGuard } from '@src/renderer/contexts/EditSessionScope'; import type { CompletionBinding } from '@src/renderer/contexts/EditSessionScope'; -import { soundApi } from '@api/modules/resourceApi'; import { soundTrimEditProps } from '../managers/soundTrimEditProps'; +import { useSoundPickerLibraryRuntime } from './useSoundPickerLibraryRuntime'; interface SoundPickerProps { open: boolean; @@ -67,33 +67,23 @@ const SoundPicker = ({ const menu = usePickerItemMenu(); const normalizedSelectedSound = (selectedSound || '').trim(); - - const loadSounds = async () => { - const requestId = ++loadRequestRef.current; - const cachedSounds = soundListCache; - if (cachedSounds) { - setSounds(cachedSounds); - setIsLoading(false); - } else { - setIsLoading(true); - } - setLoadError(''); - try { - const nextSounds = await window.api.sound.list(); - if (requestId !== loadRequestRef.current) return; - soundListCache = nextSounds; - if (!isOpenRef.current) return; - setSounds(nextSounds); - } catch (error) { - if (requestId !== loadRequestRef.current || !isOpenRef.current) return; - console.error('Failed to load sound list', error); - setLoadError(t('soundPicker.loadFailed') || '사운드 목록 로드 실패'); - } finally { - if (requestId === loadRequestRef.current && isOpenRef.current) { - setIsLoading(false); - } - } - }; + const libraryRuntime = useSoundPickerLibraryRuntime({ + t, + renameValue, + isOpenRef, + loadRequestRef, + pendingSoundActionsRef, + setSounds, + setIsLoading, + setLoadError, + setRenamingPath, + getSoundListCache: () => soundListCache, + setSoundListCache: (sounds) => { + soundListCache = sounds; + }, + }); + const { loadSounds, handleDelete, commitRename, handleToggleHidden } = + libraryRuntime; useEffect(() => { isOpenRef.current = open; @@ -193,100 +183,6 @@ const SoundPicker = ({ setTrimState({ mode: 'create', file }); }; - const handleDelete = async (item: SoundListItem) => { - if (pendingSoundActionsRef.current.has(item.soundPath)) return; - pendingSoundActionsRef.current.add(item.soundPath); - - try { - const confirmed = await window.api.ui.dialog.confirm( - t('soundPicker.deleteConfirm') || - '사운드를 삭제하시겠습니까? 파일이 삭제되고 이 사운드를 사용하는 모든 요소에서 해제됩니다.', - { - confirmText: t('contextMenu.delete') || '삭제', - cancelText: t('common.cancel') || '취소', - danger: true, - }, - ); - if (!confirmed) return; - - loadRequestRef.current += 1; - setSounds((current) => { - const next = current.filter( - (candidate) => candidate.soundPath !== item.soundPath, - ); - soundListCache = next; - return next; - }); - await soundApi.remove(item.soundPath); - // 백엔드가 참조 해제와 canonical 동기화를 소유 - // 응답 대기 중 새로 선택한 사운드를 예전 선택으로 재판단해 지우지 않는다 - await loadSounds(); - } catch (error) { - console.error('Failed to delete sound', error); - // loadSounds가 loadError를 초기화하므로 reload 후에 실패 메시지 설정 - await loadSounds(); - setLoadError(t('soundPicker.deleteFailed') || '사운드 삭제 실패'); - } finally { - pendingSoundActionsRef.current.delete(item.soundPath); - } - }; - - const commitRename = async (item: SoundListItem) => { - const trimmed = renameValue.trim(); - setRenamingPath(null); - if (!trimmed || trimmed === (item.displayName || item.fileName)) return; - if (pendingSoundActionsRef.current.has(item.soundPath)) return; - pendingSoundActionsRef.current.add(item.soundPath); - try { - loadRequestRef.current += 1; - setSounds((current) => { - const next = current.map((candidate) => - candidate.soundPath === item.soundPath - ? { ...candidate, displayName: trimmed } - : candidate, - ); - soundListCache = next; - return next; - }); - await soundApi.rename(item.soundPath, trimmed); - await loadSounds(); - } catch (error) { - console.error('Failed to rename sound', error); - // loadSounds가 loadError를 초기화하므로 reload 후에 실패 메시지 설정 - await loadSounds(); - setLoadError(t('soundPicker.renameFailed') || '사운드 이름 변경 실패'); - } finally { - pendingSoundActionsRef.current.delete(item.soundPath); - } - }; - - const handleToggleHidden = async (item: SoundListItem) => { - if (pendingSoundActionsRef.current.has(item.soundPath)) return; - pendingSoundActionsRef.current.add(item.soundPath); - try { - const nextHidden = !item.hidden; - loadRequestRef.current += 1; - setSounds((current) => { - const next = current.map((candidate) => - candidate.soundPath === item.soundPath - ? { ...candidate, hidden: nextHidden } - : candidate, - ); - soundListCache = next; - return next; - }); - await soundApi.setHidden(item.soundPath, nextHidden); - await loadSounds(); - } catch (error) { - console.error('Failed to toggle sound hidden', error); - // loadSounds가 loadError를 초기화하므로 reload 후에 실패 메시지 설정 - await loadSounds(); - setLoadError(t('soundPicker.hideFailed') || '사운드 숨김 변경 실패'); - } finally { - pendingSoundActionsRef.current.delete(item.soundPath); - } - }; - const handleTrimSaved = (soundPath: string) => { // 사운드 파일은 이미 저장됐다. 대상이 갈렸으면 연결만 하지 않는다 // (element-id 결합이면 ID applier가 유효성을 판정하므로 통과) diff --git a/src/renderer/components/main/Modal/content/pickers/useSoundPickerLibraryRuntime.ts b/src/renderer/components/main/Modal/content/pickers/useSoundPickerLibraryRuntime.ts new file mode 100644 index 000000000..2c4518641 --- /dev/null +++ b/src/renderer/components/main/Modal/content/pickers/useSoundPickerLibraryRuntime.ts @@ -0,0 +1,159 @@ +import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; +import { soundApi } from '@api/modules/resourceApi'; +import type { SoundListItem } from '@src/types/plugin/api'; + +interface SoundPickerLibraryRuntimeOptions { + t: (key: string) => string; + renameValue: string; + isOpenRef: MutableRefObject; + loadRequestRef: MutableRefObject; + pendingSoundActionsRef: MutableRefObject>; + setSounds: Dispatch>; + setIsLoading: Dispatch>; + setLoadError: Dispatch>; + setRenamingPath: Dispatch>; + getSoundListCache: () => SoundListItem[] | null; + setSoundListCache: (sounds: SoundListItem[]) => void; +} + +export const useSoundPickerLibraryRuntime = ({ + t, + renameValue, + isOpenRef, + loadRequestRef, + pendingSoundActionsRef, + setSounds, + setIsLoading, + setLoadError, + setRenamingPath, + getSoundListCache, + setSoundListCache, +}: SoundPickerLibraryRuntimeOptions) => { + const loadSounds = async () => { + const requestId = ++loadRequestRef.current; + const cachedSounds = getSoundListCache(); + if (cachedSounds) { + setSounds(cachedSounds); + setIsLoading(false); + } else { + setIsLoading(true); + } + setLoadError(''); + try { + const nextSounds = await window.api.sound.list(); + if (requestId !== loadRequestRef.current) return; + setSoundListCache(nextSounds); + if (!isOpenRef.current) return; + setSounds(nextSounds); + } catch (error) { + if (requestId !== loadRequestRef.current || !isOpenRef.current) return; + console.error('Failed to load sound list', error); + setLoadError(t('soundPicker.loadFailed') || '사운드 목록 로드 실패'); + } finally { + if (requestId === loadRequestRef.current && isOpenRef.current) { + setIsLoading(false); + } + } + }; + + const handleDelete = async (item: SoundListItem) => { + if (pendingSoundActionsRef.current.has(item.soundPath)) return; + pendingSoundActionsRef.current.add(item.soundPath); + + try { + const confirmed = await window.api.ui.dialog.confirm( + t('soundPicker.deleteConfirm') || + '사운드를 삭제하시겠습니까? 파일이 삭제되고 이 사운드를 사용하는 모든 요소에서 해제됩니다.', + { + confirmText: t('contextMenu.delete') || '삭제', + cancelText: t('common.cancel') || '취소', + danger: true, + }, + ); + if (!confirmed) return; + + loadRequestRef.current += 1; + setSounds((current) => { + const next = current.filter( + (candidate) => candidate.soundPath !== item.soundPath, + ); + setSoundListCache(next); + return next; + }); + await soundApi.remove(item.soundPath); + // 백엔드가 참조 해제와 canonical 동기화를 소유 + // 응답 대기 중 새로 선택한 사운드를 예전 선택으로 재판단해 지우지 않는다 + await loadSounds(); + } catch (error) { + console.error('Failed to delete sound', error); + // loadSounds가 loadError를 초기화하므로 reload 후에 실패 메시지 설정 + await loadSounds(); + setLoadError(t('soundPicker.deleteFailed') || '사운드 삭제 실패'); + } finally { + pendingSoundActionsRef.current.delete(item.soundPath); + } + }; + + const commitRename = async (item: SoundListItem) => { + const trimmed = renameValue.trim(); + setRenamingPath(null); + if (!trimmed || trimmed === (item.displayName || item.fileName)) return; + if (pendingSoundActionsRef.current.has(item.soundPath)) return; + pendingSoundActionsRef.current.add(item.soundPath); + try { + loadRequestRef.current += 1; + setSounds((current) => { + const next = current.map((candidate) => + candidate.soundPath === item.soundPath + ? { ...candidate, displayName: trimmed } + : candidate, + ); + setSoundListCache(next); + return next; + }); + await soundApi.rename(item.soundPath, trimmed); + await loadSounds(); + } catch (error) { + console.error('Failed to rename sound', error); + // loadSounds가 loadError를 초기화하므로 reload 후에 실패 메시지 설정 + await loadSounds(); + setLoadError(t('soundPicker.renameFailed') || '사운드 이름 변경 실패'); + } finally { + pendingSoundActionsRef.current.delete(item.soundPath); + } + }; + + const handleToggleHidden = async (item: SoundListItem) => { + if (pendingSoundActionsRef.current.has(item.soundPath)) return; + pendingSoundActionsRef.current.add(item.soundPath); + try { + const nextHidden = !item.hidden; + loadRequestRef.current += 1; + setSounds((current) => { + const next = current.map((candidate) => + candidate.soundPath === item.soundPath + ? { ...candidate, hidden: nextHidden } + : candidate, + ); + setSoundListCache(next); + return next; + }); + await soundApi.setHidden(item.soundPath, nextHidden); + await loadSounds(); + } catch (error) { + console.error('Failed to toggle sound hidden', error); + // loadSounds가 loadError를 초기화하므로 reload 후에 실패 메시지 설정 + await loadSounds(); + setLoadError(t('soundPicker.hideFailed') || '사운드 숨김 변경 실패'); + } finally { + pendingSoundActionsRef.current.delete(item.soundPath); + } + }; + + return { + loadSounds, + handleDelete, + commitRename, + handleToggleHidden, + }; +}; From 0588ca5a97a93b826e4f1251bebc83d6f378796a Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 22:34:11 +0900 Subject: [PATCH 231/245] =?UTF-8?q?refactor:=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20CSS=20=EC=8A=A4=EC=BD=94=ED=94=84=20=EB=B3=80=ED=99=98=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/utils/css/scopeUserCss.ts | 946 +----------------- .../utils/css/scopeUserCss/registryRewrite.ts | 502 ++++++++++ .../utils/css/scopeUserCss/selectorScope.ts | 428 ++++++++ 3 files changed, 956 insertions(+), 920 deletions(-) create mode 100644 src/renderer/utils/css/scopeUserCss/registryRewrite.ts create mode 100644 src/renderer/utils/css/scopeUserCss/selectorScope.ts diff --git a/src/renderer/utils/css/scopeUserCss.ts b/src/renderer/utils/css/scopeUserCss.ts index 3ec8a322a..1717db872 100644 --- a/src/renderer/utils/css/scopeUserCss.ts +++ b/src/renderer/utils/css/scopeUserCss.ts @@ -6,934 +6,40 @@ export const USER_CSS_SCOPE_ATTR = 'data-dmn-user-css-scope'; // :where로 감싸 특이도 0 - 접두사가 오버레이와 다른 캐스케이드 결과를 내지 않게 export const USER_CSS_SCOPE_SELECTOR = ':where([data-dmn-user-css-scope])'; -// 유저 keyframes 이름 접두사 - 앱 내부 이름과 겹쳐 설정 UI 애니메이션이 -// 바뀌는 것을 막는다. 참조(animation, animation-name)도 같이 재작성 -const KEYFRAMES_PREFIX = 'dmnu-'; -const FONT_FAMILY_PREFIX = 'dmnu-font-'; - -// keyframes 이름으로 쓰면 shorthand 참조 재작성이 모호해지는 키워드 - -// 이런 정의는 이름을 바꿀 수 없어 전역으로 흘리지 않고 버린다 -const ANIMATION_KEYWORDS = new Set([ - 'none', - 'initial', - 'inherit', - 'unset', - 'revert', - 'infinite', - 'alternate', - 'alternate-reverse', - 'normal', - 'reverse', - 'forwards', - 'backwards', - 'both', - 'running', - 'paused', - 'ease', - 'ease-in', - 'ease-out', - 'ease-in-out', - 'linear', - 'step-start', - 'step-end', -]); - -// 이름을 안전하게 바꿔 참조까지 맞출 수 있는 단순 식별자만 지원 -const SIMPLE_IDENT = /^-?[A-Za-z_][\w-]*$/; +import { + findBlockStart, + scopeNestedSelectorText, + scopeSelectorText, + splitLeadingStatements, +} from './scopeUserCss/selectorScope'; +import { + collectGlobalRegistryRenames, + collectKeyframeRenames, + collectRegistryVariableState, + createRegistryVariableState, + FONT_FACE_AT_RULE, + FONT_FEATURE_VALUES_AT_RULE, + KEYFRAMES_AT_RULE, + renameFontFaceRule, + renameFontFeatureValuesRule, + renameKeyframesRule, + resolveRegistryVariableReferences, + rewriteDeclarations, +} from './scopeUserCss/registryRewrite'; +import type { ScopeContext } from './scopeUserCss/registryRewrite'; + +export { + rewriteAnimationReferences, + rewriteFontFaceCssText, +} from './scopeUserCss/registryRewrite'; // font는 앱 UI와 충돌하지 않게 이름을 격리한다. 나머지 전역 leaf at-rule은 // 기존 커스텀 CSS 호환을 위해 원문 통과한다 const PASSTHROUGH_LEAF_AT_RULE = /^@(counter-style|font-palette-values|layer)\b/i; -const KEYFRAMES_AT_RULE = /^@(?:-webkit-)?keyframes\b/i; -const FONT_FACE_AT_RULE = /^@font-face\b/i; -const FONT_FEATURE_VALUES_AT_RULE = /^@font-feature-values\b/i; // 시트 머리에만 유효한 규칙 - 재조립 시 최상단으로 hoisting const HEAD_AT_RULE = /^@namespace\b/i; const IMPORT_AT_RULE = /^@import\b/i; -const LEADING_STATEMENT = /^@(charset|import|namespace|layer)\b/i; - -const isWhitespace = (ch: string): boolean => - ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r' || ch === '\f'; - -// 시트 머리의 @charset/@import/@namespace 문(과 그 앞에 올 수 있는 @layer -// 순서문)만 원문 그대로 추출 - 접두사 래핑 후에도 유효 위치를 지키려면 -// 최상단 hoisting이 필요하다. 블록 규칙이 나오면 중단 (그 뒤의 @import는 -// 브라우저도 무시하므로 파서에 맡겨 자연 드롭) -const splitLeadingStatements = ( - css: string, -): { hoisted: string[]; body: string } => { - const hoisted: string[] = []; - let i = 0; - const len = css.length; - while (i < len) { - if (isWhitespace(css[i])) { - i += 1; - continue; - } - // 주석 통과 - 주석 속 @import를 되살리지 않게 통째로 건너뜀 - if (css.startsWith('/*', i)) { - const end = css.indexOf('*/', i + 2); - if (end === -1) break; - i = end + 2; - continue; - } - if (!LEADING_STATEMENT.test(css.slice(i, i + 12))) break; - // 문자열·괄호·주석을 존중하며 종결 세미콜론까지 스캔. 블록이 먼저 열리면 문이 아니다 - let j = i; - let quote: string | null = null; - let depth = 0; - let terminated = false; - while (j < len) { - const c = css[j]; - if (quote) { - if (c === '\\') j += 1; - else if (c === quote) quote = null; - } else if (c === '/' && css[j + 1] === '*') { - // 문 중간의 주석 - 안의 ; { ( 는 구문이 아니다 - const end = css.indexOf('*/', j + 2); - if (end === -1) break; - j = end + 2; - continue; - } else if (c === '"' || c === "'") { - quote = c; - } else if (c === '(') { - depth += 1; - } else if (c === ')') { - depth = depth > 0 ? depth - 1 : 0; - } else if (c === '{' && depth === 0) { - break; - } else if (c === ';' && depth === 0) { - terminated = true; - break; - } - j += 1; - } - if (!terminated) break; - hoisted.push(css.slice(i, j + 1)); - i = j + 1; - } - return { hoisted, body: css.slice(i) }; -}; - -// 따옴표·이스케이프·괄호 밖의 첫 블록 시작 중괄호 - 셀렉터 문자열 속 `{` 무시 -const findBlockStart = (text: string): number => { - let depth = 0; - let quote: string | null = null; - for (let i = 0; i < text.length; i += 1) { - const ch = text[i]; - if (quote) { - if (ch === '\\') i += 1; - else if (ch === quote) quote = null; - continue; - } - if (ch === '\\') { - i += 1; - continue; - } - if (ch === '"' || ch === "'") { - quote = ch; - continue; - } - if (ch === '(' || ch === '[') { - depth += 1; - continue; - } - if (ch === ')' || ch === ']') { - depth = depth > 0 ? depth - 1 : 0; - continue; - } - if (ch === '{' && depth === 0) return i; - } - return -1; -}; - -// 최상위 콤마 분리 - 괄호·대괄호·따옴표·이스케이프 안 콤마는 구분자가 아니다 -const splitSelectorList = (selectorText: string): string[] => { - const parts: string[] = []; - let depth = 0; - let quote: string | null = null; - let start = 0; - for (let i = 0; i < selectorText.length; i += 1) { - const ch = selectorText[i]; - if (quote) { - if (ch === '\\') i += 1; - else if (ch === quote) quote = null; - continue; - } - if (ch === '\\') { - i += 1; - continue; - } - if (ch === '"' || ch === "'") { - quote = ch; - continue; - } - if (ch === '(' || ch === '[') { - depth += 1; - continue; - } - if (ch === ')' || ch === ']') { - depth = depth > 0 ? depth - 1 : 0; - continue; - } - if (ch === ',' && depth === 0) { - parts.push(selectorText.slice(start, i)); - start = i + 1; - } - } - parts.push(selectorText.slice(start)); - return parts.map((part) => part.trim()).filter(Boolean); -}; - -// 따옴표·이스케이프 밖에서 조건을 만족하는 첫 위치 (괄호 깊이 조건부) -const findTopLevel = ( - text: string, - start: number, - isHit: (index: number) => boolean, - ignoreBrackets: boolean, -): number => { - let depth = 0; - let quote: string | null = null; - for (let i = start; i < text.length; i += 1) { - const ch = text[i]; - if (quote) { - if (ch === '\\') i += 1; - else if (ch === quote) quote = null; - continue; - } - if (ch === '\\') { - i += 1; - continue; - } - if (ch === '"' || ch === "'") { - quote = ch; - continue; - } - if (ch === '(' || ch === '[') { - depth += 1; - continue; - } - if (ch === ')' || ch === ']') { - depth = depth > 0 ? depth - 1 : 0; - continue; - } - if ((ignoreBrackets || depth === 0) && isHit(i)) return i; - } - return -1; -}; - -// compound 셀렉터의 끝 - 괄호·대괄호·따옴표 밖 첫 공백 또는 결합자 위치 -const scanCompoundEnd = (selector: string, start: number): number => { - const end = findTopLevel( - selector, - start, - (i) => { - const ch = selector[i]; - return isWhitespace(ch) || ch === '>' || ch === '+' || ch === '~'; - }, - false, - ); - return end === -1 ? selector.length : end; -}; - -// 중첩 셀렉터에서 `&`가 :not()/:is()/:has() 같은 괄호 안에만 있는지 - 그런 -// 형태는 대상이 부모 밖으로 확장될 수 있다. 최상위 compound에 `&`가 있으면 -// (body.dark &, & .child, &:hover) 대상이 부모 또는 그 주변으로 고정된다 -const hasEscapingAmpersand = (selector: string): boolean => { - const isAmpersand = (i: number) => selector[i] === '&'; - return ( - findTopLevel(selector, 0, isAmpersand, true) !== -1 && - findTopLevel(selector, 0, isAmpersand, false) === -1 - ); -}; - -// compound 안 가상 요소 시작 위치 (::x 또는 구형 :before/:after 계열) -const LEGACY_PSEUDO_ELEMENT = - /^:(before|after|first-line|first-letter)(?![\w-])/i; -const findPseudoElementStart = (compound: string): number => - findTopLevel( - compound, - 0, - (i) => - compound[i] === ':' && - (compound[i + 1] === ':' || - LEGACY_PSEUDO_ELEMENT.test(compound.slice(i))), - false, - ); - -// 식별자 경계 강제 - body-card 같은 커스텀 요소를 body로 오인하지 않게 -// namespace-qualified type selector도 local name이 html/body면 문서 루트다. -// 반대로 body|div는 namespace 접두사일 뿐이므로 루트로 보지 않는다 -const ROOT_TOKEN = - /^(?::root(?![\w-])|(?:(?:[\w-]+|\*)?\|)?(?:html|body)(?![\w-]|\|))/i; - -interface FunctionalRootInfo { - length: number; - rootBranches: string[]; - allBranchesAreRoots: boolean; -} - -// :is(body, html)처럼 문서 루트가 포함된 함수형 compound의 분기 정보 -function functionalRootInfo(selector: string): FunctionalRootInfo | null { - const match = /^:(is|where)\(/i.exec(selector); - if (!match) return null; - const open = match[0].length - 1; - let depth = 1; - let quote: string | null = null; - let close = -1; - for (let i = open + 1; i < selector.length; i += 1) { - const ch = selector[i]; - if (quote) { - if (ch === '\\') i += 1; - else if (ch === quote) quote = null; - continue; - } - if (ch === '\\') { - i += 1; - continue; - } - if (ch === '"' || ch === "'") { - quote = ch; - continue; - } - if (ch === '(') depth += 1; - else if (ch === ')') { - depth -= 1; - if (depth === 0) { - close = i; - break; - } - } - } - if (close === -1) return null; - const branches = splitSelectorList(selector.slice(open + 1, close)); - if (!branches.length) return null; - const rootBranches = branches.filter((branch) => { - const tokenLength = leadingPotentialRootTokenLength(branch); - return ( - tokenLength !== null && - scanCompoundEnd(branch, tokenLength) === branch.length - ); - }); - if (!rootBranches.length) return null; - return { - length: close + 1, - rootBranches, - allBranchesAreRoots: rootBranches.length === branches.length, - }; -} - -function functionalRootTokenLength(selector: string): number | null { - const info = functionalRootInfo(selector); - return info?.allBranchesAreRoots ? info.length : null; -} - -function leadingRootTokenLength(selector: string): number | null { - const token = ROOT_TOKEN.exec(selector); - return token?.[0].length ?? functionalRootTokenLength(selector); -} - -function leadingPotentialRootTokenLength(selector: string): number | null { - const token = ROOT_TOKEN.exec(selector); - return token?.[0].length ?? functionalRootInfo(selector)?.length ?? null; -} - -type RootRemap = - | { kind: 'none' } - | { kind: 'drop' } - | { kind: 'remap'; selector: string }; - -// 선행 :root/html/body 체인은 조상 조건으로 그대로 두고 실제 대상만 스코프 -// 안으로 옮긴다 (`:root .x` → `:root scope .x`, `body` → `body scope`, -// `body::before` → `body scope::before`). 루트를 지우지 않아 원래 특이도가 -// 유지되고, 유저 CSS의 전역 변수·배경 관례는 스코프 요소에 실려 미리보기에 -// 상속된다. 루트의 형제 결합자(+, ~)는 스코프 자손으로 표현할 수 없어 드롭 -const remapLeadingRootChainWith = ( - selector: string, - scope: string, - findRootToken: (selector: string) => number | null, -): RootRemap => { - let pos = 0; - let matched = false; - let chainEnd = 0; - let lastCompoundStart = 0; - let combinator = ''; - let afterIndex = selector.length; - for (;;) { - const tokenLength = findRootToken(selector.slice(pos)); - if (tokenLength === null) break; - matched = true; - lastCompoundStart = pos; - chainEnd = scanCompoundEnd(selector, pos + tokenLength); - let j = chainEnd; - while (j < selector.length && isWhitespace(selector[j])) j += 1; - let comb = ''; - if (j < selector.length && '>+~'.includes(selector[j])) { - comb = selector[j]; - j += 1; - while (j < selector.length && isWhitespace(selector[j])) j += 1; - } - if (j >= selector.length) { - afterIndex = selector.length; - break; - } - if (findRootToken(selector.slice(j)) !== null) { - // html > body 처럼 루트끼리 이어진 체인은 통째로 조상 조건 - pos = j; - continue; - } - combinator = comb; - afterIndex = j; - break; - } - if (!matched) return { kind: 'none' }; - if (combinator === '+' || combinator === '~') return { kind: 'drop' }; - - // 마지막 루트 compound의 가상 요소는 스코프 요소 쪽으로 옮긴다 - - // 가상 요소는 자손을 가질 수 없어 그대로 두면 절대 매치되지 않는다 - const lastCompound = selector.slice(lastCompoundStart, chainEnd); - const pseudoAt = findPseudoElementStart(lastCompound); - let chain = selector.slice(0, chainEnd).trim(); - let pseudo = ''; - if (pseudoAt !== -1) { - pseudo = lastCompound.slice(pseudoAt); - chain = selector.slice(0, lastCompoundStart + pseudoAt).trim(); - } - const rest = selector.slice(afterIndex).trim(); - if (pseudo && rest) return { kind: 'drop' }; - const trailing = combinator ? ` ${combinator} ` : ' '; - return { - kind: 'remap', - selector: rest - ? `${chain} ${scope}${trailing}${rest}` - : `${chain} ${scope}${pseudo}`, - }; -}; - -const remapLeadingRootChain = (selector: string, scope: string): RootRemap => - remapLeadingRootChainWith(selector, scope, leadingRootTokenLength); - -const remapMixedFunctionalRoot = (selector: string, scope: string): RootRemap => - remapLeadingRootChainWith(selector, scope, leadingPotentialRootTokenLength); - -// 최상위 규칙의 스코프된 셀렉터 리스트 - 전부 드롭되면 null (규칙을 버린다) -const scopeSelectorText = ( - selectorText: string, - scope: string, -): string | null => { - const scoped: string[] = []; - for (const selector of splitSelectorList(selectorText)) { - const remap = remapLeadingRootChain(selector, scope); - if (remap.kind === 'drop') continue; - if (remap.kind === 'remap') { - scoped.push(remap.selector); - continue; - } - - scoped.push(`${scope} ${selector}`); - const functional = functionalRootInfo(selector); - if (!functional || functional.allBranchesAreRoots) continue; - // 함수 자체를 조상 조건으로 유지해 :is의 최대 분기 특이도와 :where의 - // 0 특이도를 보존한다. 대상은 scope 뒤에만 있어 메인 UI로 빠지지 않는다 - const rootRemap = remapMixedFunctionalRoot(selector, scope); - if (rootRemap.kind === 'remap' && !scoped.includes(rootRemap.selector)) { - scoped.push(rootRemap.selector); - } - } - return scoped.length ? scoped.join(', ') : null; -}; - -// 부모의 형제로 빠져나가는 중첩 셀렉터 - 선두 `+`/`~`(암묵 &의 형제) 또는 -// 최상위 `&` compound 바로 뒤의 `+`/`~`. 부모가 루트 치환으로 스코프 요소 -// 자체가 되면 그 형제는 미리보기 밖 앱 UI다 -const hasSiblingEscape = (selector: string): boolean => { - const first = selector[0]; - if (first === '+' || first === '~') return true; - let from = 0; - for (;;) { - const amp = findTopLevel(selector, from, (i) => selector[i] === '&', false); - if (amp === -1) return false; - let j = scanCompoundEnd(selector, amp + 1); - while (j < selector.length && isWhitespace(selector[j])) j += 1; - if (selector[j] === '+' || selector[j] === '~') return true; - from = amp + 1; - } -}; - -// 중첩 규칙의 셀렉터 - `&`가 없거나 최상위에 있으면 부모(이미 스코프 안)에 -// 고정이라 그대로. 괄호 안에만 있거나(:not(&)·:is(x, &)·.x:has(&)) 부모의 -// 형제로 나가면 스코프 접두사를 붙여 대상이 스코프 자손임을 강제한다. -// 접두사를 붙일 때 `&`가 없는 상대 셀렉터는 `&`를 명시해 의미를 유지 -const scopeNestedSelectorText = (selectorText: string, scope: string): string => - splitSelectorList(selectorText) - .map((selector) => { - if (!hasEscapingAmpersand(selector) && !hasSiblingEscape(selector)) { - return selector; - } - const hasAmpersand = - findTopLevel(selector, 0, (i) => selector[i] === '&', true) !== -1; - return hasAmpersand ? `${scope} ${selector}` : `${scope} & ${selector}`; - }) - .join(', '); - -const escapeRegExp = (value: string): string => - value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - -// 시트 안에서 정의한 keyframes 이름 → 접두사 이름 (그룹·중첩 내부 포함). -// 단순 식별자가 아니거나 키워드면 매핑하지 않는다 → 정의는 버려진다 -const collectKeyframeRenames = ( - rules: CSSRuleList, - out: Map, -): void => { - for (let i = 0; i < rules.length; i += 1) { - const rule = rules[i]; - if (KEYFRAMES_AT_RULE.test(rule.cssText)) { - const name = (rule as CSSKeyframesRule).name; - if ( - name && - SIMPLE_IDENT.test(name) && - !ANIMATION_KEYWORDS.has(name.toLowerCase()) - ) { - out.set(name, `${KEYFRAMES_PREFIX}${name}`); - } - continue; - } - const childRules = (rule as CSSGroupingRule).cssRules; - if (childRules) collectKeyframeRenames(childRules, out); - } -}; - -const decodeCssEscapes = (value: string): string => { - let out = ''; - for (let i = 0; i < value.length; i += 1) { - if (value[i] !== '\\') { - out += value[i]; - continue; - } - i += 1; - if (i >= value.length) break; - if (value[i] === '\n' || value[i] === '\f') continue; - if (value[i] === '\r') { - if (value[i + 1] === '\n') i += 1; - continue; - } - const hex = value.slice(i).match(/^[0-9a-f]{1,6}/i)?.[0]; - if (!hex) { - out += value[i]; - continue; - } - const codePoint = Number.parseInt(hex, 16); - out += - codePoint === 0 || codePoint > 0x10ffff - ? '\ufffd' - : String.fromCodePoint(codePoint); - i += hex.length - 1; - if (isWhitespace(value[i + 1])) i += 1; - } - return out; -}; - -const normalizeCssName = (value: string): string => { - const trimmed = value.trim(); - const first = trimmed[0]; - const inner = - (first === '"' || first === "'") && trimmed.at(-1) === first - ? trimmed.slice(1, -1) - : trimmed; - return decodeCssEscapes(inner).replace(/\s+/g, ' ').trim(); -}; - -// 읽을 수 있는 이름 뒤에 짧은 hash를 붙여 devtools에서 원래 family를 알아보게 -const registryAlias = (prefix: string, name: string): string => { - let hash = 0x811c9dc5; - for (const character of name) { - hash ^= character.codePointAt(0) ?? 0; - hash = Math.imul(hash, 0x01000193) >>> 0; - } - const readable = name.replace(/[^a-z0-9_-]+/gi, '-').replace(/^-+|-+$/g, ''); - return `${prefix}${readable ? `${readable}-` : ''}${hash - .toString(16) - .padStart(8, '0')}`; -}; - -const collectGlobalRegistryRenames = ( - rules: CSSRuleList, - fonts: Map, -): void => { - for (let i = 0; i < rules.length; i += 1) { - const rule = rules[i]; - const cssText = rule.cssText; - const style = (rule as unknown as { style?: CSSStyleDeclaration }).style; - if (FONT_FACE_AT_RULE.test(cssText) && style) { - const family = normalizeCssName(style.getPropertyValue('font-family')); - if (family) { - fonts.set( - family.toLowerCase(), - registryAlias(FONT_FAMILY_PREFIX, family.toLowerCase()), - ); - } - continue; - } - const childRules = (rule as CSSGroupingRule).cssRules; - if (childRules) { - collectGlobalRegistryRenames(childRules, fonts); - } - } -}; - -const rewriteNameList = ( - value: string, - renames: Map, - caseInsensitive = true, -): string => - splitSelectorList(value) - .map((name) => { - const normalized = normalizeCssName(name); - return ( - renames.get(caseInsensitive ? normalized.toLowerCase() : normalized) ?? - name - ); - }) - .join(', '); - -// 이름 토큰 재작성 - 따옴표 문자열은 내용 전체가, 따옴표 없는 family는 -// 공백으로 이어진 ident 묶음 전체가 등록된 이름과 일치할 때만 바꾼다. -// 부분 토큰 치환은 "Pixel Art" 같은 별개 family를 망가뜨린다 -const FAMILY_IDENT_CHARS = 'A-Za-z0-9_\\u0080-\\uFFFF-'; -const FAMILY_RUN_PATTERN = new RegExp( - `[${FAMILY_IDENT_CHARS}]+(?:\\s+[${FAMILY_IDENT_CHARS}]+)*`, - 'g', -); -const QUOTED_STRING_PATTERN = /"(?:[^"\\]|\\[\s\S])*"|'(?:[^'\\]|\\[\s\S])*'/g; - -const rewriteNamedTokens = ( - value: string, - renames: Map, - caseInsensitive = false, -): string => { - const lookup = (name: string): string | undefined => - renames.get(caseInsensitive ? name.toLowerCase() : name); - const rewriteRun = (run: string): string => { - const collapsed = run.replace(/\s+/g, ' '); - const whole = lookup(collapsed); - if (whole !== undefined) return whole; - // 정적 변수에 담긴 font 축약형(`700 24px Pixel`) - family는 마지막 size - // 토큰 뒤에 온다. `/` 뒤 line-height 키워드 normal도 family가 아니다 - const words = collapsed.split(' '); - let familyStart = 0; - words.forEach((word, index) => { - if (/^[0-9.+-]/.test(word)) familyStart = index + 1; - }); - if (familyStart === 0 && /^normal$/i.test(words[0])) familyStart = 1; - if (familyStart === 0 || familyStart >= words.length) return run; - const alias = lookup(words.slice(familyStart).join(' ')); - return alias === undefined - ? run - : `${words.slice(0, familyStart).join(' ')} ${alias}`; - }; - const rewriteUnquoted = (segment: string): string => - segment.replace(FAMILY_RUN_PATTERN, rewriteRun); - const quoted = new RegExp(QUOTED_STRING_PATTERN.source, 'g'); - let out = ''; - let last = 0; - for (let match = quoted.exec(value); match; match = quoted.exec(value)) { - out += rewriteUnquoted(value.slice(last, match.index)); - const quote = match[0][0]; - const alias = lookup(match[0].slice(1, -1)); - out += alias ? `${quote}${alias}${quote}` : match[0]; - last = match.index + match[0].length; - } - return out + rewriteUnquoted(value.slice(last)); -}; - -const collectVarNames = (value: string, out: Set): void => { - const pattern = /var\(\s*(--[\w-]+)/gi; - for (let match = pattern.exec(value); match; match = pattern.exec(value)) { - out.add(match[1]); - } -}; - -interface RegistryVariableState { - references: Set; - customValues: Map; -} - -const createRegistryVariableState = (): RegistryVariableState => ({ - references: new Set(), - customValues: new Map(), -}); - -const collectRegistryVariableState = ( - rules: CSSRuleList, - state: RegistryVariableState, -): void => { - const walk = (current: CSSRuleList): void => { - for (let i = 0; i < current.length; i += 1) { - const rule = current[i]; - const selectorText = (rule as CSSStyleRule).selectorText; - const style = (rule as unknown as { style?: CSSStyleDeclaration }).style; - if (typeof selectorText === 'string' && selectorText && style) { - collectVarNames( - `${style.getPropertyValue('font')} ${style.getPropertyValue( - 'font-family', - )}`, - state.references, - ); - for (let index = 0; index < style.length; index += 1) { - const property = style.item(index); - if (!property.startsWith('--')) continue; - const values = state.customValues.get(property) ?? []; - values.push(style.getPropertyValue(property)); - state.customValues.set(property, values); - } - } - const childRules = (rule as CSSGroupingRule).cssRules; - if (childRules) walk(childRules); - } - }; - walk(rules); -}; - -const resolveRegistryVariableReferences = ( - state: RegistryVariableState, -): Set => { - let changed = true; - while (changed) { - changed = false; - for (const name of [...state.references]) { - for (const value of state.customValues.get(name) ?? []) { - const before = state.references.size; - collectVarNames(value, state.references); - if (state.references.size !== before) changed = true; - } - } - } - return state.references; -}; - -const ANIMATION_PROPERTY = - /^(\s*(?:-webkit-)?animation(?:-name)?\s*:\s*)([\s\S]*)$/i; - -// animation 값 안의 이름 참조를 한 번의 치환으로 - 생성된 이름을 다시 바꾸지 -// 않도록 원본 이름 전체를 한 alternation에 담는다 (긴 이름 우선). -// var()로 전달한 이름은 값에 나타나지 않아 재작성 대상이 아니다 (문서화된 한계) -const buildReferencePatterns = (renames: Map) => { - const alternation = [...renames.keys()] - .sort((a, b) => b.length - a.length) - .map(escapeRegExp) - .join('|'); - return { - identifier: new RegExp(`(^|[\\s,])(${alternation})(?=$|[\\s,])`, 'g'), - quoted: new RegExp(`(["'])(${alternation})\\1`, 'g'), - }; -}; - -// 선언 문자열을 top-level `;` 단위로 걸으며 animation 참조를 재작성. -// 문자열·함수·custom property의 중괄호 블록 안은 건드리지 않는다 -// (jsdom이 못 파싱하는 입력을 검증할 수 있게 export) -export const rewriteAnimationReferences = ( - declarations: string, - renames: Map, -): string => { - if (!renames.size || !declarations) return declarations; - const { identifier, quoted } = buildReferencePatterns(renames); - const rewriteSegment = (segment: string): string => { - const match = ANIMATION_PROPERTY.exec(segment); - if (!match) return segment; - const value = match[2] - .replace( - quoted, - (_m, q: string, name: string) => `${q}${renames.get(name)}${q}`, - ) - .replace( - identifier, - (_m, lead: string, name: string) => `${lead}${renames.get(name)}`, - ); - return `${match[1]}${value}`; - }; - let out = ''; - let segmentStart = 0; - let parenDepth = 0; - let braceDepth = 0; - let quote: string | null = null; - for (let i = 0; i < declarations.length; i += 1) { - const ch = declarations[i]; - if (quote) { - if (ch === '\\') i += 1; - else if (ch === quote) quote = null; - continue; - } - if (ch === '\\') { - i += 1; - continue; - } - if (ch === '"' || ch === "'") { - quote = ch; - continue; - } - if (ch === '(') parenDepth += 1; - else if (ch === ')') parenDepth = parenDepth > 0 ? parenDepth - 1 : 0; - else if (ch === '{') braceDepth += 1; - else if (ch === '}') braceDepth = braceDepth > 0 ? braceDepth - 1 : 0; - else if (ch === ';' && parenDepth === 0 && braceDepth === 0) { - out += rewriteSegment(declarations.slice(segmentStart, i)) + ch; - segmentStart = i + 1; - } - } - return out + rewriteSegment(declarations.slice(segmentStart)); -}; - -// keyframes 정의는 매핑된 이름으로만 내보낸다 - 바꿀 수 없는 이름은 전역 -// 레지스트리에 남기지 않고 버린다 (fail-closed) -const renameKeyframesRule = ( - rule: CSSRule, - renames: Map, -): string | null => { - const cssText = rule.cssText; - const renamed = renames.get((rule as CSSKeyframesRule).name); - if (!renamed) return null; - const brace = findBlockStart(cssText); - if (brace === -1) return null; - const prelude = cssText.slice(0, brace).trim(); - const at = prelude.match(/^(@(?:-webkit-)?keyframes)\s+/i); - if (!at) return null; - return `${at[1]} ${renamed} ${cssText.slice(brace)}`; -}; - -const serializeDeclaration = ( - property: string, - value: string, - priority: string, -): string => `${property}: ${value}${priority ? ` !${priority}` : ''};`; - -const serializeRuleStyle = ( - style: CSSStyleDeclaration, - overrides: Map, -): string => { - const declarations: string[] = []; - for (let index = 0; index < style.length; index += 1) { - const property = style.item(index); - declarations.push( - serializeDeclaration( - property, - overrides.get(property) ?? style.getPropertyValue(property), - style.getPropertyPriority(property), - ), - ); - } - return declarations.join(' '); -}; - -const FONT_FACE_FAMILY_DESCRIPTOR = /(^|[{;\s])(font-family\s*:\s*)[^;}]*/i; - -/** - * @font-face 원문 텍스트에서 font-family descriptor만 격리 이름으로 바꾼다. - * CSSOM 열거로 재조립하면 엔진이 열거하지 않는 descriptor(src·font-display· - * unicode-range)가 빠지므로 텍스트를 유지한다. descriptor를 못 찾으면 null - */ -export const rewriteFontFaceCssText = ( - cssText: string, - alias: string, -): string | null => { - const brace = findBlockStart(cssText); - if (brace === -1) return null; - const block = cssText.slice(brace); - const rewritten = block.replace( - FONT_FACE_FAMILY_DESCRIPTOR, - (_match, lead: string, property: string) => `${lead}${property}"${alias}"`, - ); - return rewritten === block ? null : `@font-face ${rewritten}`; -}; - -const renameFontFaceRule = ( - rule: CSSRule, - renames: Map, -): string | null => { - const style = (rule as unknown as { style?: CSSStyleDeclaration }).style; - if (!style) return null; - const family = normalizeCssName(style.getPropertyValue('font-family')); - const alias = renames.get(family.toLowerCase()); - if (!alias) return null; - return ( - rewriteFontFaceCssText(rule.cssText, alias) ?? - `@font-face { ${serializeRuleStyle( - style, - new Map([['font-family', `"${alias}"`]]), - )} }` - ); -}; - -const renameFontFeatureValuesRule = ( - rule: CSSRule, - renames: Map, -): string | null => { - const cssText = rule.cssText; - const brace = findBlockStart(cssText); - if (brace === -1) return null; - const prelude = cssText.slice(0, brace).trim(); - const match = prelude.match(/^(@font-feature-values\s+)([\s\S]+)$/i); - if (!match) return null; - const families = rewriteNameList(match[2], renames); - if (families === match[2]) return null; - return `${match[1]}${families} ${cssText.slice(brace)}`; -}; - -interface ScopeContext { - scope: string; - renames: Map; - fontRenames: Map; - fontVariables: Set; - hoisted: string[]; - namespacesHoisted: boolean; -} - -// 선언부 재작성 - animation 이름, font-family 목록·var() fallback, font 참조 변수. -// 스타일 규칙 본문과 중첩 규칙 뒤의 선언 묶음이 같은 경로를 쓴다 -const rewriteDeclarations = ( - style: CSSStyleDeclaration, - ctx: ScopeContext, -): string => { - let declarations = rewriteAnimationReferences(style.cssText, ctx.renames); - const additions: string[] = []; - const fontFamily = style.getPropertyValue('font-family'); - if (fontFamily) { - // 목록 항목 단위로 바꾼 뒤 var() fallback 안의 이름도 토큰 단위로 재작성 - const rewritten = rewriteNamedTokens( - rewriteNameList(fontFamily, ctx.fontRenames), - ctx.fontRenames, - true, - ); - if (rewritten !== fontFamily) { - additions.push( - serializeDeclaration( - 'font-family', - rewritten, - style.getPropertyPriority('font-family'), - ), - ); - } - } - for (let index = 0; index < style.length; index += 1) { - const property = style.item(index); - if (!property.startsWith('--')) continue; - let rewritten = style.getPropertyValue(property); - if (ctx.fontVariables.has(property)) { - rewritten = rewriteNamedTokens(rewritten, ctx.fontRenames, true); - } - if (rewritten !== style.getPropertyValue(property)) { - additions.push( - serializeDeclaration( - property, - rewritten, - style.getPropertyPriority(property), - ), - ); - } - } - if (additions.length) declarations = `${declarations} ${additions.join(' ')}`; - return declarations; -}; // 스타일 규칙 재구성 - 선언부는 CSSOM 직렬화, 중첩 규칙은 재귀 스코프. // 원문 블록을 통째로 보존하면 중첩 `&` 확장이 스코프 밖으로 새므로 재구성한다 diff --git a/src/renderer/utils/css/scopeUserCss/registryRewrite.ts b/src/renderer/utils/css/scopeUserCss/registryRewrite.ts new file mode 100644 index 000000000..6795fe737 --- /dev/null +++ b/src/renderer/utils/css/scopeUserCss/registryRewrite.ts @@ -0,0 +1,502 @@ +import { + findBlockStart, + isWhitespace, + splitSelectorList, +} from './selectorScope'; + +// 유저 keyframes 이름 접두사 - 앱 내부 이름과 겹쳐 설정 UI 애니메이션이 +// 바뀌는 것을 막는다. 참조(animation, animation-name)도 같이 재작성 +const KEYFRAMES_PREFIX = 'dmnu-'; +const FONT_FAMILY_PREFIX = 'dmnu-font-'; + +// keyframes 이름으로 쓰면 shorthand 참조 재작성이 모호해지는 키워드 - +// 이런 정의는 이름을 바꿀 수 없어 전역으로 흘리지 않고 버린다 +const ANIMATION_KEYWORDS = new Set([ + 'none', + 'initial', + 'inherit', + 'unset', + 'revert', + 'infinite', + 'alternate', + 'alternate-reverse', + 'normal', + 'reverse', + 'forwards', + 'backwards', + 'both', + 'running', + 'paused', + 'ease', + 'ease-in', + 'ease-out', + 'ease-in-out', + 'linear', + 'step-start', + 'step-end', +]); + +// 이름을 안전하게 바꿔 참조까지 맞출 수 있는 단순 식별자만 지원 +const SIMPLE_IDENT = /^-?[A-Za-z_][\w-]*$/; + +export const KEYFRAMES_AT_RULE = /^@(?:-webkit-)?keyframes\b/i; +export const FONT_FACE_AT_RULE = /^@font-face\b/i; +export const FONT_FEATURE_VALUES_AT_RULE = /^@font-feature-values\b/i; + +const escapeRegExp = (value: string): string => + value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +// 시트 안에서 정의한 keyframes 이름 → 접두사 이름 (그룹·중첩 내부 포함). +// 단순 식별자가 아니거나 키워드면 매핑하지 않는다 → 정의는 버려진다 +export const collectKeyframeRenames = ( + rules: CSSRuleList, + out: Map, +): void => { + for (let i = 0; i < rules.length; i += 1) { + const rule = rules[i]; + if (KEYFRAMES_AT_RULE.test(rule.cssText)) { + const name = (rule as CSSKeyframesRule).name; + if ( + name && + SIMPLE_IDENT.test(name) && + !ANIMATION_KEYWORDS.has(name.toLowerCase()) + ) { + out.set(name, `${KEYFRAMES_PREFIX}${name}`); + } + continue; + } + const childRules = (rule as CSSGroupingRule).cssRules; + if (childRules) collectKeyframeRenames(childRules, out); + } +}; + +const decodeCssEscapes = (value: string): string => { + let out = ''; + for (let i = 0; i < value.length; i += 1) { + if (value[i] !== '\\') { + out += value[i]; + continue; + } + i += 1; + if (i >= value.length) break; + if (value[i] === '\n' || value[i] === '\f') continue; + if (value[i] === '\r') { + if (value[i + 1] === '\n') i += 1; + continue; + } + const hex = value.slice(i).match(/^[0-9a-f]{1,6}/i)?.[0]; + if (!hex) { + out += value[i]; + continue; + } + const codePoint = Number.parseInt(hex, 16); + out += + codePoint === 0 || codePoint > 0x10ffff + ? '\ufffd' + : String.fromCodePoint(codePoint); + i += hex.length - 1; + if (isWhitespace(value[i + 1])) i += 1; + } + return out; +}; + +const normalizeCssName = (value: string): string => { + const trimmed = value.trim(); + const first = trimmed[0]; + const inner = + (first === '"' || first === "'") && trimmed.at(-1) === first + ? trimmed.slice(1, -1) + : trimmed; + return decodeCssEscapes(inner).replace(/\s+/g, ' ').trim(); +}; + +// 읽을 수 있는 이름 뒤에 짧은 hash를 붙여 devtools에서 원래 family를 알아보게 +const registryAlias = (prefix: string, name: string): string => { + let hash = 0x811c9dc5; + for (const character of name) { + hash ^= character.codePointAt(0) ?? 0; + hash = Math.imul(hash, 0x01000193) >>> 0; + } + const readable = name.replace(/[^a-z0-9_-]+/gi, '-').replace(/^-+|-+$/g, ''); + return `${prefix}${readable ? `${readable}-` : ''}${hash + .toString(16) + .padStart(8, '0')}`; +}; + +export const collectGlobalRegistryRenames = ( + rules: CSSRuleList, + fonts: Map, +): void => { + for (let i = 0; i < rules.length; i += 1) { + const rule = rules[i]; + const cssText = rule.cssText; + const style = (rule as unknown as { style?: CSSStyleDeclaration }).style; + if (FONT_FACE_AT_RULE.test(cssText) && style) { + const family = normalizeCssName(style.getPropertyValue('font-family')); + if (family) { + fonts.set( + family.toLowerCase(), + registryAlias(FONT_FAMILY_PREFIX, family.toLowerCase()), + ); + } + continue; + } + const childRules = (rule as CSSGroupingRule).cssRules; + if (childRules) { + collectGlobalRegistryRenames(childRules, fonts); + } + } +}; + +const rewriteNameList = ( + value: string, + renames: Map, + caseInsensitive = true, +): string => + splitSelectorList(value) + .map((name) => { + const normalized = normalizeCssName(name); + return ( + renames.get(caseInsensitive ? normalized.toLowerCase() : normalized) ?? + name + ); + }) + .join(', '); + +// 이름 토큰 재작성 - 따옴표 문자열은 내용 전체가, 따옴표 없는 family는 +// 공백으로 이어진 ident 묶음 전체가 등록된 이름과 일치할 때만 바꾼다. +// 부분 토큰 치환은 "Pixel Art" 같은 별개 family를 망가뜨린다 +const FAMILY_IDENT_CHARS = 'A-Za-z0-9_\\u0080-\\uFFFF-'; +const FAMILY_RUN_PATTERN = new RegExp( + `[${FAMILY_IDENT_CHARS}]+(?:\\s+[${FAMILY_IDENT_CHARS}]+)*`, + 'g', +); +const QUOTED_STRING_PATTERN = /"(?:[^"\\]|\\[\s\S])*"|'(?:[^'\\]|\\[\s\S])*'/g; + +const rewriteNamedTokens = ( + value: string, + renames: Map, + caseInsensitive = false, +): string => { + const lookup = (name: string): string | undefined => + renames.get(caseInsensitive ? name.toLowerCase() : name); + const rewriteRun = (run: string): string => { + const collapsed = run.replace(/\s+/g, ' '); + const whole = lookup(collapsed); + if (whole !== undefined) return whole; + // 정적 변수에 담긴 font 축약형(`700 24px Pixel`) - family는 마지막 size + // 토큰 뒤에 온다. `/` 뒤 line-height 키워드 normal도 family가 아니다 + const words = collapsed.split(' '); + let familyStart = 0; + words.forEach((word, index) => { + if (/^[0-9.+-]/.test(word)) familyStart = index + 1; + }); + if (familyStart === 0 && /^normal$/i.test(words[0])) familyStart = 1; + if (familyStart === 0 || familyStart >= words.length) return run; + const alias = lookup(words.slice(familyStart).join(' ')); + return alias === undefined + ? run + : `${words.slice(0, familyStart).join(' ')} ${alias}`; + }; + const rewriteUnquoted = (segment: string): string => + segment.replace(FAMILY_RUN_PATTERN, rewriteRun); + const quoted = new RegExp(QUOTED_STRING_PATTERN.source, 'g'); + let out = ''; + let last = 0; + for (let match = quoted.exec(value); match; match = quoted.exec(value)) { + out += rewriteUnquoted(value.slice(last, match.index)); + const quote = match[0][0]; + const alias = lookup(match[0].slice(1, -1)); + out += alias ? `${quote}${alias}${quote}` : match[0]; + last = match.index + match[0].length; + } + return out + rewriteUnquoted(value.slice(last)); +}; + +const collectVarNames = (value: string, out: Set): void => { + const pattern = /var\(\s*(--[\w-]+)/gi; + for (let match = pattern.exec(value); match; match = pattern.exec(value)) { + out.add(match[1]); + } +}; + +interface RegistryVariableState { + references: Set; + customValues: Map; +} + +export const createRegistryVariableState = (): RegistryVariableState => ({ + references: new Set(), + customValues: new Map(), +}); + +export const collectRegistryVariableState = ( + rules: CSSRuleList, + state: RegistryVariableState, +): void => { + const walk = (current: CSSRuleList): void => { + for (let i = 0; i < current.length; i += 1) { + const rule = current[i]; + const selectorText = (rule as CSSStyleRule).selectorText; + const style = (rule as unknown as { style?: CSSStyleDeclaration }).style; + if (typeof selectorText === 'string' && selectorText && style) { + collectVarNames( + `${style.getPropertyValue('font')} ${style.getPropertyValue( + 'font-family', + )}`, + state.references, + ); + for (let index = 0; index < style.length; index += 1) { + const property = style.item(index); + if (!property.startsWith('--')) continue; + const values = state.customValues.get(property) ?? []; + values.push(style.getPropertyValue(property)); + state.customValues.set(property, values); + } + } + const childRules = (rule as CSSGroupingRule).cssRules; + if (childRules) walk(childRules); + } + }; + walk(rules); +}; + +export const resolveRegistryVariableReferences = ( + state: RegistryVariableState, +): Set => { + let changed = true; + while (changed) { + changed = false; + for (const name of [...state.references]) { + for (const value of state.customValues.get(name) ?? []) { + const before = state.references.size; + collectVarNames(value, state.references); + if (state.references.size !== before) changed = true; + } + } + } + return state.references; +}; + +const ANIMATION_PROPERTY = + /^(\s*(?:-webkit-)?animation(?:-name)?\s*:\s*)([\s\S]*)$/i; + +// animation 값 안의 이름 참조를 한 번의 치환으로 - 생성된 이름을 다시 바꾸지 +// 않도록 원본 이름 전체를 한 alternation에 담는다 (긴 이름 우선). +// var()로 전달한 이름은 값에 나타나지 않아 재작성 대상이 아니다 (문서화된 한계) +const buildReferencePatterns = (renames: Map) => { + const alternation = [...renames.keys()] + .sort((a, b) => b.length - a.length) + .map(escapeRegExp) + .join('|'); + return { + identifier: new RegExp(`(^|[\\s,])(${alternation})(?=$|[\\s,])`, 'g'), + quoted: new RegExp(`(["'])(${alternation})\\1`, 'g'), + }; +}; + +// 선언 문자열을 top-level `;` 단위로 걸으며 animation 참조를 재작성. +// 문자열·함수·custom property의 중괄호 블록 안은 건드리지 않는다 +// (jsdom이 못 파싱하는 입력을 검증할 수 있게 export) +export const rewriteAnimationReferences = ( + declarations: string, + renames: Map, +): string => { + if (!renames.size || !declarations) return declarations; + const { identifier, quoted } = buildReferencePatterns(renames); + const rewriteSegment = (segment: string): string => { + const match = ANIMATION_PROPERTY.exec(segment); + if (!match) return segment; + const value = match[2] + .replace( + quoted, + (_m, q: string, name: string) => `${q}${renames.get(name)}${q}`, + ) + .replace( + identifier, + (_m, lead: string, name: string) => `${lead}${renames.get(name)}`, + ); + return `${match[1]}${value}`; + }; + let out = ''; + let segmentStart = 0; + let parenDepth = 0; + let braceDepth = 0; + let quote: string | null = null; + for (let i = 0; i < declarations.length; i += 1) { + const ch = declarations[i]; + if (quote) { + if (ch === '\\') i += 1; + else if (ch === quote) quote = null; + continue; + } + if (ch === '\\') { + i += 1; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + if (ch === '(') parenDepth += 1; + else if (ch === ')') parenDepth = parenDepth > 0 ? parenDepth - 1 : 0; + else if (ch === '{') braceDepth += 1; + else if (ch === '}') braceDepth = braceDepth > 0 ? braceDepth - 1 : 0; + else if (ch === ';' && parenDepth === 0 && braceDepth === 0) { + out += rewriteSegment(declarations.slice(segmentStart, i)) + ch; + segmentStart = i + 1; + } + } + return out + rewriteSegment(declarations.slice(segmentStart)); +}; + +// keyframes 정의는 매핑된 이름으로만 내보낸다 - 바꿀 수 없는 이름은 전역 +// 레지스트리에 남기지 않고 버린다 (fail-closed) +export const renameKeyframesRule = ( + rule: CSSRule, + renames: Map, +): string | null => { + const cssText = rule.cssText; + const renamed = renames.get((rule as CSSKeyframesRule).name); + if (!renamed) return null; + const brace = findBlockStart(cssText); + if (brace === -1) return null; + const prelude = cssText.slice(0, brace).trim(); + const at = prelude.match(/^(@(?:-webkit-)?keyframes)\s+/i); + if (!at) return null; + return `${at[1]} ${renamed} ${cssText.slice(brace)}`; +}; + +const serializeDeclaration = ( + property: string, + value: string, + priority: string, +): string => `${property}: ${value}${priority ? ` !${priority}` : ''};`; + +const serializeRuleStyle = ( + style: CSSStyleDeclaration, + overrides: Map, +): string => { + const declarations: string[] = []; + for (let index = 0; index < style.length; index += 1) { + const property = style.item(index); + declarations.push( + serializeDeclaration( + property, + overrides.get(property) ?? style.getPropertyValue(property), + style.getPropertyPriority(property), + ), + ); + } + return declarations.join(' '); +}; + +const FONT_FACE_FAMILY_DESCRIPTOR = /(^|[{;\s])(font-family\s*:\s*)[^;}]*/i; + +/** + * @font-face 원문 텍스트에서 font-family descriptor만 격리 이름으로 바꾼다. + * CSSOM 열거로 재조립하면 엔진이 열거하지 않는 descriptor(src·font-display· + * unicode-range)가 빠지므로 텍스트를 유지한다. descriptor를 못 찾으면 null + */ +export const rewriteFontFaceCssText = ( + cssText: string, + alias: string, +): string | null => { + const brace = findBlockStart(cssText); + if (brace === -1) return null; + const block = cssText.slice(brace); + const rewritten = block.replace( + FONT_FACE_FAMILY_DESCRIPTOR, + (_match, lead: string, property: string) => `${lead}${property}"${alias}"`, + ); + return rewritten === block ? null : `@font-face ${rewritten}`; +}; + +export const renameFontFaceRule = ( + rule: CSSRule, + renames: Map, +): string | null => { + const style = (rule as unknown as { style?: CSSStyleDeclaration }).style; + if (!style) return null; + const family = normalizeCssName(style.getPropertyValue('font-family')); + const alias = renames.get(family.toLowerCase()); + if (!alias) return null; + return ( + rewriteFontFaceCssText(rule.cssText, alias) ?? + `@font-face { ${serializeRuleStyle( + style, + new Map([['font-family', `"${alias}"`]]), + )} }` + ); +}; + +export const renameFontFeatureValuesRule = ( + rule: CSSRule, + renames: Map, +): string | null => { + const cssText = rule.cssText; + const brace = findBlockStart(cssText); + if (brace === -1) return null; + const prelude = cssText.slice(0, brace).trim(); + const match = prelude.match(/^(@font-feature-values\s+)([\s\S]+)$/i); + if (!match) return null; + const families = rewriteNameList(match[2], renames); + if (families === match[2]) return null; + return `${match[1]}${families} ${cssText.slice(brace)}`; +}; + +export interface ScopeContext { + scope: string; + renames: Map; + fontRenames: Map; + fontVariables: Set; + hoisted: string[]; + namespacesHoisted: boolean; +} + +// 선언부 재작성 - animation 이름, font-family 목록·var() fallback, font 참조 변수. +// 스타일 규칙 본문과 중첩 규칙 뒤의 선언 묶음이 같은 경로를 쓴다 +export const rewriteDeclarations = ( + style: CSSStyleDeclaration, + ctx: ScopeContext, +): string => { + let declarations = rewriteAnimationReferences(style.cssText, ctx.renames); + const additions: string[] = []; + const fontFamily = style.getPropertyValue('font-family'); + if (fontFamily) { + // 목록 항목 단위로 바꾼 뒤 var() fallback 안의 이름도 토큰 단위로 재작성 + const rewritten = rewriteNamedTokens( + rewriteNameList(fontFamily, ctx.fontRenames), + ctx.fontRenames, + true, + ); + if (rewritten !== fontFamily) { + additions.push( + serializeDeclaration( + 'font-family', + rewritten, + style.getPropertyPriority('font-family'), + ), + ); + } + } + for (let index = 0; index < style.length; index += 1) { + const property = style.item(index); + if (!property.startsWith('--')) continue; + let rewritten = style.getPropertyValue(property); + if (ctx.fontVariables.has(property)) { + rewritten = rewriteNamedTokens(rewritten, ctx.fontRenames, true); + } + if (rewritten !== style.getPropertyValue(property)) { + additions.push( + serializeDeclaration( + property, + rewritten, + style.getPropertyPriority(property), + ), + ); + } + } + if (additions.length) declarations = `${declarations} ${additions.join(' ')}`; + return declarations; +}; diff --git a/src/renderer/utils/css/scopeUserCss/selectorScope.ts b/src/renderer/utils/css/scopeUserCss/selectorScope.ts new file mode 100644 index 000000000..7ac9cd709 --- /dev/null +++ b/src/renderer/utils/css/scopeUserCss/selectorScope.ts @@ -0,0 +1,428 @@ +const LEADING_STATEMENT = /^@(charset|import|namespace|layer)\b/i; + +export const isWhitespace = (ch: string): boolean => + ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r' || ch === '\f'; + +// 시트 머리의 @charset/@import/@namespace 문(과 그 앞에 올 수 있는 @layer +// 순서문)만 원문 그대로 추출 - 접두사 래핑 후에도 유효 위치를 지키려면 +// 최상단 hoisting이 필요하다. 블록 규칙이 나오면 중단 (그 뒤의 @import는 +// 브라우저도 무시하므로 파서에 맡겨 자연 드롭) +export const splitLeadingStatements = ( + css: string, +): { hoisted: string[]; body: string } => { + const hoisted: string[] = []; + let i = 0; + const len = css.length; + while (i < len) { + if (isWhitespace(css[i])) { + i += 1; + continue; + } + // 주석 통과 - 주석 속 @import를 되살리지 않게 통째로 건너뜀 + if (css.startsWith('/*', i)) { + const end = css.indexOf('*/', i + 2); + if (end === -1) break; + i = end + 2; + continue; + } + if (!LEADING_STATEMENT.test(css.slice(i, i + 12))) break; + // 문자열·괄호·주석을 존중하며 종결 세미콜론까지 스캔. 블록이 먼저 열리면 문이 아니다 + let j = i; + let quote: string | null = null; + let depth = 0; + let terminated = false; + while (j < len) { + const c = css[j]; + if (quote) { + if (c === '\\') j += 1; + else if (c === quote) quote = null; + } else if (c === '/' && css[j + 1] === '*') { + // 문 중간의 주석 - 안의 ; { ( 는 구문이 아니다 + const end = css.indexOf('*/', j + 2); + if (end === -1) break; + j = end + 2; + continue; + } else if (c === '"' || c === "'") { + quote = c; + } else if (c === '(') { + depth += 1; + } else if (c === ')') { + depth = depth > 0 ? depth - 1 : 0; + } else if (c === '{' && depth === 0) { + break; + } else if (c === ';' && depth === 0) { + terminated = true; + break; + } + j += 1; + } + if (!terminated) break; + hoisted.push(css.slice(i, j + 1)); + i = j + 1; + } + return { hoisted, body: css.slice(i) }; +}; + +// 따옴표·이스케이프·괄호 밖의 첫 블록 시작 중괄호 - 셀렉터 문자열 속 `{` 무시 +export const findBlockStart = (text: string): number => { + let depth = 0; + let quote: string | null = null; + for (let i = 0; i < text.length; i += 1) { + const ch = text[i]; + if (quote) { + if (ch === '\\') i += 1; + else if (ch === quote) quote = null; + continue; + } + if (ch === '\\') { + i += 1; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + if (ch === '(' || ch === '[') { + depth += 1; + continue; + } + if (ch === ')' || ch === ']') { + depth = depth > 0 ? depth - 1 : 0; + continue; + } + if (ch === '{' && depth === 0) return i; + } + return -1; +}; + +// 최상위 콤마 분리 - 괄호·대괄호·따옴표·이스케이프 안 콤마는 구분자가 아니다 +export const splitSelectorList = (selectorText: string): string[] => { + const parts: string[] = []; + let depth = 0; + let quote: string | null = null; + let start = 0; + for (let i = 0; i < selectorText.length; i += 1) { + const ch = selectorText[i]; + if (quote) { + if (ch === '\\') i += 1; + else if (ch === quote) quote = null; + continue; + } + if (ch === '\\') { + i += 1; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + if (ch === '(' || ch === '[') { + depth += 1; + continue; + } + if (ch === ')' || ch === ']') { + depth = depth > 0 ? depth - 1 : 0; + continue; + } + if (ch === ',' && depth === 0) { + parts.push(selectorText.slice(start, i)); + start = i + 1; + } + } + parts.push(selectorText.slice(start)); + return parts.map((part) => part.trim()).filter(Boolean); +}; + +// 따옴표·이스케이프 밖에서 조건을 만족하는 첫 위치 (괄호 깊이 조건부) +const findTopLevel = ( + text: string, + start: number, + isHit: (index: number) => boolean, + ignoreBrackets: boolean, +): number => { + let depth = 0; + let quote: string | null = null; + for (let i = start; i < text.length; i += 1) { + const ch = text[i]; + if (quote) { + if (ch === '\\') i += 1; + else if (ch === quote) quote = null; + continue; + } + if (ch === '\\') { + i += 1; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + if (ch === '(' || ch === '[') { + depth += 1; + continue; + } + if (ch === ')' || ch === ']') { + depth = depth > 0 ? depth - 1 : 0; + continue; + } + if ((ignoreBrackets || depth === 0) && isHit(i)) return i; + } + return -1; +}; + +// compound 셀렉터의 끝 - 괄호·대괄호·따옴표 밖 첫 공백 또는 결합자 위치 +const scanCompoundEnd = (selector: string, start: number): number => { + const end = findTopLevel( + selector, + start, + (i) => { + const ch = selector[i]; + return isWhitespace(ch) || ch === '>' || ch === '+' || ch === '~'; + }, + false, + ); + return end === -1 ? selector.length : end; +}; + +// 중첩 셀렉터에서 `&`가 :not()/:is()/:has() 같은 괄호 안에만 있는지 - 그런 +// 형태는 대상이 부모 밖으로 확장될 수 있다. 최상위 compound에 `&`가 있으면 +// (body.dark &, & .child, &:hover) 대상이 부모 또는 그 주변으로 고정된다 +const hasEscapingAmpersand = (selector: string): boolean => { + const isAmpersand = (i: number) => selector[i] === '&'; + return ( + findTopLevel(selector, 0, isAmpersand, true) !== -1 && + findTopLevel(selector, 0, isAmpersand, false) === -1 + ); +}; + +// compound 안 가상 요소 시작 위치 (::x 또는 구형 :before/:after 계열) +const LEGACY_PSEUDO_ELEMENT = + /^:(before|after|first-line|first-letter)(?![\w-])/i; +const findPseudoElementStart = (compound: string): number => + findTopLevel( + compound, + 0, + (i) => + compound[i] === ':' && + (compound[i + 1] === ':' || + LEGACY_PSEUDO_ELEMENT.test(compound.slice(i))), + false, + ); + +// 식별자 경계 강제 - body-card 같은 커스텀 요소를 body로 오인하지 않게 +// namespace-qualified type selector도 local name이 html/body면 문서 루트다. +// 반대로 body|div는 namespace 접두사일 뿐이므로 루트로 보지 않는다 +const ROOT_TOKEN = + /^(?::root(?![\w-])|(?:(?:[\w-]+|\*)?\|)?(?:html|body)(?![\w-]|\|))/i; + +interface FunctionalRootInfo { + length: number; + rootBranches: string[]; + allBranchesAreRoots: boolean; +} + +// :is(body, html)처럼 문서 루트가 포함된 함수형 compound의 분기 정보 +function functionalRootInfo(selector: string): FunctionalRootInfo | null { + const match = /^:(is|where)\(/i.exec(selector); + if (!match) return null; + const open = match[0].length - 1; + let depth = 1; + let quote: string | null = null; + let close = -1; + for (let i = open + 1; i < selector.length; i += 1) { + const ch = selector[i]; + if (quote) { + if (ch === '\\') i += 1; + else if (ch === quote) quote = null; + continue; + } + if (ch === '\\') { + i += 1; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + if (ch === '(') depth += 1; + else if (ch === ')') { + depth -= 1; + if (depth === 0) { + close = i; + break; + } + } + } + if (close === -1) return null; + const branches = splitSelectorList(selector.slice(open + 1, close)); + if (!branches.length) return null; + const rootBranches = branches.filter((branch) => { + const tokenLength = leadingPotentialRootTokenLength(branch); + return ( + tokenLength !== null && + scanCompoundEnd(branch, tokenLength) === branch.length + ); + }); + if (!rootBranches.length) return null; + return { + length: close + 1, + rootBranches, + allBranchesAreRoots: rootBranches.length === branches.length, + }; +} + +function functionalRootTokenLength(selector: string): number | null { + const info = functionalRootInfo(selector); + return info?.allBranchesAreRoots ? info.length : null; +} + +function leadingRootTokenLength(selector: string): number | null { + const token = ROOT_TOKEN.exec(selector); + return token?.[0].length ?? functionalRootTokenLength(selector); +} + +function leadingPotentialRootTokenLength(selector: string): number | null { + const token = ROOT_TOKEN.exec(selector); + return token?.[0].length ?? functionalRootInfo(selector)?.length ?? null; +} + +type RootRemap = + | { kind: 'none' } + | { kind: 'drop' } + | { kind: 'remap'; selector: string }; + +// 선행 :root/html/body 체인은 조상 조건으로 그대로 두고 실제 대상만 스코프 +// 안으로 옮긴다 (`:root .x` → `:root scope .x`, `body` → `body scope`, +// `body::before` → `body scope::before`). 루트를 지우지 않아 원래 특이도가 +// 유지되고, 유저 CSS의 전역 변수·배경 관례는 스코프 요소에 실려 미리보기에 +// 상속된다. 루트의 형제 결합자(+, ~)는 스코프 자손으로 표현할 수 없어 드롭 +const remapLeadingRootChainWith = ( + selector: string, + scope: string, + findRootToken: (selector: string) => number | null, +): RootRemap => { + let pos = 0; + let matched = false; + let chainEnd = 0; + let lastCompoundStart = 0; + let combinator = ''; + let afterIndex = selector.length; + for (;;) { + const tokenLength = findRootToken(selector.slice(pos)); + if (tokenLength === null) break; + matched = true; + lastCompoundStart = pos; + chainEnd = scanCompoundEnd(selector, pos + tokenLength); + let j = chainEnd; + while (j < selector.length && isWhitespace(selector[j])) j += 1; + let comb = ''; + if (j < selector.length && '>+~'.includes(selector[j])) { + comb = selector[j]; + j += 1; + while (j < selector.length && isWhitespace(selector[j])) j += 1; + } + if (j >= selector.length) { + afterIndex = selector.length; + break; + } + if (findRootToken(selector.slice(j)) !== null) { + // html > body 처럼 루트끼리 이어진 체인은 통째로 조상 조건 + pos = j; + continue; + } + combinator = comb; + afterIndex = j; + break; + } + if (!matched) return { kind: 'none' }; + if (combinator === '+' || combinator === '~') return { kind: 'drop' }; + + // 마지막 루트 compound의 가상 요소는 스코프 요소 쪽으로 옮긴다 - + // 가상 요소는 자손을 가질 수 없어 그대로 두면 절대 매치되지 않는다 + const lastCompound = selector.slice(lastCompoundStart, chainEnd); + const pseudoAt = findPseudoElementStart(lastCompound); + let chain = selector.slice(0, chainEnd).trim(); + let pseudo = ''; + if (pseudoAt !== -1) { + pseudo = lastCompound.slice(pseudoAt); + chain = selector.slice(0, lastCompoundStart + pseudoAt).trim(); + } + const rest = selector.slice(afterIndex).trim(); + if (pseudo && rest) return { kind: 'drop' }; + const trailing = combinator ? ` ${combinator} ` : ' '; + return { + kind: 'remap', + selector: rest + ? `${chain} ${scope}${trailing}${rest}` + : `${chain} ${scope}${pseudo}`, + }; +}; + +const remapLeadingRootChain = (selector: string, scope: string): RootRemap => + remapLeadingRootChainWith(selector, scope, leadingRootTokenLength); + +const remapMixedFunctionalRoot = (selector: string, scope: string): RootRemap => + remapLeadingRootChainWith(selector, scope, leadingPotentialRootTokenLength); + +// 최상위 규칙의 스코프된 셀렉터 리스트 - 전부 드롭되면 null (규칙을 버린다) +export const scopeSelectorText = ( + selectorText: string, + scope: string, +): string | null => { + const scoped: string[] = []; + for (const selector of splitSelectorList(selectorText)) { + const remap = remapLeadingRootChain(selector, scope); + if (remap.kind === 'drop') continue; + if (remap.kind === 'remap') { + scoped.push(remap.selector); + continue; + } + + scoped.push(`${scope} ${selector}`); + const functional = functionalRootInfo(selector); + if (!functional || functional.allBranchesAreRoots) continue; + // 함수 자체를 조상 조건으로 유지해 :is의 최대 분기 특이도와 :where의 + // 0 특이도를 보존한다. 대상은 scope 뒤에만 있어 메인 UI로 빠지지 않는다 + const rootRemap = remapMixedFunctionalRoot(selector, scope); + if (rootRemap.kind === 'remap' && !scoped.includes(rootRemap.selector)) { + scoped.push(rootRemap.selector); + } + } + return scoped.length ? scoped.join(', ') : null; +}; + +// 부모의 형제로 빠져나가는 중첩 셀렉터 - 선두 `+`/`~`(암묵 &의 형제) 또는 +// 최상위 `&` compound 바로 뒤의 `+`/`~`. 부모가 루트 치환으로 스코프 요소 +// 자체가 되면 그 형제는 미리보기 밖 앱 UI다 +const hasSiblingEscape = (selector: string): boolean => { + const first = selector[0]; + if (first === '+' || first === '~') return true; + let from = 0; + for (;;) { + const amp = findTopLevel(selector, from, (i) => selector[i] === '&', false); + if (amp === -1) return false; + let j = scanCompoundEnd(selector, amp + 1); + while (j < selector.length && isWhitespace(selector[j])) j += 1; + if (selector[j] === '+' || selector[j] === '~') return true; + from = amp + 1; + } +}; + +// 중첩 규칙의 셀렉터 - `&`가 없거나 최상위에 있으면 부모(이미 스코프 안)에 +// 고정이라 그대로. 괄호 안에만 있거나(:not(&)·:is(x, &)·.x:has(&)) 부모의 +// 형제로 나가면 스코프 접두사를 붙여 대상이 스코프 자손임을 강제한다. +// 접두사를 붙일 때 `&`가 없는 상대 셀렉터는 `&`를 명시해 의미를 유지 +export const scopeNestedSelectorText = ( + selectorText: string, + scope: string, +): string => + splitSelectorList(selectorText) + .map((selector) => { + if (!hasEscapingAmpersand(selector) && !hasSiblingEscape(selector)) { + return selector; + } + const hasAmpersand = + findTopLevel(selector, 0, (i) => selector[i] === '&', true) !== -1; + return hasAmpersand ? `${scope} ${selector}` : `${scope} & ${selector}`; + }) + .join(', '); From 2d10950ca19e7726f83ad08d25e7062d71d6acac Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 22:35:49 +0900 Subject: [PATCH 232/245] =?UTF-8?q?refactor:=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=ED=8C=9D=EC=97=85=20=EB=A9=94=EB=89=B4=20=ED=96=89=20=EB=9F=B0?= =?UTF-8?q?=ED=83=80=EC=9E=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/main/Modal/ListPopup.tsx | 702 +----------------- .../main/Modal/listPopupMenuRows.tsx | 688 +++++++++++++++++ 2 files changed, 707 insertions(+), 683 deletions(-) create mode 100644 src/renderer/components/main/Modal/listPopupMenuRows.tsx diff --git a/src/renderer/components/main/Modal/ListPopup.tsx b/src/renderer/components/main/Modal/ListPopup.tsx index eb96568d2..4207e7605 100644 --- a/src/renderer/components/main/Modal/ListPopup.tsx +++ b/src/renderer/components/main/Modal/ListPopup.tsx @@ -1,49 +1,28 @@ -import React, { - useContext, - useState, - useRef, - useEffect, - useLayoutEffect, -} from 'react'; -import { createPortal, flushSync } from 'react-dom'; +import React, { useEffect, useRef } from 'react'; +import { flushSync } from 'react-dom'; import FloatingPopup from './FloatingPopup'; -import { FloatingPopupMotionContext } from './floatingPopupMotion'; -import { isTopmostPopupLayer, registerPopupLayer } from './popupLayer'; -import { usePanelHost } from '@contexts/PanelHostContext'; -import { clampToViewport, POPUP_EDGE_PADDING } from '@utils/ui/popupGeometry'; import { useViewportSize } from '@hooks/ui/useViewportSize'; import { getListScrollMetrics } from './listScrollMetrics'; import { useLenis } from '@hooks/useLenis'; import type { CommitStrategy } from '@hooks/useOptimisticBooleanCommit'; import { CANVAS_POPUP_CHROME_CLASS } from './popupChrome'; - -export type ListMenuItem = { - id: string; - label: string; - disabled?: boolean; - isPlugin?: boolean; - /** 토글 항목의 체크 상태 */ - checked?: boolean; - /** 서브메뉴 항목 */ - children?: ListItem[]; -}; - -/** 성격이 다른 묶음을 가르는 선. 포커스도 키보드 순회도 받지 않는다. - * 항목 전용 필드를 undefined로 열어둬 목록을 훑는 쪽이 매번 좁히지 않아도 되게 한다 */ -export type ListSeparator = { - id: string; - separator: true; - label?: undefined; - disabled?: undefined; - isPlugin?: undefined; - checked?: undefined; - children?: undefined; -}; - -export type ListItem = ListMenuItem | ListSeparator; - -const isSeparator = (item: ListItem): item is ListSeparator => - 'separator' in item; +import { + emptySiblingActive, + getAdjacentFocusTarget, + handleMenuNavigation, + isSeparator, + MenuItemRow, + SeparatorRow, + trackPointer, + type ListItem, + type SiblingActive, +} from './listPopupMenuRows'; + +export type { + ListItem, + ListMenuItem, + ListSeparator, +} from './listPopupMenuRows'; interface ListPopupProps { open: boolean; @@ -66,649 +45,6 @@ interface ListPopupProps { /** 스크롤·contain 조상 안에서 열릴 때 필요 */ portalToBody?: boolean; } - -const DOCUMENT_FOCUSABLE_SELECTOR = [ - 'a[href]', - 'button:not([disabled])', - 'input:not([disabled])', - 'select:not([disabled])', - 'textarea:not([disabled])', - '[contenteditable="true"]', - '[tabindex]:not([tabindex="-1"])', -].join(','); - -// 표면 패딩 5px = 행 갭 4px + inset 링 1px 보정 (링이 패딩 최외곽 1px 위에 그려짐) -const POPUP_CHROME_INSET = 5; -const SUBMENU_SURFACE_GAP = 5; -const SUBMENU_ANCHOR_GAP = POPUP_CHROME_INSET + SUBMENU_SURFACE_GAP; - -const getAdjacentFocusTarget = ( - origin: HTMLElement | null, - reverse: boolean, -) => { - const modalScope = origin?.closest( - '[data-dmn-modal-backdrop="true"]', - ); - const focusScope: ParentNode = - modalScope ?? origin?.ownerDocument ?? document; - const focusable = Array.from( - focusScope.querySelectorAll(DOCUMENT_FOCUSABLE_SELECTOR), - ).filter( - (element) => - !element.closest( - '[hidden], [aria-hidden="true"], [data-dmn-popup-layer="true"]', - ) && element.getAttribute('aria-disabled') !== 'true', - ); - const originIndex = origin ? focusable.indexOf(origin) : -1; - if (originIndex < 0) { - return reverse ? focusable[focusable.length - 1] : focusable[0]; - } - const adjacent = focusable[originIndex + (reverse ? -1 : 1)]; - if (adjacent || !modalScope) return adjacent ?? origin; - return reverse ? focusable[focusable.length - 1] : focusable[0]; -}; - -const getMenuItems = (menu: HTMLElement) => - Array.from( - menu.querySelectorAll( - '[role^="menuitem"]:not(:disabled)', - ), - ); - -const handleMenuNavigation = (event: React.KeyboardEvent) => { - if (event.defaultPrevented) return; - const items = getMenuItems(event.currentTarget); - if (items.length === 0) return; - - const activeIndex = items.indexOf( - event.currentTarget.ownerDocument.activeElement as HTMLButtonElement, - ); - let nextIndex: number | null = null; - if (event.key === 'ArrowDown') { - nextIndex = activeIndex < 0 ? 0 : (activeIndex + 1) % items.length; - } else if (event.key === 'ArrowUp') { - nextIndex = - activeIndex < 0 - ? items.length - 1 - : (activeIndex - 1 + items.length) % items.length; - } else if (event.key === 'Home') { - nextIndex = 0; - } else if (event.key === 'End') { - nextIndex = items.length - 1; - } - - if (nextIndex == null) return; - event.preventDefault(); - items[nextIndex].focus(); -}; - -/** 서브메뉴 컴포넌트 (호버 시 표시) */ -const SubMenu = ({ - ariaLabel, - instant, - registerSurface, - items, - onSelect, - onCloseAll, - onMenuTab, - anchorRect, - parentItemRef, - focusFirst, - onMouseEnter, - onMouseLeave, - onRequestClose, -}: { - ariaLabel: string; - /** 형제 서브메뉴에서 곧바로 넘어온 열림 - 진입 모션을 재생하지 않는다 */ - instant: boolean; - /** 부모 행이 위치를 읽어 커서 의도를 판정한다 */ - registerSurface?: (node: HTMLDivElement | null) => void; - items: ListItem[]; - onSelect?: (id: string) => void; - onCloseAll: () => void; - onMenuTab: (event: KeyboardEvent) => void; - anchorRect: DOMRect | null; - parentItemRef: React.RefObject; - focusFirst: boolean; - onMouseEnter?: () => void; - onMouseLeave?: () => void; - onRequestClose: () => void; -}) => { - const { window: ownerWindow, document: ownerDocument } = usePanelHost(); - const subMenuRef = useRef(null); - const parentMotionState = useContext(FloatingPopupMotionContext); - const siblingActiveRef = useRef(emptySiblingActive()); - // 실측 기반 배치 — 히든 렌더 후 페인트 전에 측정·확정 (추정치 없음) - const [pos, setPos] = useState<{ - left?: number; - right?: number; - top: number; - } | null>(null); - - // 부모가 닫히는 동안 서브메뉴는 렌더를 멈추므로 등록도 함께 풀어야 한다. - // parentMotionState를 의존성에서 빼면 해제 없이 스택에 남아 Escape 순서가 뒤집힌다 - useLayoutEffect(() => { - const element = subMenuRef.current; - if (!element || parentMotionState === 'closing') return; - return registerPopupLayer(element); - }, [anchorRect, parentMotionState]); - - // 최상위 서브메뉴만 키보드 종료를 소유 - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if (e.defaultPrevented || !isTopmostPopupLayer(subMenuRef.current)) - return; - if (e.key === 'Tab') { - onMenuTab(e); - return; - } - if (e.key !== 'Escape') return; - e.preventDefault(); - flushSync(onRequestClose); - parentItemRef.current?.focus(); - }; - ownerDocument.addEventListener('keydown', onKey); - return () => ownerDocument.removeEventListener('keydown', onKey); - }, [onMenuTab, onRequestClose, parentItemRef, ownerDocument]); - - useLayoutEffect(() => { - const el = subMenuRef.current; - if (!anchorRect || !el) return; - - const padding = POPUP_EDGE_PADDING; - const { offsetWidth: width, offsetHeight: height } = el; - const normalLeft = anchorRect.right + SUBMENU_ANCHOR_GAP; - const flippedLeft = anchorRect.left - SUBMENU_ANCHOR_GAP - width; - - // 오른쪽이 좁을 때만 뒤집되, 왼쪽에도 자리가 있어야 의미가 있다 - const flipToLeft = - normalLeft + width > ownerWindow.innerWidth - padding && - flippedLeft >= padding; - - const top = clampToViewport( - anchorRect.top - POPUP_CHROME_INSET, - height, - ownerWindow.innerHeight, - ); - // 양쪽 다 좁으면 오른쪽 경계에 맞춰 안쪽으로 당긴다 - const left = clampToViewport(normalLeft, width, ownerWindow.innerWidth); - - // 측정→배치 패턴: 페인트 전 위치 확정이 목적이라 동기 setState가 의도임 - // eslint-disable-next-line react-hooks/set-state-in-effect - setPos( - flipToLeft - ? { - right: - ownerWindow.innerWidth - anchorRect.left + SUBMENU_ANCHOR_GAP, - top, - } - : { left, top }, - ); - }, [anchorRect, items.length, ownerWindow]); - - useLayoutEffect(() => { - if (!focusFirst || !pos) return; - const firstItem = subMenuRef.current - ? getMenuItems(subMenuRef.current)[0] - : null; - firstItem?.focus(); - }, [focusFirst, pos]); - - const { height: viewportHeight } = useViewportSize(); - const { needsScroll, maxHeight } = getListScrollMetrics( - items.length, - viewportHeight, - items.filter(isSeparator).length, - ); - const hasCheckColumn = items.some( - (it) => !isSeparator(it) && typeof it.checked === 'boolean', - ); - - const { scrollContainerRef: subLenisRef } = useLenis({ - wheelMultiplier: 0.7, - }); - - // 부모가 닫히기 시작하면 서브메뉴는 잔상 없이 즉시 걷는다. 남겨두면 body 포털이라 - // 부모 페이드와 무관하게 선명히 떠 있고 Escape 소유권도 계속 물고 있다 - if (!anchorRect || parentMotionState === 'closing') return null; - - // body 포털 필수 — 부모 팝업의 backdrop-filter가 fixed의 containing block이 되어 - // 뷰포트 좌표가 어긋나는 것 방지. 호버 유지는 onMouseEnter/Leave 콜백으로 연결 - return createPortal( -
{ - (subMenuRef as React.MutableRefObject).current = - node; - registerSurface?.(node); - }} - data-dmn-popup-submenu="true" - data-dmn-popup-layer="true" - role="menu" - aria-label={ariaLabel} - tabIndex={-1} - onKeyDown={(event) => { - if (event.key === 'ArrowLeft') { - event.preventDefault(); - flushSync(onRequestClose); - parentItemRef.current?.focus(); - return; - } - handleMenuNavigation(event); - }} - onMouseEnter={onMouseEnter} - onMouseLeave={onMouseLeave} - className={`fixed z-[var(--z-chrome-submenu)] ${CANVAS_POPUP_CHROME_CLASS} rounded-surface p-[5px] flex flex-col${ - instant ? '' : ' tooltip-fade-in' - }`} - style={{ - left: pos?.left, - right: pos?.right, - top: pos?.top ?? 0, - visibility: pos ? undefined : 'hidden', - ...(maxHeight ? { maxHeight } : {}), - }} - > - {/* 스크롤러를 크롬 박스 안쪽으로 한 겹 내린다 - 마스크가 표면과 그림자까지 - 갉아먹지 않게. role=none으로 menu와 menuitem의 관계는 그대로 통과시킨다 */} -
trackPointer(siblingActiveRef, event)} - className={`flex flex-col gap-[4px]${ - needsScroll ? ' listpopup-scroll dmn-scroll-fade' : '' - }`} - style={ - maxHeight ? { overflowY: 'auto', overflowX: 'hidden' } : undefined - } - > - {items.map((it) => - isSeparator(it) ? ( - - ) : ( - - ), - )} -
-
, - ownerDocument.body, - ); -}; - -// 부모 항목에서 서브메뉴로 갈 때 커서는 대각선을 그리며 중간 형제 항목을 스친다. -// 닫고 다시 여는 대신, 서브메뉴로 향하는 동안에는 형제 전환을 아예 막는다 - -// 닫힘 이벤트가 없으면 표면이 사라질 일도 없다 -interface SiblingActive { - id: string | null; - close: (() => void) | null; - /** 열려 있는 서브메뉴 표면의 현재 사각형 */ - getRect: (() => DOMRect | null) | null; - /** 예약된 닫힘을 인계 시점 뒤로 미룬다 */ - holdOpen: (() => void) | null; - /** 직전과 현재 커서 위치 - 진행 방향 판정용 */ - from: { x: number; y: number } | null; - to: { x: number; y: number } | null; -} - -const HOVER_OPEN_MS = 150; -const HOVER_CLOSE_MS = 200; -// 길목에 머물 때 형제가 넘겨받기까지. 이 값이 곧 서브메뉴까지 갈 수 있는 예산이라 -// 짧게 잡으면 느린 대각선 이동을 형제가 가로챈다 -const INTENT_GRACE_MS = 300; -// 길목으로 판정되면 기존 표면의 닫힘을 인계 뒤로 미룬다. 인계가 먼저 끝나야 -// 닫기와 열기가 한 번의 갱신으로 묶여 표면이 끊기지 않는다 -const INTENT_HOLD_MS = INTENT_GRACE_MS + 100; -const INTENT_PADDING_PX = 12; - -const emptySiblingActive = (): SiblingActive => ({ - id: null, - close: null, - getRect: null, - holdOpen: null, - from: null, - to: null, -}); - -/** 이 행이 쥐고 있던 활성 표시를 놓는다. 커서 자취는 팝업 세션 것이라 남긴다 */ -const releaseSibling = ( - ref: React.RefObject | undefined, - id: string, -) => { - if (ref?.current.id !== id) return; - ref.current = { - ...ref.current, - id: null, - close: null, - getRect: null, - holdOpen: null, - }; -}; - -/** 표면 위 커서 자취를 공유 ref에 남긴다 - 형제 전환 판정의 입력 */ -const trackPointer = ( - ref: React.RefObject, - event: React.MouseEvent, -) => { - const next = { x: event.clientX, y: event.clientY }; - const previous = ref.current.to; - if (previous && previous.x === next.x && previous.y === next.y) return; - ref.current.from = previous; - ref.current.to = next; -}; - -/** 커서 진행 방향의 반직선이 서브메뉴 사각형을 지나는가 (slab 판정) */ -const isHeadingTo = ( - from: { x: number; y: number }, - to: { x: number; y: number }, - rect: DOMRect, -): boolean => { - const dx = to.x - from.x; - const dy = to.y - from.y; - if (dx === 0 && dy === 0) return false; - - const left = rect.left - INTENT_PADDING_PX; - const right = rect.right + INTENT_PADDING_PX; - const top = rect.top - INTENT_PADDING_PX; - const bottom = rect.bottom + INTENT_PADDING_PX; - - let near = 0; - let far = Number.POSITIVE_INFINITY; - - const slab = (origin: number, delta: number, min: number, max: number) => { - if (delta === 0) return origin >= min && origin <= max; - const t1 = (min - origin) / delta; - const t2 = (max - origin) / delta; - near = Math.max(near, Math.min(t1, t2)); - far = Math.min(far, Math.max(t1, t2)); - return true; - }; - - if (!slab(to.x, dx, left, right)) return false; - if (!slab(to.y, dy, top, bottom)) return false; - return near <= far; -}; - -/** 묶음 사이 선. 표면 가장자리(4px)에서 한 번 더 물러나 라벨 시작점보다 살짝 왼쪽에서 - * 시작한다 - 행 하이라이트를 그대로 따라가면 가장자리까지 닿아 크롬처럼 보인다. - * 위아래 여백은 컨테이너 gap이 맡는다 */ -const SeparatorRow = () => ( -
-); - -/** 개별 메뉴 항목 행 */ -const MenuItemRow = ({ - item, - onSelect, - onCloseAll, - onMenuTab, - siblingActiveRef, - hasCheckColumn = false, -}: { - item: ListMenuItem; - onSelect?: (id: string) => void; - onCloseAll: () => void; - onMenuTab: (event: KeyboardEvent) => void; - /** 형제 항목 중 활성 서브메뉴와 커서 자취를 공유하는 ref */ - siblingActiveRef?: React.RefObject; - /** 목록에 체크 가능한 항목이 있을 때만 좌측 체크 컬럼 렌더 */ - hasCheckColumn?: boolean; -}) => { - const [subMenuOpen, setSubMenuOpen] = useState(false); - // 형제에서 넘어온 열림인지 - 진입 모션 재생 여부를 가른다 - const [instantOpen, setInstantOpen] = useState(false); - const rowRef = useRef(null); - // 유예 시간이 끝났을 때 아직 이 행 위에 있는지 - const hoveredRef = useRef(false); - const subSurfaceRef = useRef(null); - const hoverTimerRef = useRef | null>(null); - const [rowRect, setRowRect] = useState(null); - const [focusSubMenuOnOpen, setFocusSubMenuOnOpen] = useState(false); - - const hasChildren = item.children && item.children.length > 0; - - const scheduleClose = (delay: number) => { - if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current); - hoverTimerRef.current = setTimeout(() => { - setSubMenuOpen(false); - releaseSibling(siblingActiveRef, item.id); - }, delay); - }; - - const showSubMenu = (focusFirst: boolean, instant = false) => { - // 호버는 button이 아니라 감싼 div가 받으므로 disabled가 막아주지 않는다 - if (item.disabled || !hasChildren || !rowRef.current) return; - setRowRect(rowRef.current.getBoundingClientRect()); - setFocusSubMenuOnOpen(focusFirst); - setInstantOpen(instant); - setSubMenuOpen(true); - if (siblingActiveRef) { - siblingActiveRef.current = { - ...siblingActiveRef.current, - id: item.id, - close: () => setSubMenuOpen(false), - getRect: () => subSurfaceRef.current?.getBoundingClientRect() ?? null, - holdOpen: () => scheduleClose(INTENT_HOLD_MS), - }; - } - }; - - const handleMouseEnter = (event: React.MouseEvent) => { - hoveredRef.current = true; - if (!hasChildren) return; - if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current); - // 행에 막 들어온 좌표가 가장 새로운 자취다. 이걸 빼면 직전 행 안에서 찍힌 - // 두 점으로 방향을 재게 되어 실제로 가로지른 방향과 어긋난다 - if (siblingActiveRef) trackPointer(siblingActiveRef, event); - const active = siblingActiveRef?.current; - const hasActiveSibling = active?.id != null; - const delay = hasActiveSibling ? 0 : HOVER_OPEN_MS; - - // 서브메뉴로 가는 길목이면 형제 전환을 미룬다. 커서가 그 자리에 머무르면 - // 유예 시간 뒤에 이 행이 넘겨받는다 - if (hasActiveSibling && active?.id !== item.id) { - const rect = active?.getRect?.(); - const from = active?.from; - const to = active?.to; - if (rect && from && to && isHeadingTo(from, to, rect)) { - // 형제 행을 지나는 동안 기존 표면이 닫히면 의도 판정이 무의미해진다 - active?.holdOpen?.(); - hoverTimerRef.current = setTimeout(() => { - if (!hoveredRef.current) return; - // 형제 표면이 아직 떠 있을 때만 모션을 건너뛴다. 이미 닫혔다면 - // 이어받는 게 아니라 새로 여는 것이라 페이드가 있어야 한다 - const handover = siblingActiveRef?.current.id != null; - siblingActiveRef?.current.close?.(); - showSubMenu(false, handover); - }, INTENT_GRACE_MS); - return; - } - active?.close?.(); - } - - // 형제가 열려 있었다면 표면이 방금까지 떠 있었다 - 페이드를 다시 재생하면 - // 유리 표면이 사라졌다 돌아오는 것처럼 보인다 - hoverTimerRef.current = setTimeout( - () => showSubMenu(false, hasActiveSibling), - delay, - ); - }; - - const handleMouseLeave = () => { - hoveredRef.current = false; - scheduleClose(HOVER_CLOSE_MS); - }; - - useEffect(() => { - return () => { - if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current); - // 열린 서브메뉴를 쥔 행이 사라지면 공유 ref에 분리된 노드의 getRect가 남아 - // 다음 호버의 형제 전환 판정이 0 사각형으로 오판한다 - releaseSibling(siblingActiveRef, item.id); - }; - }, [item.id, siblingActiveRef]); - - // 부모 메뉴를 스크롤하면 앵커 행이 움직이는데 서브메뉴는 열 때 잰 좌표에 고정된다. - // 프레임마다 재측정하면 글래스 표면에서 강제 레이아웃+재필터라, 스크롤 시 닫는다 - // (FloatingPopup closeOnScroll과 같은 관용구). 서브메뉴 자체 스크롤은 행을 포함하지 - // 않으므로 무시된다 - useEffect(() => { - if (!subMenuOpen) return; - const row = rowRef.current; - const doc = row?.ownerDocument; - if (!row || !doc) return; - const onScroll = (event: Event) => { - const target = event.target as Node | null; - if (!target || !target.contains(row)) return; - if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current); - setSubMenuOpen(false); - releaseSibling(siblingActiveRef, item.id); - }; - doc.addEventListener('scroll', onScroll, true); - return () => doc.removeEventListener('scroll', onScroll, true); - }, [subMenuOpen, item.id, siblingActiveRef]); - - const hasCheck = typeof item.checked === 'boolean'; - const constrainLabel = item.isPlugin === true; - - const handleSelect = () => { - if (item.disabled) return; - if (hasChildren) { - showSubMenu(false); - return; - } - onSelect?.(item.id); - onCloseAll(); - }; - - const handleKeyDown = (event: React.KeyboardEvent) => { - if (!hasChildren || !['ArrowRight', 'Enter', ' '].includes(event.key)) { - return; - } - event.preventDefault(); - event.stopPropagation(); - showSubMenu(true); - }; - - return ( -
- - - {/* 서브메뉴 */} - {hasChildren && subMenuOpen && ( - { - subSurfaceRef.current = node; - }} - items={item.children!} - onSelect={onSelect} - onCloseAll={onCloseAll} - onMenuTab={onMenuTab} - anchorRect={rowRect} - parentItemRef={rowRef} - focusFirst={focusSubMenuOnOpen} - onMouseEnter={() => { - // 포털로 DOM 중첩이 끊기므로 서브메뉴 진입 시 닫힘 타이머 취소 - if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current); - }} - onMouseLeave={handleMouseLeave} - onRequestClose={() => { - if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current); - setSubMenuOpen(false); - releaseSibling(siblingActiveRef, item.id); - }} - /> - )} -
- ); -}; - const ListPopup = ({ open, ariaLabel, diff --git a/src/renderer/components/main/Modal/listPopupMenuRows.tsx b/src/renderer/components/main/Modal/listPopupMenuRows.tsx new file mode 100644 index 000000000..2d2bed450 --- /dev/null +++ b/src/renderer/components/main/Modal/listPopupMenuRows.tsx @@ -0,0 +1,688 @@ +/* eslint-disable react-refresh/only-export-components */ +import React, { + useContext, + useState, + useRef, + useEffect, + useLayoutEffect, +} from 'react'; +import { createPortal, flushSync } from 'react-dom'; +import { FloatingPopupMotionContext } from './floatingPopupMotion'; +import { isTopmostPopupLayer, registerPopupLayer } from './popupLayer'; +import { usePanelHost } from '@contexts/PanelHostContext'; +import { clampToViewport, POPUP_EDGE_PADDING } from '@utils/ui/popupGeometry'; +import { useViewportSize } from '@hooks/ui/useViewportSize'; +import { getListScrollMetrics } from './listScrollMetrics'; +import { useLenis } from '@hooks/useLenis'; +import { CANVAS_POPUP_CHROME_CLASS } from './popupChrome'; + +export type ListMenuItem = { + id: string; + label: string; + disabled?: boolean; + isPlugin?: boolean; + /** 토글 항목의 체크 상태 */ + checked?: boolean; + /** 서브메뉴 항목 */ + children?: ListItem[]; +}; + +/** 성격이 다른 묶음을 가르는 선. 포커스도 키보드 순회도 받지 않는다. + * 항목 전용 필드를 undefined로 열어둬 목록을 훑는 쪽이 매번 좁히지 않아도 되게 한다 */ +export type ListSeparator = { + id: string; + separator: true; + label?: undefined; + disabled?: undefined; + isPlugin?: undefined; + checked?: undefined; + children?: undefined; +}; + +export type ListItem = ListMenuItem | ListSeparator; + +export const isSeparator = (item: ListItem): item is ListSeparator => + 'separator' in item; +const DOCUMENT_FOCUSABLE_SELECTOR = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[contenteditable="true"]', + '[tabindex]:not([tabindex="-1"])', +].join(','); + +// 표면 패딩 5px = 행 갭 4px + inset 링 1px 보정 (링이 패딩 최외곽 1px 위에 그려짐) +const POPUP_CHROME_INSET = 5; +const SUBMENU_SURFACE_GAP = 5; +const SUBMENU_ANCHOR_GAP = POPUP_CHROME_INSET + SUBMENU_SURFACE_GAP; + +export const getAdjacentFocusTarget = ( + origin: HTMLElement | null, + reverse: boolean, +) => { + const modalScope = origin?.closest( + '[data-dmn-modal-backdrop="true"]', + ); + const focusScope: ParentNode = + modalScope ?? origin?.ownerDocument ?? document; + const focusable = Array.from( + focusScope.querySelectorAll(DOCUMENT_FOCUSABLE_SELECTOR), + ).filter( + (element) => + !element.closest( + '[hidden], [aria-hidden="true"], [data-dmn-popup-layer="true"]', + ) && element.getAttribute('aria-disabled') !== 'true', + ); + const originIndex = origin ? focusable.indexOf(origin) : -1; + if (originIndex < 0) { + return reverse ? focusable[focusable.length - 1] : focusable[0]; + } + const adjacent = focusable[originIndex + (reverse ? -1 : 1)]; + if (adjacent || !modalScope) return adjacent ?? origin; + return reverse ? focusable[focusable.length - 1] : focusable[0]; +}; + +const getMenuItems = (menu: HTMLElement) => + Array.from( + menu.querySelectorAll( + '[role^="menuitem"]:not(:disabled)', + ), + ); + +export const handleMenuNavigation = ( + event: React.KeyboardEvent, +) => { + if (event.defaultPrevented) return; + const items = getMenuItems(event.currentTarget); + if (items.length === 0) return; + + const activeIndex = items.indexOf( + event.currentTarget.ownerDocument.activeElement as HTMLButtonElement, + ); + let nextIndex: number | null = null; + if (event.key === 'ArrowDown') { + nextIndex = activeIndex < 0 ? 0 : (activeIndex + 1) % items.length; + } else if (event.key === 'ArrowUp') { + nextIndex = + activeIndex < 0 + ? items.length - 1 + : (activeIndex - 1 + items.length) % items.length; + } else if (event.key === 'Home') { + nextIndex = 0; + } else if (event.key === 'End') { + nextIndex = items.length - 1; + } + + if (nextIndex == null) return; + event.preventDefault(); + items[nextIndex].focus(); +}; + +/** 서브메뉴 컴포넌트 (호버 시 표시) */ +const SubMenu = ({ + ariaLabel, + instant, + registerSurface, + items, + onSelect, + onCloseAll, + onMenuTab, + anchorRect, + parentItemRef, + focusFirst, + onMouseEnter, + onMouseLeave, + onRequestClose, +}: { + ariaLabel: string; + /** 형제 서브메뉴에서 곧바로 넘어온 열림 - 진입 모션을 재생하지 않는다 */ + instant: boolean; + /** 부모 행이 위치를 읽어 커서 의도를 판정한다 */ + registerSurface?: (node: HTMLDivElement | null) => void; + items: ListItem[]; + onSelect?: (id: string) => void; + onCloseAll: () => void; + onMenuTab: (event: KeyboardEvent) => void; + anchorRect: DOMRect | null; + parentItemRef: React.RefObject; + focusFirst: boolean; + onMouseEnter?: () => void; + onMouseLeave?: () => void; + onRequestClose: () => void; +}) => { + const { window: ownerWindow, document: ownerDocument } = usePanelHost(); + const subMenuRef = useRef(null); + const parentMotionState = useContext(FloatingPopupMotionContext); + const siblingActiveRef = useRef(emptySiblingActive()); + // 실측 기반 배치 — 히든 렌더 후 페인트 전에 측정·확정 (추정치 없음) + const [pos, setPos] = useState<{ + left?: number; + right?: number; + top: number; + } | null>(null); + + // 부모가 닫히는 동안 서브메뉴는 렌더를 멈추므로 등록도 함께 풀어야 한다. + // parentMotionState를 의존성에서 빼면 해제 없이 스택에 남아 Escape 순서가 뒤집힌다 + useLayoutEffect(() => { + const element = subMenuRef.current; + if (!element || parentMotionState === 'closing') return; + return registerPopupLayer(element); + }, [anchorRect, parentMotionState]); + + // 최상위 서브메뉴만 키보드 종료를 소유 + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.defaultPrevented || !isTopmostPopupLayer(subMenuRef.current)) + return; + if (e.key === 'Tab') { + onMenuTab(e); + return; + } + if (e.key !== 'Escape') return; + e.preventDefault(); + flushSync(onRequestClose); + parentItemRef.current?.focus(); + }; + ownerDocument.addEventListener('keydown', onKey); + return () => ownerDocument.removeEventListener('keydown', onKey); + }, [onMenuTab, onRequestClose, parentItemRef, ownerDocument]); + + useLayoutEffect(() => { + const el = subMenuRef.current; + if (!anchorRect || !el) return; + + const padding = POPUP_EDGE_PADDING; + const { offsetWidth: width, offsetHeight: height } = el; + const normalLeft = anchorRect.right + SUBMENU_ANCHOR_GAP; + const flippedLeft = anchorRect.left - SUBMENU_ANCHOR_GAP - width; + + // 오른쪽이 좁을 때만 뒤집되, 왼쪽에도 자리가 있어야 의미가 있다 + const flipToLeft = + normalLeft + width > ownerWindow.innerWidth - padding && + flippedLeft >= padding; + + const top = clampToViewport( + anchorRect.top - POPUP_CHROME_INSET, + height, + ownerWindow.innerHeight, + ); + // 양쪽 다 좁으면 오른쪽 경계에 맞춰 안쪽으로 당긴다 + const left = clampToViewport(normalLeft, width, ownerWindow.innerWidth); + + // 측정→배치 패턴: 페인트 전 위치 확정이 목적이라 동기 setState가 의도임 + // eslint-disable-next-line react-hooks/set-state-in-effect + setPos( + flipToLeft + ? { + right: + ownerWindow.innerWidth - anchorRect.left + SUBMENU_ANCHOR_GAP, + top, + } + : { left, top }, + ); + }, [anchorRect, items.length, ownerWindow]); + + useLayoutEffect(() => { + if (!focusFirst || !pos) return; + const firstItem = subMenuRef.current + ? getMenuItems(subMenuRef.current)[0] + : null; + firstItem?.focus(); + }, [focusFirst, pos]); + + const { height: viewportHeight } = useViewportSize(); + const { needsScroll, maxHeight } = getListScrollMetrics( + items.length, + viewportHeight, + items.filter(isSeparator).length, + ); + const hasCheckColumn = items.some( + (it) => !isSeparator(it) && typeof it.checked === 'boolean', + ); + + const { scrollContainerRef: subLenisRef } = useLenis({ + wheelMultiplier: 0.7, + }); + + // 부모가 닫히기 시작하면 서브메뉴는 잔상 없이 즉시 걷는다. 남겨두면 body 포털이라 + // 부모 페이드와 무관하게 선명히 떠 있고 Escape 소유권도 계속 물고 있다 + if (!anchorRect || parentMotionState === 'closing') return null; + + // body 포털 필수 — 부모 팝업의 backdrop-filter가 fixed의 containing block이 되어 + // 뷰포트 좌표가 어긋나는 것 방지. 호버 유지는 onMouseEnter/Leave 콜백으로 연결 + return createPortal( +
{ + (subMenuRef as React.MutableRefObject).current = + node; + registerSurface?.(node); + }} + data-dmn-popup-submenu="true" + data-dmn-popup-layer="true" + role="menu" + aria-label={ariaLabel} + tabIndex={-1} + onKeyDown={(event) => { + if (event.key === 'ArrowLeft') { + event.preventDefault(); + flushSync(onRequestClose); + parentItemRef.current?.focus(); + return; + } + handleMenuNavigation(event); + }} + onMouseEnter={onMouseEnter} + onMouseLeave={onMouseLeave} + className={`fixed z-[var(--z-chrome-submenu)] ${CANVAS_POPUP_CHROME_CLASS} rounded-surface p-[5px] flex flex-col${ + instant ? '' : ' tooltip-fade-in' + }`} + style={{ + left: pos?.left, + right: pos?.right, + top: pos?.top ?? 0, + visibility: pos ? undefined : 'hidden', + ...(maxHeight ? { maxHeight } : {}), + }} + > + {/* 스크롤러를 크롬 박스 안쪽으로 한 겹 내린다 - 마스크가 표면과 그림자까지 + 갉아먹지 않게. role=none으로 menu와 menuitem의 관계는 그대로 통과시킨다 */} +
trackPointer(siblingActiveRef, event)} + className={`flex flex-col gap-[4px]${ + needsScroll ? ' listpopup-scroll dmn-scroll-fade' : '' + }`} + style={ + maxHeight ? { overflowY: 'auto', overflowX: 'hidden' } : undefined + } + > + {items.map((it) => + isSeparator(it) ? ( + + ) : ( + + ), + )} +
+
, + ownerDocument.body, + ); +}; + +// 부모 항목에서 서브메뉴로 갈 때 커서는 대각선을 그리며 중간 형제 항목을 스친다. +// 닫고 다시 여는 대신, 서브메뉴로 향하는 동안에는 형제 전환을 아예 막는다 - +// 닫힘 이벤트가 없으면 표면이 사라질 일도 없다 +export interface SiblingActive { + id: string | null; + close: (() => void) | null; + /** 열려 있는 서브메뉴 표면의 현재 사각형 */ + getRect: (() => DOMRect | null) | null; + /** 예약된 닫힘을 인계 시점 뒤로 미룬다 */ + holdOpen: (() => void) | null; + /** 직전과 현재 커서 위치 - 진행 방향 판정용 */ + from: { x: number; y: number } | null; + to: { x: number; y: number } | null; +} + +const HOVER_OPEN_MS = 150; +const HOVER_CLOSE_MS = 200; +// 길목에 머물 때 형제가 넘겨받기까지. 이 값이 곧 서브메뉴까지 갈 수 있는 예산이라 +// 짧게 잡으면 느린 대각선 이동을 형제가 가로챈다 +const INTENT_GRACE_MS = 300; +// 길목으로 판정되면 기존 표면의 닫힘을 인계 뒤로 미룬다. 인계가 먼저 끝나야 +// 닫기와 열기가 한 번의 갱신으로 묶여 표면이 끊기지 않는다 +const INTENT_HOLD_MS = INTENT_GRACE_MS + 100; +const INTENT_PADDING_PX = 12; + +export const emptySiblingActive = (): SiblingActive => ({ + id: null, + close: null, + getRect: null, + holdOpen: null, + from: null, + to: null, +}); + +/** 이 행이 쥐고 있던 활성 표시를 놓는다. 커서 자취는 팝업 세션 것이라 남긴다 */ +const releaseSibling = ( + ref: React.RefObject | undefined, + id: string, +) => { + if (ref?.current.id !== id) return; + ref.current = { + ...ref.current, + id: null, + close: null, + getRect: null, + holdOpen: null, + }; +}; + +/** 표면 위 커서 자취를 공유 ref에 남긴다 - 형제 전환 판정의 입력 */ +export const trackPointer = ( + ref: React.RefObject, + event: React.MouseEvent, +) => { + const next = { x: event.clientX, y: event.clientY }; + const previous = ref.current.to; + if (previous && previous.x === next.x && previous.y === next.y) return; + ref.current.from = previous; + ref.current.to = next; +}; + +/** 커서 진행 방향의 반직선이 서브메뉴 사각형을 지나는가 (slab 판정) */ +const isHeadingTo = ( + from: { x: number; y: number }, + to: { x: number; y: number }, + rect: DOMRect, +): boolean => { + const dx = to.x - from.x; + const dy = to.y - from.y; + if (dx === 0 && dy === 0) return false; + + const left = rect.left - INTENT_PADDING_PX; + const right = rect.right + INTENT_PADDING_PX; + const top = rect.top - INTENT_PADDING_PX; + const bottom = rect.bottom + INTENT_PADDING_PX; + + let near = 0; + let far = Number.POSITIVE_INFINITY; + + const slab = (origin: number, delta: number, min: number, max: number) => { + if (delta === 0) return origin >= min && origin <= max; + const t1 = (min - origin) / delta; + const t2 = (max - origin) / delta; + near = Math.max(near, Math.min(t1, t2)); + far = Math.min(far, Math.max(t1, t2)); + return true; + }; + + if (!slab(to.x, dx, left, right)) return false; + if (!slab(to.y, dy, top, bottom)) return false; + return near <= far; +}; + +/** 묶음 사이 선. 표면 가장자리(4px)에서 한 번 더 물러나 라벨 시작점보다 살짝 왼쪽에서 + * 시작한다 - 행 하이라이트를 그대로 따라가면 가장자리까지 닿아 크롬처럼 보인다. + * 위아래 여백은 컨테이너 gap이 맡는다 */ +export const SeparatorRow = () => ( +
+); + +/** 개별 메뉴 항목 행 */ +export const MenuItemRow = ({ + item, + onSelect, + onCloseAll, + onMenuTab, + siblingActiveRef, + hasCheckColumn = false, +}: { + item: ListMenuItem; + onSelect?: (id: string) => void; + onCloseAll: () => void; + onMenuTab: (event: KeyboardEvent) => void; + /** 형제 항목 중 활성 서브메뉴와 커서 자취를 공유하는 ref */ + siblingActiveRef?: React.RefObject; + /** 목록에 체크 가능한 항목이 있을 때만 좌측 체크 컬럼 렌더 */ + hasCheckColumn?: boolean; +}) => { + const [subMenuOpen, setSubMenuOpen] = useState(false); + // 형제에서 넘어온 열림인지 - 진입 모션 재생 여부를 가른다 + const [instantOpen, setInstantOpen] = useState(false); + const rowRef = useRef(null); + // 유예 시간이 끝났을 때 아직 이 행 위에 있는지 + const hoveredRef = useRef(false); + const subSurfaceRef = useRef(null); + const hoverTimerRef = useRef | null>(null); + const [rowRect, setRowRect] = useState(null); + const [focusSubMenuOnOpen, setFocusSubMenuOnOpen] = useState(false); + + const hasChildren = item.children && item.children.length > 0; + + const scheduleClose = (delay: number) => { + if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current); + hoverTimerRef.current = setTimeout(() => { + setSubMenuOpen(false); + releaseSibling(siblingActiveRef, item.id); + }, delay); + }; + + const showSubMenu = (focusFirst: boolean, instant = false) => { + // 호버는 button이 아니라 감싼 div가 받으므로 disabled가 막아주지 않는다 + if (item.disabled || !hasChildren || !rowRef.current) return; + setRowRect(rowRef.current.getBoundingClientRect()); + setFocusSubMenuOnOpen(focusFirst); + setInstantOpen(instant); + setSubMenuOpen(true); + if (siblingActiveRef) { + siblingActiveRef.current = { + ...siblingActiveRef.current, + id: item.id, + close: () => setSubMenuOpen(false), + getRect: () => subSurfaceRef.current?.getBoundingClientRect() ?? null, + holdOpen: () => scheduleClose(INTENT_HOLD_MS), + }; + } + }; + + const handleMouseEnter = (event: React.MouseEvent) => { + hoveredRef.current = true; + if (!hasChildren) return; + if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current); + // 행에 막 들어온 좌표가 가장 새로운 자취다. 이걸 빼면 직전 행 안에서 찍힌 + // 두 점으로 방향을 재게 되어 실제로 가로지른 방향과 어긋난다 + if (siblingActiveRef) trackPointer(siblingActiveRef, event); + const active = siblingActiveRef?.current; + const hasActiveSibling = active?.id != null; + const delay = hasActiveSibling ? 0 : HOVER_OPEN_MS; + + // 서브메뉴로 가는 길목이면 형제 전환을 미룬다. 커서가 그 자리에 머무르면 + // 유예 시간 뒤에 이 행이 넘겨받는다 + if (hasActiveSibling && active?.id !== item.id) { + const rect = active?.getRect?.(); + const from = active?.from; + const to = active?.to; + if (rect && from && to && isHeadingTo(from, to, rect)) { + // 형제 행을 지나는 동안 기존 표면이 닫히면 의도 판정이 무의미해진다 + active?.holdOpen?.(); + hoverTimerRef.current = setTimeout(() => { + if (!hoveredRef.current) return; + // 형제 표면이 아직 떠 있을 때만 모션을 건너뛴다. 이미 닫혔다면 + // 이어받는 게 아니라 새로 여는 것이라 페이드가 있어야 한다 + const handover = siblingActiveRef?.current.id != null; + siblingActiveRef?.current.close?.(); + showSubMenu(false, handover); + }, INTENT_GRACE_MS); + return; + } + active?.close?.(); + } + + // 형제가 열려 있었다면 표면이 방금까지 떠 있었다 - 페이드를 다시 재생하면 + // 유리 표면이 사라졌다 돌아오는 것처럼 보인다 + hoverTimerRef.current = setTimeout( + () => showSubMenu(false, hasActiveSibling), + delay, + ); + }; + + const handleMouseLeave = () => { + hoveredRef.current = false; + scheduleClose(HOVER_CLOSE_MS); + }; + + useEffect(() => { + return () => { + if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current); + // 열린 서브메뉴를 쥔 행이 사라지면 공유 ref에 분리된 노드의 getRect가 남아 + // 다음 호버의 형제 전환 판정이 0 사각형으로 오판한다 + releaseSibling(siblingActiveRef, item.id); + }; + }, [item.id, siblingActiveRef]); + + // 부모 메뉴를 스크롤하면 앵커 행이 움직이는데 서브메뉴는 열 때 잰 좌표에 고정된다. + // 프레임마다 재측정하면 글래스 표면에서 강제 레이아웃+재필터라, 스크롤 시 닫는다 + // (FloatingPopup closeOnScroll과 같은 관용구). 서브메뉴 자체 스크롤은 행을 포함하지 + // 않으므로 무시된다 + useEffect(() => { + if (!subMenuOpen) return; + const row = rowRef.current; + const doc = row?.ownerDocument; + if (!row || !doc) return; + const onScroll = (event: Event) => { + const target = event.target as Node | null; + if (!target || !target.contains(row)) return; + if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current); + setSubMenuOpen(false); + releaseSibling(siblingActiveRef, item.id); + }; + doc.addEventListener('scroll', onScroll, true); + return () => doc.removeEventListener('scroll', onScroll, true); + }, [subMenuOpen, item.id, siblingActiveRef]); + + const hasCheck = typeof item.checked === 'boolean'; + const constrainLabel = item.isPlugin === true; + + const handleSelect = () => { + if (item.disabled) return; + if (hasChildren) { + showSubMenu(false); + return; + } + onSelect?.(item.id); + onCloseAll(); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (!hasChildren || !['ArrowRight', 'Enter', ' '].includes(event.key)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + showSubMenu(true); + }; + + return ( +
+ + + {/* 서브메뉴 */} + {hasChildren && subMenuOpen && ( + { + subSurfaceRef.current = node; + }} + items={item.children!} + onSelect={onSelect} + onCloseAll={onCloseAll} + onMenuTab={onMenuTab} + anchorRect={rowRect} + parentItemRef={rowRef} + focusFirst={focusSubMenuOnOpen} + onMouseEnter={() => { + // 포털로 DOM 중첩이 끊기므로 서브메뉴 진입 시 닫힘 타이머 취소 + if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current); + }} + onMouseLeave={handleMouseLeave} + onRequestClose={() => { + if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current); + setSubMenuOpen(false); + releaseSibling(siblingActiveRef, item.id); + }} + /> + )} +
+ ); +}; From b1a99de52996431825b73288d852ebb768274060 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 22:37:35 +0900 Subject: [PATCH 233/245] =?UTF-8?q?test:=20=EC=8A=A4=EB=A7=88=ED=8A=B8=20?= =?UTF-8?q?=EA=B0=80=EC=9D=B4=EB=93=9C=20=EC=A0=95=EB=A0=AC=20=EC=95=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=EC=A6=98=20=EA=B3=84=EC=95=BD=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/utils/grid/smartGuides.test.ts | 323 ++++++++++++++++++++ 1 file changed, 323 insertions(+) diff --git a/src/renderer/utils/grid/smartGuides.test.ts b/src/renderer/utils/grid/smartGuides.test.ts index 3a9deb117..6448a069b 100644 --- a/src/renderer/utils/grid/smartGuides.test.ts +++ b/src/renderer/utils/grid/smartGuides.test.ts @@ -3,9 +3,19 @@ import { CANVAS_CENTER_X, CANVAS_CENTER_Y, calculateBounds, + calculateGroupBounds, + calculateSizeSnap, calculateSnapPoints, } from './smartGuides'; +const bounds = ( + x: number, + y: number, + width: number, + height: number, + id: string, +) => calculateBounds(x, y, width, height, id); + describe('calculateSnapPoints 캔버스 중앙 그리드 스냅', () => { it('홀수 폭의 중앙 스냅 결과를 그리드 배수로 반올림한다', () => { const draggedBounds = calculateBounds(322, 0, 255, 60, 'dragged'); @@ -85,3 +95,316 @@ describe('calculateSnapPoints 캔버스 중앙 그리드 스냅', () => { }); }); }); + +describe('calculateSnapPoints 정렬 우선순위와 가이드 순서', () => { + it.each([ + { + name: '캔버스와 요소가 같은 거리면 먼저 검사한 캔버스가 유지된다', + dragged: bounds(436, 40, 20, 20, 'dragged'), + others: [bounds(432, 300, 20, 20, 'element')], + threshold: 4, + expectedX: 440, + }, + { + name: '요소가 캔버스보다 가까우면 요소 정렬이 교체한다', + dragged: bounds(434, 40, 20, 20, 'dragged'), + others: [bounds(432, 300, 20, 20, 'element')], + threshold: 4, + expectedX: 432, + }, + { + name: '요소끼리 같은 거리면 배열의 첫 요소가 유지된다', + dragged: bounds(100, 40, 20, 20, 'dragged'), + others: [ + bounds(96, 300, 20, 20, 'first'), + bounds(104, 400, 20, 20, 'second'), + ], + threshold: 4, + expectedX: 96, + }, + { + name: '동률 요소의 배열 순서를 뒤집으면 선택도 뒤집힌다', + dragged: bounds(100, 40, 20, 20, 'dragged'), + others: [ + bounds(104, 400, 20, 20, 'second'), + bounds(96, 300, 20, 20, 'first'), + ], + threshold: 4, + expectedX: 104, + }, + ])('$name', ({ dragged, others, threshold, expectedX }) => { + const result = calculateSnapPoints(dragged, others, threshold, { + disableSpacing: true, + }); + + expect(result.snappedX).toBe(expectedX); + expect(result.didSnapX).toBe(true); + }); + + it.each([ + { distance: 8, threshold: 8, expectedX: 108, didSnapX: true }, + { distance: 9, threshold: 8, expectedX: 100, didSnapX: false }, + ])( + '정렬 거리가 $distance일 때 threshold $threshold 경계를 포함한다', + ({ distance, threshold, expectedX, didSnapX }) => { + const dragged = bounds(100, 40, 20, 20, 'dragged'); + const other = bounds(100 + distance, 300, 20, 20, 'other'); + + const result = calculateSnapPoints(dragged, [other], threshold, { + disableSpacing: true, + }); + + expect(result.snappedX).toBe(expectedX); + expect(result.didSnapX).toBe(didSnapX); + }, + ); + + it('중복 가이드는 type·position의 첫 항목만 원래 축 순서로 유지한다', () => { + const dragged = bounds(100, 100, 20, 20, 'dragged'); + const others = [ + bounds(100, 100, 20, 20, 'first'), + bounds(100, 100, 20, 20, 'duplicate'), + ]; + + const result = calculateSnapPoints(dragged, others, undefined, { + disableSpacing: true, + }); + + expect(result.guides).toEqual([ + { type: 'vertical', position: 100, alignType: 'left' }, + { type: 'vertical', position: 120, alignType: 'right' }, + { type: 'vertical', position: 110, alignType: 'center' }, + { type: 'horizontal', position: 100, alignType: 'top' }, + { type: 'horizontal', position: 120, alignType: 'bottom' }, + { type: 'horizontal', position: 110, alignType: 'middle' }, + ]); + }); +}); + +describe('calculateSnapPoints 간격 스냅 표', () => { + const cases = [ + { + name: '왼쪽 인접 요소와 기존 수평 간격', + dragged: bounds(42, 0, 10, 10, 'dragged'), + others: [ + bounds(20, 0, 10, 10, 'left'), + bounds(100, 0, 10, 10, 'gap-a'), + bounds(120, 0, 10, 10, 'gap-b'), + ], + expectedX: 40, + expectedY: 0, + axis: 'x', + expectedPairs: [ + ['gap-a', 'gap-b'], + ['left', 'dragged'], + ], + }, + { + name: '오른쪽 인접 요소와 기존 수평 간격', + dragged: bounds(58, 0, 10, 10, 'dragged'), + others: [ + bounds(80, 0, 10, 10, 'right'), + bounds(120, 0, 10, 10, 'gap-a'), + bounds(140, 0, 10, 10, 'gap-b'), + ], + expectedX: 60, + expectedY: 0, + axis: 'x', + expectedPairs: [ + ['gap-a', 'gap-b'], + ['dragged', 'right'], + ], + }, + { + name: '위쪽 인접 요소와 기존 수직 간격', + dragged: bounds(0, 42, 10, 10, 'dragged'), + others: [ + bounds(0, 20, 10, 10, 'above'), + bounds(0, 100, 10, 10, 'gap-a'), + bounds(0, 120, 10, 10, 'gap-b'), + ], + expectedX: 0, + expectedY: 40, + axis: 'y', + expectedPairs: [ + ['gap-a', 'gap-b'], + ['above', 'dragged'], + ], + }, + { + name: '아래쪽 인접 요소와 기존 수직 간격', + dragged: bounds(0, 58, 10, 10, 'dragged'), + others: [ + bounds(0, 80, 10, 10, 'below'), + bounds(0, 120, 10, 10, 'gap-a'), + bounds(0, 140, 10, 10, 'gap-b'), + ], + expectedX: 0, + expectedY: 60, + axis: 'y', + expectedPairs: [ + ['gap-a', 'gap-b'], + ['dragged', 'below'], + ], + }, + { + name: '좌우 두 요소 사이의 3요소 중점', + dragged: bounds(16, 0, 10, 10, 'dragged'), + others: [bounds(0, 0, 10, 10, 'left'), bounds(40, 0, 10, 10, 'right')], + expectedX: 20, + expectedY: 0, + axis: 'x', + expectedPairs: [ + ['left', 'dragged'], + ['dragged', 'right'], + ], + }, + { + name: '위아래 두 요소 사이의 3요소 중점', + dragged: bounds(0, 16, 10, 10, 'dragged'), + others: [bounds(0, 0, 10, 10, 'above'), bounds(0, 40, 10, 10, 'below')], + expectedX: 0, + expectedY: 20, + axis: 'y', + expectedPairs: [ + ['above', 'dragged'], + ['dragged', 'below'], + ], + }, + ] as const; + + it.each(cases)( + '$name', + ({ dragged, others, expectedX, expectedY, axis, expectedPairs }) => { + const result = calculateSnapPoints(dragged, [...others], 0); + + expect(result.snappedX).toBe(expectedX); + expect(result.snappedY).toBe(expectedY); + expect( + axis === 'x' ? result.didSpacingSnapX : result.didSpacingSnapY, + ).toBe(true); + expect( + result.spacingGuides.map((guide) => [ + guide.fromElementId, + guide.toElementId, + ]), + ).toEqual(expectedPairs); + }, + ); + + it('disableSpacing은 정렬 결과만 반환하고 간격 계산을 생략한다', () => { + const dragged = bounds(42, 0, 10, 10, 'dragged'); + const others = [ + bounds(20, 0, 10, 10, 'left'), + bounds(100, 0, 10, 10, 'gap-a'), + bounds(120, 0, 10, 10, 'gap-b'), + ]; + + const result = calculateSnapPoints(dragged, others, 0, { + disableSpacing: true, + }); + + expect(result.snappedX).toBe(42); + expect(result.spacingGuides).toEqual([]); + expect(result.didSpacingSnapX).toBe(false); + expect(result.didSpacingSnapY).toBe(false); + }); +}); + +describe('빈 입력·자기 제외·크기 스냅 우선순위', () => { + it('빈 그룹은 null이고 단일 그룹은 입력 bounds 객체를 그대로 반환한다', () => { + const only = bounds(10, 20, 30, 40, 'only'); + + expect(calculateGroupBounds([])).toBeNull(); + expect(calculateGroupBounds([only])).toBe(only); + }); + + it.each([ + { name: '빈 요소', others: [] }, + { + name: '자기 자신만 포함', + others: [bounds(100, 40, 20, 20, 'dragged')], + }, + ])('$name이면 위치 스냅을 만들지 않는다', ({ others }) => { + const dragged = bounds(100, 40, 20, 20, 'dragged'); + const result = calculateSnapPoints(dragged, others, undefined, { + disableSpacing: true, + }); + + expect(result).toMatchObject({ + snappedX: 100, + snappedY: 40, + guides: [], + spacingGuides: [], + didSnapX: false, + didSnapY: false, + }); + }); + + it('width와 height는 각각 가장 가까운 요소를 고르고 width guide가 먼저 온다', () => { + const widthCandidate = bounds(0, 0, 102, 104, 'width'); + const heightCandidate = bounds(200, 10, 104, 100, 'height'); + + const result = calculateSizeSnap(100, 101, [ + heightCandidate, + widthCandidate, + ]); + + expect(result.snappedWidth).toBe(102); + expect(result.snappedHeight).toBe(100); + expect( + result.sizeMatchGuides.map((guide) => guide.matchedElementId), + ).toEqual(['width', 'height']); + }); + + it.each([ + { + name: '동률이면 첫 요소를 유지한다', + others: [bounds(0, 0, 96, 80, 'first'), bounds(0, 0, 104, 90, 'second')], + expectedWidth: 96, + expectedId: 'first', + }, + { + name: '동률 배열을 뒤집으면 선택도 뒤집힌다', + others: [bounds(0, 0, 104, 90, 'second'), bounds(0, 0, 96, 80, 'first')], + expectedWidth: 104, + expectedId: 'second', + }, + ])('$name', ({ others, expectedWidth, expectedId }) => { + const result = calculateSizeSnap(100, 50, others); + + expect(result.snappedWidth).toBe(expectedWidth); + expect(result.sizeMatchGuides).toHaveLength(1); + expect(result.sizeMatchGuides[0]?.matchedElementId).toBe(expectedId); + }); + + it.each([ + { delta: 4, didSnap: true, expected: 104 }, + { delta: 5, didSnap: false, expected: 100 }, + ])( + '크기 차이 $delta는 임계값 경계를 따른다', + ({ delta, didSnap, expected }) => { + const other = bounds(0, 0, 100 + delta, 100 + delta, 'other'); + const result = calculateSizeSnap(100, 100, [other]); + + expect(result.didSnapWidth).toBe(didSnap); + expect(result.didSnapHeight).toBe(didSnap); + expect(result.snappedWidth).toBe(expected); + expect(result.snappedHeight).toBe(expected); + }, + ); + + it('빈 요소와 자기 자신은 크기 스냅 후보에서 제외한다', () => { + const self = bounds(0, 0, 102, 102, 'dragged'); + + for (const others of [[], [self]]) { + expect(calculateSizeSnap(100, 100, others, 'dragged')).toEqual({ + snappedWidth: 100, + snappedHeight: 100, + sizeMatchGuides: [], + didSnapWidth: false, + didSnapHeight: false, + }); + } + }); +}); From d6da9086be4f44eb9db3e2ce1e3f48c4a771dfc7 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 22:37:35 +0900 Subject: [PATCH 234/245] =?UTF-8?q?refactor:=20=EC=8A=A4=EB=A7=88=ED=8A=B8?= =?UTF-8?q?=20=EA=B0=80=EC=9D=B4=EB=93=9C=20=EC=95=8C=EA=B3=A0=EB=A6=AC?= =?UTF-8?q?=EC=A6=98=20=EA=B2=BD=EA=B3=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/utils/grid/smartGuides.ts | 1104 +---------------- .../utils/grid/smartGuides/alignment.ts | 356 ++++++ src/renderer/utils/grid/smartGuides/bounds.ts | 120 ++ src/renderer/utils/grid/smartGuides/size.ts | 95 ++ .../utils/grid/smartGuides/spacing.ts | 428 +++++++ src/renderer/utils/grid/smartGuides/types.ts | 89 ++ 6 files changed, 1109 insertions(+), 1083 deletions(-) create mode 100644 src/renderer/utils/grid/smartGuides/alignment.ts create mode 100644 src/renderer/utils/grid/smartGuides/bounds.ts create mode 100644 src/renderer/utils/grid/smartGuides/size.ts create mode 100644 src/renderer/utils/grid/smartGuides/spacing.ts create mode 100644 src/renderer/utils/grid/smartGuides/types.ts diff --git a/src/renderer/utils/grid/smartGuides.ts b/src/renderer/utils/grid/smartGuides.ts index a11f24454..57ec26583 100644 --- a/src/renderer/utils/grid/smartGuides.ts +++ b/src/renderer/utils/grid/smartGuides.ts @@ -1,1084 +1,22 @@ /** - * Smart Guides (스마트 가이드) 유틸리티 - * 드래그 중 다른 요소와 정렬될 때 가이드라인을 표시하고 스냅하는 기능 - * - 정렬 가이드 (Alignment Guides) - * - 간격 가이드 (Spacing Guides) - * - 크기 일치 가이드 (Size Matching Guides) - */ - -import { roundToGrid } from './gridSnap'; - -export interface ElementBounds { - id: string; - left: number; - top: number; - right: number; - bottom: number; - centerX: number; - centerY: number; - width: number; - height: number; -} - -export interface GuideLine { - type: 'vertical' | 'horizontal'; - position: number; // 가이드라인의 x 또는 y 위치 - alignType: 'left' | 'center' | 'right' | 'top' | 'middle' | 'bottom'; -} - -/** - * 간격 가이드 인터페이스 - * 요소 사이의 간격을 시각화 - */ -export interface SpacingGuide { - type: 'spacing'; - direction: 'horizontal' | 'vertical'; - value: number; // 간격 값 (px) - // 간격 표시 위치 - startPos: number; // 간격 시작 위치 (x 또는 y) - endPos: number; // 간격 끝 위치 (x 또는 y) - crossAxisPos: number; // 교차 축 위치 (라벨 표시용) - // 관련 요소 - fromElementId: string; - toElementId: string; - // 이 간격이 다른 간격과 일치하여 스냅됐는지 - isMatched: boolean; -} - -/** - * 크기 일치 가이드 인터페이스 - * 리사이즈 시 다른 요소와 동일한 크기로 스냅 - */ -export interface SizeMatchGuide { - type: 'size-match'; - dimension: 'width' | 'height'; - value: number; // 일치하는 크기 값 - position: { x: number; y: number }; // 표시 위치 - matchedElementId: string; - // 일치하는 요소의 bounds (테두리 표시용) - matchedElementBounds: { - left: number; - top: number; - width: number; - height: number; - }; -} - -export interface SnapResult { - snappedX: number; - snappedY: number; - guides: GuideLine[]; - spacingGuides: SpacingGuide[]; - didSnapX: boolean; - didSnapY: boolean; - // 간격 스냅 여부 - didSpacingSnapX: boolean; - didSpacingSnapY: boolean; -} - -export interface SizeSnapResult { - snappedWidth: number; - snappedHeight: number; - sizeMatchGuides: SizeMatchGuide[]; - didSnapWidth: boolean; - didSnapHeight: boolean; -} - -// 스냅 거리 임계값 (픽셀) -const SNAP_THRESHOLD = 8; -// 간격 스냅 임계값 (픽셀) -const SPACING_SNAP_THRESHOLD = 5; -// 크기 일치 스냅 임계값 (픽셀) -const SIZE_MATCH_THRESHOLD = 4; - -/** - * 요소의 bounds 정보 계산 - */ -export function calculateBounds( - x: number, - y: number, - width: number, - height: number, - id: string = '', -): ElementBounds { - return { - id, - left: x, - top: y, - right: x + width, - bottom: y + height, - centerX: x + width / 2, - centerY: y + height / 2, - width, - height, - }; -} - -/** - * 여러 요소들의 전체 바운딩 박스 계산 (그룹 선택용) - * @param elements 요소들의 bounds 배열 - * @returns 전체 요소를 감싸는 바운딩 박스 - */ -export function calculateGroupBounds( - elements: ElementBounds[], -): ElementBounds | null { - if (elements.length === 0) return null; - - if (elements.length === 1) return elements[0]; - - const left = Math.min(...elements.map((el) => el.left)); - const top = Math.min(...elements.map((el) => el.top)); - const right = Math.max(...elements.map((el) => el.right)); - const bottom = Math.max(...elements.map((el) => el.bottom)); - const width = right - left; - const height = bottom - top; - - return { - id: 'group', - left, - top, - right, - bottom, - centerX: left + width / 2, - centerY: top + height / 2, - width, - height, - }; -} - -/** - * 두 값이 스냅 거리 내에 있는지 확인 - */ -function _isWithinThreshold( - value1: number, - value2: number, - threshold: number = SNAP_THRESHOLD, -): boolean { - return Math.abs(value1 - value2) <= threshold; -} - -// 캔버스 중앙 좌표 (그리드 렌더링 영역 900x396 기준) -export const CANVAS_CENTER_X = 450; -export const CANVAS_CENTER_Y = 195; - -/** - * calculateSnapPoints 옵션 인터페이스 - */ -export interface SnapPointsOptions { - /** 그룹 선택 시 전체 그룹의 bounds (캔버스 중앙 스냅에 사용) */ - groupBounds?: ElementBounds | null; - - /** 간격(Spacing) 가이드/스냅 계산을 비활성화 */ - disableSpacing?: boolean; - - /** 캔버스 중앙 스냅 결과를 맞출 그리드 크기, 0이면 반올림 없음 */ - gridSnapSize?: number; -} - -/** - * 드래그 중인 요소와 다른 요소들 사이의 스냅 포인트 계산 - * 캔버스 중앙 기준 스냅도 포함 - * @param draggedBounds 드래그 중인 요소의 bounds - * @param otherElements 다른 요소들의 bounds - * @param threshold 스냅 임계값 (기본값: 8px) - * @param options 추가 옵션 (groupBounds 등) - */ -export function calculateSnapPoints( - draggedBounds: ElementBounds, - otherElements: ElementBounds[], - threshold: number = SNAP_THRESHOLD, - options?: SnapPointsOptions, -): SnapResult { - const guides: GuideLine[] = []; - let snappedX = draggedBounds.left; - let snappedY = draggedBounds.top; - let didSnapX = false; - let didSnapY = false; - let didSnapCanvasCenterX = false; - let didSnapCanvasCenterY = false; - - // 가장 가까운 스냅 포인트 추적 - let closestXDiff = Infinity; - let closestYDiff = Infinity; - - // === 캔버스 중앙 기준 스냅 === - // 그룹 선택 시에는 그룹 전체의 중심을 기준으로, 아니면 개별 요소의 중심을 기준으로 - const centerBounds = options?.groupBounds || draggedBounds; - - // 요소(또는 그룹)의 중심이 캔버스 가로 중앙에 정렬 - let diff = Math.abs(centerBounds.centerX - CANVAS_CENTER_X); - if (diff <= threshold && diff < closestXDiff) { - closestXDiff = diff; - // 그룹의 중심이 캔버스 중앙에 오도록 드래그 요소의 위치 계산 - const offsetX = draggedBounds.centerX - centerBounds.centerX; - snappedX = CANVAS_CENTER_X - draggedBounds.width / 2 + offsetX; - snappedX = roundToGrid(snappedX, options?.gridSnapSize ?? 0); - didSnapX = true; - didSnapCanvasCenterX = true; - } - - // 요소(또는 그룹)의 중심이 캔버스 세로 중앙에 정렬 - diff = Math.abs(centerBounds.centerY - CANVAS_CENTER_Y); - if (diff <= threshold && diff < closestYDiff) { - closestYDiff = diff; - // 그룹의 중심이 캔버스 중앙에 오도록 드래그 요소의 위치 계산 - const offsetY = draggedBounds.centerY - centerBounds.centerY; - snappedY = CANVAS_CENTER_Y - draggedBounds.height / 2 + offsetY; - snappedY = roundToGrid(snappedY, options?.gridSnapSize ?? 0); - didSnapY = true; - didSnapCanvasCenterY = true; - } - - for (const other of otherElements) { - // 자기 자신은 스킵 - if (other.id === draggedBounds.id) continue; - - // === X축 (수직 가이드라인) 스냅 체크 === - - // 왼쪽 가장자리 정렬 (left-to-left) - let diff = Math.abs(draggedBounds.left - other.left); - if (diff <= threshold && diff < closestXDiff) { - closestXDiff = diff; - snappedX = other.left; - didSnapX = true; - didSnapCanvasCenterX = false; - } - - // 오른쪽 가장자리 정렬 (right-to-right) - diff = Math.abs(draggedBounds.right - other.right); - if (diff <= threshold && diff < closestXDiff) { - closestXDiff = diff; - snappedX = other.right - draggedBounds.width; - didSnapX = true; - didSnapCanvasCenterX = false; - } - - // 왼쪽-오른쪽 정렬 (left-to-right) - 임시 비활성화 - // diff = Math.abs(draggedBounds.left - other.right); - // if (diff <= threshold && diff < closestXDiff) { - // closestXDiff = diff; - // snappedX = other.right; - // didSnapX = true; - // } - - // 오른쪽-왼쪽 정렬 (right-to-left) - 임시 비활성화 - // diff = Math.abs(draggedBounds.right - other.left); - // if (diff <= threshold && diff < closestXDiff) { - // closestXDiff = diff; - // snappedX = other.left - draggedBounds.width; - // didSnapX = true; - // } - - // 중앙 정렬 (center-to-center X) - diff = Math.abs(draggedBounds.centerX - other.centerX); - if (diff <= threshold && diff < closestXDiff) { - closestXDiff = diff; - snappedX = other.centerX - draggedBounds.width / 2; - didSnapX = true; - didSnapCanvasCenterX = false; - } - - // === Y축 (수평 가이드라인) 스냅 체크 === - - // 상단 정렬 (top-to-top) - diff = Math.abs(draggedBounds.top - other.top); - if (diff <= threshold && diff < closestYDiff) { - closestYDiff = diff; - snappedY = other.top; - didSnapY = true; - didSnapCanvasCenterY = false; - } - - // 하단 정렬 (bottom-to-bottom) - diff = Math.abs(draggedBounds.bottom - other.bottom); - if (diff <= threshold && diff < closestYDiff) { - closestYDiff = diff; - snappedY = other.bottom - draggedBounds.height; - didSnapY = true; - didSnapCanvasCenterY = false; - } - - // 상단-하단 정렬 (top-to-bottom) - 임시 비활성화 - // diff = Math.abs(draggedBounds.top - other.bottom); - // if (diff <= threshold && diff < closestYDiff) { - // closestYDiff = diff; - // snappedY = other.bottom; - // didSnapY = true; - // } - - // 하단-상단 정렬 (bottom-to-top) - 임시 비활성화 - // diff = Math.abs(draggedBounds.bottom - other.top); - // if (diff <= threshold && diff < closestYDiff) { - // closestYDiff = diff; - // snappedY = other.top - draggedBounds.height; - // didSnapY = true; - // } - - // 중앙 정렬 (center-to-center Y) - diff = Math.abs(draggedBounds.centerY - other.centerY); - if (diff <= threshold && diff < closestYDiff) { - closestYDiff = diff; - snappedY = other.centerY - draggedBounds.height / 2; - didSnapY = true; - didSnapCanvasCenterY = false; - } - } - - // 스냅된 위치를 기준으로 가이드라인 생성 - const snappedBounds = calculateBounds( - snappedX, - snappedY, - draggedBounds.width, - draggedBounds.height, - draggedBounds.id, - ); - - for (const other of otherElements) { - if (other.id === draggedBounds.id) continue; - - // X축 가이드라인 (수직선) - if (didSnapX) { - // 왼쪽 가장자리 정렬 - if (Math.abs(snappedBounds.left - other.left) < 1) { - guides.push({ - type: 'vertical', - position: other.left, - alignType: 'left', - }); - } - // 오른쪽 가장자리 정렬 - if (Math.abs(snappedBounds.right - other.right) < 1) { - guides.push({ - type: 'vertical', - position: other.right, - alignType: 'right', - }); - } - // 왼쪽-오른쪽 정렬 - 임시 비활성화 - // if (Math.abs(snappedBounds.left - other.right) < 1) { - // guides.push({ - // type: "vertical", - // position: other.right, - // alignType: "left", - // }); - // } - // 오른쪽-왼쪽 정렬 - 임시 비활성화 - // if (Math.abs(snappedBounds.right - other.left) < 1) { - // guides.push({ - // type: "vertical", - // position: other.left, - // alignType: "right", - // }); - // } - // 중앙 정렬 - if (Math.abs(snappedBounds.centerX - other.centerX) < 1) { - guides.push({ - type: 'vertical', - position: other.centerX, - alignType: 'center', - }); - } - } - - // Y축 가이드라인 (수평선) - if (didSnapY) { - // 상단 정렬 - if (Math.abs(snappedBounds.top - other.top) < 1) { - guides.push({ - type: 'horizontal', - position: other.top, - alignType: 'top', - }); - } - // 하단 정렬 - if (Math.abs(snappedBounds.bottom - other.bottom) < 1) { - guides.push({ - type: 'horizontal', - position: other.bottom, - alignType: 'bottom', - }); - } - // 상단-하단 정렬 - 임시 비활성화 - // if (Math.abs(snappedBounds.top - other.bottom) < 1) { - // guides.push({ - // type: "horizontal", - // position: other.bottom, - // alignType: "top", - // }); - // } - // 하단-상단 정렬 - 임시 비활성화 - // if (Math.abs(snappedBounds.bottom - other.top) < 1) { - // guides.push({ - // type: "horizontal", - // position: other.top, - // alignType: "bottom", - // }); - // } - // 중앙 정렬 - if (Math.abs(snappedBounds.centerY - other.centerY) < 1) { - guides.push({ - type: 'horizontal', - position: other.centerY, - alignType: 'middle', - }); - } - } - } - - // === 캔버스 중앙 기준 가이드라인 추가 === - // 그룹 선택 시에는 그룹의 중심을, 아니면 개별 요소의 중심을 기준으로 - const snappedCenterBounds = options?.groupBounds - ? { - // 그룹의 스냅 후 중심 계산 (드래그 요소의 이동량을 적용) - centerX: options.groupBounds.centerX + (snappedX - draggedBounds.left), - centerY: options.groupBounds.centerY + (snappedY - draggedBounds.top), - } - : snappedBounds; - - // X축: 요소(또는 그룹) 중심이 캔버스 가로 중앙에 정렬된 경우 - if ( - didSnapCanvasCenterX || - (didSnapX && Math.abs(snappedCenterBounds.centerX - CANVAS_CENTER_X) < 1) - ) { - guides.push({ - type: 'vertical', - position: CANVAS_CENTER_X, - alignType: 'center', - }); - } - - // Y축: 요소(또는 그룹) 중심이 캔버스 세로 중앙에 정렬된 경우 - if ( - didSnapCanvasCenterY || - (didSnapY && Math.abs(snappedCenterBounds.centerY - CANVAS_CENTER_Y) < 1) - ) { - guides.push({ - type: 'horizontal', - position: CANVAS_CENTER_Y, - alignType: 'middle', - }); - } - - // 중복 가이드라인 제거 - const uniqueGuides = guides.filter( - (guide, index, self) => - index === - self.findIndex( - (g) => - g.type === guide.type && Math.abs(g.position - guide.position) < 1, - ), - ); - - // 간격 가이드 계산 (옵션으로 비활성화 가능) - if (options?.disableSpacing) { - return { - snappedX, - snappedY, - guides: uniqueGuides, - spacingGuides: [], - didSnapX, - didSnapY, - didSpacingSnapX: false, - didSpacingSnapY: false, - }; - } - - const spacingResult = calculateSpacingGuides( - { - ...draggedBounds, - left: snappedX, - top: snappedY, - right: snappedX + draggedBounds.width, - bottom: snappedY + draggedBounds.height, - centerX: snappedX + draggedBounds.width / 2, - centerY: snappedY + draggedBounds.height / 2, - }, - otherElements, - ); - - return { - snappedX: spacingResult.didSpacingSnapX ? spacingResult.snappedX : snappedX, - snappedY: spacingResult.didSpacingSnapY ? spacingResult.snappedY : snappedY, - guides: uniqueGuides, - spacingGuides: spacingResult.spacingGuides, - didSnapX: didSnapX || spacingResult.didSpacingSnapX, - didSnapY: didSnapY || spacingResult.didSpacingSnapY, - didSpacingSnapX: spacingResult.didSpacingSnapX, - didSpacingSnapY: spacingResult.didSpacingSnapY, - }; -} - -/** - * 가이드라인의 시작/끝 위치 계산 (시각화용) - */ -export function calculateGuideLineExtent( - guide: GuideLine, - draggedBounds: ElementBounds, - otherElements: ElementBounds[], -): { start: number; end: number } { - // 캔버스 중앙 가이드라인인지 확인 - const isCanvasCenterGuide = - (guide.type === 'vertical' && - Math.abs(guide.position - CANVAS_CENTER_X) < 1) || - (guide.type === 'horizontal' && - Math.abs(guide.position - CANVAS_CENTER_Y) < 1); - - const relevantElements = otherElements.filter((el) => { - if (el.id === draggedBounds.id) return false; - - if (guide.type === 'vertical') { - // 수직 가이드라인: x 위치가 일치하는 요소 - return ( - Math.abs(el.left - guide.position) < 1 || - Math.abs(el.right - guide.position) < 1 || - Math.abs(el.centerX - guide.position) < 1 - ); - } else { - // 수평 가이드라인: y 위치가 일치하는 요소 - return ( - Math.abs(el.top - guide.position) < 1 || - Math.abs(el.bottom - guide.position) < 1 || - Math.abs(el.centerY - guide.position) < 1 - ); - } - }); - - // 드래그 중인 요소도 포함 - relevantElements.push(draggedBounds); - - // 캔버스 중앙 가이드라인의 경우 더 긴 범위 표시 - const CANVAS_CENTER_GUIDE_EXTENSION = 500; - - if (guide.type === 'vertical') { - const tops = relevantElements.map((el) => el.top); - const bottoms = relevantElements.map((el) => el.bottom); - const extension = isCanvasCenterGuide ? CANVAS_CENTER_GUIDE_EXTENSION : 20; - return { - start: Math.min(...tops) - extension, - end: Math.max(...bottoms) + extension, - }; - } else { - const lefts = relevantElements.map((el) => el.left); - const rights = relevantElements.map((el) => el.right); - const extension = isCanvasCenterGuide ? CANVAS_CENTER_GUIDE_EXTENSION : 20; - return { - start: Math.min(...lefts) - extension, - end: Math.max(...rights) + extension, - }; - } -} - -/** - * 간격 정보를 담는 헬퍼 인터페이스 - */ -interface _SpacingInfo { - element: ElementBounds; - gap: number; // 간격 값 - direction: 'left' | 'right' | 'above' | 'below'; -} - -/** - * 간격 가이드 계산 결과 - */ -interface SpacingSnapResult { - snappedX: number; - snappedY: number; - spacingGuides: SpacingGuide[]; - didSpacingSnapX: boolean; - didSpacingSnapY: boolean; -} - -/** - * 요소 간 간격 계산 및 동일 간격 스냅 - * A요소와 B요소 사이의 간격이 C요소와 D요소 사이 간격과 같으면 스냅 - */ -function calculateSpacingGuides( - draggedBounds: ElementBounds, - otherElements: ElementBounds[], -): SpacingSnapResult { - const spacingGuides: SpacingGuide[] = []; - let snappedX = draggedBounds.left; - let snappedY = draggedBounds.top; - let didSpacingSnapX = false; - let didSpacingSnapY = false; - - // 다른 요소들만 필터링 (자기 자신 제외) - const others = otherElements.filter((el) => el.id !== draggedBounds.id); - - if (others.length < 2) { - // 최소 2개 요소가 있어야 간격 비교 가능 - return { - snappedX, - snappedY, - spacingGuides, - didSpacingSnapX, - didSpacingSnapY, - }; - } - - // Y축 겹침 여부 확인 함수 (수평 간격 계산 시 사용) - const hasVerticalOverlap = (a: ElementBounds, b: ElementBounds): boolean => { - return a.bottom > b.top && a.top < b.bottom; - }; - - // X축 겹침 여부 확인 함수 (수직 간격 계산 시 사용) - const hasHorizontalOverlap = ( - a: ElementBounds, - b: ElementBounds, - ): boolean => { - return a.right > b.left && a.left < b.right; - }; - - // === 수평 간격 계산 (X축) === - // 드래그 요소와 Y축이 겹치는 요소들만 고려 (같은 행에 있는 요소들) - const horizontallyRelevant = others.filter((el) => - hasVerticalOverlap(draggedBounds, el), - ); - - // 드래그 요소 기준 왼쪽/오른쪽에 있는 요소들 찾기 - const leftElements = horizontallyRelevant - .filter((el) => el.right <= draggedBounds.left + SPACING_SNAP_THRESHOLD) - .sort((a, b) => b.right - a.right); // 가장 가까운 것 먼저 - - const rightElements = horizontallyRelevant - .filter((el) => el.left >= draggedBounds.right - SPACING_SNAP_THRESHOLD) - .sort((a, b) => a.left - b.left); // 가장 가까운 것 먼저 - - // 기존 요소들 사이의 수평 간격 수집 (서로 Y축이 겹치는 쌍만) - const horizontalGaps: { - from: ElementBounds; - to: ElementBounds; - gap: number; - }[] = []; - const sortedByX = [...horizontallyRelevant].sort((a, b) => a.left - b.left); - - for (let i = 0; i < sortedByX.length - 1; i++) { - const current = sortedByX[i]; - const next = sortedByX[i + 1]; - // 겹치지 않고, Y축이 겹치는 경우만 간격 계산 - if (current.right < next.left && hasVerticalOverlap(current, next)) { - const gap = next.left - current.right; - if (gap > 0 && gap < 500) { - // 너무 큰 간격은 무시 - horizontalGaps.push({ from: current, to: next, gap }); - } - } - } - - // 드래그 요소와 인접 요소 사이 간격이 기존 간격과 일치하는지 확인 - if (leftElements.length > 0 && !didSpacingSnapX) { - const leftNearest = leftElements[0]; - const currentGap = draggedBounds.left - leftNearest.right; - - for (const existingGap of horizontalGaps) { - // 이미 관련된 요소는 스킵 - if ( - existingGap.from.id === leftNearest.id || - existingGap.to.id === leftNearest.id - ) - continue; - - const diff = Math.abs(currentGap - existingGap.gap); - if (diff <= SPACING_SNAP_THRESHOLD && diff > 0) { - // 스냅: 동일 간격으로 조정 - snappedX = leftNearest.right + existingGap.gap; - didSpacingSnapX = true; - - // 기존 간격 가이드 추가 - spacingGuides.push({ - type: 'spacing', - direction: 'horizontal', - value: existingGap.gap, - startPos: existingGap.from.right, - endPos: existingGap.to.left, - crossAxisPos: Math.max( - existingGap.from.centerY, - existingGap.to.centerY, - ), - fromElementId: existingGap.from.id, - toElementId: existingGap.to.id, - isMatched: true, - }); - - // 현재 간격 가이드 추가 (스냅 후) - spacingGuides.push({ - type: 'spacing', - direction: 'horizontal', - value: existingGap.gap, - startPos: leftNearest.right, - endPos: snappedX, - crossAxisPos: Math.max(leftNearest.centerY, draggedBounds.centerY), - fromElementId: leftNearest.id, - toElementId: draggedBounds.id, - isMatched: true, - }); - break; - } - } - } - - if (rightElements.length > 0 && !didSpacingSnapX) { - const rightNearest = rightElements[0]; - const currentGap = rightNearest.left - draggedBounds.right; - - for (const existingGap of horizontalGaps) { - if ( - existingGap.from.id === rightNearest.id || - existingGap.to.id === rightNearest.id - ) - continue; - - const diff = Math.abs(currentGap - existingGap.gap); - if (diff <= SPACING_SNAP_THRESHOLD && diff > 0) { - snappedX = rightNearest.left - existingGap.gap - draggedBounds.width; - didSpacingSnapX = true; - - spacingGuides.push({ - type: 'spacing', - direction: 'horizontal', - value: existingGap.gap, - startPos: existingGap.from.right, - endPos: existingGap.to.left, - crossAxisPos: Math.max( - existingGap.from.centerY, - existingGap.to.centerY, - ), - fromElementId: existingGap.from.id, - toElementId: existingGap.to.id, - isMatched: true, - }); - - spacingGuides.push({ - type: 'spacing', - direction: 'horizontal', - value: existingGap.gap, - startPos: snappedX + draggedBounds.width, - endPos: rightNearest.left, - crossAxisPos: Math.max(rightNearest.centerY, draggedBounds.centerY), - fromElementId: draggedBounds.id, - toElementId: rightNearest.id, - isMatched: true, - }); - break; - } - } - } - - // 3개 요소 등간격 스냅 (A - dragged - B) - if (leftElements.length > 0 && rightElements.length > 0 && !didSpacingSnapX) { - const leftNearest = leftElements[0]; - const rightNearest = rightElements[0]; - const totalSpace = - rightNearest.left - leftNearest.right - draggedBounds.width; - - if (totalSpace > 0) { - const equalGap = totalSpace / 2; - const idealX = leftNearest.right + equalGap; - const diff = Math.abs(draggedBounds.left - idealX); - - if (diff <= SPACING_SNAP_THRESHOLD && diff > 0) { - snappedX = idealX; - didSpacingSnapX = true; - - // 왼쪽 간격 - spacingGuides.push({ - type: 'spacing', - direction: 'horizontal', - value: Math.round(equalGap), - startPos: leftNearest.right, - endPos: idealX, - crossAxisPos: Math.max(leftNearest.centerY, draggedBounds.centerY), - fromElementId: leftNearest.id, - toElementId: draggedBounds.id, - isMatched: true, - }); - - // 오른쪽 간격 - spacingGuides.push({ - type: 'spacing', - direction: 'horizontal', - value: Math.round(equalGap), - startPos: idealX + draggedBounds.width, - endPos: rightNearest.left, - crossAxisPos: Math.max(rightNearest.centerY, draggedBounds.centerY), - fromElementId: draggedBounds.id, - toElementId: rightNearest.id, - isMatched: true, - }); - } - } - } - - // === 수직 간격 계산 (Y축) === - // 드래그 요소와 X축이 겹치는 요소들만 고려 (같은 열에 있는 요소들) - const verticallyRelevant = others.filter((el) => - hasHorizontalOverlap(draggedBounds, el), - ); - - const aboveElements = verticallyRelevant - .filter((el) => el.bottom <= draggedBounds.top + SPACING_SNAP_THRESHOLD) - .sort((a, b) => b.bottom - a.bottom); - - const belowElements = verticallyRelevant - .filter((el) => el.top >= draggedBounds.bottom - SPACING_SNAP_THRESHOLD) - .sort((a, b) => a.top - b.top); - - const verticalGaps: { - from: ElementBounds; - to: ElementBounds; - gap: number; - }[] = []; - const sortedByY = [...verticallyRelevant].sort((a, b) => a.top - b.top); - - for (let i = 0; i < sortedByY.length - 1; i++) { - const current = sortedByY[i]; - const next = sortedByY[i + 1]; - // 겹치지 않고, X축이 겹치는 경우만 간격 계산 - if (current.bottom < next.top && hasHorizontalOverlap(current, next)) { - const gap = next.top - current.bottom; - if (gap > 0 && gap < 500) { - verticalGaps.push({ from: current, to: next, gap }); - } - } - } - - if (aboveElements.length > 0 && !didSpacingSnapY) { - const aboveNearest = aboveElements[0]; - const currentGap = draggedBounds.top - aboveNearest.bottom; - - for (const existingGap of verticalGaps) { - if ( - existingGap.from.id === aboveNearest.id || - existingGap.to.id === aboveNearest.id - ) - continue; - - const diff = Math.abs(currentGap - existingGap.gap); - if (diff <= SPACING_SNAP_THRESHOLD && diff > 0) { - snappedY = aboveNearest.bottom + existingGap.gap; - didSpacingSnapY = true; - - spacingGuides.push({ - type: 'spacing', - direction: 'vertical', - value: existingGap.gap, - startPos: existingGap.from.bottom, - endPos: existingGap.to.top, - crossAxisPos: Math.max( - existingGap.from.centerX, - existingGap.to.centerX, - ), - fromElementId: existingGap.from.id, - toElementId: existingGap.to.id, - isMatched: true, - }); - - spacingGuides.push({ - type: 'spacing', - direction: 'vertical', - value: existingGap.gap, - startPos: aboveNearest.bottom, - endPos: snappedY, - crossAxisPos: Math.max(aboveNearest.centerX, draggedBounds.centerX), - fromElementId: aboveNearest.id, - toElementId: draggedBounds.id, - isMatched: true, - }); - break; - } - } - } - - if (belowElements.length > 0 && !didSpacingSnapY) { - const belowNearest = belowElements[0]; - const currentGap = belowNearest.top - draggedBounds.bottom; - - for (const existingGap of verticalGaps) { - if ( - existingGap.from.id === belowNearest.id || - existingGap.to.id === belowNearest.id - ) - continue; - - const diff = Math.abs(currentGap - existingGap.gap); - if (diff <= SPACING_SNAP_THRESHOLD && diff > 0) { - snappedY = belowNearest.top - existingGap.gap - draggedBounds.height; - didSpacingSnapY = true; - - spacingGuides.push({ - type: 'spacing', - direction: 'vertical', - value: existingGap.gap, - startPos: existingGap.from.bottom, - endPos: existingGap.to.top, - crossAxisPos: Math.max( - existingGap.from.centerX, - existingGap.to.centerX, - ), - fromElementId: existingGap.from.id, - toElementId: existingGap.to.id, - isMatched: true, - }); - - spacingGuides.push({ - type: 'spacing', - direction: 'vertical', - value: existingGap.gap, - startPos: snappedY + draggedBounds.height, - endPos: belowNearest.top, - crossAxisPos: Math.max(belowNearest.centerX, draggedBounds.centerX), - fromElementId: draggedBounds.id, - toElementId: belowNearest.id, - isMatched: true, - }); - break; - } - } - } - - // 3개 요소 등간격 스냅 (위 - dragged - 아래) - if ( - aboveElements.length > 0 && - belowElements.length > 0 && - !didSpacingSnapY - ) { - const aboveNearest = aboveElements[0]; - const belowNearest = belowElements[0]; - const totalSpace = - belowNearest.top - aboveNearest.bottom - draggedBounds.height; - - if (totalSpace > 0) { - const equalGap = totalSpace / 2; - const idealY = aboveNearest.bottom + equalGap; - const diff = Math.abs(draggedBounds.top - idealY); - - if (diff <= SPACING_SNAP_THRESHOLD && diff > 0) { - snappedY = idealY; - didSpacingSnapY = true; - - spacingGuides.push({ - type: 'spacing', - direction: 'vertical', - value: Math.round(equalGap), - startPos: aboveNearest.bottom, - endPos: idealY, - crossAxisPos: Math.max(aboveNearest.centerX, draggedBounds.centerX), - fromElementId: aboveNearest.id, - toElementId: draggedBounds.id, - isMatched: true, - }); - - spacingGuides.push({ - type: 'spacing', - direction: 'vertical', - value: Math.round(equalGap), - startPos: idealY + draggedBounds.height, - endPos: belowNearest.top, - crossAxisPos: Math.max(belowNearest.centerX, draggedBounds.centerX), - fromElementId: draggedBounds.id, - toElementId: belowNearest.id, - isMatched: true, - }); - } - } - } - - return { - snappedX, - snappedY, - spacingGuides, - didSpacingSnapX, - didSpacingSnapY, - }; -} - -/** - * 크기 일치 스냅 계산 (리사이즈용) - * 다른 요소와 동일한 width/height로 스냅 - * 여러 요소가 비슷한 크기일 경우, 가장 가까운 크기를 가진 요소에 스냅 - */ -export function calculateSizeSnap( - currentWidth: number, - currentHeight: number, - otherElements: ElementBounds[], - draggedId: string = '', -): SizeSnapResult { - const sizeMatchGuides: SizeMatchGuide[] = []; - let snappedWidth = currentWidth; - let snappedHeight = currentHeight; - let didSnapWidth = false; - let didSnapHeight = false; - - const others = otherElements.filter((el) => el.id !== draggedId); - - // 가장 가까운 매치를 추적하기 위한 변수 - let closestWidthDiff = Infinity; - let closestHeightDiff = Infinity; - let closestWidthMatch: ElementBounds | null = null; - let closestHeightMatch: ElementBounds | null = null; - - // 첫 번째 패스: 임계값 내에서 가장 가까운 크기를 찾음 - for (const other of others) { - // width 일치 체크 - 가장 가까운 것 선택 - const widthDiff = Math.abs(currentWidth - other.width); - if (widthDiff <= SIZE_MATCH_THRESHOLD && widthDiff < closestWidthDiff) { - closestWidthDiff = widthDiff; - closestWidthMatch = other; - } - - // height 일치 체크 - 가장 가까운 것 선택 - const heightDiff = Math.abs(currentHeight - other.height); - if (heightDiff <= SIZE_MATCH_THRESHOLD && heightDiff < closestHeightDiff) { - closestHeightDiff = heightDiff; - closestHeightMatch = other; - } - } - - // 두 번째 패스: 가장 가까운 매치에 대한 스냅 적용 - if (closestWidthMatch) { - snappedWidth = closestWidthMatch.width; - didSnapWidth = true; - sizeMatchGuides.push({ - type: 'size-match', - dimension: 'width', - value: closestWidthMatch.width, - position: { x: closestWidthMatch.centerX, y: closestWidthMatch.top - 15 }, - matchedElementId: closestWidthMatch.id, - matchedElementBounds: { - left: closestWidthMatch.left, - top: closestWidthMatch.top, - width: closestWidthMatch.width, - height: closestWidthMatch.height, - }, - }); - } - - if (closestHeightMatch) { - snappedHeight = closestHeightMatch.height; - didSnapHeight = true; - sizeMatchGuides.push({ - type: 'size-match', - dimension: 'height', - value: closestHeightMatch.height, - position: { - x: closestHeightMatch.right + 15, - y: closestHeightMatch.centerY, - }, - matchedElementId: closestHeightMatch.id, - matchedElementBounds: { - left: closestHeightMatch.left, - top: closestHeightMatch.top, - width: closestHeightMatch.width, - height: closestHeightMatch.height, - }, - }); - } - - return { - snappedWidth, - snappedHeight, - sizeMatchGuides, - didSnapWidth, - didSnapHeight, - }; -} + * Smart Guides (스마트 가이드) 공개 API + */ + +export { + CANVAS_CENTER_X, + CANVAS_CENTER_Y, + calculateBounds, + calculateGroupBounds, + calculateGuideLineExtent, +} from './smartGuides/bounds'; +export { calculateSnapPoints } from './smartGuides/alignment'; +export { calculateSizeSnap } from './smartGuides/size'; +export type { + ElementBounds, + GuideLine, + SizeMatchGuide, + SizeSnapResult, + SnapPointsOptions, + SnapResult, + SpacingGuide, +} from './smartGuides/types'; diff --git a/src/renderer/utils/grid/smartGuides/alignment.ts b/src/renderer/utils/grid/smartGuides/alignment.ts new file mode 100644 index 000000000..4df5d7ae6 --- /dev/null +++ b/src/renderer/utils/grid/smartGuides/alignment.ts @@ -0,0 +1,356 @@ +import { roundToGrid } from '../gridSnap'; +import { CANVAS_CENTER_X, CANVAS_CENTER_Y, calculateBounds } from './bounds'; +import { calculateSpacingGuides } from './spacing'; +import type { + ElementBounds, + GuideLine, + SnapPointsOptions, + SnapResult, +} from './types'; + +// 스냅 거리 임계값 (픽셀) +const SNAP_THRESHOLD = 8; + +/** + * 두 값이 스냅 거리 내에 있는지 확인 + */ +function _isWithinThreshold( + value1: number, + value2: number, + threshold: number = SNAP_THRESHOLD, +): boolean { + return Math.abs(value1 - value2) <= threshold; +} + +/** + * 드래그 중인 요소와 다른 요소들 사이의 스냅 포인트 계산 + * 캔버스 중앙 기준 스냅도 포함 + * @param draggedBounds 드래그 중인 요소의 bounds + * @param otherElements 다른 요소들의 bounds + * @param threshold 스냅 임계값 (기본값: 8px) + * @param options 추가 옵션 (groupBounds 등) + */ +export function calculateSnapPoints( + draggedBounds: ElementBounds, + otherElements: ElementBounds[], + threshold: number = SNAP_THRESHOLD, + options?: SnapPointsOptions, +): SnapResult { + const guides: GuideLine[] = []; + let snappedX = draggedBounds.left; + let snappedY = draggedBounds.top; + let didSnapX = false; + let didSnapY = false; + let didSnapCanvasCenterX = false; + let didSnapCanvasCenterY = false; + + // 가장 가까운 스냅 포인트 추적 + let closestXDiff = Infinity; + let closestYDiff = Infinity; + + // === 캔버스 중앙 기준 스냅 === + // 그룹 선택 시에는 그룹 전체의 중심을 기준으로, 아니면 개별 요소의 중심을 기준으로 + const centerBounds = options?.groupBounds || draggedBounds; + + // 요소(또는 그룹)의 중심이 캔버스 가로 중앙에 정렬 + let diff = Math.abs(centerBounds.centerX - CANVAS_CENTER_X); + if (diff <= threshold && diff < closestXDiff) { + closestXDiff = diff; + // 그룹의 중심이 캔버스 중앙에 오도록 드래그 요소의 위치 계산 + const offsetX = draggedBounds.centerX - centerBounds.centerX; + snappedX = CANVAS_CENTER_X - draggedBounds.width / 2 + offsetX; + snappedX = roundToGrid(snappedX, options?.gridSnapSize ?? 0); + didSnapX = true; + didSnapCanvasCenterX = true; + } + + // 요소(또는 그룹)의 중심이 캔버스 세로 중앙에 정렬 + diff = Math.abs(centerBounds.centerY - CANVAS_CENTER_Y); + if (diff <= threshold && diff < closestYDiff) { + closestYDiff = diff; + // 그룹의 중심이 캔버스 중앙에 오도록 드래그 요소의 위치 계산 + const offsetY = draggedBounds.centerY - centerBounds.centerY; + snappedY = CANVAS_CENTER_Y - draggedBounds.height / 2 + offsetY; + snappedY = roundToGrid(snappedY, options?.gridSnapSize ?? 0); + didSnapY = true; + didSnapCanvasCenterY = true; + } + + for (const other of otherElements) { + // 자기 자신은 스킵 + if (other.id === draggedBounds.id) continue; + + // === X축 (수직 가이드라인) 스냅 체크 === + + // 왼쪽 가장자리 정렬 (left-to-left) + let diff = Math.abs(draggedBounds.left - other.left); + if (diff <= threshold && diff < closestXDiff) { + closestXDiff = diff; + snappedX = other.left; + didSnapX = true; + didSnapCanvasCenterX = false; + } + + // 오른쪽 가장자리 정렬 (right-to-right) + diff = Math.abs(draggedBounds.right - other.right); + if (diff <= threshold && diff < closestXDiff) { + closestXDiff = diff; + snappedX = other.right - draggedBounds.width; + didSnapX = true; + didSnapCanvasCenterX = false; + } + + // 왼쪽-오른쪽 정렬 (left-to-right) - 임시 비활성화 + // diff = Math.abs(draggedBounds.left - other.right); + // if (diff <= threshold && diff < closestXDiff) { + // closestXDiff = diff; + // snappedX = other.right; + // didSnapX = true; + // } + + // 오른쪽-왼쪽 정렬 (right-to-left) - 임시 비활성화 + // diff = Math.abs(draggedBounds.right - other.left); + // if (diff <= threshold && diff < closestXDiff) { + // closestXDiff = diff; + // snappedX = other.left - draggedBounds.width; + // didSnapX = true; + // } + + // 중앙 정렬 (center-to-center X) + diff = Math.abs(draggedBounds.centerX - other.centerX); + if (diff <= threshold && diff < closestXDiff) { + closestXDiff = diff; + snappedX = other.centerX - draggedBounds.width / 2; + didSnapX = true; + didSnapCanvasCenterX = false; + } + + // === Y축 (수평 가이드라인) 스냅 체크 === + + // 상단 정렬 (top-to-top) + diff = Math.abs(draggedBounds.top - other.top); + if (diff <= threshold && diff < closestYDiff) { + closestYDiff = diff; + snappedY = other.top; + didSnapY = true; + didSnapCanvasCenterY = false; + } + + // 하단 정렬 (bottom-to-bottom) + diff = Math.abs(draggedBounds.bottom - other.bottom); + if (diff <= threshold && diff < closestYDiff) { + closestYDiff = diff; + snappedY = other.bottom - draggedBounds.height; + didSnapY = true; + didSnapCanvasCenterY = false; + } + + // 상단-하단 정렬 (top-to-bottom) - 임시 비활성화 + // diff = Math.abs(draggedBounds.top - other.bottom); + // if (diff <= threshold && diff < closestYDiff) { + // closestYDiff = diff; + // snappedY = other.bottom; + // didSnapY = true; + // } + + // 하단-상단 정렬 (bottom-to-top) - 임시 비활성화 + // diff = Math.abs(draggedBounds.bottom - other.top); + // if (diff <= threshold && diff < closestYDiff) { + // closestYDiff = diff; + // snappedY = other.top - draggedBounds.height; + // didSnapY = true; + // } + + // 중앙 정렬 (center-to-center Y) + diff = Math.abs(draggedBounds.centerY - other.centerY); + if (diff <= threshold && diff < closestYDiff) { + closestYDiff = diff; + snappedY = other.centerY - draggedBounds.height / 2; + didSnapY = true; + didSnapCanvasCenterY = false; + } + } + + // 스냅된 위치를 기준으로 가이드라인 생성 + const snappedBounds = calculateBounds( + snappedX, + snappedY, + draggedBounds.width, + draggedBounds.height, + draggedBounds.id, + ); + + for (const other of otherElements) { + if (other.id === draggedBounds.id) continue; + + // X축 가이드라인 (수직선) + if (didSnapX) { + // 왼쪽 가장자리 정렬 + if (Math.abs(snappedBounds.left - other.left) < 1) { + guides.push({ + type: 'vertical', + position: other.left, + alignType: 'left', + }); + } + // 오른쪽 가장자리 정렬 + if (Math.abs(snappedBounds.right - other.right) < 1) { + guides.push({ + type: 'vertical', + position: other.right, + alignType: 'right', + }); + } + // 왼쪽-오른쪽 정렬 - 임시 비활성화 + // if (Math.abs(snappedBounds.left - other.right) < 1) { + // guides.push({ + // type: "vertical", + // position: other.right, + // alignType: "left", + // }); + // } + // 오른쪽-왼쪽 정렬 - 임시 비활성화 + // if (Math.abs(snappedBounds.right - other.left) < 1) { + // guides.push({ + // type: "vertical", + // position: other.left, + // alignType: "right", + // }); + // } + // 중앙 정렬 + if (Math.abs(snappedBounds.centerX - other.centerX) < 1) { + guides.push({ + type: 'vertical', + position: other.centerX, + alignType: 'center', + }); + } + } + + // Y축 가이드라인 (수평선) + if (didSnapY) { + // 상단 정렬 + if (Math.abs(snappedBounds.top - other.top) < 1) { + guides.push({ + type: 'horizontal', + position: other.top, + alignType: 'top', + }); + } + // 하단 정렬 + if (Math.abs(snappedBounds.bottom - other.bottom) < 1) { + guides.push({ + type: 'horizontal', + position: other.bottom, + alignType: 'bottom', + }); + } + // 상단-하단 정렬 - 임시 비활성화 + // if (Math.abs(snappedBounds.top - other.bottom) < 1) { + // guides.push({ + // type: "horizontal", + // position: other.bottom, + // alignType: "top", + // }); + // } + // 하단-상단 정렬 - 임시 비활성화 + // if (Math.abs(snappedBounds.bottom - other.top) < 1) { + // guides.push({ + // type: "horizontal", + // position: other.top, + // alignType: "bottom", + // }); + // } + // 중앙 정렬 + if (Math.abs(snappedBounds.centerY - other.centerY) < 1) { + guides.push({ + type: 'horizontal', + position: other.centerY, + alignType: 'middle', + }); + } + } + } + + // === 캔버스 중앙 기준 가이드라인 추가 === + // 그룹 선택 시에는 그룹의 중심을, 아니면 개별 요소의 중심을 기준으로 + const snappedCenterBounds = options?.groupBounds + ? { + // 그룹의 스냅 후 중심 계산 (드래그 요소의 이동량을 적용) + centerX: options.groupBounds.centerX + (snappedX - draggedBounds.left), + centerY: options.groupBounds.centerY + (snappedY - draggedBounds.top), + } + : snappedBounds; + + // X축: 요소(또는 그룹) 중심이 캔버스 가로 중앙에 정렬된 경우 + if ( + didSnapCanvasCenterX || + (didSnapX && Math.abs(snappedCenterBounds.centerX - CANVAS_CENTER_X) < 1) + ) { + guides.push({ + type: 'vertical', + position: CANVAS_CENTER_X, + alignType: 'center', + }); + } + + // Y축: 요소(또는 그룹) 중심이 캔버스 세로 중앙에 정렬된 경우 + if ( + didSnapCanvasCenterY || + (didSnapY && Math.abs(snappedCenterBounds.centerY - CANVAS_CENTER_Y) < 1) + ) { + guides.push({ + type: 'horizontal', + position: CANVAS_CENTER_Y, + alignType: 'middle', + }); + } + + // 중복 가이드라인 제거 + const uniqueGuides = guides.filter( + (guide, index, self) => + index === + self.findIndex( + (g) => + g.type === guide.type && Math.abs(g.position - guide.position) < 1, + ), + ); + + // 간격 가이드 계산 (옵션으로 비활성화 가능) + if (options?.disableSpacing) { + return { + snappedX, + snappedY, + guides: uniqueGuides, + spacingGuides: [], + didSnapX, + didSnapY, + didSpacingSnapX: false, + didSpacingSnapY: false, + }; + } + + const spacingResult = calculateSpacingGuides( + { + ...draggedBounds, + left: snappedX, + top: snappedY, + right: snappedX + draggedBounds.width, + bottom: snappedY + draggedBounds.height, + centerX: snappedX + draggedBounds.width / 2, + centerY: snappedY + draggedBounds.height / 2, + }, + otherElements, + ); + + return { + snappedX: spacingResult.didSpacingSnapX ? spacingResult.snappedX : snappedX, + snappedY: spacingResult.didSpacingSnapY ? spacingResult.snappedY : snappedY, + guides: uniqueGuides, + spacingGuides: spacingResult.spacingGuides, + didSnapX: didSnapX || spacingResult.didSpacingSnapX, + didSnapY: didSnapY || spacingResult.didSpacingSnapY, + didSpacingSnapX: spacingResult.didSpacingSnapX, + didSpacingSnapY: spacingResult.didSpacingSnapY, + }; +} diff --git a/src/renderer/utils/grid/smartGuides/bounds.ts b/src/renderer/utils/grid/smartGuides/bounds.ts new file mode 100644 index 000000000..b4493584e --- /dev/null +++ b/src/renderer/utils/grid/smartGuides/bounds.ts @@ -0,0 +1,120 @@ +import type { ElementBounds, GuideLine } from './types'; + +/** + * 요소의 bounds 정보 계산 + */ +export function calculateBounds( + x: number, + y: number, + width: number, + height: number, + id: string = '', +): ElementBounds { + return { + id, + left: x, + top: y, + right: x + width, + bottom: y + height, + centerX: x + width / 2, + centerY: y + height / 2, + width, + height, + }; +} + +/** + * 여러 요소들의 전체 바운딩 박스 계산 (그룹 선택용) + * @param elements 요소들의 bounds 배열 + * @returns 전체 요소를 감싸는 바운딩 박스 + */ +export function calculateGroupBounds( + elements: ElementBounds[], +): ElementBounds | null { + if (elements.length === 0) return null; + + if (elements.length === 1) return elements[0]; + + const left = Math.min(...elements.map((el) => el.left)); + const top = Math.min(...elements.map((el) => el.top)); + const right = Math.max(...elements.map((el) => el.right)); + const bottom = Math.max(...elements.map((el) => el.bottom)); + const width = right - left; + const height = bottom - top; + + return { + id: 'group', + left, + top, + right, + bottom, + centerX: left + width / 2, + centerY: top + height / 2, + width, + height, + }; +} + +// 캔버스 중앙 좌표 (그리드 렌더링 영역 900x396 기준) +export const CANVAS_CENTER_X = 450; +export const CANVAS_CENTER_Y = 195; + +/** + * 가이드라인의 시작/끝 위치 계산 (시각화용) + */ +export function calculateGuideLineExtent( + guide: GuideLine, + draggedBounds: ElementBounds, + otherElements: ElementBounds[], +): { start: number; end: number } { + // 캔버스 중앙 가이드라인인지 확인 + const isCanvasCenterGuide = + (guide.type === 'vertical' && + Math.abs(guide.position - CANVAS_CENTER_X) < 1) || + (guide.type === 'horizontal' && + Math.abs(guide.position - CANVAS_CENTER_Y) < 1); + + const relevantElements = otherElements.filter((el) => { + if (el.id === draggedBounds.id) return false; + + if (guide.type === 'vertical') { + // 수직 가이드라인: x 위치가 일치하는 요소 + return ( + Math.abs(el.left - guide.position) < 1 || + Math.abs(el.right - guide.position) < 1 || + Math.abs(el.centerX - guide.position) < 1 + ); + } else { + // 수평 가이드라인: y 위치가 일치하는 요소 + return ( + Math.abs(el.top - guide.position) < 1 || + Math.abs(el.bottom - guide.position) < 1 || + Math.abs(el.centerY - guide.position) < 1 + ); + } + }); + + // 드래그 중인 요소도 포함 + relevantElements.push(draggedBounds); + + // 캔버스 중앙 가이드라인의 경우 더 긴 범위 표시 + const CANVAS_CENTER_GUIDE_EXTENSION = 500; + + if (guide.type === 'vertical') { + const tops = relevantElements.map((el) => el.top); + const bottoms = relevantElements.map((el) => el.bottom); + const extension = isCanvasCenterGuide ? CANVAS_CENTER_GUIDE_EXTENSION : 20; + return { + start: Math.min(...tops) - extension, + end: Math.max(...bottoms) + extension, + }; + } else { + const lefts = relevantElements.map((el) => el.left); + const rights = relevantElements.map((el) => el.right); + const extension = isCanvasCenterGuide ? CANVAS_CENTER_GUIDE_EXTENSION : 20; + return { + start: Math.min(...lefts) - extension, + end: Math.max(...rights) + extension, + }; + } +} diff --git a/src/renderer/utils/grid/smartGuides/size.ts b/src/renderer/utils/grid/smartGuides/size.ts new file mode 100644 index 000000000..20c9930db --- /dev/null +++ b/src/renderer/utils/grid/smartGuides/size.ts @@ -0,0 +1,95 @@ +import type { ElementBounds, SizeMatchGuide, SizeSnapResult } from './types'; + +// 크기 일치 스냅 임계값 (픽셀) +const SIZE_MATCH_THRESHOLD = 4; + +/** + * 크기 일치 스냅 계산 (리사이즈용) + * 다른 요소와 동일한 width/height로 스냅 + * 여러 요소가 비슷한 크기일 경우, 가장 가까운 크기를 가진 요소에 스냅 + */ +export function calculateSizeSnap( + currentWidth: number, + currentHeight: number, + otherElements: ElementBounds[], + draggedId: string = '', +): SizeSnapResult { + const sizeMatchGuides: SizeMatchGuide[] = []; + let snappedWidth = currentWidth; + let snappedHeight = currentHeight; + let didSnapWidth = false; + let didSnapHeight = false; + + const others = otherElements.filter((el) => el.id !== draggedId); + + // 가장 가까운 매치를 추적하기 위한 변수 + let closestWidthDiff = Infinity; + let closestHeightDiff = Infinity; + let closestWidthMatch: ElementBounds | null = null; + let closestHeightMatch: ElementBounds | null = null; + + // 첫 번째 패스: 임계값 내에서 가장 가까운 크기를 찾음 + for (const other of others) { + // width 일치 체크 - 가장 가까운 것 선택 + const widthDiff = Math.abs(currentWidth - other.width); + if (widthDiff <= SIZE_MATCH_THRESHOLD && widthDiff < closestWidthDiff) { + closestWidthDiff = widthDiff; + closestWidthMatch = other; + } + + // height 일치 체크 - 가장 가까운 것 선택 + const heightDiff = Math.abs(currentHeight - other.height); + if (heightDiff <= SIZE_MATCH_THRESHOLD && heightDiff < closestHeightDiff) { + closestHeightDiff = heightDiff; + closestHeightMatch = other; + } + } + + // 두 번째 패스: 가장 가까운 매치에 대한 스냅 적용 + if (closestWidthMatch) { + snappedWidth = closestWidthMatch.width; + didSnapWidth = true; + sizeMatchGuides.push({ + type: 'size-match', + dimension: 'width', + value: closestWidthMatch.width, + position: { x: closestWidthMatch.centerX, y: closestWidthMatch.top - 15 }, + matchedElementId: closestWidthMatch.id, + matchedElementBounds: { + left: closestWidthMatch.left, + top: closestWidthMatch.top, + width: closestWidthMatch.width, + height: closestWidthMatch.height, + }, + }); + } + + if (closestHeightMatch) { + snappedHeight = closestHeightMatch.height; + didSnapHeight = true; + sizeMatchGuides.push({ + type: 'size-match', + dimension: 'height', + value: closestHeightMatch.height, + position: { + x: closestHeightMatch.right + 15, + y: closestHeightMatch.centerY, + }, + matchedElementId: closestHeightMatch.id, + matchedElementBounds: { + left: closestHeightMatch.left, + top: closestHeightMatch.top, + width: closestHeightMatch.width, + height: closestHeightMatch.height, + }, + }); + } + + return { + snappedWidth, + snappedHeight, + sizeMatchGuides, + didSnapWidth, + didSnapHeight, + }; +} diff --git a/src/renderer/utils/grid/smartGuides/spacing.ts b/src/renderer/utils/grid/smartGuides/spacing.ts new file mode 100644 index 000000000..806f0f9bd --- /dev/null +++ b/src/renderer/utils/grid/smartGuides/spacing.ts @@ -0,0 +1,428 @@ +import type { ElementBounds, SpacingGuide } from './types'; + +// 간격 스냅 임계값 (픽셀) +const SPACING_SNAP_THRESHOLD = 5; + +/** + * 간격 정보를 담는 헬퍼 인터페이스 + */ +interface _SpacingInfo { + element: ElementBounds; + gap: number; // 간격 값 + direction: 'left' | 'right' | 'above' | 'below'; +} + +/** + * 간격 가이드 계산 결과 + */ +interface SpacingSnapResult { + snappedX: number; + snappedY: number; + spacingGuides: SpacingGuide[]; + didSpacingSnapX: boolean; + didSpacingSnapY: boolean; +} + +/** + * 요소 간 간격 계산 및 동일 간격 스냅 + * A요소와 B요소 사이의 간격이 C요소와 D요소 사이 간격과 같으면 스냅 + */ +export function calculateSpacingGuides( + draggedBounds: ElementBounds, + otherElements: ElementBounds[], +): SpacingSnapResult { + const spacingGuides: SpacingGuide[] = []; + let snappedX = draggedBounds.left; + let snappedY = draggedBounds.top; + let didSpacingSnapX = false; + let didSpacingSnapY = false; + + // 다른 요소들만 필터링 (자기 자신 제외) + const others = otherElements.filter((el) => el.id !== draggedBounds.id); + + if (others.length < 2) { + // 최소 2개 요소가 있어야 간격 비교 가능 + return { + snappedX, + snappedY, + spacingGuides, + didSpacingSnapX, + didSpacingSnapY, + }; + } + + // Y축 겹침 여부 확인 함수 (수평 간격 계산 시 사용) + const hasVerticalOverlap = (a: ElementBounds, b: ElementBounds): boolean => { + return a.bottom > b.top && a.top < b.bottom; + }; + + // X축 겹침 여부 확인 함수 (수직 간격 계산 시 사용) + const hasHorizontalOverlap = ( + a: ElementBounds, + b: ElementBounds, + ): boolean => { + return a.right > b.left && a.left < b.right; + }; + + // === 수평 간격 계산 (X축) === + // 드래그 요소와 Y축이 겹치는 요소들만 고려 (같은 행에 있는 요소들) + const horizontallyRelevant = others.filter((el) => + hasVerticalOverlap(draggedBounds, el), + ); + + // 드래그 요소 기준 왼쪽/오른쪽에 있는 요소들 찾기 + const leftElements = horizontallyRelevant + .filter((el) => el.right <= draggedBounds.left + SPACING_SNAP_THRESHOLD) + .sort((a, b) => b.right - a.right); // 가장 가까운 것 먼저 + + const rightElements = horizontallyRelevant + .filter((el) => el.left >= draggedBounds.right - SPACING_SNAP_THRESHOLD) + .sort((a, b) => a.left - b.left); // 가장 가까운 것 먼저 + + // 기존 요소들 사이의 수평 간격 수집 (서로 Y축이 겹치는 쌍만) + const horizontalGaps: { + from: ElementBounds; + to: ElementBounds; + gap: number; + }[] = []; + const sortedByX = [...horizontallyRelevant].sort((a, b) => a.left - b.left); + + for (let i = 0; i < sortedByX.length - 1; i++) { + const current = sortedByX[i]; + const next = sortedByX[i + 1]; + // 겹치지 않고, Y축이 겹치는 경우만 간격 계산 + if (current.right < next.left && hasVerticalOverlap(current, next)) { + const gap = next.left - current.right; + if (gap > 0 && gap < 500) { + // 너무 큰 간격은 무시 + horizontalGaps.push({ from: current, to: next, gap }); + } + } + } + + // 드래그 요소와 인접 요소 사이 간격이 기존 간격과 일치하는지 확인 + if (leftElements.length > 0 && !didSpacingSnapX) { + const leftNearest = leftElements[0]; + const currentGap = draggedBounds.left - leftNearest.right; + + for (const existingGap of horizontalGaps) { + // 이미 관련된 요소는 스킵 + if ( + existingGap.from.id === leftNearest.id || + existingGap.to.id === leftNearest.id + ) + continue; + + const diff = Math.abs(currentGap - existingGap.gap); + if (diff <= SPACING_SNAP_THRESHOLD && diff > 0) { + // 스냅: 동일 간격으로 조정 + snappedX = leftNearest.right + existingGap.gap; + didSpacingSnapX = true; + + // 기존 간격 가이드 추가 + spacingGuides.push({ + type: 'spacing', + direction: 'horizontal', + value: existingGap.gap, + startPos: existingGap.from.right, + endPos: existingGap.to.left, + crossAxisPos: Math.max( + existingGap.from.centerY, + existingGap.to.centerY, + ), + fromElementId: existingGap.from.id, + toElementId: existingGap.to.id, + isMatched: true, + }); + + // 현재 간격 가이드 추가 (스냅 후) + spacingGuides.push({ + type: 'spacing', + direction: 'horizontal', + value: existingGap.gap, + startPos: leftNearest.right, + endPos: snappedX, + crossAxisPos: Math.max(leftNearest.centerY, draggedBounds.centerY), + fromElementId: leftNearest.id, + toElementId: draggedBounds.id, + isMatched: true, + }); + break; + } + } + } + + if (rightElements.length > 0 && !didSpacingSnapX) { + const rightNearest = rightElements[0]; + const currentGap = rightNearest.left - draggedBounds.right; + + for (const existingGap of horizontalGaps) { + if ( + existingGap.from.id === rightNearest.id || + existingGap.to.id === rightNearest.id + ) + continue; + + const diff = Math.abs(currentGap - existingGap.gap); + if (diff <= SPACING_SNAP_THRESHOLD && diff > 0) { + snappedX = rightNearest.left - existingGap.gap - draggedBounds.width; + didSpacingSnapX = true; + + spacingGuides.push({ + type: 'spacing', + direction: 'horizontal', + value: existingGap.gap, + startPos: existingGap.from.right, + endPos: existingGap.to.left, + crossAxisPos: Math.max( + existingGap.from.centerY, + existingGap.to.centerY, + ), + fromElementId: existingGap.from.id, + toElementId: existingGap.to.id, + isMatched: true, + }); + + spacingGuides.push({ + type: 'spacing', + direction: 'horizontal', + value: existingGap.gap, + startPos: snappedX + draggedBounds.width, + endPos: rightNearest.left, + crossAxisPos: Math.max(rightNearest.centerY, draggedBounds.centerY), + fromElementId: draggedBounds.id, + toElementId: rightNearest.id, + isMatched: true, + }); + break; + } + } + } + + // 3개 요소 등간격 스냅 (A - dragged - B) + if (leftElements.length > 0 && rightElements.length > 0 && !didSpacingSnapX) { + const leftNearest = leftElements[0]; + const rightNearest = rightElements[0]; + const totalSpace = + rightNearest.left - leftNearest.right - draggedBounds.width; + + if (totalSpace > 0) { + const equalGap = totalSpace / 2; + const idealX = leftNearest.right + equalGap; + const diff = Math.abs(draggedBounds.left - idealX); + + if (diff <= SPACING_SNAP_THRESHOLD && diff > 0) { + snappedX = idealX; + didSpacingSnapX = true; + + // 왼쪽 간격 + spacingGuides.push({ + type: 'spacing', + direction: 'horizontal', + value: Math.round(equalGap), + startPos: leftNearest.right, + endPos: idealX, + crossAxisPos: Math.max(leftNearest.centerY, draggedBounds.centerY), + fromElementId: leftNearest.id, + toElementId: draggedBounds.id, + isMatched: true, + }); + + // 오른쪽 간격 + spacingGuides.push({ + type: 'spacing', + direction: 'horizontal', + value: Math.round(equalGap), + startPos: idealX + draggedBounds.width, + endPos: rightNearest.left, + crossAxisPos: Math.max(rightNearest.centerY, draggedBounds.centerY), + fromElementId: draggedBounds.id, + toElementId: rightNearest.id, + isMatched: true, + }); + } + } + } + + // === 수직 간격 계산 (Y축) === + // 드래그 요소와 X축이 겹치는 요소들만 고려 (같은 열에 있는 요소들) + const verticallyRelevant = others.filter((el) => + hasHorizontalOverlap(draggedBounds, el), + ); + + const aboveElements = verticallyRelevant + .filter((el) => el.bottom <= draggedBounds.top + SPACING_SNAP_THRESHOLD) + .sort((a, b) => b.bottom - a.bottom); + + const belowElements = verticallyRelevant + .filter((el) => el.top >= draggedBounds.bottom - SPACING_SNAP_THRESHOLD) + .sort((a, b) => a.top - b.top); + + const verticalGaps: { + from: ElementBounds; + to: ElementBounds; + gap: number; + }[] = []; + const sortedByY = [...verticallyRelevant].sort((a, b) => a.top - b.top); + + for (let i = 0; i < sortedByY.length - 1; i++) { + const current = sortedByY[i]; + const next = sortedByY[i + 1]; + // 겹치지 않고, X축이 겹치는 경우만 간격 계산 + if (current.bottom < next.top && hasHorizontalOverlap(current, next)) { + const gap = next.top - current.bottom; + if (gap > 0 && gap < 500) { + verticalGaps.push({ from: current, to: next, gap }); + } + } + } + + if (aboveElements.length > 0 && !didSpacingSnapY) { + const aboveNearest = aboveElements[0]; + const currentGap = draggedBounds.top - aboveNearest.bottom; + + for (const existingGap of verticalGaps) { + if ( + existingGap.from.id === aboveNearest.id || + existingGap.to.id === aboveNearest.id + ) + continue; + + const diff = Math.abs(currentGap - existingGap.gap); + if (diff <= SPACING_SNAP_THRESHOLD && diff > 0) { + snappedY = aboveNearest.bottom + existingGap.gap; + didSpacingSnapY = true; + + spacingGuides.push({ + type: 'spacing', + direction: 'vertical', + value: existingGap.gap, + startPos: existingGap.from.bottom, + endPos: existingGap.to.top, + crossAxisPos: Math.max( + existingGap.from.centerX, + existingGap.to.centerX, + ), + fromElementId: existingGap.from.id, + toElementId: existingGap.to.id, + isMatched: true, + }); + + spacingGuides.push({ + type: 'spacing', + direction: 'vertical', + value: existingGap.gap, + startPos: aboveNearest.bottom, + endPos: snappedY, + crossAxisPos: Math.max(aboveNearest.centerX, draggedBounds.centerX), + fromElementId: aboveNearest.id, + toElementId: draggedBounds.id, + isMatched: true, + }); + break; + } + } + } + + if (belowElements.length > 0 && !didSpacingSnapY) { + const belowNearest = belowElements[0]; + const currentGap = belowNearest.top - draggedBounds.bottom; + + for (const existingGap of verticalGaps) { + if ( + existingGap.from.id === belowNearest.id || + existingGap.to.id === belowNearest.id + ) + continue; + + const diff = Math.abs(currentGap - existingGap.gap); + if (diff <= SPACING_SNAP_THRESHOLD && diff > 0) { + snappedY = belowNearest.top - existingGap.gap - draggedBounds.height; + didSpacingSnapY = true; + + spacingGuides.push({ + type: 'spacing', + direction: 'vertical', + value: existingGap.gap, + startPos: existingGap.from.bottom, + endPos: existingGap.to.top, + crossAxisPos: Math.max( + existingGap.from.centerX, + existingGap.to.centerX, + ), + fromElementId: existingGap.from.id, + toElementId: existingGap.to.id, + isMatched: true, + }); + + spacingGuides.push({ + type: 'spacing', + direction: 'vertical', + value: existingGap.gap, + startPos: snappedY + draggedBounds.height, + endPos: belowNearest.top, + crossAxisPos: Math.max(belowNearest.centerX, draggedBounds.centerX), + fromElementId: draggedBounds.id, + toElementId: belowNearest.id, + isMatched: true, + }); + break; + } + } + } + + // 3개 요소 등간격 스냅 (위 - dragged - 아래) + if ( + aboveElements.length > 0 && + belowElements.length > 0 && + !didSpacingSnapY + ) { + const aboveNearest = aboveElements[0]; + const belowNearest = belowElements[0]; + const totalSpace = + belowNearest.top - aboveNearest.bottom - draggedBounds.height; + + if (totalSpace > 0) { + const equalGap = totalSpace / 2; + const idealY = aboveNearest.bottom + equalGap; + const diff = Math.abs(draggedBounds.top - idealY); + + if (diff <= SPACING_SNAP_THRESHOLD && diff > 0) { + snappedY = idealY; + didSpacingSnapY = true; + + spacingGuides.push({ + type: 'spacing', + direction: 'vertical', + value: Math.round(equalGap), + startPos: aboveNearest.bottom, + endPos: idealY, + crossAxisPos: Math.max(aboveNearest.centerX, draggedBounds.centerX), + fromElementId: aboveNearest.id, + toElementId: draggedBounds.id, + isMatched: true, + }); + + spacingGuides.push({ + type: 'spacing', + direction: 'vertical', + value: Math.round(equalGap), + startPos: idealY + draggedBounds.height, + endPos: belowNearest.top, + crossAxisPos: Math.max(belowNearest.centerX, draggedBounds.centerX), + fromElementId: draggedBounds.id, + toElementId: belowNearest.id, + isMatched: true, + }); + } + } + } + + return { + snappedX, + snappedY, + spacingGuides, + didSpacingSnapX, + didSpacingSnapY, + }; +} diff --git a/src/renderer/utils/grid/smartGuides/types.ts b/src/renderer/utils/grid/smartGuides/types.ts new file mode 100644 index 000000000..2f31a4c9c --- /dev/null +++ b/src/renderer/utils/grid/smartGuides/types.ts @@ -0,0 +1,89 @@ +export interface ElementBounds { + id: string; + left: number; + top: number; + right: number; + bottom: number; + centerX: number; + centerY: number; + width: number; + height: number; +} + +export interface GuideLine { + type: 'vertical' | 'horizontal'; + position: number; // 가이드라인의 x 또는 y 위치 + alignType: 'left' | 'center' | 'right' | 'top' | 'middle' | 'bottom'; +} + +/** + * 간격 가이드 인터페이스 + * 요소 사이의 간격을 시각화 + */ +export interface SpacingGuide { + type: 'spacing'; + direction: 'horizontal' | 'vertical'; + value: number; // 간격 값 (px) + // 간격 표시 위치 + startPos: number; // 간격 시작 위치 (x 또는 y) + endPos: number; // 간격 끝 위치 (x 또는 y) + crossAxisPos: number; // 교차 축 위치 (라벨 표시용) + // 관련 요소 + fromElementId: string; + toElementId: string; + // 이 간격이 다른 간격과 일치하여 스냅됐는지 + isMatched: boolean; +} + +/** + * 크기 일치 가이드 인터페이스 + * 리사이즈 시 다른 요소와 동일한 크기로 스냅 + */ +export interface SizeMatchGuide { + type: 'size-match'; + dimension: 'width' | 'height'; + value: number; // 일치하는 크기 값 + position: { x: number; y: number }; // 표시 위치 + matchedElementId: string; + // 일치하는 요소의 bounds (테두리 표시용) + matchedElementBounds: { + left: number; + top: number; + width: number; + height: number; + }; +} + +export interface SnapResult { + snappedX: number; + snappedY: number; + guides: GuideLine[]; + spacingGuides: SpacingGuide[]; + didSnapX: boolean; + didSnapY: boolean; + // 간격 스냅 여부 + didSpacingSnapX: boolean; + didSpacingSnapY: boolean; +} + +export interface SizeSnapResult { + snappedWidth: number; + snappedHeight: number; + sizeMatchGuides: SizeMatchGuide[]; + didSnapWidth: boolean; + didSnapHeight: boolean; +} + +/** + * calculateSnapPoints 옵션 인터페이스 + */ +export interface SnapPointsOptions { + /** 그룹 선택 시 전체 그룹의 bounds (캔버스 중앙 스냅에 사용) */ + groupBounds?: ElementBounds | null; + + /** 간격(Spacing) 가이드/스냅 계산을 비활성화 */ + disableSpacing?: boolean; + + /** 캔버스 중앙 스냅 결과를 맞출 그리드 크기, 0이면 반올림 없음 */ + gridSnapSize?: number; +} From cb39221a73818c32b4711eb07390bf6c2137b862 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 22:41:51 +0900 Subject: [PATCH 235/245] =?UTF-8?q?test:=20=EB=93=9C=EB=A1=AD=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=20=EB=9F=B0=ED=83=80=EC=9E=84=20=EA=B3=84=EC=95=BD=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/common/Dropdown.runtime.test.tsx | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 src/renderer/components/main/common/Dropdown.runtime.test.tsx diff --git a/src/renderer/components/main/common/Dropdown.runtime.test.tsx b/src/renderer/components/main/common/Dropdown.runtime.test.tsx new file mode 100644 index 000000000..2c375ca3a --- /dev/null +++ b/src/renderer/components/main/common/Dropdown.runtime.test.tsx @@ -0,0 +1,132 @@ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import Dropdown from './Dropdown'; + +const observers: ResizeObserverStub[] = []; + +class ResizeObserverStub { + observe = vi.fn(); + disconnect = vi.fn(); + + constructor() { + observers.push(this); + } +} + +const options = [ + { label: 'One', value: 'one' }, + { label: 'Two', value: 'two' }, +]; + +const openListbox = () => + document.querySelector( + '[role="listbox"]:not([data-dmn-motion-state="closing"])', + ); + +describe('Dropdown 런타임 계약', () => { + let host: HTMLDivElement; + let root: Root; + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + observers.length = 0; + vi.stubGlobal('ResizeObserver', ResizeObserverStub); + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + host.remove(); + document.body.innerHTML = ''; + }); + + const renderDropdown = async ( + onChange: (value: string) => void = vi.fn(), + ) => { + await act(async () => { + root.render( + , + ); + }); + return host.querySelector('[aria-haspopup="listbox"]')!; + }; + + const openMenu = async (trigger: HTMLButtonElement) => { + await act(async () => trigger.click()); + expect(openListbox()).not.toBeNull(); + }; + + it('sync 선택 콜백을 실행한 뒤 메뉴를 닫고 트리거 포커스를 복원한다', async () => { + const snapshots: Array<{ + menuOpen: boolean; + focusedRole: string | null; + }> = []; + const trigger = await renderDropdown(() => { + snapshots.push({ + menuOpen: openListbox() !== null, + focusedRole: document.activeElement?.getAttribute('role') ?? null, + }); + }); + await openMenu(trigger); + const optionButtons = Array.from( + document.querySelectorAll('[role="option"]'), + ); + + await act(async () => { + optionButtons[1].focus(); + optionButtons[1].click(); + }); + + expect(snapshots).toEqual([{ menuOpen: true, focusedRole: 'option' }]); + expect(openListbox()).toBeNull(); + expect(document.activeElement).toBe(trigger); + }); + + it('메뉴 내부 스크롤은 유지하고 외부 스크롤·resize·바깥 클릭은 닫는다', async () => { + const trigger = await renderDropdown(); + + await openMenu(trigger); + await act(async () => { + openListbox()?.dispatchEvent(new Event('scroll', { bubbles: true })); + }); + expect(openListbox()).not.toBeNull(); + + await act(async () => { + window.dispatchEvent(new Event('scroll')); + }); + expect(openListbox()).toBeNull(); + + await openMenu(trigger); + await act(async () => { + window.dispatchEvent(new Event('resize')); + }); + expect(openListbox()).toBeNull(); + + await openMenu(trigger); + await act(async () => { + document.body.dispatchEvent( + new MouseEvent('mousedown', { bubbles: true }), + ); + }); + expect(openListbox()).toBeNull(); + }); + + it('열린 표면을 관찰하고 닫힐 때 ResizeObserver를 해제한다', async () => { + const trigger = await renderDropdown(); + await openMenu(trigger); + + const observer = observers.at(-1); + expect(observer?.observe).toHaveBeenCalledWith(openListbox()); + expect(observer?.disconnect).not.toHaveBeenCalled(); + + await act(async () => trigger.click()); + + expect(observer?.disconnect).toHaveBeenCalledTimes(1); + }); +}); From 2171196eac5a0bc302b2ac6b3f242a406f6a8346 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 22:41:51 +0900 Subject: [PATCH 236/245] =?UTF-8?q?refactor:=20=EB=93=9C=EB=A1=AD=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=20=EB=9F=B0=ED=83=80=EC=9E=84=20=EA=B2=BD=EA=B3=84=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/main/common/Dropdown.tsx | 463 +---------------- .../main/common/useDropdownRuntime.tsx | 478 ++++++++++++++++++ 2 files changed, 498 insertions(+), 443 deletions(-) create mode 100644 src/renderer/components/main/common/useDropdownRuntime.tsx diff --git a/src/renderer/components/main/common/Dropdown.tsx b/src/renderer/components/main/common/Dropdown.tsx index 4b83d71dc..895e9d41d 100644 --- a/src/renderer/components/main/common/Dropdown.tsx +++ b/src/renderer/components/main/common/Dropdown.tsx @@ -1,30 +1,6 @@ -import React, { - useState, - useRef, - useId, - useCallback, - useEffect, - useLayoutEffect, -} from 'react'; -import { createPortal } from 'react-dom'; -import { usePopupPresence } from '@hooks/ui/usePopupPresence'; -import { - hasModalLayerAbove, - isTopmostPopupLayer, - registerPopupLayer, - subscribeModalLayerActivity, -} from '../Modal/popupLayer'; -import { useOptimisticValueCommit } from '@hooks/useOptimisticValueCommit'; +import React from 'react'; import type { CommitStrategy } from '@hooks/useOptimisticBooleanCommit'; -import { clampToViewport } from '@utils/ui/popupGeometry'; -import { usePanelHost } from '@contexts/PanelHostContext'; -import { isNodeLike } from '@utils/dom/isElementNode'; -import { CANVAS_POPUP_CHROME_CLASS } from '../Modal/popupChrome'; - -interface DropdownOption { - label: string; - value: string; -} +import { useDropdownRuntime, type DropdownOption } from './useDropdownRuntime'; interface DropdownProps { options: DropdownOption[]; @@ -47,17 +23,6 @@ interface DropdownProps { size?: 'sm' | 'lg'; } -// 확정 픽셀 좌표 — 전부 레이아웃 속성이라 transform 합성 지연과 무관. -// right/bottom 정렬은 해당 모서리 좌표로 고정해 내용 크기가 변해도 유지 -interface MenuPosition { - left?: number; - right?: number; - top?: number; - bottom?: number; - // 등퇴장 원점을 실제 펼침 방향에 맞추기 위한 배치 결과 - placement: 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end'; -} - const Dropdown: React.FC = ({ options, value, @@ -72,413 +37,25 @@ const Dropdown: React.FC = ({ size = 'sm', ariaLabel, }) => { - // 분리 패널 창 안에서는 그 창 기준으로 배치·포털·바깥 클릭 처리 - const { window: ownerWindow, document: ownerDocument } = usePanelHost(); - const [open, setOpen] = useState(false); - // 트리거 실측 — 열리는 순간 캡처, 메뉴 좌표 계산의 기준 - const [anchor, setAnchor] = useState(null); - // null이면 아직 미실측 — 히든 렌더 후 layout effect에서 확정 - const [menuPos, setMenuPos] = useState(null); - const [activeIndex, setActiveIndex] = useState(-1); - const ref = useRef(null); - const buttonRef = useRef(null); - const menuRef = useRef(null); - const optionRefs = useRef>([]); - const menuId = useId(); - const { value: visualValue, select: commitSelection } = - useOptimisticValueCommit({ - canonicalValue: value, - onCommit: onChange, - strategy: commitStrategy, - }); - - // 좌표가 확정되기 전에는 감춘 채 실측하므로, 그 프레임에 등장 모션이 - // 소비되지 않게 ready를 늦춘다 - const { mounted, state: motionState } = usePopupPresence(open, { - ready: Boolean(menuPos), - motionRef: menuRef, + const { + buttonRef, + handleTriggerKeyDown, + menu, + menuId, + open, + ref, + selected, + toggleOpen, + } = useDropdownRuntime({ + options, + value, + onChange, + commitStrategy, + disabled, + fullWidth, + align, + widthClass, }); - - const selectedIndex = options.findIndex( - (option) => option.value === visualValue, - ); - - const openMenu = useCallback( - (preferredIndex?: number) => { - const button = buttonRef.current; - if (!button || disabled || options.length === 0) return; - setAnchor(button.getBoundingClientRect()); - // 닫히는 중 재오픈은 이전 좌표를 이어받는다. 같은 트리거라 위치가 같고, - // null로 되돌리면 감춰진 프레임이 한 번 껴서 튄다. - // 스크롤·리사이즈는 메뉴를 닫으므로 그사이 앵커가 움직일 일은 없다 - if (!mounted) setMenuPos(null); - const fallbackIndex = selectedIndex >= 0 ? selectedIndex : 0; - setActiveIndex( - Math.min( - Math.max(preferredIndex ?? fallbackIndex, 0), - options.length - 1, - ), - ); - setOpen(true); - }, - [disabled, mounted, options.length, selectedIndex], - ); - - // 메뉴는 body로 포털 — 패널/모달의 backdrop-filter·mask 아래에서는 - // 중첩 backdrop-blur가 무력화되므로 backdrop root 밖에서 그린다 - const toggleOpen = () => { - if (open) { - setOpen(false); - return; - } - openMenu(); - }; - - const closeAndFocusTrigger = useCallback(() => { - setOpen(false); - buttonRef.current?.focus(); - }, []); - - const selectOption = useCallback( - (index: number) => { - const option = options[index]; - if (!option) return; - if (commitStrategy === 'sync') { - commitSelection(option.value); - closeAndFocusTrigger(); - return; - } - closeAndFocusTrigger(); - commitSelection(option.value); - }, - [closeAndFocusTrigger, commitSelection, commitStrategy, options], - ); - - const moveActiveOption = useCallback( - (nextIndex: number) => { - if (options.length === 0) return; - const normalized = (nextIndex + options.length) % options.length; - setActiveIndex(normalized); - }, - [options.length], - ); - - // 실측 기반 확정 배치 — 히든 렌더한 메뉴의 레이아웃 크기(offsetWidth/Height, - // transform 무관)를 재서 최종 픽셀 좌표를 한 번에 확정하고 표시. - // 보이는 첫 프레임이 곧 확정 위치라 중간 위치가 비칠 수 없고, - // translate 같은 합성 단계 이동이 없어 WKWebView 레이어 지연에도 안전 - const place = useCallback(() => { - const menu = menuRef.current; - if (!anchor || !menu) return; - - const menuWidth = menu.offsetWidth; - const menuHeight = menu.offsetHeight; - const margin = 8; - const gap = 4; - // 하단 상주 크롬(미니 메뉴 바) 회피 여백 - const bottomPadding = 60; - - // right 정렬은 right 좌표로 고정 — 열린 뒤 내용 폭이 변해도 우측 모서리 유지 - let horizontal: Omit; - let alignment: 'start' | 'end'; - if (!fullWidth && align === 'right') { - horizontal = { - right: clampToViewport( - ownerWindow.innerWidth - anchor.right, - menuWidth, - ownerWindow.innerWidth, - margin, - ), - }; - alignment = 'end'; - } else { - horizontal = { - left: clampToViewport( - anchor.left, - menuWidth, - ownerWindow.innerWidth, - margin, - ), - }; - alignment = 'start'; - } - - // 버튼 아래 공간이 부족하면 위로 펼치기 — 위로 열릴 땐 bottom 좌표로 - // 고정해 내용 높이가 변해도 위쪽으로 자라게 유지 - const openUpward = - anchor.bottom + gap + menuHeight > - ownerWindow.innerHeight - bottomPadding; - let vertical: Omit; - if (openUpward) { - vertical = { - bottom: clampToViewport( - ownerWindow.innerHeight - anchor.top + gap, - menuHeight, - ownerWindow.innerHeight, - margin, - ), - }; - } else { - vertical = { - top: clampToViewport( - anchor.bottom + gap, - menuHeight, - ownerWindow.innerHeight, - margin, - ), - }; - } - - const next: MenuPosition = { - ...horizontal, - ...vertical, - placement: `${openUpward ? 'top' : 'bottom'}-${alignment}`, - }; - setMenuPos((prev) => - prev && - prev.left === next.left && - prev.right === next.right && - prev.top === next.top && - prev.bottom === next.bottom && - prev.placement === next.placement - ? prev - : next, - ); - }, [anchor, fullWidth, align, ownerWindow]); - - useLayoutEffect(() => { - if (!open || !mounted || menuPos) return; - // eslint-disable-next-line react-hooks/set-state-in-effect - place(); - }, [open, mounted, menuPos, place]); - - // 열린 뒤 옵션이 사라지면 빈 포털을 남기지 않고 트리거로 복귀 - useLayoutEffect(() => { - if (!open || options.length > 0) return; - // eslint-disable-next-line react-hooks/set-state-in-effect - closeAndFocusTrigger(); - }, [closeAndFocusTrigger, open, options.length]); - - useLayoutEffect(() => { - if (!open || !menuPos || activeIndex < 0) return; - optionRefs.current[activeIndex]?.focus(); - }, [activeIndex, menuPos, open]); - - // 닫힘 모션이 도는 동안에도 DOM은 남지만 레이어 소유권은 즉시 놓는다. - // 아니면 닫히는 메뉴가 150ms 동안 Escape를 계속 먹는다 - useLayoutEffect(() => { - const menu = menuRef.current; - if (!open || !mounted || !menu) return; - return registerPopupLayer(menu); - }, [anchor, mounted, open]); - - // 모달이 덮이면 body 포털 메뉴는 inert 루트 밖이라 살아남는다 — 스스로 닫는다. - // 열린 동안만 명령형 구독 (Dropdown은 패널 전역에 깔린 핫 컴포넌트라 상시 구독 회피). - // 트리거 포커스 복귀는 하지 않는다 - 트리거는 이미 inert이고 모달 포커스를 뺏는다 - useEffect(() => { - if (!open || !mounted) return; - const closeIfCovered = () => { - if (hasModalLayerAbove(menuRef.current)) setOpen(false); - }; - closeIfCovered(); - return subscribeModalLayerActivity(closeIfCovered); - }, [mounted, open]); - - // 열린 동안 내용 크기 변화(비동기 옵션 로드 등) 시 클램프·플립 재계산 - useEffect(() => { - if (!open || !mounted) return; - const menu = menuRef.current; - if (!menu) return; - const observer = new ResizeObserver(() => place()); - observer.observe(menu); - return () => observer.disconnect(); - }, [mounted, open, place]); - - // 열린 채로 비활성화되면 닫는다 — 트리거만 죽고 포털 메뉴가 살아남는 것 방지 - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - if (disabled) setOpen(false); - }, [disabled]); - - // Escape 소유 — 최상위 포털 레이어이므로 소비 후 자신만 닫기 - useEffect(() => { - if (!open) return; - const handleKey = (event: KeyboardEvent) => { - if (event.key !== 'Escape' || event.defaultPrevented) return; - if (!isTopmostPopupLayer(menuRef.current)) return; - event.preventDefault(); - closeAndFocusTrigger(); - }; - ownerDocument.addEventListener('keydown', handleKey); - return () => ownerDocument.removeEventListener('keydown', handleKey); - }, [closeAndFocusTrigger, open, ownerDocument]); - - const handleTriggerKeyDown = (event: React.KeyboardEvent) => { - if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return; - event.preventDefault(); - const preferredIndex = - selectedIndex >= 0 - ? selectedIndex - : event.key === 'ArrowUp' - ? options.length - 1 - : 0; - if (open) { - moveActiveOption(activeIndex + (event.key === 'ArrowDown' ? 1 : -1)); - } else { - openMenu(preferredIndex); - } - }; - - const handleOptionKeyDown = ( - event: React.KeyboardEvent, - index: number, - ) => { - switch (event.key) { - case 'ArrowDown': - event.preventDefault(); - moveActiveOption(index + 1); - break; - case 'ArrowUp': - event.preventDefault(); - moveActiveOption(index - 1); - break; - case 'Home': - event.preventDefault(); - setActiveIndex(0); - break; - case 'End': - event.preventDefault(); - setActiveIndex(options.length - 1); - break; - case 'Enter': - case ' ': - event.preventDefault(); - selectOption(index); - break; - case 'Tab': { - event.preventDefault(); - const selector = - 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; - const modalScope = buttonRef.current?.closest( - '[data-dmn-modal-backdrop="true"]', - ); - const popupScope = buttonRef.current?.closest( - '[data-dmn-popup-layer="true"]', - ); - const focusScope: ParentNode = popupScope ?? modalScope ?? document; - const tabStops = Array.from( - focusScope.querySelectorAll(selector), - ).filter( - (element) => - !menuRef.current?.contains(element) && - !element.closest('[hidden], [aria-hidden="true"]'), - ); - const triggerIndex = buttonRef.current - ? tabStops.indexOf(buttonRef.current) - : -1; - const nextIndex = event.shiftKey ? triggerIndex - 1 : triggerIndex + 1; - const wrappedIndex = event.shiftKey ? tabStops.length - 1 : 0; - setOpen(false); - ( - tabStops[nextIndex] ?? - (popupScope || modalScope - ? tabStops[wrappedIndex] - : buttonRef.current) - )?.focus(); - break; - } - } - }; - - useEffect(() => { - if (!open) return; - - const handleClickOutside = (event: MouseEvent) => { - const target = event.target as Node; - if (ref.current?.contains(target)) return; - if (menuRef.current?.contains(target)) return; - setOpen(false); - }; - // 트리거가 스크롤/리사이즈로 움직이면 좌표가 어긋나므로 닫는다 - // (메뉴 내부 스크롤은 제외) - const handleScroll = (event: Event) => { - if (isNodeLike(event.target) && menuRef.current?.contains(event.target)) { - return; - } - setOpen(false); - }; - - ownerDocument.addEventListener('mousedown', handleClickOutside); - ownerWindow.addEventListener('scroll', handleScroll, true); - ownerWindow.addEventListener('resize', handleScroll); - return () => { - ownerDocument.removeEventListener('mousedown', handleClickOutside); - ownerWindow.removeEventListener('scroll', handleScroll, true); - ownerWindow.removeEventListener('resize', handleScroll); - }; - }, [open, ownerDocument, ownerWindow]); - - const selected = options.find((opt) => opt.value === visualValue); - - const menu = - mounted && anchor - ? createPortal( -
- {options.length === 0 ? ( -
- 옵션 없음 -
- ) : ( - options.map((opt, index) => ( - - )) - )} -
, - ownerDocument.body, - ) - : null; - return (
void; + commitStrategy: CommitStrategy; + disabled: boolean; + fullWidth: boolean; + align: 'left' | 'right'; + widthClass: string; +} + +export const useDropdownRuntime = ({ + options, + value, + onChange, + commitStrategy, + disabled, + fullWidth, + align, + widthClass, +}: UseDropdownRuntimeOptions) => { + // 분리 패널 창 안에서는 그 창 기준으로 배치·포털·바깥 클릭 처리 + const { window: ownerWindow, document: ownerDocument } = usePanelHost(); + const [open, setOpen] = useState(false); + // 트리거 실측 — 열리는 순간 캡처, 메뉴 좌표 계산의 기준 + const [anchor, setAnchor] = useState(null); + // null이면 아직 미실측 — 히든 렌더 후 layout effect에서 확정 + const [menuPos, setMenuPos] = useState(null); + const [activeIndex, setActiveIndex] = useState(-1); + const ref = useRef(null); + const buttonRef = useRef(null); + const menuRef = useRef(null); + const optionRefs = useRef>([]); + const menuId = useId(); + const { value: visualValue, select: commitSelection } = + useOptimisticValueCommit({ + canonicalValue: value, + onCommit: onChange, + strategy: commitStrategy, + }); + + // 좌표가 확정되기 전에는 감춘 채 실측하므로, 그 프레임에 등장 모션이 + // 소비되지 않게 ready를 늦춘다 + const { mounted, state: motionState } = usePopupPresence(open, { + ready: Boolean(menuPos), + motionRef: menuRef, + }); + + const selectedIndex = options.findIndex( + (option) => option.value === visualValue, + ); + + const openMenu = useCallback( + (preferredIndex?: number) => { + const button = buttonRef.current; + if (!button || disabled || options.length === 0) return; + setAnchor(button.getBoundingClientRect()); + // 닫히는 중 재오픈은 이전 좌표를 이어받는다. 같은 트리거라 위치가 같고, + // null로 되돌리면 감춰진 프레임이 한 번 껴서 튄다. + // 스크롤·리사이즈는 메뉴를 닫으므로 그사이 앵커가 움직일 일은 없다 + if (!mounted) setMenuPos(null); + const fallbackIndex = selectedIndex >= 0 ? selectedIndex : 0; + setActiveIndex( + Math.min( + Math.max(preferredIndex ?? fallbackIndex, 0), + options.length - 1, + ), + ); + setOpen(true); + }, + [disabled, mounted, options.length, selectedIndex], + ); + + // 메뉴는 body로 포털 — 패널/모달의 backdrop-filter·mask 아래에서는 + // 중첩 backdrop-blur가 무력화되므로 backdrop root 밖에서 그린다 + const toggleOpen = () => { + if (open) { + setOpen(false); + return; + } + openMenu(); + }; + + const closeAndFocusTrigger = useCallback(() => { + setOpen(false); + buttonRef.current?.focus(); + }, []); + + const selectOption = useCallback( + (index: number) => { + const option = options[index]; + if (!option) return; + if (commitStrategy === 'sync') { + commitSelection(option.value); + closeAndFocusTrigger(); + return; + } + closeAndFocusTrigger(); + commitSelection(option.value); + }, + [closeAndFocusTrigger, commitSelection, commitStrategy, options], + ); + + const moveActiveOption = useCallback( + (nextIndex: number) => { + if (options.length === 0) return; + const normalized = (nextIndex + options.length) % options.length; + setActiveIndex(normalized); + }, + [options.length], + ); + + // 실측 기반 확정 배치 — 히든 렌더한 메뉴의 레이아웃 크기(offsetWidth/Height, + // transform 무관)를 재서 최종 픽셀 좌표를 한 번에 확정하고 표시. + // 보이는 첫 프레임이 곧 확정 위치라 중간 위치가 비칠 수 없고, + // translate 같은 합성 단계 이동이 없어 WKWebView 레이어 지연에도 안전 + const place = useCallback(() => { + const menu = menuRef.current; + if (!anchor || !menu) return; + + const menuWidth = menu.offsetWidth; + const menuHeight = menu.offsetHeight; + const margin = 8; + const gap = 4; + // 하단 상주 크롬(미니 메뉴 바) 회피 여백 + const bottomPadding = 60; + + // right 정렬은 right 좌표로 고정 — 열린 뒤 내용 폭이 변해도 우측 모서리 유지 + let horizontal: Omit; + let alignment: 'start' | 'end'; + if (!fullWidth && align === 'right') { + horizontal = { + right: clampToViewport( + ownerWindow.innerWidth - anchor.right, + menuWidth, + ownerWindow.innerWidth, + margin, + ), + }; + alignment = 'end'; + } else { + horizontal = { + left: clampToViewport( + anchor.left, + menuWidth, + ownerWindow.innerWidth, + margin, + ), + }; + alignment = 'start'; + } + + // 버튼 아래 공간이 부족하면 위로 펼치기 — 위로 열릴 땐 bottom 좌표로 + // 고정해 내용 높이가 변해도 위쪽으로 자라게 유지 + const openUpward = + anchor.bottom + gap + menuHeight > + ownerWindow.innerHeight - bottomPadding; + let vertical: Omit; + if (openUpward) { + vertical = { + bottom: clampToViewport( + ownerWindow.innerHeight - anchor.top + gap, + menuHeight, + ownerWindow.innerHeight, + margin, + ), + }; + } else { + vertical = { + top: clampToViewport( + anchor.bottom + gap, + menuHeight, + ownerWindow.innerHeight, + margin, + ), + }; + } + + const next: MenuPosition = { + ...horizontal, + ...vertical, + placement: `${openUpward ? 'top' : 'bottom'}-${alignment}`, + }; + setMenuPos((prev) => + prev && + prev.left === next.left && + prev.right === next.right && + prev.top === next.top && + prev.bottom === next.bottom && + prev.placement === next.placement + ? prev + : next, + ); + }, [anchor, fullWidth, align, ownerWindow]); + + useLayoutEffect(() => { + if (!open || !mounted || menuPos) return; + // eslint-disable-next-line react-hooks/set-state-in-effect + place(); + }, [open, mounted, menuPos, place]); + + // 열린 뒤 옵션이 사라지면 빈 포털을 남기지 않고 트리거로 복귀 + useLayoutEffect(() => { + if (!open || options.length > 0) return; + // eslint-disable-next-line react-hooks/set-state-in-effect + closeAndFocusTrigger(); + }, [closeAndFocusTrigger, open, options.length]); + + useLayoutEffect(() => { + if (!open || !menuPos || activeIndex < 0) return; + optionRefs.current[activeIndex]?.focus(); + }, [activeIndex, menuPos, open]); + + // 닫힘 모션이 도는 동안에도 DOM은 남지만 레이어 소유권은 즉시 놓는다. + // 아니면 닫히는 메뉴가 150ms 동안 Escape를 계속 먹는다 + useLayoutEffect(() => { + const menu = menuRef.current; + if (!open || !mounted || !menu) return; + return registerPopupLayer(menu); + }, [anchor, mounted, open]); + + // 모달이 덮이면 body 포털 메뉴는 inert 루트 밖이라 살아남는다 — 스스로 닫는다. + // 열린 동안만 명령형 구독 (Dropdown은 패널 전역에 깔린 핫 컴포넌트라 상시 구독 회피). + // 트리거 포커스 복귀는 하지 않는다 - 트리거는 이미 inert이고 모달 포커스를 뺏는다 + useEffect(() => { + if (!open || !mounted) return; + const closeIfCovered = () => { + if (hasModalLayerAbove(menuRef.current)) setOpen(false); + }; + closeIfCovered(); + return subscribeModalLayerActivity(closeIfCovered); + }, [mounted, open]); + + // 열린 동안 내용 크기 변화(비동기 옵션 로드 등) 시 클램프·플립 재계산 + useEffect(() => { + if (!open || !mounted) return; + const menu = menuRef.current; + if (!menu) return; + const observer = new ResizeObserver(() => place()); + observer.observe(menu); + return () => observer.disconnect(); + }, [mounted, open, place]); + + // 열린 채로 비활성화되면 닫는다 — 트리거만 죽고 포털 메뉴가 살아남는 것 방지 + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + if (disabled) setOpen(false); + }, [disabled]); + + // Escape 소유 — 최상위 포털 레이어이므로 소비 후 자신만 닫기 + useEffect(() => { + if (!open) return; + const handleKey = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || event.defaultPrevented) return; + if (!isTopmostPopupLayer(menuRef.current)) return; + event.preventDefault(); + closeAndFocusTrigger(); + }; + ownerDocument.addEventListener('keydown', handleKey); + return () => ownerDocument.removeEventListener('keydown', handleKey); + }, [closeAndFocusTrigger, open, ownerDocument]); + + const handleTriggerKeyDown = (event: React.KeyboardEvent) => { + if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return; + event.preventDefault(); + const preferredIndex = + selectedIndex >= 0 + ? selectedIndex + : event.key === 'ArrowUp' + ? options.length - 1 + : 0; + if (open) { + moveActiveOption(activeIndex + (event.key === 'ArrowDown' ? 1 : -1)); + } else { + openMenu(preferredIndex); + } + }; + + const handleOptionKeyDown = ( + event: React.KeyboardEvent, + index: number, + ) => { + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + moveActiveOption(index + 1); + break; + case 'ArrowUp': + event.preventDefault(); + moveActiveOption(index - 1); + break; + case 'Home': + event.preventDefault(); + setActiveIndex(0); + break; + case 'End': + event.preventDefault(); + setActiveIndex(options.length - 1); + break; + case 'Enter': + case ' ': + event.preventDefault(); + selectOption(index); + break; + case 'Tab': { + event.preventDefault(); + const selector = + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + const modalScope = buttonRef.current?.closest( + '[data-dmn-modal-backdrop="true"]', + ); + const popupScope = buttonRef.current?.closest( + '[data-dmn-popup-layer="true"]', + ); + const focusScope: ParentNode = popupScope ?? modalScope ?? document; + const tabStops = Array.from( + focusScope.querySelectorAll(selector), + ).filter( + (element) => + !menuRef.current?.contains(element) && + !element.closest('[hidden], [aria-hidden="true"]'), + ); + const triggerIndex = buttonRef.current + ? tabStops.indexOf(buttonRef.current) + : -1; + const nextIndex = event.shiftKey ? triggerIndex - 1 : triggerIndex + 1; + const wrappedIndex = event.shiftKey ? tabStops.length - 1 : 0; + setOpen(false); + ( + tabStops[nextIndex] ?? + (popupScope || modalScope + ? tabStops[wrappedIndex] + : buttonRef.current) + )?.focus(); + break; + } + } + }; + + useEffect(() => { + if (!open) return; + + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node; + if (ref.current?.contains(target)) return; + if (menuRef.current?.contains(target)) return; + setOpen(false); + }; + // 트리거가 스크롤/리사이즈로 움직이면 좌표가 어긋나므로 닫는다 + // (메뉴 내부 스크롤은 제외) + const handleScroll = (event: Event) => { + if (isNodeLike(event.target) && menuRef.current?.contains(event.target)) { + return; + } + setOpen(false); + }; + + ownerDocument.addEventListener('mousedown', handleClickOutside); + ownerWindow.addEventListener('scroll', handleScroll, true); + ownerWindow.addEventListener('resize', handleScroll); + return () => { + ownerDocument.removeEventListener('mousedown', handleClickOutside); + ownerWindow.removeEventListener('scroll', handleScroll, true); + ownerWindow.removeEventListener('resize', handleScroll); + }; + }, [open, ownerDocument, ownerWindow]); + + const selected = options.find((opt) => opt.value === visualValue); + + const menu = + mounted && anchor + ? createPortal( +
+ {options.length === 0 ? ( +
+ 옵션 없음 +
+ ) : ( + options.map((opt, index) => ( + + )) + )} +
, + ownerDocument.body, + ) + : null; + + return { + buttonRef, + handleTriggerKeyDown, + menu, + menuId, + open, + ref, + selected, + toggleOpen, + }; +}; From c65e1c5822e2a1c2a349e7b732a5aa791abfbbdf Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 22:49:20 +0900 Subject: [PATCH 237/245] =?UTF-8?q?test:=20=ED=94=8C=EB=A1=9C=ED=8C=85=20?= =?UTF-8?q?=ED=8C=9D=EC=97=85=20=EB=8B=AB=ED=9E=98=20=EC=A0=95=EC=B1=85=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/Modal/FloatingPopup.dismiss.test.tsx | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 src/renderer/components/main/Modal/FloatingPopup.dismiss.test.tsx diff --git a/src/renderer/components/main/Modal/FloatingPopup.dismiss.test.tsx b/src/renderer/components/main/Modal/FloatingPopup.dismiss.test.tsx new file mode 100644 index 000000000..daa60b2d6 --- /dev/null +++ b/src/renderer/components/main/Modal/FloatingPopup.dismiss.test.tsx @@ -0,0 +1,185 @@ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import FloatingPopup from './FloatingPopup'; +import { registerPopupLayer } from './popupLayer'; + +const dispatchMouseDown = (target: Element) => { + act(() => { + target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + }); +}; + +describe('FloatingPopup dismiss 런타임 계약', () => { + let host: HTMLDivElement; + let root: Root; + let reference: HTMLButtonElement; + let interactive: HTMLButtonElement; + let outside: HTMLDivElement; + let referenceRef: React.RefObject; + let interactiveRef: React.RefObject; + let onClose: ReturnType void>>; + const layerCleanups: Array<() => void> = []; + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + host = document.createElement('div'); + reference = document.createElement('button'); + interactive = document.createElement('button'); + outside = document.createElement('div'); + referenceRef = { current: reference }; + interactiveRef = { current: interactive }; + onClose = vi.fn<() => void>(); + document.body.append(reference, interactive, outside, host); + root = createRoot(host); + }); + + afterEach(async () => { + await act(async () => + layerCleanups + .splice(0) + .reverse() + .forEach((cleanup) => cleanup()), + ); + await act(async () => root.unmount()); + document.body.innerHTML = ''; + vi.restoreAllMocks(); + }); + + const renderPopup = async (autoClose: boolean, open = true) => { + await act(async () => { + root.render( + + + , + ); + }); + }; + + const dismissTargets = async () => { + const modal = document.createElement('div'); + modal.dataset.dmnModalBackdrop = 'true'; + const submenu = document.createElement('div'); + submenu.dataset.dmnPopupSubmenu = 'true'; + const gradient = document.createElement('div'); + gradient.dataset.dmnGradientOverlay = 'true'; + const higherLayer = document.createElement('div'); + higherLayer.dataset.dmnPopupLayer = 'true'; + document.body.append(modal, submenu, gradient, higherLayer); + await act(async () => { + layerCleanups.push(registerPopupLayer(higherLayer)); + }); + return { gradient, higherLayer, modal, submenu }; + }; + + it('auto-close는 표면·trigger·포털 예외를 유지하지만 interactive ref는 예외로 두지 않는다', async () => { + await renderPopup(true); + const surface = document.querySelector( + '[data-dmn-floating-popup="true"]', + )!; + const { gradient, higherLayer, modal, submenu } = await dismissTargets(); + + for (const target of [ + surface, + reference, + modal, + submenu, + gradient, + higherLayer, + ]) { + dispatchMouseDown(target); + expect(onClose).not.toHaveBeenCalled(); + } + + dispatchMouseDown(interactive); + expect(onClose).toHaveBeenCalledTimes(1); + onClose.mockClear(); + + dispatchMouseDown(outside); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('persistent는 표면·trigger·interactive ref·포털 예외를 유지하고 외부만 닫는다', async () => { + await renderPopup(false); + const surface = document.querySelector( + '[data-dmn-floating-popup="true"]', + )!; + const { gradient, higherLayer, modal, submenu } = await dismissTargets(); + + for (const target of [ + surface, + reference, + interactive, + modal, + submenu, + gradient, + higherLayer, + ]) { + dispatchMouseDown(target); + expect(onClose).not.toHaveBeenCalled(); + } + + dispatchMouseDown(outside); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('auto-close는 bubble mousedown만 등록하고 닫힐 때 같은 listener를 해제한다', async () => { + const add = vi.spyOn(document, 'addEventListener'); + const remove = vi.spyOn(document, 'removeEventListener'); + await renderPopup(true); + + const registered = add.mock.calls.find(([type]) => type === 'mousedown'); + expect(registered?.[2]).toBeUndefined(); + add.mockClear(); + remove.mockClear(); + + await renderPopup(true, false); + + expect(remove).toHaveBeenCalledWith('mousedown', registered?.[1]); + expect( + add.mock.calls.filter(([type]) => + ['pointerup', 'pointerdown', 'mousedown'].includes(type), + ), + ).toHaveLength(0); + }); + + it('persistent는 capture pointerup·pointerdown·mousedown을 등록하고 모두 해제한다', async () => { + const add = vi.spyOn(document, 'addEventListener'); + const remove = vi.spyOn(document, 'removeEventListener'); + await renderPopup(false); + + const registered = new Map( + add.mock.calls + .filter(([type]) => + ['pointerup', 'pointerdown', 'mousedown'].includes(type), + ) + .map(([type, listener, options]) => [type, { listener, options }]), + ); + expect(Array.from(registered.keys())).toEqual([ + 'pointerup', + 'pointerdown', + 'mousedown', + ]); + expect( + Array.from(registered.values()).map(({ options }) => options), + ).toEqual([true, true, true]); + remove.mockClear(); + + await renderPopup(false, false); + + for (const [type, { listener }] of registered) { + expect(remove).toHaveBeenCalledWith(type, listener, true); + } + }); +}); From 6b8ce261dbf9781ae83911964256513c67eaebd7 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 22:49:20 +0900 Subject: [PATCH 238/245] =?UTF-8?q?refactor:=20=ED=94=8C=EB=A1=9C=ED=8C=85?= =?UTF-8?q?=20=ED=8C=9D=EC=97=85=20=EB=8B=AB=ED=9E=98=20=EB=9F=B0=ED=83=80?= =?UTF-8?q?=EC=9E=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/main/Modal/FloatingPopup.tsx | 166 ++------------- .../Modal/useFloatingPopupDismissRuntime.ts | 190 ++++++++++++++++++ 2 files changed, 211 insertions(+), 145 deletions(-) create mode 100644 src/renderer/components/main/Modal/useFloatingPopupDismissRuntime.ts diff --git a/src/renderer/components/main/Modal/FloatingPopup.tsx b/src/renderer/components/main/Modal/FloatingPopup.tsx index afc594481..f2461d086 100644 --- a/src/renderer/components/main/Modal/FloatingPopup.tsx +++ b/src/renderer/components/main/Modal/FloatingPopup.tsx @@ -30,14 +30,16 @@ import { useDeferredContentMount } from '@hooks/ui/useDeferredContentMount'; import { FloatingPopupMotionContext } from './floatingPopupMotion'; import { hasModalLayerAbove, - isInsideHigherPopupLayer, isTopmostPopupLayer, registerPopupLayer, subscribeModalLayerActivity, } from './popupLayer'; import { clampToViewport, POPUP_EDGE_PADDING } from '@utils/ui/popupGeometry'; import { usePanelHost } from '@contexts/PanelHostContext'; -import { isElementNode } from '@utils/dom/isElementNode'; +import { + useFloatingPopupAutoDismissRuntime, + useFloatingPopupPersistentDismissRuntime, +} from './useFloatingPopupDismissRuntime'; import type { CommitStrategy } from '@hooks/useOptimisticBooleanCommit'; interface FloatingPopupBaseProps { @@ -378,45 +380,14 @@ const FloatingPopup = ({ return subscribeModalLayerActivity(closeIfCovered); }, [open, onClose, closeOnModalCover]); - useEffect(() => { - if (open && autoClose) { - const onClickAway = (e: MouseEvent) => { - const target = e.target as Node; - if (!refs.floating.current) return; - if ( - refs.floating.current.contains(target) || - (referenceRef && - referenceRef.current && - referenceRef.current.contains(target)) - ) - return; - // 모달이 열린 상태에서 모달 내부 클릭으로 팝업이 닫히는 것을 방지 - // (Modal은 body로 portal 렌더링되기 때문에 floating 내부로 인식되지 않음) - const isInsideModal = - isElementNode(target) && - !!target.closest('[data-dmn-modal-backdrop="true"]'); - if (isInsideModal) return; - // 서브메뉴도 body 포털이라 floating 내부로 인식되지 않음 — 닫힘 예외 - const isInsideSubMenu = - isElementNode(target) && - !!target.closest('[data-dmn-popup-submenu="true"]'); - if (isInsideSubMenu) return; - // 온캔버스 그라데이션 핸들 조작도 팝업 편집의 연장 — 닫힘 예외 - const isInsideGradientOverlay = - isElementNode(target) && - !!target.closest('[data-dmn-gradient-overlay="true"]'); - if (isInsideGradientOverlay) return; - // 위에 쌓인 팝업(자식 피커 등)은 body 포털이라 floating 내부로 인식되지 않음 - if (isInsideHigherPopupLayer(refs.floating.current, target)) return; - onClose(); - }; - - ownerDocument.addEventListener('mousedown', onClickAway); - return () => { - ownerDocument.removeEventListener('mousedown', onClickAway); - }; - } - }, [open, autoClose, onClose, referenceRef, refs.floating, ownerDocument]); + useFloatingPopupAutoDismissRuntime({ + open, + autoClose, + onClose, + referenceRef, + refs, + ownerDocument, + }); useEffect(() => { if (open) update?.(); @@ -500,110 +471,15 @@ const FloatingPopup = ({ }; }, [mounted, isFixedMode, place, ownerWindow]); - useEffect(() => { - if (!open || autoClose) return; - - let pointerCapturedInside = false; - - const referenceEl = referenceRef?.current ?? null; - - const _handlePointerDownInside = () => { - pointerCapturedInside = true; - }; - - const handlePointerUp = () => { - pointerCapturedInside = false; - }; - - const handleDocumentDown = (event: PointerEvent) => { - const target = event.target as Node; - // floatingRef.current를 이벤트 발생 시점에 동적으로 참조 - const floatingEl = floatingRef.current; - const interactiveEls = interactiveRefs - .map((r) => r?.current) - .filter(Boolean) as HTMLElement[]; - const isInsideModal = - isElementNode(target) && - !!target.closest('[data-dmn-modal-backdrop="true"]'); - - if (!floatingEl) return; - - const isInsideFloating = floatingEl.contains(target); - const isInsideReference = referenceEl?.contains(target) ?? false; - const isInsideInteractive = interactiveEls.some((el) => - el.contains(target as Node), - ); - - if (isInsideFloating) { - pointerCapturedInside = true; - return; - } - - if ( - pointerCapturedInside && - (event.type === 'pointerdown' || event.type === 'mousedown') - ) { - pointerCapturedInside = false; - } - - if (isInsideReference || isInsideInteractive) { - pointerCapturedInside = false; - return; - } - - // 모달이 열린 상태에서 모달 내부 클릭으로 팝업이 닫히는 것을 방지. - // (Modal은 body로 portal 렌더링되기 때문에 단순 z-index로는 해결이 안 됨) - if (isInsideModal) { - pointerCapturedInside = false; - return; - } - - // 서브메뉴·포털 드롭다운도 body 포털이라 floating 내부로 인식되지 않음 - const isInsideSubMenu = - isElementNode(target) && - !!target.closest('[data-dmn-popup-submenu="true"]'); - if (isInsideSubMenu) { - pointerCapturedInside = false; - return; - } - - // 온캔버스 그라데이션 핸들 조작도 팝업 편집의 연장 — 닫힘 예외 - const isInsideGradientOverlay = - isElementNode(target) && - !!target.closest('[data-dmn-gradient-overlay="true"]'); - if (isInsideGradientOverlay) { - pointerCapturedInside = false; - return; - } - - // 위에 쌓인 팝업(자식 피커 등)은 body 포털이라 floating 내부로 인식되지 않음 - if (isInsideHigherPopupLayer(floatingEl, target)) { - pointerCapturedInside = false; - return; - } - - if (pointerCapturedInside) { - return; - } - - onClose(); - }; - - // 이벤트 리스너는 document에만 등록 (floatingEl은 동적으로 참조) - ownerDocument.addEventListener('pointerup', handlePointerUp, true); - ownerDocument.addEventListener('pointerdown', handleDocumentDown, true); - ownerDocument.addEventListener('mousedown', handleDocumentDown, true); - - return () => { - ownerDocument.removeEventListener('pointerup', handlePointerUp, true); - ownerDocument.removeEventListener( - 'pointerdown', - handleDocumentDown, - true, - ); - ownerDocument.removeEventListener('mousedown', handleDocumentDown, true); - }; - }, [open, autoClose, onClose, referenceRef, interactiveRefs, ownerDocument]); + useFloatingPopupPersistentDismissRuntime({ + open, + autoClose, + onClose, + referenceRef, + interactiveRefs, + floatingRef, + ownerDocument, + }); if (!mounted) return null; diff --git a/src/renderer/components/main/Modal/useFloatingPopupDismissRuntime.ts b/src/renderer/components/main/Modal/useFloatingPopupDismissRuntime.ts new file mode 100644 index 000000000..acd3b157b --- /dev/null +++ b/src/renderer/components/main/Modal/useFloatingPopupDismissRuntime.ts @@ -0,0 +1,190 @@ +import { useEffect } from 'react'; +import type React from 'react'; + +import { isInsideHigherPopupLayer } from './popupLayer'; +import { isElementNode } from '@utils/dom/isElementNode'; + +interface FloatingPopupAutoDismissRuntimeOptions { + open: boolean; + autoClose: boolean; + onClose: () => void; + referenceRef?: React.RefObject; + refs: { floating: React.RefObject }; + ownerDocument: Document; +} + +export const useFloatingPopupAutoDismissRuntime = ({ + open, + autoClose, + onClose, + referenceRef, + refs, + ownerDocument, +}: FloatingPopupAutoDismissRuntimeOptions) => { + useEffect(() => { + if (open && autoClose) { + const onClickAway = (e: MouseEvent) => { + const target = e.target as Node; + if (!refs.floating.current) return; + if ( + refs.floating.current.contains(target) || + (referenceRef && + referenceRef.current && + referenceRef.current.contains(target)) + ) + return; + // 모달이 열린 상태에서 모달 내부 클릭으로 팝업이 닫히는 것을 방지 + // (Modal은 body로 portal 렌더링되기 때문에 floating 내부로 인식되지 않음) + const isInsideModal = + isElementNode(target) && + !!target.closest('[data-dmn-modal-backdrop="true"]'); + if (isInsideModal) return; + // 서브메뉴도 body 포털이라 floating 내부로 인식되지 않음 — 닫힘 예외 + const isInsideSubMenu = + isElementNode(target) && + !!target.closest('[data-dmn-popup-submenu="true"]'); + if (isInsideSubMenu) return; + // 온캔버스 그라데이션 핸들 조작도 팝업 편집의 연장 — 닫힘 예외 + const isInsideGradientOverlay = + isElementNode(target) && + !!target.closest('[data-dmn-gradient-overlay="true"]'); + if (isInsideGradientOverlay) return; + // 위에 쌓인 팝업(자식 피커 등)은 body 포털이라 floating 내부로 인식되지 않음 + if (isInsideHigherPopupLayer(refs.floating.current, target)) return; + onClose(); + }; + + ownerDocument.addEventListener('mousedown', onClickAway); + return () => { + ownerDocument.removeEventListener('mousedown', onClickAway); + }; + } + }, [open, autoClose, onClose, referenceRef, refs.floating, ownerDocument]); +}; + +interface FloatingPopupPersistentDismissRuntimeOptions { + open: boolean; + autoClose: boolean; + onClose: () => void; + referenceRef?: React.RefObject; + interactiveRefs: Array>; + floatingRef: React.RefObject; + ownerDocument: Document; +} + +export const useFloatingPopupPersistentDismissRuntime = ({ + open, + autoClose, + onClose, + referenceRef, + interactiveRefs, + floatingRef, + ownerDocument, +}: FloatingPopupPersistentDismissRuntimeOptions) => { + useEffect(() => { + if (!open || autoClose) return; + + let pointerCapturedInside = false; + + const referenceEl = referenceRef?.current ?? null; + + const _handlePointerDownInside = () => { + pointerCapturedInside = true; + }; + + const handlePointerUp = () => { + pointerCapturedInside = false; + }; + + const handleDocumentDown = (event: PointerEvent) => { + const target = event.target as Node; + // floatingRef.current를 이벤트 발생 시점에 동적으로 참조 + const floatingEl = floatingRef.current; + const interactiveEls = interactiveRefs + .map((r) => r?.current) + .filter(Boolean) as HTMLElement[]; + const isInsideModal = + isElementNode(target) && + !!target.closest('[data-dmn-modal-backdrop="true"]'); + + if (!floatingEl) return; + + const isInsideFloating = floatingEl.contains(target); + const isInsideReference = referenceEl?.contains(target) ?? false; + const isInsideInteractive = interactiveEls.some((el) => + el.contains(target as Node), + ); + + if (isInsideFloating) { + pointerCapturedInside = true; + return; + } + + if ( + pointerCapturedInside && + (event.type === 'pointerdown' || event.type === 'mousedown') + ) { + pointerCapturedInside = false; + } + + if (isInsideReference || isInsideInteractive) { + pointerCapturedInside = false; + return; + } + + // 모달이 열린 상태에서 모달 내부 클릭으로 팝업이 닫히는 것을 방지. + // (Modal은 body로 portal 렌더링되기 때문에 단순 z-index로는 해결이 안 됨) + if (isInsideModal) { + pointerCapturedInside = false; + return; + } + + // 서브메뉴·포털 드롭다운도 body 포털이라 floating 내부로 인식되지 않음 + const isInsideSubMenu = + isElementNode(target) && + !!target.closest('[data-dmn-popup-submenu="true"]'); + if (isInsideSubMenu) { + pointerCapturedInside = false; + return; + } + + // 온캔버스 그라데이션 핸들 조작도 팝업 편집의 연장 — 닫힘 예외 + const isInsideGradientOverlay = + isElementNode(target) && + !!target.closest('[data-dmn-gradient-overlay="true"]'); + if (isInsideGradientOverlay) { + pointerCapturedInside = false; + return; + } + + // 위에 쌓인 팝업(자식 피커 등)은 body 포털이라 floating 내부로 인식되지 않음 + if (isInsideHigherPopupLayer(floatingEl, target)) { + pointerCapturedInside = false; + return; + } + + if (pointerCapturedInside) { + return; + } + + onClose(); + }; + + // 이벤트 리스너는 document에만 등록 (floatingEl은 동적으로 참조) + ownerDocument.addEventListener('pointerup', handlePointerUp, true); + ownerDocument.addEventListener('pointerdown', handleDocumentDown, true); + ownerDocument.addEventListener('mousedown', handleDocumentDown, true); + + return () => { + ownerDocument.removeEventListener('pointerup', handlePointerUp, true); + ownerDocument.removeEventListener( + 'pointerdown', + handleDocumentDown, + true, + ); + ownerDocument.removeEventListener('mousedown', handleDocumentDown, true); + }; + // 원본 useRef 의존성 계약 유지 - 이벤트 시점 current 동적 참조 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, autoClose, onClose, referenceRef, interactiveRefs, ownerDocument]); +}; From 760b45f4307c9844104fe42845f598b5d513c282 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 23:00:07 +0900 Subject: [PATCH 239/245] =?UTF-8?q?test:=20=EB=B0=B0=EC=B9=98=20=ED=82=A4?= =?UTF-8?q?=20=EA=B3=84=EC=97=B4=20=EC=BB=A4=EB=B0=8B=20=EC=8B=A4=ED=8C=A8?= =?UTF-8?q?=20=EA=B3=84=EC=95=BD=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../batchPickerBindingOwnership.test.tsx | 92 ++++++++++++++++++- 1 file changed, 91 insertions(+), 1 deletion(-) diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/batchPickerBindingOwnership.test.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/batchPickerBindingOwnership.test.tsx index 88bedef49..f6dd4b073 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/batchPickerBindingOwnership.test.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/batchPickerBindingOwnership.test.tsx @@ -137,6 +137,10 @@ const gestures = vi.hoisted(() => ({ activeGestureId: vi.fn(() => 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'), })); +const errors = vi.hoisted(() => ({ + reportElementOpError: vi.fn(), +})); + vi.mock('@src/renderer/editor/runtime/elementOps', () => ({ patchElementPropertyByTargetsViaAuthority: patches.patchElementPropertyByTargetsViaAuthority, @@ -187,7 +191,7 @@ vi.mock('@plugins/runtime/displayElement/pluginElementActions', () => ({ patchStylePropertyViaAuthority: patches.patchDisplayTextViaAuthority, })); vi.mock('@src/renderer/editor/runtime/elementIntent', () => ({ - reportElementOpError: vi.fn(), + reportElementOpError: errors.reportElementOpError, reportElementOpSkipped: vi.fn(), })); vi.mock('@contexts/useTranslation', () => ({ @@ -717,6 +721,92 @@ describe('배치 피커 결합 소유권 (프로덕션 배선)', () => { expect(captured.color?.color).toBe('#112233'); }); + it('배치 노트 커밋 뒤 선택 교체 시 옛 실패가 로컬 상태를 옛 canonical로 덮지 않는다', async () => { + let rejectPersisted: ((reason: unknown) => void) | undefined; + patches.patchNotePaintByIds.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectPersisted = reject; + }), + ); + gestures.settleCommit.mockImplementationOnce( + (persisted: Promise) => { + gestures.activeGestureId.mockReturnValue(null); + void persisted.catch(() => {}); + }, + ); + const canonicalA = { ...keyAt(ID_A), noteColor: '#112233' }; + const propsA = panelProps(); + propsA.activeTab = 'note'; + propsA.batchPickerFor = 'noteColor'; + propsA.getSelectedKeyOnlyPositions = () => [ + { index: 0, position: canonicalA }, + ]; + propsA.getMixedValueCanonical = (( + getter: (position: never) => unknown, + ) => ({ + isMixed: false, + value: getter(canonicalA as never), + })) as PanelProps['getMixedValueCanonical']; + + act(() => { + root.render( + + + , + ); + }); + act(() => captured.color?.onColorChangeComplete('#abcdef')); + + const canonicalB = { ...keyAt(ID_B), noteColor: '#445566' }; + act(() => selectKey(ID_B)); + const propsB = panelProps(); + propsB.activeTab = 'note'; + propsB.batchPickerFor = 'noteColor'; + propsB.getSelectedKeyOnlyPositions = () => [ + { index: 0, position: canonicalB }, + ]; + propsB.getMixedValueCanonical = (( + getter: (position: never) => unknown, + ) => ({ + isMixed: false, + value: getter(canonicalB as never), + })) as PanelProps['getMixedValueCanonical']; + act(() => { + root.render( + + + , + ); + }); + expect(captured.color?.color).toBe('#ABCDEF'); + + await act(async () => { + rejectPersisted?.(new Error('old selection failure')); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(captured.color?.color).toBe('#ABCDEF'); + expect(errors.reportElementOpError).toHaveBeenCalledOnce(); + }); + it('배치 단색 노트 피커는 투명도 preview·commit·Escape 복원을 정식 채널로 전달한다', () => { const canonical = { ...keyAt(ID_A), From 90b450e0a92e365c68fceb663d35feab10fd0b70 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 23:00:07 +0900 Subject: [PATCH 240/245] =?UTF-8?q?refactor:=20=EB=B0=B0=EC=B9=98=20?= =?UTF-8?q?=ED=82=A4=20=EA=B3=84=EC=97=B4=20=EC=BB=A4=EB=B0=8B=20=EB=9F=B0?= =?UTF-8?q?=ED=83=80=EC=9E=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../batch/BatchSelectionPanel.tsx | 281 ++-------------- .../batch/useBatchKeyLikeCommitRuntime.ts | 305 ++++++++++++++++++ 2 files changed, 337 insertions(+), 249 deletions(-) create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/batch/useBatchKeyLikeCommitRuntime.ts diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx index 10108a43b..d1d4d5393 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx @@ -17,34 +17,14 @@ import { TabType, } from '../index'; import EditSessionBoundary from '../EditSessionBoundary'; -import { - patchCounterAnimationEnabledByTargets, - patchCounterEnabledByTargets, - patchCounterLayoutByTargets, - patchCounterTypographyByTargets, - patchNotePaintByIds, - patchCounterFillByTargets, - patchSoundEnabledByIds, - patchSoundVolumeByIds, -} from '@src/renderer/editor/runtime/elementOps'; -import { reportElementOpError } from '@src/renderer/editor/runtime/elementIntent'; import { editGestureController } from '@src/renderer/editor/runtime/editGestureController'; -import { getEditSessionTarget } from '@src/renderer/editor/runtime/editSessionTarget'; import { captureBatchElementBinding, useBatchElementBinding, } from '@hooks/pickers/useBatchElementBinding'; import { usePanelNav } from '../PanelNavContext'; import { BATCH_COUNTER_ANIMATION_PAGE_KEY } from './BatchCounterTabContent'; -import { BATCH_STYLE_SOUND_PAGE_KEY } from './BatchStyleTabContent'; -import { resolveElementById } from '@src/renderer/editor/model/elementIdMap'; -import { isNativeElementId } from '@src/renderer/editor/model/elementId'; -import { captureEditorDocument } from '@src/renderer/editor/runtime/editorStateCoordinator'; -import type { - EditorNotePaintPropertyPatchV1, - EditorCounterFillPropertyPatchV1, -} from '@src/types/editor'; -import { projectNotePaintPatch } from '@src/types/key/notePaint'; +import type { EditorCounterFillPropertyPatchV1 } from '@src/types/editor'; import { hexWithAlphaPercent } from '@utils/color/colorUtils'; import type { BatchElementPropertyUpdate } from '../types'; import { useBatchNotePaint, type BatchNoteSurface } from './useBatchNotePaint'; @@ -53,15 +33,13 @@ import BatchImagePickerPopup from './BatchImagePickerPopup'; import BatchColorPickerPopup from './BatchColorPickerPopup'; import BatchGraphSettingsSection from './BatchGraphSettingsSection'; import { createBatchGraphSettingsModel } from './batchGraphSettingsModel'; +import { useBatchKeyLikeCommitRuntime } from './useBatchKeyLikeCommitRuntime'; import { commitBoundActiveImage, commitBoundActiveTransparent, commitBoundIdleTransparent, commitBoundInactiveImage, commitBoundSoundPath, - createPaintHandlers, - createShadowCommitHandler, - createStylePropertyHandlers, type BatchLocalColors, type BatchPickerTarget, type KeyData, @@ -263,230 +241,35 @@ export const BatchKeyLikePanel: React.FC = ({ stat: selectedStatElements, }), ); - const counterTargets = [ - ...selectedKeyElements.map(({ id }) => ({ - elementType: 'key' as const, - id, - })), - ...selectedStatElements.map(({ id }) => ({ - elementType: 'stat' as const, - id, - })), - ]; - const stableCounterTargets = - counterTargets.length > 0 && - counterTargets.every(({ id }) => id.length > 0 && isNativeElementId(id)) - ? counterTargets - : null; - const textPropertyTargets = selectedBatchStyleElements.map( - ({ type, id }) => ({ - elementType: type as 'key' | 'stat' | 'graph' | 'knob', - id, - }), - ); - const { previewStyleProperty, commitStyleProperty } = - createStylePropertyHandlers(textPropertyTargets, selectedKeyType); - const { previewPaint, commitPaint } = createPaintHandlers( - textPropertyTargets, + const { + previewStyleProperty, + commitStyleProperty, + previewPaint, + commitPaint, + commitShadow, + commitNoteStyleProperty, + notePaintIds, + bindNotePaintFailureRestore, + commitNotePaint, + previewNotePaint, + counterFillTargets, + commitCounterFill, + commitSoundEnabled, + commitSoundVolume, + commitCounterEnabled, + commitCounterAnimationEnabled, + commitCounterLayout, + commitCounterTypography, + soundBinding, + } = useBatchKeyLikeCommitRuntime({ + selectedBatchStyleElements, + selectedKeyElements, + selectedStatElements, + selectedKnobElements, selectedKeyType, - ); - const shadowTargets = [ - ...selectedKeyElements.map(({ id }) => ({ - elementType: 'key' as const, - id, - })), - ...selectedStatElements.map(({ id }) => ({ - elementType: 'stat' as const, - id, - })), - ...selectedKnobElements.map(({ id }) => ({ - elementType: 'knob' as const, - id, - })), - ]; - const commitShadow = createShadowCommitHandler(shadowTargets); - const { commitStyleProperty: commitNoteStyleProperty } = - createStylePropertyHandlers( - selectedKeyElements.map(({ id }) => ({ - elementType: 'key', - id, - })), - selectedKeyType, - { settleGesture: false }, - ); - const notePaintIds = selectedKeyElements.map(({ id }) => id); - const stableNotePaintIds = - notePaintIds.length > 0 && - notePaintIds.every((id) => id.length > 0 && isNativeElementId(id)) && - new Set(notePaintIds).size === notePaintIds.length - ? notePaintIds - : null; - // 따라가기 대상이 하나라도 있으면 글로우 페인트는 보내지 않는다 - 백엔드가 - // 그 키 때문에 배치 transition 전체를 거부한다. 잠긴 뒤 늦게 도착한 피커 - // 콜백을 여기서 거른다 - const glowPaintLockedForSelection = ( - patch: EditorNotePaintPropertyPatchV1, - ): boolean => { - if (patch.property !== 'noteGlowPaint' || !stableNotePaintIds) return false; - const document = captureEditorDocument(); - return stableNotePaintIds.some((id) => { - const locator = resolveElementById('key', id); - const current = locator - ? document.keyPositions[locator.mode]?.[locator.index] - : undefined; - return current?.id === id && current.noteGlowSyncPaint === true; - }); - }; - // 영구 실패 시 로컬 대표값 복원 - 피커 로컬 상태(useBatchNotePaint)가 아래에서 - // 만들어지므로 실패 콜백 시점에 읽도록 늦게 묶는다 - const notePaintFailureRestore: { - current?: (patch: EditorNotePaintPropertyPatchV1) => void; - } = {}; - const commitNotePaint = stableNotePaintIds - ? (patch: EditorNotePaintPropertyPatchV1) => { - if (glowPaintLockedForSelection(patch)) return; - const gestureId = editGestureController.activeGestureId() ?? undefined; - const persisted = patchNotePaintByIds(stableNotePaintIds, patch, { - gestureId, - }); - // 커밋 시점 지문 - 실패가 돌아왔을 때 선택이 바뀌었으면 새 선택의 로컬 상태를 - // 옛 대표값으로 덮지 않는다 (settleCommit과 같은 기준) - const committedTarget = getEditSessionTarget(); - editGestureController.settleCommit(persisted); - void persisted.catch((error) => { - if (getEditSessionTarget() === committedTarget) { - notePaintFailureRestore.current?.(patch); - } - reportElementOpError(error); - }); - } - : undefined; - const previewNotePaint = stableNotePaintIds - ? (patch: EditorNotePaintPropertyPatchV1) => { - if (glowPaintLockedForSelection(patch)) return; - const entries: Array<{ - id: string; - patch: Partial; - }> = []; - // canonical 전달 - 동기화 켜진 키의 글로우 미러가 낙관 적용과 같은 규칙 - const document = captureEditorDocument(); - for (const id of stableNotePaintIds) { - const locator = resolveElementById('key', id); - if (!locator || locator.mode !== selectedKeyType) return; - const current = document.keyPositions[locator.mode]?.[locator.index]; - entries.push({ - id, - patch: projectNotePaintPatch( - patch, - current?.id === id ? current : undefined, - ), - }); - } - editGestureController.preview(selectedKeyType, entries, { - domain: 'keyPosition', - }); - } - : undefined; - const counterFillTargets = [ - ...selectedKeyElements.map(({ id }) => ({ - elementType: 'key' as const, - id, - })), - ...(batchCounterColorState === 'active' - ? [] - : selectedStatElements.map(({ id }) => ({ - elementType: 'stat' as const, - id, - }))), - ]; - const stableCounterFillTargets = - counterFillTargets.length > 0 && - counterFillTargets.every( - ({ id }) => id.length > 0 && isNativeElementId(id), - ) && - new Set(counterFillTargets.map(({ id }) => id)).size === - counterFillTargets.length - ? counterFillTargets - : null; - const commitCounterFill = stableCounterFillTargets - ? (patch: EditorCounterFillPropertyPatchV1) => { - const persisted = patchCounterFillByTargets( - stableCounterFillTargets, - patch, - ); - void persisted.catch(reportElementOpError); - } - : undefined; - const soundTargets = selectedKeyElements.map(({ id }) => id); - const stableSoundTargets = - soundTargets.length > 0 && - soundTargets.every((id) => id.length > 0 && isNativeElementId(id)) - ? soundTargets - : null; - const commitSoundEnabled = stableSoundTargets - ? (soundEnabled: boolean) => { - const persisted = patchSoundEnabledByIds( - stableSoundTargets, - soundEnabled, - ); - void persisted.catch(reportElementOpError); - } - : undefined; - const commitSoundVolume = stableSoundTargets - ? (soundVolume: number) => { - const persisted = patchSoundVolumeByIds( - stableSoundTargets, - soundVolume, - ); - void persisted.catch(reportElementOpError); - } - : undefined; - const commitCounterEnabled = stableCounterTargets - ? (enabled: boolean) => { - const persisted = patchCounterEnabledByTargets( - stableCounterTargets, - enabled, - ); - void persisted.catch(reportElementOpError); - } - : undefined; - const commitCounterAnimationEnabled = stableCounterTargets - ? (enabled: boolean) => { - const persisted = patchCounterAnimationEnabledByTargets( - stableCounterTargets, - enabled, - ); - void persisted.catch(reportElementOpError); - } - : undefined; - const commitCounterLayout = stableCounterTargets - ? ( - patch: import('@src/types/editor').EditorCounterLayoutPropertyPatchV1, - ) => { - const persisted = patchCounterLayoutByTargets( - stableCounterTargets, - patch, - ); - void persisted.catch(reportElementOpError); - } - : undefined; - const commitCounterTypography = stableCounterTargets - ? ( - patch: import('@src/types/editor').EditorCounterTypographyPropertyPatchV1, - options?: { gestureId?: string }, - ) => { - const persisted = options?.gestureId - ? patchCounterTypographyByTargets(stableCounterTargets, patch, { - gestureId: options.gestureId, - }) - : patchCounterTypographyByTargets(stableCounterTargets, patch); - void persisted.catch(reportElementOpError); - } - : undefined; - const soundBinding = useBatchElementBinding( - activePageKey === BATCH_STYLE_SOUND_PAGE_KEY, - () => captureBatchElementBinding({ key: selectedKeyElements }), - ); + batchCounterColorState, + activePageKey, + }); const hasGraphSelection = selectedGraphElements.length > 0; // 표시값·Mixed 판정은 resize가 실제로 쓰는 대상 집합(키·스탯·그래프·노브)과 같아야 한다. @@ -531,7 +314,7 @@ export const BatchKeyLikePanel: React.FC = ({ }); // 영구 실패는 canonical 재반영 신호(commitTick)가 오지 않는다. 열린 피커의 // 로컬 대표값을 canonical에서 다시 읽어 옛 편집값이 다음 커밋에 실리지 않게 - notePaintFailureRestore.current = (patch) => { + bindNotePaintFailureRestore((patch) => { if (editGestureController.activeGestureId() !== null) return; const surface: BatchNoteSurface = patch.property === 'notePaint' @@ -569,7 +352,7 @@ export const BatchKeyLikePanel: React.FC = ({ getMixedValueCanonical((pos) => pos.noteGlowOpacity, 70).value, ); } - }; + }); const getBatchNoteColorDisplay = () => batchNotePaint.displays.note; const getBatchGlowColorDisplay = () => batchNotePaint.displays.glow; const getBatchBorderColorDisplay = () => batchNotePaint.displays.border; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/useBatchKeyLikeCommitRuntime.ts b/src/renderer/components/main/Grid/PropertiesPanel/batch/useBatchKeyLikeCommitRuntime.ts new file mode 100644 index 000000000..68a7b8da4 --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/useBatchKeyLikeCommitRuntime.ts @@ -0,0 +1,305 @@ +import type { KeyPosition } from '@src/types/key/keys'; +import type { SelectedElement } from '@stores/grid/useGridSelectionStore'; +import { + patchCounterAnimationEnabledByTargets, + patchCounterEnabledByTargets, + patchCounterLayoutByTargets, + patchCounterTypographyByTargets, + patchNotePaintByIds, + patchCounterFillByTargets, + patchSoundEnabledByIds, + patchSoundVolumeByIds, +} from '@src/renderer/editor/runtime/elementOps'; +import { reportElementOpError } from '@src/renderer/editor/runtime/elementIntent'; +import { editGestureController } from '@src/renderer/editor/runtime/editGestureController'; +import { getEditSessionTarget } from '@src/renderer/editor/runtime/editSessionTarget'; +import { + captureBatchElementBinding, + useBatchElementBinding, +} from '@hooks/pickers/useBatchElementBinding'; +import { BATCH_STYLE_SOUND_PAGE_KEY } from './BatchStyleTabContent'; +import { resolveElementById } from '@src/renderer/editor/model/elementIdMap'; +import { isNativeElementId } from '@src/renderer/editor/model/elementId'; +import { captureEditorDocument } from '@src/renderer/editor/runtime/editorStateCoordinator'; +import type { + EditorNotePaintPropertyPatchV1, + EditorCounterFillPropertyPatchV1, +} from '@src/types/editor'; +import { projectNotePaintPatch } from '@src/types/key/notePaint'; +import { + createPaintHandlers, + createShadowCommitHandler, + createStylePropertyHandlers, +} from './batchPanelShared'; + +interface UseBatchKeyLikeCommitRuntimeOptions { + selectedBatchStyleElements: SelectedElement[]; + selectedKeyElements: SelectedElement[]; + selectedStatElements: SelectedElement[]; + selectedKnobElements: SelectedElement[]; + selectedKeyType: string; + batchCounterColorState: 'idle' | 'active'; + activePageKey: string | null; +} + +export const useBatchKeyLikeCommitRuntime = ({ + selectedBatchStyleElements, + selectedKeyElements, + selectedStatElements, + selectedKnobElements, + selectedKeyType, + batchCounterColorState, + activePageKey, +}: UseBatchKeyLikeCommitRuntimeOptions) => { + const counterTargets = [ + ...selectedKeyElements.map(({ id }) => ({ + elementType: 'key' as const, + id, + })), + ...selectedStatElements.map(({ id }) => ({ + elementType: 'stat' as const, + id, + })), + ]; + const stableCounterTargets = + counterTargets.length > 0 && + counterTargets.every(({ id }) => id.length > 0 && isNativeElementId(id)) + ? counterTargets + : null; + const textPropertyTargets = selectedBatchStyleElements.map( + ({ type, id }) => ({ + elementType: type as 'key' | 'stat' | 'graph' | 'knob', + id, + }), + ); + const { previewStyleProperty, commitStyleProperty } = + createStylePropertyHandlers(textPropertyTargets, selectedKeyType); + const { previewPaint, commitPaint } = createPaintHandlers( + textPropertyTargets, + selectedKeyType, + ); + const shadowTargets = [ + ...selectedKeyElements.map(({ id }) => ({ + elementType: 'key' as const, + id, + })), + ...selectedStatElements.map(({ id }) => ({ + elementType: 'stat' as const, + id, + })), + ...selectedKnobElements.map(({ id }) => ({ + elementType: 'knob' as const, + id, + })), + ]; + const commitShadow = createShadowCommitHandler(shadowTargets); + const { commitStyleProperty: commitNoteStyleProperty } = + createStylePropertyHandlers( + selectedKeyElements.map(({ id }) => ({ + elementType: 'key', + id, + })), + selectedKeyType, + { settleGesture: false }, + ); + const notePaintIds = selectedKeyElements.map(({ id }) => id); + const stableNotePaintIds = + notePaintIds.length > 0 && + notePaintIds.every((id) => id.length > 0 && isNativeElementId(id)) && + new Set(notePaintIds).size === notePaintIds.length + ? notePaintIds + : null; + // 따라가기 대상이 하나라도 있으면 글로우 페인트는 보내지 않는다 - 백엔드가 + // 그 키 때문에 배치 transition 전체를 거부한다. 잠긴 뒤 늦게 도착한 피커 + // 콜백을 여기서 거른다 + const glowPaintLockedForSelection = ( + patch: EditorNotePaintPropertyPatchV1, + ): boolean => { + if (patch.property !== 'noteGlowPaint' || !stableNotePaintIds) return false; + const document = captureEditorDocument(); + return stableNotePaintIds.some((id) => { + const locator = resolveElementById('key', id); + const current = locator + ? document.keyPositions[locator.mode]?.[locator.index] + : undefined; + return current?.id === id && current.noteGlowSyncPaint === true; + }); + }; + // 영구 실패 시 로컬 대표값 복원 - 피커 로컬 상태(useBatchNotePaint)가 아래에서 + // 만들어지므로 실패 콜백 시점에 읽도록 늦게 묶는다 + const notePaintFailureRestore: { + current?: (patch: EditorNotePaintPropertyPatchV1) => void; + } = {}; + const bindNotePaintFailureRestore = ( + restore: (patch: EditorNotePaintPropertyPatchV1) => void, + ) => { + notePaintFailureRestore.current = restore; + }; + const commitNotePaint = stableNotePaintIds + ? (patch: EditorNotePaintPropertyPatchV1) => { + if (glowPaintLockedForSelection(patch)) return; + const gestureId = editGestureController.activeGestureId() ?? undefined; + const persisted = patchNotePaintByIds(stableNotePaintIds, patch, { + gestureId, + }); + // 커밋 시점 지문 - 실패가 돌아왔을 때 선택이 바뀌었으면 새 선택의 로컬 상태를 + // 옛 대표값으로 덮지 않는다 (settleCommit과 같은 기준) + const committedTarget = getEditSessionTarget(); + editGestureController.settleCommit(persisted); + void persisted.catch((error) => { + if (getEditSessionTarget() === committedTarget) { + notePaintFailureRestore.current?.(patch); + } + reportElementOpError(error); + }); + } + : undefined; + const previewNotePaint = stableNotePaintIds + ? (patch: EditorNotePaintPropertyPatchV1) => { + if (glowPaintLockedForSelection(patch)) return; + const entries: Array<{ + id: string; + patch: Partial; + }> = []; + // canonical 전달 - 동기화 켜진 키의 글로우 미러가 낙관 적용과 같은 규칙 + const document = captureEditorDocument(); + for (const id of stableNotePaintIds) { + const locator = resolveElementById('key', id); + if (!locator || locator.mode !== selectedKeyType) return; + const current = document.keyPositions[locator.mode]?.[locator.index]; + entries.push({ + id, + patch: projectNotePaintPatch( + patch, + current?.id === id ? current : undefined, + ), + }); + } + editGestureController.preview(selectedKeyType, entries, { + domain: 'keyPosition', + }); + } + : undefined; + const counterFillTargets = [ + ...selectedKeyElements.map(({ id }) => ({ + elementType: 'key' as const, + id, + })), + ...(batchCounterColorState === 'active' + ? [] + : selectedStatElements.map(({ id }) => ({ + elementType: 'stat' as const, + id, + }))), + ]; + const stableCounterFillTargets = + counterFillTargets.length > 0 && + counterFillTargets.every( + ({ id }) => id.length > 0 && isNativeElementId(id), + ) && + new Set(counterFillTargets.map(({ id }) => id)).size === + counterFillTargets.length + ? counterFillTargets + : null; + const commitCounterFill = stableCounterFillTargets + ? (patch: EditorCounterFillPropertyPatchV1) => { + const persisted = patchCounterFillByTargets( + stableCounterFillTargets, + patch, + ); + void persisted.catch(reportElementOpError); + } + : undefined; + const soundTargets = selectedKeyElements.map(({ id }) => id); + const stableSoundTargets = + soundTargets.length > 0 && + soundTargets.every((id) => id.length > 0 && isNativeElementId(id)) + ? soundTargets + : null; + const commitSoundEnabled = stableSoundTargets + ? (soundEnabled: boolean) => { + const persisted = patchSoundEnabledByIds( + stableSoundTargets, + soundEnabled, + ); + void persisted.catch(reportElementOpError); + } + : undefined; + const commitSoundVolume = stableSoundTargets + ? (soundVolume: number) => { + const persisted = patchSoundVolumeByIds( + stableSoundTargets, + soundVolume, + ); + void persisted.catch(reportElementOpError); + } + : undefined; + const commitCounterEnabled = stableCounterTargets + ? (enabled: boolean) => { + const persisted = patchCounterEnabledByTargets( + stableCounterTargets, + enabled, + ); + void persisted.catch(reportElementOpError); + } + : undefined; + const commitCounterAnimationEnabled = stableCounterTargets + ? (enabled: boolean) => { + const persisted = patchCounterAnimationEnabledByTargets( + stableCounterTargets, + enabled, + ); + void persisted.catch(reportElementOpError); + } + : undefined; + const commitCounterLayout = stableCounterTargets + ? ( + patch: import('@src/types/editor').EditorCounterLayoutPropertyPatchV1, + ) => { + const persisted = patchCounterLayoutByTargets( + stableCounterTargets, + patch, + ); + void persisted.catch(reportElementOpError); + } + : undefined; + const commitCounterTypography = stableCounterTargets + ? ( + patch: import('@src/types/editor').EditorCounterTypographyPropertyPatchV1, + options?: { gestureId?: string }, + ) => { + const persisted = options?.gestureId + ? patchCounterTypographyByTargets(stableCounterTargets, patch, { + gestureId: options.gestureId, + }) + : patchCounterTypographyByTargets(stableCounterTargets, patch); + void persisted.catch(reportElementOpError); + } + : undefined; + const soundBinding = useBatchElementBinding( + activePageKey === BATCH_STYLE_SOUND_PAGE_KEY, + () => captureBatchElementBinding({ key: selectedKeyElements }), + ); + + return { + previewStyleProperty, + commitStyleProperty, + previewPaint, + commitPaint, + commitShadow, + commitNoteStyleProperty, + notePaintIds, + bindNotePaintFailureRestore, + commitNotePaint, + previewNotePaint, + counterFillTargets, + commitCounterFill, + commitSoundEnabled, + commitSoundVolume, + commitCounterEnabled, + commitCounterAnimationEnabled, + commitCounterLayout, + commitCounterTypography, + soundBinding, + }; +}; From edf588baa937d4428f49f7b453b2821649fb33fd Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 23:14:33 +0900 Subject: [PATCH 241/245] =?UTF-8?q?test:=20=EB=A0=88=EC=9D=B4=EC=96=B4=20?= =?UTF-8?q?=EC=95=A1=EC=85=98=20=EB=A9=94=EB=89=B4=20=EA=B3=84=EC=95=BD=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../layer/useLayerActions.test.tsx | 146 +++++++++++++++++- 1 file changed, 145 insertions(+), 1 deletion(-) diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerActions.test.tsx b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerActions.test.tsx index 2e57a5adb..2cca52074 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerActions.test.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerActions.test.tsx @@ -83,8 +83,10 @@ const Harness = ({ setLastClickedDisplayIndex: () => {}, t: (key) => key, }); + // eslint-disable-next-line react-hooks/refs -- 반환 ref를 포함한 공개 훅 계약 노출 expose(actions); - return null; + // eslint-disable-next-line react-hooks/refs -- 반환된 rename input ref 연결 계약 검증 + return ; }; describe('useLayerActions visibility routing', () => { @@ -612,4 +614,146 @@ describe('useLayerActions visibility routing', () => { expect(mocks.setGroupHidden).not.toHaveBeenCalled(); expect(mocks.setPluginHidden).not.toHaveBeenCalled(); }); + + it('컨텍스트 메뉴는 item·group 상태별 exact 항목 순서를 유지한다', async () => { + const item: LayerItem = { + type: 'key', + id: STABLE_ID, + index: 0, + name: 'A', + zIndex: 0, + hidden: false, + }; + const contextEvent = { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + clientX: 10, + clientY: 20, + } as unknown as React.MouseEvent; + + act(() => actions.handleContextMenu(contextEvent, item, 0)); + expect(actions.contextMenuItems.map(({ id }) => id)).toEqual([ + 'rename', + 'delete', + ]); + + useGridSelectionStore.setState({ + selectedElements: [ + { type: 'key', id: STABLE_ID, index: 0 }, + { + type: 'stat', + id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + index: 0, + }, + ], + }); + await exposeWithItems([]); + act(() => actions.handleContextMenu(contextEvent, item, 0)); + expect(actions.contextMenuItems.map(({ id }) => id)).toEqual([ + 'rename', + 'groupSelected', + 'delete', + ]); + + act(() => + actions.handleContextMenu( + contextEvent, + { ...item, groupId: 'group-a' }, + 0, + ), + ); + expect(actions.contextMenuItems.map(({ id }) => id)).toEqual([ + 'rename', + 'removeFromGroup', + 'delete', + ]); + + act(() => actions.handleGroupHeaderContextMenu(contextEvent, 'group-a')); + expect(actions.contextMenuItems.map(({ id }) => id)).toEqual([ + 'renameGroup', + 'ungroup', + ]); + }); + + it('rename 선택은 먼저 메뉴 상태를 닫고 다음 RAF에 input focus·select를 실행한다', async () => { + const item: LayerItem = { + type: 'key', + id: STABLE_ID, + index: 0, + name: 'Layer A', + zIndex: 0, + hidden: false, + }; + const contextEvent = { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + clientX: 10, + clientY: 20, + } as unknown as React.MouseEvent; + let scheduled: FrameRequestCallback | undefined; + const raf = vi + .spyOn(window, 'requestAnimationFrame') + .mockImplementation((callback) => { + scheduled = callback; + return 1; + }); + const input = host.querySelector( + '[aria-label="rename input"]', + )!; + const focus = vi.spyOn(input, 'focus'); + const select = vi.spyOn(input, 'select'); + + act(() => actions.handleContextMenu(contextEvent, item, 0)); + await act(async () => actions.handleContextMenuSelect('rename')); + + expect(actions.contextMenuOpen).toBe(false); + expect(actions.renamingItemId).toBe(STABLE_ID); + expect(actions.renameValue).toBe('Layer A'); + expect(focus).not.toHaveBeenCalled(); + expect(select).not.toHaveBeenCalled(); + + act(() => scheduled?.(0)); + expect(focus).toHaveBeenCalledOnce(); + expect(select).toHaveBeenCalledOnce(); + raf.mockRestore(); + }); + + it('group ungroup 비동기 실패는 reject를 전파하고 메뉴·group 상태를 열린 채 유지한다', async () => { + await exposeWithItems([ + { + type: 'key', + id: STABLE_ID, + index: 0, + name: 'A', + zIndex: 0, + hidden: false, + groupId: 'group-a', + }, + ]); + const contextEvent = { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + clientX: 10, + clientY: 20, + } as unknown as React.MouseEvent; + const failure = new Error('ungroup failed'); + mocks.setMixedElementGroups.mockRejectedValueOnce(failure); + + act(() => actions.handleGroupHeaderContextMenu(contextEvent, 'group-a')); + let rejected: unknown; + await act(async () => { + try { + await actions.handleContextMenuSelect('ungroup'); + } catch (error) { + rejected = error; + } + }); + + expect(rejected).toBe(failure); + expect(actions.contextMenuOpen).toBe(true); + expect(actions.contextMenuItems.map(({ id }) => id)).toEqual([ + 'renameGroup', + 'ungroup', + ]); + }); }); From 995ad6c3ad2b4bf3bf76ab09d439aaafc4290146 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 23:14:33 +0900 Subject: [PATCH 242/245] =?UTF-8?q?refactor:=20=EB=A0=88=EC=9D=B4=EC=96=B4?= =?UTF-8?q?=20=EC=95=A1=EC=85=98=EA=B3=BC=20=EC=BB=A8=ED=85=8D=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=A9=94=EB=89=B4=20=EA=B2=BD=EA=B3=84=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../layer/layerActionMutations.ts | 273 ++++++++ .../PropertiesPanel/layer/useLayerActions.ts | 588 +----------------- .../layer/useLayerContextMenuRuntime.ts | 323 ++++++++++ 3 files changed, 601 insertions(+), 583 deletions(-) create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/layer/layerActionMutations.ts create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerContextMenuRuntime.ts diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/layerActionMutations.ts b/src/renderer/components/main/Grid/PropertiesPanel/layer/layerActionMutations.ts new file mode 100644 index 000000000..629adcb9b --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/layerActionMutations.ts @@ -0,0 +1,273 @@ +import { setPluginElementsHidden } from '@plugins/runtime/displayElement/pluginElementActions'; +import { useKeyStore } from '@stores/data/useKeyStore'; +import { useStatItemStore } from '@stores/data/useStatItemStore'; +import { useGraphItemStore } from '@stores/data/useGraphItemStore'; +import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; +import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; +import type { LayerGroups } from '@src/types/layerGroups'; +import type { LayerItem } from '../types'; +import type { SelectedElement } from '@stores/grid/useGridSelectionStore'; +import { reportElementOpSkipped } from '@src/renderer/editor/runtime/elementIntent'; +import { + patchElementHiddenById, + patchElementLayerNameById, + renameLayerGroupById, +} from '@src/renderer/editor/runtime/elementOps'; +import { + setMixedElementGroups, + setMixedLayerGroupHidden, +} from '@src/renderer/editor/runtime/mixedElementGroups'; +import { + selectPropertyPanelPluginElements, + usePluginDisplayElementStore, +} from '@stores/plugin/usePluginDisplayElementStore'; +import { + resolveElementById, + type NativeElementType, +} from '@src/renderer/editor/model/elementIdMap'; +import { isNativeElementId } from '@src/renderer/editor/model/elementId'; +import type { + EditorElementGroupTargetV1, + EditorTargetLayerGroupV1, +} from '@src/types/editor'; + +export const stableSelectionGroupId = ( + mode: string, + elements: readonly SelectedElement[], +): { stable: boolean; groupId?: string } => { + const native = elements.filter( + (element): element is SelectedElement & { type: NativeElementType } => + element.type === 'key' || + element.type === 'stat' || + element.type === 'graph' || + element.type === 'knob', + ); + const plugin = elements.filter((element) => element.type === 'plugin'); + if ( + (native.length === 0 && plugin.length === 0) || + native.some(({ id }) => !isNativeElementId(id)) + ) { + return { stable: false }; + } + const modeGroupIds = new Set( + (useLayerGroupStore.getState().layerGroups[mode] ?? []).map( + (group) => group.id, + ), + ); + const groupIds = new Set(); + for (const element of native) { + const locator = resolveElementById(element.type, element.id); + if (!locator || locator.mode !== mode) return { stable: true }; + const record = + element.type === 'key' + ? useKeyStore.getState().canonicalPositions + : element.type === 'stat' + ? useStatItemStore.getState().positions + : element.type === 'graph' + ? useGraphItemStore.getState().positions + : useKnobItemStore.getState().positions; + const groupId = record[mode]?.[locator.index]?.groupId; + if (groupId) groupIds.add(groupId); + } + // 플러그인 소속도 단일 그룹 판정에 포함 - 모드 def가 있는 groupId만 유효. + // 패널 창의 elements는 항상 비어 있으므로 창별 미러 셀렉터를 경유한다 + const pluginElements = selectPropertyPanelPluginElements( + usePluginDisplayElementStore.getState(), + ); + for (const element of plugin) { + const groupId = pluginElements.find( + (candidate) => candidate.fullId === element.id, + )?.groupId; + if (groupId && modeGroupIds.has(groupId)) groupIds.add(groupId); + } + return groupIds.size === 1 + ? { stable: true, groupId: [...groupIds][0] } + : { stable: true }; +}; + +interface CreateLayerActionMutationsOptions { + selectedKeyType: string; + layerItems: LayerItem[]; + onSelectionFromPanel?: () => void; + clearPendingDeselect: () => void; + setRenamingItemId: (id: string | null) => void; +} + +export const createLayerActionMutations = ({ + selectedKeyType, + layerItems, + onSelectionFromPanel, + clearPendingDeselect, + setRenamingItemId, +}: CreateLayerActionMutationsOptions) => { + const handleToggleVisibility = async ( + e: React.MouseEvent, + item: LayerItem, + ) => { + e.preventDefault(); + e.stopPropagation(); + + clearPendingDeselect(); + onSelectionFromPanel?.(); + + if (item.type !== 'plugin' && isNativeElementId(item.id)) { + try { + const target = { + elementType: item.type, + id: item.id, + hidden: !item.hidden, + }; + await patchElementHiddenById( + target.elementType, + target.id, + target.hidden, + ); + } catch (error) { + console.error(`Failed to toggle ${item.type} visibility`, error); + } + return; + } + + if (item.type === 'plugin') { + const applied = await setPluginElementsHidden([ + { fullId: item.id, hidden: !item.hidden }, + ]); + if (!applied) reportElementOpSkipped('panel plugin visibility toggle'); + } + }; + + const handleToggleGroupVisibility = async ( + e: React.MouseEvent, + groupId: string, + ) => { + e.preventDefault(); + e.stopPropagation(); + + const children = layerItems.filter((item) => item.groupId === groupId); + if (children.length === 0) return; + + const allHidden = children.every((c) => c.hidden); + const newHidden = !allHidden; + const nativeChildren = children.filter((child) => child.type !== 'plugin'); + + try { + if (nativeChildren.some((child) => !isNativeElementId(child.id))) { + return; + } + // 혼합·plugin-only 분기는 mixed 진입점이 판정 (native-only는 위임) + await setMixedLayerGroupHidden(selectedKeyType, groupId, newHidden); + } catch (error) { + console.error('Failed to toggle group visibility', error); + } + }; + + const handleLayerRenameCommit = async (item: LayerItem, value: string) => { + setRenamingItemId(null); + const trimmed = value.trim(); + const newLayerName = trimmed === '' ? null : trimmed; + + if (item.type !== 'plugin' && isNativeElementId(item.id)) { + try { + const target = { + elementType: item.type, + id: item.id, + patch: { property: 'layerName', value: newLayerName }, + } as const; + await patchElementLayerNameById( + target.elementType, + target.id, + target.patch.value, + ); + } catch (error) { + console.error(`Failed to rename ${item.type} layer`, error); + } + return; + } + + // canonical ID가 없는 native layer는 저장 경계에 진입시키지 않는다 + }; + + const handleGroupRenameCommit = async (groupId: string, value: string) => { + setRenamingItemId(null); + const trimmed = value.trim(); + if (trimmed === '') return; + + const currentGroups = useLayerGroupStore.getState().layerGroups; + const currentModeGroups = currentGroups[selectedKeyType] || []; + const currentGroup = currentModeGroups.find( + (group) => group.id === groupId, + ); + if (!currentGroup || currentGroup.name === trimmed) return; + + try { + await renameLayerGroupById(selectedKeyType, groupId, trimmed); + } catch (error) { + console.error('Failed to rename group', error); + } + }; + + const setGroupIdOnSelected = async ( + targetGroupId: string | undefined, + elementsOverride?: SelectedElement[], + options?: { + historyLayerGroups?: LayerGroups; + layerGroupsForNormalization?: LayerGroups; + }, + ) => { + const selectedForUpdate = + elementsOverride ?? useGridSelectionStore.getState().selectedElements; + if (selectedForUpdate.length === 0) return false; + + const nativeTargets = selectedForUpdate.flatMap( + (element): EditorElementGroupTargetV1[] => + element.type === 'key' || + element.type === 'stat' || + element.type === 'graph' || + element.type === 'knob' + ? [{ elementType: element.type, id: element.id }] + : [], + ); + const pluginTargets = selectedForUpdate + .filter((element) => element.type === 'plugin') + .map((element) => element.id); + if (nativeTargets.length === 0 && pluginTargets.length === 0) return false; + const stableTargets = + nativeTargets.every(({ id }) => isNativeElementId(id)) && + new Set(nativeTargets.map(({ id }) => id)).size === + nativeTargets.length && + new Set(pluginTargets).size === pluginTargets.length; + if (stableTargets) { + let targetGroup: EditorTargetLayerGroupV1 | null = null; + if (targetGroupId) { + const currentGroups = useLayerGroupStore.getState().layerGroups; + const currentModeGroups = currentGroups[selectedKeyType] ?? []; + const creatingGroup = options?.layerGroupsForNormalization?.[ + selectedKeyType + ]?.find( + (group) => + group.id === targetGroupId && + !currentModeGroups.some((current) => current.id === group.id), + ); + targetGroup = creatingGroup + ? { kind: 'create', id: creatingGroup.id, name: creatingGroup.name } + : { kind: 'existing', id: targetGroupId }; + } + return setMixedElementGroups( + selectedKeyType, + nativeTargets, + pluginTargets, + targetGroup, + ); + } + return false; + }; + + return { + handleToggleVisibility, + handleToggleGroupVisibility, + handleLayerRenameCommit, + handleGroupRenameCommit, + setGroupIdOnSelected, + }; +}; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerActions.ts b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerActions.ts index 8512bd564..39f53f5a7 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerActions.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerActions.ts @@ -3,589 +3,11 @@ * 가시성 토글, 이름 변경, 컨텍스트 메뉴, 삭제, 그룹 연산 등 */ -import { setPluginElementsHidden } from '@plugins/runtime/displayElement/pluginElementActions'; -import { useState, useRef } from 'react'; -import { useKeyStore } from '@stores/data/useKeyStore'; -import { useStatItemStore } from '@stores/data/useStatItemStore'; -import { useGraphItemStore } from '@stores/data/useGraphItemStore'; -import { useKnobItemStore } from '@stores/data/useKnobItemStore'; -import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; -import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; -import { buildNextLayerGroupName } from '@utils/layerGroupUtils'; -import type { LayerGroups } from '@src/types/layerGroups'; -import type { LayerGroupDef } from '@src/types/layerGroups'; -import type { ListItem } from '@components/main/Modal/ListPopup'; -import type { LayerItem, DisplayItem } from '../types'; -import type { SelectedElement } from '@stores/grid/useGridSelectionStore'; -import { deleteFrozenSelection } from '@src/renderer/editor/runtime/deleteFrozenSelection'; -import { reportElementOpSkipped } from '@src/renderer/editor/runtime/elementIntent'; import { - patchElementHiddenById, - patchElementLayerNameById, - renameLayerGroupById, -} from '@src/renderer/editor/runtime/elementOps'; -import { - setMixedElementGroups, - setMixedLayerGroupHidden, -} from '@src/renderer/editor/runtime/mixedElementGroups'; -import { - selectPropertyPanelPluginElements, - usePluginDisplayElementStore, -} from '@stores/plugin/usePluginDisplayElementStore'; -import { - resolveElementById, - type NativeElementType, -} from '@src/renderer/editor/model/elementIdMap'; -import { isNativeElementId } from '@src/renderer/editor/model/elementId'; -import type { - EditorElementGroupTargetV1, - EditorTargetLayerGroupV1, -} from '@src/types/editor'; - -// ============================================================================ -// 파라미터 / 반환 타입 -// ============================================================================ - -interface UseLayerActionsParams { - selectedKeyType: string; - layerItems: LayerItem[]; - layerGroupsForMode: LayerGroupDef[]; - onSelectionFromPanel?: () => void; - clearPendingDeselect: () => void; - displayItemsRef: React.MutableRefObject; - setLastClickedIndex: (index: number | null) => void; - setLastClickedDisplayIndex: (index: number | null) => void; - t: (key: string) => string; -} - -const stableSelectionGroupId = ( - mode: string, - elements: readonly SelectedElement[], -): { stable: boolean; groupId?: string } => { - const native = elements.filter( - (element): element is SelectedElement & { type: NativeElementType } => - element.type === 'key' || - element.type === 'stat' || - element.type === 'graph' || - element.type === 'knob', - ); - const plugin = elements.filter((element) => element.type === 'plugin'); - if ( - (native.length === 0 && plugin.length === 0) || - native.some(({ id }) => !isNativeElementId(id)) - ) { - return { stable: false }; - } - const modeGroupIds = new Set( - (useLayerGroupStore.getState().layerGroups[mode] ?? []).map( - (group) => group.id, - ), - ); - const groupIds = new Set(); - for (const element of native) { - const locator = resolveElementById(element.type, element.id); - if (!locator || locator.mode !== mode) return { stable: true }; - const record = - element.type === 'key' - ? useKeyStore.getState().canonicalPositions - : element.type === 'stat' - ? useStatItemStore.getState().positions - : element.type === 'graph' - ? useGraphItemStore.getState().positions - : useKnobItemStore.getState().positions; - const groupId = record[mode]?.[locator.index]?.groupId; - if (groupId) groupIds.add(groupId); - } - // 플러그인 소속도 단일 그룹 판정에 포함 - 모드 def가 있는 groupId만 유효. - // 패널 창의 elements는 항상 비어 있으므로 창별 미러 셀렉터를 경유한다 - const pluginElements = selectPropertyPanelPluginElements( - usePluginDisplayElementStore.getState(), - ); - for (const element of plugin) { - const groupId = pluginElements.find( - (candidate) => candidate.fullId === element.id, - )?.groupId; - if (groupId && modeGroupIds.has(groupId)) groupIds.add(groupId); - } - return groupIds.size === 1 - ? { stable: true, groupId: [...groupIds][0] } - : { stable: true }; -}; - -// ============================================================================ -// 훅 -// ============================================================================ - -export function useLayerActions({ - selectedKeyType, - layerItems, - layerGroupsForMode, - onSelectionFromPanel, - clearPendingDeselect, - displayItemsRef, - setLastClickedIndex, - setLastClickedDisplayIndex, - t, -}: UseLayerActionsParams) { - // 컨텍스트 메뉴 상태 - const [contextMenuOpen, setContextMenuOpen] = useState(false); - const [contextMenuPosition, setContextMenuPosition] = useState({ - x: 0, - y: 0, - }); - const [contextMenuItem, setContextMenuItem] = useState( - null, - ); - const [contextMenuGroupId, setContextMenuGroupId] = useState( - null, - ); - - // 인라인 이름 변경 상태 - const [renamingItemId, setRenamingItemId] = useState(null); - const [renameValue, setRenameValue] = useState(''); - const renameInputRef = useRef(null); - const renameCancelledRef = useRef(false); - - // ────────────────────────────────────────────────────────────────────────── - // 가시성 토글 - // ────────────────────────────────────────────────────────────────────────── - - const handleToggleVisibility = async ( - e: React.MouseEvent, - item: LayerItem, - ) => { - e.preventDefault(); - e.stopPropagation(); - - clearPendingDeselect(); - onSelectionFromPanel?.(); - - if (item.type !== 'plugin' && isNativeElementId(item.id)) { - try { - const target = { - elementType: item.type, - id: item.id, - hidden: !item.hidden, - }; - await patchElementHiddenById( - target.elementType, - target.id, - target.hidden, - ); - } catch (error) { - console.error(`Failed to toggle ${item.type} visibility`, error); - } - return; - } - - if (item.type === 'plugin') { - const applied = await setPluginElementsHidden([ - { fullId: item.id, hidden: !item.hidden }, - ]); - if (!applied) reportElementOpSkipped('panel plugin visibility toggle'); - } - }; - - // ────────────────────────────────────────────────────────────────────────── - // 그룹 전체 표시/숨김 토글 - // ────────────────────────────────────────────────────────────────────────── - - const handleToggleGroupVisibility = async ( - e: React.MouseEvent, - groupId: string, - ) => { - e.preventDefault(); - e.stopPropagation(); - - const children = layerItems.filter((item) => item.groupId === groupId); - if (children.length === 0) return; - - const allHidden = children.every((c) => c.hidden); - const newHidden = !allHidden; - const nativeChildren = children.filter((child) => child.type !== 'plugin'); - - try { - if (nativeChildren.some((child) => !isNativeElementId(child.id))) { - return; - } - // 혼합·plugin-only 분기는 mixed 진입점이 판정 (native-only는 위임) - await setMixedLayerGroupHidden(selectedKeyType, groupId, newHidden); - } catch (error) { - console.error('Failed to toggle group visibility', error); - } - }; - - // ────────────────────────────────────────────────────────────────────────── - // 레이어 이름 변경 커밋 - // ────────────────────────────────────────────────────────────────────────── - - const handleLayerRenameCommit = async (item: LayerItem, value: string) => { - setRenamingItemId(null); - const trimmed = value.trim(); - const newLayerName = trimmed === '' ? null : trimmed; - - if (item.type !== 'plugin' && isNativeElementId(item.id)) { - try { - const target = { - elementType: item.type, - id: item.id, - patch: { property: 'layerName', value: newLayerName }, - } as const; - await patchElementLayerNameById( - target.elementType, - target.id, - target.patch.value, - ); - } catch (error) { - console.error(`Failed to rename ${item.type} layer`, error); - } - return; - } - - // canonical ID가 없는 native layer는 저장 경계에 진입시키지 않는다 - }; - - // ────────────────────────────────────────────────────────────────────────── - // 그룹 이름 변경 커밋 - // ────────────────────────────────────────────────────────────────────────── - - const handleGroupRenameCommit = async (groupId: string, value: string) => { - setRenamingItemId(null); - const trimmed = value.trim(); - if (trimmed === '') return; - - const currentGroups = useLayerGroupStore.getState().layerGroups; - const currentModeGroups = currentGroups[selectedKeyType] || []; - const currentGroup = currentModeGroups.find( - (group) => group.id === groupId, - ); - if (!currentGroup || currentGroup.name === trimmed) return; - - try { - await renameLayerGroupById(selectedKeyType, groupId, trimmed); - } catch (error) { - console.error('Failed to rename group', error); - } - }; - - // ────────────────────────────────────────────────────────────────────────── - // 선택된 레이어들에 groupId 설정 - // ────────────────────────────────────────────────────────────────────────── - - const setGroupIdOnSelected = async ( - targetGroupId: string | undefined, - elementsOverride?: SelectedElement[], - options?: { - historyLayerGroups?: LayerGroups; - layerGroupsForNormalization?: LayerGroups; - }, - ) => { - const selectedForUpdate = - elementsOverride ?? useGridSelectionStore.getState().selectedElements; - if (selectedForUpdate.length === 0) return false; - - const nativeTargets = selectedForUpdate.flatMap( - (element): EditorElementGroupTargetV1[] => - element.type === 'key' || - element.type === 'stat' || - element.type === 'graph' || - element.type === 'knob' - ? [{ elementType: element.type, id: element.id }] - : [], - ); - const pluginTargets = selectedForUpdate - .filter((element) => element.type === 'plugin') - .map((element) => element.id); - if (nativeTargets.length === 0 && pluginTargets.length === 0) return false; - const stableTargets = - nativeTargets.every(({ id }) => isNativeElementId(id)) && - new Set(nativeTargets.map(({ id }) => id)).size === - nativeTargets.length && - new Set(pluginTargets).size === pluginTargets.length; - if (stableTargets) { - let targetGroup: EditorTargetLayerGroupV1 | null = null; - if (targetGroupId) { - const currentGroups = useLayerGroupStore.getState().layerGroups; - const currentModeGroups = currentGroups[selectedKeyType] ?? []; - const creatingGroup = options?.layerGroupsForNormalization?.[ - selectedKeyType - ]?.find( - (group) => - group.id === targetGroupId && - !currentModeGroups.some((current) => current.id === group.id), - ); - targetGroup = creatingGroup - ? { kind: 'create', id: creatingGroup.id, name: creatingGroup.name } - : { kind: 'existing', id: targetGroupId }; - } - return setMixedElementGroups( - selectedKeyType, - nativeTargets, - pluginTargets, - targetGroup, - ); - } - return false; - }; - - // ────────────────────────────────────────────────────────────────────────── - // 컨텍스트 메뉴 아이템 (동적) - // ────────────────────────────────────────────────────────────────────────── - - const contextMenuItems: ListItem[] = (() => { - if (contextMenuGroupId) { - return [ - { - id: 'renameGroup', - label: t('contextMenu.renameGroup') || 'Rename', - }, - { id: 'ungroup', label: t('contextMenu.ungroup') || 'Ungroup' }, - ]; - } - - const selectedElements = useGridSelectionStore.getState().selectedElements; - const items: ListItem[] = [ - { id: 'rename', label: t('contextMenu.rename') || 'Rename' }, - ]; - - if (selectedElements.length >= 2 && !contextMenuItem?.groupId) { - items.push({ - id: 'groupSelected', - label: t('contextMenu.groupSelected') || 'Group', - }); - } - - if (contextMenuItem?.groupId) { - items.push({ - id: 'removeFromGroup', - label: t('contextMenu.removeFromGroup') || 'Remove from Group', - }); - } - - items.push({ - id: 'delete', - label: t('propertiesPanel.delete') || 'Delete', - }); - - return items; - })(); - - // ────────────────────────────────────────────────────────────────────────── - // 우클릭 핸들러 - // ────────────────────────────────────────────────────────────────────────── - - const handleContextMenu = ( - e: React.MouseEvent, - item: LayerItem, - index: number, - ) => { - e.preventDefault(); - e.stopPropagation(); - clearPendingDeselect(); - onSelectionFromPanel?.(); - - const selectedElements = useGridSelectionStore.getState().selectedElements; - const isSelected = selectedElements.some((el) => el.id === item.id); - - if (!isSelected) { - const { clearSelection, toggleSelection } = - useGridSelectionStore.getState(); - clearSelection(); - if (item.type === 'key' && item.index !== undefined) { - toggleSelection({ type: 'key', id: item.id, index: item.index }); - } else if (item.type === 'stat' && item.index !== undefined) { - toggleSelection({ type: 'stat', id: item.id, index: item.index }); - } else if (item.type === 'graph' && item.index !== undefined) { - toggleSelection({ type: 'graph', id: item.id, index: item.index }); - } else if (item.type === 'knob' && item.index !== undefined) { - toggleSelection({ type: 'knob', id: item.id, index: item.index }); - } else if (item.type === 'plugin') { - toggleSelection({ type: 'plugin', id: item.id }); - } - setLastClickedIndex(index); - const displayIdx = displayItemsRef.current.findIndex( - (di) => di.displayType === 'layer' && di.item.id === item.id, - ); - setLastClickedDisplayIndex(displayIdx !== -1 ? displayIdx : null); - } - - setContextMenuItem(item); - setContextMenuPosition({ x: e.clientX, y: e.clientY }); - setContextMenuOpen(true); - }; - - // 그룹 헤더 우클릭 - const handleGroupHeaderContextMenu = ( - e: React.MouseEvent, - groupId: string, - ) => { - e.preventDefault(); - e.stopPropagation(); - clearPendingDeselect(); - setContextMenuGroupId(groupId); - setContextMenuItem(null); - setContextMenuPosition({ x: e.clientX, y: e.clientY }); - setContextMenuOpen(true); - }; - - // ────────────────────────────────────────────────────────────────────────── - // 컨텍스트 메뉴 선택 핸들러 - // ────────────────────────────────────────────────────────────────────────── - - const handleContextMenuSelect = async (itemId: string) => { - // 그룹 헤더 컨텍스트 메뉴 처리 - if (contextMenuGroupId) { - if (itemId === 'renameGroup') { - const groupDef = layerGroupsForMode.find( - (g) => g.id === contextMenuGroupId, - ); - setRenamingItemId(`group:${contextMenuGroupId}`); - setRenameValue(groupDef?.name || ''); - setContextMenuOpen(false); - setContextMenuGroupId(null); - requestAnimationFrame(() => { - renameInputRef.current?.focus(); - renameInputRef.current?.select(); - }); - return; - } - if (itemId === 'ungroup') { - const children = layerItems.filter( - (item) => item.groupId === contextMenuGroupId, - ); - const elements: SelectedElement[] = children.map((child) => - child.type === 'plugin' - ? { type: 'plugin', id: child.id } - : { - type: child.type, - id: child.id, - ...(child.index === undefined ? {} : { index: child.index }), - }, - ); - await setGroupIdOnSelected(undefined, elements); - - onSelectionFromPanel?.(); - useGridSelectionStore.getState().clearSelection(); - setContextMenuOpen(false); - setContextMenuGroupId(null); - return; - } - setContextMenuOpen(false); - setContextMenuGroupId(null); - return; - } - - if (itemId === 'rename') { - if (contextMenuItem) { - setRenamingItemId(contextMenuItem.id); - setRenameValue(contextMenuItem.name); - setContextMenuOpen(false); - requestAnimationFrame(() => { - renameInputRef.current?.focus(); - renameInputRef.current?.select(); - }); - } - return; - } - - // 선택 항목 그룹화 - if (itemId === 'groupSelected') { - const selectedElements = - useGridSelectionStore.getState().selectedElements; - if (selectedElements.length < 2) return; - - const currentGroups = useLayerGroupStore.getState().layerGroups; - const modeGroups = currentGroups[selectedKeyType] || []; - const stableGroup = stableSelectionGroupId( - selectedKeyType, - selectedElements, - ); - if (!stableGroup.stable) return; - const singleGroupId = stableGroup.groupId; - - if (singleGroupId) { - await setGroupIdOnSelected(singleGroupId); - } else { - const groupId = crypto.randomUUID(); - const groupName = buildNextLayerGroupName( - t('layerGroup.newGroup') || 'New Group', - modeGroups, - ); - const nextGroups: LayerGroups = { - ...currentGroups, - [selectedKeyType]: [...modeGroups, { id: groupId, name: groupName }], - }; - - await setGroupIdOnSelected(groupId, undefined, { - historyLayerGroups: currentGroups, - layerGroupsForNormalization: nextGroups, - }); - } - - setContextMenuOpen(false); - return; - } - - // 그룹에서 제거 - if (itemId === 'removeFromGroup') { - if (contextMenuItem) { - const elements: SelectedElement[] = - contextMenuItem.type === 'plugin' - ? [{ type: 'plugin', id: contextMenuItem.id }] - : isNativeElementId(contextMenuItem.id) - ? [ - { - type: contextMenuItem.type, - id: contextMenuItem.id, - index: contextMenuItem.index, - }, - ] - : []; - if (elements.length > 0) { - await setGroupIdOnSelected(undefined, elements); - onSelectionFromPanel?.(); - useGridSelectionStore.getState().clearSelection(); - } - } - setContextMenuOpen(false); - return; - } - - if (itemId === 'delete') { - const selectedElements = - useGridSelectionStore.getState().selectedElements; - if (selectedElements.length === 0) return; - onSelectionFromPanel?.(); - await deleteFrozenSelection(selectedElements); - } - - setContextMenuOpen(false); - }; - - return { - // 컨텍스트 메뉴 상태 - contextMenuOpen, - contextMenuPosition, - contextMenuItems, - setContextMenuOpen, - setContextMenuGroupId, - - // 이름 변경 상태 - renamingItemId, - renameValue, - setRenameValue, - renameInputRef, - renameCancelledRef, - setRenamingItemId, + useLayerContextMenuRuntime, + type UseLayerContextMenuRuntimeParams, +} from './useLayerContextMenuRuntime'; - // 핸들러 - handleToggleVisibility, - handleToggleGroupVisibility, - handleLayerRenameCommit, - handleGroupRenameCommit, - handleContextMenu, - handleGroupHeaderContextMenu, - handleContextMenuSelect, - setGroupIdOnSelected, - }; +export function useLayerActions(params: UseLayerContextMenuRuntimeParams) { + return useLayerContextMenuRuntime(params); } diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerContextMenuRuntime.ts b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerContextMenuRuntime.ts new file mode 100644 index 000000000..cab705df9 --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerContextMenuRuntime.ts @@ -0,0 +1,323 @@ +import { useRef, useState } from 'react'; +import type React from 'react'; + +import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; +import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; +import { buildNextLayerGroupName } from '@utils/layerGroupUtils'; +import type { LayerGroups, LayerGroupDef } from '@src/types/layerGroups'; +import type { ListItem } from '@components/main/Modal/ListPopup'; +import type { DisplayItem, LayerItem } from '../types'; +import type { SelectedElement } from '@stores/grid/useGridSelectionStore'; +import { deleteFrozenSelection } from '@src/renderer/editor/runtime/deleteFrozenSelection'; +import { isNativeElementId } from '@src/renderer/editor/model/elementId'; +import { + createLayerActionMutations, + stableSelectionGroupId, +} from './layerActionMutations'; + +export interface UseLayerContextMenuRuntimeParams { + selectedKeyType: string; + layerItems: LayerItem[]; + layerGroupsForMode: LayerGroupDef[]; + onSelectionFromPanel?: () => void; + clearPendingDeselect: () => void; + displayItemsRef: React.MutableRefObject; + setLastClickedIndex: (index: number | null) => void; + setLastClickedDisplayIndex: (index: number | null) => void; + t: (key: string) => string; +} + +export const useLayerContextMenuRuntime = ({ + selectedKeyType, + layerItems, + layerGroupsForMode, + onSelectionFromPanel, + clearPendingDeselect, + displayItemsRef, + setLastClickedIndex, + setLastClickedDisplayIndex, + t, +}: UseLayerContextMenuRuntimeParams) => { + // 컨텍스트 메뉴 상태 + const [contextMenuOpen, setContextMenuOpen] = useState(false); + const [contextMenuPosition, setContextMenuPosition] = useState({ + x: 0, + y: 0, + }); + const [contextMenuItem, setContextMenuItem] = useState( + null, + ); + const [contextMenuGroupId, setContextMenuGroupId] = useState( + null, + ); + + // 인라인 이름 변경 상태 + const [renamingItemId, setRenamingItemId] = useState(null); + const [renameValue, setRenameValue] = useState(''); + const renameInputRef = useRef(null); + const renameCancelledRef = useRef(false); + + const { + handleToggleVisibility, + handleToggleGroupVisibility, + handleLayerRenameCommit, + handleGroupRenameCommit, + setGroupIdOnSelected, + } = createLayerActionMutations({ + selectedKeyType, + layerItems, + onSelectionFromPanel, + clearPendingDeselect, + setRenamingItemId, + }); + + // 컨텍스트 메뉴 아이템 (동적) + const contextMenuItems: ListItem[] = (() => { + if (contextMenuGroupId) { + return [ + { + id: 'renameGroup', + label: t('contextMenu.renameGroup') || 'Rename', + }, + { id: 'ungroup', label: t('contextMenu.ungroup') || 'Ungroup' }, + ]; + } + + const selectedElements = useGridSelectionStore.getState().selectedElements; + const items: ListItem[] = [ + { id: 'rename', label: t('contextMenu.rename') || 'Rename' }, + ]; + + if (selectedElements.length >= 2 && !contextMenuItem?.groupId) { + items.push({ + id: 'groupSelected', + label: t('contextMenu.groupSelected') || 'Group', + }); + } + + if (contextMenuItem?.groupId) { + items.push({ + id: 'removeFromGroup', + label: t('contextMenu.removeFromGroup') || 'Remove from Group', + }); + } + + items.push({ + id: 'delete', + label: t('propertiesPanel.delete') || 'Delete', + }); + + return items; + })(); + + const handleContextMenu = ( + e: React.MouseEvent, + item: LayerItem, + index: number, + ) => { + e.preventDefault(); + e.stopPropagation(); + clearPendingDeselect(); + onSelectionFromPanel?.(); + + const selectedElements = useGridSelectionStore.getState().selectedElements; + const isSelected = selectedElements.some((el) => el.id === item.id); + + if (!isSelected) { + const { clearSelection, toggleSelection } = + useGridSelectionStore.getState(); + clearSelection(); + if (item.type === 'key' && item.index !== undefined) { + toggleSelection({ type: 'key', id: item.id, index: item.index }); + } else if (item.type === 'stat' && item.index !== undefined) { + toggleSelection({ type: 'stat', id: item.id, index: item.index }); + } else if (item.type === 'graph' && item.index !== undefined) { + toggleSelection({ type: 'graph', id: item.id, index: item.index }); + } else if (item.type === 'knob' && item.index !== undefined) { + toggleSelection({ type: 'knob', id: item.id, index: item.index }); + } else if (item.type === 'plugin') { + toggleSelection({ type: 'plugin', id: item.id }); + } + setLastClickedIndex(index); + const displayIdx = displayItemsRef.current.findIndex( + (di) => di.displayType === 'layer' && di.item.id === item.id, + ); + setLastClickedDisplayIndex(displayIdx !== -1 ? displayIdx : null); + } + + setContextMenuItem(item); + setContextMenuPosition({ x: e.clientX, y: e.clientY }); + setContextMenuOpen(true); + }; + + // 그룹 헤더 우클릭 + const handleGroupHeaderContextMenu = ( + e: React.MouseEvent, + groupId: string, + ) => { + e.preventDefault(); + e.stopPropagation(); + clearPendingDeselect(); + setContextMenuGroupId(groupId); + setContextMenuItem(null); + setContextMenuPosition({ x: e.clientX, y: e.clientY }); + setContextMenuOpen(true); + }; + + const handleContextMenuSelect = async (itemId: string) => { + // 그룹 헤더 컨텍스트 메뉴 처리 + if (contextMenuGroupId) { + if (itemId === 'renameGroup') { + const groupDef = layerGroupsForMode.find( + (g) => g.id === contextMenuGroupId, + ); + setRenamingItemId(`group:${contextMenuGroupId}`); + setRenameValue(groupDef?.name || ''); + setContextMenuOpen(false); + setContextMenuGroupId(null); + requestAnimationFrame(() => { + renameInputRef.current?.focus(); + renameInputRef.current?.select(); + }); + return; + } + if (itemId === 'ungroup') { + const children = layerItems.filter( + (item) => item.groupId === contextMenuGroupId, + ); + const elements: SelectedElement[] = children.map((child) => + child.type === 'plugin' + ? { type: 'plugin', id: child.id } + : { + type: child.type, + id: child.id, + ...(child.index === undefined ? {} : { index: child.index }), + }, + ); + await setGroupIdOnSelected(undefined, elements); + + onSelectionFromPanel?.(); + useGridSelectionStore.getState().clearSelection(); + setContextMenuOpen(false); + setContextMenuGroupId(null); + return; + } + setContextMenuOpen(false); + setContextMenuGroupId(null); + return; + } + + if (itemId === 'rename') { + if (contextMenuItem) { + setRenamingItemId(contextMenuItem.id); + setRenameValue(contextMenuItem.name); + setContextMenuOpen(false); + requestAnimationFrame(() => { + renameInputRef.current?.focus(); + renameInputRef.current?.select(); + }); + } + return; + } + + // 선택 항목 그룹화 + if (itemId === 'groupSelected') { + const selectedElements = + useGridSelectionStore.getState().selectedElements; + if (selectedElements.length < 2) return; + + const currentGroups = useLayerGroupStore.getState().layerGroups; + const modeGroups = currentGroups[selectedKeyType] || []; + const stableGroup = stableSelectionGroupId( + selectedKeyType, + selectedElements, + ); + if (!stableGroup.stable) return; + const singleGroupId = stableGroup.groupId; + + if (singleGroupId) { + await setGroupIdOnSelected(singleGroupId); + } else { + const groupId = crypto.randomUUID(); + const groupName = buildNextLayerGroupName( + t('layerGroup.newGroup') || 'New Group', + modeGroups, + ); + const nextGroups: LayerGroups = { + ...currentGroups, + [selectedKeyType]: [...modeGroups, { id: groupId, name: groupName }], + }; + + await setGroupIdOnSelected(groupId, undefined, { + historyLayerGroups: currentGroups, + layerGroupsForNormalization: nextGroups, + }); + } + + setContextMenuOpen(false); + return; + } + + // 그룹에서 제거 + if (itemId === 'removeFromGroup') { + if (contextMenuItem) { + const elements: SelectedElement[] = + contextMenuItem.type === 'plugin' + ? [{ type: 'plugin', id: contextMenuItem.id }] + : isNativeElementId(contextMenuItem.id) + ? [ + { + type: contextMenuItem.type, + id: contextMenuItem.id, + index: contextMenuItem.index, + }, + ] + : []; + if (elements.length > 0) { + await setGroupIdOnSelected(undefined, elements); + onSelectionFromPanel?.(); + useGridSelectionStore.getState().clearSelection(); + } + } + setContextMenuOpen(false); + return; + } + + if (itemId === 'delete') { + const selectedElements = + useGridSelectionStore.getState().selectedElements; + if (selectedElements.length === 0) return; + onSelectionFromPanel?.(); + await deleteFrozenSelection(selectedElements); + } + + setContextMenuOpen(false); + }; + + return { + // 컨텍스트 메뉴 상태 + contextMenuOpen, + contextMenuPosition, + contextMenuItems, + setContextMenuOpen, + setContextMenuGroupId, + + // 이름 변경 상태 + renamingItemId, + renameValue, + setRenameValue, + renameInputRef, + renameCancelledRef, + setRenamingItemId, + + // 핸들러 + handleToggleVisibility, + handleToggleGroupVisibility, + handleLayerRenameCommit, + handleGroupRenameCommit, + handleContextMenu, + handleGroupHeaderContextMenu, + handleContextMenuSelect, + setGroupIdOnSelected, + }; +}; From d428bab76b854ff5845844cd32e52c0628d1587d Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 30 Aug 2026 23:27:16 +0900 Subject: [PATCH 243/245] =?UTF-8?q?docs:=20=EC=BD=94=EB=93=9C=20=ED=92=88?= =?UTF-8?q?=EC=A7=88=20=EB=A6=AC=ED=8C=A9=ED=84=B0=EB=A7=81=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20=EC=B5=9C=EC=8B=A0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/code-quality-refactoring-plan.md | 281 +++++++++++++------------- 1 file changed, 139 insertions(+), 142 deletions(-) diff --git a/docs/code-quality-refactoring-plan.md b/docs/code-quality-refactoring-plan.md index 07a539a02..7c43b696b 100644 --- a/docs/code-quality-refactoring-plan.md +++ b/docs/code-quality-refactoring-plan.md @@ -1,175 +1,172 @@ -# 코드 품질 리팩터링 계획 - -## 목표와 원칙 - -대형 파일을 단순히 줄 수만 나누지 않고, 변경 이유와 검증 경계가 같은 책임끼리 모듈화한다. - -- UI 컴포넌트, 비동기 상태 훅, 순수 변환 모델을 분리한다. -- 기존 공개 import 경로와 wire/API 계약은 가능한 한 유지한다. -- 파일 이동 커밋과 동작 변경 커밋을 섞지 않는다. -- 각 단계에서 관련 테스트를 먼저 실행하고, 단계 종료 시 전체 게이트를 실행한다. -- 성능 수치는 실제 benchmark 실행 결과만 기록한다. - -## 이번 브랜치에서 완료한 범위 - -기준 브랜치: `main`의 `af1bc19c` - -| 영역 | 이전 | 현재 | 분리 결과 | -| --- | ---: | ---: | --- | -| `PropertiesPanel.tsx` | 3407줄 | 1549줄 | 선택 모델, navigation, commit handler, 이름 변경 세션, 플러그인 설정 제어, 패널 가시성 생명주기 분리 | -| `BatchSelectionPanel.tsx` | 2367줄 | 1277줄 | 공통 handler/header와 그래프·노브·플러그인 전용 패널 분리 | -| `SingleSelectionPanel.tsx` | 2061줄 | 674줄 | 그래프·노브 패널과 공통 표시 모델 분리 | -| `Settings.tsx` | 1423줄 | 1172줄 | 키음 출력 UI, 비동기 적용 큐 훅, 순수 view model 분리 | -| `Grid.tsx` | 2303줄 | 1269줄 | 네이티브 장면, 선택 overlay, 컨텍스트 메뉴·복제 ghost 모델 분리 | -| `useGridSelection.ts` | 1552줄 | 1036줄 | 드래그 생명주기, 이동·클립보드·붙여넣기 순수 모델 분리 | -| `PluginElement.tsx` | 1647줄 | 1421줄 | DOM 상호작용 어댑터와 레이아웃 모델 분리 | -| `editorCoordinator.ts` | 3001줄 | 2138줄 | 의미 연산 projection, 직렬 작업 큐, 커밋 재시도 정책 분리 | -| `elementOps.ts` | 2611줄 | 571줄 | document model, 공통 property, group, style, geometry 연산을 도메인 모듈로 분리하고 기존 facade export 유지 | -| `ColorPicker.tsx` | 1513줄 | 1036줄 | 입력 필드와 팔레트·history 제어 UI 분리 | -| `NumberInput.tsx` | 1440줄 | 1286줄 | draft·수식 parser 모델과 입력 chrome 분리 | -| `CounterAnimationEditorModal.tsx` | 1417줄 | 1343줄 | draft 병합·정규화 모델 분리 | -| `SoundTrimModal.tsx` | 1275줄 | 1011줄 | decode·waveform·trim 순수 모델 분리 | -| `store.rs` | 22978줄 | 2769줄 | 테스트, writer, 자산 참조, 사운드 복구, editor helper, plugin storage transaction 분리 | -| `editor_ops.rs` | 10154줄 | 2940줄 | 인라인 테스트 모듈 분리 | -| `migration.rs` | 6053줄 | 2238줄 | 인라인 테스트 모듈 분리 | -| `editor.rs` | 5777줄 | 2396줄 | 인라인 테스트 모듈 분리 | -| `app_state.rs` | 9314줄 | 4782줄 | 테스트, 창 geometry, 키보드 daemon 정책·제어, 종료 생명주기 분리 | -| `models/mod.rs` | 4169줄 | 3033줄 | 인라인 테스트 모듈 분리 | -| `preset/load.rs` | 3104줄 | 1180줄 | 테스트와 폰트·이미지·사운드 자산 복원 분리 | -| `keys/sound.rs` | 2189줄 | 806줄 | 테스트, 라이브러리 스캔·재조정, WAV 원자 교체 분리 | -| `audio/engine.rs` | 1712줄 | 911줄 | ASIO 경계, 출력 테스트, clip decode, 명령 스레드 분리 | - -ASIO는 장치 I/O 경계와 순수 정책을 `audio/engine/asio.rs`로 분리했다. 버퍼 정규화, 드라이버 목록 정규화, 유효 출력 구성, 빌드 가용성, 오류 코드와 폴백 계약을 하드웨어 없이 검증한다. Windows release workflow도 `asio-backend` feature의 focused suite를 production build 전에 실행한다. - -이번 분리는 IPC command/event 이름, store schema, editor wire 형식, 기존 `elementOps` 공개 export를 바꾸지 않았다. 이동한 Rust 창 좌표 코드는 가시성 키워드와 포맷 차이를 제외한 토큰 비교로 원본과 동일함을 확인했다. - -## Grid 네이티브 요소 재사용성 감사 - -키·통계·그래프·노브의 편집 Grid 렌더러를 오버레이 렌더러와 대조했다. - -- 키와 통계는 공용 `components/shared/Key.tsx`를 사용한다. -- 그래프는 공용 `components/shared/GraphPanel.tsx`을 사용한다. -- 노브의 링·이미지·인디케이터 DOM은 `components/shared/KnobFace.tsx`로 공용화했다. -- 네 종류의 scene 조립과 이벤트 생성은 `Grid/core/NativeGridElements.tsx`가 담당한다. -- `useGridElementInteraction`, `useStableHandlerSlots`, range·movement·clipboard·paste 모델이 포인터와 선택 정책을 공용화한다. -- 통계 표시명은 `utils/grid/statTypeLabel.ts`, mixed 값 집계는 `utils/core/mixedValue.ts`가 단일 소스다. - -따라서 네 종류의 표시 표면을 다시 하나의 거대 조건부 컴포넌트로 합치지 않는다. 차이가 큰 graph 데이터 표현과 knob 회전 상태는 각각의 얇은 adapter에 남기고, 실제로 동일한 DOM·정책만 공유한다. - -## 추가 조사 결과와 잔여 프론트엔드 우선순위 - -아래 줄 수는 이번 브랜치에서 `wc -l`로 측정한 값이다. +# 코드 품질 리팩터링 결과와 후속 계획 + +## 목표와 판정 원칙 + +기준 커밋은 `af1bc19c`이며 작업 브랜치는 `refactor/code-quality-modularization`이다. 이번 작업의 최우선 조건은 **의도한 동작 변경이 없는 책임 분리**다. + +- UI, 상태·비동기 runtime, 순수 변환 모델을 변경 이유와 검증 경계에 맞춰 분리한다. +- 공개 import, Tauri command/event, 저장 schema, editor wire 형식을 유지한다. +- characterization test로 기존 호출 순서, 오류 전파, preview/commit, cleanup 계약을 먼저 고정한다. +- 테스트와 리팩터링을 단계별 한국어 conventional commit으로 남긴다. +- 줄 수만 줄이는 분리나 내부 상태를 넓은 props/API로 노출하는 분리는 하지 않는다. +- 기존 결함은 리팩터링에 섞어 고치지 않고 별도 후속 작업으로 기록한다. +- 완전 동일성은 자동 검증 범위에서 엄격히 확인하되, 실제 Windows ASIO 장치와 Tauri WebView 같은 플랫폼 경계는 수동 smoke test가 별도로 필요하다고 명시한다. + +## 브랜치 결과 요약 + +코드·테스트 242개와 이 결과 문서 1개를 포함해 기준 커밋 대비 총 243개 단계별 커밋, 366개 변경 파일이다. 중간 수정 커밋 11개는 원인을 도입한 커밋에 흡수했고, rewrite 전 트리는 `backup/refactor-code-quality-pre-fixup-20260830`에 보존했다. rewrite 전후 코드 tree hash는 모두 `73d4dfccf034bc9e7290ebf978b956e4082c110a`로 동일하다. + +Tauri command는 기준과 현재 모두 145개이고 정렬된 이름 집합도 동일하다. `src-tauri/permissions`와 `docs/content`에는 API 변경에 따른 차이가 없다. + +아래 줄 수는 기준 커밋과 현재 파일을 `wc -l`로 직접 측정한 값이다. 현재 값은 추출된 하위 모듈을 제외한 façade 또는 상위 조립 파일의 크기다. + +### 프론트엔드 + +| 파일 | 기준 | 현재 | 완료한 책임 경계 | +| --------------------------------- | ---: | ---: | ------------------------------------------------------------------ | +| `PropertiesPanel.tsx` | 3407 | 1199 | 선택 route, commit/runtime, plugin 설정, rename, layer action 분리 | +| `BatchSelectionPanel.tsx` | 2367 | 668 | 타입별 섹션, 공통 graph/knob, key-like commit runtime 분리 | +| `SingleSelectionPanel.tsx` | 2061 | 646 | 타입별 패널과 표시 모델 분리 | +| `Settings.tsx` | 1423 | 773 | 오디오 출력, 비동기 적용 큐, resize anchor controller 분리 | +| `Grid.tsx` | 2303 | 1269 | 네이티브 장면, 선택 overlay, context/ghost 모델 분리 | +| `useGridSelection.ts` | 1552 | 494 | drag, movement, clipboard, paste, guide 모델 분리 | +| `PluginElement.tsx` | 1647 | 686 | DOM adapter, layout, snapshot·persistence runtime 분리 | +| `editorCoordinator.ts` | 3001 | 1784 | queue, retry, semantic projection, violation 처리 분리 | +| `elementOps.ts` | 2611 | 571 | document/property/group/style/geometry 모듈과 façade 분리 | +| `ColorPicker.tsx` | 1513 | 611 | 입력, palette/history, surface runtime 분리 | +| `NumberInput.tsx` | 1440 | 226 | draft/parser, scrub·keyboard session, chrome 분리 | +| `CounterAnimationEditorModal.tsx` | 1417 | 371 | draft/model, curve canvas, preview session 분리 | +| `SoundTrimModal.tsx` | 1275 | 632 | decode, waveform, trim/export 모델 분리 | + +추가로 `scopeUserCss.ts`는 212줄 façade, `smartGuides.ts`는 22줄 façade, `Dropdown.tsx`는 127줄, `ListPopup.tsx`는 168줄, `useLayerActions.ts`는 13줄 façade가 됐다. CSS selector/registry, 팝업 dismissal, 사운드 선택기 cache/runtime, editor structural/violation, plugin snapshot/persistence, window geometry persistence를 각각 독립 경계로 분리했다. + +### 백엔드 + +| 파일 | 기준 | 현재 | 완료한 책임 경계 | +| ------------------------- | ----: | ---: | --------------------------------------------------------------------- | +| `state/store.rs` | 22978 | 829 | persistence, writer, recovery, asset, editor, plugin transaction 분리 | +| `state/editor_ops.rs` | 10154 | 49 | 연산군·검증·structural operation과 테스트 분리 | +| `state/migration.rs` | 6053 | 247 | 복구·migration 도메인과 테스트 분리 | +| `state/editor.rs` | 5777 | 176 | editor 상태·요청·테스트 분리 | +| `state/app_state.rs` | 9314 | 1265 | 창 geometry, keyboard, shutdown, controller 경계 분리 | +| `models/mod.rs` | 4169 | 805 | editor와 도메인 모델 분리 | +| `commands/preset/load.rs` | 3104 | 939 | font/image/sound 복원과 테스트 분리 | +| `commands/keys/sound.rs` | 2189 | 806 | library scan, repair, WAV 교체, 테스트 분리 | +| `audio/engine.rs` | 1788 | 911 | ASIO, decode, output test, command thread 분리 | +| `state/history.rs` | 2101 | 690 | snapshot/admission/transaction 경계 분리 | +| `services/obs_bridge.rs` | 1975 | 837 | 세션·메시지·forwarding 경계 분리 | +| `commands/keys/keys.rs` | 2177 | 887 | mapping과 import/export 경계 분리 | + +store 분리에서도 다음 불변식은 그대로 유지했다. + +- orphan 자산은 직접 삭제하지 않고 `trash/<세션>/`에서 30일 격리 +- store 복구가 발생한 세션은 asset sweep 생략 +- 파일 자산 참조 수집과 손상·크래시 복구의 교차 검증 유지 +- `keys[mode][i]`와 `keyPositions[mode][i]`의 인덱스 결합 유지 +- writer와 추출 모듈이 기존 lock·commit 경계를 우회하지 않음 -### 1. 상위 조립 컴포넌트 +## Grid·프로퍼티 패널 재사용성 결과 -- `PropertiesPanel.tsx` — 1549줄: 선택 route별 조립과 preview/commit adapter가 남아 있다. -- `PluginElement.tsx` — 1421줄: 플러그인 content lifecycle과 runtime props projection이 다음 경계다. -- `Grid.tsx` — 1269줄: viewport·도구·장면 훅을 조립하는 상위 controller다. -- `BatchSelectionPanel.tsx` — 1277줄: mixed 4-type adapter와 탭 조립이 남아 있다. +키·통계·그래프·노브를 Grid와 overlay, single/batch panel 사이에서 다시 대조했고, 실제 DOM이나 정책이 같은 부분만 공용화했다. -이 파일들은 분리된 하위 모델을 호출하는 조립부 비중이 커졌다. 다음 분리는 props contract를 더 넓히는 방식이 아니라 route/controller별 characterization test를 먼저 추가한 뒤 수행한다. +- `KeyElementFace`: 키·통계의 placeholder, border, image/error, label, inside counter 표면 +- `CounterPreviewBody` / `CounterPreviewLayer`: 키·통계 외부 카운터의 공통 body와 layer +- `GraphPanel`: Grid/overlay 그래프 표면 +- `BatchGraphSettingsSection`: graph-only와 mixed 배치 설정 +- `KnobFace`: 링, 이미지, indicator 표면 +- `PanelRenameControl`: 단일·배치 이름 변경 header와 focus/select 순서 +- `NativeGridElements`: 네이티브 요소 scene 조립과 adapter +- `nativeElementReferenceRegistry`: mount/unmount 시 ref 등록·삭제 계약 +- `useGridElementInteraction`, stable handler slot, movement/clipboard/paste/smart-guide 모델: 포인터와 선택 정책 -### 2. 에디터 런타임 후속 경계 +Graph 데이터 adapter, Knob 회전 상태, Key 활성 상태, Stat 정수 정규화처럼 의미가 다른 부분은 공용 컴포넌트의 조건문으로 합치지 않았다. 현재 `Grid.tsx`와 `PropertiesPanel.tsx`의 남은 대부분은 훅·route·props를 연결하는 상위 orchestration이다. 직접 테스트가 부족한 상태에서 더 분리하면 계약 면적과 회귀 위험이 커져 즉시 작업할 P0–P2 후보로 보지 않는다. -- `editor/runtime/editorCoordinator.ts` — 2138줄 - - canonical snapshot 동기화와 event publication을 다음 후보로 유지 - - 직렬 queue와 retry/conflict 정책은 이미 독립 모듈 -- `editor/runtime/elementStyleOps.ts` — 952줄 - - runtime·image 속성 연산은 분리 완료 - - paint/shadow 속성군은 transition contract를 유지한 채 추가 분리 검토 +## ASIO 보강 결과 -검증: coordinator conflict·retry·gesture focused suite, element operation export 계약, 전체 Vitest. +`audio/engine/asio.rs`에서 하드웨어 독립 정책과 장치 I/O 경계를 분리했다. 버퍼 프레임 정규화, 드라이버 목록 정렬·중복 제거, 출력 구성, build availability, 오류 코드와 fallback을 자동 검증한다. -### 3. 공통 입력과 편집기 +- Rust ASIO focused suite: 18개 통과 +- 프론트엔드 ASIO 설정 계약: 7개 통과 +- `.github/workflows/ci-windows-asio.yml`: pull request와 수동 실행에서 fmt, feature check, Clippy `-D warnings`, focused test 실행 +- Windows release workflow: production build 전에 ASIO focused test 실행 -- `ColorPicker.tsx` — 1036줄: gradient stop 편집기와 picker surface 조립 -- `NumberInput.tsx` — 1286줄: scrub·keyboard 세션 controller -- `CounterAnimationEditorModal.tsx` — 1343줄: media preview와 form section -- `SoundTrimModal.tsx` — 1011줄: decode/export 작업과 waveform UI +실제 ASIO 드라이버 열기, 다른 앱의 장치 점유, 재시작 후 장치 복원은 Windows 실제 장치 smoke test로 남는다. macOS의 Windows cross target은 로컬 MSVC CRT header가 없어 프로젝트 코드 전에 중단되므로 이를 대체하지 않는다. -입력 컴포넌트는 Escape 취소, preview/commit 경계, child window 동작을 회귀 테스트로 고정한 뒤 이동한다. +## 검증 결과 -## 독립 최종 감사 결과 +단계별 focused suite와 독립 서브에이전트 감사를 반복했고, 최종 코드 트리에서 다음 결과를 확인했다. -별도 코드 품질 감사와 브랜치 회귀 감사를 병렬로 수행했다. 기준점 이후 57개 커밋과 134개 변경 파일을 검토했으며 P0~P2 동작 회귀는 발견되지 않았다. Grid 장면 분리에서 새로 도입된 네이티브 요소 ref registry가 unmount 시 `null` 값을 보관해 UUID key를 누적하던 P3 항목은 `nativeElementReferenceRegistry`의 삭제 계약과 회귀 테스트로 수정했다. +- TypeScript type check 통과 +- ESLint 오류 0; 기존 `SoundTrimModal.tsx` exhaustive-deps 경고 1건만 유지 +- Prettier check 통과 +- 전체 Vitest: 360개 파일, 3472개 테스트 통과, 18개 skip +- Vite production build 통과; 기존 대형 chunk 경고만 유지 +- Rust 전체: 989개 통과, 6개 ignored +- Rust fmt, all-target check, Clippy `-D warnings` 통과 +- ASIO feature check·Clippy와 focused 18개 테스트 통과 +- 242개 코드·테스트 커밋 모두 `git diff-tree --check` 통과 +- Rust module 175개와 literal `include_str!` / `include_bytes!` 경로 확인 -다음 항목은 완료 범위와 별개의 후속 작업으로 권장한다. +동작 동일성 판정에는 AST/토큰 정규화 비교, 공개 반환 객체와 hook/ref 선언 순서 비교, close/await/RAF/focus 순서 테스트, 오류 전파·rollback·preview/commit characterization을 함께 사용했다. 마지막 독립 감사에서는 즉시 안전하고 고가치인 추가 P0–P2 회귀·분리 후보를 발견하지 못했다. -| 우선순위 | 후보 | 권장 경계와 선행 검증 | -| --- | --- | --- | -| 높음 | 배치 Graph 설정 중복 | graph-only와 mixed 패널의 type·평균선·speed·color·animation 상태 해석 및 UI를 `BatchGraphSettingsSection`으로 공용화. 두 route의 Mixed 표시, 500~5000ms clamp, 100ms snap, color preview/cancel/commit 표 기반 테스트 필요 | -| 높음 | OverlayScene 타입 경계 | canonical 위치를 `Record`으로 다시 캐스팅하는 adapter 제거. Graph/Knob의 공용 render 타입을 명시하고 overlay 렌더 계약·commit budget 테스트 실행 | -| 높음 | Windows ASIO PR 게이트 | release workflow에만 있는 feature 테스트를 PR Windows check에도 추가하고 `cargo check`·`clippy --all-targets --features asio-backend` 실행. 실제 장치 smoke test는 계속 수동 유지 | -| 중간 | Grid Key/Stat 외부 카운터 프리뷰 | `KeyCounterPreviewLayer`와 `StatCounterLayer`의 동일한 body·layer DOM을 공용화. Stat의 정수 정규화와 Key active-state preview 차이는 adapter에 유지 | -| 중간 | Key/Stat 표시 표면 | interaction·signal wrapper는 유지하고 transparent placeholder, border ring, image/error, label/inside counter DOM만 순수 `KeyElementFace`로 분리. active/image failure/inside counter 계약 테스트 선행 | -| 중간 | 배치 선택 조회 인덱스 | `batchSelectionModel`의 반복 `findIndex`·`find`를 렌더 단위 ID index/snapshot으로 교체. 선택 순서, 누락 ID, canonical/preview 구분 테스트 선행 | +## 의도적으로 보존한 기존 결함 -백엔드에서는 아래의 `store.rs` editor/history transaction과 `app_state.rs` frontend/panel/overlay controller 이동이 여전히 가장 큰 구조적 후속 작업이다. helper만 옮기는 대신 inherent impl 자체를 한 책임씩 이동하고, lock·cfg·가시성은 변경하지 않는다. +아래 항목은 이번 리팩터링에서 발견했지만 동작 변경을 섞지 않기 위해 고치지 않았다. 별도 bug-fix 브랜치에서 먼저 실패 테스트와 기대 동작을 합의해야 한다. -## 잔여 대형 백엔드 우선순위 +| 영역 | 기존 동작 | +| ---------------------------- | ------------------------------------------------------------------------------------ | +| `PluginElement` subscription | 비동기 key/rawKey 구독이 unmount 뒤 완료되면 cleanup 누락 가능 | +| `PluginElement` state | 같은 tick의 shallow `setState`가 서로 다른 leaf 하나를 잃을 수 있음 | +| `NativeGridElements` | type+index 기반 action이 reorder/delete 뒤 다른 요소를 가리키거나 실패할 수 있음 | +| main dialog / color picker | 기존 timer·ref 재진입 경계가 완전히 정산되지 않음 | +| `SoundTrimModal` | 열린 상태에서 직접 unmount하면 playback/pointer cleanup 계약이 없음 | +| counter animation preview | Escape/window blur 및 복수 preview press의 첫 release 동작이 기존대로 유지됨 | +| `useLayerDnD` | active drag 중 unmount 시 document listener, scheduler, body cursor 정산 계약이 없음 | -- `state/app_state.rs` — 4782줄 - - 다음 경계는 frontend flush/history handshake와 panel/overlay window controller - - keyboard daemon과 shutdown lifecycle은 분리 완료 -- `state/store.rs` — 2769줄 - - 다음 경계는 editor/history transaction inherent impl - - writer·asset·plugin storage 모듈은 모두 기존 `commit_locked`를 통해서만 저장 - - writer와 asset 모듈이 store lock을 우회하지 못하도록 현재 commit 경계를 유지 -- `models/mod.rs` — 3033줄: editor 외 모델을 도메인 파일로 옮기는 후속 후보 -- `state/editor_ops.rs` — 2940줄: 연산군별 validator/apply 경계가 후속 후보 -- `commands/keys/keys.rs` — 2177줄: key mapping command와 import/export 변환 분리 후보 -- `state/history.rs` — 2101줄: snapshot serializer와 admission/stack 정책 분리 후보 +## 후속 작업 분류 -`preset/load.rs` 1180줄, `keys/sound.rs` 806줄, `audio/engine.rs` 911줄은 이번 단계의 목표 경계까지 분리했다. +### 선행 harness가 필요한 고위험 경계 -store 자산 분리는 다음 불변식을 계속 지킨다. +- `WebGLTracksOGL` / `noteBuffer`: 실제 GPU resource 수명과 성능 회귀 harness +- `useNoteSystem`: fake clock 기반 timing·pool·subscription continuity 검증 +- `editorCoordinator`: queue/rebase의 결정적 trace와 failure injection +- editor transition/migration recovery: property/fuzz와 오류 우선순위 검증 +- OBS/Tauri window runtime: 실제 창·WebSocket·종료 timing 통합 테스트 +- ASIO/audio와 Windows keyboard: 실제 Windows driver/device smoke test +- 위 표의 기존 결함: 기대 동작 결정 후 별도 수정 -- orphan 정리는 직접 삭제 대신 `trash/<세션>/` 30일 격리 -- store 복구 세션은 sweep 생략 -- 파일 자산 추가 시 참조 수집과 손상/크래시 복구 테스트 동시 추가 -- `keys[mode][i]`와 `keyPositions[mode][i]`의 인덱스 결합 유지 +### 낮은 우선순위 -## 단계별 검증 게이트 +- `listPopupMenuRows.tsx`, `registryRewrite.ts` 등은 더 나눌 수 있지만 내부 pointer/registry 상태를 새 API로 노출하는 비용이 더 큼 +- `Grid.tsx`, `PropertiesPanel.tsx`의 추가 분리는 stable ID와 직접 orchestration test를 먼저 갖춘 뒤 재평가 +- Vitest의 기존 React `act(...)`, mock DOM prop, CSS parser stderr는 실패와 구분되지만 회귀 로그의 신호 대 잡음비를 낮추므로 별도 테스트 위생 작업으로 정리 +- cohesive wire schema와 단순 route table은 파일 크기만으로 분할하지 않음 -### 프론트엔드 단계 +## 재현 가능한 최종 게이트 ```bash npx tsc --noEmit npm run lint -npm run format +npm run format:check npm test -- --reporter=dot npm run build -``` - -### Rust 단계 -```bash cd src-tauri -cargo fmt --check -cargo check +cargo fmt --all -- --check +cargo check --all-targets cargo clippy --all-targets -- -D warnings -cargo test +cargo test --all-targets --quiet +cargo check --all-targets --features asio-backend +cargo clippy --all-targets --features asio-backend -- -D warnings +cargo test --lib --features asio-backend audio::engine ``` -Tauri command를 추가하거나 삭제한 단계에서는 production build 후 `permissions/dmnote-allow-all.json` 갱신 여부도 확인한다. - -## ASIO 검증 행렬 - -자동 테스트는 하드웨어와 무관한 정책을 모든 개발 플랫폼에서 실행한다. 실제 Windows/ASIO 경계는 다음 순서로 별도 확인한다. - -1. Windows release workflow에서 `cargo test --features asio-backend audio::engine --lib` 자동 실행 -2. `npm run tauri:build`로 ASIO feature production build 확인 -3. 장치 목록 정렬·중복 제거와 저장된 분리 장치 표시 확인 -4. 기본값, 0, 비표준 buffer frame 처리 확인 -5. 장치 부재와 장치 open 실패의 서로 다른 오류 코드·기동 폴백 확인 -6. 실제 드라이버에서 선택, 재시작 복원, 다른 ASIO 앱 점유 중 폴백을 수동 smoke test - -macOS의 Windows cross-check는 MSVC C 런타임 헤더가 없는 환경에서 `ring` C 빌드 전에 중단되므로 Windows 네이티브 검증을 대체하지 않는다. - -## 완료 조건 +## 완료 판정 -- 단계별 commit이 한 책임만 설명한다. -- 기존 공개 API와 저장 데이터 형식이 유지된다. -- focused suite와 전체 게이트가 모두 통과한다. -- 새 경고가 없고 Rust는 Clippy `-D warnings`를 통과한다. -- 남은 대형 파일은 이 문서의 다음 책임 경계로 이어지며, 줄 수만 줄이기 위한 임의 분할은 하지 않는다. +- 공개 API·저장 형식·command/event 집합에 의도한 변경 없음 +- 단계별 커밋과 focused/전체 검증 통과 +- rewrite 전후 tree 동일성 확인 및 복구 branch 보존 +- Grid 핵심 표면과 패널 반복 UI 공용화 완료 +- 대형 프론트엔드·백엔드 파일의 고가치 책임 경계 분리 완료 +- 마지막 독립 감사에서 즉시 진행할 추가 P0–P2 후보 없음 +- 실제 장치·GPU·native window가 필요한 항목과 기존 결함은 검증 조건을 붙여 후속 범위로 보존 From c0adc16fcb9288c4f734ef4a158e68be32b9cf72 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Mon, 31 Aug 2026 10:12:39 +0900 Subject: [PATCH 244/245] =?UTF-8?q?fix:=20Windows=20=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20=EA=B0=80=EC=8B=9C=EC=84=B1=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/state/app_state.rs | 34 ++++++++------- .../src/state/app_state/native_window.rs | 2 +- .../src/state/app_state/window_geometry.rs | 1 + .../window_geometry/overlay_placement.rs | 3 ++ src-tauri/src/state/panel_drag/windows.rs | 42 +++++++++---------- 5 files changed, 45 insertions(+), 37 deletions(-) diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs index a3c3102c1..5816d8d72 100644 --- a/src-tauri/src/state/app_state.rs +++ b/src-tauri/src/state/app_state.rs @@ -77,26 +77,30 @@ use native_window::{ raise_panel_window_without_activation, }; +#[cfg(any(target_os = "windows", test))] +use window_geometry::applied_overlay_frame_from_native; +#[cfg(not(target_os = "macos"))] +use window_geometry::applied_overlay_frame_from_window; +#[cfg(not(target_os = "windows"))] +use window_geometry::convert_physical_bounds_to_logical; #[cfg(all(target_os = "windows", not(test)))] use window_geometry::OverlayRestoreSource; -#[cfg(test)] use window_geometry::{ - applied_overlay_frame_from_native, apply_panel_bounds_change, changed_panel_max_height, - next_overlay_placement_trust, panel_bounds_from_sample, panel_height_bounds, - panel_position_beside_main, resolve_windows_overlay_placement, - stored_overlay_bounds_for_persistence, MonitorSpec, NativeRejectReason, OverlayRestoreSource, - PanelBoundsPersistenceState, PanelBoundsSample, + applied_overlay_frame_from_placement, complete_overlay_scale_resolution, + flush_deferred_overlay_bounds, logical_position_to_native, main_window_content_origin, + main_window_logical_rect, main_window_native_rect, native_placement_from_window, + overlay_restore_window_scale, panel_bounds_sample_from_window, panel_client_to_outer_position, + persist_overlay_placement, persist_overlay_placement_from_window, resolve_overlay_placement, + resolve_panel_window_layout, AppliedOverlayFrame, MonitorData, NativePlacement, NativeRect, + OverlayPersistenceAuthority, OverlayPlacementTrust, OverlayPosition, PanelBoundsChange, + PanelBoundsPersistenceController, PanelWindowLayout, ResolvedOverlayPlacement, }; +#[cfg(test)] use window_geometry::{ - applied_overlay_frame_from_placement, complete_overlay_scale_resolution, - convert_physical_bounds_to_logical, flush_deferred_overlay_bounds, logical_position_to_native, - main_window_content_origin, main_window_logical_rect, main_window_native_rect, - native_placement_from_window, overlay_restore_window_scale, panel_bounds_sample_from_window, - panel_client_to_outer_position, persist_overlay_placement, - persist_overlay_placement_from_window, resolve_overlay_placement, resolve_panel_window_layout, - AppliedOverlayFrame, MonitorData, NativePlacement, NativeRect, OverlayPersistenceAuthority, - OverlayPlacementTrust, OverlayPosition, PanelBoundsChange, PanelBoundsPersistenceController, - PanelWindowLayout, ResolvedOverlayPlacement, + apply_panel_bounds_change, changed_panel_max_height, next_overlay_placement_trust, + panel_bounds_from_sample, panel_height_bounds, panel_position_beside_main, + resolve_windows_overlay_placement, stored_overlay_bounds_for_persistence, MonitorSpec, + NativeRejectReason, OverlayRestoreSource, PanelBoundsPersistenceState, PanelBoundsSample, }; pub use window_geometry::{LogicalPoint, LogicalRect, PanelDragContext}; diff --git a/src-tauri/src/state/app_state/native_window.rs b/src-tauri/src/state/app_state/native_window.rs index 7726b8b60..442e52487 100644 --- a/src-tauri/src/state/app_state/native_window.rs +++ b/src-tauri/src/state/app_state/native_window.rs @@ -434,7 +434,7 @@ unsafe fn install_overlay_move_observer_on_owner_thread( } #[cfg(target_os = "windows")] -unsafe fn applied_overlay_frame_from_hwnd( +pub(in crate::state::app_state) unsafe fn applied_overlay_frame_from_hwnd( hwnd: windows::Win32::Foundation::HWND, ) -> Result { use windows::Win32::UI::WindowsAndMessaging::GetWindowRect; diff --git a/src-tauri/src/state/app_state/window_geometry.rs b/src-tauri/src/state/app_state/window_geometry.rs index f18e56623..a1c3db6c4 100644 --- a/src-tauri/src/state/app_state/window_geometry.rs +++ b/src-tauri/src/state/app_state/window_geometry.rs @@ -11,6 +11,7 @@ pub(super) use persistence::{ pub(super) use persistence::{ panel_bounds_sample_from_window, PanelBoundsChange, PanelBoundsPersistenceController, }; +#[cfg(not(target_os = "windows"))] pub(super) fn convert_physical_bounds_to_logical( bounds: &OverlayBounds, monitors: &MonitorData, diff --git a/src-tauri/src/state/app_state/window_geometry/overlay_placement.rs b/src-tauri/src/state/app_state/window_geometry/overlay_placement.rs index 183249850..eccefb614 100644 --- a/src-tauri/src/state/app_state/window_geometry/overlay_placement.rs +++ b/src-tauri/src/state/app_state/window_geometry/overlay_placement.rs @@ -1,5 +1,8 @@ use super::*; +#[cfg(target_os = "windows")] +use crate::state::app_state::native_window::applied_overlay_frame_from_hwnd; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(in crate::state::app_state) enum OverlayRestoreSource { #[cfg(any(target_os = "windows", test))] diff --git a/src-tauri/src/state/panel_drag/windows.rs b/src-tauri/src/state/panel_drag/windows.rs index c7fc187f1..dedeba44e 100644 --- a/src-tauri/src/state/panel_drag/windows.rs +++ b/src-tauri/src/state/panel_drag/windows.rs @@ -161,8 +161,7 @@ unsafe fn start_on_owner_thread( mode: PanelDragStartMode, enter_sender: oneshot::Sender<()>, ) -> OwnerStartResult { - use tauri::PhysicalPosition; - use windows::Win32::{ + use ::windows::Win32::{ Foundation::{LPARAM, POINT, WPARAM}, UI::{ Input::KeyboardAndMouse::{GetAsyncKeyState, ReleaseCapture, VK_LBUTTON, VK_RBUTTON}, @@ -172,6 +171,7 @@ unsafe fn start_on_owner_thread( }, }, }; + use tauri::PhysicalPosition; let Some(window) = app.get_webview_window(super::super::PANEL_LABEL) else { return owner_start_failure( @@ -693,7 +693,7 @@ fn main_drag_scale( #[cfg(target_os = "windows")] fn tao_undecorated_shadow_seed_inset(target_scale: f64) -> Option { - use windows::Win32::UI::{ + use ::windows::Win32::UI::{ HiDpi::GetSystemMetricsForDpi, WindowsAndMessaging::{SM_CXPADDEDBORDER, SM_CXSIZEFRAME}, }; @@ -752,7 +752,7 @@ unsafe fn ensure_native_observer( controller: &PanelDragController, window: &tauri::WebviewWindow, ) -> Result<*mut NativeDragContext, PanelDragError> { - use windows::Win32::UI::Shell::SetWindowSubclass; + use ::windows::Win32::UI::Shell::SetWindowSubclass; const SUBCLASS_ID: usize = 0x444d_5044; @@ -806,7 +806,7 @@ unsafe fn ensure_native_observer( #[cfg(target_os = "windows")] unsafe fn install_native_hook(context: *mut NativeDragContext) -> Result<(), PanelDragError> { - use windows::Win32::{ + use ::windows::Win32::{ System::Threading::GetCurrentThreadId, UI::WindowsAndMessaging::{SetWindowsHookExW, WH_GETMESSAGE}, }; @@ -859,7 +859,7 @@ unsafe fn install_native_hook(context: *mut NativeDragContext) -> Result<(), Pan #[cfg(target_os = "windows")] unsafe fn cleanup_native_hook(context: *mut NativeDragContext) { - use windows::Win32::UI::WindowsAndMessaging::{UnhookWindowsHookEx, HHOOK}; + use ::windows::Win32::UI::WindowsAndMessaging::{UnhookWindowsHookEx, HHOOK}; if context.is_null() { return; @@ -894,9 +894,9 @@ pub(super) fn schedule_native_hook_cleanup(app: &AppHandle, identity: Arc windows::Win32::Foundation::LRESULT { - use std::panic::{catch_unwind, AssertUnwindSafe}; - use windows::Win32::UI::{ +) -> ::windows::Win32::Foundation::LRESULT { + use ::windows::Win32::UI::{ Shell::{DefSubclassProc, RemoveWindowSubclass}, WindowsAndMessaging::{ SendMessageW, WM_CANCELMODE, WM_ENTERSIZEMOVE, WM_EXITSIZEMOVE, WM_NCDESTROY, }, }; + use std::panic::{catch_unwind, AssertUnwindSafe}; catch_unwind(AssertUnwindSafe(|| unsafe { let context = reference_data as *mut NativeDragContext; @@ -1028,14 +1028,14 @@ unsafe extern "system" fn panel_drag_subclass_proc( #[cfg(target_os = "windows")] unsafe extern "system" fn panel_drag_message_hook( code: i32, - wparam: windows::Win32::Foundation::WPARAM, - lparam: windows::Win32::Foundation::LPARAM, -) -> windows::Win32::Foundation::LRESULT { - use std::panic::{catch_unwind, AssertUnwindSafe}; - use windows::Win32::UI::{ + wparam: ::windows::Win32::Foundation::WPARAM, + lparam: ::windows::Win32::Foundation::LPARAM, +) -> ::windows::Win32::Foundation::LRESULT { + use ::windows::Win32::UI::{ Input::KeyboardAndMouse::VK_ESCAPE, WindowsAndMessaging::{CallNextHookEx, MSG, WM_KEYDOWN, WM_SYSKEYDOWN}, }; + use std::panic::{catch_unwind, AssertUnwindSafe}; let _ = catch_unwind(AssertUnwindSafe(|| unsafe { if code < 0 || lparam.0 == 0 { @@ -1056,8 +1056,8 @@ unsafe extern "system" fn panel_drag_message_hook( } #[cfg(target_os = "windows")] -fn windows_hwnd(value: usize) -> windows::Win32::Foundation::HWND { - windows::Win32::Foundation::HWND(value as *mut std::ffi::c_void) +fn windows_hwnd(value: usize) -> ::windows::Win32::Foundation::HWND { + ::windows::Win32::Foundation::HWND(value as *mut std::ffi::c_void) } #[cfg(target_os = "windows")] From 5c59f104668d211a456974c20e8eda3531618a56 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Mon, 31 Aug 2026 10:18:52 +0900 Subject: [PATCH 245/245] =?UTF-8?q?fix:=20Windows=20Clippy=20=EA=B2=8C?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EA=B2=BD=EA=B3=A0=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/media/image.rs | 4 +++- src-tauri/src/services/obs_bridge/media/tests.rs | 1 - .../src/state/app_state/window_geometry/overlay_placement.rs | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/commands/media/image.rs b/src-tauri/src/commands/media/image.rs index 08e711efa..28d5cc3c6 100644 --- a/src-tauri/src/commands/media/image.rs +++ b/src-tauri/src/commands/media/image.rs @@ -162,7 +162,9 @@ fn transcode_utf16(file: File, little_endian: bool) -> Option<(Vec, bool)> { } let units = body - .chunks_exact(2) + .as_chunks::<2>() + .0 + .iter() .map(|pair| { let bytes = [pair[0], pair[1]]; if little_endian { diff --git a/src-tauri/src/services/obs_bridge/media/tests.rs b/src-tauri/src/services/obs_bridge/media/tests.rs index 71230d3ea..1feebafe7 100644 --- a/src-tauri/src/services/obs_bridge/media/tests.rs +++ b/src-tauri/src/services/obs_bridge/media/tests.rs @@ -1,7 +1,6 @@ use std::cell::Cell; use std::path::{Path, PathBuf}; -use base64::Engine as _; use tokio::io::AsyncReadExt; use tokio::net::{TcpListener, TcpStream}; diff --git a/src-tauri/src/state/app_state/window_geometry/overlay_placement.rs b/src-tauri/src/state/app_state/window_geometry/overlay_placement.rs index eccefb614..593c81a4c 100644 --- a/src-tauri/src/state/app_state/window_geometry/overlay_placement.rs +++ b/src-tauri/src/state/app_state/window_geometry/overlay_placement.rs @@ -753,7 +753,7 @@ pub(in crate::state::app_state) fn applied_overlay_frame_from_window( #[cfg(target_os = "windows")] { let hwnd = window.hwnd()?; - return unsafe { applied_overlay_frame_from_hwnd(hwnd) }; + unsafe { applied_overlay_frame_from_hwnd(hwnd) } } #[cfg(not(target_os = "windows"))] {