From ac3512ba52f268e097d18924974788f3baea717c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 11 Aug 2026 21:53:34 +0900 Subject: [PATCH 01/35] =?UTF-8?q?feat:=20=EC=9A=94=EC=86=8C=20=EC=95=88?= =?UTF-8?q?=EC=A0=95=20ID=20=EB=B0=9C=EA=B8=89=EA=B3=BC=20=EC=BB=A4?= =?UTF-8?q?=EB=B0=8B=20v2=20=EA=B2=80=EC=A6=9D=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/keys/keys.rs | 32 +- src-tauri/src/commands/preset/load.rs | 127 ++- src-tauri/src/models/editor.rs | 1 + src-tauri/src/models/mod.rs | 63 +- src-tauri/src/state/editor.rs | 1019 +++++++++++++++++++--- src-tauri/src/state/migration.rs | 317 ++++++- src-tauri/src/state/mod.rs | 1 + src-tauri/src/state/native_element_id.rs | 833 ++++++++++++++++++ src-tauri/src/state/store.rs | 201 ++++- src/types/key/keys.ts | 2 + 10 files changed, 2436 insertions(+), 160 deletions(-) create mode 100644 src-tauri/src/state/native_element_id.rs diff --git a/src-tauri/src/commands/keys/keys.rs b/src-tauri/src/commands/keys/keys.rs index 7e36bcb6..35a23289 100644 --- a/src-tauri/src/commands/keys/keys.rs +++ b/src-tauri/src/commands/keys/keys.rs @@ -72,6 +72,7 @@ fn reset_all_editor_data(store: &mut AppStoreData, keys: &KeyMappings, positions store.selected_key_type = "4key".to_string(); store.tab_note_overrides.clear(); store.tab_css_overrides.clear(); + crate::state::native_element_id::rekey_store_element_ids(store); } fn reset_mode_kind(store: &AppStoreData, mode: &str) -> Option { @@ -156,6 +157,7 @@ fn reset_mode_data(store: &mut AppStoreData, mode: &str, kind: ModeResetKind) { .map(|key| (key.canonical(), 0)) .collect(), ); + crate::state::native_element_id::rekey_mode_element_ids(store, mode); } fn plan_custom_tab_delete(store: &AppStoreData, id: &str) -> Option { @@ -950,7 +952,7 @@ mod tests { KnobPosition, LayerGroupDef, StatPosition, StatType, TabCss, TabNoteSettings, }, }; - use std::cell::Cell; + use std::{cell::Cell, collections::HashSet}; const TARGET_TAB: &str = "custom-target"; @@ -1064,6 +1066,34 @@ mod tests { .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()); + 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()); + 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 custom_mode_reset_is_supported_and_preserves_tab_identity() { let mut store = populated_custom_tab_store(); diff --git a/src-tauri/src/commands/preset/load.rs b/src-tauri/src/commands/preset/load.rs index b4c5c040..4421e2f5 100644 --- a/src-tauri/src/commands/preset/load.rs +++ b/src-tauri/src/commands/preset/load.rs @@ -377,6 +377,7 @@ pub fn preset_load( if let Some(tab_css_overrides) = preset_tab_css_overrides { store.tab_css_overrides = tab_css_overrides; } + rekey_full_preset_elements(store); crate::state::migration::clear_dangling_group_ids(store); let diff = apply_patch_to_store(store, &settings_patch); Ok(( @@ -610,6 +611,9 @@ pub fn preset_load_tab( admission, move |store| { let previous_tab_css_overrides = store.tab_css_overrides.clone(); + let stat_positions_written = imported_stat_positions.is_some(); + let graph_positions_written = imported_graph_positions.is_some(); + let knob_positions_written = imported_knob_positions.is_some(); merge_tab_preset_key_pair(store, ¤t_tab_id, src_keys, imported_key_positions); if let Some(positions) = imported_stat_positions { store @@ -626,6 +630,13 @@ pub fn preset_load_tab( .knob_positions .insert(current_tab_id.clone(), positions); } + rekey_tab_preset_elements( + store, + ¤t_tab_id, + stat_positions_written, + graph_positions_written, + knob_positions_written, + ); apply_tab_note_override( store, ¤t_tab_id, @@ -890,6 +901,27 @@ fn align_imported_key_collections(keys: &mut KeyMappings, positions: &mut KeyPos } } +fn rekey_full_preset_elements(store: &mut AppStoreData) { + crate::state::native_element_id::rekey_store_element_ids(store); +} + +fn rekey_tab_preset_elements( + store: &mut AppStoreData, + tab_id: &str, + stat_positions_written: bool, + graph_positions_written: bool, + knob_positions_written: bool, +) { + crate::state::native_element_id::rekey_mode_element_ids_for_collections( + store, + tab_id, + true, + stat_positions_written, + graph_positions_written, + knob_positions_written, + ); +} + fn merge_tab_preset_key_pair( store: &mut AppStoreData, current_tab_id: &str, @@ -1551,7 +1583,10 @@ mod tests { use super::*; use crate::{ defaults::{default_keys, default_positions}, - models::{CustomCssHistoryEntry, CustomFont, JsPlugin, KnobPosition}, + models::{ + CustomCssHistoryEntry, CustomFont, GraphPosition, GraphStatType, GraphType, JsPlugin, + KnobPosition, StatPosition, StatType, + }, }; #[test] @@ -2262,6 +2297,96 @@ mod tests { assert_eq!(store.keys["4key"].len(), store.key_positions["4key"].len()); } + fn old_preset_store() -> AppStoreData { + AppStoreData { + key_positions: KeyPositions::from([ + ("target".to_string(), vec![KeyPosition::default()]), + ("untouched".to_string(), vec![KeyPosition::default()]), + ]), + stat_positions: StatPositions::from([( + "target".to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: KeyPosition::default(), + }], + )]), + graph_positions: GraphPositions::from([( + "target".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(), + }], + )]), + knob_positions: KnobPositions::from([( + "target".to_string(), + vec![KnobPosition { + axis_id: "axis".to_string(), + sensitivity: 1.0, + reverse: false, + position: KeyPosition::default(), + }], + )]), + ..AppStoreData::default() + } + } + + fn target_preset_ids(store: &AppStoreData) -> Vec { + vec![ + store.key_positions["target"][0].id.clone(), + store.stat_positions["target"][0].position.id.clone(), + store.graph_positions["target"][0].position.id.clone(), + store.knob_positions["target"][0].position.id.clone(), + ] + } + + #[test] + fn old_full_preset_rekeys_every_application() { + let mut first = old_preset_store(); + rekey_full_preset_elements(&mut first); + let first_ids = target_preset_ids(&first); + let mut second = old_preset_store(); + rekey_full_preset_elements(&mut second); + let second_ids = target_preset_ids(&second); + + assert!(first_ids + .iter() + .all(|id| crate::state::native_element_id::is_valid_element_id(id))); + assert!(first_ids.iter().all(|id| !second_ids.contains(id))); + } + + #[test] + fn old_tab_preset_rekeys_only_written_collections_on_every_application() { + let mut store = old_preset_store(); + crate::state::native_element_id::backfill_store_element_ids(&mut store); + let untouched_id = store.key_positions["untouched"][0].id.clone(); + let original_ids = target_preset_ids(&store); + + rekey_tab_preset_elements(&mut store, "target", true, true, false); + let first_ids = target_preset_ids(&store); + rekey_tab_preset_elements(&mut store, "target", true, true, false); + let second_ids = target_preset_ids(&store); + + assert!(original_ids[..3] + .iter() + .zip(&first_ids[..3]) + .all(|(before, after)| before != after)); + assert!(first_ids[..3] + .iter() + .zip(&second_ids[..3]) + .all(|(before, after)| before != after)); + assert_eq!(first_ids[3], original_ids[3]); + assert_eq!(second_ids[3], original_ids[3]); + assert_eq!(store.key_positions["untouched"][0].id, untouched_id); + crate::state::native_element_id::validate_document_element_ids( + &crate::models::EditorDocumentV1::from_store(&store), + ) + .unwrap(); + } + #[test] fn preset_import_alignment_repairs_each_mode_without_dropping_values() { let mut keys = KeyMappings::from([ diff --git a/src-tauri/src/models/editor.rs b/src-tauri/src/models/editor.rs index 1c8d79a6..a07afe65 100644 --- a/src-tauri/src/models/editor.rs +++ b/src-tauri/src/models/editor.rs @@ -6,6 +6,7 @@ use super::{ }; pub const EDITOR_SCHEMA_VERSION: u16 = 1; +pub const EDITOR_COMMIT_SCHEMA_VERSION_V2: u16 = 2; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 10ab6c57..bff8c312 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -475,6 +475,8 @@ pub struct ElementShadowSpec { #[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, @@ -629,6 +631,7 @@ pub struct KeyPosition { impl Default for KeyPosition { fn default() -> Self { Self { + id: String::new(), dx: 0.0, dy: 0.0, width: 60.0, @@ -2502,9 +2505,10 @@ pub struct SettingsPatch { #[cfg(test)] mod tests { use super::{ - compact_canonical_rgba, FadePosition, GradientSpec, KeyCounterAlign, KeyCounterAlignMode, - KeyCounterColor, KeyCounterPlacement, KeyCounterSettings, KeyMappings, KeyPosition, - KeySlot, NoteColor, NoteSettings, SlotMatch, StatType, MAX_SLOT_KEYS, + compact_canonical_rgba, FadePosition, GradientSpec, GraphPosition, GraphStatType, + GraphType, KeyCounterAlign, KeyCounterAlignMode, KeyCounterColor, KeyCounterPlacement, + KeyCounterSettings, KeyMappings, KeyPosition, KeySlot, KnobPosition, NoteColor, + NoteSettings, SlotMatch, StatPosition, StatType, MAX_SLOT_KEYS, }; use serde::Deserialize; @@ -2519,6 +2523,59 @@ mod tests { assert_eq!(serde_json::to_value(mappings).unwrap(), raw); } + #[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 multi_key_slot_wire_shape_and_canonical_are_stable() { let raw = serde_json::json!({ "keys": ["LEFT CTRL", "Z"], "match": "all" }); diff --git a/src-tauri/src/state/editor.rs b/src-tauri/src/state/editor.rs index b3f1e298..9b81550f 100644 --- a/src-tauri/src/state/editor.rs +++ b/src-tauri/src/state/editor.rs @@ -10,7 +10,8 @@ use crate::{ errors::EditorCommitError, models::{ AppStoreData, CustomTab, EditorCommitRequest, EditorDocumentV1, EditorField, - ElementShadowSpec, KeyCounters, KeyMappings, KeyPosition, KeySlot, EDITOR_SCHEMA_VERSION, + ElementShadowSpec, KeyCounters, KeyMappings, KeyPosition, KeySlot, + EDITOR_COMMIT_SCHEMA_VERSION_V2, EDITOR_SCHEMA_VERSION, }, }; @@ -52,21 +53,102 @@ struct FingerprintPayload<'a> { changes: &'a crate::models::EditorPatchV1, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum NativeElementKind { + Key, + Stat, + Graph, + Knob, +} + +#[derive(Debug, Clone, Copy)] +struct NativeElementDiagnostic<'a> { + kind: NativeElementKind, + field: &'static str, + mode: &'a str, + index: usize, + id: &'a str, +} + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -struct ValidationViolation { - identity: String, +enum ViolationOwner { + Mode { mode: String }, + Pair { mode: String }, + GroupOccurrence { mode: String, index: usize }, + DuplicateGroup { mode: String, id: String }, + NativeElement { kind: NativeElementKind, id: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +enum ViolationPropertyPath { + ModeId, + Collection(&'static str), + PairCollections, + GroupId, + GroupReference, + KnobSensitivity, + Shadow { + name: &'static str, + property: &'static str, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +enum InvalidValueSignature { + None, + Empty, + FloatBits(u64), + Text(String), + PairPresence { keys: bool, key_positions: bool }, + PairLength { keys: usize, key_positions: usize }, + Count(usize), +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct ViolationKey { + owner: ViolationOwner, code: &'static str, + property_path: ViolationPropertyPath, + invalid_value: InvalidValueSignature, +} + +#[derive(Debug, Clone)] +struct ValidationViolation { + key: ViolationKey, message: String, } impl ValidationViolation { - fn new(identity: impl Into, code: &'static str, message: impl Into) -> Self { + fn new(key: ViolationKey, message: impl Into) -> Self { Self { - identity: identity.into(), - code, + key, message: message.into(), } } + + fn code(&self) -> &'static str { + self.key.code + } +} + +impl PartialEq for ValidationViolation { + fn eq(&self, other: &Self) -> bool { + self.key == other.key + } +} + +impl Eq for ValidationViolation {} + +impl PartialOrd for ValidationViolation { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for ValidationViolation { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.key.cmp(&other.key) + } } pub(crate) fn validate_request_envelope( @@ -74,7 +156,10 @@ pub(crate) fn validate_request_envelope( ) -> Result<(), EditorCommitError> { validate_revision(request.base_revision)?; - if request.changes.schema_version != EDITOR_SCHEMA_VERSION { + if !matches!( + request.changes.schema_version, + EDITOR_SCHEMA_VERSION | EDITOR_COMMIT_SCHEMA_VERSION_V2 + ) { return Err(EditorCommitError::validation( "UNSUPPORTED_SCHEMA_VERSION", format!( @@ -323,6 +408,14 @@ pub(crate) fn validate_paired_update( return Err(EditorCommitError::paired_update_required("keys")); } + if key_positions_touched + && !keys_touched + && key_position_id_order(¤t.key_positions) + != key_position_id_order(&candidate.key_positions) + { + return Err(EditorCommitError::paired_update_required("keys")); + } + Ok(()) } @@ -335,6 +428,25 @@ fn collection_shape(collection: &HashMap>) -> Vec<(String, usi shape } +fn key_position_id_order( + collection: &HashMap>, +) -> Vec<(String, Vec)> { + let mut order = collection + .iter() + .map(|(mode, positions)| { + ( + mode.clone(), + positions + .iter() + .map(|position| position.id.clone()) + .collect(), + ) + }) + .collect::>(); + order.sort_unstable_by(|left, right| left.0.cmp(&right.0)); + order +} + /// 기존 store에 있던 손실 없는 비정상 데이터는 유지하되 새 비정상 상태는 만들지 않음 pub(crate) fn validate_document_transition( current: &EditorDocumentV1, @@ -354,14 +466,18 @@ pub(crate) fn validate_document_transition( let current_violations = collect_violations(current, &allowed_modes(current_store)); let candidate_violations = collect_violations(candidate, &allowed_modes(candidate_store)); + let current_violation_keys = current_violations + .iter() + .map(|violation| violation.key.clone()) + .collect::>(); validate_metric_limits(current, candidate)?; if let Some(violation) = candidate_violations.iter().find(|violation| { - is_unconditional_structural_violation(violation.code) - || !current_violations.contains(*violation) + is_unconditional_structural_violation(violation.code()) + || !is_grandfathered(¤t_violation_keys, violation) }) { return Err(EditorCommitError::validation( - violation.code, + violation.code(), violation.message.clone(), )); } @@ -369,13 +485,17 @@ pub(crate) fn validate_document_transition( Ok(()) } +fn is_grandfathered( + current_violation_keys: &BTreeSet, + candidate: &ValidationViolation, +) -> bool { + current_violation_keys.contains(&candidate.key) +} + fn is_unconditional_structural_violation(code: &str) -> bool { matches!( code, - "KEY_POSITION_MODE_MISMATCH" - | "KEY_POSITION_LENGTH_MISMATCH" - | "DUPLICATE_GROUP_ID" - | "UNKNOWN_GROUP_ID" + "KEY_POSITION_MODE_MISMATCH" | "KEY_POSITION_LENGTH_MISMATCH" | "DUPLICATE_GROUP_ID" ) } @@ -387,6 +507,24 @@ fn allowed_modes(store: &AppStoreData) -> HashSet { .collect() } +fn native_violation_key( + kind: NativeElementKind, + id: &str, + code: &'static str, + property_path: ViolationPropertyPath, + invalid_value: InvalidValueSignature, +) -> ViolationKey { + ViolationKey { + owner: ViolationOwner::NativeElement { + kind, + id: id.to_string(), + }, + code, + property_path, + invalid_value, + } +} + fn collect_violations( document: &EditorDocumentV1, allowed_modes: &HashSet, @@ -406,8 +544,12 @@ fn collect_violations( for mode in &all_modes { if mode.is_empty() { violations.insert(ValidationViolation::new( - format!("invalid-mode-id:{mode:?}"), - "INVALID_MODE_ID", + ViolationKey { + owner: ViolationOwner::Mode { mode: mode.clone() }, + code: "INVALID_MODE_ID", + property_path: ViolationPropertyPath::ModeId, + invalid_value: InvalidValueSignature::Empty, + }, "mode id is empty", )); } @@ -450,12 +592,15 @@ fn collect_violations( let positions = document.key_positions.get(mode); if keys.is_some() != positions.is_some() { violations.insert(ValidationViolation::new( - format!( - "paired-mode:{mode}:{}:{}", - keys.is_some(), - positions.is_some() - ), - "KEY_POSITION_MODE_MISMATCH", + ViolationKey { + owner: ViolationOwner::Pair { mode: mode.clone() }, + code: "KEY_POSITION_MODE_MISMATCH", + property_path: ViolationPropertyPath::PairCollections, + invalid_value: InvalidValueSignature::PairPresence { + keys: keys.is_some(), + key_positions: positions.is_some(), + }, + }, format!("keys and keyPositions must contain the same mode '{mode}'"), )); } @@ -464,8 +609,15 @@ fn collect_violations( let position_count = positions.map_or(0, Vec::len); if key_count != position_count { violations.insert(ValidationViolation::new( - format!("paired-length:{mode}:{key_count}:{position_count}"), - "KEY_POSITION_LENGTH_MISMATCH", + ViolationKey { + owner: ViolationOwner::Pair { mode: mode.clone() }, + code: "KEY_POSITION_LENGTH_MISMATCH", + property_path: ViolationPropertyPath::PairCollections, + invalid_value: InvalidValueSignature::PairLength { + keys: key_count, + key_positions: position_count, + }, + }, format!("keys and keyPositions for mode '{mode}' have different lengths"), )); } @@ -475,11 +627,13 @@ fn collect_violations( for (index, position) in positions.iter().enumerate() { if !position.sensitivity.is_finite() { violations.insert(ValidationViolation::new( - format!( - "knob-sensitivity:{mode}:{index}:{}", - position.sensitivity.to_bits() + native_violation_key( + NativeElementKind::Knob, + &position.position.id, + "INVALID_NUMBER", + ViolationPropertyPath::KnobSensitivity, + InvalidValueSignature::FloatBits(position.sensitivity.to_bits()), ), - "INVALID_NUMBER", format!("knob sensitivity at {mode}[{index}] is invalid"), )); } @@ -496,14 +650,19 @@ fn collect_position_style_violations( document: &EditorDocumentV1, violations: &mut BTreeSet, ) { - for (field, mode, index, position) in document + for (kind, field, mode, index, position) in document .key_positions .iter() .flat_map(|(mode, positions)| { - positions - .iter() - .enumerate() - .map(move |(index, position)| ("keyPositions", mode, index, position)) + positions.iter().enumerate().map(move |(index, position)| { + ( + NativeElementKind::Key, + "keyPositions", + mode, + index, + position, + ) + }) }) .chain( document @@ -511,7 +670,13 @@ fn collect_position_style_violations( .iter() .flat_map(|(mode, positions)| { positions.iter().enumerate().map(move |(index, position)| { - ("statPositions", mode, index, &position.position) + ( + NativeElementKind::Stat, + "statPositions", + mode, + index, + &position.position, + ) }) }), ) @@ -521,7 +686,13 @@ fn collect_position_style_violations( .iter() .flat_map(|(mode, positions)| { positions.iter().enumerate().map(move |(index, position)| { - ("graphPositions", mode, index, &position.position) + ( + NativeElementKind::Graph, + "graphPositions", + mode, + index, + &position.position, + ) }) }), ) @@ -531,7 +702,13 @@ fn collect_position_style_violations( .iter() .flat_map(|(mode, positions)| { positions.iter().enumerate().map(move |(index, position)| { - ("knobPositions", mode, index, &position.position) + ( + NativeElementKind::Knob, + "knobPositions", + mode, + index, + &position.position, + ) }) }), ) @@ -541,35 +718,61 @@ fn collect_position_style_violations( ("activeShadow", position.active_shadow.as_ref()), ] { if let Some(shadow) = shadow { - collect_shadow_violations(field, mode, index, name, shadow, violations); + collect_shadow_violations( + NativeElementDiagnostic { + kind, + field, + mode, + index, + id: &position.id, + }, + name, + shadow, + violations, + ); } } } } fn collect_shadow_violations( - field: &str, - mode: &str, - index: usize, - name: &str, + element: NativeElementDiagnostic<'_>, + name: &'static str, shadow: &ElementShadowSpec, violations: &mut BTreeSet, ) { + let NativeElementDiagnostic { + kind, + field, + mode, + index, + id, + } = element; if shadow.color.is_empty() { violations.insert(ValidationViolation::new( - format!("element-shadow:{field}:{mode}:{index}:{name}:color-empty"), - "INVALID_ELEMENT_SHADOW", + native_violation_key( + kind, + id, + "INVALID_ELEMENT_SHADOW", + ViolationPropertyPath::Shadow { + name, + property: "color", + }, + InvalidValueSignature::Empty, + ), format!("{field} {mode}[{index}].{name}.color must be a non-empty string"), )); } for (property, value) in [("offsetX", shadow.offset_x), ("offsetY", shadow.offset_y)] { if !value.is_finite() || !(MIN_SHADOW_OFFSET..=MAX_SHADOW_OFFSET).contains(&value) { violations.insert(ValidationViolation::new( - format!( - "element-shadow:{field}:{mode}:{index}:{name}:{property}:{}", - value.to_bits() + native_violation_key( + kind, + id, + "INVALID_ELEMENT_SHADOW", + ViolationPropertyPath::Shadow { name, property }, + InvalidValueSignature::FloatBits(value.to_bits()), ), - "INVALID_ELEMENT_SHADOW", format!( "{field} {mode}[{index}].{name}.{property} must be a finite number between {MIN_SHADOW_OFFSET} and {MAX_SHADOW_OFFSET}" ), @@ -578,11 +781,16 @@ fn collect_shadow_violations( } if !shadow.blur.is_finite() || !(MIN_SHADOW_BLUR..=MAX_SHADOW_BLUR).contains(&shadow.blur) { violations.insert(ValidationViolation::new( - format!( - "element-shadow:{field}:{mode}:{index}:{name}:blur:{}", - shadow.blur.to_bits() + native_violation_key( + kind, + id, + "INVALID_ELEMENT_SHADOW", + ViolationPropertyPath::Shadow { + name, + property: "blur", + }, + InvalidValueSignature::FloatBits(shadow.blur.to_bits()), ), - "INVALID_ELEMENT_SHADOW", format!( "{field} {mode}[{index}].{name}.blur must be a finite number between {MIN_SHADOW_BLUR} and {MAX_SHADOW_BLUR}" ), @@ -599,8 +807,12 @@ fn collect_collection_violations( for (mode, values) in collection { if !allowed_modes.contains(mode) { violations.insert(ValidationViolation::new( - format!("unknown-mode:{field}:{mode}"), - "UNKNOWN_MODE", + ViolationKey { + owner: ViolationOwner::Mode { mode: mode.clone() }, + code: "UNKNOWN_MODE", + property_path: ViolationPropertyPath::Collection(field), + invalid_value: InvalidValueSignature::None, + }, format!("{field} contains unknown mode '{mode}'"), )); } @@ -611,6 +823,15 @@ fn collect_collection_violations( fn validate_metric_limits( current: &EditorDocumentV1, candidate: &EditorDocumentV1, +) -> Result<(), EditorCommitError> { + validate_aggregate_metric_limits(current, candidate)?; + validate_mode_metric_limits(current, candidate)?; + validate_per_owner_metric_limits(current, candidate) +} + +fn validate_aggregate_metric_limits( + current: &EditorDocumentV1, + candidate: &EditorDocumentV1, ) -> Result<(), EditorCommitError> { validate_count_limit( "TOO_MANY_MODES", @@ -663,8 +884,16 @@ fn validate_metric_limits( 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 = editor_modes(current) + let current_len = current_modes .get(&mode) .map_or(0, |current_mode| current_mode.len()); validate_count_limit( @@ -676,32 +905,49 @@ fn validate_metric_limits( )?; } + Ok(()) +} + +fn validate_per_owner_metric_limits( + current: &EditorDocumentV1, + candidate: &EditorDocumentV1, +) -> 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() { - for (member_index, member) in slot.members().enumerate() { - let current_len = current - .keys - .get(mode) - .and_then(|values| values.get(slot_index)) - .and_then(|slot| slot.members().nth(member_index)) - .map_or(0, String::len); - validate_count_limit( - "KEY_LABEL_TOO_LONG", - &format!("key label {mode}[{slot_index}].members[{member_index}] byte length"), - current_len, - member.len(), - MAX_KEY_LABEL_BYTES, - )?; - } + let current_slot = candidate + .key_positions + .get(mode) + .and_then(|positions| positions.get(slot_index)) + .and_then(|position| current_key_slots.get(position.id.as_str())) + .copied(); + 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 - .layer_groups - .get(mode) - .and_then(|values| values.get(index)); + 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"), @@ -719,61 +965,79 @@ fn validate_metric_limits( } } + 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, - current - .key_positions - .get(mode) - .and_then(|values| values.get(index)), + current_key_positions.get(position.id.as_str()).copied(), position, )?; } } + + let current_stat_positions = current + .stat_positions + .values() + .flatten() + .map(|position| (position.position.id.as_str(), &position.position)) + .collect::>(); for (mode, positions) in &candidate.stat_positions { for (index, position) in positions.iter().enumerate() { validate_position_metrics( "statPositions", mode, index, - current - .stat_positions - .get(mode) - .and_then(|values| values.get(index)) - .map(|position| &position.position), + current_stat_positions + .get(position.position.id.as_str()) + .copied(), &position.position, )?; } } + + let current_graph_positions = current + .graph_positions + .values() + .flatten() + .map(|position| (position.position.id.as_str(), &position.position)) + .collect::>(); for (mode, positions) in &candidate.graph_positions { for (index, position) in positions.iter().enumerate() { validate_position_metrics( "graphPositions", mode, index, - current - .graph_positions - .get(mode) - .and_then(|values| values.get(index)) - .map(|position| &position.position), + current_graph_positions + .get(position.position.id.as_str()) + .copied(), &position.position, )?; } } + + let current_knob_positions = current + .knob_positions + .values() + .flatten() + .map(|position| (position.position.id.as_str(), &position.position)) + .collect::>(); for (mode, positions) in &candidate.knob_positions { for (index, position) in positions.iter().enumerate() { validate_position_metrics( "knobPositions", mode, index, - current - .knob_positions - .get(mode) - .and_then(|values| values.get(index)) - .map(|position| &position.position), + current_knob_positions + .get(position.position.id.as_str()) + .copied(), &position.position, )?; } @@ -782,6 +1046,41 @@ fn validate_metric_limits( 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 @@ -963,24 +1262,38 @@ fn collect_group_violations( let mut result = HashMap::new(); for (mode, groups) in &document.layer_groups { let mut ids = HashSet::new(); + let mut counts = HashMap::new(); for (index, group) in groups.iter().enumerate() { if group.id.is_empty() { violations.insert(ValidationViolation::new( - format!("group-id:{mode}:{index}:{:?}", group.id), - "INVALID_GROUP_ID", + ViolationKey { + owner: ViolationOwner::GroupOccurrence { + mode: mode.clone(), + index, + }, + code: "INVALID_GROUP_ID", + property_path: ViolationPropertyPath::GroupId, + invalid_value: InvalidValueSignature::Empty, + }, format!("layer group id at {mode}[{index}] is empty"), )); } - if !ids.insert(group.id.clone()) { - violations.insert(ValidationViolation::new( - format!("duplicate-group:{mode}:{}", group.id), - "DUPLICATE_GROUP_ID", - format!( - "layer group id '{}' is duplicated in mode '{mode}'", - group.id - ), - )); - } + ids.insert(group.id.clone()); + *counts.entry(group.id.clone()).or_insert(0usize) += 1; + } + for (id, count) in counts.into_iter().filter(|(_, count)| *count > 1) { + violations.insert(ValidationViolation::new( + ViolationKey { + owner: ViolationOwner::DuplicateGroup { + mode: mode.clone(), + id: id.clone(), + }, + code: "DUPLICATE_GROUP_ID", + property_path: ViolationPropertyPath::GroupId, + invalid_value: InvalidValueSignature::Count(count), + }, + format!("layer group id '{id}' is duplicated {count} times in mode '{mode}'"), + )); } result.insert(mode.clone(), ids); } @@ -992,14 +1305,19 @@ fn collect_group_reference_violations( group_ids: &HashMap>, violations: &mut BTreeSet, ) { - for (field, mode, index, position) in document + for (kind, field, mode, index, position) in document .key_positions .iter() .flat_map(|(mode, positions)| { - positions - .iter() - .enumerate() - .map(move |(index, position)| ("keyPositions", mode, index, position)) + positions.iter().enumerate().map(move |(index, position)| { + ( + NativeElementKind::Key, + "keyPositions", + mode, + index, + position, + ) + }) }) .chain( document @@ -1007,7 +1325,13 @@ fn collect_group_reference_violations( .iter() .flat_map(|(mode, positions)| { positions.iter().enumerate().map(move |(index, position)| { - ("statPositions", mode, index, &position.position) + ( + NativeElementKind::Stat, + "statPositions", + mode, + index, + &position.position, + ) }) }), ) @@ -1017,7 +1341,13 @@ fn collect_group_reference_violations( .iter() .flat_map(|(mode, positions)| { positions.iter().enumerate().map(move |(index, position)| { - ("graphPositions", mode, index, &position.position) + ( + NativeElementKind::Graph, + "graphPositions", + mode, + index, + &position.position, + ) }) }), ) @@ -1027,7 +1357,13 @@ fn collect_group_reference_violations( .iter() .flat_map(|(mode, positions)| { positions.iter().enumerate().map(move |(index, position)| { - ("knobPositions", mode, index, &position.position) + ( + NativeElementKind::Knob, + "knobPositions", + mode, + index, + &position.position, + ) }) }), ) @@ -1040,8 +1376,13 @@ fn collect_group_reference_violations( .is_some_and(|ids| ids.contains(group_id)); if !exists { violations.insert(ValidationViolation::new( - format!("group-ref:{field}:{mode}:{index}:{group_id}"), - "UNKNOWN_GROUP_ID", + native_violation_key( + kind, + &position.id, + "UNKNOWN_GROUP_ID", + ViolationPropertyPath::GroupReference, + InvalidValueSignature::Text(group_id.to_string()), + ), format!("{field} {mode}[{index}] references unknown group '{group_id}'"), )); } @@ -1110,11 +1451,13 @@ mod tests { } fn default_editor_store() -> AppStoreData { - 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 { @@ -1157,6 +1500,7 @@ mod tests { position: KeyPosition::default(), }], ); + crate::state::native_element_id::backfill_store_element_ids(&mut store); store } @@ -1339,6 +1683,38 @@ mod tests { ); } + #[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(); @@ -1588,6 +1964,400 @@ mod tests { ); } + #[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") + )); + } + + #[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 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); @@ -1997,7 +2767,7 @@ mod tests { } #[test] - fn existing_pair_and_group_reference_violations_are_not_grandfathered() { + 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); @@ -2013,13 +2783,22 @@ mod tests { 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, - &reference_document, - &reference_store, + &reordered_reference, &reference_store, + &reordered_store, ) - .is_err()); + .is_ok()); } #[test] diff --git a/src-tauri/src/state/migration.rs b/src-tauri/src/state/migration.rs index 9af72040..2618ac93 100644 --- a/src-tauri/src/state/migration.rs +++ b/src-tauri/src/state/migration.rs @@ -73,10 +73,11 @@ pub(crate) fn load_store_from_path(path: &Path) -> Result { // 바이트로 읽어 잘못된 UTF-8도 IO 에러 대신 JSON 파싱 실패로 흘려 복구 분기에 합류 let content = fs::read(path) .with_context(|| format!("failed to read store file at {}", path.display()))?; - let (state, needs_persist, repaired, seed_active_css_history) = + let (state, needs_persist, repaired, seed_active_css_history, explicit_invalid_element_id) = match serde_json::from_slice::(&content) { Ok(mut value) => { let seed_active_css_history = value.get("customCssHistory").is_none(); + let explicit_invalid_element_id = has_explicit_invalid_element_id(&value); let sound_library_migrated = migrate_sound_library_enabled(&mut value); match serde_json::from_value::(value.clone()) { Ok(mut data) => { @@ -126,6 +127,7 @@ pub(crate) fn load_store_from_path(path: &Path) -> Result { || editor_revision_repaired || gradient_pair_repaired, seed_active_css_history, + explicit_invalid_element_id, ) } Err(err) => { @@ -138,6 +140,7 @@ pub(crate) fn load_store_from_path(path: &Path) -> Result { true, true, seed_active_css_history, + false, ) } } @@ -147,13 +150,14 @@ pub(crate) fn load_store_from_path(path: &Path) -> Result { "[Store] Falling back to default recovery for invalid JSON at {}: {err}", path.display() ); - (repair_legacy_state(Value::Null), true, true, false) + (repair_legacy_state(Value::Null), true, true, false, false) } }; // 로드 시점은 정의와 참조가 함께 확정되는 경계 — dangling groupId 정리 // 정리가 발생하면 마이그레이션과 같은 경로로 디스크에도 영속 let mut state = state; let mut needs_persist = needs_persist; + let mut repaired = repaired; let active_css_path = seed_active_css_history .then(|| { state @@ -177,6 +181,9 @@ pub(crate) fn load_store_from_path(path: &Path) -> Result { if clear_dangling_group_ids(&mut state) { needs_persist = true; } + let id_backfill = super::native_element_id::backfill_store_element_ids(&mut state); + needs_persist |= id_backfill.changed; + repaired |= id_backfill.repaired || explicit_invalid_element_id; if needs_persist { log::info!( "[Store] Persisting migrated store file at {}", @@ -190,6 +197,26 @@ pub(crate) fn load_store_from_path(path: &Path) -> Result { }) } +fn has_explicit_invalid_element_id(value: &Value) -> bool { + [ + "keyPositions", + "statPositions", + "graphPositions", + "knobPositions", + ] + .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) @@ -1966,6 +1993,217 @@ mod tests { 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 [ + "keyPositions", + "statPositions", + "graphPositions", + "knobPositions", + ] { + 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 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); + } + #[test] fn legacy_panel_detach_setting_is_removed_without_touching_plugin_data() { let path = std::env::temp_dir().join(format!( @@ -2027,18 +2265,18 @@ mod tests { key_positions: default_positions().clone(), ..AppStoreData::default() }); - let original_position = serde_json::to_vec_pretty(&data.key_positions["4key"][0]).unwrap(); + 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 reserialized_position = - serde_json::to_vec_pretty(&loaded.data.key_positions["4key"][0]).unwrap(); + let mut reloaded_position = loaded.data.key_positions["4key"][0].clone(); + reloaded_position.id.clear(); - assert!(!loaded.needs_persist); + assert!(loaded.needs_persist); assert!(!loaded.repaired); - assert_eq!(reserialized_position, original_position); + assert_eq!(reloaded_position, original_position); let _ = std::fs::remove_file(path); } @@ -2488,6 +2726,7 @@ mod tests { }); data.editor_revision = crate::state::editor::MAX_SAFE_EDITOR_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(); @@ -2620,6 +2859,8 @@ mod tests { "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(); @@ -2629,10 +2870,13 @@ mod tests { loaded.data.keys["4key"].last().unwrap(), &KeySlot::from("F5") ); - assert_eq!( - loaded.data.key_positions["4key"].last().unwrap(), - &KeyPosition::default() - ); + 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 @@ -2764,7 +3008,9 @@ mod tests { "dmnote-sound-migration-test-{}.json", uuid::Uuid::new_v4() )); - let mut value = serde_json::to_value(AppStoreData::default()).unwrap(); + 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 }), @@ -2946,7 +3192,11 @@ mod tests { expected = normalize_state(expected); assert!(loaded.repaired); assert!(loaded.needs_persist); - assert_eq!(loaded.data, expected); + 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] @@ -3522,10 +3772,13 @@ mod tests { loaded.data.keys["positions-damaged"], vec![KeySlot::from("A"), KeySlot::from("B"), KeySlot::from("C")] ); - assert_eq!( - loaded.data.key_positions["positions-damaged"], - vec![KeyPosition::default(); 3] - ); + 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()] @@ -4108,7 +4361,9 @@ mod tests { assert!(loaded.needs_persist); let recovered_key_positions = &loaded.data.key_positions["partial-mode"]; assert_eq!(recovered_key_positions.len(), 4); - assert_eq!(recovered_key_positions[0], position); + 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!( @@ -4123,12 +4378,22 @@ mod tests { recovered_key_positions[1].sound_path, partial_position.sound_path ); - assert_eq!(recovered_key_positions[2], third_position); - assert_eq!(recovered_key_positions[3], KeyPosition::default()); + 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); - assert_eq!(loaded.data.stat_positions["partial-mode"], vec![stat]); - assert_eq!(loaded.data.graph_positions["partial-mode"], vec![graph]); - assert_eq!(loaded.data.knob_positions["partial-mode"], vec![knob]); + 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")); @@ -4160,7 +4425,11 @@ mod tests { 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); - assert_eq!(loaded.data, baseline.data); + 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] diff --git a/src-tauri/src/state/mod.rs b/src-tauri/src/state/mod.rs index 58bf0500..ba633f02 100644 --- a/src-tauri/src/state/mod.rs +++ b/src-tauri/src/state/mod.rs @@ -8,6 +8,7 @@ pub(crate) mod local_asset_path; #[cfg(target_os = "macos")] pub(crate) mod macos_termination; pub(crate) mod migration; +pub(crate) mod native_element_id; pub(crate) mod plugin; pub mod store; diff --git a/src-tauri/src/state/native_element_id.rs b/src-tauri/src/state/native_element_id.rs new file mode 100644 index 00000000..1403053b --- /dev/null +++ b/src-tauri/src/state/native_element_id.rs @@ -0,0 +1,833 @@ +use std::collections::{HashMap, HashSet}; + +use uuid::Uuid; + +use crate::{ + errors::EditorCommitError, + models::{ + AppStoreData, EditorDocumentV1, EditorPatchV1, GraphPosition, KeyPosition, KnobPosition, + StatPosition, EDITOR_COMMIT_SCHEMA_VERSION_V2, EDITOR_SCHEMA_VERSION, + }, +}; + +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"; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) struct BackfillOutcome { + pub(crate) changed: bool, + pub(crate) repaired: bool, +} + +trait NativeElement: Clone + PartialEq { + fn position(&self) -> &KeyPosition; + fn position_mut(&mut self) -> &mut KeyPosition; +} + +impl NativeElement for KeyPosition { + fn position(&self) -> &KeyPosition { + self + } + + fn position_mut(&mut self) -> &mut KeyPosition { + self + } +} + +impl NativeElement for StatPosition { + fn position(&self) -> &KeyPosition { + &self.position + } + + fn position_mut(&mut self) -> &mut KeyPosition { + &mut self.position + } +} + +impl NativeElement for GraphPosition { + fn position(&self) -> &KeyPosition { + &self.position + } + + fn position_mut(&mut self) -> &mut KeyPosition { + &mut self.position + } +} + +impl NativeElement for KnobPosition { + fn position(&self) -> &KeyPosition { + &self.position + } + + fn position_mut(&mut self) -> &mut KeyPosition { + &mut self.position + } +} + +pub(crate) fn is_valid_element_id(id: &str) -> bool { + Uuid::parse_str(id).is_ok_and(|id| !id.is_nil()) +} + +fn new_unique_id(reserved: &mut HashSet) -> String { + loop { + let id = Uuid::new_v4().to_string(); + if reserved.insert(id.clone()) { + return id; + } + } +} + +fn sorted_modes(collection: &HashMap>) -> Vec { + let mut modes = collection.keys().cloned().collect::>(); + modes.sort_unstable(); + modes +} + +fn backfill_collection( + collection: &mut HashMap>, + seen: &mut HashSet, + reserved: &mut HashSet, + outcome: &mut BackfillOutcome, +) { + for mode in sorted_modes(collection) { + let Some(elements) = collection.get_mut(&mode) else { + continue; + }; + for element in elements { + let id = &element.position().id; + let valid = is_valid_element_id(id); + if valid && seen.insert(id.clone()) { + continue; + } + + if !id.is_empty() || valid { + outcome.repaired = true; + } + let id = new_unique_id(reserved); + seen.insert(id.clone()); + element.position_mut().id = id; + outcome.changed = true; + } + } +} + +pub(crate) fn backfill_store_element_ids(store: &mut AppStoreData) -> BackfillOutcome { + let mut seen = HashSet::new(); + let mut reserved = collect_store_ids(store); + let mut outcome = BackfillOutcome::default(); + backfill_collection( + &mut store.key_positions, + &mut seen, + &mut reserved, + &mut outcome, + ); + backfill_collection( + &mut store.stat_positions, + &mut seen, + &mut reserved, + &mut outcome, + ); + backfill_collection( + &mut store.graph_positions, + &mut seen, + &mut reserved, + &mut outcome, + ); + backfill_collection( + &mut store.knob_positions, + &mut seen, + &mut reserved, + &mut outcome, + ); + outcome +} + +fn collect_collection_ids( + collection: &HashMap>, + ids: &mut HashSet, +) { + for elements in collection.values() { + for element in elements { + if is_valid_element_id(&element.position().id) { + ids.insert(element.position().id.clone()); + } + } + } +} + +fn collect_store_ids(store: &AppStoreData) -> HashSet { + let mut ids = HashSet::new(); + collect_collection_ids(&store.key_positions, &mut ids); + collect_collection_ids(&store.stat_positions, &mut ids); + collect_collection_ids(&store.graph_positions, &mut ids); + collect_collection_ids(&store.knob_positions, &mut ids); + ids +} + +fn rekey_collection( + collection: &mut HashMap>, + reserved: &mut HashSet, +) { + for mode in sorted_modes(collection) { + if let Some(elements) = collection.get_mut(&mode) { + for element in elements { + element.position_mut().id = new_unique_id(reserved); + } + } + } +} + +pub(crate) fn rekey_store_element_ids(store: &mut AppStoreData) { + let mut reserved = collect_store_ids(store); + rekey_collection(&mut store.key_positions, &mut reserved); + rekey_collection(&mut store.stat_positions, &mut reserved); + rekey_collection(&mut store.graph_positions, &mut reserved); + rekey_collection(&mut store.knob_positions, &mut reserved); +} + +fn rekey_collection_mode( + collection: &mut HashMap>, + mode: &str, + reserved: &mut HashSet, +) { + if let Some(elements) = collection.get_mut(mode) { + for element in elements { + element.position_mut().id = new_unique_id(reserved); + } + } +} + +pub(crate) fn rekey_mode_element_ids(store: &mut AppStoreData, mode: &str) { + rekey_mode_element_ids_for_collections(store, mode, true, true, true, true); +} + +pub(crate) fn rekey_mode_element_ids_for_collections( + store: &mut AppStoreData, + mode: &str, + key_positions: bool, + stat_positions: bool, + graph_positions: bool, + knob_positions: bool, +) { + let mut reserved = collect_store_ids(store); + if key_positions { + rekey_collection_mode(&mut store.key_positions, mode, &mut reserved); + } + if stat_positions { + rekey_collection_mode(&mut store.stat_positions, mode, &mut reserved); + } + if graph_positions { + rekey_collection_mode(&mut store.graph_positions, mode, &mut reserved); + } + if knob_positions { + rekey_collection_mode(&mut store.knob_positions, mode, &mut reserved); + } +} + +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) +} + +fn same_value_without_id(left: &T, right: &T) -> bool { + let mut left = left.clone(); + let mut right = right.clone(); + left.position_mut().id.clear(); + right.position_mut().id.clear(); + left == right +} + +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 adapt_v1_collection( + current: &HashMap>, + candidate: &mut HashMap>, + canonical_ids: &HashSet, + consumed_current_ids: &mut HashSet, + reserved: &mut HashSet, +) { + let current_elements = ordered_current_elements(current); + + 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); + } + } + } + + 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 inherited = current_elements.iter().find(|current_element| { + let current_id = ¤t_element.position().id; + !consumed_current_ids.contains(current_id) + && same_value_without_id(*current_element, &*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); + } + } + } +} + +fn adapt_v1_patch_ids( + store: &AppStoreData, + patch: &mut EditorPatchV1, +) -> Result<(), EditorCommitError> { + 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); + + if let Some(collection) = patch.key_positions.as_mut() { + adapt_v1_collection( + &store.key_positions, + 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 { + use std::collections::{HashMap, HashSet}; + + use crate::models::{ + AppStoreData, EditorPatchV1, GraphPosition, GraphStatType, GraphType, KeyPosition, + KnobPosition, StatPosition, StatType, + }; + + use super::*; + + fn position(dx: f64) -> KeyPosition { + KeyPosition { + dx, + ..KeyPosition::default() + } + } + + 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_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_non_uuid_and_duplicate_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 + ); + } + + let mut positions = store.key_positions.clone(); + let duplicate = positions["mode"][0].id.clone(); + positions.get_mut("mode").unwrap()[1].id = duplicate; + let mut patch = EditorPatchV1 { + key_positions: Some(positions), + ..EditorPatchV1::default() + }; + assert_eq!( + validation_code(prepare_commit_patch_element_ids(&store, &mut patch).unwrap_err()), + DUPLICATE_ELEMENT_ID + ); + } +} diff --git a/src-tauri/src/state/store.rs b/src-tauri/src/state/store.rs index 83ff4063..2924713e 100644 --- a/src-tauri/src/state/store.rs +++ b/src-tauri/src/state/store.rs @@ -852,6 +852,11 @@ impl AppStore { return Err(EditorCommitError::multi_key_unsupported()); } + super::native_element_id::prepare_commit_patch_element_ids( + &guard.data, + &mut request.changes, + )?; + let gesture_id = request.history_gesture_id(); let gesture_ids = request.echoed_gesture_ids(); let touched_fields = request.changes.included_fields(); @@ -994,7 +999,7 @@ impl AppStore { fn commit_gesture_admitted( &self, - request: GestureCommitRequest, + mut request: GestureCommitRequest, admission: &HistoryAdmissionLease, ) -> std::result::Result { let fingerprint = canonical_request_fingerprint(&request)?; @@ -1034,6 +1039,10 @@ impl AppStore { )); } + if let Some(changes) = request.editor_changes.as_mut() { + super::native_element_id::prepare_commit_patch_element_ids(&guard.data, changes)?; + } + let current_store = guard.data.clone(); let (current_editor, candidate_editor, mut scratch, changed_fields) = if let Some(changes) = request.editor_changes.as_ref() { @@ -2744,12 +2753,13 @@ fn preserve_pre_migration_store(path: &Path) -> Result> { fn initialize_default_state() -> AppStoreData { use crate::defaults::{default_keys, default_positions}; - let data = AppStoreData { + let mut data = normalize_state(AppStoreData { keys: default_keys().clone(), key_positions: default_positions().clone(), ..Default::default() - }; - normalize_state(data) + }); + crate::state::native_element_id::backfill_store_element_ids(&mut data); + data } fn ensure_generic_editor_unchanged(before: &AppStoreData, after: &AppStoreData) -> Result<()> { @@ -4006,7 +4016,8 @@ mod tests { KnobPosition, OverlayBounds, PanelBounds, PendingProcessedWavReplacement, PluginInstancesCommitRequest, PluginInstancesReconcileRequest, PluginPoint, SavedPluginInstance, SettingsPatchInput, SlotMatch, SoundLibraryEntry, SoundSource, - StatPosition, StatType, TabCss, TabNoteSettings, + StatPosition, StatType, TabCss, TabNoteSettings, EDITOR_COMMIT_SCHEMA_VERSION_V2, + EDITOR_SCHEMA_VERSION, }, services::{css_watcher::commit_css_reload, settings::apply_patch_to_store}, state::{ @@ -5886,6 +5897,171 @@ mod tests { 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 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 = AppStore::initialize_in_dir(&dir).unwrap(); + 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"); @@ -8042,8 +8218,14 @@ mod tests { let mut data = super::initialize_default_state(); data.keys .insert("ghost".to_string(), vec!["GhostKey".into()]); - data.key_positions - .insert("ghost".to_string(), vec![KeyPosition::default()]); + 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 @@ -8056,10 +8238,7 @@ mod tests { let snapshot = store.snapshot(); assert_eq!(snapshot.keys["ghost"], vec![KeySlot::from("GhostKey")]); - assert_eq!( - snapshot.key_positions["ghost"], - vec![KeyPosition::default()] - ); + assert_eq!(snapshot.key_positions["ghost"][0].id, ghost_id); store.flush_and_shutdown().unwrap(); drop(store); let _ = std::fs::remove_dir_all(dir); diff --git a/src/types/key/keys.ts b/src/types/key/keys.ts index 89fd735d..f9cdd909 100644 --- a/src/types/key/keys.ts +++ b/src/types/key/keys.ts @@ -257,6 +257,8 @@ export const imageFitSchema = z.union([ export type ImageFit = z.infer; export const keyPositionSchema = z.object({ + // 요소 안정 신원. 백엔드가 발급·검증하는 UUID, 프론트는 보존과 신규 발급만 한다 + id: z.string().optional(), dx: z.number(), dy: z.number(), width: z.number().positive(), From 20dc2d14f3bb56174d225fa3f9d1f81fd81aa826 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 11 Aug 2026 21:53:43 +0900 Subject: [PATCH 02/35] =?UTF-8?q?feat:=20=EC=84=A0=ED=83=9D=C2=B7=EB=A0=8C?= =?UTF-8?q?=EB=8D=94=20=EC=8B=A0=EC=9B=90=EC=9D=84=20=EC=9A=94=EC=86=8C=20?= =?UTF-8?q?=EC=95=88=EC=A0=95=20ID=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/modules/editorAdapters.test.ts | 5 +- .../PropertiesPanel/layer/layerPanelModel.ts | 9 +- .../PropertiesPanel/layer/useLayerActions.ts | 12 +- .../components/main/Grid/core/Grid.tsx | 193 +++++++++++------- .../main/Grid/handles/GroupResizeHandles.tsx | 7 +- .../main/Grid/overlays/SmartGuidesOverlay.tsx | 3 +- src/renderer/components/shared/Key.tsx | 11 +- .../components/shared/OverlayScene.tsx | 14 +- .../components/shared/PluginElement.tsx | 3 +- src/renderer/editor/model/elementId.ts | 3 + .../editor/model/elementIdMap.test.ts | 84 ++++++++ src/renderer/editor/model/elementIdMap.ts | 104 ++++++++++ .../editor/model/keys.elementId.test.ts | 38 ++++ src/renderer/editor/model/keys.ts | 4 + .../hooks/Grid/useGridCanvasActions.ts | 10 +- src/renderer/hooks/Grid/useGridMarquee.ts | 9 +- src/renderer/hooks/Grid/useGridResize.ts | 37 +++- src/renderer/hooks/Grid/useGridSelection.ts | 30 ++- .../hooks/Grid/useSmartGuidesElements.ts | 9 +- .../useGridSelectionStore.idReconcile.test.ts | 75 +++++++ .../stores/grid/useGridSelectionStore.ts | 86 ++++++-- src/renderer/utils/layerGroupUtils.ts | 9 +- 22 files changed, 622 insertions(+), 133 deletions(-) create mode 100644 src/renderer/editor/model/elementId.ts create mode 100644 src/renderer/editor/model/elementIdMap.test.ts create mode 100644 src/renderer/editor/model/elementIdMap.ts create mode 100644 src/renderer/editor/model/keys.elementId.test.ts create mode 100644 src/renderer/stores/grid/useGridSelectionStore.idReconcile.test.ts diff --git a/src/renderer/api/modules/editorAdapters.test.ts b/src/renderer/api/modules/editorAdapters.test.ts index 13f2a38a..8b94b33e 100644 --- a/src/renderer/api/modules/editorAdapters.test.ts +++ b/src/renderer/api/modules/editorAdapters.test.ts @@ -38,9 +38,8 @@ describe('editor API compatibility adapters', () => { it('routes key writers through editor_commit and preserves return shapes', async () => { const mappings = { '4key': ['A'] }; - const positions = { - '4key': [createDefaultKeyPosition()], - } as KeyPositions; + // 입력 echo와 canonical fixture가 값 비교되므로 id까지 같은 사본을 쓴다 + const positions = structuredClone(document.keyPositions) as KeyPositions; await expect(keysApi.update(mappings)).resolves.toEqual(document.keys); await expect(keysApi.updatePositions(positions)).resolves.toEqual( diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/layerPanelModel.ts b/src/renderer/components/main/Grid/PropertiesPanel/layer/layerPanelModel.ts index 572a8295..6dffcf6b 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/layerPanelModel.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/layerPanelModel.ts @@ -3,6 +3,7 @@ * LayerTabContent에서 사용하는 layerItems / displayItems 생성 */ +import { selectionElementId } from '@stores/grid/useGridSelectionStore'; import { slotDisplayName } from '@utils/keySlot'; import type { KeyMappings, KeyPositions } from '@src/types/key/keys'; import type { StatItemPositions } from '@src/types/key/statItems'; @@ -45,7 +46,7 @@ export function buildLayerItems({ const defaultName = slotDisplayName(slot) || `Key ${index + 1}`; items.push({ type: 'key', - id: `key-${index}`, + id: selectionElementId('key', pos, index), index, name: pos.layerName || defaultName, zIndex: pos.zIndex ?? index, @@ -67,7 +68,7 @@ export function buildLayerItems({ : 'KPS'; items.push({ type: 'stat', - id: `stat-${index}`, + id: selectionElementId('stat', pos, index), index, name: pos.layerName || defaultName, zIndex: pos.zIndex ?? index, @@ -89,7 +90,7 @@ export function buildLayerItems({ : 'KPS Graph'; items.push({ type: 'graph', - id: `graph-${index}`, + id: selectionElementId('graph', pos, index), index, name: pos.layerName || defaultName, zIndex: pos.zIndex ?? index, @@ -103,7 +104,7 @@ export function buildLayerItems({ currentKnobPositions.forEach((pos, index) => { items.push({ type: 'knob', - id: `knob-${index}`, + id: selectionElementId('knob', pos, index), index, name: pos.layerName || `Knob ${index + 1}`, zIndex: pos.zIndex ?? index, diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerActions.ts b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerActions.ts index a5895cb6..60fa4210 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerActions.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerActions.ts @@ -236,7 +236,9 @@ export function useLayerActions({ const allHidden = children.every((c) => c.hidden); const newHidden = !allHidden; - const changes: EditorPatchV1 = { schemaVersion: 1 }; + const changes: EditorPatchV1 = { + schemaVersion: 1, + }; const previousKeyPositions = useKeyStore.getState().canonicalPositions; const previousStatPositions = useStatItemStore.getState().positions; const previousGraphPositions = useGraphItemStore.getState().positions; @@ -581,7 +583,9 @@ export function useLayerActions({ useLayerGroupStore.getState().setLayerGroups(normalized.layerGroups); } - const changes: EditorPatchV1 = { schemaVersion: 1 }; + const changes: EditorPatchV1 = { + schemaVersion: 1, + }; if (hasChanged(pos, normalized.keyPositions)) { changes.keyPositions = normalized.keyPositions; } @@ -971,7 +975,9 @@ export function useLayerActions({ useLayerGroupStore.getState().setLayerGroups(normalized.layerGroups); } - const changes: EditorPatchV1 = { schemaVersion: 1 }; + const changes: EditorPatchV1 = { + schemaVersion: 1, + }; if (hasChanged(currentMappings, nextMappings)) { changes.keys = nextMappings; changes.keyPositions = normalized.keyPositions; diff --git a/src/renderer/components/main/Grid/core/Grid.tsx b/src/renderer/components/main/Grid/core/Grid.tsx index 59b79e78..64e1c8f5 100644 --- a/src/renderer/components/main/Grid/core/Grid.tsx +++ b/src/renderer/components/main/Grid/core/Grid.tsx @@ -42,6 +42,7 @@ import KnobItem from '../layers/KnobItem'; import { useGridSelectionStore, isElementInMarquee, + selectionElementId, type SelectedElement, } from '@stores/grid/useGridSelectionStore'; import { openPropertiesPanelForSelection } from '@stores/grid/usePanelWindowStore'; @@ -846,55 +847,31 @@ const Grid = ({ setIsContextOpen(false); setContextPosition(null); } + const collections = { + key: positions[selectedKeyType] || [], + stat: useStatItemStore.getState().positions[selectedKeyType] || [], + graph: useGraphItemStore.getState().positions[selectedKeyType] || [], + knob: useKnobItemStore.getState().positions[selectedKeyType] || [], + } as const; + + const clicked = collections[type][index]; const nextSelection: SelectedElement[] = [ - { type, id: `${type}-${index}`, index }, + { type, id: selectionElementId(type, clicked, index), index }, ]; - // 그룹 ID 조회 - let groupId: string | undefined; - if (type === 'key') { - groupId = positions[selectedKeyType]?.[index]?.groupId; - } else if (type === 'stat') { - groupId = - useStatItemStore.getState().positions[selectedKeyType]?.[index] - ?.groupId; - } else if (type === 'graph') { - groupId = - useGraphItemStore.getState().positions[selectedKeyType]?.[index] - ?.groupId; - } else { - groupId = - useKnobItemStore.getState().positions[selectedKeyType]?.[index] - ?.groupId; - } - + const groupId = clicked?.groupId; if (groupId) { // 같은 그룹의 모든 요소 선택 - (positions[selectedKeyType] || []).forEach((p, i) => { - if (p?.groupId === groupId && !(type === 'key' && i === index)) { - nextSelection.push({ type: 'key', id: `key-${i}`, index: i }); - } - }); - const statPos = - useStatItemStore.getState().positions[selectedKeyType] || []; - statPos.forEach((p, i) => { - if (p?.groupId === groupId && !(type === 'stat' && i === index)) { - nextSelection.push({ type: 'stat', id: `stat-${i}`, index: i }); - } - }); - const graphPos = - useGraphItemStore.getState().positions[selectedKeyType] || []; - graphPos.forEach((p, i) => { - if (p?.groupId === groupId && !(type === 'graph' && i === index)) { - nextSelection.push({ type: 'graph', id: `graph-${i}`, index: i }); - } - }); - const knobPos = - useKnobItemStore.getState().positions[selectedKeyType] || []; - knobPos.forEach((p, i) => { - if (p?.groupId === groupId && !(type === 'knob' && i === index)) { - nextSelection.push({ type: 'knob', id: `knob-${i}`, index: i }); - } + (['key', 'stat', 'graph', 'knob'] as const).forEach((memberType) => { + collections[memberType].forEach((p, i) => { + if (p?.groupId === groupId && !(type === memberType && i === index)) { + nextSelection.push({ + type: memberType, + id: selectionElementId(memberType, p, i), + index: i, + }); + } + }); }); } // 그룹 멤버 수와 무관하게 선택 Store 알림·React render를 1회로 유지 @@ -911,7 +888,7 @@ const Grid = ({ useGridSelectionStore.getState(); const isMultiMember = currentSelection.length > 1 && - currentSelection.some((el) => el.id === `${type}-${index}`); + currentSelection.some((el) => el.type === type && el.index === index); if (!isMultiMember) { selectElementWithGroup(type, index); } @@ -930,7 +907,15 @@ const Grid = ({ setDuplicateState(null); setDuplicateCursor(null); } - const clickedId = `${type}-${index}`; + const clickedPosition = + type === 'key' + ? positions[selectedKeyType]?.[index] + : type === 'stat' + ? useStatItemStore.getState().positions[selectedKeyType]?.[index] + : type === 'graph' + ? useGraphItemStore.getState().positions[selectedKeyType]?.[index] + : useKnobItemStore.getState().positions[selectedKeyType]?.[index]; + const clickedId = selectionElementId(type, clickedPosition, index); if (shouldOpenMixedSelectionMenu(clickedId)) { openMixedSelectionContextMenu(clientX, clientY, ref); return; @@ -1005,7 +990,7 @@ const Grid = ({ return positions[selectedKeyType].map( (position: KeyPosition, index: number) => { - const handlers = stableHandlers(`key-${index}`, { + const handlers = stableHandlers(position.id || `key-${index}`, { onPositionChange: onPositionChange, onClick: () => { selectElementWithGroup('key', index); @@ -1023,7 +1008,15 @@ const Grid = ({ onDoubleClick: () => openElementEditor('key', index), onCtrlClick: () => { // 다중 선택: 기존 선택 유지하면서 추가/제거 - toggleSelection({ type: 'key', id: `key-${index}`, index }); + toggleSelection({ + type: 'key', + id: selectionElementId( + 'key', + positions[selectedKeyType]?.[index], + index, + ), + index, + }); // 마지막 선택 키 좌표 저장 (Shift+클릭 범위 선택용) const pos = positions[selectedKeyType]?.[index]; if (pos) { @@ -1040,7 +1033,15 @@ const Grid = ({ if (!lastSelectedKeyBounds) { // 이전 선택이 없으면 단일 선택처럼 동작 clearSelection(); - toggleSelection({ type: 'key', id: `key-${index}`, index }); + toggleSelection({ + type: 'key', + id: selectionElementId( + 'key', + positions[selectedKeyType]?.[index], + index, + ), + index, + }); const pos = positions[selectedKeyType]?.[index]; if (pos) { setLastSelectedKeyBounds({ @@ -1094,7 +1095,7 @@ const Grid = ({ if (isElementInMarquee(elementBounds, rangeRect)) { newSelectedElements.push({ type: 'key', - id: `key-${i}`, + id: selectionElementId('key', pos, i), index: i, }); } @@ -1132,7 +1133,7 @@ const Grid = ({ if (isElementInMarquee(elementBounds, rangeRect)) { newSelectedElements.push({ type: 'stat', - id: `stat-${i}`, + id: selectionElementId('stat', pos, i), index: i, }); } @@ -1150,7 +1151,7 @@ const Grid = ({ if (isElementInMarquee(elementBounds, rangeRect)) { newSelectedElements.push({ type: 'graph', - id: `graph-${i}`, + id: selectionElementId('graph', pos, i), index: i, }); } @@ -1168,7 +1169,7 @@ const Grid = ({ if (isElementInMarquee(elementBounds, rangeRect)) { newSelectedElements.push({ type: 'knob', - id: `knob-${i}`, + id: selectionElementId('knob', pos, i), index: i, }); } @@ -1205,8 +1206,9 @@ const Grid = ({ return ( { - const handlers = stableHandlers(`stat-${index}`, { + const handlers = stableHandlers(position.id || `stat-${index}`, { onPositionChange: handleStatPositionChange, onClick: () => { selectElementWithGroup('stat', index); }, onDoubleClick: () => openElementEditor('stat', index), onCtrlClick: () => { - toggleSelection({ type: 'stat', id: `stat-${index}`, index }); + toggleSelection({ + type: 'stat', + id: selectionElementId( + 'stat', + useStatItemStore.getState().positions[selectedKeyType]?.[index], + index, + ), + index, + }); }, onShiftClick: () => { // 통계 요소는 범위 선택 대상이 아니므로 Ctrl+클릭과 동일하게 처리 - toggleSelection({ type: 'stat', id: `stat-${index}`, index }); + toggleSelection({ + type: 'stat', + id: selectionElementId( + 'stat', + useStatItemStore.getState().positions[selectedKeyType]?.[index], + index, + ), + index, + }); }, onMultiDrag: (deltaX: number, deltaY: number) => moveSelectedElements(deltaX, deltaY, undefined, false), @@ -1298,9 +1316,10 @@ const Grid = ({ return ( ( openElementEditor('graph', index)} onCtrlClick={() => { - toggleSelection({ type: 'graph', id: `graph-${index}`, index }); + toggleSelection({ + type: 'graph', + id: selectionElementId( + 'graph', + useGraphItemStore.getState().positions[selectedKeyType]?.[index], + index, + ), + index, + }); }} onShiftClick={() => { - toggleSelection({ type: 'graph', id: `graph-${index}`, index }); + toggleSelection({ + type: 'graph', + id: selectionElementId( + 'graph', + useGraphItemStore.getState().positions[selectedKeyType]?.[index], + index, + ), + index, + }); }} isSelected={selectedElements.some( (el) => el.type === 'graph' && el.index === index, @@ -1431,9 +1466,9 @@ const Grid = ({ return items.map((position, index) => ( openElementEditor('knob', index)} onCtrlClick={() => { - toggleSelection({ type: 'knob', id: `knob-${index}`, index }); + toggleSelection({ + type: 'knob', + id: selectionElementId( + 'knob', + useKnobItemStore.getState().positions[selectedKeyType]?.[index], + index, + ), + index, + }); }} onShiftClick={() => { - toggleSelection({ type: 'knob', id: `knob-${index}`, index }); + toggleSelection({ + type: 'knob', + id: selectionElementId( + 'knob', + useKnobItemStore.getState().positions[selectedKeyType]?.[index], + index, + ), + index, + }); }} isSelected={selectedElements.some( (el) => el.type === 'knob' && el.index === index, @@ -1890,7 +1941,7 @@ const Grid = ({ width: pos.width || 60, height: pos.height || 60, }; - elementId = `key-${el.index}`; + elementId = selectionElementId('key', pos, el.index); } else if (el.type === 'stat' && el.index !== undefined) { const pos = statPositions?.[selectedKeyType]?.[el.index]; if (!pos) return null; @@ -1901,7 +1952,7 @@ const Grid = ({ width: pos.width || 60, height: pos.height || 60, }; - elementId = `stat-${el.index}`; + elementId = selectionElementId('stat', pos, el.index); } else if (el.type === 'graph' && el.index !== undefined) { const pos = graphPositions?.[selectedKeyType]?.[el.index]; if (!pos) return null; @@ -1912,7 +1963,7 @@ const Grid = ({ width: pos.width || 200, height: pos.height || 100, }; - elementId = `graph-${el.index}`; + elementId = selectionElementId('graph', pos, el.index); } else if (el.type === 'knob' && el.index !== undefined) { const pos = knobPositions?.[selectedKeyType]?.[el.index]; if (!pos) return null; @@ -1923,7 +1974,7 @@ const Grid = ({ width: pos.width || 60, height: pos.height || 60, }; - elementId = `knob-${el.index}`; + elementId = selectionElementId('knob', pos, el.index); } else if (el.type === 'plugin') { // 플러그인 요소 - resizable 속성 확인 const pluginEl = pluginElements.find((p) => p.fullId === el.id); diff --git a/src/renderer/components/main/Grid/handles/GroupResizeHandles.tsx b/src/renderer/components/main/Grid/handles/GroupResizeHandles.tsx index b51e2350..1be46728 100644 --- a/src/renderer/components/main/Grid/handles/GroupResizeHandles.tsx +++ b/src/renderer/components/main/Grid/handles/GroupResizeHandles.tsx @@ -466,10 +466,9 @@ const GroupResizeHandles = ({ const spacingGuidesEnabled = gridSettings?.spacingGuides !== false; const sizeMatchGuidesEnabled = gridSettings?.sizeMatchGuides !== false; - // 선택된 요소들의 ID 수집 (스마트 가이드에서 제외) - const selectedIds = selectedElements.map((el) => - el.type === 'key' ? `key-${el.index}` : el.id, - ); + // 선택된 요소들의 ID 수집 (스마트 가이드에서 제외). + // 선택 id와 가이드 bounds id가 같은 생성자(position.id)를 쓰므로 그대로 넘긴다 + const selectedIds = selectedElements.map((el) => el.id); if (getOtherElements && alignmentGuidesEnabled) { const otherElements = getOtherElements(selectedIds); diff --git a/src/renderer/components/main/Grid/overlays/SmartGuidesOverlay.tsx b/src/renderer/components/main/Grid/overlays/SmartGuidesOverlay.tsx index 390647cb..0a7e7995 100644 --- a/src/renderer/components/main/Grid/overlays/SmartGuidesOverlay.tsx +++ b/src/renderer/components/main/Grid/overlays/SmartGuidesOverlay.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { useSmartGuidesStore } from '@stores/grid/useSmartGuidesStore'; +import { selectionElementId } from '@stores/grid/useGridSelectionStore'; import { calculateGuideLineExtent } from '@utils/grid/smartGuides'; import { useKeyStore } from '@stores/data/useKeyStore'; import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; @@ -54,7 +55,7 @@ export const SmartGuidesOverlay: React.FC = ({ pos.dy, pos.width || 60, pos.height || 60, - `key-${index}`, + selectionElementId('key', pos, index), ), ); }); diff --git a/src/renderer/components/shared/Key.tsx b/src/renderer/components/shared/Key.tsx index 716c5d40..fb1ef352 100644 --- a/src/renderer/components/shared/Key.tsx +++ b/src/renderer/components/shared/Key.tsx @@ -36,6 +36,8 @@ interface SelectedElement { interface DraggableKeyProps { index: number; elementId?: string; + /** 그라디언트 프리뷰 앵커 종류. id 문자열 모양으로 추론하지 않는다 */ + anchorKind?: 'key' | 'stat'; position: KeyPosition; keyName: string; onPositionChange: (index: number, dx: number, dy: number) => void; @@ -74,6 +76,7 @@ const DraggableKey = React.memo( ({ index, elementId, + anchorKind, position, keyName, onPositionChange, @@ -125,15 +128,13 @@ const DraggableKey = React.memo( (state) => state.isDraggingOrResizing, ); - const effectiveElementId = elementId || `key-${index}`; + const effectiveElementId = elementId || `${anchorKind ?? 'key'}-${index}`; // 편집 세션 일시 페인트 — 드래그 프리뷰가 저장·히스토리를 거치지 않고 // 해당 표면의 spec과 대기/입력 상태 전체를 함께 그린다 - const anchorKind = effectiveElementId.startsWith('stat-') - ? ('stat' as const) - : ('key' as const); + const previewAnchorKind = anchorKind ?? 'key'; const previewSession = useGradientPreviewSession( - anchorKind, + previewAnchorKind, index, isSelected, ); diff --git a/src/renderer/components/shared/OverlayScene.tsx b/src/renderer/components/shared/OverlayScene.tsx index f1ef104c..829f05ef 100644 --- a/src/renderer/components/shared/OverlayScene.tsx +++ b/src/renderer/components/shared/OverlayScene.tsx @@ -16,6 +16,12 @@ import type { NoteSettings } from '@src/types/settings/noteSettings'; import type { NoteBuffer } from '@stores/signals/noteBuffer'; import { resolveZIndexFallback } from '@utils/core/zIndexFallback'; +// 오버레이 wire 타입에 id가 아직 좁혀지지 않은 컬렉션용 안전 접근 +const stableKeyOf = (pos: unknown, fallback: string): string => { + const id = (pos as { id?: unknown } | null | undefined)?.id; + return typeof id === 'string' && id.length > 0 ? id : fallback; +}; + const FALLBACK_POSITION: KeyPosition = { dx: 0, dy: 0, @@ -189,7 +195,7 @@ const OverlayScene = ({ return ( @@ -251,7 +257,7 @@ const OverlayScene = ({ }; return ( diff --git a/src/renderer/components/shared/PluginElement.tsx b/src/renderer/components/shared/PluginElement.tsx index c638320a..eb931154 100644 --- a/src/renderer/components/shared/PluginElement.tsx +++ b/src/renderer/components/shared/PluginElement.tsx @@ -16,6 +16,7 @@ import { useGridSelectionStore, SelectedElement, isElementInMarquee, + selectionElementId, } from '@stores/grid/useGridSelectionStore'; import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; import { openPropertiesPanelForSelection } from '@stores/grid/usePanelWindowStore'; @@ -1255,7 +1256,7 @@ const PluginElementImpl: React.FC = ({ if (isElementInMarquee(elementBounds, rangeRect)) { newSelectedElements.push({ type: 'key', - id: `key-${i}`, + id: selectionElementId('key', pos, i), index: i, }); } diff --git a/src/renderer/editor/model/elementId.ts b/src/renderer/editor/model/elementId.ts new file mode 100644 index 00000000..dd1d6bb7 --- /dev/null +++ b/src/renderer/editor/model/elementId.ts @@ -0,0 +1,3 @@ +// 요소 안정 신원 발급. 생성·복제·붙여넣기는 항상 새 ID를 받는다 (수명 규칙). +// 형식과 전역 유일성 검증은 백엔드 커밋 경계가 한다 +export const newElementId = (): string => crypto.randomUUID(); diff --git a/src/renderer/editor/model/elementIdMap.test.ts b/src/renderer/editor/model/elementIdMap.test.ts new file mode 100644 index 00000000..e8de71fb --- /dev/null +++ b/src/renderer/editor/model/elementIdMap.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + captureElementRef, + resolveElementById, + resolveElementByIdAcross, +} from './elementIdMap'; +import { createDefaultKeyPosition } from './keys'; +import { useKeyStore } from '@stores/data/useKeyStore'; +import { useStatItemStore } from '@stores/data/useStatItemStore'; + +import type { KeyPosition } from '@src/types/key/keys'; +import type { StatItemPosition } from '@src/types/key/statItems'; + +const keyAt = (): KeyPosition => createDefaultKeyPosition(); +const statAt = (): StatItemPosition => ({ + ...createDefaultKeyPosition(), + statType: 'kps', +}); + +describe('elementIdMap', () => { + beforeEach(() => { + useKeyStore.setState({ + selectedKeyType: '4key', + canonicalPositions: { '4key': [] }, + } as never); + useStatItemStore.setState({ positions: { '4key': [] } } as never); + }); + + it('id로 현재 (mode, index)를 찾는다', () => { + const a = keyAt(); + const b = keyAt(); + useKeyStore.setState({ + canonicalPositions: { '4key': [a, b] }, + } as never); + + expect(resolveElementById('key', b.id!)).toEqual({ + type: 'key', + mode: '4key', + index: 1, + }); + }); + + it('재정렬 후에도 같은 요소를 찾는다 (live 참조 기준 - 캐시 무효화)', () => { + const a = keyAt(); + const b = keyAt(); + useKeyStore.setState({ + canonicalPositions: { '4key': [a, b] }, + } as never); + expect(resolveElementById('key', a.id!)?.index).toBe(0); + + // 낙관 변경으로 배열이 즉시 뒤집혀도 다음 조회가 새 위치를 본다 + useKeyStore.setState({ + canonicalPositions: { '4key': [b, a] }, + } as never); + expect(resolveElementById('key', a.id!)?.index).toBe(1); + }); + + it('삭제된 id는 null을 돌린다', () => { + const a = keyAt(); + useKeyStore.setState({ canonicalPositions: { '4key': [a] } } as never); + expect(resolveElementById('key', a.id!)).not.toBeNull(); + + useKeyStore.setState({ canonicalPositions: { '4key': [] } } as never); + expect(resolveElementById('key', a.id!)).toBeNull(); + }); + + it('타입을 넘나들며 조회한다 (id 전역 유일)', () => { + const s = statAt(); + useStatItemStore.setState({ positions: { '4key': [s] } } as never); + + expect(resolveElementByIdAcross(['key', 'stat'], s.id!)).toEqual({ + type: 'stat', + mode: '4key', + index: 0, + }); + }); + + it('id가 없는 요소는 캡처하지 않는다 (구형 데이터 폴백)', () => { + expect(captureElementRef('key', '4key', { id: undefined })).toBeNull(); + expect(captureElementRef('key', '4key', undefined)).toBeNull(); + expect(captureElementRef('key', '4key', { id: 'abc' })?.id).toBe('abc'); + }); +}); diff --git a/src/renderer/editor/model/elementIdMap.ts b/src/renderer/editor/model/elementIdMap.ts new file mode 100644 index 00000000..36883802 --- /dev/null +++ b/src/renderer/editor/model/elementIdMap.ts @@ -0,0 +1,104 @@ +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 type { KeyPosition } from '@src/types/key/keys'; + +export type NativeElementType = 'key' | 'stat' | 'graph' | 'knob'; + +export interface ElementLocator { + type: NativeElementType; + mode: string; + index: number; +} + +// 비동기 시작 시 캡처해 완료 시 조회하는 요소 참조 +export interface ElementRef { + type: NativeElementType; + mode: string; + id: string; +} + +type PositionsRecord = Record | undefined; + +// 권위 컬렉션만 읽는다. 키의 렌더 positions는 canonical + 프리뷰 합성이라 +// 조회 기준으로 쓰면 프리뷰 재합성 타이밍에 따라 결과가 흔들린다 +const authorityRecords = (): Record => ({ + key: useKeyStore.getState().canonicalPositions, + stat: useStatItemStore.getState().positions, + graph: useGraphItemStore.getState().positions, + knob: useKnobItemStore.getState().positions, +}); + +interface CacheEntry { + source: PositionsRecord; + byId: Map; +} + +// live 참조 기준 lazy cache. canonical 이벤트에서만 재구축하면 낙관 변경 직후 +// stale해진다 - 배열 record 참조가 바뀌었을 때만 그 타입을 다시 인덱싱한다 +const cache = new Map(); + +const indexRecord = ( + type: NativeElementType, + source: PositionsRecord, +): Map => { + const byId = new Map(); + if (!source) return byId; + for (const [mode, positions] of Object.entries(source)) { + positions.forEach((position, index) => { + const id = position?.id; + if (typeof id === 'string' && id.length > 0) { + byId.set(id, { type, mode, index }); + } + }); + } + return byId; +}; + +const lookupIn = ( + type: NativeElementType, + source: PositionsRecord, + id: string, +): ElementLocator | null => { + const cached = cache.get(type); + if (!cached || cached.source !== source) { + cache.set(type, { source, byId: indexRecord(type, source) }); + } + return cache.get(type)!.byId.get(id) ?? null; +}; + +// id로 요소의 현재 위치를 찾는다. 없으면 null - 요소가 삭제된 것이므로 +// 비동기 완료는 연결만 조용히 중단한다 +export const resolveElementById = ( + type: NativeElementType, + id: string, +): ElementLocator | null => { + if (!id) return null; + return lookupIn(type, authorityRecords()[type], id); +}; + +// 여러 타입에 걸쳐 조회. id는 전역 유일이라 최대 한 곳에서만 발견된다 +export const resolveElementByIdAcross = ( + types: readonly NativeElementType[], + id: string, +): ElementLocator | null => { + for (const type of types) { + const hit = resolveElementById(type, id); + if (hit) return hit; + } + return null; +}; + +// 비동기 작업 시작 시점의 참조 캡처. 대상 요소에 id가 없으면(구형 데이터가 +// 아직 backfill 전) null을 돌려 호출부가 기존 경로를 유지하게 한다 +export const captureElementRef = ( + type: NativeElementType, + mode: string, + position: { id?: string } | undefined, +): ElementRef | null => { + const id = position?.id; + if (typeof id !== 'string' || id.length === 0) return null; + return { type, mode, id }; +}; diff --git a/src/renderer/editor/model/keys.elementId.test.ts b/src/renderer/editor/model/keys.elementId.test.ts new file mode 100644 index 00000000..3bb86e27 --- /dev/null +++ b/src/renderer/editor/model/keys.elementId.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; + +import { addKey, createDefaultKeyPosition, duplicateKey } from './keys'; +import type { KeyMappings, KeyPositions } from '@src/types/key/keys'; + +// 생성·복제는 항상 새 신원을 받는다. source id를 물려받으면 커밋 후보 안 +// 중복 ID로 백엔드가 원자 거절한다 (stable-id-contract 2·4절) +describe('요소 신원 발급', () => { + it('신규 생성마다 서로 다른 id를 발급한다', () => { + const first = createDefaultKeyPosition(); + const second = createDefaultKeyPosition(); + + expect(first.id).toBeTruthy(); + expect(second.id).toBeTruthy(); + expect(first.id).not.toBe(second.id); + }); + + it('복제본은 source의 id를 물려받지 않는다', () => { + const mappings: KeyMappings = { '4key': ['KeyA'] }; + const source = createDefaultKeyPosition(10, 20); + const positions: KeyPositions = { '4key': [source] }; + + const result = duplicateKey(mappings, positions, '4key', 0, 30, 40); + + expect(result).not.toBeNull(); + const cloned = result!.positions['4key'][1]; + expect(cloned.id).toBeTruthy(); + expect(cloned.id).not.toBe(source.id); + // 신원 외 스타일은 복제된다 + expect(cloned.width).toBe(source.width); + }); + + it('addKey 경로도 새 id를 발급한다', () => { + const result = addKey({}, {}, '4key', 0, 0); + + expect(result.positions['4key'][0].id).toBeTruthy(); + }); +}); diff --git a/src/renderer/editor/model/keys.ts b/src/renderer/editor/model/keys.ts index fdc8a2fb..4e792b9a 100644 --- a/src/renderer/editor/model/keys.ts +++ b/src/renderer/editor/model/keys.ts @@ -16,6 +16,7 @@ import { createDefaultCounterSettings, normalizeCounterSettings, } from '@src/types/key/keys'; +import { newElementId } from './elementId'; import { cloneSlot } from '@utils/keySlot'; // ---------------------------------------------------------------------------- @@ -24,6 +25,7 @@ import { cloneSlot } from '@utils/keySlot'; export function createDefaultKeyPosition(dx = 0, dy = 0): KeyPosition { return { + id: newElementId(), dx, dy, width: 60, @@ -159,6 +161,8 @@ export function duplicateKey( const clonedPosition: KeyPosition = { ...sourcePosition, + // 복제본은 새 신원. source id를 물려받으면 후보 안 중복으로 커밋이 거절된다 + id: newElementId(), dx: Math.round(targetDx), dy: Math.round(targetDy), counter: clonedCounter, diff --git a/src/renderer/hooks/Grid/useGridCanvasActions.ts b/src/renderer/hooks/Grid/useGridCanvasActions.ts index cbdf2151..7017273b 100644 --- a/src/renderer/hooks/Grid/useGridCanvasActions.ts +++ b/src/renderer/hooks/Grid/useGridCanvasActions.ts @@ -3,6 +3,7 @@ * Grid.tsx에서 추출된 stat/graph 편집 로직 */ +import { newElementId } from '@src/renderer/editor/model/elementId'; import { useKeyStore } from '@stores/data/useKeyStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { useGraphItemStore } from '@stores/data/useGraphItemStore'; @@ -357,7 +358,7 @@ export function useGridCanvasActions(selectedKeyType: string): CanvasActions { ...current, [selectedKeyType]: [ ...tabPositions, - { ...templatePosition, dx, dy, zIndex: maxZ + 1 }, + { ...templatePosition, id: newElementId(), dx, dy, zIndex: maxZ + 1 }, ], }; persistStatPositions(nextPositions, 'Failed to duplicate stat item'); @@ -477,7 +478,7 @@ export function useGridCanvasActions(selectedKeyType: string): CanvasActions { ...current, [selectedKeyType]: [ ...tabPositions, - { ...templatePosition, dx, dy, zIndex: maxZ + 1 }, + { ...templatePosition, id: newElementId(), dx, dy, zIndex: maxZ + 1 }, ], }; persistGraphPositions(nextPositions, 'Failed to duplicate graph item'); @@ -488,6 +489,7 @@ export function useGridCanvasActions(selectedKeyType: string): CanvasActions { const list = [...(current[selectedKeyType] || [])]; list.push({ + id: newElementId(), statType: 'kps', dx, dy, @@ -523,6 +525,7 @@ export function useGridCanvasActions(selectedKeyType: string): CanvasActions { const list = [...(current[selectedKeyType] || [])]; list.push({ + id: newElementId(), statType: 'kps', graphType: 'line', graphSpeed: 1000, @@ -668,7 +671,7 @@ export function useGridCanvasActions(selectedKeyType: string): CanvasActions { ...current, [selectedKeyType]: [ ...tabPositions, - { ...templatePosition, dx, dy, zIndex: maxZ + 1 }, + { ...templatePosition, id: newElementId(), dx, dy, zIndex: maxZ + 1 }, ], }; persistKnobPositions(nextPositions, 'Failed to duplicate knob item'); @@ -678,6 +681,7 @@ export function useGridCanvasActions(selectedKeyType: string): CanvasActions { const current = useKnobItemStore.getState().positions; const list = [...(current[selectedKeyType] || [])]; list.push({ + id: newElementId(), axisId: '', sensitivity: 1, reverse: false, diff --git a/src/renderer/hooks/Grid/useGridMarquee.ts b/src/renderer/hooks/Grid/useGridMarquee.ts index 10763060..5e70ef20 100644 --- a/src/renderer/hooks/Grid/useGridMarquee.ts +++ b/src/renderer/hooks/Grid/useGridMarquee.ts @@ -4,6 +4,7 @@ import { useEffect, useRef } from 'react'; import { + selectionElementId, useGridSelectionStore, isElementInMarquee, getMarqueeRect, @@ -130,7 +131,7 @@ export function useGridMarquee({ if (isElementInMarquee(elementBounds, rect)) { newSelectedElements.push({ type: 'key', - id: `key-${index}`, + id: selectionElementId('key', pos, index), index, }); } @@ -149,7 +150,7 @@ export function useGridMarquee({ if (isElementInMarquee(elementBounds, rect)) { newSelectedElements.push({ type: 'stat', - id: `stat-${index}`, + id: selectionElementId('stat', pos, index), index, }); } @@ -168,7 +169,7 @@ export function useGridMarquee({ if (isElementInMarquee(elementBounds, rect)) { newSelectedElements.push({ type: 'graph', - id: `graph-${index}`, + id: selectionElementId('graph', pos, index), index, }); } @@ -187,7 +188,7 @@ export function useGridMarquee({ if (isElementInMarquee(elementBounds, rect)) { newSelectedElements.push({ type: 'knob', - id: `knob-${index}`, + id: selectionElementId('knob', pos, index), index, }); } diff --git a/src/renderer/hooks/Grid/useGridResize.ts b/src/renderer/hooks/Grid/useGridResize.ts index 9d6faddb..c41c908e 100644 --- a/src/renderer/hooks/Grid/useGridResize.ts +++ b/src/renderer/hooks/Grid/useGridResize.ts @@ -11,6 +11,7 @@ import { calculateSnapPoints, calculateSizeSnap, } from '@utils/grid/smartGuides'; +import { selectionElementId } from '@stores/grid/useGridSelectionStore'; import type { SelectedElement } from '@stores/grid/useGridSelectionStore'; import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; import type { KeyPositions } from '@src/types/key/keys'; @@ -469,7 +470,14 @@ export function useGridResize({ handle?: ResizeHandle; }, ) => { - handleElementResizePreview(`key-${index}`, newBounds); + handleElementResizePreview( + selectionElementId( + 'key', + useKeyStore.getState().canonicalPositions[selectedKeyType]?.[index], + index, + ), + newBounds, + ); }; const handleStatResizePreview = ( @@ -482,7 +490,14 @@ export function useGridResize({ handle?: ResizeHandle; }, ) => { - handleElementResizePreview(`stat-${index}`, newBounds); + handleElementResizePreview( + selectionElementId( + 'stat', + useStatItemStore.getState().positions[selectedKeyType]?.[index], + index, + ), + newBounds, + ); }; const handleGraphResizePreview = ( @@ -495,7 +510,14 @@ export function useGridResize({ handle?: ResizeHandle; }, ) => { - handleElementResizePreview(`graph-${index}`, newBounds); + handleElementResizePreview( + selectionElementId( + 'graph', + useGraphItemStore.getState().positions[selectedKeyType]?.[index], + index, + ), + newBounds, + ); }; const handleKnobResizePreview = ( @@ -508,7 +530,14 @@ export function useGridResize({ handle?: ResizeHandle; }, ) => { - handleElementResizePreview(`knob-${index}`, newBounds); + handleElementResizePreview( + selectionElementId( + 'knob', + useKnobItemStore.getState().positions[selectedKeyType]?.[index], + index, + ), + newBounds, + ); }; // 플러그인 요소 리사이즈 처리 (스마트 가이드 포함) - 프리뷰 모드 diff --git a/src/renderer/hooks/Grid/useGridSelection.ts b/src/renderer/hooks/Grid/useGridSelection.ts index 1ebbf32f..09e98e59 100644 --- a/src/renderer/hooks/Grid/useGridSelection.ts +++ b/src/renderer/hooks/Grid/useGridSelection.ts @@ -5,6 +5,7 @@ * - 복사/붙여넣기 */ +import { newElementId } from '@src/renderer/editor/model/elementId'; import { useKeyStore } from '@stores/data/useKeyStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { useGraphItemStore } from '@stores/data/useGraphItemStore'; @@ -12,6 +13,7 @@ import { useKnobItemStore } from '@stores/data/useKnobItemStore'; import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; import { + selectionElementId, useGridSelectionStore, type SelectedElement, type ClipboardItem, @@ -646,6 +648,7 @@ export function useGridSelection({ keyCode: cloneSlot(item.keyCode), position: { ...item.position, + id: newElementId(), groupId: remapGroupId(item.position.groupId), dx: (item.position.dx || 0) + PASTE_OFFSET, dy: (item.position.dy || 0) + PASTE_OFFSET, @@ -655,6 +658,7 @@ export function useGridSelection({ statsToAdd.push({ position: { ...item.position, + id: newElementId(), groupId: remapGroupId(item.position.groupId), dx: (item.position.dx || 0) + PASTE_OFFSET, dy: (item.position.dy || 0) + PASTE_OFFSET, @@ -664,6 +668,7 @@ export function useGridSelection({ graphsToAdd.push({ position: { ...item.position, + id: newElementId(), groupId: remapGroupId(item.position.groupId), dx: (item.position.dx || 0) + PASTE_OFFSET, dy: (item.position.dy || 0) + PASTE_OFFSET, @@ -673,6 +678,7 @@ export function useGridSelection({ knobsToAdd.push({ position: { ...item.position, + id: newElementId(), groupId: remapGroupId(item.position.groupId), dx: (item.position.dx || 0) + PASTE_OFFSET, dy: (item.position.dy || 0) + PASTE_OFFSET, @@ -729,7 +735,11 @@ export function useGridSelection({ posArray.push(keysToAdd[i].position); newSelectedElements.push({ type: 'key', - id: `key-${startIndex + i}`, + id: selectionElementId( + 'key', + keysToAdd[i].position, + startIndex + i, + ), index: startIndex + i, }); } @@ -754,7 +764,11 @@ export function useGridSelection({ posArray.push(statsToAdd[i].position); newSelectedElements.push({ type: 'stat', - id: `stat-${startIndex + i}`, + id: selectionElementId( + 'stat', + statsToAdd[i].position, + startIndex + i, + ), index: startIndex + i, }); } @@ -776,7 +790,11 @@ export function useGridSelection({ posArray.push(graphsToAdd[i].position); newSelectedElements.push({ type: 'graph', - id: `graph-${startIndex + i}`, + id: selectionElementId( + 'graph', + graphsToAdd[i].position, + startIndex + i, + ), index: startIndex + i, }); } @@ -798,7 +816,11 @@ export function useGridSelection({ posArray.push(knobsToAdd[i].position); newSelectedElements.push({ type: 'knob', - id: `knob-${startIndex + i}`, + id: selectionElementId( + 'knob', + knobsToAdd[i].position, + startIndex + i, + ), index: startIndex + i, }); } diff --git a/src/renderer/hooks/Grid/useSmartGuidesElements.ts b/src/renderer/hooks/Grid/useSmartGuidesElements.ts index 7c7fe027..38407838 100644 --- a/src/renderer/hooks/Grid/useSmartGuidesElements.ts +++ b/src/renderer/hooks/Grid/useSmartGuidesElements.ts @@ -2,6 +2,7 @@ * 스마트 가이드를 위한 모든 요소의 bounds를 제공하는 훅 */ +import { selectionElementId } from '@stores/grid/useGridSelectionStore'; import { useKeyStore } from '@stores/data/useKeyStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { useGraphItemStore } from '@stores/data/useGraphItemStore'; @@ -35,7 +36,7 @@ const getOtherElementsSnapshot = ( const keyPositions = positions[selectedKeyType] || []; keyPositions.forEach((pos, index) => { if (pos.hidden) return; - const id = `key-${index}`; + const id = selectionElementId('key', pos, index); if (!excludeSet.has(id)) { bounds.push( calculateBounds(pos.dx, pos.dy, pos.width || 60, pos.height || 60, id), @@ -47,7 +48,7 @@ const getOtherElementsSnapshot = ( const stats = statPositions[selectedKeyType] || []; stats.forEach((pos, index) => { if (!pos || pos.hidden) return; - const id = `stat-${index}`; + const id = selectionElementId('stat', pos, index); if (!excludeSet.has(id)) { bounds.push( calculateBounds(pos.dx, pos.dy, pos.width || 60, pos.height || 60, id), @@ -59,7 +60,7 @@ const getOtherElementsSnapshot = ( const graphs = graphPositions[selectedKeyType] || []; graphs.forEach((pos, index) => { if (!pos || pos.hidden) return; - const id = `graph-${index}`; + const id = selectionElementId('graph', pos, index); if (!excludeSet.has(id)) { bounds.push( calculateBounds( @@ -77,7 +78,7 @@ const getOtherElementsSnapshot = ( const knobs = knobPositions[selectedKeyType] || []; knobs.forEach((pos, index) => { if (!pos || pos.hidden) return; - const id = `knob-${index}`; + const id = selectionElementId('knob', pos, index); if (!excludeSet.has(id)) { bounds.push( calculateBounds(pos.dx, pos.dy, pos.width || 60, pos.height || 60, id), diff --git a/src/renderer/stores/grid/useGridSelectionStore.idReconcile.test.ts b/src/renderer/stores/grid/useGridSelectionStore.idReconcile.test.ts new file mode 100644 index 00000000..ee8bce3f --- /dev/null +++ b/src/renderer/stores/grid/useGridSelectionStore.idReconcile.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + invalidateSelectionForChangedIndexedElementArrays, + selectionElementId, + useGridSelectionStore, +} from './useGridSelectionStore'; +import { createDefaultKeyPosition } from '@src/renderer/editor/model/keys'; + +import type { KeyPosition } from '@src/types/key/keys'; + +const arraysOf = (keyPositions: KeyPosition[]) => ({ + keyMappings: keyPositions.map(() => 'A'), + keyPositions, + stat: [] as unknown[], + graph: [] as unknown[], + knob: [] as unknown[], +}); + +describe('id 기반 선택 재조정', () => { + const a = createDefaultKeyPosition(); + const b = createDefaultKeyPosition(); + + beforeEach(() => { + useGridSelectionStore.setState({ selectedElements: [] }); + }); + + it('재정렬되면 선택이 같은 요소를 따라간다 (index 갱신, id 유지)', () => { + useGridSelectionStore + .getState() + .setSelectedElements([ + { type: 'key', id: selectionElementId('key', a, 0), index: 0 }, + ]); + + invalidateSelectionForChangedIndexedElementArrays( + arraysOf([a, b]), + arraysOf([b, a]), + ); + + const [selected] = useGridSelectionStore.getState().selectedElements; + expect(selected.id).toBe(a.id); + expect(selected.index).toBe(1); + }); + + it('요소가 삭제되면 그 선택만 풀린다', () => { + useGridSelectionStore.getState().setSelectedElements([ + { type: 'key', id: a.id!, index: 0 }, + { type: 'key', id: b.id!, index: 1 }, + ]); + + invalidateSelectionForChangedIndexedElementArrays( + arraysOf([a, b]), + arraysOf([b]), + ); + + const selected = useGridSelectionStore.getState().selectedElements; + expect(selected).toHaveLength(1); + expect(selected[0].id).toBe(b.id); + expect(selected[0].index).toBe(0); + }); + + it('변화가 없으면 선택 참조를 보존한다', () => { + useGridSelectionStore + .getState() + .setSelectedElements([{ type: 'key', id: a.id!, index: 0 }]); + const reference = useGridSelectionStore.getState().selectedElements; + + invalidateSelectionForChangedIndexedElementArrays( + arraysOf([a, b]), + arraysOf([a, b]), + ); + + expect(useGridSelectionStore.getState().selectedElements).toBe(reference); + }); +}); diff --git a/src/renderer/stores/grid/useGridSelectionStore.ts b/src/renderer/stores/grid/useGridSelectionStore.ts index 681a8d96..88ee5ed3 100644 --- a/src/renderer/stores/grid/useGridSelectionStore.ts +++ b/src/renderer/stores/grid/useGridSelectionStore.ts @@ -28,10 +28,21 @@ export interface IndexedElementArrays { export interface SelectedElement { type: SelectableElementType; - id: string; // key의 경우 "key-{index}", plugin의 경우 fullId - index?: number; // key인 경우 인덱스 + // 네이티브 요소는 position.id(UUID). backfill 전 데이터만 "type-{index}" 폴백. + // plugin은 fullId + id: string; + // canonical에서 파생된 locator 캐시. 신원이 아니다 - 문서 적용 시 id로 재계산된다 + index?: number; } +// 선택 id 생성의 단일 지점. 신원은 요소 안의 UUID이고, 합성 문자열은 +// id가 아직 없는 구형 데이터를 위한 폴백일 뿐이다 +export const selectionElementId = ( + type: IndexedSelectableElementType, + position: { id?: string } | undefined, + index: number, +): string => position?.id || `${type}-${index}`; + // 클립보드에 저장되는 키 데이터 export interface ClipboardKeyData { type: 'key'; @@ -305,9 +316,15 @@ export function reconcileSelectionAfterIndexedElementDeletion( } if (element.index < indexToDelete) return [element]; + // 신원(id)은 그대로, locator(index)만 한 칸 당긴다. + // UUID id는 유지되고 합성 폴백 id만 새 index로 재작성된다 changed = true; const index = element.index - 1; - return [{ ...element, id: `${elementType}-${index}`, index }]; + const id = + element.id === `${elementType}-${element.index}` + ? `${elementType}-${index}` + : element.id; + return [{ ...element, id, index }]; }); if (changed) selection.setSelectedElements(selectedElements); @@ -317,6 +334,17 @@ export function invalidateSelectionForChangedIndexedElementArrays( current: IndexedElementArrays, next: IndexedElementArrays, ) { + // id는 신원이지 값이 아니다. 이 경계 탐지는 "값이 같으면 같은 요소"라는 + // 단계 5 이전의 근사를 유지하므로 id를 지문에서 뺀다 - 넣으면 값이 동일한 + // 요소의 id 재발급만으로 앞쪽 선택이 무효화된다 + const withoutElementId = (item: unknown): unknown => { + if (typeof item !== 'object' || item === null || Array.isArray(item)) { + return item; + } + const { id: _id, ...rest } = item as { id?: unknown }; + return rest; + }; + const firstDifferentIndex = ( currentItems: readonly unknown[], nextItems: readonly unknown[], @@ -324,8 +352,8 @@ export function invalidateSelectionForChangedIndexedElementArrays( const sharedLength = Math.min(currentItems.length, nextItems.length); for (let index = 0; index < sharedLength; index += 1) { if ( - stableStringify(currentItems[index]) !== - stableStringify(nextItems[index]) + stableStringify(withoutElementId(currentItems[index])) !== + stableStringify(withoutElementId(nextItems[index])) ) { return index; } @@ -358,18 +386,48 @@ export function invalidateSelectionForChangedIndexedElementArrays( ); } } - if (boundaries.size === 0) return; - const selection = useGridSelectionStore.getState(); - const selectedElements = selection.selectedElements.filter((element) => { - if (element.type === 'plugin') return true; + if (selection.selectedElements.length === 0) return; + + const nextPositionsFor = ( + type: IndexedSelectableElementType, + ): readonly { id?: string }[] => + (type === 'key' ? next.keyPositions : next[type]) as readonly { + id?: string; + }[]; + + let changed = false; + const selectedElements = selection.selectedElements.flatMap((element) => { + if (element.type === 'plugin') return [element]; + + // 신원 id를 가진 선택은 id로 재조정한다: 살아 있으면 index만 갱신, + // 사라졌으면 제거. 재정렬돼도 선택은 같은 요소를 따라간다 + if (element.id !== `${element.type}-${element.index}`) { + const newIndex = nextPositionsFor(element.type).findIndex( + (position) => position?.id === element.id, + ); + if (newIndex === -1) { + changed = true; + return []; + } + if (newIndex !== element.index) { + changed = true; + return [{ ...element, index: newIndex }]; + } + return [element]; + } + + // 합성 id 폴백 (backfill 전 데이터): 기존 경계 휴리스틱 유지 const boundary = boundaries.get(element.type); - if (boundary === undefined) return true; - return typeof element.index === 'number' && element.index < boundary; + if (boundary === undefined) return [element]; + if (typeof element.index === 'number' && element.index < boundary) { + return [element]; + } + changed = true; + return []; }); - if (selectedElements.length !== selection.selectedElements.length) { - selection.setSelectedElements(selectedElements); - } + + if (changed) selection.setSelectedElements(selectedElements); } /** diff --git a/src/renderer/utils/layerGroupUtils.ts b/src/renderer/utils/layerGroupUtils.ts index c268aa5a..36d9c608 100644 --- a/src/renderer/utils/layerGroupUtils.ts +++ b/src/renderer/utils/layerGroupUtils.ts @@ -1,3 +1,4 @@ +import { selectionElementId } from '@stores/grid/useGridSelectionStore'; import type { SelectedElement } from '@stores/grid/useGridSelectionStore'; import type { KeyPositions } from '@src/types/key/keys'; import type { StatItemPositions } from '@src/types/key/statItems'; @@ -360,7 +361,7 @@ export function buildLayerItemsForMode( (keyPositions[mode] || []).forEach((pos, index) => { items.push({ type: 'key', - id: `key-${index}`, + id: selectionElementId('key', pos, index), index, zIndex: pos.zIndex ?? index, groupId: pos.groupId, @@ -370,7 +371,7 @@ export function buildLayerItemsForMode( (statPositions[mode] || []).forEach((pos, index) => { items.push({ type: 'stat', - id: `stat-${index}`, + id: selectionElementId('stat', pos, index), index, zIndex: pos.zIndex ?? index, groupId: pos.groupId, @@ -380,7 +381,7 @@ export function buildLayerItemsForMode( (graphPositions[mode] || []).forEach((pos, index) => { items.push({ type: 'graph', - id: `graph-${index}`, + id: selectionElementId('graph', pos, index), index, zIndex: pos.zIndex ?? index, groupId: pos.groupId, @@ -390,7 +391,7 @@ export function buildLayerItemsForMode( (knobPositions[mode] || []).forEach((pos, index) => { items.push({ type: 'knob', - id: `knob-${index}`, + id: selectionElementId('knob', pos, index), index, zIndex: pos.zIndex ?? index, groupId: pos.groupId, From db94a947a6f1256a3e7449edf8d23a77b764f558 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 11 Aug 2026 21:53:48 +0900 Subject: [PATCH 03/35] =?UTF-8?q?fix:=20=EC=BB=A4=EB=B0=8B=20wire=20?= =?UTF-8?q?=EB=B2=84=EC=A0=84=EC=9D=84=20=EC=A0=84=EC=86=A1=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=EA=B0=80=20=EA=B2=B0=EC=A0=95=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=9D=BC=EC=9B=90=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../editor/runtime/editorCoordinator.test.ts | 53 +++++++++++++++++-- .../editor/runtime/editorCoordinator.ts | 24 +++++++-- .../editor/runtime/editorStateCoordinator.ts | 1 + .../runtime/api/pluginWriteGateway.test.ts | 52 ++++++++++++++++++ .../plugins/runtime/api/pluginWriteGateway.ts | 13 +++-- src/types/editor.ts | 25 ++++++++- src/types/plugin/api.ts | 4 +- 7 files changed, 157 insertions(+), 15 deletions(-) diff --git a/src/renderer/editor/runtime/editorCoordinator.test.ts b/src/renderer/editor/runtime/editorCoordinator.test.ts index 175550d7..26732939 100644 --- a/src/renderer/editor/runtime/editorCoordinator.test.ts +++ b/src/renderer/editor/runtime/editorCoordinator.test.ts @@ -411,6 +411,47 @@ describe('EditorSaveCoordinator', () => { harness.coordinator.stop(); }); + it('stamps the wire schema version by transport path', async () => { + const base = makeDocument('A'); + const harness = createHarness(base); + await harness.coordinator.start(); + + // 자사 일반 커밋 wire는 v2 - 호출부 패치 버전과 무관하게 경로가 결정 + await harness.coordinator.commitPatch({ + schemaVersion: 1, + keys: { '4key': ['B'] }, + }); + expect( + harness.transport.commitMock.mock.calls.at(-1)?.[0].changes.schemaVersion, + ).toBe(2); + + // 게스처 커밋 wire도 v2 + let gestureWireVersion: number | undefined; + await harness.coordinator.commitGesture( + { schemaVersion: 1, keys: { '4key': ['C'] } }, + 'gesture-wire', + async (context) => { + gestureWireVersion = context.editorChanges?.schemaVersion; + return harness.transport.commit({ + baseRevision: context.editorBaseRevision, + mutationId: context.mutationId, + changes: context.editorChanges!, + }); + }, + ); + expect(gestureWireVersion).toBe(2); + + // 플러그인 격리 커밋 wire는 v1 유지 (레거시 패치 수용 경계) + await harness.coordinator.commitIsolatedPluginPatch( + { schemaVersion: 1, keys: { '4key': ['D'] } }, + { multiKey: false }, + ); + expect( + harness.transport.commitMock.mock.calls.at(-1)?.[0].changes.schemaVersion, + ).toBe(1); + harness.coordinator.stop(); + }); + it('keeps queued mixed gestures as separate ordered transactions', async () => { const base = makeDocument('A'); const harness = createHarness(base); @@ -476,6 +517,8 @@ describe('EditorSaveCoordinator', () => { it('resyncs canonical state when a gesture result is outcome-unknown', async () => { const base = makeDocument('A'); const target = makeDocument('B'); + // 키만 다른 문서다. positions id까지 갈리면 resync 판정과 무관한 차이가 섞인다 + target.keyPositions = structuredClone(base.keyPositions); const harness = createHarness(base); await harness.coordinator.start(); @@ -699,7 +742,7 @@ describe('EditorSaveCoordinator', () => { expect(harness.transport.commitMock).toHaveBeenCalledTimes(2), ); expect(harness.transport.commitMock.mock.calls[1][0].changes).toEqual({ - schemaVersion: 1, + schemaVersion: 2, statPositions, graphPositions, knobPositions, @@ -1018,7 +1061,7 @@ describe('EditorSaveCoordinator', () => { const retried = harness.transport.commitMock.mock.calls[1][0]; expect(retried.baseRevision).toBe(1); expect(retried.changes).toEqual({ - schemaVersion: 1, + schemaVersion: 2, keys: { '4key': ['L'] }, }); expect(harness.coordinator.getState().conflict).toBeNull(); @@ -1083,7 +1126,7 @@ describe('EditorSaveCoordinator', () => { expect(harness.getLocal()).toEqual(expected); expect(harness.transport.commitMock.mock.calls[1][0]).toMatchObject({ baseRevision: 1, - changes: { schemaVersion: 1, keys: { '4key': ['L'] } }, + changes: { schemaVersion: 2, keys: { '4key': ['L'] } }, }); harness.coordinator.stop(); }); @@ -1265,7 +1308,7 @@ describe('EditorSaveCoordinator', () => { expect(result).toEqual(base); expect(harness.transport.commitMock).toHaveBeenCalledOnce(); expect(harness.transport.commitMock.mock.calls[0][0].changes).toEqual({ - schemaVersion: 1, + schemaVersion: 2, keys: base.keys, }); expect(harness.coordinator.getState()).toMatchObject({ @@ -1292,7 +1335,7 @@ describe('EditorSaveCoordinator', () => { }); expect(harness.transport.commitMock.mock.calls[0][0].changes).toEqual({ - schemaVersion: 1, + schemaVersion: 2, keys: { '4key': ['B'] }, }); expect(result.graphPositions).toEqual(base.graphPositions); diff --git a/src/renderer/editor/runtime/editorCoordinator.ts b/src/renderer/editor/runtime/editorCoordinator.ts index a632ad27..0c91660e 100644 --- a/src/renderer/editor/runtime/editorCoordinator.ts +++ b/src/renderer/editor/runtime/editorCoordinator.ts @@ -1,6 +1,7 @@ import { stableStringify } from '@utils/core/stableStringify'; import { + EDITOR_COMMIT_SCHEMA_VERSION, EDITOR_FIELDS, EDITOR_SCHEMA_VERSION, assertEditorCommitResult, @@ -147,11 +148,14 @@ const unresolvedLocalFields = ( stableStringify(canonical[field]), ); +// wire 버전은 전송 경로가 결정한다. 호출부 패치의 schemaVersion은 문서 적용 +// 과정에서 소비되어 여기까지 오지 않으므로, 자사 전송 지점만 v2를 명시한다 const patchForFields = ( document: EditorDocumentV1, fields: readonly EditorField[], + schemaVersion: EditorPatchV1['schemaVersion'] = EDITOR_SCHEMA_VERSION, ): EditorPatchV1 => { - const patch: EditorPatchV1 = { schemaVersion: EDITOR_SCHEMA_VERSION }; + const patch: EditorPatchV1 = { schemaVersion }; fields.forEach((field) => { Object.assign(patch, { [field]: clone(document[field]) }); }); @@ -489,6 +493,8 @@ export class EditorSaveCoordinator { const result = await this.transport.commit({ baseRevision, mutationId, + // 플러그인 격리 커밋은 v1 유지 - ID 없는 레거시 패치를 백엔드 + // adapter가 수용한다 (계약 §10) changes: patchForFields(target, requestFields), // provenance 명시 전달 - 기본값 승격 경로 없음 multiKey: options.multiKey === true, @@ -772,7 +778,12 @@ export class EditorSaveCoordinator { const result = await this.transport.commit({ baseRevision, mutationId, - changes: patchForFields(target, requestFields), + // 자사 커밋은 v2 - 백엔드가 ID 필수와 merged 유일성을 검증한다 + changes: patchForFields( + target, + requestFields, + EDITOR_COMMIT_SCHEMA_VERSION, + ), ...(gestureId ? { gestureId } : {}), ...(gestureIds.length > 0 ? { gestureIds } : {}), }); @@ -877,8 +888,15 @@ export class EditorSaveCoordinator { const result = await commit({ editorBaseRevision: inFlight.baseRevision, mutationId, + // 게스처 커밋도 자사 전용 경로라 v2 ...(requestFields.length > 0 - ? { editorChanges: patchForFields(target, requestFields) } + ? { + editorChanges: patchForFields( + target, + requestFields, + EDITOR_COMMIT_SCHEMA_VERSION, + ), + } : {}), }); assertEditorCommitResult(result); diff --git a/src/renderer/editor/runtime/editorStateCoordinator.ts b/src/renderer/editor/runtime/editorStateCoordinator.ts index 1f3d2e9e..f604e8a0 100644 --- a/src/renderer/editor/runtime/editorStateCoordinator.ts +++ b/src/renderer/editor/runtime/editorStateCoordinator.ts @@ -48,6 +48,7 @@ const ensurePreviewSubscription = (): Promise => { export const captureEditorDocument = (): EditorDocumentV1 => { const keyState = useKeyStore.getState(); return { + // 문서 스키마는 v1 유지. v2는 쓰기(commit) 전용 버전이다 schemaVersion: 1, keys: keyState.keyMappings, // 프리뷰가 섞이지 않은 canonical만 문서로 캡처 diff --git a/src/renderer/plugins/runtime/api/pluginWriteGateway.test.ts b/src/renderer/plugins/runtime/api/pluginWriteGateway.test.ts index 4e611fed..ff71bd72 100644 --- a/src/renderer/plugins/runtime/api/pluginWriteGateway.test.ts +++ b/src/renderer/plugins/runtime/api/pluginWriteGateway.test.ts @@ -16,9 +16,11 @@ vi.mock('@src/renderer/editor/runtime/editorStateCoordinator', () => ({ vi.mock('@api/modules/editorApi', () => ({ editorCommitRaw })); import { + pluginEditorCommit, pluginKeysUpdate, pluginKeysUpdateWithPositions, } from './pluginWriteGateway'; +import type { PluginEditorCommitRequest } from '@src/types/editor'; import type { KeyMappings, KeyPositions } from '@src/types/key/keys'; describe('pluginWriteGateway', () => { @@ -82,4 +84,54 @@ describe('pluginWriteGateway', () => { { multiKey: false }, ); }); + + // 자사 wire가 v2로 옮겨가도 raw plugin envelope는 재직렬화 없이 + // 선언된 버전 그대로 백엔드에 도달해야 한다 + it('forwards a raw editor commit envelope without reserialization', async () => { + editorCommitRaw.mockReset(); + editorCommitRaw.mockResolvedValue({ revision: 2, changedFields: [] }); + const request = { + baseRevision: 1, + mutationId: '00000000-0000-4000-8000-000000000001', + changes: { schemaVersion: 1, statPositions: {} }, + } as unknown as PluginEditorCommitRequest; + + await pluginEditorCommit(request); + + expect(editorCommitRaw).toHaveBeenCalledTimes(1); + // 같은 참조가 무가공 전달된다 (버전 재작성 지점 자체가 없음) + expect(editorCommitRaw.mock.calls[0][0]).toBe(request); + }); + + // commit wire v2는 자사 내부 전용 - 플러그인 경계는 v1만 통과해야 한다 + it('rejects a v2 envelope at the plugin boundary', async () => { + editorCommitRaw.mockReset(); + const request = { + baseRevision: 1, + mutationId: '00000000-0000-4000-8000-000000000003', + changes: { schemaVersion: 2, statPositions: {} }, + } as unknown as PluginEditorCommitRequest; + + await expect(pluginEditorCommit(request)).rejects.toThrow(TypeError); + expect(editorCommitRaw).not.toHaveBeenCalled(); + }); + + it('serializes keys-bearing raw commits but keeps the envelope untouched', async () => { + editorCommitRaw.mockReset(); + editorCommitRaw.mockResolvedValue({ revision: 2, changedFields: [] }); + runSerializedPluginCommit.mockReset(); + runSerializedPluginCommit.mockImplementation( + (run: () => Promise) => run(), + ); + const request = { + baseRevision: 1, + mutationId: '00000000-0000-4000-8000-000000000002', + changes: { schemaVersion: 1, keys: { '4key': ['Z'] } }, + } as unknown as PluginEditorCommitRequest; + + await pluginEditorCommit(request); + + expect(runSerializedPluginCommit).toHaveBeenCalledTimes(1); + expect(editorCommitRaw.mock.calls[0][0]).toBe(request); + }); }); diff --git a/src/renderer/plugins/runtime/api/pluginWriteGateway.ts b/src/renderer/plugins/runtime/api/pluginWriteGateway.ts index 066001b4..277b2b2f 100644 --- a/src/renderer/plugins/runtime/api/pluginWriteGateway.ts +++ b/src/renderer/plugins/runtime/api/pluginWriteGateway.ts @@ -7,9 +7,10 @@ import { editorCommitRaw } from '@api/modules/editorApi'; import { editorCoordinator } from '@src/renderer/editor/runtime/editorStateCoordinator'; +import { EDITOR_SCHEMA_VERSION } from '@src/types/editor'; import type { - EditorCommitRequest, EditorCommitResult, + PluginEditorCommitRequest, } from '@src/types/editor'; import { normalizeSlot } from '@utils/keySlot'; import type { KeyMappings, KeyPositions } from '@src/types/key/keys'; @@ -79,10 +80,14 @@ export const pluginKeysUpdateWithPositions = async ( // 플러그인의 직접 editor_commit. keys를 포함하면 coordinator 큐로 직렬화해 // 예약된 자사 변경보다 먼저 lock을 잡는 경합을 차단하고, envelope는 무가공 // 전달 (multiKey는 플러그인이 선언한 값만 백엔드 게이트에 도달) -export const pluginEditorCommit = ( - request: EditorCommitRequest, +export const pluginEditorCommit = async ( + request: PluginEditorCommitRequest, ): Promise => { - if (request?.changes?.keys !== undefined) { + // commit wire v2는 자사 내부 전용 - 플러그인 경계는 v1만 통과 (fail-closed) + if (request?.changes?.schemaVersion !== EDITOR_SCHEMA_VERSION) { + throw new TypeError('editor.commit changes.schemaVersion must be 1'); + } + if (request.changes.keys !== undefined) { return editorCoordinator.runSerializedPluginCommit(() => editorCommitRaw(request), ); diff --git a/src/types/editor.ts b/src/types/editor.ts index f1250351..eb6f1eed 100644 --- a/src/types/editor.ts +++ b/src/types/editor.ts @@ -15,6 +15,11 @@ import { canonicalizePositionGradients } from '@src/types/color'; export const EDITOR_SCHEMA_VERSION = 1 as const; +// 쓰기(commit) 전용 버전. 문서(editor_get)와 이벤트(editor:committed)는 v1을 +// 유지하고 id를 additive로 싣는다. v2 커밋은 포함된 모든 위치 항목에 유효 ID가 +// 필수라 백엔드가 형식·전역 유일성을 강제한다. 구형 플러그인 gateway만 v1로 남는다 +export const EDITOR_COMMIT_SCHEMA_VERSION = 2 as const; + export const EDITOR_FIELDS = [ 'keys', 'keyPositions', @@ -37,6 +42,14 @@ export interface EditorDocumentV1 { } export type EditorPatchV1 = { + schemaVersion: + | typeof EDITOR_SCHEMA_VERSION + | typeof EDITOR_COMMIT_SCHEMA_VERSION; +} & Partial>; + +// 플러그인 공개 표면 전용 패치. commit wire v2(ID 필수 검증)는 자사 내부 +// 전용이라 플러그인 경계에는 v1만 노출한다 - durable plugin ID는 별도 릴리스 +export type EditorLegacyPatchV1 = { schemaVersion: typeof EDITOR_SCHEMA_VERSION; } & Partial>; @@ -53,6 +66,12 @@ export interface EditorCommitRequest { multiKey?: boolean; } +// 플러그인 dmn.editor.commit 요청. changes만 v1으로 좁힌다 +export interface PluginEditorCommitRequest + extends Omit { + changes: EditorLegacyPatchV1; +} + export interface EditorCommitResult { revision: number; changedFields: EditorField[]; @@ -473,7 +492,11 @@ export function assertEditorPatch( value: unknown, label = 'editor patch', ): asserts value is EditorPatchV1 { - if (!isRecord(value) || value.schemaVersion !== EDITOR_SCHEMA_VERSION) { + if ( + !isRecord(value) || + (value.schemaVersion !== EDITOR_SCHEMA_VERSION && + value.schemaVersion !== EDITOR_COMMIT_SCHEMA_VERSION) + ) { throw new EditorProtocolError(`${label} has an unsupported schema version`); } const unknownKey = Object.keys(value).find( diff --git a/src/types/plugin/api.ts b/src/types/plugin/api.ts index 0fe7e45a..6a5fcf98 100644 --- a/src/types/plugin/api.ts +++ b/src/types/plugin/api.ts @@ -13,10 +13,10 @@ import type { GraphItemPositions } from '@src/types/key/graphItems'; import type { KnobItemPositions } from '@src/types/key/knobs'; import type { LayerGroups } from '@src/types/layerGroups'; import type { - EditorCommitRequest, EditorCommitResult, EditorCommittedV1, EditorGetResult, + PluginEditorCommitRequest, } from '@src/types/editor'; import { SettingsDiff, @@ -910,7 +910,7 @@ export interface DMNoteAPI { }; editor: { get(): Promise; - commit(request: EditorCommitRequest): Promise; + commit(request: PluginEditorCommitRequest): Promise; onCommitted(listener: (event: EditorCommittedV1) => void): ReadyUnsubscribe; }; keys: { From 81129d883eb2e022c7e6ce4cd8fc76f4fb9412a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 11 Aug 2026 21:54:12 +0900 Subject: [PATCH 04/35] =?UTF-8?q?fix:=20=EB=B9=84=EB=8F=99=EA=B8=B0=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=EB=A5=BC=20ID=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=EC=9E=90=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/counterAnimationMerge.test.ts | 71 +++++++ .../singleStyleAsyncCompletion.test.tsx | 201 ++++++++++++++++++ .../single/CounterTabContent.tsx | 29 ++- .../single/SingleSelectionPanel.tsx | 49 +++-- .../single/StyleTabContent.tsx | 35 ++- ...ounterAnimationPicker.editSession.test.tsx | 21 +- .../pickers/CounterAnimationPicker.tsx | 12 +- .../pickers/ImagePicker.editSession.test.tsx | 20 +- .../Modal/content/pickers/ImagePicker.tsx | 12 +- .../pickers/SoundPicker.editSession.test.tsx | 34 ++- .../Modal/content/pickers/SoundPicker.tsx | 18 +- src/renderer/contexts/EditSessionScope.tsx | 20 ++ .../editor/runtime/elementPatch.test.ts | 156 ++++++++++++++ src/renderer/editor/runtime/elementPatch.ts | 129 +++++++++++ src/types/key/counterAnimation.ts | 23 ++ 15 files changed, 791 insertions(+), 39 deletions(-) create mode 100644 src/renderer/__tests__/counterAnimationMerge.test.ts create mode 100644 src/renderer/__tests__/singleStyleAsyncCompletion.test.tsx create mode 100644 src/renderer/editor/runtime/elementPatch.test.ts create mode 100644 src/renderer/editor/runtime/elementPatch.ts diff --git a/src/renderer/__tests__/counterAnimationMerge.test.ts b/src/renderer/__tests__/counterAnimationMerge.test.ts new file mode 100644 index 00000000..c670aa3c --- /dev/null +++ b/src/renderer/__tests__/counterAnimationMerge.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { mergeChangedAnimationFields } from '@src/types/key/counterAnimation'; + +import type { KeyCounterAnimationSettings } from '@src/types/key/keys'; + +const animation = ( + overrides: Partial = {}, +): KeyCounterAnimationSettings => ({ + enabled: true, + presetId: 'preset-a', + bezier: [0.25, 0.1, 0.25, 1], + scale: 1.1, + durationMs: 300, + ...overrides, +}); + +// 비동기 완료가 시작 스냅샷을 통째로 되돌리지 않는지 고정. +// 대기 중 다른 writer가 바꾼 필드는 fresh 값이 살아남아야 한다 +describe('mergeChangedAnimationFields', () => { + it('피커가 바꾼 필드만 fresh 위에 적용한다', () => { + const start = animation(); + // 대기 중 다른 writer가 enabled를 끔 + const fresh = animation({ enabled: false }); + // 피커는 preset 필드만 변경 + const next = animation({ + presetId: 'preset-b', + bezier: [0.4, 0, 0.2, 1], + scale: 1.3, + durationMs: 500, + }); + + const merged = mergeChangedAnimationFields(fresh, start, next); + + expect(merged.enabled).toBe(false); + expect(merged.presetId).toBe('preset-b'); + expect(merged.bezier).toEqual([0.4, 0, 0.2, 1]); + expect(merged.scale).toBe(1.3); + expect(merged.durationMs).toBe(500); + }); + + it('피커가 바꾼 필드는 동시 변경보다 우선한다', () => { + const start = animation({ durationMs: 300 }); + const fresh = animation({ durationMs: 400 }); + const next = animation({ durationMs: 500 }); + + const merged = mergeChangedAnimationFields(fresh, start, next); + + expect(merged.durationMs).toBe(500); + }); + + it('아무것도 안 바뀌면 fresh를 그대로 돌려준다', () => { + const start = animation(); + const fresh = animation({ enabled: false, scale: 2 }); + + const merged = mergeChangedAnimationFields(fresh, start, animation()); + + expect(merged).toEqual(fresh); + }); + + // preset 매칭용 epsilon(0.001)을 변경 감지에 재사용하면 이런 미세 드래그가 + // 무변경으로 오판된다 - 정확 비교를 고정 + it('epsilon보다 작은 bezier 변경도 적용한다', () => { + const start = animation({ bezier: [0.25, 0.1, 0.25, 1] }); + const fresh = animation({ bezier: [0.25, 0.1, 0.25, 1] }); + const next = animation({ bezier: [0.2505, 0.1, 0.25, 1] }); + + const merged = mergeChangedAnimationFields(fresh, start, next); + + expect(merged.bezier).toEqual([0.2505, 0.1, 0.25, 1]); + }); +}); diff --git a/src/renderer/__tests__/singleStyleAsyncCompletion.test.tsx b/src/renderer/__tests__/singleStyleAsyncCompletion.test.tsx new file mode 100644 index 00000000..460a408e --- /dev/null +++ b/src/renderer/__tests__/singleStyleAsyncCompletion.test.tsx @@ -0,0 +1,201 @@ +// @vitest-environment jsdom +import React, { act, createRef } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, + type Mock, +} from 'vitest'; +import type { KeyPosition, KeyPositions } from '@src/types/key/keys'; +import { createDefaultKeyPosition } from '@src/renderer/editor/model/keys'; + +const api = vi.hoisted(() => ({ + updatePositionsWithGesture: vi.fn( + async (_positions: KeyPositions, _gestureId?: string) => ({}), + ), + updateMappingsAndPositionsWithGesture: vi.fn(async () => ({})), +})); + +vi.mock('@api/modules/keysApi', () => ({ + updatePositionsWithGesture: api.updatePositionsWithGesture, + updateMappingsAndPositionsWithGesture: + api.updateMappingsAndPositionsWithGesture, +})); +vi.mock('@api/modules/editorApi', () => ({ + editorApi: { + get: vi.fn(), + commit: vi.fn(), + onCommitted: vi.fn(() => + Object.assign(() => {}, { ready: Promise.resolve() }), + ), + }, +})); +vi.mock('@api/modules/previewApi', () => ({ + previewApi: { + cancel: vi.fn(async () => {}), + publish: vi.fn(async () => {}), + subscribe: vi.fn(async () => 1), + }, +})); +vi.mock('@contexts/useTranslation', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock('@components/main/Grid/PropertiesPanel/PickerSurface', () => ({ + default: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); +vi.mock('@components/main/common/Checkbox', () => ({ default: () => null })); +vi.mock('@components/main/common/Dropdown', () => ({ default: () => null })); +vi.mock('@components/main/common/TabSwitch', () => ({ default: () => null })); +vi.mock('@components/main/Modal/content/pickers/ColorPicker', () => ({ + default: () => null, +})); +vi.mock('@components/main/Grid/PropertiesPanel/ShadowControls', () => ({ + default: () => null, +})); +vi.mock('@components/main/Modal/PopupExit', () => ({ + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +import { EditSessionScope } from '@src/renderer/contexts/EditSessionScope'; +import { PanelNavProvider } from '@components/main/Grid/PropertiesPanel/PanelNavContext'; +import { useKeyStore } from '@stores/data/useKeyStore'; +import StyleTabContent from '@components/main/Grid/PropertiesPanel/single/StyleTabContent'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const ID_TARGET = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const ID_OTHER = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; + +const keyAt = (id: string) => + ({ ...createDefaultKeyPosition(), id } as KeyPosition); + +const navValue = { + activePageKey: null, + renderPageKey: null, + openPage: vi.fn(), + closePage: vi.fn(), + pageHost: null, +}; + +// 실호출부 통합: StyleTabContent가 completionBinding을 선언하고, 대기 중 +// 재정렬·모드 전환이 일어나도 완료가 원 모드의 현재 index에 적용되는지 고정 +describe('단일 스타일 패널 비동기 이미지 완료', () => { + let host: HTMLDivElement; + let root: Root; + let resolveLoad: (value: unknown) => void; + let onKeyUpdate: Mock< + (data: Partial & { index: number }) => void + >; + + beforeEach(() => { + vi.clearAllMocks(); + onKeyUpdate = vi.fn(); + useKeyStore.setState({ + selectedKeyType: '4key', + canonicalPositions: { '4key': [keyAt(ID_OTHER), keyAt(ID_TARGET)] }, + positions: { '4key': [keyAt(ID_OTHER), keyAt(ID_TARGET)] }, + }); + window.api = { + image: { + load: vi.fn( + () => + new Promise((resolve) => { + resolveLoad = resolve; + }), + ), + }, + } as never; + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + }); + + afterEach(() => { + act(() => root.unmount()); + host.remove(); + }); + + const mountPanel = () => { + const target = useKeyStore.getState().canonicalPositions['4key'][1]; + act(() => { + root.render( + + + ()} + panelElement={null} + t={(key) => key} + /> + + , + ); + }); + }; + + const clickPreview = () => { + const overlay = host.querySelector( + 'div.absolute.inset-0.bg-black', + )!; + act(() => overlay.click()); + }; + + const finishLoad = async () => { + await act(async () => { + resolveLoad({ success: true, imagePath: '/tmp/picked.png' }); + await Promise.resolve(); + }); + }; + + it('대기 중 재정렬·모드 전환에도 원 모드의 현재 index에 적용한다', async () => { + mountPanel(); + clickPreview(); + + // 대기 중 재정렬 (대상이 index 1 -> 0) + 보는 모드 전환 + act(() => { + const [other, target] = useKeyStore.getState().canonicalPositions['4key']; + useKeyStore.getState().setPositions({ '4key': [target, other] }); + useKeyStore.setState({ selectedKeyType: '8key' }); + }); + await finishLoad(); + + expect(api.updatePositionsWithGesture).toHaveBeenCalledTimes(1); + const persisted = api.updatePositionsWithGesture.mock.calls[0][0]; + expect(persisted['4key'][0].id).toBe(ID_TARGET); + expect(persisted['4key'][0].inactiveImage).toBe('/tmp/picked.png'); + expect(persisted['4key'][1].inactiveImage ?? '').toBe(''); + // 레거시 index writer는 우회된다 + expect(onKeyUpdate).not.toHaveBeenCalled(); + // wire에 gestureId 없음 + expect(api.updatePositionsWithGesture.mock.calls[0][1]).toBeUndefined(); + }); + + it('대기 중 요소가 삭제되면 아무것도 쓰지 않는다', async () => { + mountPanel(); + clickPreview(); + + act(() => { + const [other] = useKeyStore.getState().canonicalPositions['4key']; + useKeyStore.getState().setPositions({ '4key': [other] }); + }); + await finishLoad(); + + expect(api.updatePositionsWithGesture).not.toHaveBeenCalled(); + expect(onKeyUpdate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/CounterTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/CounterTabContent.tsx index c1167fe1..156f6af8 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/CounterTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/CounterTabContent.tsx @@ -24,6 +24,8 @@ import { ColorSwatchButton } from '@components/main/Modal/content/pickers/ColorS import { DEFAULT_COUNTER_FONT_SIZE } from '@utils/core/elementDefaults'; import { useGradientColorState } from '@hooks/pickers/useGradientColorState'; import { useKeyStore } from '@stores/data/useKeyStore'; +import { applyElementPatchById } from '@src/renderer/editor/runtime/elementPatch'; +import { mergeChangedAnimationFields } from '@src/types/key/counterAnimation'; import { counterFillPair, gradientToCss, @@ -103,10 +105,34 @@ const CounterTabContent: React.FC = ({ onKeyUpdate({ index: keyIndex, counter: newSettings }); }; + // 모션 편집기를 기다린 비동기 완료. 대기 중 재정렬·모드 전환이 일어나도 + // id로 현재 (mode, index)를 다시 찾아 적용하고, 병합 base는 완료 시점의 + // 현재 값으로 읽는다. animation도 시작 스냅샷 대비 바뀐 필드만 얹어 대기 중 + // 다른 writer의 변경(enabled 등)을 되돌리지 않는다. + // 시작 type으로만 조회해 type이 옮겨졌으면 조용히 중단한다 const handleAnimationUpdate = ( nextAnimation: KeyCounterAnimationSettings, ) => { - handleCounterUpdate({ animation: nextAnimation }); + const id = keyPosition.id; + if (!id) { + handleCounterUpdate({ animation: nextAnimation }); + return; + } + const startType = isStat ? ('stat' as const) : ('key' as const); + const startAnimation = counterSettings.animation; + applyElementPatchById(startType, id, (current) => { + const settings = normalizeCounterSettings(current.counter); + return { + counter: { + ...settings, + animation: mergeChangedAnimationFields( + settings.animation, + startAnimation, + nextAnimation, + ), + }, + }; + }); }; const handlePickerToggle = (target: Exclude) => { @@ -536,6 +562,7 @@ const CounterTabContent: React.FC = ({ createPortal( = ({ useCustomCSS, t, }) => { + // 이미지 대화상자 완료 전용. 대기 중 재정렬·모드 전환이 일어나도 id로 + // 현재 (mode, index)를 다시 찾아 적용하고, 삭제됐으면 조용히 중단한다 + const applyToGraphById = (patch: Omit, 'id'>) => { + const id = singleGraphPosition.id; + if (!id) { + handleGraphUpdate({ index: singleGraphIndex, ...patch }); + return; + } + applyElementPatchById('graph', id, () => patch); + }; + const graphShapeOptions = [ { label: t('propertiesPanel.graphShapeLine') || 'Line', value: 'line' }, { label: t('propertiesPanel.graphShapeBar') || 'Bar', value: 'bar' }, @@ -720,6 +732,9 @@ export const SingleGraphPanel: React.FC = ({ referenceRef={graphImageButtonRef} panelElement={panelElement} showActiveState={false} + completionBinding={ + singleGraphPosition.id ? 'element-id' : 'session-mode' + } idleImage={singleGraphPosition.inactiveImage || ''} activeImage={singleGraphPosition.activeImage || ''} idleTransparent={false} @@ -735,16 +750,10 @@ export const SingleGraphPanel: React.FC = ({ 'cover' } onIdleImageChange={(imageUrl: string) => - handleGraphUpdate({ - index: singleGraphIndex, - inactiveImage: imageUrl, - }) + applyToGraphById({ inactiveImage: imageUrl }) } onActiveImageChange={(imageUrl: string) => - handleGraphUpdate({ - index: singleGraphIndex, - activeImage: imageUrl, - }) + applyToGraphById({ activeImage: imageUrl }) } onIdleTransparentChange={(value: boolean) => handleGraphUpdate({ @@ -835,6 +844,17 @@ export const SingleKnobPanel: React.FC = ({ useCustomCSS, t, }) => { + // 이미지 대화상자 완료 전용. 대기 중 재정렬·모드 전환이 일어나도 id로 + // 현재 (mode, index)를 다시 찾아 적용하고, 삭제됐으면 조용히 중단한다 + const applyToKnobById = (patch: Omit, 'id'>) => { + const id = singleKnobPosition.id; + if (!id) { + handleKnobUpdate({ index: singleKnobIndex, ...patch }); + return; + } + applyElementPatchById('knob', id, () => patch); + }; + const panelRef = useRef(null); const imageButtonRef = useRef(null); const [showImagePicker, setShowImagePicker] = useState(false); @@ -1441,6 +1461,9 @@ export const SingleKnobPanel: React.FC = ({ open={showImagePicker} referenceRef={imageButtonRef} panelElement={panelRef.current} + completionBinding={ + singleKnobPosition.id ? 'element-id' : 'session-mode' + } idleImage={singleKnobPosition.inactiveImage || ''} activeImage={singleKnobPosition.activeImage || ''} idleTransparent={singleKnobPosition.idleTransparent ?? false} @@ -1456,16 +1479,10 @@ export const SingleKnobPanel: React.FC = ({ 'cover' } onIdleImageChange={(imageUrl: string) => - handleKnobUpdate({ - index: singleKnobIndex, - inactiveImage: imageUrl, - }) + applyToKnobById({ inactiveImage: imageUrl }) } onActiveImageChange={(imageUrl: string) => - handleKnobUpdate({ - index: singleKnobIndex, - activeImage: imageUrl, - }) + applyToKnobById({ activeImage: imageUrl }) } onIdleTransparentChange={(value: boolean) => handleKnobUpdate({ diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/StyleTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/StyleTabContent.tsx index 67f3dae0..628b8f9a 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/StyleTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/StyleTabContent.tsx @@ -22,6 +22,8 @@ import { } from '../PropertyInputs'; import { usePanelNav } from '../PanelNavContext'; import { useKeyStore } from '@stores/data/useKeyStore'; +import { resolveElementByIdAcross } from '@src/renderer/editor/model/elementIdMap'; +import { applyElementPatchById } from '@src/renderer/editor/runtime/elementPatch'; import ImagePicker from '../../../Modal/content/pickers/ImagePicker'; import ColorPicker from '../../../Modal/content/pickers/ColorPicker'; import PopupExit from '@components/main/Modal/PopupExit'; @@ -580,15 +582,35 @@ const StyleTabContent: React.FC = ({ onKeyUpdate({ index: keyIndex, [property]: value }); }; + // 작업 시작(=이 렌더) 시점의 소속 컬렉션. in-flight 클로저에 함께 캡처된다. + // 이 패널은 writer가 key/stat 어느 쪽인지 모르므로 시작 시점 소속으로 고정한다 + const boundElementType = keyPosition.id + ? resolveElementByIdAcross(['key', 'stat'], keyPosition.id)?.type ?? null + : null; + + // 비동기 완료 전용 적용자. 파일 대화상자·모달을 기다리는 사이 배열 재정렬이나 + // 모드 전환이 일어나도 id로 현재 (mode, index)를 다시 찾아 그 요소에 적용한다. + // 삭제됐거나 type이 옮겨졌으면 자산만 남기고 연결은 조용히 중단한다 + const applyToBoundElement = (patch: Omit, 'id'>) => { + const id = keyPosition.id; + if (!id) { + // id 없는 구형 데이터는 기존 index 경로 유지 + onKeyPreview?.(keyIndex, patch); + onKeyUpdate({ index: keyIndex, ...patch }); + return; + } + // id가 있는데 시작 시점 조회가 실패했으면 옛 index 폴백 대신 중단 + if (!boundElementType) return; + applyElementPatchById(boundElementType, id, () => patch); + }; + // 이미지 변경 핸들러 const handleIdleImageChange = (imageUrl: string) => { - onKeyPreview?.(keyIndex, { inactiveImage: imageUrl }); - onKeyUpdate({ index: keyIndex, inactiveImage: imageUrl }); + applyToBoundElement({ inactiveImage: imageUrl }); }; const handleActiveImageChange = (imageUrl: string) => { - onKeyPreview?.(keyIndex, { activeImage: imageUrl }); - onKeyUpdate({ index: keyIndex, activeImage: imageUrl }); + applyToBoundElement({ activeImage: imageUrl }); }; const handleIdleTransparentChange = (checked: boolean) => { @@ -1114,6 +1136,7 @@ const StyleTabContent: React.FC = ({ open={showImagePicker} referenceRef={imageButtonRef} panelElement={panelElement} + completionBinding={keyPosition.id ? 'element-id' : 'session-mode'} idleImage={keyPosition.inactiveImage || ''} activeImage={keyPosition.activeImage || ''} idleTransparent={keyPosition.idleTransparent ?? false} @@ -1208,11 +1231,11 @@ const StyleTabContent: React.FC = ({ createPortal( { const nextPath = soundPath || ''; - onKeyPreview?.(keyIndex, { soundPath: nextPath }); - onKeyUpdate({ index: keyIndex, soundPath: nextPath }); + applyToBoundElement({ soundPath: nextPath }); }} previewVolume={keyPosition.soundVolume ?? 100} pageTitle={t('propertiesPanel.keySound') || '키 사운드'} diff --git a/src/renderer/components/main/Modal/content/pickers/CounterAnimationPicker.editSession.test.tsx b/src/renderer/components/main/Modal/content/pickers/CounterAnimationPicker.editSession.test.tsx index 6d175c61..ee4cf401 100644 --- a/src/renderer/components/main/Modal/content/pickers/CounterAnimationPicker.editSession.test.tsx +++ b/src/renderer/components/main/Modal/content/pickers/CounterAnimationPicker.editSession.test.tsx @@ -88,6 +88,12 @@ describe('CounterAnimationPicker 저장 완료와 모드 전환', () => { container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); + await mountPicker(); + }); + + const mountPicker = async ( + completionBinding?: 'session-mode' | 'element-id', + ) => { act(() => { root.render( @@ -98,13 +104,14 @@ describe('CounterAnimationPicker 저장 완료와 모드 전환', () => { t={(key: string) => key} pageTitle="animation" onBack={vi.fn()} + completionBinding={completionBinding} /> , ); }); await settle(); list.mockClear(); - }); + }; afterEach(() => { act(() => root.unmount()); @@ -128,4 +135,16 @@ describe('CounterAnimationPicker 저장 완료와 모드 전환', () => { expect(list).toHaveBeenCalled(); expect(onAnimationChange).not.toHaveBeenCalled(); }); + + // element-id 결합은 유효성 판정을 ID applier에 위임한다 + it('element-id 결합이면 모드가 바뀌어도 저장한 모션을 콜백에 전달한다', async () => { + await mountPicker('element-id'); + act(() => { + useKeyStore.setState({ selectedKeyType: '8key' }); + }); + + await savePreset(); + + expect(onAnimationChange).toHaveBeenCalled(); + }); }); diff --git a/src/renderer/components/main/Modal/content/pickers/CounterAnimationPicker.tsx b/src/renderer/components/main/Modal/content/pickers/CounterAnimationPicker.tsx index f0c427db..de43b2e5 100644 --- a/src/renderer/components/main/Modal/content/pickers/CounterAnimationPicker.tsx +++ b/src/renderer/components/main/Modal/content/pickers/CounterAnimationPicker.tsx @@ -23,7 +23,9 @@ import { } from './pickerRowClass'; import CounterAnimationEditorModal from '../editors/CounterAnimationEditorModal'; import type { CounterAnimationKeyVisual } from '@utils/core/counterAnimationPreview'; -import { useEditSessionModeGuard } from '@src/renderer/contexts/EditSessionScope'; +import { useEditSessionCompletionGuard } from '@src/renderer/contexts/EditSessionScope'; + +import type { CompletionBinding } from '@src/renderer/contexts/EditSessionScope'; interface CounterAnimationPickerProps { open: boolean; @@ -34,6 +36,8 @@ interface CounterAnimationPickerProps { t: (key: string) => string; pageTitle: string; onBack: () => void; + /** 비동기 완료 콜백이 안정 ID applier로 라우팅되면 element-id */ + completionBinding?: CompletionBinding; } type FilterType = 'all' | 'builtin' | 'user'; @@ -71,6 +75,7 @@ const CounterAnimationPicker = ({ t, pageTitle, onBack, + completionBinding = 'session-mode', }: CounterAnimationPickerProps) => { const [searchQuery, setSearchQuery] = useState(''); const [filterType, setFilterType] = useState('all'); @@ -80,7 +85,7 @@ const CounterAnimationPicker = ({ const [isLoading, setIsLoading] = useState(false); const [errorText, setErrorText] = useState(''); const [editorState, setEditorState] = useState(null); - const isSameEditSessionMode = useEditSessionModeGuard(); + const canBindCompletion = useEditSessionCompletionGuard(completionBinding); const loadRequestRef = useRef(0); const isOpenRef = useRef(open); const pendingPresetActionsRef = useRef(new Set()); @@ -253,7 +258,8 @@ const CounterAnimationPicker = ({ }) => { await loadLibrary(); // preset은 라이브러리에 이미 저장됐다. 대상이 갈렸으면 적용만 하지 않는다 - if (!isSameEditSessionMode()) return; + // (element-id 결합이면 ID applier가 유효성을 판정하므로 통과) + if (!canBindCompletion()) return; if (mode === 'create' || selectedPresetId === preset.id) { onAnimationChange(applyPresetToAnimation(animation, preset)); } diff --git a/src/renderer/components/main/Modal/content/pickers/ImagePicker.editSession.test.tsx b/src/renderer/components/main/Modal/content/pickers/ImagePicker.editSession.test.tsx index 6b80fa92..cdd4a3bb 100644 --- a/src/renderer/components/main/Modal/content/pickers/ImagePicker.editSession.test.tsx +++ b/src/renderer/components/main/Modal/content/pickers/ImagePicker.editSession.test.tsx @@ -39,7 +39,10 @@ describe('ImagePicker 비동기 완료와 대상 전환', () => { let onIdleImageChange: Mock<(path: string) => void>; let resolveLoad: (value: unknown) => void; - const mount = (scoped: boolean) => { + const mount = ( + scoped: boolean, + completionBinding?: 'session-mode' | 'element-id', + ) => { container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); @@ -50,6 +53,7 @@ describe('ImagePicker 비동기 완료와 대상 전환', () => { onIdleImageChange={onIdleImageChange} onClose={vi.fn()} showActiveState={false} + completionBinding={completionBinding} /> ); act(() => { @@ -145,4 +149,18 @@ describe('ImagePicker 비동기 완료와 대상 전환', () => { expect(onIdleImageChange).toHaveBeenCalledWith('/tmp/picked.png'); }); + + // element-id 결합은 유효성 판정을 ID applier에 위임한다. 모드가 바뀌어도 + // 콜백은 호출되고, 원 요소 연결·삭제 중단은 applier가 결정한다 + it('element-id 결합이면 모드가 바뀌어도 완료를 콜백에 전달한다', async () => { + mount(true, 'element-id'); + clickPreview(); + + act(() => { + useKeyStore.setState({ selectedKeyType: '8key' }); + }); + await finishLoad(); + + expect(onIdleImageChange).toHaveBeenCalledWith('/tmp/picked.png'); + }); }); diff --git a/src/renderer/components/main/Modal/content/pickers/ImagePicker.tsx b/src/renderer/components/main/Modal/content/pickers/ImagePicker.tsx index 9636878c..b22c6f77 100644 --- a/src/renderer/components/main/Modal/content/pickers/ImagePicker.tsx +++ b/src/renderer/components/main/Modal/content/pickers/ImagePicker.tsx @@ -6,7 +6,9 @@ import Dropdown from '@components/main/common/Dropdown'; import TabSwitch from '@components/main/common/TabSwitch'; import { PropertySection } from '@components/main/Grid/PropertiesPanel/PropertyInputs'; import { resolveImageSource } from '@utils/core/imageSource'; -import { useEditSessionModeGuard } from '@src/renderer/contexts/EditSessionScope'; +import { useEditSessionCompletionGuard } from '@src/renderer/contexts/EditSessionScope'; + +import type { CompletionBinding } from '@src/renderer/contexts/EditSessionScope'; interface ImagePickerProps { open: boolean; @@ -30,6 +32,8 @@ interface ImagePickerProps { interactiveRefs?: React.RefObject[]; /** 눌림 상태가 없는 요소는 대기 이미지만 편집 */ showActiveState?: boolean; + /** 비동기 완료 콜백이 안정 ID applier로 라우팅되면 element-id */ + completionBinding?: CompletionBinding; } const STATE_MODES = { @@ -58,6 +62,7 @@ const ImagePicker = ({ onClose, interactiveRefs = [], showActiveState = true, + completionBinding = 'session-mode', }: ImagePickerProps) => { const { t } = useTranslation(); const [mode, setMode] = useState< @@ -65,7 +70,7 @@ const ImagePicker = ({ >(STATE_MODES.idle); const [isLoadingImage, setIsLoadingImage] = useState(false); const loadingImageRef = useRef(false); - const isSameEditSessionMode = useEditSessionModeGuard(); + const canBindCompletion = useEditSessionCompletionGuard(completionBinding); const effectiveMode = showActiveState ? mode : STATE_MODES.idle; useEffect(() => { @@ -82,7 +87,8 @@ const ImagePicker = ({ return; } // 파일 복사는 이미 끝났다. 대상이 갈렸으면 연결만 하지 않는다 - if (!isSameEditSessionMode()) return; + // (element-id 결합이면 ID applier가 유효성을 판정하므로 통과) + if (!canBindCompletion()) return; if (stateMode === STATE_MODES.idle) { onIdleImageChange?.(result.imagePath); } else { diff --git a/src/renderer/components/main/Modal/content/pickers/SoundPicker.editSession.test.tsx b/src/renderer/components/main/Modal/content/pickers/SoundPicker.editSession.test.tsx index 8c79ba7a..e96045cf 100644 --- a/src/renderer/components/main/Modal/content/pickers/SoundPicker.editSession.test.tsx +++ b/src/renderer/components/main/Modal/content/pickers/SoundPicker.editSession.test.tsx @@ -119,6 +119,12 @@ describe('SoundPicker 비동기 완료와 모드 전환', () => { container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); + await mountPicker(); + }); + + const mountPicker = async ( + completionBinding?: 'session-mode' | 'element-id', + ) => { act(() => { root.render( @@ -128,12 +134,13 @@ describe('SoundPicker 비동기 완료와 모드 전환', () => { onSoundSelect={onSoundSelect} pageTitle="sound" onBack={vi.fn()} + completionBinding={completionBinding} /> , ); }); await settle(); - }); + }; afterEach(() => { act(() => root.unmount()); @@ -178,4 +185,29 @@ describe('SoundPicker 비동기 완료와 모드 전환', () => { expect(remove).toHaveBeenCalledWith(SOUND.soundPath); expect(onSoundSelect).toHaveBeenCalledWith(null); }); + + // element-id 결합은 유효성 판정을 ID applier에 위임한다. + // 가드 2곳(트림 저장, 삭제 후 해제) 모두 같은 조건으로 통과해야 한다 + it('element-id 결합이면 모드가 바뀌어도 트림 저장을 연결한다', async () => { + await mountPicker('element-id'); + switchMode(); + + act(() => mocks.saveTrim?.('sounds/new.wav')); + + expect(onSoundSelect).toHaveBeenCalledWith('sounds/new.wav'); + }); + + it('element-id 결합이면 모드가 바뀌어도 삭제 해제를 원 요소로 보낸다', async () => { + await mountPicker('element-id'); + await runDelete(); + switchMode(); + + await act(async () => { + resolveConfirm(true); + await settle(); + }); + + expect(remove).toHaveBeenCalledWith(SOUND.soundPath); + expect(onSoundSelect).toHaveBeenCalledWith(null); + }); }); diff --git a/src/renderer/components/main/Modal/content/pickers/SoundPicker.tsx b/src/renderer/components/main/Modal/content/pickers/SoundPicker.tsx index b03ed6c5..05bdb26a 100644 --- a/src/renderer/components/main/Modal/content/pickers/SoundPicker.tsx +++ b/src/renderer/components/main/Modal/content/pickers/SoundPicker.tsx @@ -12,7 +12,9 @@ import { import MoreVerticalIcon from './MoreVerticalIcon'; import { usePickerItemMenu } from '@hooks/usePickerItemMenu'; import SoundTrimModal from '../managers/SoundTrimModal'; -import { useEditSessionModeGuard } from '@src/renderer/contexts/EditSessionScope'; +import { useEditSessionCompletionGuard } from '@src/renderer/contexts/EditSessionScope'; + +import type { CompletionBinding } from '@src/renderer/contexts/EditSessionScope'; interface SoundPickerProps { open: boolean; @@ -21,6 +23,8 @@ interface SoundPickerProps { pageTitle: string; onBack: () => void; previewVolume?: number; + /** 비동기 완료 콜백이 안정 ID applier로 라우팅되면 element-id */ + completionBinding?: CompletionBinding; } type TrimState = @@ -36,6 +40,7 @@ const SoundPicker = ({ pageTitle, onBack, previewVolume, + completionBinding = 'session-mode', }: SoundPickerProps) => { const { t } = useTranslation(); const [searchQuery, setSearchQuery] = useState(''); @@ -48,7 +53,7 @@ const SoundPicker = ({ const [isLoading, setIsLoading] = useState(false); const [loadError, setLoadError] = useState(''); const [trimState, setTrimState] = useState(null); - const isSameEditSessionMode = useEditSessionModeGuard(); + const canBindCompletion = useEditSessionCompletionGuard(completionBinding); const [renamingPath, setRenamingPath] = useState(null); const [renameValue, setRenameValue] = useState(''); const renameInputRef = useRef(null); @@ -213,10 +218,8 @@ const SoundPicker = ({ await window.api.sound.remove(item.soundPath); // 백엔드가 이미 모든 요소에서 이 사운드를 해제했다. 대상이 갈렸으면 // 여기서 한 번 더 비우는 건 새 모드의 다른 사운드를 지우는 일이 된다 - if ( - normalizedSelectedSound === item.soundPath && - isSameEditSessionMode() - ) { + // (element-id 결합이면 ID applier가 원 요소에만 해제를 적용한다) + if (normalizedSelectedSound === item.soundPath && canBindCompletion()) { onSoundSelect(null); } await loadSounds(); @@ -288,7 +291,8 @@ const SoundPicker = ({ const handleTrimSaved = (soundPath: string) => { // 사운드 파일은 이미 저장됐다. 대상이 갈렸으면 연결만 하지 않는다 - if (isSameEditSessionMode()) onSoundSelect(soundPath); + // (element-id 결합이면 ID applier가 유효성을 판정하므로 통과) + if (canBindCompletion()) onSoundSelect(soundPath); setTrimState(null); void loadSounds(); }; diff --git a/src/renderer/contexts/EditSessionScope.tsx b/src/renderer/contexts/EditSessionScope.tsx index c2a0930c..3c9cfeb3 100644 --- a/src/renderer/contexts/EditSessionScope.tsx +++ b/src/renderer/contexts/EditSessionScope.tsx @@ -54,3 +54,23 @@ export const useEditSessionModeGuard = (): (() => boolean) => { [scoped], ); }; + +// 비동기 완료 콜백의 대상 결합 방식. +// +// session-mode: 위 mode guard 그대로 - 완료 writer가 실행 시점 모드를 다시 +// 읽는 레거시 index 경로용 기본값. +// element-id: 가드를 통과시킨다 - 완료 콜백이 안정 ID applier로 라우팅되어 +// 유효성 판정(현재 mode·index 재결정, 삭제 시 중단)을 resolver가 전담한다. +// 호출부는 대상 요소에 id가 있을 때만 element-id를 선언해야 한다 +export type CompletionBinding = 'session-mode' | 'element-id'; + +// eslint-disable-next-line react-refresh/only-export-components +export const useEditSessionCompletionGuard = ( + binding: CompletionBinding = 'session-mode', +): (() => boolean) => { + const isSameMode = useEditSessionModeGuard(); + return useCallback( + () => binding === 'element-id' || isSameMode(), + [binding, isSameMode], + ); +}; diff --git a/src/renderer/editor/runtime/elementPatch.test.ts b/src/renderer/editor/runtime/elementPatch.test.ts new file mode 100644 index 00000000..967fa361 --- /dev/null +++ b/src/renderer/editor/runtime/elementPatch.test.ts @@ -0,0 +1,156 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createDefaultKeyPosition } from '../model/keys'; + +import type { KeyPosition, KeyPositions } from '@src/types/key/keys'; +import type { StatItemPosition } from '@src/types/key/statItems'; + +const api = vi.hoisted(() => ({ + updatePositionsWithGesture: vi.fn( + async (_positions: KeyPositions, _gestureId?: string) => ({}), + ), + updateMappingsAndPositionsWithGesture: vi.fn(async () => ({})), + statUpdate: vi.fn(async (_positions: Record) => ({})), + graphUpdate: vi.fn(async (_positions: Record) => ({})), + knobUpdate: vi.fn(async (_positions: Record) => ({})), +})); + +vi.mock('@api/modules/keysApi', () => ({ + updatePositionsWithGesture: api.updatePositionsWithGesture, + updateMappingsAndPositionsWithGesture: + api.updateMappingsAndPositionsWithGesture, +})); +vi.mock('@api/modules/editorApi', () => ({ + editorApi: { + get: vi.fn(), + commit: vi.fn(), + onCommitted: vi.fn(() => + Object.assign(() => {}, { ready: Promise.resolve() }), + ), + }, +})); +vi.mock('@api/modules/previewApi', () => ({ + previewApi: { + cancel: vi.fn(async () => {}), + publish: vi.fn(async () => {}), + subscribe: vi.fn(async () => 1), + }, +})); + +import { useKeyStore } from '@stores/data/useKeyStore'; +import { useStatItemStore } from '@stores/data/useStatItemStore'; +import { applyElementPatchById } from './elementPatch'; +import { editGestureController } from './editGestureController'; + +const ID_A = '11111111-1111-4111-8111-111111111111'; +const ID_B = '22222222-2222-4222-8222-222222222222'; +const ID_S = '33333333-3333-4333-8333-333333333333'; +const ID_GONE = '99999999-9999-4999-8999-999999999999'; + +const keyAt = (id: string) => ({ ...createDefaultKeyPosition(), id }); + +describe('applyElementPatchById', () => { + beforeEach(() => { + vi.clearAllMocks(); + editGestureController.cancel(); + useKeyStore.setState({ + selectedKeyType: '4key', + canonicalPositions: { '4key': [keyAt(ID_A), keyAt(ID_B)] }, + positions: { '4key': [keyAt(ID_A), keyAt(ID_B)] }, + }); + useStatItemStore.setState({ positions: {} }); + window.api = { + statItems: { updatePositions: api.statUpdate }, + graphItems: { updatePositions: api.graphUpdate }, + knobItems: { updatePositions: api.knobUpdate }, + } as never; + }); + + it('재정렬 뒤에도 id가 가리키는 요소의 현재 index에 적용한다', () => { + const [a, b] = useKeyStore.getState().canonicalPositions['4key']; + useKeyStore.getState().setPositions({ '4key': [b, a] }); + + const applied = applyElementPatchById('key', ID_A, () => ({ + inactiveImage: 'picked.png', + })); + + expect(applied).toBe(true); + const persisted = api.updatePositionsWithGesture.mock.calls[0][0]; + expect(persisted['4key'][1].inactiveImage).toBe('picked.png'); + expect(persisted['4key'][0].inactiveImage ?? '').toBe(''); + expect( + useKeyStore.getState().canonicalPositions['4key'][1].inactiveImage, + ).toBe('picked.png'); + }); + + it('보고 있는 모드가 바뀌어도 원 모드 컬렉션에 적용한다', () => { + const stat = { + ...createDefaultKeyPosition(), + id: ID_S, + statType: 'kps', + } as StatItemPosition; + useStatItemStore.setState({ positions: { '4key': [stat] } }); + useKeyStore.setState({ selectedKeyType: '8key' }); + + const applied = applyElementPatchById('stat', ID_S, () => ({ + inactiveImage: 'picked.png', + })); + + expect(applied).toBe(true); + const persisted = api.statUpdate.mock.calls[0][0]; + expect(persisted['4key'][0].inactiveImage).toBe('picked.png'); + expect(useStatItemStore.getState().positions['4key'][0].inactiveImage).toBe( + 'picked.png', + ); + }); + + it('요소가 삭제됐으면 아무것도 쓰지 않는다', () => { + const applied = applyElementPatchById('key', ID_GONE, () => ({ + inactiveImage: 'picked.png', + })); + + expect(applied).toBe(false); + expect(api.updatePositionsWithGesture).not.toHaveBeenCalled(); + expect(api.statUpdate).not.toHaveBeenCalled(); + }); + + it('updater가 id를 끼워 넣어도 신원은 보존된다', () => { + const applied = applyElementPatchById( + 'key', + ID_A, + () => ({ id: 'hijacked', inactiveImage: 'picked.png' } as never), + ); + + expect(applied).toBe(true); + expect(useKeyStore.getState().canonicalPositions['4key'][0].id).toBe(ID_A); + }); + + it('updater가 입력 객체의 id를 직접 변조해도 신원은 보존된다', () => { + const applied = applyElementPatchById('key', ID_A, (current) => { + (current as { id?: string }).id = 'mutated'; + return { inactiveImage: 'picked.png' }; + }); + + expect(applied).toBe(true); + expect(useKeyStore.getState().canonicalPositions['4key'][0].id).toBe(ID_A); + const persisted = api.updatePositionsWithGesture.mock.calls[0][0]; + expect(persisted['4key'][0].id).toBe(ID_A); + }); + + it('활성 게스처를 정산하지 않고 wire에 gestureId도 싣지 않는다', () => { + editGestureController.preview('4key', [{ index: 0, patch: { dx: 5 } }], { + domain: 'keyPosition', + }); + const activeBefore = editGestureController.activeGestureId(); + expect(activeBefore).not.toBeNull(); + + const applied = applyElementPatchById('key', ID_B, () => ({ + inactiveImage: 'picked.png', + })); + + expect(applied).toBe(true); + expect(editGestureController.activeGestureId()).toBe(activeBefore); + expect( + api.updatePositionsWithGesture.mock.calls.at(-1)?.[1], + ).toBeUndefined(); + }); +}); diff --git a/src/renderer/editor/runtime/elementPatch.ts b/src/renderer/editor/runtime/elementPatch.ts new file mode 100644 index 00000000..2b46c145 --- /dev/null +++ b/src/renderer/editor/runtime/elementPatch.ts @@ -0,0 +1,129 @@ +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 { resolveElementById } from '../model/elementIdMap'; +import { persistPositionsWithFlag } from './persistState'; + +import type { NativeElementType } from '../model/elementIdMap'; +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 { StatItemPosition } from '@src/types/key/statItems'; + +// 비동기 완료 전용 mode-aware 쓰기. +// +// 파일 대화상자·편집기를 기다리는 사이 배열 재정렬이나 모드 전환이 일어나도 +// id로 현재 (mode, index)를 다시 찾아 그 요소에만 적용한다. 계약(§8)상 검사는 +// type 일치(per-type 조회)뿐이고, 조회 실패(삭제·미등록)는 쓰지 않는다. +// +// 게스처와 결합하지 않는다 - settleCommit은 무관한 활성 게스처를 정산해 버리고, +// gestureId 연결은 이 완료를 남의 히스토리 엔트리에 병합한다. 쓰기 자체는 +// 기존 API 경로를 타므로 write barrier 등록은 그대로 유지된다 + +export type ElementPatchUpdater = ( + current: Readonly, +) => Omit, 'id'>; + +// updater가 어떤 patch를 만들거나 입력을 직접 변조해도 신원은 불변. +// updater 실행 전에 id를 캡처해야 직접 mutation까지 막힌다 +const mergePosition = ( + current: T, + updater: ElementPatchUpdater, +): T => { + const id = current.id; + const patch = updater(current); + return { ...current, ...patch, id }; +}; + +type ElementWriter = ( + mode: string, + index: number, + updater: ElementPatchUpdater, +) => boolean; + +const writeKey: ElementWriter = (mode, index, updater) => { + const state = useKeyStore.getState(); + const list = state.canonicalPositions[mode] ?? []; + const current = list[index]; + if (!current) return false; + const next = { + ...state.canonicalPositions, + [mode]: list.map((position, i) => + i === index ? mergePosition(position, updater) : position, + ), + }; + void persistPositionsWithFlag( + next, + state.setPositions, + state.setLocalUpdateInProgress, + ); + return true; +}; + +interface ItemStoreLike { + positions: Record; + setPositions: (positions: Record) => void; + setLocalUpdateInProgress: (value: boolean) => void; +} + +const writeItem = + ( + readStore: () => ItemStoreLike, + persist: (positions: Record) => Promise, + label: string, + ): ElementWriter => + (mode, index, updater) => { + const state = readStore(); + const list = state.positions[mode] ?? []; + const current = list[index]; + if (!current) return false; + const next = { + ...state.positions, + [mode]: list.map((position, i) => + i === index ? mergePosition(position, updater) : position, + ), + }; + state.setLocalUpdateInProgress(true); + state.setPositions(next); + persist(next) + .catch((error) => { + console.error(`Failed to apply ${label} element patch`, error); + }) + .finally(() => { + state.setLocalUpdateInProgress(false); + }); + return true; + }; + +const writers: Record = { + key: writeKey, + stat: writeItem( + () => useStatItemStore.getState(), + (positions) => window.api.statItems.updatePositions(positions), + 'stat', + ), + graph: writeItem( + () => useGraphItemStore.getState(), + (positions) => window.api.graphItems.updatePositions(positions), + 'graph', + ), + knob: writeItem( + () => useKnobItemStore.getState(), + (positions) => window.api.knobItems.updatePositions(positions), + 'knob', + ), +}; + +// 반환 false = 요소 없음(삭제·미등록). 호출부는 연결만 조용히 중단한다 +export const applyElementPatchById = ( + type: NativeElementType, + id: string, + updater: ElementPatchUpdater, +): boolean => { + if (!id) return false; + const locator = resolveElementById(type, id); + if (!locator) return false; + return writers[type](locator.mode, locator.index, updater); +}; diff --git a/src/types/key/counterAnimation.ts b/src/types/key/counterAnimation.ts index 320344b9..b7cf09be 100644 --- a/src/types/key/counterAnimation.ts +++ b/src/types/key/counterAnimation.ts @@ -167,3 +167,26 @@ export function applyPresetToAnimation( durationMs: preset.durationMs, }; } + +// 비동기 완료 병합용. 시작 스냅샷(start) 대비 실제로 바뀐 필드만 base(fresh) +// 위에 적용해, 대기 중 다른 writer가 바꾼 필드(enabled 등)를 시작 값으로 +// 되돌리지 않는다. 필드가 늘면 여기 병합도 함께 늘려야 컴파일된다. +// 변경 감지는 정확 비교다 - preset 매칭용 epsilon(isBezierEqual)을 쓰면 +// 드래그의 미세 변경이 무변경으로 오판된다 +export function mergeChangedAnimationFields( + base: KeyCounterAnimationSettings, + start: KeyCounterAnimationSettings, + next: KeyCounterAnimationSettings, +): KeyCounterAnimationSettings { + const bezierUnchanged = next.bezier.every( + (value, index) => value === start.bezier[index as 0 | 1 | 2 | 3], + ); + return { + enabled: next.enabled === start.enabled ? base.enabled : next.enabled, + presetId: next.presetId === start.presetId ? base.presetId : next.presetId, + bezier: bezierUnchanged ? base.bezier : next.bezier, + scale: next.scale === start.scale ? base.scale : next.scale, + durationMs: + next.durationMs === start.durationMs ? base.durationMs : next.durationMs, + }; +} From bbc450a6c282d6c57b9cb5ccc24a7368d0aa0d41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 11 Aug 2026 21:54:19 +0900 Subject: [PATCH 05/35] =?UTF-8?q?docs:=20=EC=9A=94=EC=86=8C=20=EC=95=88?= =?UTF-8?q?=EC=A0=95=20ID=20=EC=8B=A0=EC=9B=90=20=EA=B7=9C=EC=B9=99=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/content/en/api-reference/editor/page.mdx | 9 +++++++++ docs/content/en/api-reference/keys/page.mdx | 8 ++++++++ docs/content/ko/api-reference/editor/page.mdx | 9 +++++++++ docs/content/ko/api-reference/keys/page.mdx | 8 ++++++++ 4 files changed, 34 insertions(+) diff --git a/docs/content/en/api-reference/editor/page.mdx b/docs/content/en/api-reference/editor/page.mdx index de6f4dc3..46c23c3a 100644 --- a/docs/content/en/api-reference/editor/page.mdx +++ b/docs/content/en/api-reference/editor/page.mdx @@ -52,6 +52,15 @@ that top-level collection. It is not an item-level diff. identifier rules. + + Every element in the position collections (`keyPositions`, `statPositions`, + `graphPositions`, `knobPositions`) carries a stable `id` (UUID) assigned and + owned by the app. Treat it as opaque: echo back the value you read and never + invent or duplicate one. A written element with a missing or unknown `id` + gets a fresh one from the backend. See the + [Keys API](/docs/api-reference/keys) for the identity rules. + + ## Read the Current Document ### `dmn.editor.get(): Promise` diff --git a/docs/content/en/api-reference/keys/page.mdx b/docs/content/en/api-reference/keys/page.mdx index 6225721e..1c858f04 100644 --- a/docs/content/en/api-reference/keys/page.mdx +++ b/docs/content/en/api-reference/keys/page.mdx @@ -258,6 +258,7 @@ gradient siblings: ```typescript interface KeyPosition { + id?: string; // stable element identity (UUID), assigned and owned by the app // ...position, image, note, and counter fields... backgroundColor?: string; activeBackgroundColor?: string; @@ -293,6 +294,13 @@ interface ElementShadowSpec { } ``` +Every element position (`keyPositions`, `statPositions`, `graphPositions`, +`knobPositions`) carries a stable `id`. Treat it as opaque: echo back the value +you read, never invent one, and never copy an `id` onto another element. A +write whose `id` is missing or unknown gets a fresh one assigned by the +backend, and loading a preset re-issues every `id`. Use `id` to track an +element across reorders instead of its array index. + When a gradient field is present it takes priority over the matching solid field, and the solid field is kept in sync with the first stop color on save. To return to a solid color, set the gradient field to `null` and update the diff --git a/docs/content/ko/api-reference/editor/page.mdx b/docs/content/ko/api-reference/editor/page.mdx index f4e90065..692fb1e1 100644 --- a/docs/content/ko/api-reference/editor/page.mdx +++ b/docs/content/ko/api-reference/editor/page.mdx @@ -51,6 +51,15 @@ type EditorPatchV1 = { 식별자 규칙은 [Keys API](/docs/api-reference/keys)를 참고하세요. + + 위치 컬렉션(`keyPositions`, `statPositions`, `graphPositions`, + `knobPositions`)의 모든 요소는 앱이 발급·소유하는 안정 `id`(UUID)를 + 가집니다. 불투명 값으로 다루세요. 읽은 값을 그대로 되돌려 보내고, 직접 + 만들거나 중복시키지 마세요. `id`가 없거나 미확인인 요소를 쓰면 백엔드가 새 + 값을 발급합니다. 신원 규칙은 [Keys API](/docs/api-reference/keys)를 + 참고하세요. + + ## 현재 문서 조회 ### `dmn.editor.get(): Promise` diff --git a/docs/content/ko/api-reference/keys/page.mdx b/docs/content/ko/api-reference/keys/page.mdx index 0f47845a..3558f4cc 100644 --- a/docs/content/ko/api-reference/keys/page.mdx +++ b/docs/content/ko/api-reference/keys/page.mdx @@ -106,6 +106,7 @@ fail-closed 게이트입니다. 같은 규칙이 `updateWithPositions()`와, `ke ```typescript interface KeyPosition { + id?: string; // 요소 안정 ID (UUID). 앱이 발급·소유 dx: number; dy: number; width: number; @@ -160,6 +161,13 @@ interface ElementShadowSpec { } ``` +모든 요소 위치(`keyPositions`, `statPositions`, `graphPositions`, +`knobPositions`)는 안정 `id`를 가집니다. 불투명 값으로 다루세요. 읽은 값을 +그대로 되돌려 보내고, 직접 만들거나 다른 요소에 복사하지 마세요. `id`가 +없거나 미확인인 쓰기는 백엔드가 새 값을 발급하며, 프리셋을 불러오면 모든 +`id`가 재발급됩니다. 재정렬을 가로질러 요소를 추적할 때는 배열 index 대신 +`id`를 사용하세요. + 그라데이션 필드가 있으면 렌더에서 대응 단색 필드보다 우선하며, 저장 시 대응 단색 필드는 첫 스톱 색으로 자동 동기화됩니다. 단색으로 되돌리려면 그라데이션 필드를 `null`로 두고 단색 필드를 갱신하세요. From 715899ea60f10f5758f0c350d842265e73552231 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 11 Aug 2026 22:08:18 +0900 Subject: [PATCH 06/35] =?UTF-8?q?fix:=20=EB=B2=A0=EC=A7=80=EC=96=B4=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=EA=B8=B0=20=EC=BA=94=EB=B2=84=EC=8A=A4=20?= =?UTF-8?q?=EB=B9=84=EC=9C=A8=20=EC=99=9C=EA=B3=A1=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CounterAnimationEditorModal.save.test.tsx | 189 ++++++++++++++++++ .../CounterAnimationEditorModal.test.tsx | 74 +++++++ .../editors/CounterAnimationEditorModal.tsx | 7 +- 3 files changed, 266 insertions(+), 4 deletions(-) create mode 100644 src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.save.test.tsx diff --git a/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.save.test.tsx b/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.save.test.tsx new file mode 100644 index 00000000..35b9816f --- /dev/null +++ b/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.save.test.tsx @@ -0,0 +1,189 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, + type Mock, +} from 'vitest'; + +import CounterAnimationEditorModal from './CounterAnimationEditorModal'; + +import type { CounterAnimationPreset } from '@src/types/key/counterAnimation'; + +interface CounterAnimationSavePayload { + preset: CounterAnimationPreset; + mode: 'create' | 'edit'; + affectedUsageCount: number; +} + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const mocks = vi.hoisted(() => ({ + submit: null as null | (() => void), +})); + +// 저장 버튼은 레이아웃이 들고 있다. 콜백을 밖으로 꺼내 눌러본다 +vi.mock('@components/main/Modal/FullSurfaceModalLayout', () => ({ + default: ({ + children, + onSubmit, + }: { + children: React.ReactNode; + onSubmit: () => void; + }) => { + mocks.submit = onSubmit; + return <>{children}; + }, +})); +vi.mock('@components/main/common/Dropdown', () => ({ default: () => null })); +vi.mock('@components/main/Grid/PropertiesPanel/PropertyInputs', () => ({ + TextInput: () => null, + NumberInput: () => null, +})); +vi.mock('@components/overlay/counters/CountDisplay', () => ({ + default: () => null, +})); + +const PRESET: CounterAnimationPreset = { + id: 'preset-1', + name: 'pop', + source: 'user', + bezier: [0.4, 0, 0.2, 1], + scale: 1.2, + durationMs: 300, +} as CounterAnimationPreset; + +// 가드는 피커의 onSaved 안에 있다. 그러니 자산 작업(preset 저장)이 onSaved보다 +// 먼저 끝난다는 사실을 여기서 고정해야 "자산은 남기고 연결만 버린다"가 증명된다 +describe('CounterAnimationEditorModal 저장 순서', () => { + let host: HTMLDivElement; + let root: Root; + let onSaved: Mock<(payload: CounterAnimationSavePayload) => void>; + let create: ReturnType; + let update: ReturnType; + let resolveSave: (value: unknown) => void; + + const deferred = () => + vi.fn( + () => + new Promise((resolve) => { + resolveSave = resolve; + }), + ); + + const mount = ( + mode: 'create' | 'edit', + initialPreset: CounterAnimationPreset | null, + ) => { + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + act(() => { + root.render( + undefined} + onSaved={onSaved} + t={(key: string) => key} + />, + ); + }); + }; + + // 생성 모드는 이름이 비어 있으면 저장이 잠긴다 + const typeName = (value: string) => { + const input = host.querySelector('input[type=text]')!; + const setter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )!.set!; + act(() => { + setter.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + }; + + const settle = async () => { + await act(async () => { + for (let i = 0; i < 5; i += 1) await Promise.resolve(); + }); + }; + + beforeEach(() => { + onSaved = vi.fn(); + create = deferred(); + update = deferred(); + mocks.submit = null; + vi.stubGlobal('requestAnimationFrame', () => 1); + vi.stubGlobal('cancelAnimationFrame', () => undefined); + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + disconnect() {} + }, + ); + Object.defineProperty(window, 'api', { + configurable: true, + value: { + css: { + get: vi.fn().mockResolvedValue({ content: '' }), + getUse: vi.fn().mockResolvedValue(false), + tab: { getAll: vi.fn().mockResolvedValue({}) }, + }, + counterAnimation: { create, update }, + }, + }); + }); + + afterEach(() => { + act(() => root.unmount()); + host.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('생성은 저장 API가 끝난 뒤에만 onSaved를 부른다', async () => { + mount('create', null); + typeName('my motion'); + + act(() => mocks.submit?.()); + await settle(); + + expect(create).toHaveBeenCalledTimes(1); + expect(onSaved).not.toHaveBeenCalled(); + + await act(async () => { + resolveSave({ preset: PRESET, affectedUsageCount: 0 }); + await settle(); + }); + + expect(onSaved).toHaveBeenCalledTimes(1); + expect(onSaved.mock.calls[0][0]).toMatchObject({ mode: 'create' }); + }); + + it('편집은 갱신 API가 끝난 뒤에만 onSaved를 부른다', async () => { + mount('edit', PRESET); + + act(() => mocks.submit?.()); + await settle(); + + expect(update).toHaveBeenCalledTimes(1); + expect(onSaved).not.toHaveBeenCalled(); + + await act(async () => { + resolveSave({ preset: PRESET, affectedUsageCount: 3 }); + await settle(); + }); + + expect(onSaved).toHaveBeenCalledTimes(1); + expect(onSaved.mock.calls[0][0]).toMatchObject({ mode: 'edit' }); + }); +}); 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 8ac6e639..2628ca48 100644 --- a/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.test.tsx +++ b/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.test.tsx @@ -36,6 +36,7 @@ describe('CounterAnimationEditorModal 베지어 드래그', () => { let host: HTMLDivElement; let root: Root; let callbacks: Map; + let resizeCallback: ResizeObserverCallback; beforeEach(() => { callbacks = new Map(); @@ -49,6 +50,10 @@ describe('CounterAnimationEditorModal 베지어 드래그', () => { vi.stubGlobal( 'ResizeObserver', class { + constructor(callback: ResizeObserverCallback) { + resizeCallback = callback; + } + observe() {} disconnect() {} }, @@ -102,6 +107,75 @@ describe('CounterAnimationEditorModal 베지어 드래그', () => { const p1 = () => host.querySelector('[data-counter-bezier-handle="p1"]')!; + const p1Visual = () => p1().nextElementSibling as SVGCircleElement; + + it('캔버스 실측 전에도 베지어 좌표계를 비균등 확대하지 않는다', () => { + const svg = host.querySelector( + '[data-counter-bezier-editor="true"]', + )!; + + expect(svg.getAttribute('preserveAspectRatio')).toBe('xMidYMid meet'); + }); + + it('가로형 캔버스 실측 뒤에는 같은 비율의 풀블리드 viewBox를 쓴다', () => { + const svg = host.querySelector( + '[data-counter-bezier-editor="true"]', + )!; + const area = svg.parentElement!; + vi.spyOn(area, 'getBoundingClientRect').mockReturnValue({ + left: 0, + top: 0, + width: 440, + height: 220, + right: 440, + bottom: 220, + x: 0, + y: 0, + toJSON: () => ({}), + }); + + act(() => resizeCallback([], {} as ResizeObserver)); + + const [, , viewWidth, viewHeight] = svg + .getAttribute('viewBox')! + .split(' ') + .map(Number); + expect(viewWidth / viewHeight).toBeCloseTo(2); + }); + + it('가로형 캔버스에서도 손잡이의 기존 화면 크기를 유지한다', () => { + const svg = host.querySelector( + '[data-counter-bezier-editor="true"]', + )!; + const area = svg.parentElement!; + vi.spyOn(area, 'getBoundingClientRect').mockReturnValue({ + left: 0, + top: 0, + width: 440, + height: 220, + right: 440, + bottom: 220, + x: 0, + y: 0, + toJSON: () => ({}), + }); + + act(() => resizeCallback([], {} as ResizeObserver)); + + const [, , , viewHeight] = svg + .getAttribute('viewBox')! + .split(' ') + .map(Number); + const screenScale = 220 / viewHeight; + const visualRadius = Number(p1Visual().getAttribute('r')) * screenScale; + const strokeWidth = + Number(p1Visual().getAttribute('stroke-width')) * screenScale; + const hitRadius = Number(p1().getAttribute('r')) * screenScale; + + expect(visualRadius).toBeCloseTo(6); + expect(strokeWidth).toBeCloseTo(2); + expect(hitRadius).toBeCloseTo(10); + }); it('연속 pointermove의 최신 좌표만 한 프레임에 반영한다', () => { act(() => { diff --git a/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.tsx b/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.tsx index e9a4f87a..b7d4f621 100644 --- a/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.tsx +++ b/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.tsx @@ -908,11 +908,10 @@ const CounterAnimationEditorModal = ({ const viewTop = viewOffset.y - (vbH - vbBase) / 2; const viewBoxStr = `${viewLeft} ${viewTop} ${vbW} ${vbH}`; const ns = 1 / viewScale; - // 캔버스가 커져도 핸들·코너는 기준 렌더 크기의 화면 크기 유지 (짧은 변 기준) + // 캔버스와 줌에 관계없이 기존 손잡이 화면 크기 유지 const uns = ns * - (EDITOR_RENDER_SIZE / - Math.max(Math.min(editorSize.width, editorSize.height), 1)); + (TOTAL_SIZE / Math.max(Math.min(editorSize.width, editorSize.height), 1)); const headerTitle = mode === 'edit' @@ -963,7 +962,7 @@ const CounterAnimationEditorModal = ({ data-counter-bezier-editor="true" className="absolute inset-0 w-full h-full" viewBox={viewBoxStr} - preserveAspectRatio="none" + preserveAspectRatio="xMidYMid meet" onWheel={handleWheel} onPointerDown={handleSvgPointerDown} onDoubleClick={handleDoubleClick} From 6b1e844147a5141756ac4a59642aac7b44ec5599 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 11 Aug 2026 22:08:18 +0900 Subject: [PATCH 07/35] =?UTF-8?q?test:=20=ED=94=84=EB=A6=AC=EC=85=8B=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=EC=9D=98=20=EC=A0=84=20=EB=AA=A8=EB=93=9C=20?= =?UTF-8?q?=EC=A0=84=ED=8C=8C=EB=A5=BC=20=EB=B0=B1=EC=97=94=EB=93=9C=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=EC=9C=BC=EB=A1=9C=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/commands/media/counter_animation.rs | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src-tauri/src/commands/media/counter_animation.rs b/src-tauri/src/commands/media/counter_animation.rs index 30c94a76..f3814177 100644 --- a/src-tauri/src/commands/media/counter_animation.rs +++ b/src-tauri/src/commands/media/counter_animation.rs @@ -454,6 +454,41 @@ mod tests { ); } + // 프리셋 편집은 백엔드가 모든 모드의 바인딩된 요소를 갱신한다. + // 프론트가 index로 한 번 더 얹지 않는 근거라 값까지 고정한다 + #[test] + fn preset_update_rewrites_every_mode_and_leaves_unbound_alone() { + const OTHER_MODE: &str = "8key"; + let preset = target_preset(); + let mut store = counter_store(true, true, true); + store + .keys + .insert(OTHER_MODE.to_string(), vec!["KeyB".into()]); + store.key_positions.insert( + OTHER_MODE.to_string(), + vec![position(true), position(false)], + ); + + let affected = apply_preset_to_bound_counters(&mut store, TARGET_PRESET_ID, &preset); + + assert_eq!(affected, 4); + + let other = &store.key_positions[OTHER_MODE]; + let bound = &other[0].counter.animation; + assert_eq!(bound.preset_id.as_deref(), Some(TARGET_PRESET_ID)); + assert_eq!(bound.bezier, preset.bezier); + assert_eq!(bound.scale, preset.scale); + assert_eq!(bound.duration_ms, preset.duration_ms); + + // 바인딩되지 않은 요소는 그대로 둔다 + let untouched = &other[1].counter.animation; + let default_animation = KeyPosition::default().counter.animation; + assert_eq!(untouched.preset_id, default_animation.preset_id); + assert_eq!(untouched.bezier, default_animation.bezier); + assert_eq!(untouched.scale, default_animation.scale); + assert_eq!(untouched.duration_ms, default_animation.duration_ms); + } + #[test] fn preset_update_reports_only_actually_changed_collections() { let mut store = counter_store(false, true, false); From 797820bfb065e8baae38af28336f7283941276a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 12 Aug 2026 00:27:53 +0900 Subject: [PATCH 08/35] =?UTF-8?q?fix:=20v1=20=EC=9E=AC=EC=A0=95=EB=A0=AC?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=95=88=EC=A0=95=20ID=EA=B0=80=20?= =?UTF-8?q?=ED=82=A4=20=EC=8A=AC=EB=A1=AF=EC=9D=84=20=EB=94=B0=EB=9D=BC?= =?UTF-8?q?=EA=B0=80=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/state/native_element_id.rs | 295 ++++++++++++++++++++++- 1 file changed, 286 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/state/native_element_id.rs b/src-tauri/src/state/native_element_id.rs index 1403053b..157c4e32 100644 --- a/src-tauri/src/state/native_element_id.rs +++ b/src-tauri/src/state/native_element_id.rs @@ -5,8 +5,9 @@ use uuid::Uuid; use crate::{ errors::EditorCommitError, models::{ - AppStoreData, EditorDocumentV1, EditorPatchV1, GraphPosition, KeyPosition, KnobPosition, - StatPosition, EDITOR_COMMIT_SCHEMA_VERSION_V2, EDITOR_SCHEMA_VERSION, + AppStoreData, EditorDocumentV1, EditorPatchV1, GraphPosition, KeyMappings, KeyPosition, + KeySlot, KnobPosition, StatPosition, EDITOR_COMMIT_SCHEMA_VERSION_V2, + EDITOR_SCHEMA_VERSION, }, }; @@ -300,15 +301,12 @@ fn ordered_current_elements(collection: &HashMap( - current: &HashMap>, +fn keep_or_rekey_supplied_ids( candidate: &mut HashMap>, canonical_ids: &HashSet, consumed_current_ids: &mut HashSet, reserved: &mut HashSet, ) { - let current_elements = ordered_current_elements(current); - for mode in sorted_modes(candidate) { let Some(elements) = candidate.get_mut(&mode) else { continue; @@ -325,7 +323,14 @@ fn adapt_v1_collection( } } } +} +fn inherit_ids_by_value( + current_elements: &[T], + candidate: &mut HashMap>, + consumed_current_ids: &mut HashSet, + reserved: &mut HashSet, +) { for mode in sorted_modes(candidate) { let Some(elements) = candidate.get_mut(&mode) else { continue; @@ -350,6 +355,136 @@ fn adapt_v1_collection( } } +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); + inherit_ids_by_value( + &ordered_current_elements(current), + candidate, + consumed_current_ids, + reserved, + ); +} + +struct SlotPairedPosition { + mode: String, + 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(), + 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, +) { + 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 inherited = current_pairs.iter().find(|pair| { + (!same_mode_only || pair.mode == mode) + && (!match_slot || pair.slot.as_ref() == slot) + && !consumed_current_ids.contains(&pair.position.id) + && same_value_without_id(&pair.position, &*element) + }); + 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 { + adapt_v1_collection( + &store.key_positions, + candidate, + canonical_ids, + 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); + // 웨이브 순서: 같은 모드 슬롯+값 → 모드 간 슬롯+값(모드 이동) → + // 같은 모드 값(재바인딩) → 마지막 전역 값 폴백과 신규 발급 + for (match_slot, same_mode_only) in [(true, true), (true, false), (false, true)] { + consume_slot_paired_ids( + candidate, + patch_keys, + ¤t_pairs, + consumed_current_ids, + match_slot, + same_mode_only, + ); + } + + 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, @@ -364,9 +499,11 @@ fn adapt_v1_patch_ids( 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_collection( - &store.key_positions, + adapt_v1_key_position_ids( + store, + patch_keys.as_ref(), collection, &canonical_ids, &mut consumed_current_ids, @@ -485,7 +622,7 @@ mod tests { use crate::models::{ AppStoreData, EditorPatchV1, GraphPosition, GraphStatType, GraphType, KeyPosition, - KnobPosition, StatPosition, StatType, + KnobPosition, SlotMatch, StatPosition, StatType, }; use super::*; @@ -780,6 +917,146 @@ mod tests { assert_ne!(positions[2].id, second_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_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 { From dd2e9932a6378ceca332aca6a9b0e782396bafd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 12 Aug 2026 00:27:53 +0900 Subject: [PATCH 09/35] =?UTF-8?q?fix:=20=EA=B2=A9=EB=A6=AC=20=EC=BB=A4?= =?UTF-8?q?=EB=B0=8B=EC=9D=98=20canonical=20=EC=9E=AC=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94=EB=A1=9C=20=EC=9A=94=EC=86=8C=20ID=20=EC=9C=A0?= =?UTF-8?q?=EC=8B=A4=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../editor/runtime/editorCoordinator.test.ts | 201 ++++++++++++++++++ .../editor/runtime/editorCoordinator.ts | 46 +++- 2 files changed, 243 insertions(+), 4 deletions(-) diff --git a/src/renderer/editor/runtime/editorCoordinator.test.ts b/src/renderer/editor/runtime/editorCoordinator.test.ts index 26732939..725c6391 100644 --- a/src/renderer/editor/runtime/editorCoordinator.test.ts +++ b/src/renderer/editor/runtime/editorCoordinator.test.ts @@ -411,6 +411,207 @@ describe('EditorSaveCoordinator', () => { harness.coordinator.stop(); }); + // 백엔드 v1 adapter 흉내: 무ID keyPositions에 canonical의 ID를 되살린다. + // 결과 envelope에는 adapted 값이 없으므로 coordinator는 get으로만 알 수 있다 + const emulateV1AdapterCommit = ( + harness: ReturnType, + ) => { + harness.transport.commitMock.mockImplementation(async (request) => { + const before = harness.transport.canonical.document; + const next = applyEditorPatch(before, request.changes); + for (const [mode, positions] of Object.entries(next.keyPositions)) { + positions.forEach((position, index) => { + if (!position.id) { + const inherited = before.keyPositions[mode]?.[index]?.id; + if (inherited) position.id = inherited; + } + }); + } + const changedFields = getChangedEditorFields(before, next); + if (changedFields.length > 0) harness.transport.canonical.revision += 1; + harness.transport.canonical.document = next; + return { + revision: harness.transport.canonical.revision, + changedFields, + }; + }); + }; + + const strippedIdPositions = (document: EditorDocumentV1) => { + const positions = structuredClone(document.keyPositions); + Object.values(positions).forEach((list) => + list.forEach((position) => { + delete position.id; + }), + ); + return positions; + }; + + it('keeps canonical element ids after a no-op idless plugin commit', async () => { + const base = makeDocument('A'); + const idBefore = base.keyPositions['4key'][0].id; + expect(idBefore).toBeTruthy(); + const harness = createHarness(base); + emulateV1AdapterCommit(harness); + await harness.coordinator.start(); + + const result = await harness.coordinator.commitIsolatedPluginPatch( + { + schemaVersion: 1, + keys: base.keys, + keyPositions: strippedIdPositions(base), + }, + { multiKey: false }, + ); + + // 백엔드는 ID를 보존했다 - lastAck가 무ID 요청값으로 덮이면 안 된다 + expect(result.keyPositions['4key'][0].id).toBe(idBefore); + expect(harness.getLocal().keyPositions['4key'][0].id).toBe(idBefore); + }); + + it('keeps canonical element ids after a changing idless plugin commit', async () => { + const base = makeDocument('A'); + const idBefore = base.keyPositions['4key'][0].id; + const harness = createHarness(base); + emulateV1AdapterCommit(harness); + await harness.coordinator.start(); + + const moved = strippedIdPositions(base); + moved['4key'][0].dx += 10; + const result = await harness.coordinator.commitIsolatedPluginPatch( + { schemaVersion: 1, keys: base.keys, keyPositions: moved }, + { multiKey: false }, + ); + + // own committed 이벤트는 revision 선점으로 패치가 무시되므로, + // 커밋 경로 자체가 canonical(ID 포함)을 되찾아야 한다 + expect(result.keyPositions['4key'][0].dx).toBe(moved['4key'][0].dx); + expect(result.keyPositions['4key'][0].id).toBe(idBefore); + expect(harness.getLocal().keyPositions['4key'][0].id).toBe(idBefore); + }); + + it('keeps the previous canonical when the post-commit read fails and recovers on retry', async () => { + const base = makeDocument('A'); + const idBefore = base.keyPositions['4key'][0].id; + const harness = createHarness(base); + emulateV1AdapterCommit(harness); + await harness.coordinator.start(); + harness.transport.getMock.mockRejectedValueOnce( + new Error('ipc unavailable'), + ); + + const moved = strippedIdPositions(base); + moved['4key'][0].dx += 10; + await expect( + harness.coordinator.commitIsolatedPluginPatch( + { schemaVersion: 1, keys: base.keys, keyPositions: moved }, + { multiKey: false }, + ), + ).rejects.toThrow('ipc unavailable'); + + // 무ID target이 lastAck·스토어를 오염시키지 않는다 (이전 canonical 유지) + expect(harness.getLocal().keyPositions['4key'][0].id).toBe(idBefore); + expect(harness.getLocal().keyPositions['4key'][0].dx).toBe( + base.keyPositions['4key'][0].dx, + ); + + // coordinator는 죽은 상태가 아니다 - 재시도가 정상 경로로 복구된다 + const retried = await harness.coordinator.commitIsolatedPluginPatch( + { schemaVersion: 1, keys: base.keys, keyPositions: moved }, + { multiKey: false }, + ); + expect(retried.keyPositions['4key'][0].id).toBe(idBefore); + expect(retried.keyPositions['4key'][0].dx).toBe(moved['4key'][0].dx); + expect(harness.getLocal().keyPositions['4key'][0].dx).toBe( + moved['4key'][0].dx, + ); + harness.coordinator.stop(); + }); + + it('recovers the local store from the own event after a failed post-commit read', async () => { + const base = makeDocument('A'); + const idBefore = base.keyPositions['4key'][0].id; + const harness = createHarness(base); + emulateV1AdapterCommit(harness); + await harness.coordinator.start(); + harness.transport.getMock.mockRejectedValueOnce( + new Error('ipc unavailable'), + ); + + const moved = strippedIdPositions(base); + moved['4key'][0].dx += 10; + await expect( + harness.coordinator.commitIsolatedPluginPatch( + { schemaVersion: 1, keys: base.keys, keyPositions: moved }, + { multiKey: false }, + ), + ).rejects.toThrow('ipc unavailable'); + + // own committed 이벤트가 lastAck뿐 아니라 store까지 복구한다 + const request = harness.transport.commitMock.mock.calls[0][0]; + harness.transport.emit( + eventFor( + harness.transport.canonical.revision, + request.mutationId, + base, + harness.transport.canonical.document, + ), + ); + await vi.waitFor(() => + expect(harness.getLocal().keyPositions['4key'][0].dx).toBe( + moved['4key'][0].dx, + ), + ); + expect(harness.getLocal().keyPositions['4key'][0].id).toBe(idBefore); + + // 후속 flush가 성공한 플러그인 변경을 낡은 로컬로 되돌리지 않는다 + const commitsBefore = harness.transport.commitMock.mock.calls.length; + await harness.coordinator.commitEditorState(); + expect(harness.transport.commitMock.mock.calls.length).toBe(commitsBefore); + harness.coordinator.stop(); + }); + + it('recovers the local store when the own event lands before the failed read', async () => { + const base = makeDocument('A'); + const idBefore = base.keyPositions['4key'][0].id; + const harness = createHarness(base); + emulateV1AdapterCommit(harness); + await harness.coordinator.start(); + harness.transport.getMock.mockImplementationOnce(async () => { + const request = harness.transport.commitMock.mock.calls[0][0]; + harness.transport.emit( + eventFor( + harness.transport.canonical.revision, + request.mutationId, + base, + harness.transport.canonical.document, + ), + ); + throw new Error('ipc unavailable'); + }); + + const moved = strippedIdPositions(base); + moved['4key'][0].dx += 10; + await expect( + harness.coordinator.commitIsolatedPluginPatch( + { schemaVersion: 1, keys: base.keys, keyPositions: moved }, + { multiKey: false }, + ), + ).rejects.toThrow('ipc unavailable'); + + await vi.waitFor(() => + expect(harness.getLocal().keyPositions['4key'][0].dx).toBe( + moved['4key'][0].dx, + ), + ); + expect(harness.getLocal().keyPositions['4key'][0].id).toBe(idBefore); + + const commitsBefore = harness.transport.commitMock.mock.calls.length; + await harness.coordinator.commitEditorState(); + expect(harness.transport.commitMock.mock.calls.length).toBe(commitsBefore); + harness.coordinator.stop(); + }); + it('stamps the wire schema version by transport path', async () => { const base = makeDocument('A'); const harness = createHarness(base); diff --git a/src/renderer/editor/runtime/editorCoordinator.ts b/src/renderer/editor/runtime/editorCoordinator.ts index 0c91660e..6e432019 100644 --- a/src/renderer/editor/runtime/editorCoordinator.ts +++ b/src/renderer/editor/runtime/editorCoordinator.ts @@ -245,6 +245,9 @@ export class EditorSaveCoordinator { private unsubscribeCommitted: EditorReadyUnsubscribe | null = null; private bufferedEvents: EditorCommittedV1[] = []; private ownMutations = new Set(); + // 낙관 적용 없이 커밋되는 격리 플러그인 mutation. own 이벤트가 도착하면 + // store 적용까지 필요하다는 표시 + private isolatedMutations = new Set(); private listeners = new Set<(state: EditorCoordinatorState) => void>(); private readonly handleFocus = () => { @@ -485,7 +488,7 @@ export class EditorSaveCoordinator { gestureIds: [], }; this.inFlight = inFlight; - this.rememberOwnMutation(inFlight); + this.rememberOwnMutation(inFlight, true); this.phase = 'saving'; this.notify(); @@ -500,7 +503,18 @@ export class EditorSaveCoordinator { multiKey: options.multiKey === true, }); assertEditorCommitResult(result); - await this.applyCommitResult(inFlight, result); + // v1 adapter가 무ID 요소에 ID를 채우므로 canonical은 요청 target과 + // 다를 수 있고, 결과 envelope에는 adapted 값이 없다. target을 lastAck로 + // 승인하지 않고 canonical을 직접 읽어 성공했을 때만 전진한다 - 읽기가 + // 실패하면 revision을 이전 값에 묶어 두어 own committed 이벤트가 + // 선점 없이 patch를 적용해 자가 복구한다 (no-op이면 canonical이 + // 이전과 같아 복구할 것이 없다) + const canonical = await this.transport.get(); + assertEditorGetResult(canonical); + if (canonical.revision >= this.requireRevision()) { + this.revision = canonical.revision; + this.lastAck = clone(canonical.document); + } // 실행 창의 로컬 문서도 canonical로 갱신 - 격리 커밋은 낙관 적용을 // 거치지 않으므로 여기서 반영하지 않으면 이후 flush가 낡은 로컬을 // 새 편집으로 계산해 방금 성공한 변경을 되돌린다 @@ -1076,6 +1090,7 @@ export class EditorSaveCoordinator { if (event.revision <= this.revision) { this.ownMutations.delete(event.mutationId); + this.isolatedMutations.delete(event.mutationId); this.onCommittedApplied?.(event); return; } @@ -1098,7 +1113,25 @@ export class EditorSaveCoordinator { if (isOwnMutation) { this.error = null; - this.notify(); + // 격리 커밋은 낙관 적용이 없다. 커밋 경로의 canonical 재동기화가 + // 실패했을 때만 이 분기까지 오므로 store에도 적용해야 다음 flush가 + // 성공한 플러그인 변경을 낡은 로컬로 되돌리지 않는다 + if (this.isolatedMutations.delete(event.mutationId)) { + // 아직 해제 전인 자기 inFlight의 무ID target을 pending으로 오인해 + // canonical 위에 되얹지 않게 먼저 내린다 + if (this.inFlight?.mutationId === event.mutationId) { + this.inFlight = null; + } + this.applyExternalCanonical( + canonical, + event.changedFields, + event.revision, + 'event', + previousCanonical, + ); + } else { + this.notify(); + } this.onCommittedApplied?.(event); return; } @@ -1288,12 +1321,17 @@ export class EditorSaveCoordinator { this.notify(); } - private rememberOwnMutation(inFlight: InFlightCommit): void { + private rememberOwnMutation( + inFlight: InFlightCommit, + isolated = false, + ): void { this.ownMutations.add(inFlight.mutationId); + if (isolated) this.isolatedMutations.add(inFlight.mutationId); while (this.ownMutations.size > MAX_TRACKED_MUTATIONS) { const oldest = this.ownMutations.values().next().value; if (oldest === undefined) break; this.ownMutations.delete(oldest); + this.isolatedMutations.delete(oldest); } } From e55469c4c62df59e02fa34259fdc49b342236590 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 12 Aug 2026 00:59:40 +0900 Subject: [PATCH 10/35] =?UTF-8?q?test:=20=EA=B2=A9=EB=A6=AC=20=EC=BB=A4?= =?UTF-8?q?=EB=B0=8B=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=8C=80=EC=97=AD?= =?UTF-8?q?=EC=9D=84=20=EC=8B=A4=EB=B0=B1=EC=97=94=EB=93=9C=20=EA=B3=84?= =?UTF-8?q?=EC=95=BD=EC=97=90=20=EB=A7=9E=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../editor/runtime/editorCoordinator.test.ts | 89 +++++++++++++------ 1 file changed, 63 insertions(+), 26 deletions(-) diff --git a/src/renderer/editor/runtime/editorCoordinator.test.ts b/src/renderer/editor/runtime/editorCoordinator.test.ts index 725c6391..63e79847 100644 --- a/src/renderer/editor/runtime/editorCoordinator.test.ts +++ b/src/renderer/editor/runtime/editorCoordinator.test.ts @@ -411,20 +411,32 @@ describe('EditorSaveCoordinator', () => { harness.coordinator.stop(); }); - // 백엔드 v1 adapter 흉내: 무ID keyPositions에 canonical의 ID를 되살린다. - // 결과 envelope에는 adapted 값이 없으므로 coordinator는 get으로만 알 수 있다 + // 백엔드 v1 adapter 흉내 (계약 §3 충실 재현): 무ID 요소는 같은 자리의 + // 값 일치만 ID를 승계하고, 값이 수정된 요소는 새 ID를 발급한다. stale + // baseRevision은 실백엔드처럼 REVISION_CONFLICT로 거절한다. 결과 + // envelope에는 adapted 값이 없으므로 coordinator는 get으로만 알 수 있다 const emulateV1AdapterCommit = ( harness: ReturnType, ) => { + let issued = 0; + const valueWithoutId = (position: Record) => { + const { id: _id, ...rest } = position; + return JSON.stringify(rest); + }; harness.transport.commitMock.mockImplementation(async (request) => { + if (request.baseRevision !== harness.transport.canonical.revision) { + throw revisionConflict(); + } const before = harness.transport.canonical.document; const next = applyEditorPatch(before, request.changes); for (const [mode, positions] of Object.entries(next.keyPositions)) { positions.forEach((position, index) => { - if (!position.id) { - const inherited = before.keyPositions[mode]?.[index]?.id; - if (inherited) position.id = inherited; - } + if (position.id) return; + const current = before.keyPositions[mode]?.[index]; + position.id = + current?.id && valueWithoutId(current) === valueWithoutId(position) + ? current.id + : `fresh-${(issued += 1)}`; }); } const changedFields = getChangedEditorFields(before, next); @@ -469,7 +481,7 @@ describe('EditorSaveCoordinator', () => { expect(harness.getLocal().keyPositions['4key'][0].id).toBe(idBefore); }); - it('keeps canonical element ids after a changing idless plugin commit', async () => { + it('mirrors adapter-issued ids after a changing idless plugin commit', async () => { const base = makeDocument('A'); const idBefore = base.keyPositions['4key'][0].id; const harness = createHarness(base); @@ -483,14 +495,19 @@ describe('EditorSaveCoordinator', () => { { multiKey: false }, ); - // own committed 이벤트는 revision 선점으로 패치가 무시되므로, - // 커밋 경로 자체가 canonical(ID 포함)을 되찾아야 한다 + // 값이 수정된 무ID 요소는 계약(§3)상 새 ID를 받는다. own committed + // 이벤트는 revision 선점으로 패치가 무시되므로 커밋 경로 자체가 + // 백엔드가 발급한 canonical ID를 되찾아 비춰야 한다 + const adaptedId = + harness.transport.canonical.document.keyPositions['4key'][0].id; + expect(adaptedId).toBeTruthy(); + expect(adaptedId).not.toBe(idBefore); expect(result.keyPositions['4key'][0].dx).toBe(moved['4key'][0].dx); - expect(result.keyPositions['4key'][0].id).toBe(idBefore); - expect(harness.getLocal().keyPositions['4key'][0].id).toBe(idBefore); + expect(result.keyPositions['4key'][0].id).toBe(adaptedId); + expect(harness.getLocal().keyPositions['4key'][0].id).toBe(adaptedId); }); - it('keeps the previous canonical when the post-commit read fails and recovers on retry', async () => { + it('recovers via sync when the post-commit read and own event are both lost', async () => { const base = makeDocument('A'); const idBefore = base.keyPositions['4key'][0].id; const harness = createHarness(base); @@ -502,11 +519,15 @@ describe('EditorSaveCoordinator', () => { const moved = strippedIdPositions(base); moved['4key'][0].dx += 10; + const patch = { + schemaVersion: 1 as const, + keys: base.keys, + keyPositions: moved, + }; await expect( - harness.coordinator.commitIsolatedPluginPatch( - { schemaVersion: 1, keys: base.keys, keyPositions: moved }, - { multiKey: false }, - ), + harness.coordinator.commitIsolatedPluginPatch(patch, { + multiKey: false, + }), ).rejects.toThrow('ipc unavailable'); // 무ID target이 lastAck·스토어를 오염시키지 않는다 (이전 canonical 유지) @@ -515,16 +536,27 @@ describe('EditorSaveCoordinator', () => { base.keyPositions['4key'][0].dx, ); - // coordinator는 죽은 상태가 아니다 - 재시도가 정상 경로로 복구된다 - const retried = await harness.coordinator.commitIsolatedPluginPatch( - { schemaVersion: 1, keys: base.keys, keyPositions: moved }, - { multiKey: false }, - ); - expect(retried.keyPositions['4key'][0].id).toBe(idBefore); - expect(retried.keyPositions['4key'][0].dx).toBe(moved['4key'][0].dx); + // revision이 뒤처진 즉시 재시도는 실백엔드 계약대로 충돌한다 + await expect( + harness.coordinator.commitIsolatedPluginPatch(patch, { + multiKey: false, + }), + ).rejects.toMatchObject({ errorCode: 'REVISION_CONFLICT' }); + + // 복구 경로: sync가 canonical(발급된 ID 포함)을 revision·lastAck·스토어에 적용 + await harness.coordinator.sync(); + const adaptedId = + harness.transport.canonical.document.keyPositions['4key'][0].id; + expect(harness.getLocal().keyPositions['4key'][0].id).toBe(adaptedId); expect(harness.getLocal().keyPositions['4key'][0].dx).toBe( moved['4key'][0].dx, ); + + // 복구 후 재시도는 정상 완료된다 (값 일치라 승계, no-op) + const retried = await harness.coordinator.commitIsolatedPluginPatch(patch, { + multiKey: false, + }); + expect(retried.keyPositions['4key'][0].id).toBe(adaptedId); harness.coordinator.stop(); }); @@ -562,7 +594,10 @@ describe('EditorSaveCoordinator', () => { moved['4key'][0].dx, ), ); - expect(harness.getLocal().keyPositions['4key'][0].id).toBe(idBefore); + expect(harness.getLocal().keyPositions['4key'][0].id).toBe( + harness.transport.canonical.document.keyPositions['4key'][0].id, + ); + expect(harness.getLocal().keyPositions['4key'][0].id).toBeTruthy(); // 후속 flush가 성공한 플러그인 변경을 낡은 로컬로 되돌리지 않는다 const commitsBefore = harness.transport.commitMock.mock.calls.length; @@ -573,7 +608,6 @@ describe('EditorSaveCoordinator', () => { it('recovers the local store when the own event lands before the failed read', async () => { const base = makeDocument('A'); - const idBefore = base.keyPositions['4key'][0].id; const harness = createHarness(base); emulateV1AdapterCommit(harness); await harness.coordinator.start(); @@ -604,7 +638,10 @@ describe('EditorSaveCoordinator', () => { moved['4key'][0].dx, ), ); - expect(harness.getLocal().keyPositions['4key'][0].id).toBe(idBefore); + expect(harness.getLocal().keyPositions['4key'][0].id).toBe( + harness.transport.canonical.document.keyPositions['4key'][0].id, + ); + expect(harness.getLocal().keyPositions['4key'][0].id).toBeTruthy(); const commitsBefore = harness.transport.commitMock.mock.calls.length; await harness.coordinator.commitEditorState(); From 142f7f3a103e3c9f2460afcc092d9491fdeb98c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 12 Aug 2026 01:54:47 +0900 Subject: [PATCH 11/35] =?UTF-8?q?fix:=20=EA=B2=A9=EB=A6=AC=20=EC=BB=A4?= =?UTF-8?q?=EB=B0=8B=20target=EC=9D=84=20pending=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=98=A4=EC=9D=B8=ED=95=98=EC=A7=80=20=EC=95=8A=EA=B2=8C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../editor/runtime/editorCoordinator.test.ts | 105 +++++++++++++++++- .../editor/runtime/editorCoordinator.ts | 41 +++++-- 2 files changed, 131 insertions(+), 15 deletions(-) diff --git a/src/renderer/editor/runtime/editorCoordinator.test.ts b/src/renderer/editor/runtime/editorCoordinator.test.ts index 63e79847..ca36d138 100644 --- a/src/renderer/editor/runtime/editorCoordinator.test.ts +++ b/src/renderer/editor/runtime/editorCoordinator.test.ts @@ -507,7 +507,7 @@ describe('EditorSaveCoordinator', () => { expect(harness.getLocal().keyPositions['4key'][0].id).toBe(adaptedId); }); - it('recovers via sync when the post-commit read and own event are both lost', async () => { + it('resyncs on retry conflict so plugin retries recover without ui sync', async () => { const base = makeDocument('A'); const idBefore = base.keyPositions['4key'][0].id; const harness = createHarness(base); @@ -536,15 +536,15 @@ describe('EditorSaveCoordinator', () => { base.keyPositions['4key'][0].dx, ); - // revision이 뒤처진 즉시 재시도는 실백엔드 계약대로 충돌한다 + // revision이 뒤처진 재시도는 실백엔드 계약대로 충돌하되, 격리 경로가 + // conflict에서 canonical을 재동기화해 둔다 (플러그인은 sync 접근 불가) await expect( harness.coordinator.commitIsolatedPluginPatch(patch, { multiKey: false, }), ).rejects.toMatchObject({ errorCode: 'REVISION_CONFLICT' }); - // 복구 경로: sync가 canonical(발급된 ID 포함)을 revision·lastAck·스토어에 적용 - await harness.coordinator.sync(); + // 별도 ui sync 없이도 conflict 반환 시점에 이미 복구돼 있다 const adaptedId = harness.transport.canonical.document.keyPositions['4key'][0].id; expect(harness.getLocal().keyPositions['4key'][0].id).toBe(adaptedId); @@ -552,7 +552,7 @@ describe('EditorSaveCoordinator', () => { moved['4key'][0].dx, ); - // 복구 후 재시도는 정상 완료된다 (값 일치라 승계, no-op) + // 다음 재시도는 정상 완료된다 (값 일치라 승계, no-op) const retried = await harness.coordinator.commitIsolatedPluginPatch(patch, { multiKey: false, }); @@ -562,7 +562,6 @@ describe('EditorSaveCoordinator', () => { it('recovers the local store from the own event after a failed post-commit read', async () => { const base = makeDocument('A'); - const idBefore = base.keyPositions['4key'][0].id; const harness = createHarness(base); emulateV1AdapterCommit(harness); await harness.coordinator.start(); @@ -649,6 +648,100 @@ describe('EditorSaveCoordinator', () => { harness.coordinator.stop(); }); + it('does not mistake an isolated in-flight target for pending when an external event lands first', async () => { + const base = makeDocument('A'); + const idBefore = base.keyPositions['4key'][0].id; + const harness = createHarness(base); + await harness.coordinator.start(); + + // 다른 창의 커밋이 먼저 반영됨 - 이벤트가 격리 커밋 in-flight 중 도착하고 + // 격리 커밋은 실백엔드처럼 stale base로 거절된다 + const external = structuredClone(base); + external.keys['4key'] = ['B']; + harness.transport.commitMock.mockImplementationOnce(async () => { + harness.transport.canonical = { + revision: 1, + document: structuredClone(external), + }; + harness.transport.emit(eventFor(1, 'external-1', base, external)); + await new Promise((resolve) => setTimeout(resolve, 0)); + throw revisionConflict(); + }); + + const moved = strippedIdPositions(base); + moved['4key'][0].dx += 10; + await expect( + harness.coordinator.commitIsolatedPluginPatch( + { schemaVersion: 1, keys: base.keys, keyPositions: moved }, + { multiKey: false }, + ), + ).rejects.toMatchObject({ errorCode: 'REVISION_CONFLICT' }); + + // 화면은 거절된 플러그인 값이 아니라 외부 canonical이어야 한다 + await vi.waitFor(() => + expect(harness.getLocal().keys['4key']).toEqual(['B']), + ); + expect(harness.getLocal().keyPositions['4key'][0].dx).toBe( + base.keyPositions['4key'][0].dx, + ); + expect(harness.getLocal().keyPositions['4key'][0].id).toBe(idBefore); + harness.coordinator.stop(); + }); + + it('keeps canonical ids when a late own event overlaps a retrying isolated commit', async () => { + const base = makeDocument('A'); + const harness = createHarness(base); + emulateV1AdapterCommit(harness); + await harness.coordinator.start(); + harness.transport.getMock.mockRejectedValueOnce( + new Error('ipc unavailable'), + ); + + const moved = strippedIdPositions(base); + moved['4key'][0].dx += 10; + const patch = { + schemaVersion: 1 as const, + keys: base.keys, + keyPositions: moved, + }; + await expect( + harness.coordinator.commitIsolatedPluginPatch(patch, { + multiKey: false, + }), + ).rejects.toThrow('ipc unavailable'); + const request = harness.transport.commitMock.mock.calls[0][0]; + const adapted = structuredClone(harness.transport.canonical.document); + + // 재시도가 전송 대기 중일 때 늦은 own 이벤트가 도착한다 + const gate = deferred(); + harness.transport.commitMock.mockImplementationOnce(() => gate.promise); + const retry = harness.coordinator.commitIsolatedPluginPatch(patch, { + multiKey: false, + }); + await vi.waitFor(() => + expect(harness.transport.commitMock.mock.calls.length).toBe(2), + ); + harness.transport.emit(eventFor(1, request.mutationId, base, adapted)); + await vi.waitFor(() => + expect(harness.getLocal().keyPositions['4key'][0].id).toBe( + adapted.keyPositions['4key'][0].id, + ), + ); + + // 재시도는 실백엔드처럼 stale base로 거절되고, canonical UUID는 유지된다 + gate.reject(revisionConflict()); + await expect(retry).rejects.toMatchObject({ + errorCode: 'REVISION_CONFLICT', + }); + expect(harness.getLocal().keyPositions['4key'][0].id).toBe( + adapted.keyPositions['4key'][0].id, + ); + expect(harness.getLocal().keyPositions['4key'][0].dx).toBe( + moved['4key'][0].dx, + ); + harness.coordinator.stop(); + }); + it('stamps the wire schema version by transport path', async () => { const base = makeDocument('A'); const harness = createHarness(base); diff --git a/src/renderer/editor/runtime/editorCoordinator.ts b/src/renderer/editor/runtime/editorCoordinator.ts index 6e432019..0d03ef35 100644 --- a/src/renderer/editor/runtime/editorCoordinator.ts +++ b/src/renderer/editor/runtime/editorCoordinator.ts @@ -120,6 +120,9 @@ interface InFlightCommit { localFields: EditorField[]; requestFields: EditorField[]; gestureIds: string[]; + // 낙관 적용 없이 전송되는 격리 플러그인 커밋. 승인 전 target을 + // 로컬 pending이나 커밋 base로 세면 안 된다 + isolated?: boolean; } const MAX_AUTO_REBASE_ATTEMPTS = 2; @@ -486,6 +489,7 @@ export class EditorSaveCoordinator { localFields: getChangedEditorFields(baseDocument, target), requestFields, gestureIds: [], + isolated: true, }; this.inFlight = inFlight; this.rememberOwnMutation(inFlight, true); @@ -526,7 +530,21 @@ export class EditorSaveCoordinator { return clone(this.requireLastAck()); } catch (error) { // 거절돼도 코디네이터는 건강한 상태 유지 - 오류는 플러그인 호출자에게만 - // 전파하고 pending은 보존하지 않음 (store·로컬 모두 불변) + // 전파하고 pending은 보존하지 않음 (conflict resync 성공 시에만 로컬이 + // canonical로 전진) + if ( + isEditorCommitError(error) && + error.errorCode === 'REVISION_CONFLICT' + ) { + // 플러그인 호출자는 sync()에 접근할 수 없다. revision이 뒤처져 + // 거절됐다면 canonical만 재동기화해 다음 재시도가 성공하게 한다. + // patch 자동 재적용은 하지 않는다 - concurrent writer를 덮을 수 있다 + try { + await this.fetchAndApplyCanonical('resync'); + } catch { + // 재동기화 실패 시 원래 conflict 오류를 유지 + } + } if (!this.conflict && !this.stopped) this.phase = 'idle'; this.notify(); throw error; @@ -1117,11 +1135,6 @@ export class EditorSaveCoordinator { // 실패했을 때만 이 분기까지 오므로 store에도 적용해야 다음 flush가 // 성공한 플러그인 변경을 낡은 로컬로 되돌리지 않는다 if (this.isolatedMutations.delete(event.mutationId)) { - // 아직 해제 전인 자기 inFlight의 무ID target을 pending으로 오인해 - // canonical 위에 되얹지 않게 먼저 내린다 - if (this.inFlight?.mutationId === event.mutationId) { - this.inFlight = null; - } this.applyExternalCanonical( canonical, event.changedFields, @@ -1238,10 +1251,19 @@ export class EditorSaveCoordinator { this.notify(); } + // 격리 커밋의 target은 낙관 적용된 로컬 편집이 아니다. pending·base로 + // 세면 외부 이벤트 병합이 미승인 플러그인 값(무ID)을 되얹거나, flush가 + // 플러그인 필드를 사용자 의도로 오인한다 + private optimisticInFlight(): InFlightCommit | null { + if (!this.inFlight || this.inFlight.isolated) return null; + return this.inFlight; + } + private getLatestCommitBase(): EditorDocumentV1 { if (this.conflict) return clone(this.conflict.pendingLocal); if (this.pendingLocal) return clone(this.pendingLocal); - if (this.inFlight) return clone(this.inFlight.target); + const optimistic = this.optimisticInFlight(); + if (optimistic) return clone(optimistic.target); return clone(this.requireLastAck()); } @@ -1249,7 +1271,7 @@ export class EditorSaveCoordinator { return ( this.conflict?.pendingLocal ?? this.pendingLocal ?? - this.inFlight?.target ?? + this.optimisticInFlight()?.target ?? null ); } @@ -1260,7 +1282,8 @@ export class EditorSaveCoordinator { ): EditorField[] { if (this.conflict) return [...this.conflict.localFields]; if (this.pendingLocal) return [...this.pendingFields]; - if (this.inFlight) return [...this.inFlight.localFields]; + const optimistic = this.optimisticInFlight(); + if (optimistic) return [...optimistic.localFields]; return getChangedEditorFields(comparisonBase, pending); } From 5ea6cb00c65f2042e956b9f77544a1b83b1c5b44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 12 Aug 2026 02:51:36 +0900 Subject: [PATCH 12/35] =?UTF-8?q?fix:=20=EC=9E=90=EC=82=AC=20=EC=BB=A4?= =?UTF-8?q?=EB=B0=8B=20base=EA=B0=80=20=EA=B2=A9=EB=A6=AC=20in-flight=20ta?= =?UTF-8?q?rget=EC=9D=84=20=EC=B0=B8=EC=A1=B0=ED=95=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../editor/runtime/editorCoordinator.test.ts | 79 +++++++++++++++++++ .../editor/runtime/editorCoordinator.ts | 2 +- 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/src/renderer/editor/runtime/editorCoordinator.test.ts b/src/renderer/editor/runtime/editorCoordinator.test.ts index ca36d138..e70cae49 100644 --- a/src/renderer/editor/runtime/editorCoordinator.test.ts +++ b/src/renderer/editor/runtime/editorCoordinator.test.ts @@ -742,6 +742,85 @@ describe('EditorSaveCoordinator', () => { harness.coordinator.stop(); }); + it('keeps first-party commit bases off the isolated in-flight target', async () => { + const base = makeDocument('A'); + const idBefore = base.keyPositions['4key'][0].id; + // start()는 호출마다 이 훅을 기다린다. 자사 호출만 여기서 정지시켜 + // "빈 tail 통과 -> start 대기 중 격리 커밋이 in-flight" TOCTOU 순서를 + // 결정적으로 만든다 + const startGates: Array> = []; + const harness = createHarness(base, { + onStartSucceeded: () => { + const gate = deferred(); + startGates.push(gate); + return gate.promise; + }, + }); + const starting = harness.coordinator.start(); + await vi.waitFor(() => expect(startGates.length).toBe(1)); + startGates[0].resolve(undefined); + await starting; + + // 자사 커밋이 먼저 진입해 start 훅에서 대기한다 + const firstParty = harness.coordinator.commitPatch({ + schemaVersion: 1, + layerGroups: { '4key': [{ id: 'group-1', name: 'group-1' }] }, + }); + await vi.waitFor(() => expect(startGates.length).toBe(2)); + + // 그 사이 격리 커밋이 in-flight가 된다 + const commitGate = deferred(); + harness.transport.commitMock.mockImplementationOnce( + () => commitGate.promise, + ); + const moved = strippedIdPositions(base); + moved['4key'][0].dx += 10; + const isolated = harness.coordinator.commitIsolatedPluginPatch( + { schemaVersion: 1, keys: base.keys, keyPositions: moved }, + { multiKey: false }, + ); + await vi.waitFor(() => expect(startGates.length).toBe(3)); + startGates[2].resolve(undefined); + await vi.waitFor(() => + expect(harness.transport.commitMock).toHaveBeenCalledOnce(), + ); + + // 자사 호출이 재개되어 base를 계산한다 - 미승인 격리 target이 base면 + // 오염은 wire가 아니라 lastAck 승인으로 귀결된다 (아래 단언) + startGates[1].resolve(undefined); + await vi.waitFor(() => + expect(harness.transport.commitMock).toHaveBeenCalledTimes(2), + ); + const firstPartyRequest = harness.transport.commitMock.mock.calls[1][0]; + expect(firstPartyRequest.changes.layerGroups).toBeDefined(); + expect(firstPartyRequest.changes.keyPositions).toBeUndefined(); + + // 오염은 wire가 아니라 lastAck로 귀결된다 - base가 격리 target이면 + // 자사 커밋 성공 시 applyCommitResult가 무ID keyPositions를 lastAck로 + // 승인하고, 다음 flush가 이를 근거로 플러그인 변경을 되돌린다 + await firstParty; + const acknowledged = harness.coordinator.getState().lastAck!; + expect(acknowledged.keyPositions['4key'][0].id).toBe(idBefore); + expect(harness.getLocal().keyPositions['4key'][0].id).toBe(idBefore); + + // 정리: 격리 커밋을 adapted canonical로 완료시킨다 + const adapted = structuredClone( + harness.transport.canonical.document, + ) as EditorDocumentV1; + adapted.keyPositions['4key'][0].dx = moved['4key'][0].dx; + harness.transport.canonical = { + revision: harness.transport.canonical.revision + 1, + document: structuredClone(adapted), + }; + commitGate.resolve({ + revision: harness.transport.canonical.revision, + changedFields: ['keyPositions'], + }); + await isolated; + await firstParty; + harness.coordinator.stop(); + }); + it('stamps the wire schema version by transport path', async () => { const base = makeDocument('A'); const harness = createHarness(base); diff --git a/src/renderer/editor/runtime/editorCoordinator.ts b/src/renderer/editor/runtime/editorCoordinator.ts index 0d03ef35..a321e8d6 100644 --- a/src/renderer/editor/runtime/editorCoordinator.ts +++ b/src/renderer/editor/runtime/editorCoordinator.ts @@ -376,7 +376,7 @@ export class EditorSaveCoordinator { } const outstandingFields = new Set([ - ...(this.inFlight?.localFields ?? []), + ...(this.optimisticInFlight()?.localFields ?? []), ...this.pendingFields, ...newIntentFields, ]); From 1790d73753209d249e51b0590172648b570379ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 12 Aug 2026 04:58:44 +0900 Subject: [PATCH 13/35] =?UTF-8?q?fix:=20=EB=B9=84=EB=8F=99=EA=B8=B0=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EC=BB=A4=EB=B0=8B=EC=9D=84=20=EC=A7=81?= =?UTF-8?q?=EB=A0=AC=20=EC=8A=AC=EB=A1=AF=EC=97=90=EC=84=9C=20=EC=9E=AC?= =?UTF-8?q?=EC=83=9D=EC=84=B1=ED=95=B4=20=EB=8C=80=EA=B8=B0=20=EC=A4=91=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EC=86=90=EC=8B=A4=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../singleStyleAsyncCompletion.test.tsx | 44 ++- .../editor/runtime/editorCoordinator.test.ts | 265 ++++++++++++++++ .../editor/runtime/editorCoordinator.ts | 29 +- .../editor/runtime/elementPatch.test.ts | 272 ++++++++++++++--- src/renderer/editor/runtime/elementPatch.ts | 284 ++++++++++++------ 5 files changed, 763 insertions(+), 131 deletions(-) diff --git a/src/renderer/__tests__/singleStyleAsyncCompletion.test.tsx b/src/renderer/__tests__/singleStyleAsyncCompletion.test.tsx index 460a408e..0ef4a576 100644 --- a/src/renderer/__tests__/singleStyleAsyncCompletion.test.tsx +++ b/src/renderer/__tests__/singleStyleAsyncCompletion.test.tsx @@ -18,6 +18,7 @@ const api = vi.hoisted(() => ({ async (_positions: KeyPositions, _gestureId?: string) => ({}), ), updateMappingsAndPositionsWithGesture: vi.fn(async () => ({})), + commitGeneratedPatch: vi.fn(), })); vi.mock('@api/modules/keysApi', () => ({ @@ -25,6 +26,9 @@ vi.mock('@api/modules/keysApi', () => ({ updateMappingsAndPositionsWithGesture: api.updateMappingsAndPositionsWithGesture, })); +vi.mock('@src/renderer/editor/runtime/editorStateCoordinator', () => ({ + editorCoordinator: { commitGeneratedPatch: api.commitGeneratedPatch }, +})); vi.mock('@api/modules/editorApi', () => ({ editorApi: { get: vi.fn(), @@ -67,6 +71,8 @@ import { PanelNavProvider } from '@components/main/Grid/PropertiesPanel/PanelNav import { useKeyStore } from '@stores/data/useKeyStore'; import StyleTabContent from '@components/main/Grid/PropertiesPanel/single/StyleTabContent'; +import type { EditorDocumentV1, EditorPatchV1 } from '@src/types/editor'; + globalThis.IS_REACT_ACT_ENVIRONMENT = true; const ID_TARGET = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; @@ -92,9 +98,30 @@ describe('단일 스타일 패널 비동기 이미지 완료', () => { let onKeyUpdate: Mock< (data: Partial & { index: number }) => void >; + const generatedPatches: Array = []; beforeEach(() => { vi.clearAllMocks(); + generatedPatches.length = 0; + // 슬롯 시점 base는 호출 시점 스토어 상태 - 대기 중 재정렬·삭제가 이미 + // 스토어에 반영된 뒤 생성됨을 재현 + api.commitGeneratedPatch.mockImplementation( + async (generate: (base: EditorDocumentV1) => EditorPatchV1 | null) => { + const base = { + schemaVersion: 1, + keys: {}, + keyPositions: structuredClone( + useKeyStore.getState().canonicalPositions, + ), + statPositions: {}, + graphPositions: {}, + knobPositions: {}, + layerGroups: {}, + } as EditorDocumentV1; + generatedPatches.push(generate(base)); + return base; + }, + ); onKeyUpdate = vi.fn(); useKeyStore.setState({ selectedKeyType: '4key', @@ -174,15 +201,14 @@ describe('단일 스타일 패널 비동기 이미지 완료', () => { }); await finishLoad(); - expect(api.updatePositionsWithGesture).toHaveBeenCalledTimes(1); - const persisted = api.updatePositionsWithGesture.mock.calls[0][0]; - expect(persisted['4key'][0].id).toBe(ID_TARGET); - expect(persisted['4key'][0].inactiveImage).toBe('/tmp/picked.png'); - expect(persisted['4key'][1].inactiveImage ?? '').toBe(''); - // 레거시 index writer는 우회된다 + expect(api.commitGeneratedPatch).toHaveBeenCalledTimes(1); + const persisted = generatedPatches[0]?.keyPositions; + expect(persisted?.['4key'][0].id).toBe(ID_TARGET); + expect(persisted?.['4key'][0].inactiveImage).toBe('/tmp/picked.png'); + expect(persisted?.['4key'][1].inactiveImage ?? '').toBe(''); + // 레거시 index writer와 캡처 레코드 경로는 우회된다 expect(onKeyUpdate).not.toHaveBeenCalled(); - // wire에 gestureId 없음 - expect(api.updatePositionsWithGesture.mock.calls[0][1]).toBeUndefined(); + expect(api.updatePositionsWithGesture).not.toHaveBeenCalled(); }); it('대기 중 요소가 삭제되면 아무것도 쓰지 않는다', async () => { @@ -195,6 +221,8 @@ describe('단일 스타일 패널 비동기 이미지 완료', () => { }); await finishLoad(); + // 슬롯 재판정까지 가되 wire에는 아무것도 싣지 않는다 + expect(generatedPatches).toEqual([null]); expect(api.updatePositionsWithGesture).not.toHaveBeenCalled(); expect(onKeyUpdate).not.toHaveBeenCalled(); }); diff --git a/src/renderer/editor/runtime/editorCoordinator.test.ts b/src/renderer/editor/runtime/editorCoordinator.test.ts index e70cae49..db0d1123 100644 --- a/src/renderer/editor/runtime/editorCoordinator.test.ts +++ b/src/renderer/editor/runtime/editorCoordinator.test.ts @@ -15,6 +15,7 @@ import { createEditorPatch, getChangedEditorFields, } from './editorCoordinator'; +import { enqueueEditorCompatibilityWrite } from './editorCompatibilityQueue'; import type { EditorCommitError, @@ -1756,3 +1757,267 @@ describe('EditorSaveCoordinator', () => { harness.coordinator.stop(); }); }); +// 지연 생성 커밋: 호출 시점 캡처 patch가 대기 중 정산된 다른 커밋의 같은 +// 컬렉션 값을 되돌리는 lost-update의 방어 경로 +describe('commitGeneratedPatch', () => { + const gatedDefaultCommit = ( + harness: ReturnType, + gate: Promise, + ) => { + harness.transport.commitMock.mockImplementationOnce(async (request) => { + await gate; + const before = harness.transport.canonical.document; + const next = applyEditorPatch(before, request.changes); + const changedFields = getChangedEditorFields(before, next); + if (changedFields.length > 0) harness.transport.canonical.revision += 1; + harness.transport.canonical.document = next; + return { + revision: harness.transport.canonical.revision, + changedFields, + }; + }); + }; + + const imageRecordFrom = (base: EditorDocumentV1) => { + const record = structuredClone(base.keyPositions); + record['4key'] = record['4key'].map((position, index) => + index === 0 ? { ...position, inactiveImage: 'generated.png' } : position, + ); + return record; + }; + + it('게스처 in-flight 완료 후의 base에서 생성해 양쪽 값을 보존한다', async () => { + const base = makeDocument('A'); + const harness = createHarness(base); + await harness.coordinator.start(); + + const gate = deferred(); + gatedDefaultCommit(harness, gate.promise); + const gesture = harness.coordinator.commitGesture( + { schemaVersion: 1, keys: { '4key': ['G'] } }, + 'gesture-g', + async (context) => + harness.transport.commit({ + baseRevision: context.editorBaseRevision, + mutationId: context.mutationId, + changes: context.editorChanges!, + }), + ); + + const generatorSpy = vi.fn((latest: EditorDocumentV1) => ({ + schemaVersion: 1 as const, + keyPositions: imageRecordFrom(latest), + })); + const generated = harness.coordinator.commitGeneratedPatch(generatorSpy); + + // 게스처가 정산되기 전에는 생성하지 않는다 + await Promise.resolve(); + await Promise.resolve(); + expect(generatorSpy).not.toHaveBeenCalled(); + + gate.resolve(); + await gesture; + await generated; + + // 생성 base에 게스처 결과가 이미 반영돼 있다 + expect(generatorSpy.mock.calls[0][0].keys['4key']).toEqual(['G']); + const finalDocument = harness.transport.canonical.document; + expect(finalDocument.keys['4key']).toEqual(['G']); + expect(finalDocument.keyPositions['4key'][0].inactiveImage).toBe( + 'generated.png', + ); + harness.coordinator.stop(); + }); + + it('격리 플러그인 커밋 선행 시 그 결과 위에서 생성한다', async () => { + const base = makeDocument('A'); + const harness = createHarness(base); + await harness.coordinator.start(); + + const gate = deferred(); + gatedDefaultCommit(harness, gate.promise); + const isolated = harness.coordinator.commitIsolatedPluginPatch( + { schemaVersion: 1, keys: { '4key': ['P'] } }, + { multiKey: false }, + ); + + const generatorSpy = vi.fn((latest: EditorDocumentV1) => ({ + schemaVersion: 1 as const, + keyPositions: imageRecordFrom(latest), + })); + const generated = harness.coordinator.commitGeneratedPatch(generatorSpy); + + gate.resolve(); + await isolated; + await generated; + + expect(generatorSpy.mock.calls[0][0].keys['4key']).toEqual(['P']); + const finalDocument = harness.transport.canonical.document; + expect(finalDocument.keys['4key']).toEqual(['P']); + expect(finalDocument.keyPositions['4key'][0].inactiveImage).toBe( + 'generated.png', + ); + harness.coordinator.stop(); + }); + + it('compatibility 큐 선행 writer의 stale 레코드와 생성 커밋이 모두 생존한다', async () => { + const base = makeDocument('A'); + const harness = createHarness(base); + await harness.coordinator.start(); + + // C가 큐 점유 - X는 클릭 전 캡처한 full record를 들고 대기 + const releaseC = deferred(); + const cDone = enqueueEditorCompatibilityWrite( + () => releaseC.promise, + () => undefined, + ); + const staleRecord = structuredClone( + harness.transport.canonical.document.keyPositions, + ); + staleRecord['4key'] = staleRecord['4key'].map((position, index) => + index === 0 ? { ...position, noteWidth: 222 } : position, + ); + const xDone = enqueueEditorCompatibilityWrite( + () => + harness.coordinator.commitPatch({ + schemaVersion: 1, + keyPositions: staleRecord, + }), + () => undefined, + ); + + // 생성 커밋도 같은 큐에 합류 - 큐를 건너뛰면 X가 나중에 실행되어 + // 생성 값을 되돌린다 + const bDone = enqueueEditorCompatibilityWrite( + () => + harness.coordinator.commitGeneratedPatch((latest) => ({ + schemaVersion: 1, + keyPositions: imageRecordFrom(latest), + })), + () => undefined, + ); + + releaseC.resolve(); + await Promise.all([cDone, xDone, bDone]); + + const finalPosition = + harness.transport.canonical.document.keyPositions['4key'][0]; + expect(finalPosition.noteWidth).toBe(222); + expect(finalPosition.inactiveImage).toBe('generated.png'); + harness.coordinator.stop(); + }); + + it('선행 커밋이 대상을 삭제하면 생성이 null로 수렴해 커밋하지 않는다', async () => { + const base = makeDocument('A'); + const harness = createHarness(base); + await harness.coordinator.start(); + const targetId = + harness.transport.canonical.document.keyPositions['4key'][0].id; + + const gate = deferred(); + gatedDefaultCommit(harness, gate.promise); + const deletion = harness.coordinator.commitGesture( + { schemaVersion: 1, keys: { '4key': [] }, keyPositions: { '4key': [] } }, + 'gesture-delete', + async (context) => + harness.transport.commit({ + baseRevision: context.editorBaseRevision, + mutationId: context.mutationId, + changes: context.editorChanges!, + }), + ); + + const generatorSpy = vi.fn((latest: EditorDocumentV1) => { + const found = latest.keyPositions['4key']?.some( + (position) => position.id === targetId, + ); + if (!found) return null; + return { + schemaVersion: 1 as const, + keyPositions: imageRecordFrom(latest), + }; + }); + const generated = harness.coordinator.commitGeneratedPatch(generatorSpy); + + gate.resolve(); + await deletion; + await generated; + + // 생성은 삭제가 반영된 base를 받아 null로 수렴한다 + expect(generatorSpy.mock.calls[0][0].keyPositions['4key']).toEqual([]); + // wire 커밋은 삭제 1건뿐, revision도 그만큼만 전진 + expect(harness.transport.commitMock).toHaveBeenCalledTimes(1); + expect(harness.transport.canonical.revision).toBe(1); + expect(harness.transport.canonical.document.keyPositions['4key']).toEqual( + [], + ); + harness.coordinator.stop(); + }); + + it('null 생성은 mutation·낙관 적용·revision 전진이 전부 없다', async () => { + const base = makeDocument('A'); + const harness = createHarness(base); + await harness.coordinator.start(); + + const commitsBefore = harness.transport.commitMock.mock.calls.length; + const applicationsBefore = harness.applications.length; + const revisionBefore = harness.transport.canonical.revision; + + const result = await harness.coordinator.commitGeneratedPatch(() => null); + + expect(harness.transport.commitMock.mock.calls.length).toBe(commitsBefore); + expect(harness.applications.length).toBe(applicationsBefore); + expect(harness.transport.canonical.revision).toBe(revisionBefore); + expect(result).toEqual(base); + harness.coordinator.stop(); + }); + + it('생성 후 revision 충돌은 비중첩 rebase로 양쪽을 보존한다', async () => { + const base = makeDocument('A'); + const harness = createHarness(base); + await harness.coordinator.start(); + + harness.transport.commitMock.mockImplementationOnce(async () => { + // 외부 writer가 먼저 revision을 전진시킨 상황 + harness.transport.canonical.revision += 1; + harness.transport.canonical.document = applyEditorPatch( + harness.transport.canonical.document, + { schemaVersion: 1, keys: { '4key': ['EXT'] } }, + ); + throw revisionConflict(); + }); + + await harness.coordinator.commitGeneratedPatch((latest) => ({ + schemaVersion: 1, + keyPositions: imageRecordFrom(latest), + })); + + const finalDocument = harness.transport.canonical.document; + expect(finalDocument.keys['4key']).toEqual(['EXT']); + expect(finalDocument.keyPositions['4key'][0].inactiveImage).toBe( + 'generated.png', + ); + harness.coordinator.stop(); + }); + + it('generator 예외는 해당 커밋만 실패시키고 큐는 계속 진행된다', async () => { + const base = makeDocument('A'); + const harness = createHarness(base); + await harness.coordinator.start(); + + await expect( + harness.coordinator.commitGeneratedPatch(() => { + throw new Error('generator failed'); + }), + ).rejects.toThrow('generator failed'); + + await expect( + harness.coordinator.commitPatch({ + schemaVersion: 1, + keys: { '4key': ['N'] }, + }), + ).resolves.toBeTruthy(); + expect(harness.transport.canonical.document.keys['4key']).toEqual(['N']); + harness.coordinator.stop(); + }); +}); diff --git a/src/renderer/editor/runtime/editorCoordinator.ts b/src/renderer/editor/runtime/editorCoordinator.ts index a321e8d6..a9509ae2 100644 --- a/src/renderer/editor/runtime/editorCoordinator.ts +++ b/src/renderer/editor/runtime/editorCoordinator.ts @@ -405,6 +405,15 @@ export class EditorSaveCoordinator { this.assertWritable(); await this.waitForGestureCommits(); await this.start(); + return this.commitPatchSettled(changes, meta?.gestureId); + } + + // 대기 이후 공통 본문. 슬롯 안에서 재사용하므로 여기서 tail을 다시 + // 기다리면 자기 슬롯 교착이 된다 + private commitPatchSettled( + changes: EditorPatchV1, + gestureId?: string, + ): Promise { // gradient canonical 정규화를 assert 앞에 — optimistic·diff·invoke가 같은 값 사용 const canonicalChanges = canonicalizeEditorGradients(changes); assertEditorPatch(canonicalChanges); @@ -430,10 +439,28 @@ export class EditorSaveCoordinator { target, newIntentFields, requestFields, - meta?.gestureId, + gestureId, ); } + // 호출 시점 캡처 patch는 대기 중 정산된 다른 커밋의 같은 컬렉션 값을 + // 통째로 되돌린다. 컬렉션 전체 레코드를 보내야 하는 호출자는 이 경로로 + // 직렬 슬롯 안에서 최신 base를 받아 patch를 생성한다. null 반환은 무커밋 + // (mutation·낙관 적용·revision 전진 전부 없음) + commitGeneratedPatch( + generate: (base: EditorDocumentV1) => EditorPatchV1 | null, + ): Promise { + this.assertWritable(); + return this.enqueueSerialized(async () => { + await this.start(); + await this.drainUntilSettled(); + await this.eventQueue; + const changes = generate(this.getLatestCommitBase()); + if (!changes) return clone(this.requireLastAck()); + return this.commitPatchSettled(changes); + }); + } + // 플러그인 발신 커밋을 gesture 커밋과 같은 단일 직렬 큐에 태운다. // 별도 게이트를 두면 gesture 큐와 상호 대기 교착이 생기므로 큐는 하나만 유지 private enqueueSerialized(task: () => Promise): Promise { diff --git a/src/renderer/editor/runtime/elementPatch.test.ts b/src/renderer/editor/runtime/elementPatch.test.ts index 967fa361..37e0298c 100644 --- a/src/renderer/editor/runtime/elementPatch.test.ts +++ b/src/renderer/editor/runtime/elementPatch.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createDefaultKeyPosition } from '../model/keys'; -import type { KeyPosition, KeyPositions } from '@src/types/key/keys'; +import type { KeyPositions } from '@src/types/key/keys'; import type { StatItemPosition } from '@src/types/key/statItems'; const api = vi.hoisted(() => ({ @@ -9,9 +9,11 @@ const api = vi.hoisted(() => ({ async (_positions: KeyPositions, _gestureId?: string) => ({}), ), updateMappingsAndPositionsWithGesture: vi.fn(async () => ({})), - statUpdate: vi.fn(async (_positions: Record) => ({})), - graphUpdate: vi.fn(async (_positions: Record) => ({})), - knobUpdate: vi.fn(async (_positions: Record) => ({})), + commitGeneratedPatch: vi.fn(), +})); + +vi.mock('./editorStateCoordinator', () => ({ + editorCoordinator: { commitGeneratedPatch: api.commitGeneratedPatch }, })); vi.mock('@api/modules/keysApi', () => ({ @@ -36,11 +38,17 @@ vi.mock('@api/modules/previewApi', () => ({ }, })); +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 { applyElementPatchById } from './elementPatch'; +import { enqueueEditorCompatibilityWrite } from './editorCompatibilityQueue'; +import { applyElementPatchById, applyElementPatchesById } from './elementPatch'; import { editGestureController } from './editGestureController'; +import type { EditorDocumentV1, EditorPatchV1 } from '@src/types/editor'; +import type { GraphItemPosition } from '@src/types/key/graphItems'; + const ID_A = '11111111-1111-4111-8111-111111111111'; const ID_B = '22222222-2222-4222-8222-222222222222'; const ID_S = '33333333-3333-4333-8333-333333333333'; @@ -48,9 +56,35 @@ const ID_GONE = '99999999-9999-4999-8999-999999999999'; const keyAt = (id: string) => ({ ...createDefaultKeyPosition(), id }); +// 슬롯 시점 base 문서. 기본은 호출 시점 스토어 상태 - 테스트가 대기 중 +// 정산(재정렬·삭제·병행 변경)을 시뮬레이션하려면 slotBase를 지정한다 +let slotBase: (() => EditorDocumentV1) | null = null; + +const documentFromStores = (): EditorDocumentV1 => ({ + schemaVersion: 1, + keys: {}, + keyPositions: structuredClone(useKeyStore.getState().canonicalPositions), + statPositions: structuredClone(useStatItemStore.getState().positions), + graphPositions: structuredClone(useGraphItemStore.getState().positions), + knobPositions: structuredClone(useKnobItemStore.getState().positions), + layerGroups: {}, +}); + +const generatedPatches: Array = []; + describe('applyElementPatchById', () => { beforeEach(() => { vi.clearAllMocks(); + slotBase = null; + generatedPatches.length = 0; + api.commitGeneratedPatch.mockImplementation( + async (generate: (base: EditorDocumentV1) => EditorPatchV1 | null) => { + const base = (slotBase ?? documentFromStores)(); + const patch = generate(base); + generatedPatches.push(patch); + return base; + }, + ); editGestureController.cancel(); useKeyStore.setState({ selectedKeyType: '4key', @@ -58,31 +92,63 @@ describe('applyElementPatchById', () => { positions: { '4key': [keyAt(ID_A), keyAt(ID_B)] }, }); useStatItemStore.setState({ positions: {} }); - window.api = { - statItems: { updatePositions: api.statUpdate }, - graphItems: { updatePositions: api.graphUpdate }, - knobItems: { updatePositions: api.knobUpdate }, - } as never; + useGraphItemStore.setState({ positions: {} }); + useKnobItemStore.setState({ positions: {} }); }); - it('재정렬 뒤에도 id가 가리키는 요소의 현재 index에 적용한다', () => { + it('클릭 시점에 스토어에 즉시 반영한다', () => { + void applyElementPatchById('key', ID_A, () => ({ + inactiveImage: 'picked.png', + })); + + // await 전 단언 - 이후의 full-record 캡처가 이 값을 포함해야 한다 + expect( + useKeyStore.getState().canonicalPositions['4key'][0].inactiveImage, + ).toBe('picked.png'); + }); + + it('재정렬 뒤에도 id가 가리키는 요소의 현재 index에 적용한다', async () => { const [a, b] = useKeyStore.getState().canonicalPositions['4key']; useKeyStore.getState().setPositions({ '4key': [b, a] }); - const applied = applyElementPatchById('key', ID_A, () => ({ + const applied = await applyElementPatchById('key', ID_A, () => ({ inactiveImage: 'picked.png', })); expect(applied).toBe(true); - const persisted = api.updatePositionsWithGesture.mock.calls[0][0]; - expect(persisted['4key'][1].inactiveImage).toBe('picked.png'); - expect(persisted['4key'][0].inactiveImage ?? '').toBe(''); + const patch = generatedPatches[0]; + expect(patch?.keyPositions?.['4key'][1].inactiveImage).toBe('picked.png'); + expect(patch?.keyPositions?.['4key'][0].inactiveImage ?? '').toBe(''); expect( useKeyStore.getState().canonicalPositions['4key'][1].inactiveImage, ).toBe('picked.png'); }); - it('보고 있는 모드가 바뀌어도 원 모드 컬렉션에 적용한다', () => { + it('대기 중 재정렬은 슬롯 시점 문서에서 재해석하고 병행 변경을 보존한다', async () => { + // 클릭 시점 순서는 [A, B]. 대기 중 [B, A]로 재정렬되고 B에 병행 + // 변경(noteWidth 222)이 정산된 상황 + slotBase = () => { + const base = documentFromStores(); + const [first, second] = base.keyPositions['4key']; + base.keyPositions['4key'] = [{ ...second, noteWidth: 222 }, first]; + return base; + }; + + const applied = await applyElementPatchById('key', ID_A, () => ({ + inactiveImage: 'picked.png', + })); + + expect(applied).toBe(true); + const record = generatedPatches[0]?.keyPositions?.['4key']; + expect(record?.[1].id).toBe(ID_A); + expect(record?.[1].inactiveImage).toBe('picked.png'); + // 병행 변경은 그대로 - 클릭 시점 캡처였다면 222가 사라진다 + expect(record?.[0].id).toBe(ID_B); + expect(record?.[0].noteWidth).toBe(222); + expect(record?.[0].inactiveImage ?? '').toBe(''); + }); + + it('보고 있는 모드가 바뀌어도 원 모드 컬렉션에 적용한다', async () => { const stat = { ...createDefaultKeyPosition(), id: ID_S, @@ -91,30 +157,52 @@ describe('applyElementPatchById', () => { useStatItemStore.setState({ positions: { '4key': [stat] } }); useKeyStore.setState({ selectedKeyType: '8key' }); - const applied = applyElementPatchById('stat', ID_S, () => ({ + const applied = await applyElementPatchById('stat', ID_S, () => ({ inactiveImage: 'picked.png', })); expect(applied).toBe(true); - const persisted = api.statUpdate.mock.calls[0][0]; - expect(persisted['4key'][0].inactiveImage).toBe('picked.png'); + expect(generatedPatches[0]?.statPositions?.['4key'][0].inactiveImage).toBe( + 'picked.png', + ); expect(useStatItemStore.getState().positions['4key'][0].inactiveImage).toBe( 'picked.png', ); }); - it('요소가 삭제됐으면 아무것도 쓰지 않는다', () => { - const applied = applyElementPatchById('key', ID_GONE, () => ({ + it('요소가 삭제됐으면 아무것도 쓰지 않는다', async () => { + const before = structuredClone(useKeyStore.getState().canonicalPositions); + + const applied = await applyElementPatchById('key', ID_GONE, () => ({ inactiveImage: 'picked.png', })); expect(applied).toBe(false); - expect(api.updatePositionsWithGesture).not.toHaveBeenCalled(); - expect(api.statUpdate).not.toHaveBeenCalled(); + // 슬롯 재판정까지 가되 wire에는 아무것도 싣지 않는다 + expect(generatedPatches).toEqual([null]); + expect(useKeyStore.getState().canonicalPositions).toEqual(before); }); - it('updater가 id를 끼워 넣어도 신원은 보존된다', () => { - const applied = applyElementPatchById( + it('대기 중 삭제된 id는 wire에 싣지 않는다', async () => { + // 클릭 시점엔 존재, 슬롯 시점 문서에서 삭제된 상황 + slotBase = () => { + const base = documentFromStores(); + base.keyPositions['4key'] = base.keyPositions['4key'].filter( + (position) => position.id !== ID_A, + ); + return base; + }; + + const applied = await applyElementPatchById('key', ID_A, () => ({ + inactiveImage: 'picked.png', + })); + + expect(applied).toBe(false); + expect(generatedPatches).toEqual([null]); + }); + + it('updater가 id를 끼워 넣어도 신원은 보존된다', async () => { + const applied = await applyElementPatchById( 'key', ID_A, () => ({ id: 'hijacked', inactiveImage: 'picked.png' } as never), @@ -122,35 +210,151 @@ describe('applyElementPatchById', () => { expect(applied).toBe(true); expect(useKeyStore.getState().canonicalPositions['4key'][0].id).toBe(ID_A); + expect(generatedPatches[0]?.keyPositions?.['4key'][0].id).toBe(ID_A); }); - it('updater가 입력 객체의 id를 직접 변조해도 신원은 보존된다', () => { - const applied = applyElementPatchById('key', ID_A, (current) => { + it('updater가 입력 객체의 id를 직접 변조해도 신원은 보존된다', async () => { + const applied = await applyElementPatchById('key', ID_A, (current) => { (current as { id?: string }).id = 'mutated'; return { inactiveImage: 'picked.png' }; }); expect(applied).toBe(true); expect(useKeyStore.getState().canonicalPositions['4key'][0].id).toBe(ID_A); - const persisted = api.updatePositionsWithGesture.mock.calls[0][0]; - expect(persisted['4key'][0].id).toBe(ID_A); + expect(generatedPatches[0]?.keyPositions?.['4key'][0].id).toBe(ID_A); }); - it('활성 게스처를 정산하지 않고 wire에 gestureId도 싣지 않는다', () => { + const GRAPH_SEED: GraphItemPosition = { + ...createDefaultKeyPosition(), + id: '44444444-4444-4444-8444-444444444444', + statType: 'kps', + graphType: 'line', + graphSpeed: 1000, + graphColor: '#86EFAC', + } as GraphItemPosition; + + it('시작 시점 ID 집합 전체를 한 트랜잭션으로 적용한다', async () => { + const stat = { + ...createDefaultKeyPosition(), + id: ID_S, + statType: 'kps', + } as StatItemPosition; + useStatItemStore.setState({ positions: { '4key': [stat] } }); + useGraphItemStore.setState({ positions: { '4key': [GRAPH_SEED] } }); + const [a, b] = useKeyStore.getState().canonicalPositions['4key']; + useKeyStore.getState().setPositions({ '4key': [b, a] }); + + const applied = await applyElementPatchesById( + { key: [ID_A, ID_B], stat: [ID_S], graph: [GRAPH_SEED.id!] }, + () => ({ inactiveImage: 'batch.png' }), + ); + + expect(applied).toBe(4); + expect(api.commitGeneratedPatch).toHaveBeenCalledOnce(); + const patch = generatedPatches[0]; + expect( + patch?.keyPositions?.['4key'].every( + (position) => position.inactiveImage === 'batch.png', + ), + ).toBe(true); + expect(patch?.statPositions?.['4key'][0].inactiveImage).toBe('batch.png'); + expect(patch?.graphPositions?.['4key'][0].inactiveImage).toBe('batch.png'); + expect(patch?.knobPositions).toBeUndefined(); + expect( + patch?.keyPositions?.['4key'].map((position) => position.id).sort(), + ).toEqual([ID_A, ID_B].sort()); + expect( + useKeyStore.getState().canonicalPositions['4key'][0].inactiveImage, + ).toBe('batch.png'); + }); + + it('삭제된 id는 건너뛰고 찾은 요소에만 적용한다', async () => { + const applied = await applyElementPatchesById( + { key: [ID_A, ID_GONE], stat: [ID_GONE] }, + () => ({ inactiveImage: 'batch.png' }), + ); + + expect(applied).toBe(1); + const patch = generatedPatches[0]; + expect(patch?.keyPositions?.['4key'][0].inactiveImage).toBe('batch.png'); + expect(patch?.keyPositions?.['4key'][1].inactiveImage ?? '').toBe(''); + expect(patch?.statPositions).toBeUndefined(); + }); + + it('아무 요소도 못 찾으면 커밋하지 않는다', async () => { + const applied = await applyElementPatchesById({ key: [ID_GONE] }, () => ({ + inactiveImage: 'batch.png', + })); + + expect(applied).toBe(0); + expect(generatedPatches).toEqual([null]); + }); + + it('updater는 요소당 eager와 wire 생성에서 두 번 실행될 수 있다', async () => { + // 계약 고정: updater는 동기·순수·멱등이어야 한다 + const updater = vi.fn(() => ({ inactiveImage: 'twice.png' })); + + await applyElementPatchesById({ key: [ID_A] }, updater); + + expect(updater).toHaveBeenCalledTimes(2); + }); + + it('선행 compatibility write가 큐를 점유하면 생성 커밋은 그 뒤에 실행된다', async () => { + let releaseBlocker!: () => void; + const blocker = new Promise((resolve) => { + releaseBlocker = resolve; + }); + const blocked = enqueueEditorCompatibilityWrite( + () => blocker, + () => undefined, + ); + + const pending = applyElementPatchesById({ key: [ID_A] }, () => ({ + inactiveImage: 'queued.png', + })); + await Promise.resolve(); + // 큐를 건너뛰면 여기서 이미 호출된다 - 먼저 캡처하고 대기 중인 + // writer가 나중에 실행되어 이 값을 되돌리는 순서 위반 + expect(api.commitGeneratedPatch).not.toHaveBeenCalled(); + + releaseBlocker(); + await blocked; + expect(await pending).toBe(1); + expect(api.commitGeneratedPatch).toHaveBeenCalledOnce(); + }); + + it('커밋 실패는 내부에서 소비하고 대상 수를 반환한다', async () => { + api.commitGeneratedPatch.mockImplementation( + async (generate: (base: EditorDocumentV1) => EditorPatchV1 | null) => { + generate(documentFromStores()); + throw new Error('commit failed'); + }, + ); + const errorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + const applied = await applyElementPatchesById({ key: [ID_A] }, () => ({ + inactiveImage: 'failed.png', + })); + + expect(applied).toBe(1); + expect(errorSpy).toHaveBeenCalled(); + errorSpy.mockRestore(); + }); + + it('활성 게스처를 정산하지 않는다', async () => { editGestureController.preview('4key', [{ index: 0, patch: { dx: 5 } }], { domain: 'keyPosition', }); const activeBefore = editGestureController.activeGestureId(); expect(activeBefore).not.toBeNull(); - const applied = applyElementPatchById('key', ID_B, () => ({ + const applied = await applyElementPatchById('key', ID_B, () => ({ inactiveImage: 'picked.png', })); expect(applied).toBe(true); expect(editGestureController.activeGestureId()).toBe(activeBefore); - expect( - api.updatePositionsWithGesture.mock.calls.at(-1)?.[1], - ).toBeUndefined(); }); }); diff --git a/src/renderer/editor/runtime/elementPatch.ts b/src/renderer/editor/runtime/elementPatch.ts index 2b46c145..07a67db2 100644 --- a/src/renderer/editor/runtime/elementPatch.ts +++ b/src/renderer/editor/runtime/elementPatch.ts @@ -4,24 +4,32 @@ import { useKnobItemStore } from '@stores/data/useKnobItemStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { resolveElementById } from '../model/elementIdMap'; -import { persistPositionsWithFlag } from './persistState'; +import { enqueueEditorCompatibilityWrite } from './editorCompatibilityQueue'; +import { editorCoordinator } from './editorStateCoordinator'; + +import type { EditorDocumentV1, EditorPatchV1 } from '@src/types/editor'; import type { NativeElementType } from '../model/elementIdMap'; 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 { StatItemPosition } from '@src/types/key/statItems'; // 비동기 완료 전용 mode-aware 쓰기. // // 파일 대화상자·편집기를 기다리는 사이 배열 재정렬이나 모드 전환이 일어나도 -// id로 현재 (mode, index)를 다시 찾아 그 요소에만 적용한다. 계약(§8)상 검사는 -// type 일치(per-type 조회)뿐이고, 조회 실패(삭제·미등록)는 쓰지 않는다. +// id로 요소를 다시 찾아 그 요소에만 적용한다. 계약(§8)상 검사는 type 일치 +// (per-type 조회)뿐이고, 조회 실패(삭제·미등록)는 쓰지 않는다. +// +// 쓰기는 두 단계로 나뉜다. 클릭 시점에 Zustand에 즉시 반영해 이후의 +// full-record 캡처가 이 값을 포함하게 하고, wire patch는 coordinator의 +// 직렬 슬롯 안에서 최신 문서를 받아 다시 생성한다. 호출 시점에 캡처한 +// 컬렉션 레코드를 그대로 보내면 대기 중 정산된 다른 커밋(게스처 경계 +// 정산, 플러그인 격리 커밋)의 같은 컬렉션 값을 통째로 되돌린다. // -// 게스처와 결합하지 않는다 - settleCommit은 무관한 활성 게스처를 정산해 버리고, -// gestureId 연결은 이 완료를 남의 히스토리 엔트리에 병합한다. 쓰기 자체는 -// 기존 API 경로를 타므로 write barrier 등록은 그대로 유지된다 +// 게스처와 결합하지 않는다 - settleCommit은 무관한 활성 게스처를 정산해 +// 버리고, gestureId 연결은 이 완료를 남의 히스토리 엔트리에 병합한다 +// 동기·순수·멱등 계약: eager 반영과 wire 생성에서 요소마다 두 번 실행될 수 +// 있고, 두 실행의 current가 다를 수 있다(대기 중 다른 커밋 정산). 외부 +// 부작용이나 비멱등 연산을 넣지 말 것 export type ElementPatchUpdater = ( current: Readonly, ) => Omit, 'id'>; @@ -37,93 +45,193 @@ const mergePosition = ( return { ...current, ...patch, id }; }; -type ElementWriter = ( - mode: string, - index: number, +// 배치 완료용 시작 시점 ID 집합 (피커 open 시 캡처, close까지 불변) +export interface ElementIdSelection { + key?: readonly string[]; + stat?: readonly string[]; + graph?: readonly string[]; + knob?: readonly string[]; +} + +const NATIVE_ELEMENT_TYPES: readonly NativeElementType[] = [ + 'key', + 'stat', + 'graph', + 'knob', +]; + +const selectedIdSet = ( + selection: ElementIdSelection, + type: NativeElementType, +): ReadonlySet | null => { + const ids = selection[type]; + if (!ids || ids.length === 0) return null; + const wanted = new Set(); + for (const id of ids) { + if (id) wanted.add(id); + } + return wanted.size > 0 ? wanted : null; +}; + +// 클릭 시점 즉시 반영. 신원 해석은 현재 스토어 기준 - 이후 재정렬·삭제는 +// wire 생성 단계가 최신 문서에서 다시 해석한다 +const eagerRecordFor = ( + positions: Record, + wanted: ReadonlySet, + type: NativeElementType, updater: ElementPatchUpdater, -) => boolean; - -const writeKey: ElementWriter = (mode, index, updater) => { - const state = useKeyStore.getState(); - const list = state.canonicalPositions[mode] ?? []; - const current = list[index]; - if (!current) return false; - const next = { - ...state.canonicalPositions, - [mode]: list.map((position, i) => - i === index ? mergePosition(position, updater) : position, - ), - }; - void persistPositionsWithFlag( - next, - state.setPositions, - state.setLocalUpdateInProgress, - ); - return true; +): Record | null => { + const targets = new Map>(); + for (const id of wanted) { + const locator = resolveElementById(type, id); + if (!locator) continue; + const indices = targets.get(locator.mode) ?? new Set(); + indices.add(locator.index); + targets.set(locator.mode, indices); + } + if (targets.size === 0) return null; + const next = { ...positions }; + for (const [mode, indices] of targets) { + const list = next[mode]; + if (!list) continue; + next[mode] = list.map((position, index) => + indices.has(index) ? mergePosition(position, updater) : position, + ); + } + return next; }; -interface ItemStoreLike { - positions: Record; - setPositions: (positions: Record) => void; - setLocalUpdateInProgress: (value: boolean) => void; -} +const applyEagerly = ( + selection: ElementIdSelection, + updater: ElementPatchUpdater, +): void => { + for (const type of NATIVE_ELEMENT_TYPES) { + const wanted = selectedIdSet(selection, type); + if (!wanted) continue; + if (type === 'key') { + const state = useKeyStore.getState(); + const next = eagerRecordFor( + state.canonicalPositions, + wanted, + 'key', + updater, + ); + if (next) state.setPositions(next); + } else if (type === 'stat') { + const state = useStatItemStore.getState(); + const next = eagerRecordFor(state.positions, wanted, 'stat', updater); + if (next) state.setPositions(next); + } else if (type === 'graph') { + const state = useGraphItemStore.getState(); + const next = eagerRecordFor(state.positions, wanted, 'graph', updater); + if (next) state.setPositions(next); + } else { + const state = useKnobItemStore.getState(); + const next = eagerRecordFor(state.positions, wanted, 'knob', updater); + if (next) state.setPositions(next); + } + } +}; -const writeItem = - ( - readStore: () => ItemStoreLike, - persist: (positions: Record) => Promise, - label: string, - ): ElementWriter => - (mode, index, updater) => { - const state = readStore(); - const list = state.positions[mode] ?? []; - const current = list[index]; - if (!current) return false; - const next = { - ...state.positions, - [mode]: list.map((position, i) => - i === index ? mergePosition(position, updater) : position, - ), - }; - state.setLocalUpdateInProgress(true); - state.setPositions(next); - persist(next) - .catch((error) => { - console.error(`Failed to apply ${label} element patch`, error); - }) - .finally(() => { - state.setLocalUpdateInProgress(false); - }); - return true; - }; - -const writers: Record = { - key: writeKey, - stat: writeItem( - () => useStatItemStore.getState(), - (positions) => window.api.statItems.updatePositions(positions), - 'stat', - ), - graph: writeItem( - () => useGraphItemStore.getState(), - (positions) => window.api.graphItems.updatePositions(positions), - 'graph', - ), - knob: writeItem( - () => useKnobItemStore.getState(), - (positions) => window.api.knobItems.updatePositions(positions), - 'knob', - ), +// 최신 base 문서에서 id를 다시 찾아 적용한다. 스토어 조회(resolveElementById) +// 금지 - 슬롯 진입 전 상태라 대기 중 정산된 재정렬·삭제를 놓친다 +const generatedRecordFor = ( + positions: Record, + wanted: ReadonlySet, + updater: ElementPatchUpdater, +): { next: Record; touched: number } => { + let touched = 0; + const next: Record = {}; + for (const [mode, list] of Object.entries(positions)) { + next[mode] = list.map((position) => { + if (!position.id || !wanted.has(position.id)) return position; + touched += 1; + return mergePosition(position, updater); + }); + } + return { next, touched }; +}; + +const generatePatchFrom = ( + base: EditorDocumentV1, + selection: ElementIdSelection, + updater: ElementPatchUpdater, +): { patch: EditorPatchV1 | null; applied: number } => { + const patch: EditorPatchV1 = { schemaVersion: 1 }; + let applied = 0; + + for (const type of NATIVE_ELEMENT_TYPES) { + const wanted = selectedIdSet(selection, type); + if (!wanted) continue; + if (type === 'key') { + const result = generatedRecordFor(base.keyPositions, wanted, updater); + if (result.touched > 0) { + patch.keyPositions = result.next; + applied += result.touched; + } + } else if (type === 'stat') { + const result = generatedRecordFor(base.statPositions, wanted, updater); + if (result.touched > 0) { + patch.statPositions = result.next; + applied += result.touched; + } + } else if (type === 'graph') { + const result = generatedRecordFor(base.graphPositions, wanted, updater); + if (result.touched > 0) { + patch.graphPositions = result.next; + applied += result.touched; + } + } else { + const result = generatedRecordFor(base.knobPositions, wanted, updater); + if (result.touched > 0) { + patch.knobPositions = result.next; + applied += result.touched; + } + } + } + + return applied === 0 ? { patch: null, applied: 0 } : { patch, applied }; }; -// 반환 false = 요소 없음(삭제·미등록). 호출부는 연결만 조용히 중단한다 +// 시작 시점 ID 집합 전체에 한 트랜잭션으로 적용한다. 못 찾는 id(삭제)는 +// 건너뛰고, 터치된 컬렉션들을 단일 커밋으로 저장해 결합 원자성(한 커밋 = +// 한 undo 엔트리)을 유지한다. 전원 미발견이면 커밋하지 않는다. +// +// wire 커밋은 다른 first-party writer와 같은 compatibility 큐에 등록한다. +// 큐는 commitPatch 호출 자체를 지연시키므로, 여기서 큐를 건너뛰면 먼저 +// 캡처하고 대기 중이던 writer가 나중에 실행되어 이 값을 되돌린다. +// +// 반환 promise는 reject하지 않는다. 값은 wire patch 생성 시점의 대상 수이고 +// 저장 성공 보장이 아니다 - 커밋 실패는 write barrier에서 관측된다 +export const applyElementPatchesById = ( + selection: ElementIdSelection, + updater: ElementPatchUpdater, +): Promise => { + applyEagerly(selection, updater); + let generated = 0; + return enqueueEditorCompatibilityWrite( + () => + editorCoordinator.commitGeneratedPatch((base) => { + const result = generatePatchFrom(base, selection, updater); + generated = result.applied; + return result.patch; + }), + () => generated, + ).catch((error) => { + console.error('Failed to commit element patches', error); + return generated; + }); +}; + +// 단일 완료는 배치의 1-ID 호출. false = wire에 실리지 않음(요소 없음 또는 +// 생성 전 커밋 경로 실패), 호출부는 연결만 조용히 중단한다 export const applyElementPatchById = ( type: NativeElementType, id: string, updater: ElementPatchUpdater, -): boolean => { - if (!id) return false; - const locator = resolveElementById(type, id); - if (!locator) return false; - return writers[type](locator.mode, locator.index, updater); +): Promise => { + if (!id) return Promise.resolve(false); + return applyElementPatchesById({ [type]: [id] }, updater).then( + (applied) => applied > 0, + ); }; From 255913f092b7e63f7bdce8fb5ce151c356acb734 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 12 Aug 2026 04:58:44 +0900 Subject: [PATCH 14/35] =?UTF-8?q?feat:=20=EB=B0=B0=EC=B9=98=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=20=EB=B9=84=EB=8F=99=EA=B8=B0=20=EC=99=84=EB=A3=8C?= =?UTF-8?q?=EB=A5=BC=20=EC=8B=9C=EC=9E=91=20=EC=84=A0=ED=83=9D=20ID=20?= =?UTF-8?q?=EA=B2=B0=ED=95=A9=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/counterAnimationMerge.test.ts | 38 ++- .../batch/BatchCounterTabContent.tsx | 34 ++- .../batch/BatchSelectionPanel.tsx | 96 +++++- .../batch/BatchStyleTabContent.tsx | 23 +- .../batchPickerBindingOwnership.test.tsx | 285 ++++++++++++++++++ .../pickers/useBatchElementBinding.test.tsx | 106 +++++++ .../hooks/pickers/useBatchElementBinding.ts | 73 +++++ src/types/key/counterAnimation.ts | 16 + 8 files changed, 666 insertions(+), 5 deletions(-) create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/batch/batchPickerBindingOwnership.test.tsx create mode 100644 src/renderer/hooks/pickers/useBatchElementBinding.test.tsx create mode 100644 src/renderer/hooks/pickers/useBatchElementBinding.ts diff --git a/src/renderer/__tests__/counterAnimationMerge.test.ts b/src/renderer/__tests__/counterAnimationMerge.test.ts index c670aa3c..f8c8021b 100644 --- a/src/renderer/__tests__/counterAnimationMerge.test.ts +++ b/src/renderer/__tests__/counterAnimationMerge.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { mergeChangedAnimationFields } from '@src/types/key/counterAnimation'; +import { + applyAnimationIntentMask, + mergeChangedAnimationFields, +} from '@src/types/key/counterAnimation'; import type { KeyCounterAnimationSettings } from '@src/types/key/keys'; @@ -57,6 +60,39 @@ describe('mergeChangedAnimationFields', () => { expect(merged).toEqual(fresh); }); + // 배치는 첫 요소 기준값이라 델타가 아니라 intent mask를 쓴다. + // 혼합 상태(요소마다 preset·duration이 다름)에서 같은 preset을 재선택해 + // 통일하는 동작이 무변경으로 오판되면 안 된다 + describe('applyAnimationIntentMask', () => { + it('preset 필드는 전부 쓰고 각 요소의 enabled만 보존한다', () => { + const current = animation({ + enabled: false, + presetId: 'preset-c', + durationMs: 500, + }); + const next = animation({ presetId: 'preset-b', durationMs: 300 }); + + const masked = applyAnimationIntentMask(current, next); + + expect(masked.enabled).toBe(false); + expect(masked.presetId).toBe('preset-b'); + expect(masked.durationMs).toBe(300); + expect(masked.bezier).toEqual(next.bezier); + expect(masked.scale).toBe(next.scale); + }); + + it('기준값과 같은 preset을 재선택해도 혼합 요소가 통일된다', () => { + // 기준(첫 요소)은 이미 preset-a인데 둘째 요소는 preset-c인 혼합 상태 + const mixedSecond = animation({ presetId: 'preset-c', durationMs: 500 }); + const reselected = animation(); + + const masked = applyAnimationIntentMask(mixedSecond, reselected); + + expect(masked.presetId).toBe('preset-a'); + expect(masked.durationMs).toBe(300); + }); + }); + // preset 매칭용 epsilon(0.001)을 변경 감지에 재사용하면 이런 미세 드래그가 // 무변경으로 오판된다 - 정확 비교를 고정 it('epsilon보다 작은 bezier 변경도 적용한다', () => { diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchCounterTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchCounterTabContent.tsx index 9a272101..4699c881 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchCounterTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchCounterTabContent.tsx @@ -1,6 +1,13 @@ import React from 'react'; import { createPortal } from 'react-dom'; import type { KeyCounterSettings } from '@src/types/key/keys'; +import { normalizeCounterSettings } from '@src/types/key/keys'; +import { applyAnimationIntentMask } from '@src/types/key/counterAnimation'; +import { applyElementPatchesById } from '@src/renderer/editor/runtime/elementPatch'; +import { + LEGACY_BATCH_ELEMENT_BINDING, + type BatchElementBinding, +} from '@hooks/pickers/useBatchElementBinding'; import { PropertyRow, NumberInput, @@ -18,7 +25,9 @@ import { DEFAULT_COUNTER_FONT_SIZE } from '@utils/core/elementDefaults'; // 인-패널 서브 페이지 키 — 트리거 사이트별 유니크 const FONT_PAGE_KEY = 'batch-counter:font'; -const ANIMATION_PAGE_KEY = 'batch-counter:animation'; +// 결합 캡처 소유자(리마운트 경계 밖)가 open 판정에 쓰도록 export +export const BATCH_COUNTER_ANIMATION_PAGE_KEY = 'batch-counter:animation'; +const ANIMATION_PAGE_KEY = BATCH_COUNTER_ANIMATION_PAGE_KEY; interface BatchCounterTabContentProps { // 카운터 설정 (첫 번째 선택 키 기준) @@ -38,6 +47,9 @@ interface BatchCounterTabContentProps { batchCounterStrokeButtonRef: React.RefObject; isFillPickerOpen: boolean; isStrokePickerOpen: boolean; + // 모션 완료의 시작 시점 결합. 소유자는 EditSessionBoundary 밖 부모다 - + // 이 컴포넌트는 선택 변경 시 리마운트되어 open 중 재캡처가 일어난다 + animationBinding?: BatchElementBinding; // 패널 요소 (FloatingPopup 위치용) // 번역 t: (key: string) => string; @@ -55,15 +67,34 @@ const BatchCounterTabContent: React.FC = ({ batchCounterStrokeButtonRef, isFillPickerOpen, isStrokePickerOpen, + animationBinding = LEGACY_BATCH_ELEMENT_BINDING, t, }) => { // 인-패널 내비게이션 (폰트/애니메이션 서브 페이지) const { activePageKey, renderPageKey, openPage, closePage, pageHost } = usePanelNav(); + // 모션 편집기를 기다린 비동기 완료. ID 결합이면 시작 시점 선택 요소들에 + // 적용하되, 피커가 소유한 preset 필드만 쓰고 각 요소의 fresh enabled는 + // 보존한다 (첫 요소 기준 델타는 혼합 상태를 오판하므로 intent mask 방식) const handleAnimationUpdate = ( nextAnimation: KeyCounterSettings['animation'], ) => { + if (animationBinding.binding === 'element-id') { + applyElementPatchesById(animationBinding.selection, (current) => { + const settings = normalizeCounterSettings(current.counter); + return { + counter: { + ...settings, + animation: applyAnimationIntentMask( + settings.animation, + nextAnimation, + ), + }, + }; + }); + return; + } handleBatchCounterUpdate({ animation: nextAnimation }); }; @@ -329,6 +360,7 @@ const BatchCounterTabContent: React.FC = ({ createPortal( ( = ({ setPanelElement, selectedBatchStyleElements, selectedKeyElements, - selectedStatElements: _selectedStatElements, + selectedStatElements, selectedKnobElements, selectedGraphElements, selectedKeyLikeElements, @@ -303,6 +311,36 @@ export const BatchKeyLikePanel: React.FC = ({ selectedKeyType, t, }) => { + // 피커 open 시점의 선택을 ID로 고정 - 대기 중 재정렬·모드 전환에도 + // 완료가 시작 시점 요소들에 적용된다 (전원이 ID를 가질 때만, 아니면 legacy). + // 결합 소유자는 이 패널이다 - EditSessionBoundary 안(탭 컴포넌트)에 두면 + // 같은 개수 선택 교체 시 리마운트로 open 중 재캡처가 일어난다 + const batchImageBinding = useBatchElementBinding(showBatchImagePicker, () => + captureBatchElementBinding({ + key: selectedKeyElements, + stat: selectedStatElements, + graph: selectedGraphElements, + knob: selectedKnobElements, + }), + ); + + // open 판정은 activePageKey다. renderPageKey는 exit 애니메이션 동안 + // 유지되는 마운트 상태라, 닫고 250ms 안에 재열면 전환이 감지되지 않아 + // 이전 결합이 재사용된다 (닫히는 동안의 옛 완료는 유지된 bound가 담당) + const { activePageKey } = usePanelNav(); + const animationBinding = useBatchElementBinding( + activePageKey === BATCH_COUNTER_ANIMATION_PAGE_KEY, + () => + captureBatchElementBinding({ + key: selectedKeyElements, + stat: selectedStatElements, + }), + ); + const soundBinding = useBatchElementBinding( + activePageKey === BATCH_STYLE_SOUND_PAGE_KEY, + () => captureBatchElementBinding({ key: selectedKeyElements }), + ); + const hasGraphSelection = selectedGraphElements.length > 0; const styleMixedValueGetter = hasGraphSelection ? getMixedValueBatch @@ -661,6 +699,7 @@ export const BatchKeyLikePanel: React.FC = ({ 0} showShadowControls={!hasGraphSelection} shadowActiveState={ @@ -877,6 +916,7 @@ export const BatchKeyLikePanel: React.FC = ({ batchCounterStrokeButtonRef={batchCounterStrokeButtonRef} isFillPickerOpen={batchPickerFor === 'fill'} isStrokePickerOpen={batchPickerFor === 'stroke'} + animationBinding={animationBinding} t={t} /> @@ -992,10 +1032,28 @@ export const BatchKeyLikePanel: React.FC = ({ false, ).value } + completionBinding={batchImageBinding.binding} onIdleImageChange={(imageUrl: string) => { + if (batchImageBinding.binding === 'element-id') { + applyElementPatchesById(batchImageBinding.selection, () => ({ + inactiveImage: imageUrl, + })); + return; + } handleBatchStyleChangeComplete('inactiveImage', imageUrl); }} onActiveImageChange={(imageUrl: string) => { + if (batchImageBinding.binding === 'element-id') { + // active 이미지는 key·knob만 지원 (기존 writer와 동일 범위) + applyElementPatchesById( + { + key: batchImageBinding.selection.key, + knob: batchImageBinding.selection.knob, + }, + () => ({ activeImage: imageUrl }), + ); + return; + } handleActiveCapableStyleChangeComplete('activeImage', imageUrl); }} onIdleTransparentChange={(value: boolean) => { @@ -1116,6 +1174,11 @@ export const BatchGraphOnlyPanel: React.FC = ({ selectedKeyType, t, }) => { + // 이미지 피커 open 시점의 그래프 선택을 ID로 고정 + const graphImageBinding = useBatchElementBinding(showBatchImagePicker, () => + captureBatchElementBinding({ graph: selectedGraphElements }), + ); + const graphShapeOptions = [ { label: t('propertiesPanel.graphShapeLine') || 'Line', value: 'line' }, { label: t('propertiesPanel.graphShapeBar') || 'Bar', value: 'bar' }, @@ -1376,10 +1439,23 @@ export const BatchGraphOnlyPanel: React.FC = ({ activeTransparent={ getMixedValueGraphs((pos) => pos.activeTransparent, false).value } + completionBinding={graphImageBinding.binding} onIdleImageChange={(imageUrl: string) => { + if (graphImageBinding.binding === 'element-id') { + applyElementPatchesById(graphImageBinding.selection, () => ({ + inactiveImage: imageUrl, + })); + return; + } handleGraphBatchSharedSetting({ inactiveImage: imageUrl }); }} onActiveImageChange={(imageUrl: string) => { + if (graphImageBinding.binding === 'element-id') { + applyElementPatchesById(graphImageBinding.selection, () => ({ + activeImage: imageUrl, + })); + return; + } handleGraphBatchSharedSetting({ activeImage: imageUrl }); }} onIdleTransparentChange={(value: boolean) => { @@ -1498,6 +1574,11 @@ export const BatchKnobOnlyPanel: React.FC = ({ useCustomCSS, t, }) => { + // 이미지 피커 open 시점의 노브 선택을 ID로 고정 + const knobImageBinding = useBatchElementBinding(showBatchImagePicker, () => + captureBatchElementBinding({ knob: selectedKnobElements }), + ); + const sensitivityState = getMixedValueKnobs( (pos) => Number(pos.sensitivity ?? 1), 1, @@ -1676,10 +1757,23 @@ export const BatchKnobOnlyPanel: React.FC = ({ activeTransparent={ getMixedValueKnobs((pos) => pos.activeTransparent, false).value } + completionBinding={knobImageBinding.binding} onIdleImageChange={(imageUrl: string) => { + if (knobImageBinding.binding === 'element-id') { + applyElementPatchesById(knobImageBinding.selection, () => ({ + inactiveImage: imageUrl, + })); + return; + } handleKnobBatchSharedSetting({ inactiveImage: imageUrl }); }} onActiveImageChange={(imageUrl: string) => { + if (knobImageBinding.binding === 'element-id') { + applyElementPatchesById(knobImageBinding.selection, () => ({ + activeImage: imageUrl, + })); + return; + } handleKnobBatchSharedSetting({ activeImage: imageUrl }); }} onIdleTransparentChange={(value: boolean) => { diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchStyleTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchStyleTabContent.tsx index 6ff57d10..1bebb8b6 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchStyleTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchStyleTabContent.tsx @@ -28,6 +28,11 @@ import { } from '@utils/core/elementDefaults'; import FontPicker from '@components/main/Modal/content/pickers/FontPicker'; import SoundPicker from '@components/main/Modal/content/pickers/SoundPicker'; +import { applyElementPatchesById } from '@src/renderer/editor/runtime/elementPatch'; +import { + LEGACY_BATCH_ELEMENT_BINDING, + type BatchElementBinding, +} from '@hooks/pickers/useBatchElementBinding'; import { usePanelNav } from '../PanelNavContext'; import ShadowControls from '../ShadowControls'; import { @@ -39,7 +44,9 @@ import { AXIS_FIELD_WIDTH } from '@utils/cardRecipes'; // 인-패널 서브 페이지 키 — 트리거 사이트별 유니크 const FONT_PAGE_KEY = 'batch-style:font'; -const SOUND_PAGE_KEY = 'batch-style:sound'; +// 결합 캡처 소유자(리마운트 경계 밖)가 open 판정에 쓰도록 export +export const BATCH_STYLE_SOUND_PAGE_KEY = 'batch-style:sound'; +const SOUND_PAGE_KEY = BATCH_STYLE_SOUND_PAGE_KEY; const SPACING_COMMIT_DEBOUNCE_MS = 80; const SPACING_COMMIT_EPSILON = 0.0001; @@ -54,6 +61,9 @@ interface KeyData { interface BatchStyleTabContentProps { // 다중 선택 정보 selectedCount: number; + // 사운드 완료의 시작 시점 결합. 소유자는 EditSessionBoundary 밖 부모다 - + // 이 컴포넌트는 선택 변경 시 리마운트되어 open 중 재캡처가 일어난다 + soundBinding?: BatchElementBinding; hideDisplayText?: boolean; hideFontControls?: boolean; showSoundControls?: boolean; @@ -132,6 +142,7 @@ const BatchStyleTabContent: React.FC = ({ selectedCount, hideDisplayText = false, hideFontControls = false, + soundBinding = LEGACY_BATCH_ELEMENT_BINDING, showSoundControls = false, showShadowControls = true, shadowActiveState = true, @@ -1277,6 +1288,7 @@ const BatchStyleTabContent: React.FC = ({ createPortal( pos.soundPath, @@ -1284,10 +1296,17 @@ const BatchStyleTabContent: React.FC = ({ ).value || null } onSoundSelect={(soundPath) => { + const nextPath = soundPath || ''; + if (soundBinding.binding === 'element-id') { + applyElementPatchesById(soundBinding.selection, () => ({ + soundPath: nextPath, + })); + return; + } ( handleKeyOnlyStyleChangeComplete ?? handleBatchStyleChangeComplete - )('soundPath', soundPath || ''); + )('soundPath', nextPath); }} previewVolume={ (getKeyOnlyMixedValue ?? getMixedValue)( diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/batchPickerBindingOwnership.test.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/batchPickerBindingOwnership.test.tsx new file mode 100644 index 00000000..c406ac22 --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/batchPickerBindingOwnership.test.tsx @@ -0,0 +1,285 @@ +// @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 { createDefaultKeyPosition } from '@src/renderer/editor/model/keys'; +import { useKeyStore } from '@stores/data/useKeyStore'; +import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; + +import type { CompletionBinding } from '@src/renderer/contexts/EditSessionScope'; + +const captured = vi.hoisted(() => ({ + sound: null as null | { + completionBinding?: CompletionBinding; + onSoundSelect: (soundPath: string | null) => void; + }, +})); + +const patches = vi.hoisted(() => ({ + applyElementPatchesById: vi.fn(() => 1), + applyElementPatchById: vi.fn(() => true), +})); + +vi.mock('@src/renderer/editor/runtime/elementPatch', () => patches); +vi.mock('@contexts/useTranslation', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock('@components/main/Modal/content/pickers/SoundPicker', () => ({ + default: (props: (typeof captured)['sound']) => { + captured.sound = props; + return null; + }, +})); +vi.mock( + '@components/main/Modal/content/pickers/CounterAnimationPicker', + () => ({ + default: () => null, + }), +); +vi.mock('@components/main/Modal/content/pickers/ImagePicker', () => ({ + default: () => null, +})); +vi.mock('@components/main/Modal/content/pickers/ColorPicker', () => ({ + default: () => null, +})); +vi.mock('@components/main/Modal/content/pickers/FontPicker', () => ({ + default: () => null, +})); +vi.mock('@components/main/Grid/PropertiesPanel/ShadowControls', () => ({ + default: () => null, +})); +vi.mock('@src/renderer/editor/runtime/editGestureController', () => ({ + editGestureController: { + preview: vi.fn(), + cancel: vi.fn(), + settleCommit: vi.fn(), + activeGestureId: () => null, + commitPendingAsync: vi.fn(async () => true), + }, +})); + +import { PanelNavProvider } from '@components/main/Grid/PropertiesPanel/PanelNavContext'; +import { BatchKeyLikePanel } from '@components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel'; +import { BATCH_STYLE_SOUND_PAGE_KEY } from '@components/main/Grid/PropertiesPanel/batch/BatchStyleTabContent'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const ID_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const ID_B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; + +const keyAt = (id: string) => ({ ...createDefaultKeyPosition(), id }); + +type PanelProps = React.ComponentProps; + +const mixedValue = (_getter: unknown, defaultValue: T) => ({ + isMixed: false, + value: defaultValue, +}); + +// 프로덕션 배선 고정: 결합 소유자는 EditSessionBoundary 밖 패널이고, +// open 판정은 activePageKey다. 이 테스트는 실제 BatchKeyLikePanel과 실제 +// EditSessionBoundary(선택 지문 리마운트)를 사용한다 +describe('배치 피커 결합 소유권 (프로덕션 배선)', () => { + let host: HTMLDivElement; + let pageHost: HTMLDivElement; + let root: Root; + + const selectKey = (id: string) => { + useKeyStore.setState({ + selectedKeyType: '4key', + canonicalPositions: { '4key': [keyAt(id)] }, + positions: { '4key': [keyAt(id)] }, + }); + useGridSelectionStore.setState({ + selectedElements: [{ type: 'key', id, index: 0 }], + }); + }; + + const panelProps = (): PanelProps => { + const selected = useGridSelectionStore.getState().selectedElements; + const keyElements = selected.filter((element) => element.type === 'key'); + return { + setPanelElement: vi.fn(), + selectedBatchStyleElements: keyElements, + selectedKeyElements: keyElements, + selectedStatElements: [], + selectedGraphElements: [], + selectedKnobElements: [], + selectedKeyLikeElements: keyElements, + selectedGroupInfo: null, + isRenaming: false, + renameInputRef: createRef(), + renameValue: '', + setRenameValue: vi.fn(), + renameCancelledRef: { current: false }, + handleRenameCommit: vi.fn(), + handleRenameCancel: vi.fn(), + handleRenameStart: vi.fn(), + activeTab: 'style', + setActiveTab: vi.fn(), + handleBatchAlign: vi.fn(), + handleBatchDistribute: vi.fn(), + handleBatchSpacing: vi.fn(), + handleBatchSpacingPreview: vi.fn(), + handleBatchSpacingCommit: vi.fn(), + getBatchSpacingValue: () => ({ isMixed: false, value: 0 }), + handleBatchResize: vi.fn(), + handleBatchStyleChange: vi.fn(), + handleBatchStyleChangeComplete: vi.fn(), + handleBatchShadowChangeComplete: vi.fn(), + handleBatchShadowEnabledChange: vi.fn(), + handleBatchGradientCommit: vi.fn(), + handleKeyOnlyStyleChangeComplete: vi.fn(), + handleBatchCounterUpdate: vi.fn(), + handleBatchNoteColorChange: vi.fn(), + handleBatchNoteColorChangeComplete: vi.fn(), + handleBatchGlowColorChange: vi.fn(), + handleBatchGlowColorChangeComplete: vi.fn(), + handleGraphBatchSharedSetting: vi.fn(), + getMixedValue: mixedValue, + getMixedValueBatch: mixedValue, + getMixedValueGraphs: mixedValue, + getMixedValueGraphsAsKey: mixedValue, + getMixedValueKeysOnly: mixedValue, + getMixedValueActiveCapable: mixedValue, + handleActiveCapableStyleChangeComplete: vi.fn(), + getSelectedKeysData: () => [], + getSelectedGraphsData: () => [], + getSelectedBatchStyleData: () => [], + getSelectedKeyOnlyPositions: () => [], + handleBatchKeyOnlyStyleChangeComplete: vi.fn(), + handleBatchNoteColorChangeKeysOnly: vi.fn(), + handleBatchGlowColorChangeKeysOnly: vi.fn(), + batchNoteColorButtonRef: createRef(), + batchGlowColorButtonRef: createRef(), + batchBorderColorButtonRef: createRef(), + batchCounterFillButtonRef: createRef(), + batchCounterStrokeButtonRef: createRef(), + batchImageButtonRef: createRef(), + showBatchImagePicker: false, + setShowBatchImagePicker: vi.fn(), + batchPickerFor: null, + setBatchPickerFor: vi.fn(), + batchCounterColorState: 'idle', + setBatchCounterColorState: vi.fn(), + batchLocalColors: { + noteColor: '#ffffff', + glowColor: '#ffffff', + borderColor: '#ffffff', + borderOpacity: 100, + fillIdle: '#ffffff', + fillActive: '#ffffff', + strokeIdle: '#ffffff', + strokeActive: '#ffffff', + }, + setBatchLocalColors: vi.fn(), + batchLocalOpacities: { noteColor: 100, glowColor: 100 }, + setBatchLocalOpacities: vi.fn(), + handleBatchPickerToggle: vi.fn(), + handleBatchPickerColorChange: vi.fn(), + handleBatchPickerColorChangeComplete: vi.fn(), + getBatchPickerColor: () => '#ffffff', + getBatchPickerRef: () => createRef(), + batchColorPickerInteractiveRefs: [], + batchScrollRefFor: () => () => {}, + panelElement: null, + useCustomCSS: false, + selectedKeyType: '4key', + t: (key: string) => key, + } as unknown as PanelProps; + }; + + const renderPanel = (nav: { + active: string | null; + renderKey: string | null; + }) => { + act(() => { + root.render( + + + , + ); + }); + }; + + beforeEach(() => { + vi.clearAllMocks(); + captured.sound = null; + selectKey(ID_A); + host = document.createElement('div'); + pageHost = document.createElement('div'); + document.body.appendChild(host); + document.body.appendChild(pageHost); + root = createRoot(host); + }); + + afterEach(() => { + act(() => root.unmount()); + host.remove(); + pageHost.remove(); + }); + + it('페이지가 열려 있는 동안 동일 개수 선택 교체에도 시작 선택에 적용한다', () => { + renderPanel({ + active: BATCH_STYLE_SOUND_PAGE_KEY, + renderKey: BATCH_STYLE_SOUND_PAGE_KEY, + }); + expect(captured.sound?.completionBinding).toBe('element-id'); + + act(() => captured.sound!.onSoundSelect('first.wav')); + expect(patches.applyElementPatchesById).toHaveBeenLastCalledWith( + { key: [ID_A] }, + expect.any(Function), + ); + + // 같은 개수의 다른 선택으로 교체 - 경계는 리마운트되지만 결합은 유지 + act(() => selectKey(ID_B)); + renderPanel({ + active: BATCH_STYLE_SOUND_PAGE_KEY, + renderKey: BATCH_STYLE_SOUND_PAGE_KEY, + }); + + act(() => captured.sound!.onSoundSelect('second.wav')); + expect(patches.applyElementPatchesById).toHaveBeenLastCalledWith( + { key: [ID_A] }, + expect.any(Function), + ); + }); + + it('exit 애니메이션 중 재열기는 새 선택을 캡처한다', () => { + renderPanel({ + active: BATCH_STYLE_SOUND_PAGE_KEY, + renderKey: BATCH_STYLE_SOUND_PAGE_KEY, + }); + act(() => captured.sound!.onSoundSelect('first.wav')); + expect(patches.applyElementPatchesById).toHaveBeenLastCalledWith( + { key: [ID_A] }, + expect.any(Function), + ); + + // close: activePageKey는 즉시 null, renderPageKey는 exit 동안 유지 + renderPanel({ active: null, renderKey: BATCH_STYLE_SOUND_PAGE_KEY }); + + // exit 만료 전 다른 선택으로 재열기 + act(() => selectKey(ID_B)); + renderPanel({ active: null, renderKey: BATCH_STYLE_SOUND_PAGE_KEY }); + renderPanel({ + active: BATCH_STYLE_SOUND_PAGE_KEY, + renderKey: BATCH_STYLE_SOUND_PAGE_KEY, + }); + + act(() => captured.sound!.onSoundSelect('second.wav')); + expect(patches.applyElementPatchesById).toHaveBeenLastCalledWith( + { key: [ID_B] }, + expect.any(Function), + ); + }); +}); diff --git a/src/renderer/hooks/pickers/useBatchElementBinding.test.tsx b/src/renderer/hooks/pickers/useBatchElementBinding.test.tsx new file mode 100644 index 00000000..88afe784 --- /dev/null +++ b/src/renderer/hooks/pickers/useBatchElementBinding.test.tsx @@ -0,0 +1,106 @@ +// @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 { + captureBatchElementBinding, + useBatchElementBinding, + type BatchElementBinding, +} from './useBatchElementBinding'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const ID_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const ID_B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; + +describe('captureBatchElementBinding', () => { + // index는 스냅샷 한정 locator다 - stale index가 다른 요소를 결합하지 않게 + // 선택의 안정 ID 자체를 캡처한다 (positions 재조회 없음) + it('선택 요소의 안정 ID를 그대로 캡처한다', () => { + const binding = captureBatchElementBinding({ + key: [{ id: ID_A }, { id: ID_B }], + stat: [], + }); + + expect(binding.binding).toBe('element-id'); + expect(binding.selection.key).toEqual([ID_A, ID_B]); + expect(binding.selection.stat).toBeUndefined(); + }); + + // 일부만 ID로 적용하고 나머지를 조용히 빠뜨리는 반쪽 적용 금지 + it('합성 폴백 ID가 하나라도 있으면 배치 전체를 legacy로 보낸다', () => { + const binding = captureBatchElementBinding({ + key: [{ id: ID_A }, { id: 'key-0' }], + }); + + expect(binding.binding).toBe('session-mode'); + expect(binding.selection).toEqual({}); + }); +}); + +describe('useBatchElementBinding', () => { + let host: HTMLDivElement; + let root: Root; + const seen: { latest: BatchElementBinding | null } = { latest: null }; + const source: { ids: string[] } = { ids: [] }; + + const Probe = ({ open }: { open: boolean }) => { + const binding = useBatchElementBinding(open, () => + captureBatchElementBinding({ + key: source.ids.map((id) => ({ id })), + }), + ); + React.useEffect(() => { + seen.latest = binding; + }); + return null; + }; + + beforeEach(() => { + seen.latest = null; + source.ids = [ID_A]; + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + }); + + afterEach(() => { + act(() => root.unmount()); + host.remove(); + }); + + // 배치 피커는 선택 변경에 언마운트되지 않으므로 open 시점 고정이 핵심 + it('open 시점에 1회 캡처하고 닫힐 때까지 불변으로 유지한다', () => { + act(() => root.render()); + expect(seen.latest?.binding).toBe('session-mode'); + + act(() => root.render()); + expect(seen.latest?.selection.key).toEqual([ID_A]); + + // open 중 선택이 바뀌어도 결합은 시작 시점 그대로 + act(() => { + source.ids = [ID_B]; + root.render(); + }); + expect(seen.latest?.selection.key).toEqual([ID_A]); + + // 닫았다 다시 열면 재캡처 + act(() => root.render()); + act(() => root.render()); + expect(seen.latest?.selection.key).toEqual([ID_B]); + }); + + // 소유자가 리마운트 경계 안에 있으면 open 중 선택 교체가 재캡처를 만든다. + // 이 훅의 소유자를 EditSessionBoundary 밖에 두는 이유를 고정한다 + it('소유자가 리마운트되면 open 상태여도 재캡처된다', () => { + act(() => root.render()); + expect(seen.latest?.selection.key).toEqual([ID_A]); + + act(() => { + source.ids = [ID_B]; + root.render(); + }); + expect(seen.latest?.selection.key).toEqual([ID_B]); + }); +}); diff --git a/src/renderer/hooks/pickers/useBatchElementBinding.ts b/src/renderer/hooks/pickers/useBatchElementBinding.ts new file mode 100644 index 00000000..550b931c --- /dev/null +++ b/src/renderer/hooks/pickers/useBatchElementBinding.ts @@ -0,0 +1,73 @@ +import { useEffect, useRef, useState } from 'react'; + +import type { NativeElementType } from '@src/renderer/editor/model/elementIdMap'; +import type { ElementIdSelection } from '@src/renderer/editor/runtime/elementPatch'; +import type { CompletionBinding } from '@src/renderer/contexts/EditSessionScope'; + +// 배치 피커의 비동기 완료가 결합할 시작 시점 대상 +export interface BatchElementBinding { + binding: CompletionBinding; + selection: ElementIdSelection; +} + +export const LEGACY_BATCH_ELEMENT_BINDING: BatchElementBinding = { + binding: 'session-mode', + selection: {}, +}; + +type SelectionGroups = Partial< + Record +>; + +const NATIVE_ELEMENT_TYPES: readonly NativeElementType[] = [ + 'key', + 'stat', + 'graph', + 'knob', +]; + +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +// 선택 요소의 안정 ID를 그대로 캡처한다. index는 스냅샷 한정 locator라 +// 신원 재추론에 쓰지 않는다 (stale index가 다른 요소를 결합하는 오염 방지). +// 합성 폴백 ID('key-0' 등, backfill 전 구형 데이터)가 하나라도 있으면 배치 +// 전체를 legacy index 경로로 보낸다 - 반쪽 적용 금지 +export const captureBatchElementBinding = ( + groups: SelectionGroups, +): BatchElementBinding => { + const selection: { [K in NativeElementType]?: string[] } = {}; + for (const type of NATIVE_ELEMENT_TYPES) { + const elements = groups[type]; + if (!elements || elements.length === 0) continue; + const ids: string[] = []; + for (const element of elements) { + if (!UUID_PATTERN.test(element.id)) return LEGACY_BATCH_ELEMENT_BINDING; + ids.push(element.id); + } + selection[type] = ids; + } + return { binding: 'element-id', selection }; +}; + +// 피커 open 전환 시 1회 캡처해 close까지 불변으로 유지한다. +// 배치 피커는 선택 변경에도 언마운트되지 않으므로, 렌더 스코프 계산으로는 +// 시작 시점 결합을 고정할 수 없다. 이 훅의 소유자는 선택 변경 리마운트 +// 경계(EditSessionBoundary) 밖에 있어야 한다 - 경계 안이면 같은 개수 선택 +// 교체 시 새 인스턴스가 open 상태로 마운트되어 재캡처된다 +export const useBatchElementBinding = ( + open: boolean, + capture: () => BatchElementBinding, +): BatchElementBinding => { + const [bound, setBound] = useState( + LEGACY_BATCH_ELEMENT_BINDING, + ); + const wasOpen = useRef(false); + useEffect(() => { + // open 전환에서만 1회 실행되는 의도적 동기 캡처 - 연쇄 렌더 없음 + // eslint-disable-next-line react-hooks/set-state-in-effect + if (open && !wasOpen.current) setBound(capture()); + wasOpen.current = open; + }, [open, capture]); + return bound; +}; diff --git a/src/types/key/counterAnimation.ts b/src/types/key/counterAnimation.ts index b7cf09be..5956cb9a 100644 --- a/src/types/key/counterAnimation.ts +++ b/src/types/key/counterAnimation.ts @@ -168,6 +168,22 @@ export function applyPresetToAnimation( }; } +// 배치 모션 적용용 intent mask: 피커가 소유한 preset 필드만 쓰고 각 요소의 +// enabled는 보존한다. 배치 기준값은 첫 요소라 델타 비교는 혼합 상태를 +// 오판한다 (같은 preset 재선택으로 혼합 통일하려는 동작이 무변경으로 보임) +export function applyAnimationIntentMask( + current: KeyCounterAnimationSettings, + next: KeyCounterAnimationSettings, +): KeyCounterAnimationSettings { + return { + enabled: current.enabled, + presetId: next.presetId, + bezier: next.bezier, + scale: next.scale, + durationMs: next.durationMs, + }; +} + // 비동기 완료 병합용. 시작 스냅샷(start) 대비 실제로 바뀐 필드만 base(fresh) // 위에 적용해, 대기 중 다른 writer가 바꾼 필드(enabled 등)를 시작 값으로 // 되돌리지 않는다. 필드가 늘면 여기 병합도 함께 늘려야 컴파일된다. From 9bf0f7213c418c45a2e21729dfa67e123db13743 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 12 Aug 2026 05:16:19 +0900 Subject: [PATCH 15/35] =?UTF-8?q?test:=20=EC=82=AD=EC=A0=9C=20=ED=9B=84=20?= =?UTF-8?q?=EC=9E=AC=EB=B0=9C=EA=B8=89=20=EC=9A=94=EC=86=8C=EC=97=90=20?= =?UTF-8?q?=EC=98=9B=20ID=20=EC=99=84=EB=A3=8C=EA=B0=80=20=EB=8B=BF?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EC=9D=8C=EC=9D=84=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../editor/runtime/editorCoordinator.test.ts | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/src/renderer/editor/runtime/editorCoordinator.test.ts b/src/renderer/editor/runtime/editorCoordinator.test.ts index db0d1123..dfb5e108 100644 --- a/src/renderer/editor/runtime/editorCoordinator.test.ts +++ b/src/renderer/editor/runtime/editorCoordinator.test.ts @@ -508,6 +508,66 @@ describe('EditorSaveCoordinator', () => { expect(harness.getLocal().keyPositions['4key'][0].id).toBe(adaptedId); }); + it('삭제 후 stale 재제출로 재발급된 요소에 옛 ID 완료가 닿지 않는다', async () => { + // 계약 신뢰 경계 필수 테스트: retired-ID 집합 없이도 이 체인이 안전해야 한다 + const base = makeDocument('A'); + const retiredId = base.keyPositions['4key'][0].id; + const harness = createHarness(base); + emulateV1AdapterCommit(harness); + await harness.coordinator.start(); + + // 1) 비동기 완료가 retiredId를 캡처해 둔 상태에서 요소 삭제 + await harness.coordinator.commitPatch({ + schemaVersion: 1, + keys: { '4key': [] }, + keyPositions: { '4key': [] }, + }); + expect(harness.transport.canonical.document.keyPositions['4key']).toEqual( + [], + ); + + // 2) 삭제 전 스냅샷을 든 stale v1 클라이언트가 무ID로 재제출 - + // 계약(§3)상 삭제된 ID를 승계하지 못하고 새 ID를 발급받는다 + await harness.coordinator.commitIsolatedPluginPatch( + { + schemaVersion: 1, + keys: base.keys, + keyPositions: strippedIdPositions(base), + }, + { multiKey: false }, + ); + const reissued = + harness.transport.canonical.document.keyPositions['4key'][0]; + expect(reissued.id).toBeTruthy(); + expect(reissued.id).not.toBe(retiredId); + + // 3) 옛 ID에 묶인 완료가 실행돼도 재발급 요소를 건드리지 않는다 + const commitsBefore = harness.transport.commitMock.mock.calls.length; + const result = await harness.coordinator.commitGeneratedPatch((latest) => { + const record = structuredClone(latest.keyPositions); + let touched = false; + for (const list of Object.values(record)) { + list.forEach((position, index) => { + if (position.id !== retiredId) return; + list[index] = { ...position, inactiveImage: 'stale.png' }; + touched = true; + }); + } + return touched ? { schemaVersion: 1, keyPositions: record } : null; + }); + + expect(harness.transport.commitMock.mock.calls.length).toBe(commitsBefore); + const finalPosition = + harness.transport.canonical.document.keyPositions['4key'][0]; + expect(finalPosition.id).toBe(reissued.id); + expect(finalPosition.inactiveImage ?? '').toBe(''); + expect(result.keyPositions['4key'][0].inactiveImage ?? '').toBe(''); + expect(harness.getLocal().keyPositions['4key'][0].inactiveImage ?? '').toBe( + '', + ); + harness.coordinator.stop(); + }); + it('resyncs on retry conflict so plugin retries recover without ui sync', async () => { const base = makeDocument('A'); const idBefore = base.keyPositions['4key'][0].id; From 86a421d202584692523a661e0fe38c37d557829d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 12 Aug 2026 05:16:19 +0900 Subject: [PATCH 16/35] =?UTF-8?q?feat:=20=ED=82=A4=20=EB=A9=94=EB=89=B4=20?= =?UTF-8?q?=EC=BB=A8=ED=85=8D=EC=8A=A4=ED=8A=B8=EC=97=90=20=EC=9A=94?= =?UTF-8?q?=EC=86=8C=20=EC=95=88=EC=A0=95=20ID=20=EB=85=B8=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/content/en/ui-api/page.mdx | 3 ++- docs/content/ko/ui-api/page.mdx | 3 ++- src/renderer/components/main/Grid/core/Grid.tsx | 1 + src/renderer/hooks/Grid/useGridContextMenu.ts | 3 +++ src/types/plugin/api.ts | 3 +++ 5 files changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/content/en/ui-api/page.mdx b/docs/content/en/ui-api/page.mdx index 187cedd5..989edc36 100644 --- a/docs/content/en/ui-api/page.mdx +++ b/docs/content/en/ui-api/page.mdx @@ -33,7 +33,8 @@ const menuId = dmn.ui.contextMenu.addKeyMenuItem({ | Property | Type | Description | | ---------- | ----------- | ----------------------- | | `keyCode` | string | Canonical slot identifier (e.g. "D", "LEFT CTRL+Z", "F\|NUMPAD 4") | -| `index` | number | Key index | +| `id` | string | Stable element ID (UUID). Keeps identifying the same key across reorders and mode switches | +| `index` | number | Key index. Deprecated: only valid for the current snapshot, use `id` for identity | | `position` | KeyPosition | Key position info | | `mode` | string | Current key mode | diff --git a/docs/content/ko/ui-api/page.mdx b/docs/content/ko/ui-api/page.mdx index a25195a9..87cf88c9 100644 --- a/docs/content/ko/ui-api/page.mdx +++ b/docs/content/ko/ui-api/page.mdx @@ -33,7 +33,8 @@ const menuId = dmn.ui.contextMenu.addKeyMenuItem({ | 속성 | 타입 | 설명 | | ---------- | ----------- | -------------------- | | `keyCode` | string | canonical 슬롯 식별자 (예: "D", "LEFT CTRL+Z", "F\|NUMPAD 4") | -| `index` | number | 키 인덱스 | +| `id` | string | 요소 안정 ID (UUID). 재정렬·모드 전환에도 같은 키를 가리킵니다 | +| `index` | number | 키 인덱스. deprecated: 현재 스냅샷에서만 유효하며 신원은 `id`를 사용하세요 | | `position` | KeyPosition | 키 위치 정보 | | `mode` | string | 현재 키 모드 | diff --git a/src/renderer/components/main/Grid/core/Grid.tsx b/src/renderer/components/main/Grid/core/Grid.tsx index 64e1c8f5..a9474311 100644 --- a/src/renderer/components/main/Grid/core/Grid.tsx +++ b/src/renderer/components/main/Grid/core/Grid.tsx @@ -2189,6 +2189,7 @@ const Grid = ({ keyCode: slotCanonical( keyMappings[selectedKeyType]?.[contextIndex] ?? '', ), + id: positionForContext.id ?? '', index: contextIndex, position: positionForContext, mode: selectedKeyType, diff --git a/src/renderer/hooks/Grid/useGridContextMenu.ts b/src/renderer/hooks/Grid/useGridContextMenu.ts index 515bb2cd..1430fe15 100644 --- a/src/renderer/hooks/Grid/useGridContextMenu.ts +++ b/src/renderer/hooks/Grid/useGridContextMenu.ts @@ -26,6 +26,8 @@ interface MenuItem { interface KeyContext { keyCode: string; + // 요소 안정 ID - index는 스냅샷 한정 locator + id: string; index: number; position: KeyPosition; mode: string; @@ -123,6 +125,7 @@ export function useGridContextMenu({ keyCode: slotCanonical( keyMappings[selectedKeyType]?.[contextIndex] ?? '', ), + id: keyPosition.id ?? '', index: contextIndex, position: keyPosition, mode: selectedKeyType, diff --git a/src/types/plugin/api.ts b/src/types/plugin/api.ts index 6a5fcf98..8173e2dc 100644 --- a/src/types/plugin/api.ts +++ b/src/types/plugin/api.ts @@ -350,6 +350,9 @@ export type WindowTarget = 'main' | 'overlay'; // UI Plugin 컨텍스트 메뉴 types export type KeyMenuContext = { keyCode: string; + /** 요소 안정 ID (UUID). 재정렬·모드 전환에도 유지된다 */ + id: string; + /** @deprecated 현재 스냅샷에서만 유효한 위치 locator - 신원은 id 사용 */ index: number; position: KeyPosition; mode: string; From c482330b0aa7e16d353204efe8b9bd844c36ab5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 12 Aug 2026 05:16:19 +0900 Subject: [PATCH 17/35] =?UTF-8?q?docs:=20=ED=94=84=EB=A6=AC=EC=85=8B=20ID?= =?UTF-8?q?=20=EC=9E=AC=EB=B0=9C=EA=B8=89=EA=B3=BC=20=EB=85=B8=EB=B8=8C=20?= =?UTF-8?q?=EC=95=88=EC=A0=95=20ID=20=EB=AC=B8=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/content/en/api-reference/knobs/page.mdx | 2 ++ docs/content/en/api-reference/presets/page.mdx | 2 ++ docs/content/ko/api-reference/knobs/page.mdx | 2 ++ docs/content/ko/api-reference/presets/page.mdx | 2 ++ 4 files changed, 8 insertions(+) diff --git a/docs/content/en/api-reference/knobs/page.mdx b/docs/content/en/api-reference/knobs/page.mdx index 502ca037..85a5d909 100644 --- a/docs/content/en/api-reference/knobs/page.mdx +++ b/docs/content/en/api-reference/knobs/page.mdx @@ -36,6 +36,8 @@ identifiers are stored in a knob element's `axisId`. ```typescript // KeyPosition styling fields are inherited (position, size, colors, images...) +// including the stable element `id` (UUID): it keeps identifying the same knob +// across reorders and mode switches, and preset loads reissue new IDs type KnobItemPosition = KeyPosition & { axisId: string; // bound HID axis ("HIDA:..."), empty if unbound sensitivity: number; // rotation multiplier (default 1) diff --git a/docs/content/en/api-reference/presets/page.mdx b/docs/content/en/api-reference/presets/page.mdx index 3267f4bc..00b3f60c 100644 --- a/docs/content/en/api-reference/presets/page.mdx +++ b/docs/content/en/api-reference/presets/page.mdx @@ -69,6 +69,8 @@ const result = await dmn.presets.loadTab(); Subscribes to preset load completions. Receives the full snapshot of applied data (key mappings, positions, tabs, mode, etc.) in a single event. +Position collections in the snapshot carry element `id` values that were freshly issued during this load. Applying a preset rekeys every element with new UUIDs, so IDs from the preset file or from a previous load never survive into the applied state, and loading the same preset twice produces two different ID sets. Re-resolve any cached element IDs from this snapshot. + ```typescript interface PresetSnapshot { // Entries use the KeySlot union (string | MultiKeySlot) diff --git a/docs/content/ko/api-reference/knobs/page.mdx b/docs/content/ko/api-reference/knobs/page.mdx index 8ac602d6..fb672b33 100644 --- a/docs/content/ko/api-reference/knobs/page.mdx +++ b/docs/content/ko/api-reference/knobs/page.mdx @@ -36,6 +36,8 @@ DmNote는 HID 입력에 고정 문자열 식별자를 부여합니다: ```typescript // KeyPosition의 스타일 필드를 상속 (위치, 크기, 색상, 이미지 등) +// 요소 안정 id(UUID)도 포함: 재정렬·모드 전환에도 같은 노브를 가리키며, +// 프리셋 로드 시에는 새 ID가 발급됩니다 type KnobItemPosition = KeyPosition & { axisId: string; // 바인딩된 HID 축("HIDA:..."), 미바인딩이면 빈 문자열 sensitivity: number; // 회전 배율 (기본 1) diff --git a/docs/content/ko/api-reference/presets/page.mdx b/docs/content/ko/api-reference/presets/page.mdx index ff5946c9..bf01777c 100644 --- a/docs/content/ko/api-reference/presets/page.mdx +++ b/docs/content/ko/api-reference/presets/page.mdx @@ -106,6 +106,8 @@ dmn.plugin.registerCleanup(() => { 프리셋 로드 완료 시 적용된 스냅샷 데이터를 수신합니다. 키 매핑, 위치, 탭, 모드 등이 한 번에 전달됩니다. +스냅샷의 위치 컬렉션에는 이번 로드에서 새로 발급된 요소 `id`가 담깁니다. 프리셋 적용은 모든 요소를 새 UUID로 재발급하므로, 프리셋 파일 안의 ID나 이전 로드의 ID는 적용 상태로 이어지지 않고, 같은 프리셋을 두 번 적용하면 서로 다른 ID 집합이 생깁니다. 캐시해 둔 요소 ID는 이 스냅샷에서 다시 조회하세요. + ```typescript interface PresetSnapshot { // 항목은 KeySlot union(string | MultiKeySlot) From dd803514c31f8d264fc8e81ded637bf5053c6954 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 12 Aug 2026 05:16:19 +0900 Subject: [PATCH 18/35] =?UTF-8?q?refactor:=20=EB=AF=B8=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=20=EC=9A=94=EC=86=8C=20=EC=B0=B8=EC=A1=B0=20=EC=BA=A1=EC=B2=98?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../editor/model/elementIdMap.test.ts | 12 +----------- src/renderer/editor/model/elementIdMap.ts | 19 ------------------- 2 files changed, 1 insertion(+), 30 deletions(-) diff --git a/src/renderer/editor/model/elementIdMap.test.ts b/src/renderer/editor/model/elementIdMap.test.ts index e8de71fb..3f836cfe 100644 --- a/src/renderer/editor/model/elementIdMap.test.ts +++ b/src/renderer/editor/model/elementIdMap.test.ts @@ -1,10 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import { - captureElementRef, - resolveElementById, - resolveElementByIdAcross, -} from './elementIdMap'; +import { resolveElementById, resolveElementByIdAcross } from './elementIdMap'; import { createDefaultKeyPosition } from './keys'; import { useKeyStore } from '@stores/data/useKeyStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; @@ -75,10 +71,4 @@ describe('elementIdMap', () => { index: 0, }); }); - - it('id가 없는 요소는 캡처하지 않는다 (구형 데이터 폴백)', () => { - expect(captureElementRef('key', '4key', { id: undefined })).toBeNull(); - expect(captureElementRef('key', '4key', undefined)).toBeNull(); - expect(captureElementRef('key', '4key', { id: 'abc' })?.id).toBe('abc'); - }); }); diff --git a/src/renderer/editor/model/elementIdMap.ts b/src/renderer/editor/model/elementIdMap.ts index 36883802..a9960176 100644 --- a/src/renderer/editor/model/elementIdMap.ts +++ b/src/renderer/editor/model/elementIdMap.ts @@ -13,13 +13,6 @@ export interface ElementLocator { index: number; } -// 비동기 시작 시 캡처해 완료 시 조회하는 요소 참조 -export interface ElementRef { - type: NativeElementType; - mode: string; - id: string; -} - type PositionsRecord = Record | undefined; // 권위 컬렉션만 읽는다. 키의 렌더 positions는 canonical + 프리뷰 합성이라 @@ -90,15 +83,3 @@ export const resolveElementByIdAcross = ( } return null; }; - -// 비동기 작업 시작 시점의 참조 캡처. 대상 요소에 id가 없으면(구형 데이터가 -// 아직 backfill 전) null을 돌려 호출부가 기존 경로를 유지하게 한다 -export const captureElementRef = ( - type: NativeElementType, - mode: string, - position: { id?: string } | undefined, -): ElementRef | null => { - const id = position?.id; - if (typeof id !== 'string' || id.length === 0) return null; - return { type, mode, id }; -}; From 5450aa3c1748e5ae5911ad6f8e1ca31f86cd080d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 12 Aug 2026 14:25:41 +0900 Subject: [PATCH 19/35] =?UTF-8?q?feat:=20=ED=8E=B8=EC=A7=91=20=EC=BB=A4?= =?UTF-8?q?=EB=B0=8B=EC=97=90=20=EC=A7=80=EC=97=B0=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5=EA=B3=BC=20=EB=B0=B0=ED=83=80=20legacy=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=20=EA=B8=B0=EB=B0=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../runtime/editorCompatibilityQueue.test.ts | 45 +++++ .../runtime/editorCompatibilityQueue.ts | 13 ++ .../editor/runtime/editorCoordinator.test.ts | 179 +++++++++++++++++- .../editor/runtime/editorCoordinator.ts | 24 ++- 4 files changed, 259 insertions(+), 2 deletions(-) create mode 100644 src/renderer/editor/runtime/editorCompatibilityQueue.test.ts diff --git a/src/renderer/editor/runtime/editorCompatibilityQueue.test.ts b/src/renderer/editor/runtime/editorCompatibilityQueue.test.ts new file mode 100644 index 00000000..ca400971 --- /dev/null +++ b/src/renderer/editor/runtime/editorCompatibilityQueue.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; + +import { + enqueueEditorCompatibilityOperation, + enqueueEditorCompatibilityWrite, +} from './editorCompatibilityQueue'; + +describe('enqueueEditorCompatibilityOperation', () => { + it('선행 write 뒤에 실행되고 반환값을 보존한다', async () => { + const order: string[] = []; + let release!: () => void; + const blocker = new Promise((resolve) => { + release = resolve; + }); + const first = enqueueEditorCompatibilityWrite( + async () => { + await blocker; + order.push('write'); + }, + () => undefined, + ); + const second = enqueueEditorCompatibilityOperation(async () => { + order.push('operation'); + return 42; + }); + + await Promise.resolve(); + expect(order).toEqual([]); + + release(); + await first; + expect(await second).toBe(42); + expect(order).toEqual(['write', 'operation']); + }); + + it('실패는 원 오류로 전파되고 큐는 계속 진행된다', async () => { + const failing = enqueueEditorCompatibilityOperation(async () => { + throw new Error('operation failed'); + }); + const following = enqueueEditorCompatibilityOperation(async () => 'next'); + + await expect(failing).rejects.toThrow('operation failed'); + expect(await following).toBe('next'); + }); +}); diff --git a/src/renderer/editor/runtime/editorCompatibilityQueue.ts b/src/renderer/editor/runtime/editorCompatibilityQueue.ts index c2aa142e..ccf88b7b 100644 --- a/src/renderer/editor/runtime/editorCompatibilityQueue.ts +++ b/src/renderer/editor/runtime/editorCompatibilityQueue.ts @@ -13,3 +13,16 @@ export const enqueueEditorCompatibilityWrite = ( ); return trackEditorWrite(operation.then(result)); }; + +// 작업의 실제 반환값을 보존하는 variant. 큐 합류와 write barrier 추적은 +// 동일하고, 실패는 원 오류 그대로 전파되며 큐는 계속 진행된다 +export const enqueueEditorCompatibilityOperation = ( + operation: () => Promise, +): Promise => { + const run = compatibilityWriteQueue.then(operation); + compatibilityWriteQueue = run.then( + () => undefined, + () => undefined, + ); + return trackEditorWrite(run); +}; diff --git a/src/renderer/editor/runtime/editorCoordinator.test.ts b/src/renderer/editor/runtime/editorCoordinator.test.ts index dfb5e108..ba495f3e 100644 --- a/src/renderer/editor/runtime/editorCoordinator.test.ts +++ b/src/renderer/editor/runtime/editorCoordinator.test.ts @@ -15,7 +15,10 @@ import { createEditorPatch, getChangedEditorFields, } from './editorCoordinator'; -import { enqueueEditorCompatibilityWrite } from './editorCompatibilityQueue'; +import { + enqueueEditorCompatibilityOperation, + enqueueEditorCompatibilityWrite, +} from './editorCompatibilityQueue'; import type { EditorCommitError, @@ -2014,6 +2017,24 @@ describe('commitGeneratedPatch', () => { harness.coordinator.stop(); }); + it('생성 커밋의 gestureId가 wire 요청에 실린다', async () => { + const base = makeDocument('A'); + const harness = createHarness(base); + await harness.coordinator.start(); + + await harness.coordinator.commitGeneratedPatch( + (latest) => ({ + schemaVersion: 1, + keyPositions: imageRecordFrom(latest), + }), + { gestureId: 'gesture-generated' }, + ); + + const request = harness.transport.commitMock.mock.calls.at(-1)?.[0]; + expect(request?.gestureIds).toEqual(['gesture-generated']); + harness.coordinator.stop(); + }); + it('null 생성은 mutation·낙관 적용·revision 전진이 전부 없다', async () => { const base = makeDocument('A'); const harness = createHarness(base); @@ -2060,6 +2081,162 @@ describe('commitGeneratedPatch', () => { harness.coordinator.stop(); }); + it('배타 legacy mutation은 in-flight 커밋 완료 후 실행되고 canonical을 재동기화한다', async () => { + const base = makeDocument('A'); + const harness = createHarness(base); + await harness.coordinator.start(); + + const gate = deferred(); + gatedDefaultCommit(harness, gate.promise); + const gesture = harness.coordinator.commitGesture( + { schemaVersion: 1, keys: { '4key': ['G'] } }, + 'gesture-g', + async (context) => + harness.transport.commit({ + baseRevision: context.editorBaseRevision, + mutationId: context.mutationId, + changes: context.editorChanges!, + }), + ); + + const mutationSpy = vi.fn(async () => { + // 백엔드가 문서를 직접 바꾸는 legacy 커맨드 흉내 + const before = harness.transport.canonical.document; + harness.transport.canonical.document = applyEditorPatch(before, { + schemaVersion: 1, + keys: { '4key': ['L'] }, + }); + harness.transport.canonical.revision += 1; + return 'mutated'; + }); + const exclusive = + harness.coordinator.runExclusiveLegacyMutation(mutationSpy); + + await Promise.resolve(); + await Promise.resolve(); + expect(mutationSpy).not.toHaveBeenCalled(); + + gate.resolve(); + await gesture; + const result = await exclusive; + + expect(result).toBe('mutated'); + // 슬롯 안 재동기화로 로컬 문서가 mutation 결과를 반영 + expect(harness.getLocal().keys['4key']).toEqual(['L']); + + // 이후 자사 커밋은 mutation 결과 위에서 진행 + await harness.coordinator.commitPatch({ + schemaVersion: 1, + keyPositions: imageRecordFrom(harness.getLocal()), + }); + const finalDocument = harness.transport.canonical.document; + expect(finalDocument.keys['4key']).toEqual(['L']); + expect(finalDocument.keyPositions['4key'][0].inactiveImage).toBe( + 'generated.png', + ); + harness.coordinator.stop(); + }); + + it('선행 stale compat write와 후행 generated가 배타 mutation 결과를 되돌리지 않는다', async () => { + const base = makeDocument('A'); + const harness = createHarness(base); + await harness.coordinator.start(); + + // 선행 writer: 클릭 시점 캡처된 stale full record (compat 큐 대기) + const releaseC = deferred(); + const gate = enqueueEditorCompatibilityWrite( + () => releaseC.promise, + () => undefined, + ); + const staleRecord = structuredClone( + harness.transport.canonical.document.keyPositions, + ); + staleRecord['4key'] = staleRecord['4key'].map((position, index) => + index === 0 ? { ...position, noteWidth: 111 } : position, + ); + const staleWrite = enqueueEditorCompatibilityWrite( + () => + harness.coordinator.commitPatch({ + schemaVersion: 1, + keyPositions: staleRecord, + }), + () => undefined, + ); + + // 배타 legacy mutation (compat 큐 + 직렬 tail 점유) + const legacy = enqueueEditorCompatibilityOperation(() => + harness.coordinator.runExclusiveLegacyMutation(async () => { + harness.transport.canonical.document = applyEditorPatch( + harness.transport.canonical.document, + { schemaVersion: 1, keys: { '4key': ['L'] } }, + ); + harness.transport.canonical.revision += 1; + return 'mutated'; + }), + ); + + // 후행 generated + const generated = enqueueEditorCompatibilityOperation(() => + harness.coordinator.commitGeneratedPatch((latest) => ({ + schemaVersion: 1, + keyPositions: imageRecordFrom(latest), + })), + ); + + releaseC.resolve(); + await Promise.all([gate, staleWrite, legacy, generated]); + + const finalDocument = harness.transport.canonical.document; + // 셋 다 생존: stale write 값, mutation 결과, generated 값 + expect(finalDocument.keyPositions['4key'][0].noteWidth).toBe(111); + expect(finalDocument.keys['4key']).toEqual(['L']); + expect(finalDocument.keyPositions['4key'][0].inactiveImage).toBe( + 'generated.png', + ); + harness.coordinator.stop(); + }); + + it('배타 mutation 실패는 원 오류로 전파되고 tail은 계속 진행된다', async () => { + const base = makeDocument('A'); + const harness = createHarness(base); + await harness.coordinator.start(); + + await expect( + harness.coordinator.runExclusiveLegacyMutation(async () => { + throw new Error('legacy failed'); + }), + ).rejects.toThrow('legacy failed'); + + await expect( + harness.coordinator.commitPatch({ + schemaVersion: 1, + keys: { '4key': ['N'] }, + }), + ).resolves.toBeTruthy(); + harness.coordinator.stop(); + }); + + it('배타 mutation 재동기화 실패는 mutation 성공을 뒤집지 않는다', async () => { + const base = makeDocument('A'); + const harness = createHarness(base); + await harness.coordinator.start(); + const errorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + harness.transport.getMock.mockRejectedValueOnce( + new Error('ipc unavailable'), + ); + + const result = await harness.coordinator.runExclusiveLegacyMutation( + async () => 'ok', + ); + + expect(result).toBe('ok'); + expect(errorSpy).toHaveBeenCalled(); + errorSpy.mockRestore(); + harness.coordinator.stop(); + }); + it('generator 예외는 해당 커밋만 실패시키고 큐는 계속 진행된다', async () => { const base = makeDocument('A'); const harness = createHarness(base); diff --git a/src/renderer/editor/runtime/editorCoordinator.ts b/src/renderer/editor/runtime/editorCoordinator.ts index a9509ae2..4266f64f 100644 --- a/src/renderer/editor/runtime/editorCoordinator.ts +++ b/src/renderer/editor/runtime/editorCoordinator.ts @@ -449,6 +449,7 @@ export class EditorSaveCoordinator { // (mutation·낙관 적용·revision 전진 전부 없음) commitGeneratedPatch( generate: (base: EditorDocumentV1) => EditorPatchV1 | null, + meta?: { gestureId?: string }, ): Promise { this.assertWritable(); return this.enqueueSerialized(async () => { @@ -457,7 +458,28 @@ export class EditorSaveCoordinator { await this.eventQueue; const changes = generate(this.getLatestCommitBase()); if (!changes) return clone(this.requireLastAck()); - return this.commitPatchSettled(changes); + return this.commitPatchSettled(changes, meta?.gestureId); + }); + } + + // 백엔드가 문서를 직접 바꾸는 legacy 커맨드(프리셋 로드, 리셋 등) 전용 + // 배타 실행. 직렬 tail을 점유해 대기 중 stale full-record가 mutation + // 결과를 되돌리는 순서를 차단하고, 슬롯 안에서 canonical을 재동기화한다. + // public sync()는 tail을 기다리므로 슬롯 안에서 부르면 자기 교착 + runExclusiveLegacyMutation(mutation: () => Promise): Promise { + this.assertWritable(); + return this.enqueueSerialized(async () => { + await this.start(); + await this.drainUntilSettled(); + await this.eventQueue; + const result = await mutation(); + try { + await this.fetchAndApplyCanonical('resync'); + } catch (error) { + // 명령 성공을 재동기화 실패로 뒤집지 않음 - committed 이벤트가 보정 + console.error('배타 legacy mutation 재동기화 실패', error); + } + return result; }); } From 46c024ee56d5d87ee3fc660fbe780ed83e76a241 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 12 Aug 2026 14:25:41 +0900 Subject: [PATCH 20/35] =?UTF-8?q?fix:=20=EB=AC=B8=EC=84=9C=20=EC=9E=AC?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20legacy=20=EC=BB=A4=EB=A7=A8=EB=93=9C?= =?UTF-8?q?=EB=A5=BC=20=EB=B0=B0=ED=83=80=20=EC=A7=81=EB=A0=AC=ED=99=94?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20=ED=94=8C=EB=9F=AC=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EC=93=B0=EA=B8=B0=EB=A5=BC=20=EA=B2=A9?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/content/en/api-reference/keys/page.mdx | 9 + docs/content/ko/api-reference/keys/page.mdx | 7 + src/renderer/api/modules/keysApi.ts | 10 +- .../api/modules/legacyMutationRouting.test.ts | 50 +++++ src/renderer/api/modules/presetsApi.ts | 8 +- src/renderer/api/modules/resourceApi.ts | 11 +- .../runtime/legacyEditorMutation.test.ts | 176 +++++++++++------- .../editor/runtime/legacyEditorMutation.ts | 33 ++++ .../api/pluginApiProxy.positions.test.ts | 65 +++++++ .../plugins/runtime/api/pluginApiProxy.ts | 36 ++++ .../runtime/api/pluginWriteGateway.test.ts | 40 ++++ .../plugins/runtime/api/pluginWriteGateway.ts | 23 +++ 12 files changed, 388 insertions(+), 80 deletions(-) create mode 100644 src/renderer/api/modules/legacyMutationRouting.test.ts create mode 100644 src/renderer/plugins/runtime/api/pluginApiProxy.positions.test.ts diff --git a/docs/content/en/api-reference/keys/page.mdx b/docs/content/en/api-reference/keys/page.mdx index 1c858f04..0af203c0 100644 --- a/docs/content/en/api-reference/keys/page.mdx +++ b/docs/content/en/api-reference/keys/page.mdx @@ -323,6 +323,15 @@ positions['4key'][0].dx = 100; const committed = await dmn.keys.updatePositions(positions); ``` +Position writes from plugins are serialized on a dedicated queue and settle +against the committed document: the promise resolves only after the commit and +the follow-up read complete, and the returned collections carry the stable +element `id` values the app assigned (elements submitted without an `id` get +one issued). On failure the original error is rejected as-is; if a commit +might have succeeded but the follow-up read failed, read the current value +before retrying instead of resubmitting blindly. The same applies to +`statItems`, `graphItems`, and `knobItems` `updatePositions`. + `keys[mode][i]` and `keyPositions[mode][i]` are coupled by index. A standalone `update()` or `updatePositions()` may fail with `PAIRED_UPDATE_REQUIRED` if it diff --git a/docs/content/ko/api-reference/keys/page.mdx b/docs/content/ko/api-reference/keys/page.mdx index 3558f4cc..4237d347 100644 --- a/docs/content/ko/api-reference/keys/page.mdx +++ b/docs/content/ko/api-reference/keys/page.mdx @@ -194,6 +194,13 @@ current['4key'][0].dx = 100; await dmn.keys.updatePositions(current); ``` +플러그인의 위치 쓰기는 전용 큐로 직렬화되어 확정 문서 기준으로 정산됩니다. +promise는 커밋과 후속 조회까지 끝난 뒤 resolve되고, 반환 컬렉션에는 앱이 +부여한 요소 안정 `id`가 담깁니다 (`id` 없이 제출한 요소는 새로 발급됨). +실패 시 원 오류가 그대로 reject되며, 커밋은 성공했는데 후속 조회만 실패했을 +가능성이 있으면 무작정 재제출하지 말고 현재 값을 먼저 조회해 확인하세요. +`statItems`·`graphItems`·`knobItems`의 `updatePositions`도 동일합니다. + ### updateWithPositions(mappings, positions, options?) 키 매핑과 인덱스로 결합된 위치 정보를 한 번의 원자적 커밋으로 갱신합니다. diff --git a/src/renderer/api/modules/keysApi.ts b/src/renderer/api/modules/keysApi.ts index 31372437..83e1ee8b 100644 --- a/src/renderer/api/modules/keysApi.ts +++ b/src/renderer/api/modules/keysApi.ts @@ -3,7 +3,7 @@ import { subscribe } from './shared'; import { rawKeyEventBus } from '@utils/core/rawKeyEventBus'; import { enqueueEditorCompatibilityWrite } from '@src/renderer/editor/runtime/editorCompatibilityQueue'; import { editorCoordinator } from '@src/renderer/editor/runtime/editorStateCoordinator'; -import { runLegacyEditorMutation } from '@src/renderer/editor/runtime/legacyEditorMutation'; +import { runExclusiveLegacyMutation } from '@src/renderer/editor/runtime/legacyEditorMutation'; import type { KeyCounterUpdate, @@ -99,11 +99,11 @@ export const keysApi = { setMode: (mode: string) => invoke('keys_set_mode', { mode }), resetAll: () => - runLegacyEditorMutation(() => + runExclusiveLegacyMutation(() => invoke('keys_reset_all'), ), resetMode: (mode: string) => - runLegacyEditorMutation(() => + runExclusiveLegacyMutation(() => invoke('keys_reset_mode', { mode }), ), setCounters: (counters: KeyCounters) => @@ -157,11 +157,11 @@ export const keysApi = { customTabs: { list: () => invoke('custom_tabs_list'), create: (name: string) => - runLegacyEditorMutation(() => + runExclusiveLegacyMutation(() => invoke('custom_tabs_create', { name }), ), delete: (id: string) => - runLegacyEditorMutation(() => + runExclusiveLegacyMutation(() => invoke('custom_tabs_delete', { id }), ), select: (id: string) => diff --git a/src/renderer/api/modules/legacyMutationRouting.test.ts b/src/renderer/api/modules/legacyMutationRouting.test.ts new file mode 100644 index 00000000..305f5541 --- /dev/null +++ b/src/renderer/api/modules/legacyMutationRouting.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const routing = vi.hoisted(() => ({ + exclusive: vi.fn(async (mutation: () => Promise) => mutation()), + legacy: vi.fn(async (mutation: () => Promise) => mutation()), + invoke: vi.fn(async () => ({})), +})); + +vi.mock('@src/renderer/editor/runtime/legacyEditorMutation', () => ({ + runExclusiveLegacyMutation: routing.exclusive, + runLegacyEditorMutation: routing.legacy, +})); +vi.mock('@tauri-apps/api/core', () => ({ invoke: routing.invoke })); + +import { keysApi } from './keysApi'; +import { presetsApi } from './presetsApi'; +import { counterAnimationApi, imageApi, soundApi } from './resourceApi'; + +// 편집 문서를 직접 바꾸는 legacy 커맨드 9함수는 전부 배타 mutation을 타야 +// 한다. 큐를 우회하면 대기 중이던 stale full-record가 결과를 되돌린다 +describe('legacy mutation 라우팅', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each([ + ['preset_load', () => presetsApi.load()], + ['preset_load_tab', () => presetsApi.loadTab()], + ['keys_reset_all', () => keysApi.resetAll()], + ['keys_reset_mode', () => keysApi.resetMode('4key')], + ['custom_tabs_create', () => keysApi.customTabs.create('tab')], + ['custom_tabs_delete', () => keysApi.customTabs.delete('tab-id')], + ['counter_animation_update', () => counterAnimationApi.update({} as never)], + ['counter_animation_delete', () => counterAnimationApi.remove('id')], + ['sound_delete', () => soundApi.remove('path.wav')], + ] as const)('%s는 배타 mutation을 탄다', async (command, call) => { + await call(); + + expect(routing.exclusive).toHaveBeenCalledTimes(1); + expect(routing.invoke).toHaveBeenCalledTimes(1); + expect((routing.invoke.mock.calls[0] as unknown[])[0]).toBe(command); + }); + + it('문서를 바꾸지 않는 image.load는 배타 경로가 아니다', async () => { + await imageApi.load(); + + expect(routing.exclusive).not.toHaveBeenCalled(); + expect(routing.legacy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/renderer/api/modules/presetsApi.ts b/src/renderer/api/modules/presetsApi.ts index f10ad537..5c14c473 100644 --- a/src/renderer/api/modules/presetsApi.ts +++ b/src/renderer/api/modules/presetsApi.ts @@ -1,6 +1,6 @@ import { invoke } from '@tauri-apps/api/core'; import { subscribe } from './shared'; -import { runLegacyEditorMutation } from '@src/renderer/editor/runtime/legacyEditorMutation'; +import { runExclusiveLegacyMutation } from '@src/renderer/editor/runtime/legacyEditorMutation'; import type { PresetOperationResult, @@ -10,10 +10,12 @@ import type { export const presetsApi = { save: () => invoke('preset_save'), load: () => - runLegacyEditorMutation(() => invoke('preset_load')), + runExclusiveLegacyMutation(() => + invoke('preset_load'), + ), saveTab: () => invoke('preset_save_tab'), loadTab: () => - runLegacyEditorMutation(() => + runExclusiveLegacyMutation(() => invoke('preset_load_tab'), ), onSnapshot: (listener: (snapshot: PresetSnapshot) => void) => diff --git a/src/renderer/api/modules/resourceApi.ts b/src/renderer/api/modules/resourceApi.ts index b239ff0f..83387852 100644 --- a/src/renderer/api/modules/resourceApi.ts +++ b/src/renderer/api/modules/resourceApi.ts @@ -1,6 +1,9 @@ import { invoke } from '@tauri-apps/api/core'; import { subscribe } from './shared'; -import { runLegacyEditorMutation } from '@src/renderer/editor/runtime/legacyEditorMutation'; +import { + runExclusiveLegacyMutation, + runLegacyEditorMutation, +} from '@src/renderer/editor/runtime/legacyEditorMutation'; export const fontApi = { load: () => @@ -27,7 +30,7 @@ export const soundApi = { displayName, }), remove: (soundPath: string) => - runLegacyEditorMutation(() => + runExclusiveLegacyMutation(() => invoke( 'sound_delete', { @@ -142,14 +145,14 @@ export const counterAnimationApi = { update: ( request: import('@src/types/plugin/api').CounterAnimationUpdateRequest, ) => - runLegacyEditorMutation(() => + runExclusiveLegacyMutation(() => invoke( 'counter_animation_update', { request }, ), ), remove: (id: string) => - runLegacyEditorMutation(() => + runExclusiveLegacyMutation(() => invoke( 'counter_animation_delete', { id }, diff --git a/src/renderer/editor/runtime/legacyEditorMutation.test.ts b/src/renderer/editor/runtime/legacyEditorMutation.test.ts index 9b2a5d71..4fa00fb2 100644 --- a/src/renderer/editor/runtime/legacyEditorMutation.test.ts +++ b/src/renderer/editor/runtime/legacyEditorMutation.test.ts @@ -1,78 +1,118 @@ -import { describe, expect, it, vi } from 'vitest'; - -import { runLegacyEditorMutationWith } from './legacyEditorMutation'; - -describe('legacy editor mutation coordinator gate', () => { - it('coordinator 구독 확립 뒤 명령을 실행하고 canonical을 재적용한다', async () => { - const order: string[] = []; - const coordinator = { - start: vi.fn(async () => { - order.push('start'); - }), - sync: vi.fn(async () => { - order.push('sync'); - }), - }; - - const result = await runLegacyEditorMutationWith(coordinator, async () => { - order.push('mutation'); - return 'saved'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const order = vi.hoisted(() => ({ + calls: [] as string[], + settleResult: true, + activeIds: [] as Array, + activeGestureId: vi.fn(() => order.activeIds.shift() ?? null), + commitPendingAsync: vi.fn(async () => { + order.calls.push('settle'); + return order.settleResult; + }), + cancel: vi.fn(() => { + order.calls.push('cancel'); + }), + exclusive: vi.fn(async (mutation: () => Promise) => { + order.calls.push('exclusive'); + return mutation(); + }), +})); + +vi.mock('./editGestureController', () => ({ + editGestureController: { + activeGestureId: order.activeGestureId, + commitPendingAsync: order.commitPendingAsync, + cancel: order.cancel, + }, +})); +vi.mock('./editorStateCoordinator', () => ({ + editorCoordinator: { runExclusiveLegacyMutation: order.exclusive }, +})); + +import { enqueueEditorCompatibilityWrite } from './editorCompatibilityQueue'; +import { runExclusiveLegacyMutation } from './legacyEditorMutation'; + +describe('runExclusiveLegacyMutation', () => { + beforeEach(() => { + vi.clearAllMocks(); + order.calls.length = 0; + order.settleResult = true; + order.activeIds = []; + }); + + it('활성 게스처를 compat 슬롯 획득 전에 정산한다', async () => { + // 정산이 mutation 뒤로 밀리면 요소가 유지된 채 참조 필드만 재작성되는 + // mutation(프리셋 삭제 fallback, 사운드 삭제)에서 삭제 참조가 부활한다 + const result = await runExclusiveLegacyMutation(async () => { + order.calls.push('mutation'); + return 'ok'; }); - expect(result).toBe('saved'); - expect(order).toEqual(['start', 'mutation', 'sync']); - expect(coordinator.sync).toHaveBeenCalledWith(); + expect(result).toBe('ok'); + expect(order.calls).toEqual(['settle', 'exclusive', 'mutation']); }); - it('coordinator 시작 실패 시 백엔드 mutation을 실행하지 않는다', async () => { - const failure = new Error('subscribe failed'); - const mutation = vi.fn(async () => 'unexpected'); - const coordinator = { - start: vi.fn(async () => { - throw failure; - }), - sync: vi.fn(async () => undefined), - }; - - await expect( - runLegacyEditorMutationWith(coordinator, mutation), - ).rejects.toBe(failure); - expect(mutation).not.toHaveBeenCalled(); - expect(coordinator.sync).not.toHaveBeenCalled(); + it('정산 실패로 되살아난 같은 게스처만 폐기 후 진행한다', async () => { + // 살려두면 mutation이 재작성한 참조 위에 옛 patch가 재적용되어 + // 삭제된 참조가 부활한다 + order.settleResult = false; + order.activeIds = ['gesture-a', 'gesture-a']; + + await runExclusiveLegacyMutation(async () => 'ok'); + + expect(order.calls).toEqual(['settle', 'cancel', 'exclusive']); }); - it('명령 성공 뒤 sync 실패는 성공 결과를 뒤집지 않는다', async () => { - const syncError = new Error('temporary read failure'); - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const coordinator = { - start: vi.fn(async () => undefined), - sync: vi.fn(async () => { - throw syncError; - }), - }; - - await expect( - runLegacyEditorMutationWith(coordinator, async () => 42), - ).resolves.toBe(42); - expect(errorSpy).toHaveBeenCalledWith( - '레거시 편집 상태 재동기화 실패', - syncError, - ); - errorSpy.mockRestore(); + it('정산 대기 중 시작된 새 게스처는 폐기하지 않는다', async () => { + // 실패한 A가 아니라 그 뒤의 최신 편집 B가 활성이면 건드리지 않는다 + order.settleResult = false; + order.activeIds = ['gesture-a', 'gesture-b']; + + await runExclusiveLegacyMutation(async () => 'ok'); + + expect(order.cancel).not.toHaveBeenCalled(); + }); + + it('게스처 없이 drain만 실패한 경우 폐기하지 않는다', async () => { + order.settleResult = false; + order.activeIds = [null, null]; + + await runExclusiveLegacyMutation(async () => 'ok'); + + expect(order.cancel).not.toHaveBeenCalled(); + }); + + it('정산 성공이면 게스처를 폐기하지 않는다', async () => { + order.activeIds = ['gesture-a', null]; + + await runExclusiveLegacyMutation(async () => 'ok'); + + expect(order.cancel).not.toHaveBeenCalled(); }); - it('구독만 필요한 명령은 mutation 뒤 canonical sync를 생략한다', async () => { - const coordinator = { - start: vi.fn(async () => undefined), - sync: vi.fn(async () => undefined), - }; - - await expect( - runLegacyEditorMutationWith(coordinator, async () => 'selected', { - syncAfter: false, - }), - ).resolves.toBe('selected'); - expect(coordinator.start).toHaveBeenCalledOnce(); - expect(coordinator.sync).not.toHaveBeenCalled(); + it('compat 큐 선행 작업 뒤에 실행된다', async () => { + let release!: () => void; + const blocker = new Promise((resolve) => { + release = resolve; + }); + const first = enqueueEditorCompatibilityWrite( + async () => { + await blocker; + order.calls.push('prior-write'); + }, + () => undefined, + ); + + const pending = runExclusiveLegacyMutation(async () => 'done'); + await Promise.resolve(); + await Promise.resolve(); + expect(order.calls).not.toContain('exclusive'); + + release(); + await first; + expect(await pending).toBe('done'); + expect(order.calls.indexOf('prior-write')).toBeLessThan( + order.calls.indexOf('exclusive'), + ); }); }); diff --git a/src/renderer/editor/runtime/legacyEditorMutation.ts b/src/renderer/editor/runtime/legacyEditorMutation.ts index 9501c6f1..6ef3d6dd 100644 --- a/src/renderer/editor/runtime/legacyEditorMutation.ts +++ b/src/renderer/editor/runtime/legacyEditorMutation.ts @@ -1,3 +1,5 @@ +import { editGestureController } from './editGestureController'; +import { enqueueEditorCompatibilityOperation } from './editorCompatibilityQueue'; import { editorCoordinator } from './editorStateCoordinator'; interface LegacyEditorMutationCoordinator { @@ -35,3 +37,34 @@ export const runLegacyEditorMutation = ( options?: LegacyEditorMutationOptions, ): Promise => runLegacyEditorMutationWith(editorCoordinator, mutation, options); + +// 백엔드가 편집 문서를 직접 바꾸는 legacy 커맨드(프리셋 로드, 리셋, 커스텀 탭, +// 카운터 애니메이션 사용처 재작성, 사운드 삭제) 전용. 호환 큐와 coordinator +// 직렬 tail을 모두 점유해, 먼저 캡처하고 대기 중이던 full-record 커밋이 +// mutation 결과(재발급 ID 포함)를 되돌리는 순서를 차단한다. +// +// 활성 게스처는 compat 슬롯을 얻기 전에 정산한다. ID 의도는 요소 소실에는 +// 수렴하지만, 요소가 유지된 채 참조 필드만 재작성되는 mutation(카운터 +// 프리셋 삭제의 fallback 재작성, 사운드 삭제)에서는 정산이 mutation 뒤로 +// 밀리면 삭제된 참조를 되살린다 - 정산을 먼저 큐에 앉혀 순서를 고정한다 +export const runExclusiveLegacyMutation = async ( + mutation: () => Promise, +): Promise => { + // 정산 대상을 시점 세션으로 한정 - 정산 대기 중 사용자가 시작한 새 게스처 + // B는 실패한 A와 무관한 최신 편집이므로 건드리면 안 된다 + const settlingGestureId = editGestureController.activeGestureId(); + const settled = await editGestureController.commitPendingAsync(); + if ( + !settled && + settlingGestureId !== null && + editGestureController.activeGestureId() === settlingGestureId + ) { + // 정산 실패로 되살아난 그 게스처만 폐기한다. 살려둔 채 진행하면 mutation이 + // 재작성한 참조 위에 옛 patch가 blur·flush 재시도로 재적용되어 삭제된 + // 참조가 부활한다 - 지금의 사용자 의도는 mutation 쪽이다 + editGestureController.cancel(); + } + return enqueueEditorCompatibilityOperation(() => + editorCoordinator.runExclusiveLegacyMutation(mutation), + ); +}; diff --git a/src/renderer/plugins/runtime/api/pluginApiProxy.positions.test.ts b/src/renderer/plugins/runtime/api/pluginApiProxy.positions.test.ts new file mode 100644 index 00000000..ae68aebd --- /dev/null +++ b/src/renderer/plugins/runtime/api/pluginApiProxy.positions.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const gateway = vi.hoisted(() => ({ + pluginKeysUpdate: vi.fn(async () => ({})), + pluginKeysUpdateWithPositions: vi.fn(async () => ({})), + pluginEditorCommit: vi.fn(async () => ({})), + pluginPositionsUpdate: vi.fn(async () => ({ '4key': [] })), +})); + +vi.mock('./pluginWriteGateway', () => gateway); + +import { createPluginApiProxy } from './pluginApiProxy'; + +// 직접 호출용 raw API - 프록시가 4개 위치 API를 게이트웨이로 덮지 않으면 +// 여기 스파이가 호출된다 +const rawApi = { + keys: { updatePositions: vi.fn(), update: vi.fn() }, + statItems: { updatePositions: vi.fn() }, + graphItems: { updatePositions: vi.fn() }, + knobItems: { updatePositions: vi.fn() }, + editor: { commit: vi.fn() }, + plugin: { storage: { get: vi.fn(), set: vi.fn() } }, +} as unknown as typeof window.api; + +describe('플러그인 프록시 위치 API 재라우팅', () => { + beforeEach(() => { + vi.clearAllMocks(); + (window as { api?: typeof window.api }).api = rawApi; + }); + + it.each([ + ['keys', 'keyPositions'], + ['statItems', 'statPositions'], + ['graphItems', 'graphPositions'], + ['knobItems', 'knobPositions'], + ] as const)( + '%s.updatePositions는 격리 v1 게이트웨이를 탄다', + async (namespace, field) => { + const proxied = createPluginApiProxy({ + pluginId: 'test-plugin', + registerCleanup: () => {}, + isReloading: () => false, + waitForReloadEnd: async () => {}, + }); + + const positions = { '4key': [{ dx: 1 }] }; + await ( + proxied[namespace] as { + updatePositions: (p: unknown) => Promise; + } + ).updatePositions(positions); + + expect(gateway.pluginPositionsUpdate).toHaveBeenCalledWith( + field, + positions, + ); + // raw API 직행 금지 - 자사 큐를 타면 wire v2가 되어 무ID 구 플러그인 + // 입력이 거절된다 + const raw = rawApi[namespace] as unknown as { + updatePositions: ReturnType; + }; + expect(raw.updatePositions).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/src/renderer/plugins/runtime/api/pluginApiProxy.ts b/src/renderer/plugins/runtime/api/pluginApiProxy.ts index ddb00c60..ef67b461 100644 --- a/src/renderer/plugins/runtime/api/pluginApiProxy.ts +++ b/src/renderer/plugins/runtime/api/pluginApiProxy.ts @@ -14,6 +14,7 @@ import { pluginEditorCommit, pluginKeysUpdate, pluginKeysUpdateWithPositions, + pluginPositionsUpdate, } from './pluginWriteGateway'; interface CreatePluginApiProxyOptions { @@ -79,6 +80,41 @@ export const createPluginApiProxy = ( args[2] as Parameters[2], ), ), + // 위치 단독 쓰기도 격리 v1 - 자사 큐를 타면 wire v2가 되어 무ID + // 구 플러그인 입력이 거절된다 + updatePositions: wrapWithContext((...args: unknown[]) => + pluginPositionsUpdate( + 'keyPositions', + args[0] as Record, + ), + ), + }, + statItems: { + ...((wrappedApi.statItems as Record) ?? {}), + updatePositions: wrapWithContext((...args: unknown[]) => + pluginPositionsUpdate( + 'statPositions', + args[0] as Record, + ), + ), + }, + graphItems: { + ...((wrappedApi.graphItems as Record) ?? {}), + updatePositions: wrapWithContext((...args: unknown[]) => + pluginPositionsUpdate( + 'graphPositions', + args[0] as Record, + ), + ), + }, + knobItems: { + ...((wrappedApi.knobItems as Record) ?? {}), + updatePositions: wrapWithContext((...args: unknown[]) => + pluginPositionsUpdate( + 'knobPositions', + args[0] as Record, + ), + ), }, editor: { ...((wrappedApi.editor as Record) ?? {}), diff --git a/src/renderer/plugins/runtime/api/pluginWriteGateway.test.ts b/src/renderer/plugins/runtime/api/pluginWriteGateway.test.ts index ff71bd72..553e45d5 100644 --- a/src/renderer/plugins/runtime/api/pluginWriteGateway.test.ts +++ b/src/renderer/plugins/runtime/api/pluginWriteGateway.test.ts @@ -19,6 +19,7 @@ import { pluginEditorCommit, pluginKeysUpdate, pluginKeysUpdateWithPositions, + pluginPositionsUpdate, } from './pluginWriteGateway'; import type { PluginEditorCommitRequest } from '@src/types/editor'; import type { KeyMappings, KeyPositions } from '@src/types/key/keys'; @@ -134,4 +135,43 @@ describe('pluginWriteGateway', () => { expect(runSerializedPluginCommit).toHaveBeenCalledTimes(1); expect(editorCommitRaw.mock.calls[0][0]).toBe(request); }); + + it.each([ + ['keyPositions'], + ['statPositions'], + ['graphPositions'], + ['knobPositions'], + ] as const)( + '%s 단독 쓰기는 격리 v1 커밋을 타고 canonical 필드를 돌려준다', + async (field) => { + const canonical = { + keys: {}, + keyPositions: {}, + statPositions: {}, + graphPositions: {}, + knobPositions: {}, + [field]: { '4key': [{ id: 'adapter-issued', dx: 1 }] }, + }; + commitIsolatedPluginPatch.mockResolvedValue(canonical); + + const input = { '4key': [{ dx: 1 }] }; + const result = await pluginPositionsUpdate(field, input); + + expect(commitIsolatedPluginPatch).toHaveBeenCalledWith( + { schemaVersion: 1, [field]: input }, + { multiKey: false }, + ); + // 입력 에코가 아니라 adapter가 발급한 ID를 포함한 canonical + expect(result).toEqual({ '4key': [{ id: 'adapter-issued', dx: 1 }] }); + }, + ); + + it('위치 쓰기 실패는 원 오류 그대로 reject된다', async () => { + const original = { errorCode: 'VALIDATION_FAILED', message: 'bad' }; + commitIsolatedPluginPatch.mockRejectedValueOnce(original); + + await expect( + pluginPositionsUpdate('keyPositions', { '4key': [] }), + ).rejects.toBe(original); + }); }); diff --git a/src/renderer/plugins/runtime/api/pluginWriteGateway.ts b/src/renderer/plugins/runtime/api/pluginWriteGateway.ts index 277b2b2f..8bad7bec 100644 --- a/src/renderer/plugins/runtime/api/pluginWriteGateway.ts +++ b/src/renderer/plugins/runtime/api/pluginWriteGateway.ts @@ -77,6 +77,29 @@ export const pluginKeysUpdateWithPositions = async ( return { keys: document.keys, positions: document.keyPositions }; }; +type PluginPositionField = + | 'keyPositions' + | 'statPositions' + | 'graphPositions' + | 'knobPositions'; + +// 위치 컬렉션 단독 쓰기도 격리 v1로 라우팅한다. 자사 호환 큐를 타면 사용자 +// 편집과 snapshot 병합되고 wire가 v2가 되어 ID 없는 구 플러그인 입력이 +// 거절된다 (v1 장기 수용 계약 회귀). canonical get까지 끝난 뒤 resolve하고 +// adapter가 발급한 ID를 포함한 canonical 필드를 반환한다. 오류는 wrapping +// 없이 원형 그대로 reject - 커밋 성공 후 get 실패는 결과 불명이므로 +// 호출자는 재시도 전 조회로 확인해야 한다 (docs 안내) +export const pluginPositionsUpdate = async ( + field: PluginPositionField, + positions: Record, +): Promise> => { + const document = await editorCoordinator.commitIsolatedPluginPatch( + { schemaVersion: 1, [field]: positions }, + { multiKey: false }, + ); + return document[field] as Record; +}; + // 플러그인의 직접 editor_commit. keys를 포함하면 coordinator 큐로 직렬화해 // 예약된 자사 변경보다 먼저 lock을 잡는 경합을 차단하고, envelope는 무가공 // 전달 (multiKey는 플러그인이 선언한 값만 백엔드 게이트에 도달) From 2d4852de4478480e2e9b5d02b53b64d7a5248d3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 12 Aug 2026 14:25:41 +0900 Subject: [PATCH 21/35] =?UTF-8?q?fix:=20=EA=B2=8C=EC=8A=A4=EC=B2=98=20?= =?UTF-8?q?=EC=A0=95=EC=82=B0=20=EC=9D=98=EB=8F=84=EB=A5=BC=20=EC=9A=94?= =?UTF-8?q?=EC=86=8C=20=EC=95=88=EC=A0=95=20ID=EB=A1=9C=20=EB=B3=B4?= =?UTF-8?q?=EC=A1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/previewOverlaySession.test.ts | 160 +++++++++++++-- .../editor/runtime/editGestureController.ts | 194 ++++++++++++------ 2 files changed, 268 insertions(+), 86 deletions(-) diff --git a/src/renderer/__tests__/previewOverlaySession.test.ts b/src/renderer/__tests__/previewOverlaySession.test.ts index 15b103c7..4a9f61ee 100644 --- a/src/renderer/__tests__/previewOverlaySession.test.ts +++ b/src/renderer/__tests__/previewOverlaySession.test.ts @@ -10,6 +10,7 @@ import { previewOverlay, } from '@src/renderer/editor/runtime/previewOverlay'; import { editGestureController } from '@src/renderer/editor/runtime/editGestureController'; +import { runExclusiveLegacyMutation } from '@src/renderer/editor/runtime/legacyEditorMutation'; import { previewApi } from '@api/modules/previewApi'; import type { KeyPositions } from '@src/types/key/keys'; @@ -18,9 +19,12 @@ import type { GraphItemPositions } from '@src/types/key/graphItems'; import type { KnobItemPositions } from '@src/types/key/knobs'; import type { PreviewEnvelope } from '@src/types/preview'; -const { commitPatchMock } = vi.hoisted(() => ({ - commitPatchMock: vi.fn().mockResolvedValue(undefined), -})); +const { commitPatchMock, commitGeneratedPatchMock, generatedPatches } = + vi.hoisted(() => ({ + commitPatchMock: vi.fn().mockResolvedValue(undefined), + commitGeneratedPatchMock: vi.fn(), + generatedPatches: [] as Array, + })); vi.mock('@api/modules/previewApi', () => ({ previewApi: { @@ -33,11 +37,16 @@ vi.mock('@api/modules/previewApi', () => ({ vi.mock('@src/renderer/editor/runtime/editorStateCoordinator', () => ({ editorCoordinator: { commitPatch: commitPatchMock, + commitGeneratedPatch: commitGeneratedPatchMock, + runExclusiveLegacyMutation: vi.fn( + async (mutation: () => Promise) => mutation(), + ), getState: () => ({ revision: null }), }, })); -const basePosition = (dx: number) => ({ +const basePosition = (dx: number, id?: string) => ({ + ...(id ? { id } : {}), dx, dy: 0, width: 60, @@ -48,19 +57,19 @@ const basePosition = (dx: number) => ({ const canonicalFixture = (): KeyPositions => ({ - '4key': [basePosition(0), basePosition(70)], + '4key': [basePosition(0, 'key-id-a'), basePosition(70, 'key-id-b')], } as unknown as KeyPositions); const statFixture = (): StatItemPositions => ({ - '4key': [{ ...basePosition(0), statType: 'kps' }], + '4key': [{ ...basePosition(0, 'stat-id-a'), statType: 'kps' }], } as unknown as StatItemPositions); const graphFixture = (): GraphItemPositions => ({ '4key': [ { - ...basePosition(0), + ...basePosition(0, 'graph-id-a'), statType: 'kps', graphType: 'line', graphSpeed: 1, @@ -73,7 +82,7 @@ const knobFixture = (): KnobItemPositions => ({ '4key': [ { - ...basePosition(0), + ...basePosition(0, 'knob-id-a'), axisId: 'HIDA:test', sensitivity: 1, reverse: false, @@ -263,6 +272,28 @@ describe('previewOverlay', () => { describe('editGestureController', () => { beforeEach(() => { vi.clearAllMocks(); + generatedPatches.length = 0; + // 슬롯 시점 base = 호출 시점 스토어 상태. 대기 중 재정렬·삭제 시뮬레이션은 + // 테스트가 commitPendingAsync 호출 전에 스토어를 바꿔 재현한다 + commitGeneratedPatchMock.mockImplementation( + async (generate: (base: unknown) => unknown) => { + const base = { + schemaVersion: 1, + keys: {}, + keyPositions: structuredClone( + useKeyStore.getState().canonicalPositions, + ), + statPositions: structuredClone(useStatItemStore.getState().positions), + graphPositions: structuredClone( + useGraphItemStore.getState().positions, + ), + knobPositions: structuredClone(useKnobItemStore.getState().positions), + layerGroups: {}, + }; + generatedPatches.push(generate(base)); + return base; + }, + ); editGestureController.cancel(); previewOverlay.clearAll(); vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { @@ -504,22 +535,109 @@ describe('editGestureController', () => { true, ); - expect(commitPatchMock).toHaveBeenCalledOnce(); - expect(commitPatchMock).toHaveBeenCalledWith( - { - schemaVersion: 1, - keyPositions: { - '4key': [expect.objectContaining({ width: 90 }), expect.any(Object)], - }, - statPositions: { '4key': [expect.objectContaining({ width: 100 })] }, - graphPositions: { '4key': [expect.objectContaining({ width: 110 })] }, - knobPositions: { '4key': [expect.objectContaining({ width: 120 })] }, - }, - { gestureId: sessionId }, - ); + // wire는 슬롯 안에서 최신 base로 재생성 - 호출 시점 full-record 금지 + expect(commitPatchMock).not.toHaveBeenCalled(); + expect(commitGeneratedPatchMock).toHaveBeenCalledOnce(); + expect(commitGeneratedPatchMock.mock.calls[0][1]).toEqual({ + gestureId: sessionId, + }); + const patch = generatedPatches[0] as { + keyPositions?: Record>>; + statPositions?: Record>>; + graphPositions?: Record>>; + knobPositions?: Record>>; + }; + expect(patch.keyPositions?.['4key'][0]).toMatchObject({ + id: 'key-id-a', + width: 90, + }); + expect(patch.statPositions?.['4key'][0]).toMatchObject({ width: 100 }); + expect(patch.graphPositions?.['4key'][0]).toMatchObject({ width: 110 }); + expect(patch.knobPositions?.['4key'][0]).toMatchObject({ width: 120 }); expect(editGestureController.hasActiveGesture()).toBe(false); }); + it('정산은 스토어에 즉시 반영된다 - 후행 full-record 캡처 자가 치유', () => { + editGestureController.preview('4key', [{ index: 0, patch: { width: 90 } }]); + + void editGestureController.commitPendingAsync(); + + expect(useKeyStore.getState().canonicalPositions['4key'][0].width).toBe(90); + }); + + it('정산 대기 중 재정렬돼도 생성 patch가 같은 id를 따라간다', async () => { + editGestureController.preview('4key', [{ index: 0, patch: { width: 90 } }]); + + // 슬롯 진입 전 재정렬 시뮬레이션: base가 뒤집힌 상태로 생성됨 + const [a, b] = useKeyStore.getState().canonicalPositions['4key']; + useKeyStore.setState({ + canonicalPositions: { '4key': [b, a] } as never, + positions: { '4key': [b, a] } as never, + }); + + await expect(editGestureController.commitPendingAsync()).resolves.toBe( + true, + ); + + const patch = generatedPatches[0] as { + keyPositions?: Record>>; + }; + // 시작 시점 index 0 = key-id-a, 재정렬 후에는 index 1 + expect(patch.keyPositions?.['4key'][1]).toMatchObject({ + id: 'key-id-a', + width: 90, + }); + expect(patch.keyPositions?.['4key'][0].width).toBe(60); + }); + + it('정산 대기 중 대상이 삭제되면 커밋하지 않는다', async () => { + editGestureController.preview('4key', [{ index: 1, patch: { width: 90 } }]); + + const [a] = useKeyStore.getState().canonicalPositions['4key']; + useKeyStore.setState({ + canonicalPositions: { '4key': [a] } as never, + positions: { '4key': [a] } as never, + }); + + await expect(editGestureController.commitPendingAsync()).resolves.toBe( + true, + ); + + // generator가 null을 반환해 무커밋 (mock이 null patch를 기록) + expect(generatedPatches[0]).toBeNull(); + }); + + it('배타 mutation은 정산 실패한 A만 폐기하고 대기 중 시작된 B는 유지한다', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + // A의 정산 커밋을 지연시켜 실패 예약 + let rejectSettle!: (reason: Error) => void; + commitGeneratedPatchMock.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectSettle = reject; + }), + ); + + editGestureController.preview('4key', [{ index: 0, patch: { width: 90 } }]); + const gestureA = editGestureController.activeGestureId(); + + const run = runExclusiveLegacyMutation(async () => 'done'); + // 정산 enqueue가 A를 비운 뒤 사용자가 새 게스처 B 시작 + await Promise.resolve(); + await Promise.resolve(); + editGestureController.preview('4key', [ + { index: 1, patch: { width: 120 } }, + ]); + const gestureB = editGestureController.activeGestureId(); + expect(gestureB).not.toBe(gestureA); + + rejectSettle(new Error('settle failed')); + await run; + + // A는 복원되지 않고(B가 활성), B는 폐기되지 않는다 + expect(editGestureController.activeGestureId()).toBe(gestureB); + }); + it('모드가 바뀌면 이전 게스처를 취소하고 새로 시작', () => { editGestureController.preview('4key', [{ index: 0, patch: { width: 90 } }]); const first = editGestureController.activeGestureId(); diff --git a/src/renderer/editor/runtime/editGestureController.ts b/src/renderer/editor/runtime/editGestureController.ts index 5cded2ce..b64a43e9 100644 --- a/src/renderer/editor/runtime/editGestureController.ts +++ b/src/renderer/editor/runtime/editGestureController.ts @@ -14,6 +14,7 @@ import type { EditorPatchV1 } from '@src/types/editor'; import { PREVIEW_SCHEMA_VERSION, type PreviewDomain } from '@src/types/preview'; import { previewOverlay } from './previewOverlay'; +import { enqueueEditorCompatibilityOperation } from './editorCompatibilityQueue'; import { editorCoordinator } from './editorStateCoordinator'; import { drainEditorWrites, trackEditorWrite } from './editorWriteBarrier'; import { getEditSessionTarget } from './editSessionTarget'; @@ -37,9 +38,12 @@ interface ActiveGesture { lifecycle: GestureSessionLifecycle; mode: string; seq: number; - // 도메인 → target index → 게스처 동안 누적된 전체 patch - appliedPatches: Map>>; - // 도메인 → target index → 다음 flush에 실릴 patch. + // 도메인 → 요소 id → 게스처 동안 누적된 전체 patch. + // 정산 의도는 index가 아니라 id로 보존한다 - index로 두면 정산이 큐를 + // 기다리는 동안 재정렬된 다른 요소에 적용된다. id가 없는 구형 요소만 + // index sentinel로 남긴다 + appliedPatches: Map>>; + // 도메인 → target index → 다음 flush에 실릴 patch (프리뷰 wire는 index 표현 유지). // 대상별로 모아야 같은 대상의 옛 값이 덮어써진다. patch 내용으로 묶으면 // 값이 연속으로 바뀌는 편집(드래그, 방향키 꾹 누르기)에서 중간값마다 그룹이 하나씩 생기고, // in-flight 하나가 도는 동안 쌓인 그룹이 전부 순차 발행돼 이미 무의미해진 값까지 IPC를 탄다 @@ -48,6 +52,77 @@ interface ActiveGesture { publishInFlight: boolean; } +type PositionsRecordLike = Record< + string, + Array<{ id?: string } & Record> +>; + +const DOMAIN_FIELDS: Record< + PreviewDomain, + 'keyPositions' | 'statPositions' | 'graphPositions' | 'knobPositions' +> = { + keyPosition: 'keyPositions', + statPosition: 'statPositions', + graphPosition: 'graphPositions', + knobPosition: 'knobPositions', +}; + +const authorityRecordFor = (domain: PreviewDomain): PositionsRecordLike => + (domain === 'keyPosition' + ? useKeyStore.getState().canonicalPositions + : domain === 'statPosition' + ? useStatItemStore.getState().positions + : domain === 'graphPosition' + ? useGraphItemStore.getState().positions + : useKnobItemStore.getState().positions) as PositionsRecordLike; + +const writeAuthorityRecord = ( + domain: PreviewDomain, + next: PositionsRecordLike, +): void => { + if (domain === 'keyPosition') { + useKeyStore.getState().setPositions(next as never); + } else if (domain === 'statPosition') { + useStatItemStore.getState().setPositions(next as never); + } else if (domain === 'graphPosition') { + useGraphItemStore.getState().setPositions(next as never); + } else { + useKnobItemStore.getState().setPositions(next as never); + } +}; + +const INDEX_SENTINEL = 'index:'; + +// 프리뷰 시점 index가 아직 뜨거울 때 id로 승격 +const intentKeyFor = ( + domain: PreviewDomain, + mode: string, + index: number, +): string => { + const id = authorityRecordFor(domain)[mode]?.[index]?.id; + return typeof id === 'string' && id.length > 0 + ? id + : `${INDEX_SENTINEL}${index}`; +}; + +// resolved id 집합을 record 전 모드에서 찾아 patch 병합 (id 불변) +const mergeIntentRecord = ( + record: PositionsRecordLike, + resolved: ReadonlyMap>, +): { next: PositionsRecordLike; touched: number } => { + let touched = 0; + const next: PositionsRecordLike = {}; + for (const [mode, list] of Object.entries(record)) { + next[mode] = list.map((position) => { + const id = position.id; + if (typeof id !== 'string' || !resolved.has(id)) return position; + touched += 1; + return { ...position, ...resolved.get(id), id }; + }); + } + return { next, touched }; +}; + let active: ActiveGesture | null = null; const schedulePublishFlush = () => { @@ -163,9 +238,10 @@ export const editGestureController = { active.appliedPatches.set(domain, domainPatches); } for (const entry of entries) { - const applied = domainPatches.get(entry.index); + const intentKey = intentKeyFor(domain, mode, entry.index); + const applied = domainPatches.get(intentKey); domainPatches.set( - entry.index, + intentKey, applied ? { ...applied, ...entry.patch } : { ...entry.patch }, ); previewOverlay.applyLocalPatch( @@ -261,78 +337,66 @@ export const editGestureController = { if (gesture) this.cancel(); return drainEditorWrites(); } - const changes: EditorPatchV1 = { schemaVersion: 1 }; - let hasChanges = false; - - const applyPatches = ( - positions: Record, - patches: Map>, - ): Record | null => { - const current = positions[gesture.mode]; - if (!current) return null; - return { - ...positions, - [gesture.mode]: current.map((position, index) => { - const patch = patches.get(index); - return patch ? { ...position, ...patch } : position; - }), - }; - }; + // 의도 확정: sentinel(구형 무ID)은 현재 canonical에서 한 번 더 id 승격을 + // 시도하고, 여전히 없으면 대상 소실로 보고 버린다 (fail-closed) + const intents = new Map< + PreviewDomain, + Map> + >(); for (const [domain, patches] of gesture.appliedPatches) { if (patches.size === 0) continue; - if (domain === 'keyPosition') { - const updated = applyPatches( - useKeyStore.getState().canonicalPositions, - patches, - ); - if (!updated) { - this.cancel(); - return drainEditorWrites(); - } - changes.keyPositions = updated; - } else if (domain === 'statPosition') { - const updated = applyPatches( - useStatItemStore.getState().positions, - patches, - ); - if (!updated) { - this.cancel(); - return drainEditorWrites(); + const resolved = new Map>(); + for (const [key, patch] of patches) { + if (!key.startsWith(INDEX_SENTINEL)) { + resolved.set(key, { ...(resolved.get(key) ?? {}), ...patch }); + continue; } - changes.statPositions = updated; - } else if (domain === 'graphPosition') { - const updated = applyPatches( - useGraphItemStore.getState().positions, - patches, - ); - if (!updated) { - this.cancel(); - return drainEditorWrites(); + const index = Number(key.slice(INDEX_SENTINEL.length)); + const id = authorityRecordFor(domain)[gesture.mode]?.[index]?.id; + if (typeof id === 'string' && id.length > 0) { + resolved.set(id, { ...(resolved.get(id) ?? {}), ...patch }); } - changes.graphPositions = updated; - } else { - const updated = applyPatches( - useKnobItemStore.getState().positions, - patches, - ); - if (!updated) { - this.cancel(); - return drainEditorWrites(); - } - changes.knobPositions = updated; } - hasChanges = true; + if (resolved.size > 0) intents.set(domain, resolved); } - if (!hasChanges) { + if (intents.size === 0) { this.cancel(); return drainEditorWrites(); } - const persisted = editorCoordinator.commitPatch(changes, { - gestureId: gesture.sessionId, - }); + // eager 반영 - 이후의 full-record 캡처가 이 값을 포함해 자가 치유 + for (const [domain, resolved] of intents) { + const merged = mergeIntentRecord(authorityRecordFor(domain), resolved); + if (merged.touched > 0) writeAuthorityRecord(domain, merged.next); + } + + // wire는 직렬 슬롯 안에서 최신 base로 재생성한다. 호출 시점 full-record는 + // 대기 중 정산된 다른 커밋(격리 플러그인 등)의 값을 통째로 되돌린다. + // elementPatch applier는 rejection을 소비하므로 재사용 금지 - 정산은 + // 거절되는 원 promise가 필요하다 + const persisted = enqueueEditorCompatibilityOperation(() => + editorCoordinator.commitGeneratedPatch( + (base) => { + const changes: EditorPatchV1 = { schemaVersion: 1 }; + let hasChanges = false; + for (const [domain, resolved] of intents) { + const field = DOMAIN_FIELDS[domain]; + const merged = mergeIntentRecord( + base[field] as PositionsRecordLike, + resolved, + ); + if (merged.touched > 0) { + changes[field] = merged.next as never; + hasChanges = true; + } + } + return hasChanges ? changes : null; + }, + { gestureId: gesture.sessionId }, + ), + ); this.settleCommit(persisted); const own = await persisted.then( () => true, From f7dad0a9e8ee5ea287f7c05c56fba93ce84f67fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 12 Aug 2026 14:25:41 +0900 Subject: [PATCH 22/35] =?UTF-8?q?fix:=20=EA=B7=B8=EB=A6=AC=EB=93=9C=20?= =?UTF-8?q?=EC=A7=80=EC=97=B0=20=EB=8C=80=EC=83=81=20=EC=95=A1=EC=85=98?= =?UTF-8?q?=EA=B3=BC=20=EB=8B=A4=EC=A4=91=20=ED=8E=B8=EC=A7=91=EC=9D=84=20?= =?UTF-8?q?=EC=95=88=EC=A0=95=20ID=EB=A1=9C=20=EA=B2=B0=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/main/Grid/core/Grid.tsx | 521 ++++++++++++++---- .../components/main/Grid/layers/GraphItem.tsx | 10 +- .../components/main/Grid/layers/KnobItem.tsx | 10 +- src/renderer/components/shared/Key.tsx | 10 +- src/renderer/editor/model/elementIdMap.ts | 5 + src/renderer/editor/model/keys.ts | 47 +- .../editor/runtime/elementOps.test.ts | 346 ++++++++++++ src/renderer/editor/runtime/elementOps.ts | 491 +++++++++++++++++ .../editor/runtime/selectionSync.test.ts | 51 +- src/renderer/editor/runtime/selectionSync.ts | 31 +- .../hooks/Grid/elementPositionCommit.test.ts | 47 ++ .../hooks/Grid/elementPositionCommit.ts | 22 + .../hooks/Grid/useGridCanvasActions.ts | 4 +- src/renderer/hooks/Grid/useGridSelection.ts | 35 +- src/renderer/hooks/useKeyManager.ts | 9 + 15 files changed, 1498 insertions(+), 141 deletions(-) create mode 100644 src/renderer/editor/runtime/elementOps.test.ts create mode 100644 src/renderer/editor/runtime/elementOps.ts create mode 100644 src/renderer/hooks/Grid/elementPositionCommit.test.ts create mode 100644 src/renderer/hooks/Grid/elementPositionCommit.ts diff --git a/src/renderer/components/main/Grid/core/Grid.tsx b/src/renderer/components/main/Grid/core/Grid.tsx index a9474311..7ecb4251 100644 --- a/src/renderer/components/main/Grid/core/Grid.tsx +++ b/src/renderer/components/main/Grid/core/Grid.tsx @@ -13,6 +13,17 @@ declare global { } import { useTranslation } from '@contexts/useTranslation'; import DraggableKey from '@components/shared/Key'; +import { commitElementPosition } from '@src/renderer/hooks/Grid/elementPositionCommit'; +import { + applyZOrderByIds, + deleteElementById, + placeDuplicatedKey, + type ZOrderTarget, +} from '@src/renderer/editor/runtime/elementOps'; +import { + isSyntheticElementId, + resolveElementById, +} from '@src/renderer/editor/model/elementIdMap'; import GridKeySettingModal from './GridKeySettingModal'; import TabCssModal from '../../Modal/content/editors/TabCssModal'; import TabNoteSettingModal from '../../Modal/content/editors/TabNoteSettingModal'; @@ -160,7 +171,12 @@ interface GridProps { setSelectedKey: (key: SelectedKeyInfo | null) => void; keyMappings: KeyMappings; positions: KeyPositions; - onPositionChange: (index: number, dx: number, dy: number) => void; + onPositionChange: ( + index: number, + dx: number, + dy: number, + elementId?: string, + ) => void; onKeyUpdate: (data: Omit) => void; onKeyPreview: (index: number, updates: KeyPreviewUpdates) => void; onNoteColorUpdate: ( @@ -461,7 +477,9 @@ const Grid = ({ } = useGridResize({ selectedElements, selectedKeyType, - onResizeEnd: syncSelectedElementsToOverlay, + // 리사이즈 종료는 크기 필드까지 의도에 포함 (이동 경로는 dx·dy만) + onResizeEnd: (gestureId?: string) => + syncSelectedElementsToOverlay(gestureId, { includeSize: true }), getOtherElements, }); @@ -520,6 +538,8 @@ const Grid = ({ // 키 컨텍스트 메뉴 const [isContextOpen, setIsContextOpen] = useState(false); const [contextIndex, setContextIndex] = useState(null); + // 메뉴 대상의 안정 신원 - 열림 동안 재정렬돼도 액션이 같은 요소를 향한다 + const [contextElementId, setContextElementId] = useState(null); const [contextType, setContextType] = useState('key'); const contextRef = useRef(null); const [contextPosition, setContextPosition] = useState<{ @@ -680,6 +700,24 @@ const Grid = ({ await pasteElements(); }; + // 합성 id(`${type}-${index}`)가 아닌 안정 id를 가진 native 선택 판별 + 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 && + !isSyntheticElementId(el.id); + + const pluginZIndexesForMode = (): number[] => + usePluginDisplayElementStore + .getState() + .elements.filter((el) => !el.tabId || el.tabId === selectedKeyType) + .map((el) => el.zIndex ?? 0); + const moveSelectedToFront = async () => { if (selectedElements.length === 0) return; @@ -689,21 +727,32 @@ const Grid = ({ .map((element) => element.id), ); + // id 보유 native 요소는 단일 트랜잭션 - 루프-await는 반복 사이 재정렬과 + // 렌더 클로저 base의 상호 덮어쓰기(lost update)를 만든다 + const idTargets: ZOrderTarget[] = []; for (const el of selectedElements) { - if (el.type === 'key' && el.index !== undefined) { - if (typeof onMoveToFront === 'function') { - await onMoveToFront(el.index); - } - } else if (el.type === 'stat' && el.index !== undefined) { + if (el.type === 'plugin') { + usePluginDisplayElementStore.getState().bringToFront(el.id); + continue; + } + if (isStableNativeSelection(el)) { + idTargets.push({ type: el.type, id: el.id }); + continue; + } + if (el.index === undefined) continue; + if (el.type === 'key') { + if (typeof onMoveToFront === 'function') await onMoveToFront(el.index); + } else if (el.type === 'stat') { moveStatToFront(el.index); - } else if (el.type === 'graph' && el.index !== undefined) { + } else if (el.type === 'graph') { moveGraphToFront(el.index); - } else if (el.type === 'knob' && el.index !== undefined) { + } else if (el.type === 'knob') { moveKnobToFront(el.index); - } else if (el.type === 'plugin') { - usePluginDisplayElementStore.getState().bringToFront(el.id); } } + if (idTargets.length > 0) { + await applyZOrderByIds(idTargets, 'front', pluginZIndexesForMode()); + } syncSelectedElementsToOverlay(); }; @@ -717,21 +766,30 @@ const Grid = ({ .map((element) => element.id), ); + const idTargets: ZOrderTarget[] = []; for (const el of selectedElements) { - if (el.type === 'key' && el.index !== undefined) { - if (typeof onMoveToBack === 'function') { - await onMoveToBack(el.index); - } - } else if (el.type === 'stat' && el.index !== undefined) { + if (el.type === 'plugin') { + usePluginDisplayElementStore.getState().sendToBack(el.id); + continue; + } + if (isStableNativeSelection(el)) { + idTargets.push({ type: el.type, id: el.id }); + continue; + } + if (el.index === undefined) continue; + if (el.type === 'key') { + if (typeof onMoveToBack === 'function') await onMoveToBack(el.index); + } else if (el.type === 'stat') { moveStatToBack(el.index); - } else if (el.type === 'graph' && el.index !== undefined) { + } else if (el.type === 'graph') { moveGraphToBack(el.index); - } else if (el.type === 'knob' && el.index !== undefined) { + } else if (el.type === 'knob') { moveKnobToBack(el.index); - } else if (el.type === 'plugin') { - usePluginDisplayElementStore.getState().sendToBack(el.id); } } + if (idTargets.length > 0) { + await applyZOrderByIds(idTargets, 'back', pluginZIndexesForMode()); + } syncSelectedElementsToOverlay(); }; @@ -922,6 +980,11 @@ const Grid = ({ } setContextType(type); setContextIndex(index); + setContextElementId( + typeof clickedPosition?.id === 'string' && clickedPosition.id.length > 0 + ? clickedPosition.id + : null, + ); contextRef.current = ref; setContextPosition({ x: clientX, y: clientY }); setIsContextOpen(true); @@ -991,7 +1054,15 @@ const Grid = ({ return positions[selectedKeyType].map( (position: KeyPosition, index: number) => { const handlers = stableHandlers(position.id || `key-${index}`, { - onPositionChange: onPositionChange, + onPositionChange: ( + targetIndex: number, + dx: number, + dy: number, + elementId?: string, + ) => + commitElementPosition('key', elementId, dx, dy, () => + onPositionChange(targetIndex, dx, dy), + ), onClick: () => { selectElementWithGroup('key', index); // 마지막 선택 키 좌표 저장 (Shift+클릭 범위 선택용) @@ -1186,7 +1257,14 @@ const Grid = ({ const displayName = slotDisplayName(slot); showConfirm( t('confirm.removeKey', { name: displayName }), - () => onKeyDelete(index), + () => { + const id = position.id; + if (id) { + void deleteElementById('key', id); + return; + } + onKeyDelete(index); + }, { confirmText: t('confirm.remove') }, ); }, @@ -1240,22 +1318,33 @@ const Grid = ({ index: number, dx: number, dy: number, + elementId?: string, ) => { - const current = useStatItemStore.getState().positions; - const tabPositions = current[selectedKeyType] || []; - const prev = tabPositions[index]; - if (!prev) return; - if (prev.dx === dx && prev.dy === dy) return; - - const nextTabPositions = tabPositions.map((pos, i) => - i === index ? { ...pos, dx, dy } : pos, + // 합성 id는 commitElementPosition이 fallback으로 돌린다 - 기존 index + // 커밋을 fallback 클로저로 넘겨 무ID 요소의 저장을 유지 + commitElementPosition('stat', elementId, dx, dy, () => + legacyPositionCommit(), ); - const nextPositions = { ...current, [selectedKeyType]: nextTabPositions }; - - useStatItemStore.getState().setPositions(nextPositions); - window.api.statItems.updatePositions(nextPositions).catch((error) => { - console.error('Failed to update stat item positions', error); - }); + function legacyPositionCommit() { + const current = useStatItemStore.getState().positions; + const tabPositions = current[selectedKeyType] || []; + const prev = tabPositions[index]; + if (!prev) return; + if (prev.dx === dx && prev.dy === dy) return; + + const nextTabPositions = tabPositions.map((pos, i) => + i === index ? { ...pos, dx, dy } : pos, + ); + const nextPositions = { + ...current, + [selectedKeyType]: nextTabPositions, + }; + + useStatItemStore.getState().setPositions(nextPositions); + window.api.statItems.updatePositions(nextPositions).catch((error) => { + console.error('Failed to update stat item positions', error); + }); + } }; return items.map((position: StatItemPosition, index: number) => { @@ -1296,7 +1385,14 @@ const Grid = ({ const displayName = getStatTypeLabel(position.statType); showConfirm( t('confirm.removeStat', { name: displayName }), - () => deleteStatAtIndex(index), + () => { + const id = position.id; + if (id) { + void deleteElementById('stat', id); + return; + } + deleteStatAtIndex(index); + }, { confirmText: t('confirm.remove') }, ); }, @@ -1348,22 +1444,33 @@ const Grid = ({ index: number, dx: number, dy: number, + elementId?: string, ) => { - const current = useGraphItemStore.getState().positions; - const tabPositions = current[selectedKeyType] || []; - const prev = tabPositions[index]; - if (!prev) return; - if (prev.dx === dx && prev.dy === dy) return; - - const nextTabPositions = tabPositions.map((pos, i) => - i === index ? { ...pos, dx, dy } : pos, + // 합성 id는 commitElementPosition이 fallback으로 돌린다 - 기존 index + // 커밋을 fallback 클로저로 넘겨 무ID 요소의 저장을 유지 + commitElementPosition('graph', elementId, dx, dy, () => + legacyPositionCommit(), ); - const nextPositions = { ...current, [selectedKeyType]: nextTabPositions }; - - useGraphItemStore.getState().setPositions(nextPositions); - window.api.graphItems.updatePositions(nextPositions).catch((error) => { - console.error('Failed to update graph item positions', error); - }); + function legacyPositionCommit() { + const current = useGraphItemStore.getState().positions; + const tabPositions = current[selectedKeyType] || []; + const prev = tabPositions[index]; + if (!prev) return; + if (prev.dx === dx && prev.dy === dy) return; + + const nextTabPositions = tabPositions.map((pos, i) => + i === index ? { ...pos, dx, dy } : pos, + ); + const nextPositions = { + ...current, + [selectedKeyType]: nextTabPositions, + }; + + useGraphItemStore.getState().setPositions(nextPositions); + window.api.graphItems.updatePositions(nextPositions).catch((error) => { + console.error('Failed to update graph item positions', error); + }); + } }; return items.map((position, index) => ( @@ -1414,7 +1521,14 @@ const Grid = ({ const displayName = getStatTypeLabel(position.statType); showConfirm( t('confirm.removeGraph', { name: displayName }), - () => deleteGraphAtIndex(index), + () => { + const id = position.id; + if (id) { + void deleteElementById('graph', id); + return; + } + deleteGraphAtIndex(index); + }, { confirmText: t('confirm.remove') }, ); }} @@ -1446,22 +1560,33 @@ const Grid = ({ index: number, dx: number, dy: number, + elementId?: string, ) => { - const current = useKnobItemStore.getState().positions; - const tabPositions = current[selectedKeyType] || []; - const prev = tabPositions[index]; - if (!prev) return; - if (prev.dx === dx && prev.dy === dy) return; - - const nextTabPositions = tabPositions.map((pos, i) => - i === index ? { ...pos, dx, dy } : pos, + // 합성 id는 commitElementPosition이 fallback으로 돌린다 - 기존 index + // 커밋을 fallback 클로저로 넘겨 무ID 요소의 저장을 유지 + commitElementPosition('knob', elementId, dx, dy, () => + legacyPositionCommit(), ); - const nextPositions = { ...current, [selectedKeyType]: nextTabPositions }; - - useKnobItemStore.getState().setPositions(nextPositions); - window.api.knobItems.updatePositions(nextPositions).catch((error) => { - console.error('Failed to update knob item positions', error); - }); + function legacyPositionCommit() { + const current = useKnobItemStore.getState().positions; + const tabPositions = current[selectedKeyType] || []; + const prev = tabPositions[index]; + if (!prev) return; + if (prev.dx === dx && prev.dy === dy) return; + + const nextTabPositions = tabPositions.map((pos, i) => + i === index ? { ...pos, dx, dy } : pos, + ); + const nextPositions = { + ...current, + [selectedKeyType]: nextTabPositions, + }; + + useKnobItemStore.getState().setPositions(nextPositions); + window.api.knobItems.updatePositions(nextPositions).catch((error) => { + console.error('Failed to update knob item positions', error); + }); + } }; return items.map((position, index) => ( @@ -1511,7 +1636,14 @@ const Grid = ({ onEraserClick={() => { showConfirm( t('confirm.removeKnob', { name: 'Knob' }), - () => deleteKnobAtIndex(index), + () => { + const id = position.id; + if (id) { + void deleteElementById('knob', id); + return; + } + deleteKnobAtIndex(index); + }, { confirmText: t('confirm.remove') }, ); }} @@ -1720,12 +1852,26 @@ const Grid = ({ const height = duplicateState.position.height || 60; const type = duplicateState.elementType || 'key'; - if (type === 'key' && typeof onKeyDuplicate === 'function') { - onKeyDuplicate( - duplicateState.sourceIndex, - snapped.x - width / 2, - snapped.y - height / 2, - ); + if (type === 'key') { + if (typeof duplicateState.slot !== 'undefined') { + // 시작 시점 동결 payload로 배치 - sourceIndex 재조회는 고스트 + // 대기 중 재정렬 시 다른 키를 복제한다 + void placeDuplicatedKey( + { + slot: duplicateState.slot, + position: duplicateState.position as KeyPosition, + }, + selectedKeyType, + snapped.x - width / 2, + snapped.y - height / 2, + ); + } else if (typeof onKeyDuplicate === 'function') { + onKeyDuplicate( + duplicateState.sourceIndex, + snapped.x - width / 2, + snapped.y - height / 2, + ); + } } else if (type === 'stat') { placeDuplicateStat( duplicateState.position as StatItemPosition, @@ -2104,25 +2250,67 @@ const Grid = ({ if (contextIndex == null) return; + // 메뉴가 열린 동안의 재정렬·삭제를 액션 시점에 재해석. + // 모드 밖으로 이동한 대상은 소실로 취급한다 + const resolveContextTarget = ( + targetType: 'key' | 'stat' | 'graph' | 'knob', + ): number | null => { + if (contextElementId) { + const locator = resolveElementById( + targetType, + contextElementId, + ); + return locator && locator.mode === selectedKeyType + ? locator.index + : null; + } + return contextIndex; + }; + if (contextType === 'stat') { + const statIndex = resolveContextTarget('stat'); const pos = - useStatItemStore.getState().positions?.[selectedKeyType]?.[ - contextIndex - ] || null; + statIndex != null + ? useStatItemStore.getState().positions?.[selectedKeyType]?.[ + statIndex + ] || null + : null; const displayName = pos ? getStatTypeLabel(pos.statType) : ''; if (id === 'delete') { showConfirm( t('confirm.removeStat', { name: displayName }), - () => deleteStatAtIndex(contextIndex), + () => { + if (contextElementId) { + void deleteElementById('stat', contextElementId); + return; + } + if (statIndex != null) deleteStatAtIndex(statIndex); + }, { confirmText: t('confirm.remove') }, ); } else if (id === 'duplicate') { - beginDuplicateStat(contextIndex); + if (statIndex != null) beginDuplicateStat(statIndex); } else if (id === 'bringToFront') { - moveStatToFront(contextIndex); + if (contextElementId) { + void applyZOrderByIds( + [{ type: 'stat', id: contextElementId }], + 'front', + pluginZIndexesForMode(), + ); + } else if (statIndex != null) { + moveStatToFront(statIndex); + } } else if (id === 'sendToBack') { - moveStatToBack(contextIndex); + if (contextElementId) { + void applyZOrderByIds( + [{ type: 'stat', id: contextElementId }], + 'back', + pluginZIndexesForMode(), + ); + } else if (statIndex != null) { + moveStatToBack(statIndex); + } } setIsContextOpen(false); @@ -2131,24 +2319,49 @@ const Grid = ({ } if (contextType === 'graph') { + const graphIndex = resolveContextTarget('graph'); const pos = - useGraphItemStore.getState().positions?.[selectedKeyType]?.[ - contextIndex - ] || null; + graphIndex != null + ? useGraphItemStore.getState().positions?.[selectedKeyType]?.[ + graphIndex + ] || null + : null; const displayName = pos ? getStatTypeLabel(pos.statType) : ''; if (id === 'delete') { showConfirm( t('confirm.removeGraph', { name: displayName }), - () => deleteGraphAtIndex(contextIndex), + () => { + if (contextElementId) { + void deleteElementById('graph', contextElementId); + return; + } + if (graphIndex != null) deleteGraphAtIndex(graphIndex); + }, { confirmText: t('confirm.remove') }, ); } else if (id === 'duplicate') { - beginDuplicateGraph(contextIndex); + if (graphIndex != null) beginDuplicateGraph(graphIndex); } else if (id === 'bringToFront') { - moveGraphToFront(contextIndex); + if (contextElementId) { + void applyZOrderByIds( + [{ type: 'graph', id: contextElementId }], + 'front', + pluginZIndexesForMode(), + ); + } else if (graphIndex != null) { + moveGraphToFront(graphIndex); + } } else if (id === 'sendToBack') { - moveGraphToBack(contextIndex); + if (contextElementId) { + void applyZOrderByIds( + [{ type: 'graph', id: contextElementId }], + 'back', + pluginZIndexesForMode(), + ); + } else if (graphIndex != null) { + moveGraphToBack(graphIndex); + } } setIsContextOpen(false); @@ -2157,18 +2370,41 @@ const Grid = ({ } if (contextType === 'knob') { + const knobIndex = resolveContextTarget('knob'); if (id === 'delete') { showConfirm( t('confirm.removeKnob', { name: 'Knob' }), - () => deleteKnobAtIndex(contextIndex), + () => { + if (contextElementId) { + void deleteElementById('knob', contextElementId); + return; + } + if (knobIndex != null) deleteKnobAtIndex(knobIndex); + }, { confirmText: t('confirm.remove') }, ); } else if (id === 'duplicate') { - beginDuplicateKnob(contextIndex); + if (knobIndex != null) beginDuplicateKnob(knobIndex); } else if (id === 'bringToFront') { - moveKnobToFront(contextIndex); + if (contextElementId) { + void applyZOrderByIds( + [{ type: 'knob', id: contextElementId }], + 'front', + pluginZIndexesForMode(), + ); + } else if (knobIndex != null) { + moveKnobToFront(knobIndex); + } } else if (id === 'sendToBack') { - moveKnobToBack(contextIndex); + if (contextElementId) { + void applyZOrderByIds( + [{ type: 'knob', id: contextElementId }], + 'back', + pluginZIndexesForMode(), + ); + } else if (knobIndex != null) { + moveKnobToBack(knobIndex); + } } setIsContextOpen(false); @@ -2181,16 +2417,22 @@ const Grid = ({ (item) => item.fullId === id, ); if (pluginItem) { + const keyIndex = resolveContextTarget('key'); + if (keyIndex == null) return; const positionForContext = - positions[selectedKeyType]?.[contextIndex]; + useKeyStore.getState().canonicalPositions[selectedKeyType]?.[ + keyIndex + ]; if (!positionForContext) return; const context = { // 플러그인 메뉴 표면은 canonical 문자열 유지 keyCode: slotCanonical( - keyMappings[selectedKeyType]?.[contextIndex] ?? '', + useKeyStore.getState().keyMappings[selectedKeyType]?.[ + keyIndex + ] ?? '', ), id: positionForContext.id ?? '', - index: contextIndex, + index: keyIndex, position: positionForContext, mode: selectedKeyType, }; @@ -2219,20 +2461,39 @@ const Grid = ({ // 기본 메뉴 처리 if (id === 'delete') { - const slot = keyMappings[selectedKeyType]?.[contextIndex] ?? ''; + const keyIndex = resolveContextTarget('key'); + const slot = + keyIndex != null + ? keyMappings[selectedKeyType]?.[keyIndex] ?? '' + : ''; const displayName = slotDisplayName(slot); showConfirm( t('confirm.removeKey', { name: displayName }), - () => onKeyDelete(contextIndex), + () => { + if (contextElementId) { + void deleteElementById('key', contextElementId); + return; + } + if (keyIndex != null) onKeyDelete(keyIndex); + }, { confirmText: t('confirm.remove') }, ); } else if (id === 'duplicate') { - const displayLabel = slotDisplayName( - keyMappings[selectedKeyType]?.[contextIndex] ?? '', - ); + const keyIndex = resolveContextTarget('key'); + const sourceSlot = + keyIndex != null + ? useKeyStore.getState().keyMappings[selectedKeyType]?.[ + keyIndex + ] + : undefined; + const displayLabel = slotDisplayName(sourceSlot ?? ''); const position = - positions[selectedKeyType]?.[contextIndex] || null; - if (position) { + keyIndex != null + ? useKeyStore.getState().canonicalPositions[ + selectedKeyType + ]?.[keyIndex] || null + : null; + if (position && typeof sourceSlot !== 'undefined') { const clonedNoteColor = position.noteColor && typeof position.noteColor === 'object' && @@ -2266,7 +2527,10 @@ const Grid = ({ ); setDuplicateState({ elementType: 'key', - sourceIndex: contextIndex, + sourceIndex: keyIndex, + // 배치 시 재조회 금지 - 고스트를 따라다니는 동안의 재정렬이 + // 다른 키를 복제하게 만든다 + slot: sourceSlot, keyName: displayLabel, position: { ...position, @@ -2277,13 +2541,24 @@ const Grid = ({ setDuplicateCursor(initialCursor); } } else if (id === 'counterReset') { - const slot = keyMappings[selectedKeyType]?.[contextIndex] ?? ''; - // 카운터 리셋 커맨드의 key 인자 = canonical (계약 §7) - const globalKey = slotCanonical(slot); + const menuIndex = resolveContextTarget('key'); + const slot = + menuIndex != null + ? keyMappings[selectedKeyType]?.[menuIndex] ?? '' + : ''; const displayName = slotDisplayName(slot); showConfirm( t('confirm.resetKeyCounter', { name: displayName }), async () => { + // 확인 시점 재해석 - 모달이 떠 있는 동안의 재바인딩 반영 + const confirmIndex = resolveContextTarget('key'); + if (confirmIndex == null) return; + // 카운터 리셋 커맨드의 key 인자 = canonical (계약 §7) + const globalKey = slotCanonical( + useKeyStore.getState().keyMappings[selectedKeyType]?.[ + confirmIndex + ] ?? '', + ); try { await window.api.keys.resetSingleCounter( selectedKeyType, @@ -2296,20 +2571,40 @@ const Grid = ({ { confirmText: t('confirm.reset') }, ); } else if (id === 'bringToFront') { - if (typeof onMoveToFront === 'function') { - onMoveToFront(contextIndex); + if (contextElementId) { + void applyZOrderByIds( + [{ type: 'key', id: contextElementId }], + 'front', + pluginZIndexesForMode(), + ); + } else { + const keyIndex = resolveContextTarget('key'); + if (keyIndex != null && typeof onMoveToFront === 'function') { + onMoveToFront(keyIndex); + } } } else if (id === 'bringForward') { - if (typeof onMoveForward === 'function') { - onMoveForward(contextIndex); + const keyIndex = resolveContextTarget('key'); + if (keyIndex != null && typeof onMoveForward === 'function') { + onMoveForward(keyIndex); } } else if (id === 'sendBackward') { - if (typeof onMoveBackward === 'function') { - onMoveBackward(contextIndex); + const keyIndex = resolveContextTarget('key'); + if (keyIndex != null && typeof onMoveBackward === 'function') { + onMoveBackward(keyIndex); } } else if (id === 'sendToBack') { - if (typeof onMoveToBack === 'function') { - onMoveToBack(contextIndex); + if (contextElementId) { + void applyZOrderByIds( + [{ type: 'key', id: contextElementId }], + 'back', + pluginZIndexesForMode(), + ); + } else { + const keyIndex = resolveContextTarget('key'); + if (keyIndex != null && typeof onMoveToBack === 'function') { + onMoveToBack(keyIndex); + } } } setIsContextOpen(false); diff --git a/src/renderer/components/main/Grid/layers/GraphItem.tsx b/src/renderer/components/main/Grid/layers/GraphItem.tsx index 1256fdd2..c2c02313 100644 --- a/src/renderer/components/main/Grid/layers/GraphItem.tsx +++ b/src/renderer/components/main/Grid/layers/GraphItem.tsx @@ -44,7 +44,12 @@ interface GraphItemProps { index: number; elementId?: string; position: GraphPosition; - onPositionChange: (index: number, dx: number, dy: number) => void; + onPositionChange: ( + index: number, + dx: number, + dy: number, + elementId?: string, + ) => void; onClick?: (e: React.MouseEvent) => void; onDoubleClick?: (e: React.MouseEvent) => void; onCtrlClick?: (e: React.MouseEvent) => void; @@ -152,7 +157,8 @@ const GraphItem = ({ initialY: dy, onPositionChange: (newDx: number, newDy: number) => { if (!isSelectionMode) { - onPositionChange(index, newDx, newDy); + // 프리즈된 index의 재해석은 수신 측이 elementId로 수행 + onPositionChange(index, newDx, newDy, elementId); } }, zoom, diff --git a/src/renderer/components/main/Grid/layers/KnobItem.tsx b/src/renderer/components/main/Grid/layers/KnobItem.tsx index c8d8ba23..688f04ab 100644 --- a/src/renderer/components/main/Grid/layers/KnobItem.tsx +++ b/src/renderer/components/main/Grid/layers/KnobItem.tsx @@ -67,7 +67,12 @@ interface KnobItemProps { index: number; elementId?: string; position: KnobPosition; - onPositionChange: (index: number, dx: number, dy: number) => void; + onPositionChange: ( + index: number, + dx: number, + dy: number, + elementId?: string, + ) => void; onClick?: (e: React.MouseEvent) => void; onDoubleClick?: (e: React.MouseEvent) => void; onCtrlClick?: (e: React.MouseEvent) => void; @@ -226,7 +231,8 @@ const KnobItem = ({ initialY: dy, onPositionChange: (newDx: number, newDy: number) => { if (!isSelectionMode) { - onPositionChange(index, newDx, newDy); + // 프리즈된 index의 재해석은 수신 측이 elementId로 수행 + onPositionChange(index, newDx, newDy, elementId); } }, zoom, diff --git a/src/renderer/components/shared/Key.tsx b/src/renderer/components/shared/Key.tsx index fb1ef352..70f83d25 100644 --- a/src/renderer/components/shared/Key.tsx +++ b/src/renderer/components/shared/Key.tsx @@ -40,7 +40,12 @@ interface DraggableKeyProps { anchorKind?: 'key' | 'stat'; position: KeyPosition; keyName: string; - onPositionChange: (index: number, dx: number, dy: number) => void; + onPositionChange: ( + index: number, + dx: number, + dy: number, + elementId?: string, + ) => void; onClick?: (e: React.MouseEvent) => void; onDoubleClick?: (e: React.MouseEvent) => void; onCtrlClick?: (e: React.MouseEvent) => void; @@ -150,7 +155,8 @@ const DraggableKey = React.memo( initialY: dy, onPositionChange: (newDx: number, newDy: number) => { if (!isSelectionMode) { - onPositionChange(index, newDx, newDy); + // 프리즈된 index의 재해석은 수신 측이 elementId로 수행 + onPositionChange(index, newDx, newDy, elementId); } }, zoom, diff --git a/src/renderer/editor/model/elementIdMap.ts b/src/renderer/editor/model/elementIdMap.ts index a9960176..07828e3b 100644 --- a/src/renderer/editor/model/elementIdMap.ts +++ b/src/renderer/editor/model/elementIdMap.ts @@ -7,6 +7,11 @@ import type { KeyPosition } from '@src/types/key/keys'; export type NativeElementType = 'key' | 'stat' | 'graph' | 'knob'; +// 무ID 구형 요소의 합성 신원(`${type}-${index}`). 안정 ID처럼 다루면 +// 조회가 항상 실패해 legacy 폴백까지 막힌다 +export const isSyntheticElementId = (id: string): boolean => + /^(key|stat|graph|knob)-\d+$/.test(id); + export interface ElementLocator { type: NativeElementType; mode: string; diff --git a/src/renderer/editor/model/keys.ts b/src/renderer/editor/model/keys.ts index 4e792b9a..8ada1db6 100644 --- a/src/renderer/editor/model/keys.ts +++ b/src/renderer/editor/model/keys.ts @@ -114,24 +114,12 @@ export function removeKey( // 키 복제 // ---------------------------------------------------------------------------- -/** 키 복제 후 새 mappings/positions 반환 */ -export function duplicateKey( - mappings: KeyMappings, - positions: KeyPositions, - mode: string, - sourceIndex: number, +/** 복제용 위치 클론: 새 신원 발급 + 참조 분리 + 좌표 반올림 + 기본값 백필 */ +export function cloneKeyPositionForDuplicate( + sourcePosition: KeyPosition, targetDx: number, targetDy: number, -): AddKeyResult | null { - const mapping = mappings[mode] || []; - const pos = positions[mode] || []; - const sourceKey = mapping[sourceIndex]; - const sourcePosition = pos[sourceIndex]; - - if (typeof sourceKey === 'undefined' || !sourcePosition) { - return null; - } - +): KeyPosition { const clonedNoteColor = sourcePosition.noteColor && typeof sourcePosition.noteColor === 'object' && @@ -159,7 +147,7 @@ export function duplicateKey( }, }; - const clonedPosition: KeyPosition = { + return { ...sourcePosition, // 복제본은 새 신원. source id를 물려받으면 후보 안 중복으로 커밋이 거절된다 id: newElementId(), @@ -173,6 +161,31 @@ export function duplicateKey( noteGlowColor: clonedNoteColor, noteAutoYCorrection: sourcePosition.noteAutoYCorrection ?? true, }; +} + +/** 키 복제 후 새 mappings/positions 반환 */ +export function duplicateKey( + mappings: KeyMappings, + positions: KeyPositions, + mode: string, + sourceIndex: number, + targetDx: number, + targetDy: number, +): AddKeyResult | null { + const mapping = mappings[mode] || []; + const pos = positions[mode] || []; + const sourceKey = mapping[sourceIndex]; + const sourcePosition = pos[sourceIndex]; + + if (typeof sourceKey === 'undefined' || !sourcePosition) { + return null; + } + + const clonedPosition = cloneKeyPositionForDuplicate( + sourcePosition, + targetDx, + targetDy, + ); return { mappings: { diff --git a/src/renderer/editor/runtime/elementOps.test.ts b/src/renderer/editor/runtime/elementOps.test.ts new file mode 100644 index 00000000..e0be7246 --- /dev/null +++ b/src/renderer/editor/runtime/elementOps.test.ts @@ -0,0 +1,346 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createDefaultKeyPosition } from '../model/keys'; + +const api = vi.hoisted(() => ({ + commitGeneratedPatch: vi.fn(), +})); + +vi.mock('./editorStateCoordinator', () => ({ + editorCoordinator: { commitGeneratedPatch: api.commitGeneratedPatch }, +})); + +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 { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; +import { + applyZOrderByIds, + commitSelectedGeometryByIds, + deleteElementById, + placeDuplicatedKey, + rebindKeySlotById, +} from './elementOps'; + +import { enqueueEditorCompatibilityWrite } from './editorCompatibilityQueue'; + +import type { EditorDocumentV1, EditorPatchV1 } from '@src/types/editor'; + +const ID_A = '11111111-1111-4111-8111-111111111111'; +const ID_B = '22222222-2222-4222-8222-222222222222'; + +const keyAt = (id: string, zIndex?: number) => ({ + ...createDefaultKeyPosition(), + id, + ...(zIndex !== undefined ? { zIndex } : {}), +}); + +// 슬롯 시점 base. 기본은 호출 시점 스토어 - 대기 중 재정렬·삭제는 테스트가 +// slotBase로 재현한다 +let slotBase: (() => EditorDocumentV1) | null = null; +const generatedPatches: Array = []; + +const documentFromStores = (): EditorDocumentV1 => + ({ + schemaVersion: 1, + keys: structuredClone(useKeyStore.getState().keyMappings), + keyPositions: structuredClone(useKeyStore.getState().canonicalPositions), + statPositions: structuredClone(useStatItemStore.getState().positions), + graphPositions: structuredClone(useGraphItemStore.getState().positions), + knobPositions: structuredClone(useKnobItemStore.getState().positions), + layerGroups: {}, + } as unknown as EditorDocumentV1); + +describe('elementOps', () => { + beforeEach(() => { + vi.clearAllMocks(); + slotBase = null; + generatedPatches.length = 0; + api.commitGeneratedPatch.mockImplementation( + async (generate: (base: EditorDocumentV1) => EditorPatchV1 | null) => { + const base = (slotBase ?? documentFromStores)(); + generatedPatches.push(generate(base)); + return base; + }, + ); + useKeyStore.setState({ + selectedKeyType: '4key', + keyMappings: { '4key': ['A', 'B'] }, + canonicalPositions: { '4key': [keyAt(ID_A), keyAt(ID_B)] }, + positions: { '4key': [keyAt(ID_A), keyAt(ID_B)] }, + }); + useStatItemStore.setState({ positions: {} }); + useGraphItemStore.setState({ positions: {} }); + useKnobItemStore.setState({ positions: {} }); + useGridSelectionStore.setState({ selectedElements: [] }); + }); + + it('키 삭제는 keys와 keyPositions를 함께 제거하고 즉시 스토어에 반영한다', async () => { + // base는 eager 반영 전 canonical + const pre = documentFromStores(); + slotBase = () => pre; + const applied = await deleteElementById('key', ID_A); + + expect(applied).toBe(true); + // eager: 스토어에서 이미 제거됨 + expect(useKeyStore.getState().keyMappings['4key']).toEqual(['B']); + expect( + useKeyStore.getState().canonicalPositions['4key'].map((p) => p.id), + ).toEqual([ID_B]); + // wire: paired 제거 + const patch = generatedPatches[0]; + expect(patch?.keys?.['4key']).toEqual(['B']); + expect(patch?.keyPositions?.['4key'].map((p) => p.id)).toEqual([ID_B]); + }); + + it('삭제 확정 시점에 재정렬돼 있어도 같은 id를 제거한다', async () => { + slotBase = () => { + const base = documentFromStores(); + base.keys = { '4key': ['B', 'A'] }; + base.keyPositions = { '4key': [keyAt(ID_B), keyAt(ID_A)] } as never; + return base; + }; + + await deleteElementById('key', ID_A); + + const patch = generatedPatches[0]; + expect(patch?.keys?.['4key']).toEqual(['B']); + expect(patch?.keyPositions?.['4key'].map((p) => p.id)).toEqual([ID_B]); + }); + + it('확정 시점에 이미 삭제된 대상은 커밋하지 않는다', async () => { + slotBase = () => { + const base = documentFromStores(); + base.keys = { '4key': ['B'] }; + base.keyPositions = { '4key': [keyAt(ID_B)] } as never; + return base; + }; + + await deleteElementById('key', ID_A); + + expect(generatedPatches).toEqual([null]); + }); + + it('복제 배치는 동결 payload를 새 id로 추가한다', async () => { + const frozen = { + slot: 'A', + position: keyAt(ID_A), + }; + + const pre = documentFromStores(); + slotBase = () => pre; + await placeDuplicatedKey(frozen, '4key', 10, 20); + + const patch = generatedPatches[0]; + expect(patch?.keys?.['4key']).toEqual(['A', 'B', 'A']); + const added = patch?.keyPositions?.['4key'][2]; + expect(added?.dx).toBe(10); + expect(added?.dy).toBe(20); + expect(added?.id).toBeTruthy(); + expect(added?.id).not.toBe(ID_A); + // eager 반영 + expect(useKeyStore.getState().keyMappings['4key']).toHaveLength(3); + }); + + it('z-order는 대상 id들에 단일 트랜잭션으로 새 z를 배정한다', async () => { + useKeyStore.setState({ + canonicalPositions: { + '4key': [keyAt(ID_A, 1), keyAt(ID_B, 5)], + }, + positions: { '4key': [keyAt(ID_A, 1), keyAt(ID_B, 5)] }, + }); + + const pre = documentFromStores(); + slotBase = () => pre; + const applied = await applyZOrderByIds( + [ + { type: 'key', id: ID_A }, + { type: 'key', id: ID_B }, + ], + 'front', + [9], + ); + + expect(applied).toBe(2); + expect(api.commitGeneratedPatch).toHaveBeenCalledOnce(); + const record = generatedPatches[0]?.keyPositions?.['4key']; + // 외부(9) 포함 max=9, 선택 순서대로 10, 11 + expect(record?.find((p) => p.id === ID_A)?.zIndex).toBe(10); + expect(record?.find((p) => p.id === ID_B)?.zIndex).toBe(11); + }); + + it('z-order 확정 시점 재정렬에도 id를 따라간다', async () => { + useKeyStore.setState({ + canonicalPositions: { + '4key': [keyAt(ID_A, 1), keyAt(ID_B, 5)], + }, + positions: { '4key': [keyAt(ID_A, 1), keyAt(ID_B, 5)] }, + }); + slotBase = () => { + const base = documentFromStores(); + base.keyPositions = { + '4key': [keyAt(ID_B, 5), keyAt(ID_A, 1)], + } as never; + return base; + }; + + await applyZOrderByIds([{ type: 'key', id: ID_A }], 'front', []); + + const record = generatedPatches[0]?.keyPositions?.['4key']; + expect(record?.[1].id).toBe(ID_A); + expect(record?.[1].zIndex).toBe(6); + expect(record?.[0].zIndex).toBe(5); + }); + + it('슬롯 재바인딩은 same-shape 재정렬에도 위치 id의 paired index를 따라간다', async () => { + slotBase = () => { + const base = documentFromStores(); + base.keys = { '4key': ['B', 'A'] }; + base.keyPositions = { '4key': [keyAt(ID_B), keyAt(ID_A)] } as never; + return base; + }; + + const applied = await rebindKeySlotById(ID_A, 'Z'); + + expect(applied).toBe(true); + // eager: 호출 시점 스토어 기준 index 0 (ID_A 위치) + expect(useKeyStore.getState().keyMappings['4key']).toEqual(['Z', 'B']); + // wire: 재정렬된 base에서 ID_A는 index 1 - 그 자리의 슬롯이 바뀐다 + const patch = generatedPatches[0]; + expect(patch?.keys?.['4key']).toEqual(['B', 'Z']); + expect(patch?.keyPositions).toBeUndefined(); + }); + + it('다중 정산은 기하만 실어 base의 무관 필드 재작성을 보존한다', async () => { + // 호출 시점 스토어: 드래그 결과 dx=50. base(슬롯 시점)에는 그 사이 + // 배타 mutation이 재작성한 counter preset(Q)이 있다 + useKeyStore.setState({ + canonicalPositions: { + '4key': [{ ...keyAt(ID_A), dx: 50 }, keyAt(ID_B)], + }, + positions: { '4key': [{ ...keyAt(ID_A), dx: 50 }, keyAt(ID_B)] }, + }); + slotBase = () => { + const base = documentFromStores(); + base.keyPositions = { + '4key': [ + { ...keyAt(ID_A), dx: 0, inactiveImage: 'rewritten-by-mutation.png' }, + keyAt(ID_B), + ], + } as never; + return base; + }; + + const applied = await commitSelectedGeometryByIds( + [{ type: 'key', id: ID_A }], + 'gesture-sync', + ); + + expect(applied).toBe(1); + expect(api.commitGeneratedPatch.mock.calls[0][1]).toEqual({ + gestureId: 'gesture-sync', + }); + const record = generatedPatches[0]?.keyPositions?.['4key']; + // 기하는 의도값, mutation이 재작성한 필드는 base 값 유지 + expect(record?.[0].dx).toBe(50); + expect(record?.[0].inactiveImage).toBe('rewritten-by-mutation.png'); + }); + + it('리사이즈 정산은 크기 필드까지 의도에 싣는다', async () => { + useKeyStore.setState({ + canonicalPositions: { + '4key': [{ ...keyAt(ID_A), dx: 5, width: 90, height: 80 }], + }, + positions: { + '4key': [{ ...keyAt(ID_A), dx: 5, width: 90, height: 80 }], + }, + }); + const pre = documentFromStores(); + pre.keyPositions = { + '4key': [{ ...keyAt(ID_A), dx: 0, width: 60, height: 60 }], + } as never; + slotBase = () => pre; + + await commitSelectedGeometryByIds([{ type: 'key', id: ID_A }], undefined, [ + 'dx', + 'dy', + 'width', + 'height', + ]); + + const record = generatedPatches[0]?.keyPositions?.['4key']; + expect(record?.[0]).toMatchObject({ dx: 5, width: 90, height: 80 }); + }); + + it('이동 정산은 크기 필드를 싣지 않는다', async () => { + useKeyStore.setState({ + canonicalPositions: { + '4key': [{ ...keyAt(ID_A), dx: 5, width: 90 }], + }, + positions: { '4key': [{ ...keyAt(ID_A), dx: 5, width: 90 }] }, + }); + const pre = documentFromStores(); + // 병행 크기 변경이 base에 정산된 상황 + pre.keyPositions = { + '4key': [{ ...keyAt(ID_A), dx: 0, width: 120 }], + } as never; + slotBase = () => pre; + + await commitSelectedGeometryByIds([{ type: 'key', id: ID_A }]); + + const record = generatedPatches[0]?.keyPositions?.['4key']; + expect(record?.[0].dx).toBe(5); + // 병행 크기 변경 보존 + expect(record?.[0].width).toBe(120); + }); + + it('다중 정산 대상이 전부 사라졌으면 커밋하지 않는다', async () => { + slotBase = () => { + const base = documentFromStores(); + base.keyPositions = { '4key': [keyAt(ID_B)] } as never; + return base; + }; + + const applied = await commitSelectedGeometryByIds([ + { type: 'key', id: ID_A }, + ]); + + expect(applied).toBe(0); + expect(generatedPatches).toEqual([null]); + }); + + it('semantic op는 compat 큐 선행 작업 뒤에 커밋한다', async () => { + let release!: () => void; + const blocker = new Promise((resolve) => { + release = resolve; + }); + const first = enqueueEditorCompatibilityWrite( + () => blocker, + () => undefined, + ); + + const pending = deleteElementById('key', ID_A); + await Promise.resolve(); + await Promise.resolve(); + // 큐를 건너뛰면 여기서 이미 커밋된다 + expect(api.commitGeneratedPatch).not.toHaveBeenCalled(); + + release(); + await first; + expect(await pending).toBe(true); + expect(api.commitGeneratedPatch).toHaveBeenCalledOnce(); + }); + + it('재바인딩 대상이 사라졌으면 커밋하지 않는다', async () => { + slotBase = () => { + const base = documentFromStores(); + base.keys = { '4key': ['B'] }; + base.keyPositions = { '4key': [keyAt(ID_B)] } as never; + return base; + }; + + await rebindKeySlotById(ID_A, 'Z'); + + expect(generatedPatches).toEqual([null]); + }); +}); diff --git a/src/renderer/editor/runtime/elementOps.ts b/src/renderer/editor/runtime/elementOps.ts new file mode 100644 index 00000000..b003adcb --- /dev/null +++ b/src/renderer/editor/runtime/elementOps.ts @@ -0,0 +1,491 @@ +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 { reconcileSelectionAfterIndexedElementDeletion } from '@stores/grid/useGridSelectionStore'; + +import { resolveElementById } from '../model/elementIdMap'; +import { cloneKeyPositionForDuplicate } from '../model/keys'; +import { cloneSlot } from '@utils/keySlot'; +import { enqueueEditorCompatibilityOperation } from './editorCompatibilityQueue'; +import { editorCoordinator } from './editorStateCoordinator'; + +import type { EditorDocumentV1, EditorPatchV1 } from '@src/types/editor'; + +import type { NativeElementType } from '../model/elementIdMap'; +import type { KeyPosition } from '@src/types/key/keys'; + +// 메뉴·확인 모달처럼 대상 확정과 실행 사이가 긴 파괴적 액션의 semantic op. +// 대상은 {type, id}로 받고, eager 반영과 wire 생성 각각이 실행 시점의 +// 문서에서 id를 다시 찾아 적용한다. 못 찾으면(삭제·모드 소실) 조용히 +// 중단한다 - index를 들고 있다가 다른 요소를 지우는 창을 없애는 것이 목적 + +type LooseRecord = Record< + string, + Array<{ id?: string } & Record> +>; + +const COLLECTION_FIELDS: Record< + Exclude, + 'statPositions' | 'graphPositions' | 'knobPositions' +> = { + stat: 'statPositions', + graph: 'graphPositions', + knob: 'knobPositions', +}; + +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 = 실행 시점에 대상 없음(이미 삭제) +export const deleteElementById = ( + type: NativeElementType, + id: string, +): Promise => { + if (!id) return Promise.resolve(false); + const locator = resolveElementById(type, id); + if (!locator) return Promise.resolve(false); + + // eager 반영 + 선택 재조정 - 이후의 캡처가 삭제를 포함해 자가 치유 + if (type === 'key') { + const state = useKeyStore.getState(); + const mappings = state.keyMappings; + const nextMappings = { + ...mappings, + [locator.mode]: (mappings[locator.mode] ?? []).filter( + (_, i) => i !== locator.index, + ), + }; + const nextPositions = removeAt( + state.canonicalPositions as unknown as LooseRecord, + locator.mode, + locator.index, + ); + state.setKeyMappingsAndPositions(nextMappings, nextPositions as never); + } else if (type === 'stat') { + const state = useStatItemStore.getState(); + state.setPositions( + removeAt( + state.positions as unknown as LooseRecord, + locator.mode, + locator.index, + ) as never, + ); + } else if (type === 'graph') { + const state = useGraphItemStore.getState(); + state.setPositions( + removeAt( + state.positions as unknown as LooseRecord, + locator.mode, + locator.index, + ) as never, + ); + } else { + const state = useKnobItemStore.getState(); + state.setPositions( + removeAt( + state.positions as unknown as LooseRecord, + locator.mode, + locator.index, + ) as never, + ); + } + // 선택 보정은 현재 모드 배열 기준 - 다른 모드로 이동한 대상의 index로 + // 현재 모드의 무관한 선택을 지우면 안 된다 + if (locator.mode === useKeyStore.getState().selectedKeyType) { + reconcileSelectionAfterIndexedElementDeletion(type, locator.index); + } + + return enqueueEditorCompatibilityOperation(() => + editorCoordinator.commitGeneratedPatch((base) => { + if (type === 'key') { + const found = findInRecord( + base.keyPositions as unknown as LooseRecord, + id, + ); + if (!found) return null; + return { + schemaVersion: 1, + keys: { + ...base.keys, + [found.mode]: (base.keys[found.mode] ?? []).filter( + (_, i) => i !== found.index, + ), + }, + keyPositions: removeAt( + base.keyPositions as unknown as LooseRecord, + found.mode, + found.index, + ) as never, + }; + } + const field = COLLECTION_FIELDS[type]; + const found = findInRecord(base[field] as unknown as LooseRecord, id); + if (!found) return null; + return { + schemaVersion: 1, + [field]: removeAt( + base[field] as unknown as LooseRecord, + found.mode, + found.index, + ), + } as EditorPatchV1; + }), + ).then( + () => true, + (error) => { + console.error('Failed to commit element deletion', error); + return true; + }, + ); +}; + +// 복제 배치: 시작 시점에 동결한 payload(slot + position)를 현재 모드에 새 +// 요소로 추가한다. sourceIndex 재조회 금지 - 고스트를 따라다니는 동안의 +// 재정렬이 다른 키를 복제하게 만든다 +export interface FrozenKeyDuplicate { + slot: unknown; + position: KeyPosition; +} + +export const placeDuplicatedKey = ( + frozen: FrozenKeyDuplicate, + mode: string, + dx: number, + dy: number, +): Promise => { + // 구 duplicateKey와 같은 정규화: 새 신원, 좌표 반올림, 참조 분리, 기본값 백필 + const newPosition = cloneKeyPositionForDuplicate(frozen.position, dx, dy); + const newId = newPosition.id as string; + const frozenSlot = cloneSlot(frozen.slot as never); + + const state = useKeyStore.getState(); + state.setKeyMappingsAndPositions( + { + ...state.keyMappings, + [mode]: [...(state.keyMappings[mode] ?? []), frozenSlot as never], + }, + { + ...state.canonicalPositions, + [mode]: [...(state.canonicalPositions[mode] ?? []), newPosition], + } as never, + ); + + return enqueueEditorCompatibilityOperation(() => + editorCoordinator.commitGeneratedPatch((base: EditorDocumentV1) => { + // 이미 같은 id가 들어가 있으면(이중 실행) 재추가 금지 + if (findInRecord(base.keyPositions as unknown as LooseRecord, newId)) { + return null; + } + return { + schemaVersion: 1, + keys: { + ...base.keys, + [mode]: [...(base.keys[mode] ?? []), frozenSlot as never], + }, + keyPositions: { + ...base.keyPositions, + [mode]: [...(base.keyPositions[mode] ?? []), newPosition], + } as never, + }; + }), + ).then( + () => true, + (error) => { + console.error('Failed to commit key duplication', error); + return true; + }, + ); +}; + +// z-order: 모드 전역(4 컬렉션 + 외부 플러그인 z) 기준으로 대상 id들에 +// 새 zIndex를 선택 순서대로 할당하는 단일 트랜잭션. 루프-await로 요소마다 +// 따로 커밋하면 렌더 클로저 base가 서로를 덮는다 (플러그인 없이 재현되는 +// lost update). 플러그인 요소는 편집 문서 밖이라 이 op에 결합하지 않는다 +export interface ZOrderTarget { + type: NativeElementType; + id: string; +} + +const Z_ORDER_FIELDS = [ + 'keyPositions', + 'statPositions', + 'graphPositions', + 'knobPositions', +] as const; + +const zOrderRecords = ( + base: EditorDocumentV1, +): Record<(typeof Z_ORDER_FIELDS)[number], LooseRecord> => ({ + keyPositions: base.keyPositions as unknown as LooseRecord, + statPositions: base.statPositions as unknown as LooseRecord, + graphPositions: base.graphPositions as unknown as LooseRecord, + knobPositions: base.knobPositions as unknown as LooseRecord, +}); + +const FIELD_BY_TYPE: Record< + NativeElementType, + (typeof Z_ORDER_FIELDS)[number] +> = { + key: 'keyPositions', + stat: 'statPositions', + graph: 'graphPositions', + knob: 'knobPositions', +}; + +const computeZOrderPatch = ( + base: EditorDocumentV1, + targets: readonly ZOrderTarget[], + direction: 'front' | 'back', + externalZIndexes: readonly number[], +): { patch: EditorPatchV1 | null; applied: number } => { + const records = zOrderRecords(base); + const located: Array<{ + field: (typeof Z_ORDER_FIELDS)[number]; + mode: string; + index: number; + }> = []; + for (const target of targets) { + if (!target.id) continue; + const field = FIELD_BY_TYPE[target.type]; + const found = findInRecord(records[field], target.id); + if (!found) continue; + located.push({ field, ...found }); + } + if (located.length === 0) return { patch: null, applied: 0 }; + + // 대상 모드들의 전역 z 범위 (컬렉션 4개 + 외부) + const modes = new Set(located.map((entry) => entry.mode)); + const zValues: number[] = [...externalZIndexes]; + for (const field of Z_ORDER_FIELDS) { + for (const mode of modes) { + (records[field][mode] ?? []).forEach((position, i) => { + zValues.push(typeof position.zIndex === 'number' ? position.zIndex : i); + }); + } + } + const maxZ = Math.max(0, ...zValues); + const minZ = Math.min(0, ...zValues); + + const next: Partial> = + {}; + located.forEach((entry, order) => { + const zIndex = direction === 'front' ? maxZ + 1 + order : minZ - 1 - order; + const record = next[entry.field] ?? { + ...records[entry.field], + }; + record[entry.mode] = (record[entry.mode] ?? []).map((position, i) => + i === entry.index ? { ...position, zIndex } : position, + ); + next[entry.field] = record; + }); + + const patch: EditorPatchV1 = { schemaVersion: 1 }; + for (const field of Z_ORDER_FIELDS) { + if (next[field]) patch[field] = next[field] as never; + } + return { patch, applied: located.length }; +}; + +const storeDocumentSnapshot = (): EditorDocumentV1 => + ({ + schemaVersion: 1, + keys: useKeyStore.getState().keyMappings, + keyPositions: useKeyStore.getState().canonicalPositions, + statPositions: useStatItemStore.getState().positions, + graphPositions: useGraphItemStore.getState().positions, + knobPositions: useKnobItemStore.getState().positions, + layerGroups: {}, + } as unknown as EditorDocumentV1); + +const applyZOrderEagerly = (patch: EditorPatchV1): void => { + if (patch.keyPositions) { + useKeyStore.getState().setPositions(patch.keyPositions as never); + } + if (patch.statPositions) { + useStatItemStore.getState().setPositions(patch.statPositions as never); + } + if (patch.graphPositions) { + useGraphItemStore.getState().setPositions(patch.graphPositions as never); + } + if (patch.knobPositions) { + useKnobItemStore.getState().setPositions(patch.knobPositions as never); + } +}; + +export const applyZOrderByIds = ( + targets: readonly ZOrderTarget[], + direction: 'front' | 'back', + externalZIndexes: readonly number[] = [], +): Promise => { + const eager = computeZOrderPatch( + storeDocumentSnapshot(), + targets, + direction, + externalZIndexes, + ); + if (eager.patch) applyZOrderEagerly(eager.patch); + + let applied = 0; + return enqueueEditorCompatibilityOperation(() => + editorCoordinator.commitGeneratedPatch((base) => { + const generated = computeZOrderPatch( + base, + targets, + direction, + externalZIndexes, + ); + applied = generated.applied; + return generated.patch; + }), + ).then( + () => applied, + (error) => { + console.error('Failed to commit z-order change', error); + return applied; + }, + ); +}; + +// 키 슬롯 재바인딩: keys만 바꾸되 대상은 paired 위치의 안정 id로 재결합한다. +// index 기반 keys 단독 커밋은 same-shape 재정렬과 겹치면 다른 위치 id와 +// 잘못 결합된다 +export const rebindKeySlotById = ( + positionId: string, + newSlot: unknown, +): Promise => { + if (!positionId) return Promise.resolve(false); + const locator = resolveElementById('key', positionId); + if (!locator) return Promise.resolve(false); + + const state = useKeyStore.getState(); + state.setKeyMappings({ + ...state.keyMappings, + [locator.mode]: (state.keyMappings[locator.mode] ?? []).map((slot, i) => + i === locator.index ? newSlot : slot, + ), + } as never); + + return enqueueEditorCompatibilityOperation(() => + editorCoordinator.commitGeneratedPatch((base) => { + const found = findInRecord( + base.keyPositions as unknown as LooseRecord, + positionId, + ); + if (!found) return null; + return { + schemaVersion: 1, + keys: { + ...base.keys, + [found.mode]: (base.keys[found.mode] ?? []).map((slot, i) => + i === found.index ? newSlot : slot, + ), + } as never, + }; + }), + ).then( + () => true, + (error) => { + console.error('Failed to commit key slot rebinding', error); + return true; + }, + ); +}; + +// 다중 선택 정산: 대상 id들의 현재 canonical 기하(dx·dy)를 의도로 캡처해 +// 슬롯 안에서 id 재해석으로 적용한다. 4컬렉션 full-record 캡처는 배타 +// mutation(카운터 프리셋 삭제 등)의 IPC 창과 겹치면 직렬화 때문에 그 직후에 +// 확정적으로 착지해 무관 필드 재작성을 되돌린다 - 기하만 실어 그 결합을 끊는다 +export type GeometryField = 'dx' | 'dy' | 'width' | 'height'; + +export const commitSelectedGeometryByIds = ( + targets: readonly ZOrderTarget[], + gestureId?: string, + // 이동 경로는 dx·dy만 - 크기까지 항상 실으면 병행 크기 변경을 되돌린다. + // 리사이즈 종료만 width·height를 명시적으로 포함한다 + fields: readonly GeometryField[] = ['dx', 'dy'], +): Promise => { + const intents = new Map< + NativeElementType, + Map>> + >(); + for (const target of targets) { + if (!target.id) continue; + const locator = resolveElementById(target.type, target.id); + if (!locator) continue; + const record = + target.type === 'key' + ? (useKeyStore.getState().canonicalPositions as unknown as LooseRecord) + : target.type === 'stat' + ? (useStatItemStore.getState().positions as unknown as LooseRecord) + : target.type === 'graph' + ? (useGraphItemStore.getState().positions as unknown as LooseRecord) + : (useKnobItemStore.getState().positions as unknown as LooseRecord); + const position = record[locator.mode]?.[locator.index]; + if (!position) continue; + const byId = intents.get(target.type) ?? new Map(); + const intent: Partial> = {}; + for (const field of fields) { + const value = position[field]; + if (typeof value === 'number') intent[field] = value; + } + byId.set(target.id, intent); + intents.set(target.type, byId); + } + if (intents.size === 0) return Promise.resolve(0); + + let applied = 0; + return enqueueEditorCompatibilityOperation(() => + editorCoordinator.commitGeneratedPatch( + (base) => { + const patch: EditorPatchV1 = { schemaVersion: 1 }; + let touchedAny = false; + for (const [type, byId] of intents) { + const field = FIELD_BY_TYPE[type]; + const record = base[field] as unknown as LooseRecord; + let touched = 0; + const next: LooseRecord = {}; + for (const [mode, list] of Object.entries(record)) { + next[mode] = list.map((position) => { + const id = position.id; + if (typeof id !== 'string' || !byId.has(id)) return position; + touched += 1; + return { ...position, ...byId.get(id) }; + }); + } + if (touched > 0) { + patch[field] = next as never; + applied += touched; + touchedAny = true; + } + } + return touchedAny ? patch : null; + }, + gestureId ? { gestureId } : undefined, + ), + ).then( + () => applied, + (error) => { + console.error('Failed to commit selection geometry', error); + return applied; + }, + ); +}; diff --git a/src/renderer/editor/runtime/selectionSync.test.ts b/src/renderer/editor/runtime/selectionSync.test.ts index 51ffb09b..747cb1d4 100644 --- a/src/renderer/editor/runtime/selectionSync.test.ts +++ b/src/renderer/editor/runtime/selectionSync.test.ts @@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({ selectionListeners: new Set(), selectionState: null as FakeSelectionState | null, selectedKeyType: '4key', + keyPositions: {} as Record>, })); const notifySelection = () => { @@ -80,7 +81,10 @@ vi.mock('@stores/grid/useGridSelectionStore', () => ({ vi.mock('@stores/data/useKeyStore', () => ({ useKeyStore: { - getState: () => ({ selectedKeyType: mocks.selectedKeyType }), + getState: () => ({ + selectedKeyType: mocks.selectedKeyType, + canonicalPositions: mocks.keyPositions, + }), }, })); @@ -123,9 +127,54 @@ describe('selection sync drain', () => { selectedGroupIds: [], clearSelection: () => setSelection([]), }; + mocks.keyPositions = {}; selectionSync = await import('./selectionSync'); }); + const UUID_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + const UUID_B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; + + // S-F1: 수신 wire index는 발신 창 스냅샷 기준이라 이 창 배열과 어긋날 수 있다 + it('수신 선택의 stale index를 안정 id로 재해석한다', () => { + mocks.keyPositions = { '4key': [{ id: UUID_B }, { id: UUID_A }] }; + const stop = selectionSync.initSelectionSync(); + + mocks.changedListener!( + responseFor([{ elementType: 'key', index: 0, fullId: UUID_A }], 5), + ); + + expect(mocks.selectionState!.selectedElements).toEqual([ + { type: 'key', id: UUID_A, index: 1 }, + ]); + stop(); + }); + + it('수신 선택에서 삭제된 안정 id는 버린다', () => { + mocks.keyPositions = { '4key': [{ id: UUID_B }] }; + const stop = selectionSync.initSelectionSync(); + + mocks.changedListener!( + responseFor([{ elementType: 'key', index: 0, fullId: UUID_A }], 5), + ); + + expect(mocks.selectionState!.selectedElements).toEqual([]); + stop(); + }); + + it('합성 id(무ID 구형)는 wire 표현을 유지한다', () => { + mocks.keyPositions = { '4key': [{}] }; + const stop = selectionSync.initSelectionSync(); + + mocks.changedListener!( + responseFor([{ elementType: 'key', index: 0, fullId: 'key-0' }], 5), + ); + + expect(mocks.selectionState!.selectedElements).toEqual([ + { type: 'key', id: 'key-0', index: 0 }, + ]); + stop(); + }); + it('예약된 publish의 ACK까지 기다린다', async () => { const pending = deferred>(); mocks.publish.mockReturnValueOnce(pending.promise); diff --git a/src/renderer/editor/runtime/selectionSync.ts b/src/renderer/editor/runtime/selectionSync.ts index 142e5ab0..8ae4d45e 100644 --- a/src/renderer/editor/runtime/selectionSync.ts +++ b/src/renderer/editor/runtime/selectionSync.ts @@ -3,6 +3,11 @@ * 로컬 선택 변경을 백엔드 세션에 publish하고, 원격 스냅샷을 revision 게이트로 반영 * 반영 중 재-publish를 막아 에코 루프 차단 */ +import { + isSyntheticElementId, + resolveElementById, + type NativeElementType, +} from '../model/elementIdMap'; import { selectionSessionApi, @@ -125,7 +130,31 @@ const applyRemote = (snapshot: SelectionSessionSnapshot): void => { if (snapshot.selectionRevision <= appliedRevision) return; appliedRevision = snapshot.selectionRevision; - const remoteElements = fromWireElements(snapshot.selectedElements); + // wire index는 발신 창 스냅샷 기준 - 이 창의 배열과 어긋날 수 있다. + // 안정 id는 현재 문서에서 재해석하고 삭제된 id는 버린다. 합성 id + // (구형 무ID 요소의 `${type}-${index}`)만 기존 표현을 유지한다 + const NATIVE_SELECTION_TYPES: ReadonlySet = new Set([ + 'key', + 'stat', + 'graph', + 'knob', + ]); + + const currentMode = useKeyStore.getState().selectedKeyType; + const remoteElements = fromWireElements(snapshot.selectedElements).flatMap( + (element) => { + if (!NATIVE_SELECTION_TYPES.has(element.type)) return [element]; + const locator = resolveElementById( + element.type as NativeElementType, + element.id, + ); + if (locator) { + if (locator.mode !== currentMode) return []; + return [{ ...element, index: locator.index }]; + } + return isSyntheticElementId(element.id) ? [element] : []; + }, + ); const fingerprint = stableStringify({ selectedElements: remoteElements, selectedGroupIds: snapshot.selectedGroupIds, diff --git a/src/renderer/hooks/Grid/elementPositionCommit.test.ts b/src/renderer/hooks/Grid/elementPositionCommit.test.ts new file mode 100644 index 00000000..98806d89 --- /dev/null +++ b/src/renderer/hooks/Grid/elementPositionCommit.test.ts @@ -0,0 +1,47 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const patches = vi.hoisted(() => ({ + applyElementPatchById: vi.fn(async () => true), +})); + +vi.mock('@src/renderer/editor/runtime/elementPatch', () => patches); + +import { commitElementPosition } from './elementPositionCommit'; + +describe('commitElementPosition', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('id가 있으면 index 폴백 대신 ID applier로 적용한다', () => { + const fallback = vi.fn(); + + commitElementPosition('key', 'element-id', 12, 34, fallback); + + expect(fallback).not.toHaveBeenCalled(); + expect(patches.applyElementPatchById).toHaveBeenCalledTimes(1); + const [type, id, updater] = patches.applyElementPatchById.mock + .calls[0] as unknown as [string, string, () => Record]; + expect(type).toBe('key'); + expect(id).toBe('element-id'); + expect(updater()).toEqual({ dx: 12, dy: 34 }); + }); + + it('합성 id는 안정 ID가 아니므로 index 폴백을 유지한다', () => { + const fallback = vi.fn(); + + commitElementPosition('stat', 'stat-0', 1, 2, fallback); + + expect(patches.applyElementPatchById).not.toHaveBeenCalled(); + expect(fallback).toHaveBeenCalledTimes(1); + }); + + it('무ID 요소는 기존 index 폴백을 유지한다', () => { + const fallback = vi.fn(); + + commitElementPosition('stat', undefined, 1, 2, fallback); + + expect(patches.applyElementPatchById).not.toHaveBeenCalled(); + expect(fallback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/renderer/hooks/Grid/elementPositionCommit.ts b/src/renderer/hooks/Grid/elementPositionCommit.ts new file mode 100644 index 00000000..463bcdd8 --- /dev/null +++ b/src/renderer/hooks/Grid/elementPositionCommit.ts @@ -0,0 +1,22 @@ +import { applyElementPatchById } from '@src/renderer/editor/runtime/elementPatch'; +import { isSyntheticElementId } from '@src/renderer/editor/model/elementIdMap'; + +import type { NativeElementType } from '@src/renderer/editor/model/elementIdMap'; + +// 드래그 완료를 시작 시점 신원(id)으로 결합한다. 드래그 리스너의 프리즈된 +// 콜백이 들고 있는 index는 대기 중 재정렬·삭제를 모르므로, id가 있으면 +// applier가 완료 시점 (mode, index)를 다시 찾아 적용하고 삭제면 조용히 +// 중단한다. 무ID(합성 id) 요소만 기존 index 폴백을 쓴다 +export const commitElementPosition = ( + type: NativeElementType, + elementId: string | undefined, + dx: number, + dy: number, + fallback: () => void, +): void => { + if (elementId && !isSyntheticElementId(elementId)) { + void applyElementPatchById(type, elementId, () => ({ dx, dy })); + return; + } + fallback(); +}; diff --git a/src/renderer/hooks/Grid/useGridCanvasActions.ts b/src/renderer/hooks/Grid/useGridCanvasActions.ts index 7017273b..7b998a57 100644 --- a/src/renderer/hooks/Grid/useGridCanvasActions.ts +++ b/src/renderer/hooks/Grid/useGridCanvasActions.ts @@ -10,7 +10,7 @@ import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; import { reconcileSelectionAfterIndexedElementDeletion } from '@stores/grid/useGridSelectionStore'; -import type { KeyPosition } from '@src/types/key/keys'; +import type { KeySlot, KeyPosition } from '@src/types/key/keys'; import type { StatItemPosition, StatItemPositions, @@ -198,6 +198,8 @@ export interface CanvasActions { export interface DuplicateState { elementType: 'key' | 'stat' | 'graph' | 'knob'; sourceIndex: number; + // 키 복제의 시작 시점 동결 슬롯 - 배치 시 sourceIndex 재조회 금지 + slot?: KeySlot; keyName: string; position: | KeyPosition diff --git a/src/renderer/hooks/Grid/useGridSelection.ts b/src/renderer/hooks/Grid/useGridSelection.ts index 09e98e59..e279ef34 100644 --- a/src/renderer/hooks/Grid/useGridSelection.ts +++ b/src/renderer/hooks/Grid/useGridSelection.ts @@ -43,6 +43,8 @@ import { findPasteAnchorIndex, applyZIndexToLayerOrder, } from '@utils/layerGroupUtils'; +import { commitSelectedGeometryByIds } from '@src/renderer/editor/runtime/elementOps'; +import { isSyntheticElementId } from '@src/renderer/editor/model/elementIdMap'; import { editorCoordinator } from '@src/renderer/editor/runtime/editorStateCoordinator'; import { sendBridgeMessageBestEffort } from '@utils/plugin/bridgeMessages'; import { deletePluginElements } from '@plugins/rpc/pluginElementActions'; @@ -70,7 +72,10 @@ interface UseGridSelectionReturn { deleteSelectedElements: () => Promise; copySelectedElements: () => void; pasteElements: () => Promise; - syncSelectedElementsToOverlay: (gestureId?: string) => void; + syncSelectedElementsToOverlay: ( + gestureId?: string, + options?: { includeSize?: boolean }, + ) => void; clipboard: ClipboardItem[]; } @@ -92,7 +97,10 @@ export function useGridSelection({ // 선택된 요소들의 최종 위치를 한 번에 저장 // 커밋 base는 canonical - rendered에는 다른 세션의 미커밋 프리뷰가 섞일 수 있음 - const syncSelectedElementsToOverlay = (gestureId?: string) => { + const syncSelectedElementsToOverlay = ( + gestureId?: string, + options?: { includeSize?: boolean }, + ) => { const currentPositions = useKeyStore.getState().canonicalPositions; const currentStatPositions = useStatItemStore.getState().positions; const currentGraphPositions = useGraphItemStore.getState().positions; @@ -123,9 +131,32 @@ export function useGridSelection({ const isMixed = currentSelection.some((element) => element.type !== 'plugin') && pluginIds.length > 0; + // 안정 id native 선택은 기하 의도 커밋 - full-record 캡처는 배타 + // mutation 직후에 착지해 무관 필드 재작성을 되돌린다. 합성 id가 하나라도 + // 있으면 전체 legacy 폴백 (혼합 플러그인 트랜잭션도 기존 경로 유지) + const nativeTargets = currentSelection + .filter( + (element): element is (typeof currentSelection)[number] => + element.type !== 'plugin', + ) + .map((element) => ({ + type: element.type as 'key' | 'stat' | 'graph' | 'knob', + id: element.id, + })); + const allStableIds = + nativeTargets.length > 0 && + nativeTargets.every( + (target) => target.id.length > 0 && !isSyntheticElementId(target.id), + ); const persisted = gestureId && isMixed ? commitMixedGestureTransaction(gestureId, editorChanges, pluginIds) + : allStableIds + ? commitSelectedGeometryByIds( + nativeTargets, + gestureId, + options?.includeSize ? ['dx', 'dy', 'width', 'height'] : undefined, + ) : editorCoordinator.commitPatch( editorChanges, gestureId ? { gestureId } : undefined, diff --git a/src/renderer/hooks/useKeyManager.ts b/src/renderer/hooks/useKeyManager.ts index 4b9e50a0..7f866ab6 100644 --- a/src/renderer/hooks/useKeyManager.ts +++ b/src/renderer/hooks/useKeyManager.ts @@ -5,6 +5,7 @@ import { syncHistoryStatus, } from '@stores/data/useHistoryStatusStore'; import { historyApi } from '@api/modules/historyApi'; +import { rebindKeySlotById } from '@src/renderer/editor/runtime/elementOps'; import { reconcileSelectionAfterIndexedElementDeletion, useGridSelectionStore, @@ -523,6 +524,14 @@ export function useKeyManager() { // ──────────────────────────────────────────────────────────────────────── const handleKeyMappingChange = (index: number, newSlot: KeySlot) => { + // keys 단독 full-record 커밋은 same-shape 재정렬과 겹치면 다른 위치 + // id와 잘못 결합된다 - 위치 안정 id로 paired index를 재결합해 커밋 + const positionId = + useKeyStore.getState().canonicalPositions[selectedKeyType]?.[index]?.id; + if (positionId) { + void rebindKeySlotById(positionId, newSlot); + return; + } const updatedMappings = updateKeyMapping( keyMappings, selectedKeyType, From d5194529b417600b18209fdc941747b19d2f57ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 12 Aug 2026 16:33:25 +0900 Subject: [PATCH 23/35] =?UTF-8?q?fix:=20=EC=9A=94=EC=86=8C=20=EC=9D=98?= =?UTF-8?q?=EB=8F=84=20=EC=BB=A4=EB=B0=8B=20=EB=9F=AC=EB=84=88=EC=99=80=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EB=B3=B5=EC=9B=90=20=EC=86=8C=EC=9C=A0?= =?UTF-8?q?=EA=B6=8C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/previewOverlaySession.test.ts | 2 +- .../batch/BatchCounterTabContent.tsx | 3 +- .../batch/BatchSelectionPanel.tsx | 13 +- .../batch/BatchStyleTabContent.tsx | 3 +- .../batchPickerBindingOwnership.test.tsx | 7 +- .../single/CounterTabContent.tsx | 3 +- .../single/SingleSelectionPanel.tsx | 5 +- .../single/StyleTabContent.tsx | 5 +- .../editor/runtime/editGestureController.ts | 104 ++------ .../editor/runtime/editorCoordinator.ts | 18 +- .../editor/runtime/elementIntent.test.ts | 170 ++++++++++++ src/renderer/editor/runtime/elementIntent.ts | 243 ++++++++++++++++++ .../editor/runtime/elementPatch.test.ts | 66 ++++- src/renderer/editor/runtime/elementPatch.ts | 121 ++++----- .../hooks/Grid/elementPositionCommit.ts | 5 +- src/renderer/hooks/useKeyManager.ts | 3 +- 16 files changed, 594 insertions(+), 177 deletions(-) create mode 100644 src/renderer/editor/runtime/elementIntent.test.ts create mode 100644 src/renderer/editor/runtime/elementIntent.ts diff --git a/src/renderer/__tests__/previewOverlaySession.test.ts b/src/renderer/__tests__/previewOverlaySession.test.ts index 4a9f61ee..63490e76 100644 --- a/src/renderer/__tests__/previewOverlaySession.test.ts +++ b/src/renderer/__tests__/previewOverlaySession.test.ts @@ -538,7 +538,7 @@ describe('editGestureController', () => { // wire는 슬롯 안에서 최신 base로 재생성 - 호출 시점 full-record 금지 expect(commitPatchMock).not.toHaveBeenCalled(); expect(commitGeneratedPatchMock).toHaveBeenCalledOnce(); - expect(commitGeneratedPatchMock.mock.calls[0][1]).toEqual({ + expect(commitGeneratedPatchMock.mock.calls[0][1]).toMatchObject({ gestureId: sessionId, }); const patch = generatedPatches[0] as { diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchCounterTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchCounterTabContent.tsx index 4699c881..a8a4f2a5 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchCounterTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchCounterTabContent.tsx @@ -4,6 +4,7 @@ import type { KeyCounterSettings } from '@src/types/key/keys'; import { normalizeCounterSettings } from '@src/types/key/keys'; import { applyAnimationIntentMask } from '@src/types/key/counterAnimation'; import { applyElementPatchesById } from '@src/renderer/editor/runtime/elementPatch'; +import { reportElementOpError } from '@src/renderer/editor/runtime/elementIntent'; import { LEGACY_BATCH_ELEMENT_BINDING, type BatchElementBinding, @@ -92,7 +93,7 @@ const BatchCounterTabContent: React.FC = ({ ), }, }; - }); + }).catch(reportElementOpError); return; } handleBatchCounterUpdate({ animation: nextAnimation }); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx index 7689966a..9ed1def1 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx @@ -36,6 +36,7 @@ import PopupExit from '@components/main/Modal/PopupExit'; import ImagePicker from '@components/main/Modal/content/pickers/ImagePicker'; import EditSessionBoundary from '../EditSessionBoundary'; import { applyElementPatchesById } from '@src/renderer/editor/runtime/elementPatch'; +import { reportElementOpError } from '@src/renderer/editor/runtime/elementIntent'; import { captureBatchElementBinding, useBatchElementBinding, @@ -1037,7 +1038,7 @@ export const BatchKeyLikePanel: React.FC = ({ if (batchImageBinding.binding === 'element-id') { applyElementPatchesById(batchImageBinding.selection, () => ({ inactiveImage: imageUrl, - })); + })).catch(reportElementOpError); return; } handleBatchStyleChangeComplete('inactiveImage', imageUrl); @@ -1051,7 +1052,7 @@ export const BatchKeyLikePanel: React.FC = ({ knob: batchImageBinding.selection.knob, }, () => ({ activeImage: imageUrl }), - ); + ).catch(reportElementOpError); return; } handleActiveCapableStyleChangeComplete('activeImage', imageUrl); @@ -1444,7 +1445,7 @@ export const BatchGraphOnlyPanel: React.FC = ({ if (graphImageBinding.binding === 'element-id') { applyElementPatchesById(graphImageBinding.selection, () => ({ inactiveImage: imageUrl, - })); + })).catch(reportElementOpError); return; } handleGraphBatchSharedSetting({ inactiveImage: imageUrl }); @@ -1453,7 +1454,7 @@ export const BatchGraphOnlyPanel: React.FC = ({ if (graphImageBinding.binding === 'element-id') { applyElementPatchesById(graphImageBinding.selection, () => ({ activeImage: imageUrl, - })); + })).catch(reportElementOpError); return; } handleGraphBatchSharedSetting({ activeImage: imageUrl }); @@ -1762,7 +1763,7 @@ export const BatchKnobOnlyPanel: React.FC = ({ if (knobImageBinding.binding === 'element-id') { applyElementPatchesById(knobImageBinding.selection, () => ({ inactiveImage: imageUrl, - })); + })).catch(reportElementOpError); return; } handleKnobBatchSharedSetting({ inactiveImage: imageUrl }); @@ -1771,7 +1772,7 @@ export const BatchKnobOnlyPanel: React.FC = ({ if (knobImageBinding.binding === 'element-id') { applyElementPatchesById(knobImageBinding.selection, () => ({ activeImage: imageUrl, - })); + })).catch(reportElementOpError); return; } handleKnobBatchSharedSetting({ activeImage: imageUrl }); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchStyleTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchStyleTabContent.tsx index 1bebb8b6..8c972b39 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchStyleTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchStyleTabContent.tsx @@ -29,6 +29,7 @@ import { import FontPicker from '@components/main/Modal/content/pickers/FontPicker'; import SoundPicker from '@components/main/Modal/content/pickers/SoundPicker'; import { applyElementPatchesById } from '@src/renderer/editor/runtime/elementPatch'; +import { reportElementOpError } from '@src/renderer/editor/runtime/elementIntent'; import { LEGACY_BATCH_ELEMENT_BINDING, type BatchElementBinding, @@ -1300,7 +1301,7 @@ const BatchStyleTabContent: React.FC = ({ if (soundBinding.binding === 'element-id') { applyElementPatchesById(soundBinding.selection, () => ({ soundPath: nextPath, - })); + })).catch(reportElementOpError); return; } ( 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 c406ac22..25b83a33 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/batchPickerBindingOwnership.test.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/batchPickerBindingOwnership.test.tsx @@ -16,11 +16,14 @@ const captured = vi.hoisted(() => ({ })); const patches = vi.hoisted(() => ({ - applyElementPatchesById: vi.fn(() => 1), - applyElementPatchById: vi.fn(() => true), + applyElementPatchesById: vi.fn(async () => 1), + applyElementPatchById: vi.fn(async () => true), })); vi.mock('@src/renderer/editor/runtime/elementPatch', () => patches); +vi.mock('@src/renderer/editor/runtime/elementIntent', () => ({ + reportElementOpError: vi.fn(), +})); vi.mock('@contexts/useTranslation', () => ({ useTranslation: () => ({ t: (key: string) => key }), })); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/CounterTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/CounterTabContent.tsx index 156f6af8..2fc70106 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/CounterTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/CounterTabContent.tsx @@ -25,6 +25,7 @@ import { DEFAULT_COUNTER_FONT_SIZE } from '@utils/core/elementDefaults'; import { useGradientColorState } from '@hooks/pickers/useGradientColorState'; import { useKeyStore } from '@stores/data/useKeyStore'; import { applyElementPatchById } from '@src/renderer/editor/runtime/elementPatch'; +import { reportElementOpError } from '@src/renderer/editor/runtime/elementIntent'; import { mergeChangedAnimationFields } from '@src/types/key/counterAnimation'; import { counterFillPair, @@ -132,7 +133,7 @@ const CounterTabContent: React.FC = ({ ), }, }; - }); + }).catch(reportElementOpError); }; const handlePickerToggle = (target: Exclude) => { diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx index 2b099755..d89fc7da 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx @@ -1,6 +1,7 @@ /* eslint-disable react-hooks/refs */ import React, { useEffect, useRef, useState } from 'react'; import { applyElementPatchById } from '@src/renderer/editor/runtime/elementPatch'; +import { reportElementOpError } from '@src/renderer/editor/runtime/elementIntent'; import type { ImageFit, KeyPosition, KeySlot } from '@src/types/key/keys'; import { STAT_BASE_OPTIONS, @@ -358,7 +359,7 @@ export const SingleGraphPanel: React.FC = ({ handleGraphUpdate({ index: singleGraphIndex, ...patch }); return; } - applyElementPatchById('graph', id, () => patch); + applyElementPatchById('graph', id, () => patch).catch(reportElementOpError); }; const graphShapeOptions = [ @@ -852,7 +853,7 @@ export const SingleKnobPanel: React.FC = ({ handleKnobUpdate({ index: singleKnobIndex, ...patch }); return; } - applyElementPatchById('knob', id, () => patch); + applyElementPatchById('knob', id, () => patch).catch(reportElementOpError); }; const panelRef = useRef(null); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/StyleTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/StyleTabContent.tsx index 628b8f9a..3fdb476a 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/StyleTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/StyleTabContent.tsx @@ -24,6 +24,7 @@ import { usePanelNav } from '../PanelNavContext'; import { useKeyStore } from '@stores/data/useKeyStore'; import { resolveElementByIdAcross } from '@src/renderer/editor/model/elementIdMap'; import { applyElementPatchById } from '@src/renderer/editor/runtime/elementPatch'; +import { reportElementOpError } from '@src/renderer/editor/runtime/elementIntent'; import ImagePicker from '../../../Modal/content/pickers/ImagePicker'; import ColorPicker from '../../../Modal/content/pickers/ColorPicker'; import PopupExit from '@components/main/Modal/PopupExit'; @@ -601,7 +602,9 @@ const StyleTabContent: React.FC = ({ } // id가 있는데 시작 시점 조회가 실패했으면 옛 index 폴백 대신 중단 if (!boundElementType) return; - applyElementPatchById(boundElementType, id, () => patch); + applyElementPatchById(boundElementType, id, () => patch).catch( + reportElementOpError, + ); }; // 이미지 변경 핸들러 diff --git a/src/renderer/editor/runtime/editGestureController.ts b/src/renderer/editor/runtime/editGestureController.ts index b64a43e9..c9ee21ef 100644 --- a/src/renderer/editor/runtime/editGestureController.ts +++ b/src/renderer/editor/runtime/editGestureController.ts @@ -10,12 +10,16 @@ import { useKeyStore } from '@stores/data/useKeyStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; -import type { EditorPatchV1 } from '@src/types/editor'; import { PREVIEW_SCHEMA_VERSION, type PreviewDomain } from '@src/types/preview'; import { previewOverlay } from './previewOverlay'; -import { enqueueEditorCompatibilityOperation } from './editorCompatibilityQueue'; -import { editorCoordinator } from './editorStateCoordinator'; +import { + applyPropertyIntentsEagerly, + generatePropertyIntentPatch, + intentPatch, + runElementIntent, + type PropertyIntents, +} from './elementIntent'; import { drainEditorWrites, trackEditorWrite } from './editorWriteBarrier'; import { getEditSessionTarget } from './editSessionTarget'; import { @@ -57,16 +61,6 @@ type PositionsRecordLike = Record< Array<{ id?: string } & Record> >; -const DOMAIN_FIELDS: Record< - PreviewDomain, - 'keyPositions' | 'statPositions' | 'graphPositions' | 'knobPositions' -> = { - keyPosition: 'keyPositions', - statPosition: 'statPositions', - graphPosition: 'graphPositions', - knobPosition: 'knobPositions', -}; - const authorityRecordFor = (domain: PreviewDomain): PositionsRecordLike => (domain === 'keyPosition' ? useKeyStore.getState().canonicalPositions @@ -76,21 +70,6 @@ const authorityRecordFor = (domain: PreviewDomain): PositionsRecordLike => ? useGraphItemStore.getState().positions : useKnobItemStore.getState().positions) as PositionsRecordLike; -const writeAuthorityRecord = ( - domain: PreviewDomain, - next: PositionsRecordLike, -): void => { - if (domain === 'keyPosition') { - useKeyStore.getState().setPositions(next as never); - } else if (domain === 'statPosition') { - useStatItemStore.getState().setPositions(next as never); - } else if (domain === 'graphPosition') { - useGraphItemStore.getState().setPositions(next as never); - } else { - useKnobItemStore.getState().setPositions(next as never); - } -}; - const INDEX_SENTINEL = 'index:'; // 프리뷰 시점 index가 아직 뜨거울 때 id로 승격 @@ -105,24 +84,6 @@ const intentKeyFor = ( : `${INDEX_SENTINEL}${index}`; }; -// resolved id 집합을 record 전 모드에서 찾아 patch 병합 (id 불변) -const mergeIntentRecord = ( - record: PositionsRecordLike, - resolved: ReadonlyMap>, -): { next: PositionsRecordLike; touched: number } => { - let touched = 0; - const next: PositionsRecordLike = {}; - for (const [mode, list] of Object.entries(record)) { - next[mode] = list.map((position) => { - const id = position.id; - if (typeof id !== 'string' || !resolved.has(id)) return position; - touched += 1; - return { ...position, ...resolved.get(id), id }; - }); - } - return { next, touched }; -}; - let active: ActiveGesture | null = null; const schedulePublishFlush = () => { @@ -366,37 +327,28 @@ export const editGestureController = { return drainEditorWrites(); } - // eager 반영 - 이후의 full-record 캡처가 이 값을 포함해 자가 치유 - for (const [domain, resolved] of intents) { - const merged = mergeIntentRecord(authorityRecordFor(domain), resolved); - if (merged.touched > 0) writeAuthorityRecord(domain, merged.next); - } - - // wire는 직렬 슬롯 안에서 최신 base로 재생성한다. 호출 시점 full-record는 - // 대기 중 정산된 다른 커밋(격리 플러그인 등)의 값을 통째로 되돌린다. - // elementPatch applier는 rejection을 소비하므로 재사용 금지 - 정산은 - // 거절되는 원 promise가 필요하다 - const persisted = enqueueEditorCompatibilityOperation(() => - editorCoordinator.commitGeneratedPatch( - (base) => { - const changes: EditorPatchV1 = { schemaVersion: 1 }; - let hasChanges = false; - for (const [domain, resolved] of intents) { - const field = DOMAIN_FIELDS[domain]; - const merged = mergeIntentRecord( - base[field] as PositionsRecordLike, - resolved, - ); - if (merged.touched > 0) { - changes[field] = merged.next as never; - hasChanges = true; - } - } - return hasChanges ? changes : null; - }, - { gestureId: gesture.sessionId }, - ), + // (domain, id) 의도를 (type, id) 속성 의도로 변환 - eager 낙관과 실패 + // 복원(편입 전·대상 소실)은 runElementIntent가 소유하고, wire는 직렬 + // 슬롯 안에서 최신 base로 재생성된다. 정산에는 거절되는 원 promise가 + // 필요하므로 오류를 삼키는 applier 경로는 쓰지 않는다 + const DOMAIN_TO_TYPE = { + keyPosition: 'key', + statPosition: 'stat', + graphPosition: 'graph', + knobPosition: 'knob', + } as const; + const propertyIntents: PropertyIntents = new Map( + [...intents].map(([domain, resolved]) => [ + DOMAIN_TO_TYPE[domain], + resolved, + ]), ); + const persisted = runElementIntent({ + applyEager: () => applyPropertyIntentsEagerly(propertyIntents), + generate: (base) => + intentPatch(generatePropertyIntentPatch(base, propertyIntents)), + gestureId: gesture.sessionId, + }); this.settleCommit(persisted); const own = await persisted.then( () => true, diff --git a/src/renderer/editor/runtime/editorCoordinator.ts b/src/renderer/editor/runtime/editorCoordinator.ts index 4266f64f..d2773653 100644 --- a/src/renderer/editor/runtime/editorCoordinator.ts +++ b/src/renderer/editor/runtime/editorCoordinator.ts @@ -355,6 +355,7 @@ export class EditorSaveCoordinator { newIntentFields: readonly EditorField[], requestFields: readonly EditorField[], gestureId?: string, + onEnrolled?: () => void, ): Promise { if (gestureId) { this.replacePendingGestureIds([...this.pendingGestureIds, gestureId]); @@ -371,6 +372,8 @@ export class EditorSaveCoordinator { conflict.localFields.includes(field) || newlyChangedFields.includes(field), ); + // conflict pendingLocal에 실제 편입 완료 - keepLocal 해소가 소유 + onEnrolled?.(); this.notify(); return Promise.reject(this.error ?? new Error('editor conflict pending')); } @@ -391,6 +394,8 @@ export class EditorSaveCoordinator { this.pendingRequestFields = EDITOR_FIELDS.filter((field) => requested.has(field), ); + // pending에 실제 편입 완료 - 이후 실패는 재시도·거절 경로가 소유 + onEnrolled?.(); this.error = null; this.failureKind = null; this.notify(); @@ -413,6 +418,7 @@ export class EditorSaveCoordinator { private commitPatchSettled( changes: EditorPatchV1, gestureId?: string, + onEnrolled?: () => void, ): Promise { // gradient canonical 정규화를 assert 앞에 — optimistic·diff·invoke가 같은 값 사용 const canonicalChanges = canonicalizeEditorGradients(changes); @@ -440,6 +446,7 @@ export class EditorSaveCoordinator { newIntentFields, requestFields, gestureId, + onEnrolled, ); } @@ -449,7 +456,7 @@ export class EditorSaveCoordinator { // (mutation·낙관 적용·revision 전진 전부 없음) commitGeneratedPatch( generate: (base: EditorDocumentV1) => EditorPatchV1 | null, - meta?: { gestureId?: string }, + meta?: { gestureId?: string; onEnrolled?: () => void }, ): Promise { this.assertWritable(); return this.enqueueSerialized(async () => { @@ -458,7 +465,14 @@ export class EditorSaveCoordinator { await this.eventQueue; const changes = generate(this.getLatestCommitBase()); if (!changes) return clone(this.requireLastAck()); - return this.commitPatchSettled(changes, meta?.gestureId); + // onEnrolled는 pending/conflict에 실제 편입된 직후 발화 - 호출자의 + // 롤백 판별 기준. 편입 전 종료(사전 실패·생성 예외·검증 실패)는 + // 어떤 기존 복원 경로도 이 intent를 소유하지 않는다 + return this.commitPatchSettled( + changes, + meta?.gestureId, + meta?.onEnrolled, + ); }); } diff --git a/src/renderer/editor/runtime/elementIntent.test.ts b/src/renderer/editor/runtime/elementIntent.test.ts new file mode 100644 index 00000000..909cc978 --- /dev/null +++ b/src/renderer/editor/runtime/elementIntent.test.ts @@ -0,0 +1,170 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createDefaultKeyPosition } from '../model/keys'; + +const api = vi.hoisted(() => ({ + commitGeneratedPatch: vi.fn(), +})); + +vi.mock('./editorStateCoordinator', () => ({ + editorCoordinator: { commitGeneratedPatch: api.commitGeneratedPatch }, +})); + +import { useKeyStore } from '@stores/data/useKeyStore'; +import { + applyPropertyIntentsEagerly, + intentPatch, + runElementIntent, +} from './elementIntent'; + +import type { EditorDocumentV1, EditorPatchV1 } from '@src/types/editor'; +import type { NativeElementType } from '../model/elementIdMap'; + +const ID_A = '11111111-1111-4111-8111-111111111111'; + +const keyAt = (id: string) => ({ ...createDefaultKeyPosition(), id }); + +const intentsFor = ( + patch: Record, +): Map>> => + new Map([['key' as NativeElementType, new Map([[ID_A, patch]])]]); + +describe('elementIntent', () => { + beforeEach(() => { + vi.clearAllMocks(); + useKeyStore.setState({ + selectedKeyType: '4key', + canonicalPositions: { '4key': [keyAt(ID_A)] }, + positions: { '4key': [keyAt(ID_A)] }, + }); + }); + + it('속성 receipt는 필드 단위로 복원한다', () => { + const receipt = applyPropertyIntentsEagerly( + intentsFor({ inactiveImage: 'eager.png' }), + ); + expect( + useKeyStore.getState().canonicalPositions['4key'][0].inactiveImage, + ).toBe('eager.png'); + + receipt!.rollback(); + + expect( + useKeyStore.getState().canonicalPositions['4key'][0].inactiveImage ?? '', + ).toBe(''); + }); + + it('CAS: 이후 다른 writer가 같은 필드를 바꿨으면 복원하지 않는다', () => { + const receipt = applyPropertyIntentsEagerly( + intentsFor({ inactiveImage: 'eager.png' }), + ); + // 다른 writer가 같은 필드를 다른 값으로 + const state = useKeyStore.getState(); + state.setPositions({ + '4key': [ + { ...state.canonicalPositions['4key'][0], inactiveImage: 'newer.png' }, + ], + } as never); + + receipt!.rollback(); + + // 소유권 밖 - 그대로 유지 + expect( + useKeyStore.getState().canonicalPositions['4key'][0].inactiveImage, + ).toBe('newer.png'); + }); + + it('CAS: 다른 필드만 바뀌었으면 소유 필드는 복원한다', () => { + const receipt = applyPropertyIntentsEagerly( + intentsFor({ inactiveImage: 'eager.png' }), + ); + const state = useKeyStore.getState(); + state.setPositions({ + '4key': [ + { + ...state.canonicalPositions['4key'][0], + noteWidth: 222, + }, + ], + } as never); + + receipt!.rollback(); + + const position = useKeyStore.getState().canonicalPositions['4key'][0]; + expect(position.inactiveImage ?? '').toBe(''); + expect(position.noteWidth).toBe(222); + }); + + it('편입 후 실패는 receipt를 호출하지 않는다', async () => { + const rollback = vi.fn(); + api.commitGeneratedPatch.mockImplementation( + async ( + generate: (base: EditorDocumentV1) => EditorPatchV1 | null, + meta?: { onEnrolled?: () => void }, + ) => { + generate({} as EditorDocumentV1); + meta?.onEnrolled?.(); + throw new Error('after enrollment'); + }, + ); + + await expect( + runElementIntent({ + applyEager: () => ({ rollback }), + generate: () => intentPatch({ schemaVersion: 1 }), + }), + ).rejects.toThrow('after enrollment'); + + expect(rollback).not.toHaveBeenCalled(); + }); + + it('편입 전 실패는 receipt를 호출하고 원 오류를 전파한다', async () => { + const rollback = vi.fn(); + api.commitGeneratedPatch.mockRejectedValue(new Error('start failed')); + + await expect( + runElementIntent({ + applyEager: () => ({ rollback }), + generate: () => intentPatch({ schemaVersion: 1 }), + }), + ).rejects.toThrow('start failed'); + + expect(rollback).toHaveBeenCalledTimes(1); + }); + + it('satisfied(이미 canonical 달성)는 롤백하지 않는다', async () => { + const rollback = vi.fn(); + api.commitGeneratedPatch.mockImplementation( + async (generate: (base: EditorDocumentV1) => EditorPatchV1 | null) => { + generate({} as EditorDocumentV1); + return {} as EditorDocumentV1; + }, + ); + + const result = await runElementIntent({ + applyEager: () => ({ rollback }), + generate: () => ({ kind: 'satisfied' }), + }); + + expect(result.committed).toBe(false); + expect(result.satisfied).toBe(true); + expect(rollback).not.toHaveBeenCalled(); + }); + + it('대상 소실(null)은 receipt 호출 후 committed false', async () => { + const rollback = vi.fn(); + api.commitGeneratedPatch.mockImplementation( + async (generate: (base: EditorDocumentV1) => EditorPatchV1 | null) => { + generate({} as EditorDocumentV1); + return {} as EditorDocumentV1; + }, + ); + + const result = await runElementIntent({ + applyEager: () => ({ rollback }), + generate: () => intentPatch(null), + }); + + expect(result.committed).toBe(false); + expect(rollback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/renderer/editor/runtime/elementIntent.ts b/src/renderer/editor/runtime/elementIntent.ts new file mode 100644 index 00000000..daadda84 --- /dev/null +++ b/src/renderer/editor/runtime/elementIntent.ts @@ -0,0 +1,243 @@ +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 { enqueueEditorCompatibilityOperation } from './editorCompatibilityQueue'; +import { editorCoordinator } from './editorStateCoordinator'; + +import type { EditorDocumentV1, EditorPatchV1 } from '@src/types/editor'; + +import type { NativeElementType } from '../model/elementIdMap'; + +// 요소 의도 커밋 러너: eager 낙관 반영과 wire 생성·실패 복원의 소유권을 +// 한 곳에 고정한다. +// +// 롤백 판별은 오류의 retryable 여부가 아니라 intent의 편입 단계다: +// - notGenerated(선행 pending drain 실패, start 실패, generator·검증 throw): +// 어떤 기존 복원 경로도 이 intent를 모른다 - receipt로 즉시 복원 +// - generatedNull(대상 소실): 커밋·이벤트가 없어 eager가 잔존한다 - 복원 +// - 편입 후: transient는 pendingLocal 재시도가, 영구 거절은 +// discardRejectedPending의 lastAck 전체 적용이 소유한다 - 복원 금지 +// +// 한계(잔여 기록): legacy full-record writer가 eager 값을 사전 캡처해 두면 +// 복원 후 재커밋으로 부활할 수 있다. 보증은 "즉시 로컬 복원"까지다 + +export interface ElementIntentReceipt { + rollback: () => void; +} + +// 생성 결과 3상태: patch = 커밋할 변경 / satisfied = 의도가 이미 canonical에 +// 반영됨(다른 writer가 먼저 달성 - 롤백하면 lastAck와 반대로 발산) / +// targetLost = 대상 소실(커밋·이벤트가 없어 eager 잔존 - 복원) +export type ElementIntentGeneration = + | { kind: 'patch'; patch: EditorPatchV1 } + | { kind: 'satisfied' } + | { kind: 'targetLost' }; + +export const intentPatch = ( + patch: EditorPatchV1 | null, +): ElementIntentGeneration => + patch === null ? { kind: 'targetLost' } : { kind: 'patch', patch }; + +export interface ElementIntentResult { + committed: boolean; + // satisfied = 커밋 없이 의도 달성 (호출자 성공 판정용) + satisfied: boolean; + document: EditorDocumentV1 | null; +} + +export const runElementIntent = async (options: { + applyEager: () => ElementIntentReceipt | null; + generate: (base: EditorDocumentV1) => ElementIntentGeneration; + gestureId?: string; +}): Promise => { + const receipt = options.applyEager(); + let enrolled = false; + let lastKind: ElementIntentGeneration['kind'] | null = null; + try { + const document = await enqueueEditorCompatibilityOperation(() => + editorCoordinator.commitGeneratedPatch( + (base) => { + const generation = options.generate(base); + lastKind = generation.kind; + return generation.kind === 'patch' ? generation.patch : null; + }, + { + ...(options.gestureId ? { gestureId: options.gestureId } : {}), + onEnrolled: () => { + enrolled = true; + }, + }, + ), + ); + if (lastKind === 'targetLost') { + receipt?.rollback(); + return { committed: false, satisfied: false, document: null }; + } + if (lastKind === 'satisfied') { + return { committed: false, satisfied: true, document: null }; + } + return { committed: true, satisfied: true, document }; + } catch (error) { + if (!enrolled) receipt?.rollback(); + throw error; + } +}; + +// --------------------------------------------------------------------------- +// 속성 의도 receipt: (type, id)별 필드 patch를 eager 적용하고, 필드 단위 +// before/expected를 기록해 CAS 복원한다. 이후 다른 writer가 같은 필드를 +// 다른 값으로 바꿨으면 그 필드는 소유권 밖이라 건드리지 않는다 +// --------------------------------------------------------------------------- + +type LooseRecord = Record< + string, + Array<{ id?: string } & Record> +>; + +export type PropertyIntents = ReadonlyMap< + NativeElementType, + ReadonlyMap> +>; + +const readRecord = (type: NativeElementType): LooseRecord => + (type === 'key' + ? useKeyStore.getState().canonicalPositions + : type === 'stat' + ? useStatItemStore.getState().positions + : type === 'graph' + ? useGraphItemStore.getState().positions + : useKnobItemStore.getState().positions) as LooseRecord; + +const writeRecord = (type: NativeElementType, next: LooseRecord): void => { + if (type === 'key') { + useKeyStore.getState().setPositions(next as never); + } else if (type === 'stat') { + useStatItemStore.getState().setPositions(next as never); + } else if (type === 'graph') { + useGraphItemStore.getState().setPositions(next as never); + } else { + useKnobItemStore.getState().setPositions(next as never); + } +}; + +interface PropertyReceiptEntry { + type: NativeElementType; + id: string; + field: string; + before: unknown; + expected: unknown; +} + +export const applyPropertyIntentsEagerly = ( + intents: PropertyIntents, +): ElementIntentReceipt | null => { + const entries: PropertyReceiptEntry[] = []; + + for (const [type, byId] of intents) { + const record = readRecord(type); + let touched = false; + const next: LooseRecord = {}; + for (const [mode, list] of Object.entries(record)) { + next[mode] = list.map((position) => { + const id = position.id; + if (typeof id !== 'string') return position; + const patch = byId.get(id); + if (!patch) return position; + touched = true; + for (const [field, expected] of Object.entries(patch)) { + entries.push({ + type, + id, + field, + before: position[field], + expected, + }); + } + return { ...position, ...patch, id }; + }); + } + if (touched) writeRecord(type, next); + } + + if (entries.length === 0) return null; + return { + rollback: () => { + const byType = new Map(); + for (const entry of entries) { + const group = byType.get(entry.type) ?? []; + group.push(entry); + byType.set(entry.type, group); + } + for (const [type, group] of byType) { + const record = readRecord(type); + let touched = false; + const next: LooseRecord = {}; + for (const [mode, list] of Object.entries(record)) { + next[mode] = list.map((position) => { + const id = position.id; + if (typeof id !== 'string') return position; + const owned = group.filter((entry) => entry.id === id); + if (owned.length === 0) return position; + let restored = position; + for (const entry of owned) { + // CAS: 우리가 쓴 값 그대로일 때만 복원 + if (restored[entry.field] !== entry.expected) continue; + touched = true; + restored = { ...restored, [entry.field]: entry.before }; + } + return restored; + }); + } + if (touched) writeRecord(type, next); + } + }, + }; +}; + +// 최신 base에서 속성 의도를 재적용하는 표준 generator +export const generatePropertyIntentPatch = ( + base: EditorDocumentV1, + intents: PropertyIntents, +): EditorPatchV1 | null => { + const FIELD_BY_TYPE: Record< + NativeElementType, + 'keyPositions' | 'statPositions' | 'graphPositions' | 'knobPositions' + > = { + key: 'keyPositions', + stat: 'statPositions', + graph: 'graphPositions', + knob: 'knobPositions', + }; + const patch: EditorPatchV1 = { schemaVersion: 1 }; + let touchedAny = false; + for (const [type, byId] of intents) { + const field = FIELD_BY_TYPE[type]; + const record = base[field] as unknown as LooseRecord; + let touched = 0; + const next: LooseRecord = {}; + for (const [mode, list] of Object.entries(record)) { + next[mode] = list.map((position) => { + const id = position.id; + if (typeof id !== 'string') return position; + const intentPatch = byId.get(id); + if (!intentPatch) return position; + touched += 1; + return { ...position, ...intentPatch, id }; + }); + } + if (touched > 0) { + patch[field] = next as never; + touchedAny = true; + } + } + return touchedAny ? patch : null; +}; + +// UI fire-and-forget 경계용: 상태 정합은 receipt·pending 경로가 소유하므로 +// 호출부는 기록만 한다. 오류를 성공으로 둔갑시키던 내부 삼킴과 달리 +// 프로그램적 호출자는 원 promise로 실패를 받을 수 있다 +export const reportElementOpError = (error: unknown): void => { + console.error('Element operation failed', error); +}; diff --git a/src/renderer/editor/runtime/elementPatch.test.ts b/src/renderer/editor/runtime/elementPatch.test.ts index 37e0298c..fc1523b1 100644 --- a/src/renderer/editor/runtime/elementPatch.test.ts +++ b/src/renderer/editor/runtime/elementPatch.test.ts @@ -78,10 +78,14 @@ describe('applyElementPatchById', () => { slotBase = null; generatedPatches.length = 0; api.commitGeneratedPatch.mockImplementation( - async (generate: (base: EditorDocumentV1) => EditorPatchV1 | null) => { + async ( + generate: (base: EditorDocumentV1) => EditorPatchV1 | null, + meta?: { onEnrolled?: () => void }, + ) => { const base = (slotBase ?? documentFromStores)(); const patch = generate(base); generatedPatches.push(patch); + if (patch !== null) meta?.onEnrolled?.(); return base; }, ); @@ -323,24 +327,66 @@ describe('applyElementPatchById', () => { expect(api.commitGeneratedPatch).toHaveBeenCalledOnce(); }); - it('커밋 실패는 내부에서 소비하고 대상 수를 반환한다', async () => { + it('편입 전 실패는 eager를 복원하고 원 오류로 reject한다', async () => { + // 편입(onEnrolled) 없이 실패 - 어떤 기존 복원 경로도 소유하지 않으므로 + // receipt가 즉시 복원한다 api.commitGeneratedPatch.mockImplementation( async (generate: (base: EditorDocumentV1) => EditorPatchV1 | null) => { generate(documentFromStores()); - throw new Error('commit failed'); + throw new Error('pre-enrollment failed'); + }, + ); + + await expect( + applyElementPatchesById({ key: [ID_A] }, () => ({ + inactiveImage: 'failed.png', + })), + ).rejects.toThrow('pre-enrollment failed'); + + expect( + useKeyStore.getState().canonicalPositions['4key'][0].inactiveImage ?? '', + ).toBe(''); + }); + + it('편입 후 실패는 eager를 유지하고 reject한다 - pending 재시도가 소유', async () => { + api.commitGeneratedPatch.mockImplementation( + async ( + generate: (base: EditorDocumentV1) => EditorPatchV1 | null, + meta?: { onEnrolled?: () => void }, + ) => { + generate(documentFromStores()); + meta?.onEnrolled?.(); + throw new Error('transient after enrollment'); }, ); - const errorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => undefined); + + await expect( + applyElementPatchesById({ key: [ID_A] }, () => ({ + inactiveImage: 'kept.png', + })), + ).rejects.toThrow('transient after enrollment'); + + expect( + useKeyStore.getState().canonicalPositions['4key'][0].inactiveImage, + ).toBe('kept.png'); + }); + + it('대상 소실(generator null)은 eager를 복원한다', async () => { + slotBase = () => { + const base = documentFromStores(); + base.keyPositions = { '4key': [keyAt(ID_B)] } as never; + return base; + }; const applied = await applyElementPatchesById({ key: [ID_A] }, () => ({ - inactiveImage: 'failed.png', + inactiveImage: 'lost.png', })); - expect(applied).toBe(1); - expect(errorSpy).toHaveBeenCalled(); - errorSpy.mockRestore(); + expect(applied).toBe(0); + // eager로 썼던 값이 receipt로 복원됨 + expect( + useKeyStore.getState().canonicalPositions['4key'][0].inactiveImage ?? '', + ).toBe(''); }); it('활성 게스처를 정산하지 않는다', async () => { diff --git a/src/renderer/editor/runtime/elementPatch.ts b/src/renderer/editor/runtime/elementPatch.ts index 07a67db2..fd45b3c9 100644 --- a/src/renderer/editor/runtime/elementPatch.ts +++ b/src/renderer/editor/runtime/elementPatch.ts @@ -4,8 +4,12 @@ import { useKnobItemStore } from '@stores/data/useKnobItemStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { resolveElementById } from '../model/elementIdMap'; -import { enqueueEditorCompatibilityWrite } from './editorCompatibilityQueue'; -import { editorCoordinator } from './editorStateCoordinator'; +import { + applyPropertyIntentsEagerly, + intentPatch, + runElementIntent, + type PropertyIntents, +} from './elementIntent'; import type { EditorDocumentV1, EditorPatchV1 } from '@src/types/editor'; @@ -73,64 +77,44 @@ const selectedIdSet = ( return wanted.size > 0 ? wanted : null; }; -// 클릭 시점 즉시 반영. 신원 해석은 현재 스토어 기준 - 이후 재정렬·삭제는 -// wire 생성 단계가 최신 문서에서 다시 해석한다 -const eagerRecordFor = ( - positions: Record, - wanted: ReadonlySet, - type: NativeElementType, - updater: ElementPatchUpdater, -): Record | null => { - const targets = new Map>(); - for (const id of wanted) { - const locator = resolveElementById(type, id); - if (!locator) continue; - const indices = targets.get(locator.mode) ?? new Set(); - indices.add(locator.index); - targets.set(locator.mode, indices); - } - if (targets.size === 0) return null; - const next = { ...positions }; - for (const [mode, indices] of targets) { - const list = next[mode]; - if (!list) continue; - next[mode] = list.map((position, index) => - indices.has(index) ? mergePosition(position, updater) : position, - ); - } - return next; -}; - -const applyEagerly = ( +// 클릭 시점 낙관 의도: 현재 스토어에서 id를 해석해 updater 출력을 필드 +// 의도로 동결한다 (receipt CAS 복원의 before/expected 기준). +// wire 생성은 슬롯에서 updater를 다시 실행하므로 이 동결과 별개다 +const buildEagerIntents = ( selection: ElementIdSelection, updater: ElementPatchUpdater, -): void => { +): PropertyIntents => { + const intents = new Map< + NativeElementType, + Map> + >(); for (const type of NATIVE_ELEMENT_TYPES) { const wanted = selectedIdSet(selection, type); if (!wanted) continue; - if (type === 'key') { - const state = useKeyStore.getState(); - const next = eagerRecordFor( - state.canonicalPositions, - wanted, - 'key', - updater, - ); - if (next) state.setPositions(next); - } else if (type === 'stat') { - const state = useStatItemStore.getState(); - const next = eagerRecordFor(state.positions, wanted, 'stat', updater); - if (next) state.setPositions(next); - } else if (type === 'graph') { - const state = useGraphItemStore.getState(); - const next = eagerRecordFor(state.positions, wanted, 'graph', updater); - if (next) state.setPositions(next); - } else { - const state = useKnobItemStore.getState(); - const next = eagerRecordFor(state.positions, wanted, 'knob', updater); - if (next) state.setPositions(next); + const record = ( + type === 'key' + ? useKeyStore.getState().canonicalPositions + : type === 'stat' + ? useStatItemStore.getState().positions + : type === 'graph' + ? useGraphItemStore.getState().positions + : useKnobItemStore.getState().positions + ) as Record>; + for (const id of wanted) { + const locator = resolveElementById(type, id); + if (!locator) continue; + const current = record[locator.mode]?.[locator.index]; + if (!current) continue; + // 스토어 원본을 updater에 직접 주지 않는다 - 입력 변조 방어 + const { id: _ignored, ...patch } = { + ...updater({ ...current }), + } as Record; + const byId = intents.get(type) ?? new Map(); + byId.set(id, patch); + intents.set(type, byId); } } + return intents; }; // 최신 base 문서에서 id를 다시 찾아 적용한다. 스토어 조회(resolveElementById) @@ -197,30 +181,23 @@ const generatePatchFrom = ( // 건너뛰고, 터치된 컬렉션들을 단일 커밋으로 저장해 결합 원자성(한 커밋 = // 한 undo 엔트리)을 유지한다. 전원 미발견이면 커밋하지 않는다. // -// wire 커밋은 다른 first-party writer와 같은 compatibility 큐에 등록한다. -// 큐는 commitPatch 호출 자체를 지연시키므로, 여기서 큐를 건너뛰면 먼저 -// 캡처하고 대기 중이던 writer가 나중에 실행되어 이 값을 되돌린다. -// -// 반환 promise는 reject하지 않는다. 값은 wire patch 생성 시점의 대상 수이고 -// 저장 성공 보장이 아니다 - 커밋 실패는 write barrier에서 관측된다 +// 낙관 반영·실패 복원은 runElementIntent가 소유한다: 편입 전 실패와 대상 +// 소실은 receipt로 즉시 복원되고, 오류는 원형 그대로 전파된다. +// 반환값은 wire patch 생성 시점의 적용 대상 수 export const applyElementPatchesById = ( selection: ElementIdSelection, updater: ElementPatchUpdater, ): Promise => { - applyEagerly(selection, updater); let generated = 0; - return enqueueEditorCompatibilityWrite( - () => - editorCoordinator.commitGeneratedPatch((base) => { - const result = generatePatchFrom(base, selection, updater); - generated = result.applied; - return result.patch; - }), - () => generated, - ).catch((error) => { - console.error('Failed to commit element patches', error); - return generated; - }); + return runElementIntent({ + applyEager: () => + applyPropertyIntentsEagerly(buildEagerIntents(selection, updater)), + generate: (base) => { + const result = generatePatchFrom(base, selection, updater); + generated = result.applied; + return intentPatch(result.patch); + }, + }).then((result) => (result.committed ? generated : 0)); }; // 단일 완료는 배치의 1-ID 호출. false = wire에 실리지 않음(요소 없음 또는 diff --git a/src/renderer/hooks/Grid/elementPositionCommit.ts b/src/renderer/hooks/Grid/elementPositionCommit.ts index 463bcdd8..83a6dad6 100644 --- a/src/renderer/hooks/Grid/elementPositionCommit.ts +++ b/src/renderer/hooks/Grid/elementPositionCommit.ts @@ -1,5 +1,6 @@ import { applyElementPatchById } from '@src/renderer/editor/runtime/elementPatch'; import { isSyntheticElementId } from '@src/renderer/editor/model/elementIdMap'; +import { reportElementOpError } from '@src/renderer/editor/runtime/elementIntent'; import type { NativeElementType } from '@src/renderer/editor/model/elementIdMap'; @@ -15,7 +16,9 @@ export const commitElementPosition = ( fallback: () => void, ): void => { if (elementId && !isSyntheticElementId(elementId)) { - void applyElementPatchById(type, elementId, () => ({ dx, dy })); + void applyElementPatchById(type, elementId, () => ({ dx, dy })).catch( + reportElementOpError, + ); return; } fallback(); diff --git a/src/renderer/hooks/useKeyManager.ts b/src/renderer/hooks/useKeyManager.ts index 7f866ab6..f54dcf04 100644 --- a/src/renderer/hooks/useKeyManager.ts +++ b/src/renderer/hooks/useKeyManager.ts @@ -6,6 +6,7 @@ import { } from '@stores/data/useHistoryStatusStore'; import { historyApi } from '@api/modules/historyApi'; import { rebindKeySlotById } from '@src/renderer/editor/runtime/elementOps'; +import { reportElementOpError } from '@src/renderer/editor/runtime/elementIntent'; import { reconcileSelectionAfterIndexedElementDeletion, useGridSelectionStore, @@ -529,7 +530,7 @@ export function useKeyManager() { const positionId = useKeyStore.getState().canonicalPositions[selectedKeyType]?.[index]?.id; if (positionId) { - void rebindKeySlotById(positionId, newSlot); + void rebindKeySlotById(positionId, newSlot).catch(reportElementOpError); return; } const updatedMappings = updateKeyMapping( From aae45a6df85eb12388d6c4d99f93f4b404e475aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 12 Aug 2026 16:33:40 +0900 Subject: [PATCH 24/35] =?UTF-8?q?fix:=20=EC=9A=94=EC=86=8C=20op=EC=9D=98?= =?UTF-8?q?=20satisfied=20=ED=8C=90=EB=B3=84=EA=B3=BC=20bounds=20=EC=9D=98?= =?UTF-8?q?=EB=8F=84=20=EC=BB=A4=EB=B0=8B=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../editor/runtime/elementOps.test.ts | 81 ++- src/renderer/editor/runtime/elementOps.ts | 489 +++++++++++------- 2 files changed, 377 insertions(+), 193 deletions(-) diff --git a/src/renderer/editor/runtime/elementOps.test.ts b/src/renderer/editor/runtime/elementOps.test.ts index e0be7246..8fc39f86 100644 --- a/src/renderer/editor/runtime/elementOps.test.ts +++ b/src/renderer/editor/runtime/elementOps.test.ts @@ -16,6 +16,7 @@ import { useStatItemStore } from '@stores/data/useStatItemStore'; import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; import { applyZOrderByIds, + commitElementBoundsById, commitSelectedGeometryByIds, deleteElementById, placeDuplicatedKey, @@ -237,7 +238,7 @@ describe('elementOps', () => { ); expect(applied).toBe(1); - expect(api.commitGeneratedPatch.mock.calls[0][1]).toEqual({ + expect(api.commitGeneratedPatch.mock.calls[0][1]).toMatchObject({ gestureId: 'gesture-sync', }); const record = generatedPatches[0]?.keyPositions?.['4key']; @@ -309,6 +310,82 @@ describe('elementOps', () => { expect(generatedPatches).toEqual([null]); }); + it('bounds 의도의 편입 전 실패는 4필드를 CAS 복원한다', async () => { + api.commitGeneratedPatch.mockRejectedValue(new Error('start failed')); + const before = structuredClone( + useKeyStore.getState().canonicalPositions['4key'][0], + ); + + await expect( + commitElementBoundsById( + new Map([ + ['key', new Map([[ID_A, { dx: 50, dy: 60, width: 90, height: 80 }]])], + ]), + ), + ).rejects.toThrow('start failed'); + + expect(useKeyStore.getState().canonicalPositions['4key'][0]).toMatchObject({ + dx: before.dx, + dy: before.dy, + width: before.width, + height: before.height, + }); + }); + + it('bounds 의도의 대상 소실은 eager를 복원하고 미커밋한다', async () => { + const pre = documentFromStores(); + pre.keyPositions = { '4key': [keyAt(ID_B)] } as never; + slotBase = () => pre; + const before = structuredClone( + useKeyStore.getState().canonicalPositions['4key'][0], + ); + + const committed = await commitElementBoundsById( + new Map([ + ['key', new Map([[ID_A, { dx: 50, dy: 60, width: 90, height: 80 }]])], + ]), + ); + + expect(committed).toBe(false); + expect(generatedPatches).toEqual([null]); + expect(useKeyStore.getState().canonicalPositions['4key'][0]).toMatchObject({ + dx: before.dx, + width: before.width, + }); + }); + + it('삭제의 편입 전 실패는 로컬 pair를 복원한다', async () => { + api.commitGeneratedPatch.mockRejectedValue(new Error('start failed')); + + await expect(deleteElementById('key', ID_A)).rejects.toThrow( + 'start failed', + ); + + expect(useKeyStore.getState().keyMappings['4key']).toEqual(['A', 'B']); + expect( + useKeyStore.getState().canonicalPositions['4key'].map((p) => p.id), + ).toEqual([ID_A, ID_B]); + }); + + it('복제의 편입 전 실패는 추가한 pair를 제거한다', async () => { + api.commitGeneratedPatch.mockRejectedValue(new Error('start failed')); + + await expect( + placeDuplicatedKey({ slot: 'A', position: keyAt(ID_A) }, '4key', 1, 2), + ).rejects.toThrow('start failed'); + + expect(useKeyStore.getState().keyMappings['4key']).toEqual(['A', 'B']); + expect(useKeyStore.getState().canonicalPositions['4key']).toHaveLength(2); + }); + + it('재바인딩의 편입 전 실패는 슬롯을 복원한다', async () => { + api.commitGeneratedPatch.mockRejectedValue(new Error('start failed')); + + await expect(rebindKeySlotById(ID_A, 'Z')).rejects.toThrow('start failed'); + + expect(useKeyStore.getState().keyMappings['4key']).toEqual(['A', 'B']); + }); + it('semantic op는 compat 큐 선행 작업 뒤에 커밋한다', async () => { let release!: () => void; const blocker = new Promise((resolve) => { @@ -319,6 +396,8 @@ describe('elementOps', () => { () => undefined, ); + const pre = documentFromStores(); + slotBase = () => pre; const pending = deleteElementById('key', ID_A); await Promise.resolve(); await Promise.resolve(); diff --git a/src/renderer/editor/runtime/elementOps.ts b/src/renderer/editor/runtime/elementOps.ts index b003adcb..5bdca68f 100644 --- a/src/renderer/editor/runtime/elementOps.ts +++ b/src/renderer/editor/runtime/elementOps.ts @@ -7,8 +7,15 @@ import { reconcileSelectionAfterIndexedElementDeletion } from '@stores/grid/useG import { resolveElementById } from '../model/elementIdMap'; import { cloneKeyPositionForDuplicate } from '../model/keys'; import { cloneSlot } from '@utils/keySlot'; -import { enqueueEditorCompatibilityOperation } from './editorCompatibilityQueue'; -import { editorCoordinator } from './editorStateCoordinator'; +import { + applyPropertyIntentsEagerly, + generatePropertyIntentPatch, + intentPatch, + runElementIntent, + type ElementIntentGeneration, + type ElementIntentReceipt, + type PropertyIntents, +} from './elementIntent'; import type { EditorDocumentV1, EditorPatchV1 } from '@src/types/editor'; @@ -55,107 +62,157 @@ const removeAt = ( }); // 삭제: 키는 keys와 keyPositions의 인덱스 결합을 함께 제거, 아이템은 해당 -// 컬렉션만. 반환 false = 실행 시점에 대상 없음(이미 삭제) +// 컬렉션만. 반환 false = 실행 시점에 대상 없음(이미 삭제). +// 오류는 전파된다 - 편입 전 실패는 receipt가 로컬 삭제를 되돌린다. +// 선택 보정은 정책상 eager와 함께 즉시 수행하고 실패해도 복구하지 않는다 export const deleteElementById = ( type: NativeElementType, id: string, ): Promise => { if (!id) return Promise.resolve(false); - const locator = resolveElementById(type, id); - if (!locator) return Promise.resolve(false); - // eager 반영 + 선택 재조정 - 이후의 캡처가 삭제를 포함해 자가 치유 - if (type === 'key') { - const state = useKeyStore.getState(); - const mappings = state.keyMappings; - const nextMappings = { - ...mappings, - [locator.mode]: (mappings[locator.mode] ?? []).filter( - (_, i) => i !== locator.index, - ), - }; - const nextPositions = removeAt( - state.canonicalPositions as unknown as LooseRecord, - locator.mode, - locator.index, - ); - state.setKeyMappingsAndPositions(nextMappings, nextPositions as never); - } else if (type === 'stat') { - const state = useStatItemStore.getState(); - state.setPositions( - removeAt( - state.positions as unknown as LooseRecord, - locator.mode, - locator.index, - ) as never, - ); - } else if (type === 'graph') { - const state = useGraphItemStore.getState(); - state.setPositions( - removeAt( - state.positions as unknown as LooseRecord, - locator.mode, - locator.index, - ) as never, - ); - } else { - const state = useKnobItemStore.getState(); - state.setPositions( - removeAt( - state.positions as unknown as LooseRecord, + const applyEager = (): ElementIntentReceipt | null => { + const locator = resolveElementById(type, id); + if (!locator) return null; + + let removedSlot: unknown; + let removedPosition: Record | undefined; + if (type === 'key') { + const state = useKeyStore.getState(); + removedSlot = state.keyMappings[locator.mode]?.[locator.index]; + removedPosition = (state.canonicalPositions as unknown as LooseRecord)[ + locator.mode + ]?.[locator.index]; + const nextMappings = { + ...state.keyMappings, + [locator.mode]: (state.keyMappings[locator.mode] ?? []).filter( + (_, i) => i !== locator.index, + ), + }; + const nextPositions = removeAt( + state.canonicalPositions as unknown as LooseRecord, locator.mode, locator.index, - ) as never, - ); - } - // 선택 보정은 현재 모드 배열 기준 - 다른 모드로 이동한 대상의 index로 - // 현재 모드의 무관한 선택을 지우면 안 된다 - if (locator.mode === useKeyStore.getState().selectedKeyType) { - reconcileSelectionAfterIndexedElementDeletion(type, locator.index); - } + ); + state.setKeyMappingsAndPositions(nextMappings, nextPositions as never); + } else { + const state = + type === 'stat' + ? useStatItemStore.getState() + : type === 'graph' + ? useGraphItemStore.getState() + : useKnobItemStore.getState(); + removedPosition = (state.positions as unknown as LooseRecord)[ + locator.mode + ]?.[locator.index]; + state.setPositions( + removeAt( + state.positions as unknown as LooseRecord, + locator.mode, + locator.index, + ) as never, + ); + } + // 선택 보정은 현재 모드 배열 기준 - 다른 모드로 이동한 대상의 index로 + // 현재 모드의 무관한 선택을 지우면 안 된다 + if (locator.mode === useKeyStore.getState().selectedKeyType) { + reconcileSelectionAfterIndexedElementDeletion(type, locator.index); + } + + return { + rollback: () => { + if (!removedPosition) return; + // membership CAS: id가 이미 돌아와 있으면(다른 경로 복원) 중복 금지 + if (type === 'key') { + const state = useKeyStore.getState(); + const record = state.canonicalPositions as unknown as LooseRecord; + if (findInRecord(record, id)) return; + const list = record[locator.mode] ?? []; + const at = Math.min(locator.index, list.length); + const mappings = state.keyMappings[locator.mode] ?? []; + state.setKeyMappingsAndPositions( + { + ...state.keyMappings, + [locator.mode]: [ + ...mappings.slice(0, at), + removedSlot as never, + ...mappings.slice(at), + ], + }, + { + ...record, + [locator.mode]: [ + ...list.slice(0, at), + removedPosition, + ...list.slice(at), + ], + } as never, + ); + return; + } + const state = + type === 'stat' + ? useStatItemStore.getState() + : type === 'graph' + ? useGraphItemStore.getState() + : useKnobItemStore.getState(); + const record = state.positions as unknown as LooseRecord; + if (findInRecord(record, id)) return; + const list = record[locator.mode] ?? []; + const at = Math.min(locator.index, list.length); + state.setPositions({ + ...record, + [locator.mode]: [ + ...list.slice(0, at), + removedPosition, + ...list.slice(at), + ], + } as never); + }, + }; + }; - return enqueueEditorCompatibilityOperation(() => - editorCoordinator.commitGeneratedPatch((base) => { + let found = false; + return runElementIntent({ + applyEager, + generate: (base): ElementIntentGeneration => { if (type === 'key') { - const found = findInRecord( + const located = findInRecord( base.keyPositions as unknown as LooseRecord, id, ); - if (!found) return null; - return { + // 이미 canonical에서 사라짐 = 의도 달성 - 재삽입 롤백 금지 + if (!located) return { kind: 'satisfied' }; + found = true; + return intentPatch({ schemaVersion: 1, keys: { ...base.keys, - [found.mode]: (base.keys[found.mode] ?? []).filter( - (_, i) => i !== found.index, + [located.mode]: (base.keys[located.mode] ?? []).filter( + (_, i) => i !== located.index, ), }, keyPositions: removeAt( base.keyPositions as unknown as LooseRecord, - found.mode, - found.index, + located.mode, + located.index, ) as never, - }; + }); } const field = COLLECTION_FIELDS[type]; - const found = findInRecord(base[field] as unknown as LooseRecord, id); - if (!found) return null; - return { + const located = findInRecord(base[field] as unknown as LooseRecord, id); + if (!located) return { kind: 'satisfied' }; + found = true; + return intentPatch({ schemaVersion: 1, [field]: removeAt( base[field] as unknown as LooseRecord, - found.mode, - found.index, + located.mode, + located.index, ), - } as EditorPatchV1; - }), - ).then( - () => true, - (error) => { - console.error('Failed to commit element deletion', error); - return true; + } as EditorPatchV1); }, - ); + }).then((result) => (result.committed && found) || result.satisfied); }; // 복제 배치: 시작 시점에 동결한 payload(slot + position)를 현재 모드에 새 @@ -177,25 +234,47 @@ export const placeDuplicatedKey = ( const newId = newPosition.id as string; const frozenSlot = cloneSlot(frozen.slot as never); - const state = useKeyStore.getState(); - state.setKeyMappingsAndPositions( - { - ...state.keyMappings, - [mode]: [...(state.keyMappings[mode] ?? []), frozenSlot as never], - }, - { - ...state.canonicalPositions, - [mode]: [...(state.canonicalPositions[mode] ?? []), newPosition], - } as never, - ); - - return enqueueEditorCompatibilityOperation(() => - editorCoordinator.commitGeneratedPatch((base: EditorDocumentV1) => { - // 이미 같은 id가 들어가 있으면(이중 실행) 재추가 금지 + const applyEager = (): ElementIntentReceipt => { + const state = useKeyStore.getState(); + state.setKeyMappingsAndPositions( + { + ...state.keyMappings, + [mode]: [...(state.keyMappings[mode] ?? []), frozenSlot as never], + }, + { + ...state.canonicalPositions, + [mode]: [...(state.canonicalPositions[mode] ?? []), newPosition], + } as never, + ); + return { + rollback: () => { + // membership CAS: 우리가 넣은 newId가 아직 있으면 pair 제거 + const current = useKeyStore.getState(); + const record = current.canonicalPositions as unknown as LooseRecord; + const located = findInRecord(record, newId); + if (!located) return; + current.setKeyMappingsAndPositions( + { + ...current.keyMappings, + [located.mode]: (current.keyMappings[located.mode] ?? []).filter( + (_, i) => i !== located.index, + ), + }, + removeAt(record, located.mode, located.index) as never, + ); + }, + }; + }; + + return runElementIntent({ + applyEager, + generate: (base: EditorDocumentV1): ElementIntentGeneration => { + // 이미 같은 id가 들어가 있으면(이중 실행·선반영) 재추가 금지 - + // canonical 달성으로 보고 로컬 복제를 되돌리지 않는다 if (findInRecord(base.keyPositions as unknown as LooseRecord, newId)) { - return null; + return { kind: 'satisfied' }; } - return { + return intentPatch({ schemaVersion: 1, keys: { ...base.keys, @@ -205,15 +284,9 @@ export const placeDuplicatedKey = ( ...base.keyPositions, [mode]: [...(base.keyPositions[mode] ?? []), newPosition], } as never, - }; - }), - ).then( - () => true, - (error) => { - console.error('Failed to commit key duplication', error); - return true; + }); }, - ); + }).then((result) => result.committed || result.satisfied); }; // z-order: 모드 전역(4 컬렉션 + 외부 플러그인 z) 기준으로 대상 id들에 @@ -316,37 +389,41 @@ const storeDocumentSnapshot = (): EditorDocumentV1 => layerGroups: {}, } as unknown as EditorDocumentV1); -const applyZOrderEagerly = (patch: EditorPatchV1): void => { - if (patch.keyPositions) { - useKeyStore.getState().setPositions(patch.keyPositions as never); - } - if (patch.statPositions) { - useStatItemStore.getState().setPositions(patch.statPositions as never); - } - if (patch.graphPositions) { - useGraphItemStore.getState().setPositions(patch.graphPositions as never); - } - if (patch.knobPositions) { - useKnobItemStore.getState().setPositions(patch.knobPositions as never); - } -}; - export const applyZOrderByIds = ( targets: readonly ZOrderTarget[], direction: 'front' | 'back', externalZIndexes: readonly number[] = [], ): Promise => { - const eager = computeZOrderPatch( - storeDocumentSnapshot(), - targets, - direction, - externalZIndexes, - ); - if (eager.patch) applyZOrderEagerly(eager.patch); - let applied = 0; - return enqueueEditorCompatibilityOperation(() => - editorCoordinator.commitGeneratedPatch((base) => { + return runElementIntent({ + applyEager: () => { + // eager z를 id별 속성 의도로 변환해 receipt CAS 복원을 얻는다 + const eager = computeZOrderPatch( + storeDocumentSnapshot(), + targets, + direction, + externalZIndexes, + ); + if (!eager.patch) return null; + const intents = new Map< + NativeElementType, + Map> + >(); + for (const target of targets) { + if (!target.id) continue; + const field = FIELD_BY_TYPE[target.type]; + const record = eager.patch[field] as unknown as LooseRecord | undefined; + if (!record) continue; + const located = findInRecord(record, target.id); + if (!located) continue; + const zIndex = record[located.mode][located.index].zIndex; + const byId = intents.get(target.type) ?? new Map(); + byId.set(target.id, { zIndex }); + intents.set(target.type, byId); + } + return applyPropertyIntentsEagerly(intents); + }, + generate: (base) => { const generated = computeZOrderPatch( base, targets, @@ -354,15 +431,9 @@ export const applyZOrderByIds = ( externalZIndexes, ); applied = generated.applied; - return generated.patch; - }), - ).then( - () => applied, - (error) => { - console.error('Failed to commit z-order change', error); - return applied; + return intentPatch(generated.patch); }, - ); + }).then((result) => (result.committed ? applied : 0)); }; // 키 슬롯 재바인딩: keys만 바꾸되 대상은 paired 위치의 안정 id로 재결합한다. @@ -373,41 +444,61 @@ export const rebindKeySlotById = ( newSlot: unknown, ): Promise => { if (!positionId) return Promise.resolve(false); - const locator = resolveElementById('key', positionId); - if (!locator) return Promise.resolve(false); - - const state = useKeyStore.getState(); - state.setKeyMappings({ - ...state.keyMappings, - [locator.mode]: (state.keyMappings[locator.mode] ?? []).map((slot, i) => - i === locator.index ? newSlot : slot, - ), - } as never); - - return enqueueEditorCompatibilityOperation(() => - editorCoordinator.commitGeneratedPatch((base) => { - const found = findInRecord( + + const applyEager = (): ElementIntentReceipt | null => { + const locator = resolveElementById('key', positionId); + if (!locator) return null; + const state = useKeyStore.getState(); + const beforeSlot = state.keyMappings[locator.mode]?.[locator.index]; + state.setKeyMappings({ + ...state.keyMappings, + [locator.mode]: (state.keyMappings[locator.mode] ?? []).map((slot, i) => + i === locator.index ? newSlot : slot, + ), + } as never); + return { + rollback: () => { + // paired CAS: 위치 id의 현재 자리 슬롯이 우리가 쓴 값일 때만 복원 + const current = useKeyStore.getState(); + const located = findInRecord( + current.canonicalPositions as unknown as LooseRecord, + positionId, + ); + if (!located) return; + if (current.keyMappings[located.mode]?.[located.index] !== newSlot) { + return; + } + current.setKeyMappings({ + ...current.keyMappings, + [located.mode]: (current.keyMappings[located.mode] ?? []).map( + (slot, i) => (i === located.index ? beforeSlot : slot), + ), + } as never); + }, + }; + }; + + let found = false; + return runElementIntent({ + applyEager, + generate: (base) => { + const located = findInRecord( base.keyPositions as unknown as LooseRecord, positionId, ); - if (!found) return null; - return { + if (!located) return { kind: 'targetLost' }; + found = true; + return intentPatch({ schemaVersion: 1, keys: { ...base.keys, - [found.mode]: (base.keys[found.mode] ?? []).map((slot, i) => - i === found.index ? newSlot : slot, + [located.mode]: (base.keys[located.mode] ?? []).map((slot, i) => + i === located.index ? newSlot : slot, ), } as never, - }; - }), - ).then( - () => true, - (error) => { - console.error('Failed to commit key slot rebinding', error); - return true; + }); }, - ); + }).then((result) => result.committed && found); }; // 다중 선택 정산: 대상 id들의 현재 canonical 기하(dx·dy)를 의도로 캡처해 @@ -453,39 +544,53 @@ export const commitSelectedGeometryByIds = ( if (intents.size === 0) return Promise.resolve(0); let applied = 0; - return enqueueEditorCompatibilityOperation(() => - editorCoordinator.commitGeneratedPatch( - (base) => { - const patch: EditorPatchV1 = { schemaVersion: 1 }; - let touchedAny = false; - for (const [type, byId] of intents) { - const field = FIELD_BY_TYPE[type]; - const record = base[field] as unknown as LooseRecord; - let touched = 0; - const next: LooseRecord = {}; - for (const [mode, list] of Object.entries(record)) { - next[mode] = list.map((position) => { - const id = position.id; - if (typeof id !== 'string' || !byId.has(id)) return position; - touched += 1; - return { ...position, ...byId.get(id) }; - }); - } - if (touched > 0) { - patch[field] = next as never; - applied += touched; - touchedAny = true; - } + return runElementIntent({ + // 드래그가 이미 스토어에 최종값을 반영했으므로 eager 없음 - 실패 시 + // 남는 값은 드래그 산출물이며 수용된 낙관 의미론(V-5)을 따른다 + applyEager: () => null, + generate: (base) => { + const patch: EditorPatchV1 = { schemaVersion: 1 }; + let touchedAny = false; + for (const [type, byId] of intents) { + const field = FIELD_BY_TYPE[type]; + const record = base[field] as unknown as LooseRecord; + let touched = 0; + const next: LooseRecord = {}; + for (const [mode, list] of Object.entries(record)) { + next[mode] = list.map((position) => { + const id = position.id; + if (typeof id !== 'string' || !byId.has(id)) return position; + touched += 1; + return { ...position, ...byId.get(id) }; + }); } - return touchedAny ? patch : null; - }, - gestureId ? { gestureId } : undefined, - ), - ).then( - () => applied, - (error) => { - console.error('Failed to commit selection geometry', error); - return applied; + if (touched > 0) { + patch[field] = next as never; + applied += touched; + touchedAny = true; + } + } + return intentPatch(touchedAny ? patch : null); }, - ); + ...(gestureId ? { gestureId } : {}), + }).then((result) => (result.committed ? applied : 0)); +}; + +// 리사이즈 완료 전용: 시작 시 동결한 대상 id들에 최종 bounds를 하나의 +// intent로 - eager와 wire, receipt를 같은 의도가 소유한다. 대상 소실은 +// targetLost로 eager 복원, 오류는 전파 +export const commitElementBoundsById = ( + intents: PropertyIntents, + gestureId?: string, +): Promise => { + let hasIntent = false; + for (const byId of intents.values()) { + if (byId.size > 0) hasIntent = true; + } + if (!hasIntent) return Promise.resolve(false); + return runElementIntent({ + applyEager: () => applyPropertyIntentsEagerly(intents), + generate: (base) => intentPatch(generatePropertyIntentPatch(base, intents)), + ...(gestureId ? { gestureId } : {}), + }).then((result) => result.committed); }; From f232a9ffd74dc3830143f77ed8461695b3dbabe1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 12 Aug 2026 16:33:59 +0900 Subject: [PATCH 25/35] =?UTF-8?q?fix:=20=EB=A6=AC=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=EC=A6=88=20=EC=A0=95=EC=82=B0=EC=9D=84=20=EC=8B=9C=EC=9E=91=20?= =?UTF-8?q?=EC=8B=9C=EC=A0=90=20=EB=8F=99=EA=B2=B0=20=EB=8C=80=EC=83=81?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=82=B4=EC=9E=AC=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/main/Grid/core/Grid.tsx | 54 ++--- .../hooks/Grid/useGridResize.test.tsx | 182 ++++++++++++++--- src/renderer/hooks/Grid/useGridResize.ts | 186 ++++++++++++++++-- src/renderer/hooks/Grid/useGridSelection.ts | 16 +- 4 files changed, 360 insertions(+), 78 deletions(-) diff --git a/src/renderer/components/main/Grid/core/Grid.tsx b/src/renderer/components/main/Grid/core/Grid.tsx index 7ecb4251..20666fd5 100644 --- a/src/renderer/components/main/Grid/core/Grid.tsx +++ b/src/renderer/components/main/Grid/core/Grid.tsx @@ -20,6 +20,7 @@ import { placeDuplicatedKey, type ZOrderTarget, } from '@src/renderer/editor/runtime/elementOps'; +import { reportElementOpError } from '@src/renderer/editor/runtime/elementIntent'; import { isSyntheticElementId, resolveElementById, @@ -477,9 +478,6 @@ const Grid = ({ } = useGridResize({ selectedElements, selectedKeyType, - // 리사이즈 종료는 크기 필드까지 의도에 포함 (이동 경로는 dx·dy만) - onResizeEnd: (gestureId?: string) => - syncSelectedElementsToOverlay(gestureId, { includeSize: true }), getOtherElements, }); @@ -751,7 +749,9 @@ const Grid = ({ } } if (idTargets.length > 0) { - await applyZOrderByIds(idTargets, 'front', pluginZIndexesForMode()); + await applyZOrderByIds(idTargets, 'front', pluginZIndexesForMode()).catch( + reportElementOpError, + ); } syncSelectedElementsToOverlay(); @@ -788,7 +788,9 @@ const Grid = ({ } } if (idTargets.length > 0) { - await applyZOrderByIds(idTargets, 'back', pluginZIndexesForMode()); + await applyZOrderByIds(idTargets, 'back', pluginZIndexesForMode()).catch( + reportElementOpError, + ); } syncSelectedElementsToOverlay(); @@ -1260,7 +1262,7 @@ const Grid = ({ () => { const id = position.id; if (id) { - void deleteElementById('key', id); + void deleteElementById('key', id).catch(reportElementOpError); return; } onKeyDelete(index); @@ -1388,7 +1390,7 @@ const Grid = ({ () => { const id = position.id; if (id) { - void deleteElementById('stat', id); + void deleteElementById('stat', id).catch(reportElementOpError); return; } deleteStatAtIndex(index); @@ -1524,7 +1526,7 @@ const Grid = ({ () => { const id = position.id; if (id) { - void deleteElementById('graph', id); + void deleteElementById('graph', id).catch(reportElementOpError); return; } deleteGraphAtIndex(index); @@ -1639,7 +1641,7 @@ const Grid = ({ () => { const id = position.id; if (id) { - void deleteElementById('knob', id); + void deleteElementById('knob', id).catch(reportElementOpError); return; } deleteKnobAtIndex(index); @@ -1864,7 +1866,7 @@ const Grid = ({ selectedKeyType, snapped.x - width / 2, snapped.y - height / 2, - ); + ).catch(reportElementOpError); } else if (typeof onKeyDuplicate === 'function') { onKeyDuplicate( duplicateState.sourceIndex, @@ -2282,7 +2284,9 @@ const Grid = ({ t('confirm.removeStat', { name: displayName }), () => { if (contextElementId) { - void deleteElementById('stat', contextElementId); + void deleteElementById('stat', contextElementId).catch( + reportElementOpError, + ); return; } if (statIndex != null) deleteStatAtIndex(statIndex); @@ -2297,7 +2301,7 @@ const Grid = ({ [{ type: 'stat', id: contextElementId }], 'front', pluginZIndexesForMode(), - ); + ).catch(reportElementOpError); } else if (statIndex != null) { moveStatToFront(statIndex); } @@ -2307,7 +2311,7 @@ const Grid = ({ [{ type: 'stat', id: contextElementId }], 'back', pluginZIndexesForMode(), - ); + ).catch(reportElementOpError); } else if (statIndex != null) { moveStatToBack(statIndex); } @@ -2333,7 +2337,9 @@ const Grid = ({ t('confirm.removeGraph', { name: displayName }), () => { if (contextElementId) { - void deleteElementById('graph', contextElementId); + void deleteElementById('graph', contextElementId).catch( + reportElementOpError, + ); return; } if (graphIndex != null) deleteGraphAtIndex(graphIndex); @@ -2348,7 +2354,7 @@ const Grid = ({ [{ type: 'graph', id: contextElementId }], 'front', pluginZIndexesForMode(), - ); + ).catch(reportElementOpError); } else if (graphIndex != null) { moveGraphToFront(graphIndex); } @@ -2358,7 +2364,7 @@ const Grid = ({ [{ type: 'graph', id: contextElementId }], 'back', pluginZIndexesForMode(), - ); + ).catch(reportElementOpError); } else if (graphIndex != null) { moveGraphToBack(graphIndex); } @@ -2376,7 +2382,9 @@ const Grid = ({ t('confirm.removeKnob', { name: 'Knob' }), () => { if (contextElementId) { - void deleteElementById('knob', contextElementId); + void deleteElementById('knob', contextElementId).catch( + reportElementOpError, + ); return; } if (knobIndex != null) deleteKnobAtIndex(knobIndex); @@ -2391,7 +2399,7 @@ const Grid = ({ [{ type: 'knob', id: contextElementId }], 'front', pluginZIndexesForMode(), - ); + ).catch(reportElementOpError); } else if (knobIndex != null) { moveKnobToFront(knobIndex); } @@ -2401,7 +2409,7 @@ const Grid = ({ [{ type: 'knob', id: contextElementId }], 'back', pluginZIndexesForMode(), - ); + ).catch(reportElementOpError); } else if (knobIndex != null) { moveKnobToBack(knobIndex); } @@ -2471,7 +2479,9 @@ const Grid = ({ t('confirm.removeKey', { name: displayName }), () => { if (contextElementId) { - void deleteElementById('key', contextElementId); + void deleteElementById('key', contextElementId).catch( + reportElementOpError, + ); return; } if (keyIndex != null) onKeyDelete(keyIndex); @@ -2576,7 +2586,7 @@ const Grid = ({ [{ type: 'key', id: contextElementId }], 'front', pluginZIndexesForMode(), - ); + ).catch(reportElementOpError); } else { const keyIndex = resolveContextTarget('key'); if (keyIndex != null && typeof onMoveToFront === 'function') { @@ -2599,7 +2609,7 @@ const Grid = ({ [{ type: 'key', id: contextElementId }], 'back', pluginZIndexesForMode(), - ); + ).catch(reportElementOpError); } else { const keyIndex = resolveContextTarget('key'); if (keyIndex != null && typeof onMoveToBack === 'function') { diff --git a/src/renderer/hooks/Grid/useGridResize.test.tsx b/src/renderer/hooks/Grid/useGridResize.test.tsx index bfed5776..551aed08 100644 --- a/src/renderer/hooks/Grid/useGridResize.test.tsx +++ b/src/renderer/hooks/Grid/useGridResize.test.tsx @@ -1,14 +1,6 @@ import React, { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; -import { - afterEach, - beforeEach, - describe, - expect, - it, - type Mock, - vi, -} from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { SelectedElement } from '@stores/grid/useGridSelectionStore'; import { useGridResize } from './useGridResize'; @@ -21,7 +13,10 @@ const mocks = vi.hoisted(() => ({ clearGuides: vi.fn(), commitPatch: vi.fn(() => Promise.resolve()), beginMixedGesture: vi.fn(), + commitMixedGesture: vi.fn(() => Promise.resolve()), cancelMixedGesture: vi.fn(), + sendBridge: vi.fn(), + commitBounds: vi.fn(() => Promise.resolve(true)), elements: [] as Array<{ fullId: string; pluginId: string }>, })); @@ -32,10 +27,19 @@ vi.mock('@plugins/runtime/displayElement/instancesCommitQueue', () => ({ vi.mock('@plugins/runtime/displayElement/gestureTransaction', () => ({ beginMixedGestureTransaction: mocks.beginMixedGesture, + commitMixedGestureTransaction: mocks.commitMixedGesture, cancelMixedGestureTransaction: mocks.cancelMixedGesture, cancelUncommittedMixedGestureTransaction: mocks.cancelMixedGesture, })); +vi.mock('@utils/plugin/bridgeMessages', () => ({ + sendBridgeMessageBestEffort: mocks.sendBridge, +})); + +vi.mock('@src/renderer/editor/runtime/elementOps', () => ({ + commitElementBoundsById: mocks.commitBounds, +})); + vi.mock('@stores/plugin/usePluginDisplayElementStore', () => ({ usePluginDisplayElementStore: { getState: () => ({ @@ -58,6 +62,11 @@ vi.mock('@stores/grid/useSmartGuidesStore', () => ({ })); vi.mock('@stores/grid/useGridSelectionStore', () => ({ + selectionElementId: ( + type: string, + position: { id?: string } | undefined, + index: number, + ) => position?.id || `${type}-${index}`, useGridSelectionStore: { getState: () => ({ setDraggingOrResizing: mocks.setDraggingOrResizing, @@ -114,15 +123,13 @@ type ResizeApi = ReturnType; interface HarnessProps { selectedElements: SelectedElement[]; - onResizeEnd: (gestureId?: string) => void; expose: (api: ResizeApi) => void; } -const Harness = ({ selectedElements, onResizeEnd, expose }: HarnessProps) => { +const Harness = ({ selectedElements, expose }: HarnessProps) => { const api = useGridResize({ selectedElements, selectedKeyType: '4key', - onResizeEnd, }); expose(api); return null; @@ -139,13 +146,21 @@ const keySelection = (): SelectedElement => ({ index: 0, }); +const STABLE_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const STABLE_B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; + +const stableKeySelection = (id: string, index = 0): SelectedElement => ({ + id, + type: 'key', + index, +}); + describe('useGridResize plugin gesture lifecycle', () => { let host: HTMLDivElement; let root: Root; let api: ResizeApi; let tokenSequence: number; let events: string[]; - let onResizeEnd: Mock<(gestureId?: string) => void>; let pluginGestureIds: string[]; const renderHarness = async (selectedElements: SelectedElement[]) => { @@ -153,7 +168,6 @@ describe('useGridResize plugin gesture lifecycle', () => { root.render( { api = nextApi; }} @@ -176,6 +190,9 @@ describe('useGridResize plugin gesture lifecycle', () => { mocks.setDraggingOrResizing.mockReset(); mocks.clearGuides.mockReset(); mocks.commitPatch.mockClear(); + mocks.commitBounds.mockClear(); + mocks.commitMixedGesture.mockClear(); + mocks.sendBridge.mockClear(); mocks.beginMixedGesture.mockClear(); mocks.cancelMixedGesture.mockClear(); mocks.elements = []; @@ -191,9 +208,6 @@ describe('useGridResize plugin gesture lifecycle', () => { mocks.end.mockImplementation((pluginId: string, token: string) => { events.push(`end:${pluginId}:${token}`); }); - onResizeEnd = vi.fn(() => { - events.push('editor-end'); - }); }); afterEach(async () => { @@ -220,11 +234,9 @@ describe('useGridResize plugin gesture lifecycle', () => { expect(events).toEqual([ 'begin:plugin-a:token-1', 'update:plugin-a:one', - 'editor-end', 'end:plugin-a:token-1', 'begin:plugin-a:token-2', 'update:plugin-a:one', - 'editor-end', 'end:plugin-a:token-2', ]); }); @@ -258,13 +270,18 @@ describe('useGridResize plugin gesture lifecycle', () => { 'begin:plugin-b:token-2', 'update:plugin-a:one', 'update:plugin-b:one', - 'editor-end', 'end:plugin-a:token-1', 'end:plugin-b:token-2', ]); expect(new Set(pluginGestureIds).size).toBe(1); - expect(onResizeEnd).toHaveBeenCalledWith(pluginGestureIds[0]); + // plugin-only는 editor 무커밋 계약 - 오버레이 동기화만 수행 + expect(mocks.commitPatch).not.toHaveBeenCalled(); expect(mocks.beginMixedGesture).not.toHaveBeenCalled(); + expect(mocks.sendBridge).toHaveBeenCalledWith( + 'overlay', + 'plugin:displayElements:sync', + { elements: mocks.elements }, + ); }); it('혼합 그룹 resize는 중복 commit 없이 공유 gesture를 종료 callback에 전달한다', async () => { @@ -286,12 +303,129 @@ describe('useGridResize plugin gesture lifecycle', () => { }); expect(mocks.commitPatch).not.toHaveBeenCalled(); - expect(onResizeEnd).toHaveBeenCalledTimes(1); - expect(onResizeEnd).toHaveBeenCalledWith(pluginGestureIds[0]); + // 정산은 훅 내부에서 시작 시점 plugin ID 집합으로 완결된다 expect(mocks.beginMixedGesture).toHaveBeenCalledWith(pluginGestureIds[0], [ 'plugin-a', ]); - expect(mocks.cancelMixedGesture).toHaveBeenCalledWith(pluginGestureIds[0]); + expect(mocks.commitMixedGesture).toHaveBeenCalledWith( + pluginGestureIds[0], + expect.objectContaining({ schemaVersion: 1 }), + ['plugin-a'], + ); + }); + + it('혼합 그룹 resize 중 선택이 바뀌어도 시작 구성으로 정산한다', async () => { + mocks.elements = [{ fullId: 'plugin-a:one', pluginId: 'plugin-a' }]; + const selected = [ + stableKeySelection(STABLE_A), + pluginSelection('plugin-a:one'), + ]; + await renderHarness(selected); + + await act(async () => { + api.handleResizeStart(); + api.handleGroupResize({ + groupBounds: { x: 10, y: 20, width: 200, height: 80 }, + elementBounds: selected.map((element, index) => ({ + element, + bounds: { x: 10 + index * 100, y: 20, width: 80, height: 80 }, + })), + handle: { id: 'e', dx: 1, dy: 0 }, + }); + }); + // 대기 중 다른 혼합 선택으로 교체 + mocks.elements = [{ fullId: 'plugin-b:one', pluginId: 'plugin-b' }]; + await renderHarness([ + stableKeySelection(STABLE_B), + pluginSelection('plugin-b:one'), + ]); + await act(async () => { + api.handleGroupResizeComplete(); + }); + + // 정산은 시작 gesture와 시작 plugin ID 집합만 사용 + expect(mocks.commitMixedGesture).toHaveBeenCalledTimes(1); + expect(mocks.commitMixedGesture).toHaveBeenCalledWith( + pluginGestureIds[0], + expect.objectContaining({ schemaVersion: 1 }), + ['plugin-a'], + ); + expect(mocks.commitBounds).not.toHaveBeenCalled(); + expect(mocks.commitPatch).not.toHaveBeenCalled(); + }); + + it('그룹 resize 완료는 시작 시점 entries의 안정 id별로 bounds를 커밋한다', async () => { + const selected = [ + stableKeySelection(STABLE_A, 0), + stableKeySelection(STABLE_B, 1), + ]; + await renderHarness(selected); + + await act(async () => { + api.handleResizeStart(); + api.handleGroupResize({ + groupBounds: { x: 10, y: 20, width: 210, height: 80 }, + elementBounds: [ + { + element: selected[0], + bounds: { x: 10, y: 20, width: 100, height: 80 }, + }, + { + element: selected[1], + bounds: { x: 120, y: 20, width: 90, height: 70 }, + }, + ], + handle: { id: 'e', dx: 1, dy: 0 }, + }); + }); + // 대기 중 선택 교체 (외부 재정렬·분리 패널 동기화) + await renderHarness([keySelection()]); + await act(async () => { + api.handleGroupResizeComplete(); + }); + + expect(mocks.commitBounds).toHaveBeenCalledTimes(1); + const [intents] = mocks.commitBounds.mock.calls[0] as unknown as [ + Map>>, + ]; + const byId = intents.get('key')!; + expect([...byId.keys()].sort()).toEqual([STABLE_A, STABLE_B]); + expect(byId.get(STABLE_A)).toMatchObject({ + dx: 10, + dy: 20, + width: 100, + height: 80, + }); + expect(byId.get(STABLE_B)).toMatchObject({ + dx: 120, + dy: 20, + width: 90, + height: 70, + }); + expect(mocks.commitPatch).not.toHaveBeenCalled(); + expect(mocks.commitMixedGesture).not.toHaveBeenCalled(); + }); + + it('리사이즈 중 선택이 바뀌어도 시작 시점 동결 대상에 bounds를 커밋한다', async () => { + await renderHarness([stableKeySelection(STABLE_A)]); + + await act(async () => { + api.handleResizeStart(); + api.handleResize({ x: 10, y: 20, width: 120, height: 80 }); + }); + // 대기 중 같은 개수의 다른 선택으로 교체 (분리 패널 동기화 등) + await renderHarness([stableKeySelection(STABLE_B)]); + await act(async () => { + api.handleResizeComplete(); + }); + + expect(mocks.commitBounds).toHaveBeenCalledTimes(1); + const [intents] = mocks.commitBounds.mock.calls[0] as unknown as [ + Map>>, + ]; + const byId = intents.get('key')!; + expect([...byId.keys()]).toEqual([STABLE_A]); + expect(byId.get(STABLE_A)).toMatchObject({ width: 120, height: 80 }); }); it('active resize 중 unmount하면 보관한 token을 종료한다', async () => { diff --git a/src/renderer/hooks/Grid/useGridResize.ts b/src/renderer/hooks/Grid/useGridResize.ts index c41c908e..3c2610b0 100644 --- a/src/renderer/hooks/Grid/useGridResize.ts +++ b/src/renderer/hooks/Grid/useGridResize.ts @@ -1,3 +1,11 @@ +import { isSyntheticElementId } from '@src/renderer/editor/model/elementIdMap'; +import { + applyPropertyIntentsEagerly, + reportElementOpError, +} from '@src/renderer/editor/runtime/elementIntent'; +import { sendBridgeMessageBestEffort } from '@utils/plugin/bridgeMessages'; +import { editorCoordinator } from '@src/renderer/editor/runtime/editorStateCoordinator'; +import { commitElementBoundsById } from '@src/renderer/editor/runtime/elementOps'; import { useEffect, useRef, useState } from 'react'; import { useKeyStore } from '@stores/data/useKeyStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; @@ -25,6 +33,7 @@ import { } from '@plugins/runtime/displayElement/instancesCommitQueue'; import { beginMixedGestureTransaction, + commitMixedGestureTransaction, cancelUncommittedMixedGestureTransaction, } from '@plugins/runtime/displayElement/gestureTransaction'; @@ -57,7 +66,6 @@ interface GroupResizeResult { interface UseGridResizeOptions { selectedElements: SelectedElement[]; selectedKeyType: string; - onResizeEnd?: (gestureId?: string) => void; getOtherElements?: (excludeId: string) => ElementBounds[]; } @@ -69,7 +77,6 @@ interface UseGridResizeOptions { export function useGridResize({ selectedElements, selectedKeyType, - onResizeEnd, getOtherElements, }: UseGridResizeOptions) { const resizeStartRef = useRef(false); @@ -79,6 +86,9 @@ export function useGridResize({ const [previewBounds, setPreviewBounds] = useState(null); // 최종 적용할 bounds를 저장 (드래그 종료 시 사용) const finalBoundsRef = useRef(null); + const frozenResizeTargetsRef = useRef< + Array<{ type: string; id: string; index?: number }> + >([]); // 그룹 리사이즈용 상태 const [previewGroupBounds, setPreviewGroupBounds] = @@ -110,6 +120,13 @@ export function useGridResize({ }); }; + // plugin-only·혼합 완료의 오버레이 동기화 - editor 커밋과 분리 + const syncPluginElementsToOverlay = () => { + sendBridgeMessageBestEffort('overlay', 'plugin:displayElements:sync', { + elements: usePluginDisplayElementStore.getState().elements, + }); + }; + const endPluginResizeSessions = () => { const tokens = pluginResizeTokensRef.current; pluginResizeTokensRef.current = new Map(); @@ -136,6 +153,13 @@ export function useGridResize({ resizeStartRef.current = true; const gestureId = crypto.randomUUID(); resizeGestureIdRef.current = gestureId; + // 시작 대상 동결 - 완료 시 live 선택을 다시 읽으면 리사이즈 중 같은 + // 개수의 다른 선택으로 바뀐 경우 남의 요소에 bounds가 적용된다 + frozenResizeTargetsRef.current = selectedElements.map((element) => ({ + type: element.type, + id: element.id, + index: element.index, + })); beginPluginResizeSessions(gestureId); if ( pluginResizeTokensRef.current.size > 0 && @@ -875,10 +899,42 @@ export function useGridResize({ // 최종 bounds를 실제 요소에 적용 const finalBounds = finalBoundsRef.current; - if (finalBounds && selectedElements.length === 1) { - const element = selectedElements[0]; - - if (element.type === 'key' && element.index !== undefined) { + const frozenTargets = frozenResizeTargetsRef.current; + frozenResizeTargetsRef.current = []; + if (finalBounds && frozenTargets.length === 1) { + const element = frozenTargets[0] as { + type: 'key' | 'stat' | 'graph' | 'knob' | 'plugin'; + id: string; + index?: number; + }; + + if ( + element.type !== 'plugin' && + element.id.length > 0 && + !isSyntheticElementId(element.id) + ) { + // 시작 시 동결한 안정 id에 최종 bounds를 하나의 의도로 커밋 - + // eager·wire·receipt를 같은 의도가 소유한다 (live 선택 재조회 금지) + void commitElementBoundsById( + new Map([ + [ + element.type, + new Map([ + [ + element.id, + { + dx: finalBounds.x, + dy: finalBounds.y, + width: finalBounds.width, + height: finalBounds.height, + }, + ], + ]), + ], + ]), + resizeGestureIdRef.current ?? undefined, + ).catch(reportElementOpError); + } else if (element.type === 'key' && element.index !== undefined) { // 키 요소에 최종 크기 적용 - 커밋 base는 canonical const positions = useKeyStore.getState().canonicalPositions; const setPositions = useKeyStore.getState().setPositions; @@ -986,11 +1042,14 @@ export function useGridResize({ setPreviewBounds(null); finalBoundsRef.current = null; - try { - onResizeEnd?.(resizeGestureIdRef.current ?? undefined); - } finally { - endPluginResizeSessions(); + // 정산은 시작 시 동결한 구성으로 여기서 완결 - 완료 시점 live 선택을 + // 읽는 외부 콜백 금지. plugin이 움직였으면 오버레이만 동기화 + // (plugin-only는 editor 무커밋 계약). 합성 native 단일은 위 legacy + // 경로의 updatePositions가 이미 저장했다 + if (frozenTargets.some((target) => target.type === 'plugin')) { + syncPluginElementsToOverlay(); } + endPluginResizeSessions(); }; // 그룹 리사이즈 핸들러 - 프리뷰 모드 @@ -1006,6 +1065,10 @@ export function useGridResize({ // 그룹 리사이즈 완료 처리 - 실제 요소들에 최종 bounds 적용 const handleGroupResizeComplete = () => { resizeStartRef.current = false; + let groupHandledNatively = false; + let groupPluginInvolved = false; + let groupHasNative = false; + frozenResizeTargetsRef.current = []; // 스마트 가이드 클리어 useSmartGuidesStore.getState().clearGuides(); @@ -1032,9 +1095,56 @@ export function useGridResize({ // 프리뷰 값을 그대로 사용 (스냅은 이미 드래그 중에 적용됨) // 추가 스냅 적용 시 프리뷰와 최종 위치가 달라지는 문제 발생 - // 키 요소들 업데이트 + // 시작 시 동결된 entries(elementBounds)의 안정 id에 최종 bounds 의도 + // 구성. 플러그인 없고 전원 안정 id면 전용 의도 커밋이 eager와 wire를 + // 함께 소유, 혼합이면 eager만 반영 후 기존 mixed 경로가 보정된 + // 스토어에서 full record를 만든다. 합성 id는 index 경로 유지 + const stableBoundsIntents = new Map< + 'key' | 'stat' | 'graph' | 'knob', + Map> + >(); + const isStableEntry = (element: { type: string; id: string }): boolean => + element.type !== 'plugin' && + element.id.length > 0 && + !isSyntheticElementId(element.id); + for (const { element, bounds } of finalData.elementBounds) { + if (!isStableEntry(element)) continue; + const type = element.type as 'key' | 'stat' | 'graph' | 'knob'; + const byId = stableBoundsIntents.get(type) ?? new Map(); + byId.set(element.id, { + dx: bounds.x, + dy: bounds.y, + width: bounds.width, + height: bounds.height, + }); + stableBoundsIntents.set(type, byId); + } + const pluginInvolved = finalData.elementBounds.some( + ({ element }) => element.type === 'plugin', + ); + const allStable = finalData.elementBounds.every(({ element }) => + element.type === 'plugin' ? true : isStableEntry(element), + ); + groupPluginInvolved = pluginInvolved; + groupHasNative = finalData.elementBounds.some( + ({ element }) => element.type !== 'plugin', + ); + if (!pluginInvolved && allStable && stableBoundsIntents.size > 0) { + groupHandledNatively = true; + void commitElementBoundsById( + stableBoundsIntents, + resizeGestureIdRef.current ?? undefined, + ).catch(reportElementOpError); + } else if (stableBoundsIntents.size > 0) { + applyPropertyIntentsEagerly(stableBoundsIntents); + } + + // 키 요소들 업데이트 (합성 id 폴백) const keyUpdates = finalData.elementBounds.filter( - ({ element }) => element.type === 'key' && element.index !== undefined, + ({ element }) => + element.type === 'key' && + element.index !== undefined && + !isStableEntry(element), ); if (keyUpdates.length > 0) { @@ -1061,7 +1171,10 @@ export function useGridResize({ // 통계 요소들 업데이트 const statUpdates = finalData.elementBounds.filter( - ({ element }) => element.type === 'stat' && element.index !== undefined, + ({ element }) => + element.type === 'stat' && + element.index !== undefined && + !isStableEntry(element), ); if (statUpdates.length > 0) { @@ -1090,7 +1203,9 @@ export function useGridResize({ // 그래프 요소들 업데이트 const graphUpdates = finalData.elementBounds.filter( ({ element }) => - element.type === 'graph' && element.index !== undefined, + element.type === 'graph' && + element.index !== undefined && + !isStableEntry(element), ); if (graphUpdates.length > 0) { @@ -1118,7 +1233,10 @@ export function useGridResize({ // 노브 요소들 업데이트 const knobUpdates = finalData.elementBounds.filter( - ({ element }) => element.type === 'knob' && element.index !== undefined, + ({ element }) => + element.type === 'knob' && + element.index !== undefined && + !isStableEntry(element), ); if (knobUpdates.length > 0) { @@ -1165,11 +1283,41 @@ export function useGridResize({ setPreviewElementBounds(null); finalGroupBoundsRef.current = null; - try { - onResizeEnd?.(resizeGestureIdRef.current ?? undefined); - } finally { - endPluginResizeSessions(); + // 정산 완결 - 완료 시점 live 선택 금지. 혼합: 보정된 스토어 full-record를 + // 시작 시점 plugin ID 집합과 mixed 트랜잭션으로 / plugin-only: editor + // 무커밋 + 오버레이 동기화 / 합성 포함 native: full-record 커밋(기록된 + // legacy 이연 계열, 크기 저장 보존) + const settlementGestureId = resizeGestureIdRef.current ?? undefined; + if (!groupHandledNatively && groupHasNative) { + const editorChanges = { + schemaVersion: 1 as const, + keyPositions: useKeyStore.getState().canonicalPositions, + statPositions: useStatItemStore.getState().positions, + graphPositions: useGraphItemStore.getState().positions, + knobPositions: useKnobItemStore.getState().positions, + }; + if (groupPluginInvolved && settlementGestureId) { + const frozenPluginIds = [...pluginResizeTokensRef.current.keys()]; + void commitMixedGestureTransaction( + settlementGestureId, + editorChanges, + frozenPluginIds, + ).catch(reportElementOpError); + } else { + void editorCoordinator + .commitPatch( + editorChanges, + settlementGestureId + ? { gestureId: settlementGestureId } + : undefined, + ) + .catch(reportElementOpError); + } + } + if (groupPluginInvolved) { + syncPluginElementsToOverlay(); } + endPluginResizeSessions(); }; return { diff --git a/src/renderer/hooks/Grid/useGridSelection.ts b/src/renderer/hooks/Grid/useGridSelection.ts index e279ef34..2bf04a3e 100644 --- a/src/renderer/hooks/Grid/useGridSelection.ts +++ b/src/renderer/hooks/Grid/useGridSelection.ts @@ -72,10 +72,7 @@ interface UseGridSelectionReturn { deleteSelectedElements: () => Promise; copySelectedElements: () => void; pasteElements: () => Promise; - syncSelectedElementsToOverlay: ( - gestureId?: string, - options?: { includeSize?: boolean }, - ) => void; + syncSelectedElementsToOverlay: (gestureId?: string) => void; clipboard: ClipboardItem[]; } @@ -97,10 +94,7 @@ export function useGridSelection({ // 선택된 요소들의 최종 위치를 한 번에 저장 // 커밋 base는 canonical - rendered에는 다른 세션의 미커밋 프리뷰가 섞일 수 있음 - const syncSelectedElementsToOverlay = ( - gestureId?: string, - options?: { includeSize?: boolean }, - ) => { + const syncSelectedElementsToOverlay = (gestureId?: string) => { const currentPositions = useKeyStore.getState().canonicalPositions; const currentStatPositions = useStatItemStore.getState().positions; const currentGraphPositions = useGraphItemStore.getState().positions; @@ -152,11 +146,7 @@ export function useGridSelection({ gestureId && isMixed ? commitMixedGestureTransaction(gestureId, editorChanges, pluginIds) : allStableIds - ? commitSelectedGeometryByIds( - nativeTargets, - gestureId, - options?.includeSize ? ['dx', 'dy', 'width', 'height'] : undefined, - ) + ? commitSelectedGeometryByIds(nativeTargets, gestureId) : editorCoordinator.commitPatch( editorChanges, gestureId ? { gestureId } : undefined, From a369dca2cb0485c3023b0be1eae7f6916fef79e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 12 Aug 2026 16:33:59 +0900 Subject: [PATCH 26/35] =?UTF-8?q?fix:=20=EB=A0=88=EC=9D=B4=EC=96=B4=20DnD?= =?UTF-8?q?=EB=A5=BC=20mouseup=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=EA=B3=BC=20=EC=9D=B4=EC=9B=83=20=EC=95=B5=EC=BB=A4?= =?UTF-8?q?=EB=A1=9C=20=EC=9E=AC=ED=95=B4=EC=84=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PropertiesPanel/layer/LayerTabContent.tsx | 24 + .../layer/useLayerDnD.anchors.test.ts | 245 +++++++ .../layer/useLayerDnD.routing.test.tsx | 559 +++++++++++++++ .../Grid/PropertiesPanel/layer/useLayerDnD.ts | 663 +++++++++++++----- 4 files changed, 1324 insertions(+), 167 deletions(-) create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.anchors.test.ts create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.routing.test.tsx diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx index 3ed3d9bc..51a22d8f 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx @@ -228,12 +228,36 @@ const LayerTabContent: React.FC = ({ // DnD 훅 // ────────────────────────────────────────────────────────────────────────── + // 드롭 확정 시점의 authoritative 재구성 - effect 지연 ref는 외부 + // 재정렬을 한 렌더 늦게 본다 + const buildLiveLayerModel = () => { + const keyState = useKeyStore.getState(); + const liveLayerItems = buildLayerItems({ + selectedKeyType, + positions: keyState.canonicalPositions, + keyMappings: keyState.keyMappings, + statPositions: useStatItemStore.getState().positions, + graphPositions: useGraphItemStore.getState().positions, + knobPositions: useKnobItemStore.getState().positions, + pluginElements: usePluginDisplayElementStore.getState().elements, + }); + const groupState = useLayerGroupStore.getState(); + const liveDisplayItems = buildDisplayItems({ + layerItems: liveLayerItems, + layerGroupsForMode: groupState.layerGroups[selectedKeyType] || [], + collapsedGroups: groupState.collapsedGroups, + defaultGroupName: t('layerGroup.defaultName'), + }); + return { layerItems: liveLayerItems, displayItems: liveDisplayItems }; + }; + const dnd = useLayerDnD({ selectedKeyType, layerItemsRef, displayItemsRef, scrollElementRef, clearPendingDeselect, + buildLiveLayerModel, }); // 선택 상태 (렌더링용) diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.anchors.test.ts b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.anchors.test.ts new file mode 100644 index 00000000..6ef90933 --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.anchors.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it } from 'vitest'; + +import { + generateModeScopedIntentPatch, + resolveDropIndexFromAnchors, +} from './useLayerDnD'; + +import type { EditorDocumentV1 } from '@src/types/editor'; + +const layer = (id: string) => + ({ displayType: 'layer' as const, item: { id } } as never); +const header = (groupId: string) => + ({ displayType: 'group-header' as const, groupId } as never); + +const NO_DRAG: ReadonlySet = new Set(); + +describe('resolveDropIndexFromAnchors', () => { + it('양 앵커 생존·인접이면 사이 index를 준다', () => { + const display = [layer('a'), layer('b'), layer('c')]; + expect( + resolveDropIndexFromAnchors( + { + toDisplayIndex: 99, + targetGroupId: undefined, + anchorBeforeId: 'a', + anchorAfterId: 'b', + }, + NO_DRAG, + display, + ), + ).toBe(1); + }); + + it('앵커 순서가 역전되면 무커밋한다', () => { + // 병행 재정렬이 b를 a 앞으로 옮긴 상황 + const display = [layer('b'), layer('c'), layer('a')]; + expect( + resolveDropIndexFromAnchors( + { + toDisplayIndex: 1, + targetGroupId: undefined, + anchorBeforeId: 'a', + anchorAfterId: 'b', + }, + NO_DRAG, + display, + ), + ).toBeNull(); + }); + + it('앵커 사이에 비드래그 layer가 끼면 무커밋한다', () => { + const display = [layer('a'), layer('x'), layer('b')]; + expect( + resolveDropIndexFromAnchors( + { + toDisplayIndex: 1, + targetGroupId: undefined, + anchorBeforeId: 'a', + anchorAfterId: 'b', + }, + NO_DRAG, + display, + ), + ).toBeNull(); + }); + + it('앵커 사이에 그룹 헤더가 끼면 무커밋한다', () => { + const display = [layer('a'), header('g1'), layer('b')]; + expect( + resolveDropIndexFromAnchors( + { + toDisplayIndex: 1, + targetGroupId: undefined, + anchorBeforeId: 'a', + anchorAfterId: 'b', + }, + NO_DRAG, + display, + ), + ).toBeNull(); + }); + + it('한쪽 앵커만 생존하면 그 기준으로 배치한다', () => { + const display = [layer('x'), layer('a'), layer('y')]; + expect( + resolveDropIndexFromAnchors( + { + toDisplayIndex: 0, + targetGroupId: undefined, + anchorBeforeId: 'a', + anchorAfterId: 'gone', + }, + NO_DRAG, + display, + ), + ).toBe(2); + expect( + resolveDropIndexFromAnchors( + { + toDisplayIndex: 0, + targetGroupId: undefined, + anchorBeforeId: 'gone', + anchorAfterId: 'a', + }, + NO_DRAG, + display, + ), + ).toBe(1); + }); + + it('양 앵커 소실이면 무커밋한다', () => { + const display = [layer('x')]; + expect( + resolveDropIndexFromAnchors( + { + toDisplayIndex: 0, + targetGroupId: undefined, + anchorBeforeId: 'gone1', + anchorAfterId: 'gone2', + }, + NO_DRAG, + display, + ), + ).toBeNull(); + }); + + it('그룹 헤더 경계 앵커로 그룹 앞뒤 배치를 재해석한다', () => { + const display = [layer('a'), header('g1'), layer('m1'), header('g2')]; + // g1 헤더 아래(명시 헤더 앵커) + expect( + resolveDropIndexFromAnchors( + { + toDisplayIndex: 0, + targetGroupId: 'g1', + anchorHeaderGroupId: 'g1', + }, + NO_DRAG, + display, + ), + ).toBe(2); + // 그룹 사이 경계: before=g1 마지막 멤버, after=g2 헤더 + expect( + resolveDropIndexFromAnchors( + { + toDisplayIndex: 1, + targetGroupId: undefined, + anchorBeforeId: 'm1', + anchorAfterHeaderGroupId: 'g2', + }, + NO_DRAG, + display, + ), + ).toBe(3); + }); + + it('대상 그룹이 삭제됐으면 무커밋한다', () => { + const display = [layer('a')]; + expect( + resolveDropIndexFromAnchors( + { + toDisplayIndex: 0, + targetGroupId: 'gone-group', + anchorBeforeId: 'a', + }, + NO_DRAG, + display, + ), + ).toBeNull(); + }); + + it('이동 집합에 편입된 앵커는 소실로 취급한다', () => { + const display = [layer('x'), layer('a'), layer('y'), layer('b')]; + // before 앵커 a가 함께 이동 - a를 고정점으로 보면 사이의 y 개입으로 + // 무커밋되지만, 소실 취급하면 살아남은 after 앵커 b 기준으로 배치 + expect( + resolveDropIndexFromAnchors( + { + toDisplayIndex: 0, + targetGroupId: undefined, + anchorBeforeId: 'a', + anchorAfterId: 'b', + }, + new Set(['a']), + display, + ), + ).toBe(3); + // 양 앵커 모두 이동 집합이면 무커밋 + expect( + resolveDropIndexFromAnchors( + { + toDisplayIndex: 0, + targetGroupId: undefined, + anchorBeforeId: 'a', + anchorAfterId: 'b', + }, + new Set(['a', 'b']), + display, + ), + ).toBeNull(); + }); + + it('앵커가 원래 없던 경계는 캡처 index를 유지한다', () => { + expect( + resolveDropIndexFromAnchors( + { toDisplayIndex: 0, targetGroupId: undefined }, + NO_DRAG, + [], + ), + ).toBe(0); + }); +}); + +describe('generateModeScopedIntentPatch', () => { + const base = { + schemaVersion: 1, + keys: {}, + keyPositions: { + '4key': [{ id: 'in-mode', zIndex: 0 }], + '8key': [{ id: 'moved-away', zIndex: 0 }], + }, + statPositions: {}, + graphPositions: {}, + knobPositions: {}, + layerGroups: {}, + } as unknown as EditorDocumentV1; + + it('선택 모드의 요소에만 적용한다', () => { + const patch = generateModeScopedIntentPatch( + base, + new Map([['key', new Map([['in-mode', { zIndex: 7 }]])]]), + '4key', + ); + expect(patch?.keyPositions?.['4key'][0]).toMatchObject({ zIndex: 7 }); + expect(patch?.keyPositions?.['8key'][0]).toMatchObject({ zIndex: 0 }); + }); + + it('대기 중 다른 모드로 이동한 요소는 skip한다', () => { + const patch = generateModeScopedIntentPatch( + base, + new Map([['key', new Map([['moved-away', { zIndex: 7 }]])]]), + '4key', + ); + expect(patch).toBeNull(); + }); +}); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.routing.test.tsx b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.routing.test.tsx new file mode 100644 index 00000000..d2b02104 --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.routing.test.tsx @@ -0,0 +1,559 @@ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useLayerDnD } from './useLayerDnD'; + +import type { DisplayItem, LayerItem } from '../types'; + +const mocks = vi.hoisted(() => ({ + runElementIntent: vi.fn( + (_options: unknown): Promise => + Promise.resolve({ committed: true }), + ), + applyPropertyIntentsEagerly: vi.fn(() => ({ rollback: vi.fn() })), + reportElementOpError: vi.fn(), + setPluginZIndexes: vi.fn(), + commitPatch: vi.fn(() => Promise.resolve()), + setKeyPositions: vi.fn(), + setLayerGroups: vi.fn(), + selectedElements: [] as Array<{ id: string }>, + selectedGroupIds: [] as string[], +})); + +vi.mock('@src/renderer/editor/runtime/elementIntent', () => ({ + runElementIntent: mocks.runElementIntent, + applyPropertyIntentsEagerly: mocks.applyPropertyIntentsEagerly, + intentPatch: (patch: unknown) => + patch === null ? { kind: 'targetLost' } : { kind: 'patch', patch }, + reportElementOpError: mocks.reportElementOpError, +})); + +vi.mock('@plugins/rpc/pluginElementActions', () => ({ + setPluginElementZIndexes: mocks.setPluginZIndexes, +})); + +vi.mock('@src/renderer/editor/runtime/editorStateCoordinator', () => ({ + editorCoordinator: { commitPatch: mocks.commitPatch }, +})); + +vi.mock('@stores/data/useKeyStore', () => ({ + useKeyStore: { + getState: () => ({ + canonicalPositions: { '4key': [] }, + setPositions: mocks.setKeyPositions, + }), + }, +})); +vi.mock('@stores/data/useStatItemStore', () => ({ + useStatItemStore: { + getState: () => ({ positions: { '4key': [] }, setPositions: vi.fn() }), + }, +})); +vi.mock('@stores/data/useGraphItemStore', () => ({ + useGraphItemStore: { + getState: () => ({ positions: { '4key': [] }, setPositions: vi.fn() }), + }, +})); +vi.mock('@stores/data/useKnobItemStore', () => ({ + useKnobItemStore: { + getState: () => ({ positions: { '4key': [] }, setPositions: vi.fn() }), + }, +})); +vi.mock('@stores/data/useLayerGroupStore', () => ({ + useLayerGroupStore: { + getState: () => ({ + layerGroups: {}, + setLayerGroups: mocks.setLayerGroups, + }), + }, +})); +vi.mock('@stores/grid/useGridSelectionStore', () => ({ + useGridSelectionStore: { + getState: () => ({ + selectedElements: mocks.selectedElements, + selectedGroupIds: mocks.selectedGroupIds, + }), + }, +})); +vi.mock('@utils/layerGroupUtils', () => ({ + normalizeLayerGroupsForMode: (input: { + keyPositions: unknown; + statPositions: unknown; + graphPositions: unknown; + knobPositions: unknown; + layerGroups: unknown; + }) => ({ ...input, groupsChanged: false }), +})); + +const ID_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const ID_B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; + +const nativeItem = ( + id: string, + index: number, + zIndex: number, + groupId?: string, +): LayerItem => ({ + type: 'key', + id, + index, + name: `key-${index}`, + zIndex, + hidden: false, + ...(groupId ? { groupId } : {}), +}); + +const headerRow = (groupId: string, childCount: number): DisplayItem => ({ + displayType: 'group-header', + groupId, + groupName: groupId, + isCollapsed: false, + childCount, + allHidden: false, +}); + +const pluginItem = (fullId: string, zIndex: number): LayerItem => ({ + type: 'plugin', + id: fullId, + name: fullId, + zIndex, + hidden: false, +}); + +const toDisplay = (items: LayerItem[]): DisplayItem[] => + items.map((item, flatIndex) => ({ + displayType: 'layer' as const, + item, + groupDepth: 0, + flatIndex, + })); + +type DnDApi = ReturnType; + +interface HarnessProps { + layerItems: LayerItem[]; + displayItems?: DisplayItem[]; + buildLiveLayerModel: () => { + layerItems: LayerItem[]; + displayItems: DisplayItem[]; + }; + expose: (api: DnDApi) => void; +} + +const Harness = ({ + layerItems, + displayItems, + buildLiveLayerModel, + expose, +}: HarnessProps) => { + const layerItemsRef = React.useRef(layerItems); + const displayItemsRef = React.useRef( + displayItems ?? toDisplay(layerItems), + ); + const scrollElementRef = React.useRef({ + getBoundingClientRect: () => ({ + top: 0, + bottom: 1000, + left: 0, + right: 100, + }), + scrollTop: 0, + } as unknown as HTMLDivElement); + const api = useLayerDnD({ + selectedKeyType: '4key', + layerItemsRef, + displayItemsRef, + buildLiveLayerModel, + scrollElementRef, + clearPendingDeselect: () => {}, + }); + expose(api); + return null; +}; + +describe('useLayerDnD 커밋 경로 라우팅', () => { + let host: HTMLDivElement; + let root: Root; + let api: DnDApi; + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { + cb(0); + return 1; + }); + vi.stubGlobal('cancelAnimationFrame', () => {}); + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + mocks.runElementIntent.mockClear(); + // 러너 계약 재현: eager 적용 후 커밋 성공 + mocks.runElementIntent.mockImplementation((options: unknown) => { + (options as { applyEager: () => unknown }).applyEager(); + return Promise.resolve({ committed: true }); + }); + mocks.applyPropertyIntentsEagerly.mockClear(); + mocks.reportElementOpError.mockClear(); + mocks.setPluginZIndexes.mockClear(); + mocks.commitPatch.mockClear(); + mocks.setKeyPositions.mockClear(); + mocks.setLayerGroups.mockClear(); + mocks.selectedElements = []; + mocks.selectedGroupIds = []; + }); + + afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + vi.unstubAllGlobals(); + }); + + // 아이템 A를 목록 끝으로 드래그하는 공용 시퀀스 + const dragItemToEnd = async ( + layerItems: LayerItem[], + liveModel: { layerItems: LayerItem[]; displayItems: DisplayItem[] }, + ) => { + await act(async () => { + root.render( + liveModel} + expose={(nextApi) => { + api = nextApi; + }} + />, + ); + }); + + await act(async () => { + api.handleMouseDown( + { + button: 0, + clientX: 0, + clientY: 10, + currentTarget: { + getBoundingClientRect: () => ({ height: 24 }), + }, + } as unknown as React.MouseEvent, + layerItems[0], + 0, + ); + document.dispatchEvent( + new MouseEvent('mousemove', { clientX: 0, clientY: 200 }), + ); + document.dispatchEvent(new MouseEvent('mouseup')); + }); + }; + + it('mouseup 시점 live 모델에 plugin이 있으면 native intent 경로에 진입하지 않는다', async () => { + const startItems = [nativeItem(ID_A, 0, 2), nativeItem(ID_B, 1, 1)]; + // 드래그 중 plugin 요소가 추가된 라이브 모델 + const liveItems = [...startItems, pluginItem('plugin-x:one', 0)]; + await dragItemToEnd(startItems, { + layerItems: liveItems, + displayItems: toDisplay(liveItems), + }); + + expect(mocks.runElementIntent).not.toHaveBeenCalled(); + expect(mocks.applyPropertyIntentsEagerly).not.toHaveBeenCalled(); + expect(mocks.commitPatch).toHaveBeenCalledTimes(1); + expect(mocks.setPluginZIndexes).toHaveBeenCalledTimes(1); + }); + + it('native 전용 편입 전 실패는 runner가 소유하고 layerGroups는 eager를 건드리지 않는다', async () => { + mocks.runElementIntent.mockImplementation((options: unknown) => { + // 러너 계약 재현: eager 적용 후 편입 전 실패 + (options as { applyEager: () => unknown }).applyEager(); + return Promise.reject(new Error('start failed')); + }); + const startItems = [nativeItem(ID_A, 0, 1), nativeItem(ID_B, 1, 0)]; + await dragItemToEnd(startItems, { + layerItems: startItems, + displayItems: toDisplay(startItems), + }); + + expect(mocks.runElementIntent).toHaveBeenCalledTimes(1); + // eager는 속성 의도만 - 그룹 정의·포지션 스토어 직접 쓰기 없음 + expect(mocks.applyPropertyIntentsEagerly).toHaveBeenCalledTimes(1); + const [intents] = mocks.applyPropertyIntentsEagerly.mock + .calls[0] as unknown as [ + Map>>, + ]; + const byId = intents.get('key')!; + expect(byId.get(ID_B)).toMatchObject({ zIndex: 1 }); + expect(byId.get(ID_A)).toMatchObject({ zIndex: 0 }); + expect(mocks.setLayerGroups).not.toHaveBeenCalled(); + expect(mocks.setKeyPositions).not.toHaveBeenCalled(); + expect(mocks.commitPatch).not.toHaveBeenCalled(); + // 실패는 경계 로거로 보고되고 복원은 러너 receipt가 소유 + expect(mocks.reportElementOpError).toHaveBeenCalledTimes(1); + }); + + const ID_C = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; + const ID_X = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'; + const ID_Y = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'; + const ID_M1 = '11111111-1111-4111-8111-111111111111'; + const ID_M2 = '22222222-2222-4222-8222-222222222222'; + + const renderDnD = async (props: { + layerItems: LayerItem[]; + displayItems?: DisplayItem[]; + liveModel: { layerItems: LayerItem[]; displayItems: DisplayItem[] }; + }) => { + await act(async () => { + root.render( + props.liveModel} + expose={(nextApi) => { + api = nextApi; + }} + />, + ); + }); + }; + + const mouseDownEvent = () => + ({ + button: 0, + clientX: 0, + clientY: 10, + currentTarget: { + getBoundingClientRect: () => ({ height: 24 }), + }, + } as unknown as React.MouseEvent); + + const finishDrag = async (start: () => void, moveClientY: number) => { + await act(async () => { + start(); + document.dispatchEvent( + new MouseEvent('mousemove', { clientX: 0, clientY: moveClientY }), + ); + document.dispatchEvent(new MouseEvent('mouseup')); + }); + }; + + const eagerIntents = () => { + const [intents] = mocks.applyPropertyIntentsEagerly.mock + .calls[0] as unknown as [ + Map>>, + ]; + return intents.get('key')!; + }; + + it('외부 재정렬 후 드롭은 최신 순서 기준으로 커밋한다', async () => { + const itemA = nativeItem(ID_A, 0, 2); + const itemB = nativeItem(ID_B, 1, 1); + const itemC = nativeItem(ID_C, 2, 0); + // 드래그 중 외부 재정렬로 B·C 순서 교체 + const liveItems = [itemA, itemC, itemB]; + await renderDnD({ + layerItems: [itemA, itemB, itemC], + liveModel: { layerItems: liveItems, displayItems: toDisplay(liveItems) }, + }); + + await finishDrag( + () => api.handleMouseDown(mouseDownEvent(), itemA, 0), + 200, + ); + + expect(mocks.runElementIntent).toHaveBeenCalledTimes(1); + const byId = eagerIntents(); + // 최신 순서 [C, A, B] - stale ref 순서라면 [B, A, C]가 된다 + expect(byId.get(ID_C)).toMatchObject({ zIndex: 2 }); + expect(byId.get(ID_A)).toMatchObject({ zIndex: 1 }); + expect(byId.get(ID_B)).toMatchObject({ zIndex: 0 }); + }); + + it('그룹+추가 선택 드래그는 mouseup 시점 live 구성원 전체를 함께 옮긴다', async () => { + const itemA = nativeItem(ID_A, 0, 3, 'G'); + const itemX = nativeItem(ID_X, 1, 2); + const itemY = nativeItem(ID_Y, 2, 1); + // 드래그 중 외부 변경으로 B가 G에 합류 + const itemB = nativeItem(ID_B, 3, 0, 'G'); + const liveItems = [itemA, itemB, itemX, itemY]; + const liveDisplay: DisplayItem[] = [ + headerRow('G', 2), + { displayType: 'layer', item: itemA, groupDepth: 1, flatIndex: 0 }, + { displayType: 'layer', item: itemB, groupDepth: 1, flatIndex: 1 }, + { displayType: 'layer', item: itemX, groupDepth: 0, flatIndex: 2 }, + { displayType: 'layer', item: itemY, groupDepth: 0, flatIndex: 3 }, + ]; + mocks.selectedGroupIds = ['G']; + mocks.selectedElements = [{ id: ID_A }, { id: ID_X }]; + await renderDnD({ + layerItems: [itemA, itemX, itemY], + displayItems: [ + headerRow('G', 1), + { displayType: 'layer', item: itemA, groupDepth: 1, flatIndex: 0 }, + { displayType: 'layer', item: itemX, groupDepth: 0, flatIndex: 1 }, + { displayType: 'layer', item: itemY, groupDepth: 0, flatIndex: 2 }, + ], + liveModel: { layerItems: liveItems, displayItems: liveDisplay }, + }); + + await finishDrag( + () => api.handleGroupMouseDown(mouseDownEvent(), 'G'), + 200, + ); + + expect(mocks.runElementIntent).toHaveBeenCalledTimes(1); + const byId = eagerIntents(); + // 최종 순서 [Y, A, B, X] - 새 구성원 B가 그룹과 함께 이동 + expect(byId.get(ID_Y)).toMatchObject({ zIndex: 3 }); + expect(byId.get(ID_A)).toMatchObject({ zIndex: 2 }); + expect(byId.get(ID_B)).toMatchObject({ zIndex: 1 }); + expect(byId.get(ID_X)).toMatchObject({ zIndex: 0 }); + }); + + it('추가 선택이 앵커 후보였던 드롭도 살아있는 비이동 앵커 기준으로 배치한다', async () => { + const itemA = nativeItem(ID_A, 0, 3, 'G'); + const itemX = nativeItem(ID_X, 1, 2); + const itemY = nativeItem(ID_Y, 2, 1); + const itemZ = nativeItem(ID_B, 3, 0); + const staleDisplay: DisplayItem[] = [ + headerRow('G', 1), + { displayType: 'layer', item: itemA, groupDepth: 1, flatIndex: 0 }, + { displayType: 'layer', item: itemX, groupDepth: 0, flatIndex: 1 }, + { displayType: 'layer', item: itemY, groupDepth: 0, flatIndex: 2 }, + { displayType: 'layer', item: itemZ, groupDepth: 0, flatIndex: 3 }, + ]; + // 드래그 중 외부 재정렬로 비이동 Y·Z 순서 교체 + const liveItems = [itemA, itemX, itemZ, itemY]; + const liveDisplay: DisplayItem[] = [ + headerRow('G', 1), + { displayType: 'layer', item: itemA, groupDepth: 1, flatIndex: 0 }, + { displayType: 'layer', item: itemX, groupDepth: 0, flatIndex: 1 }, + { displayType: 'layer', item: itemZ, groupDepth: 0, flatIndex: 2 }, + { displayType: 'layer', item: itemY, groupDepth: 0, flatIndex: 3 }, + ]; + mocks.selectedGroupIds = ['G']; + mocks.selectedElements = [{ id: ID_A }, { id: ID_X }]; + await renderDnD({ + layerItems: [itemA, itemX, itemY, itemZ], + displayItems: staleDisplay, + liveModel: { layerItems: liveItems, displayItems: liveDisplay }, + }); + + // X와 Y 사이 슬롯(display 3) - 함께 이동하는 X는 앵커가 될 수 없고 + // 살아있는 비이동 앵커 Y 기준으로 해석돼야 한다 + await finishDrag(() => api.handleGroupMouseDown(mouseDownEvent(), 'G'), 74); + + expect(mocks.runElementIntent).toHaveBeenCalledTimes(1); + const byId = eagerIntents(); + // 최종 순서 [Z, A, X, Y] - live Y 앵커 앞 배치 + expect(byId.get(ID_B)).toMatchObject({ zIndex: 3 }); + expect(byId.get(ID_A)).toMatchObject({ zIndex: 2 }); + expect(byId.get(ID_X)).toMatchObject({ zIndex: 1 }); + expect(byId.get(ID_Y)).toMatchObject({ zIndex: 0 }); + }); + + it('캡처 후 선택 축소로 이동 집합이 줄면 무커밋한다', async () => { + // 표시 순서 [X, Y, headerG, A], 선택 G+X - G를 최상단으로 드래그 + const itemX = nativeItem(ID_X, 0, 3); + const itemY = nativeItem(ID_Y, 1, 2); + const itemA = nativeItem(ID_A, 2, 1, 'G'); + const layerItems = [itemX, itemY, itemA]; + const display: DisplayItem[] = [ + { displayType: 'layer', item: itemX, groupDepth: 0, flatIndex: 0 }, + { displayType: 'layer', item: itemY, groupDepth: 0, flatIndex: 1 }, + headerRow('G', 1), + { displayType: 'layer', item: itemA, groupDepth: 1, flatIndex: 2 }, + ]; + mocks.selectedGroupIds = ['G']; + mocks.selectedElements = [{ id: ID_A }, { id: ID_X }]; + await renderDnD({ + layerItems, + displayItems: display, + liveModel: { layerItems, displayItems: display }, + }); + + await act(async () => { + api.handleGroupMouseDown(mouseDownEvent(), 'G'); + // 최상단 슬롯 - X는 이동 예정이라 앵커에서 제외되고 after=Y만 캡처 + document.dispatchEvent( + new MouseEvent('mousemove', { clientX: 0, clientY: 2 }), + ); + // mouseup 전 원격 동기화로 X만 선택 해제 + mocks.selectedElements = [{ id: ID_A }]; + document.dispatchEvent(new MouseEvent('mouseup')); + }); + + // X 잔류를 앵커가 모르는 채 해석하면 [X, A, Y] 오배치 - 무커밋이어야 한다 + expect(mocks.runElementIntent).not.toHaveBeenCalled(); + expect(mocks.applyPropertyIntentsEagerly).not.toHaveBeenCalled(); + expect(mocks.commitPatch).not.toHaveBeenCalled(); + }); + + it('드래그 중 원본 그룹이 소실되면 잔존 선택만 이동시키지 않는다', async () => { + const itemA = nativeItem(ID_A, 0, 2, 'G'); + const itemX = nativeItem(ID_X, 1, 1); + const itemY = nativeItem(ID_Y, 2, 0); + const staleDisplay: DisplayItem[] = [ + headerRow('G', 1), + { displayType: 'layer', item: itemA, groupDepth: 1, flatIndex: 0 }, + { displayType: 'layer', item: itemX, groupDepth: 0, flatIndex: 1 }, + { displayType: 'layer', item: itemY, groupDepth: 0, flatIndex: 2 }, + ]; + // 드래그 중 G 해체 - A가 그룹 밖으로 + const ungroupedA = nativeItem(ID_A, 0, 2); + const liveItems = [ungroupedA, itemX, itemY]; + mocks.selectedGroupIds = ['G']; + mocks.selectedElements = [{ id: ID_A }, { id: ID_X }]; + await renderDnD({ + layerItems: [itemA, itemX, itemY], + displayItems: staleDisplay, + liveModel: { layerItems: liveItems, displayItems: toDisplay(liveItems) }, + }); + + await finishDrag( + () => api.handleGroupMouseDown(mouseDownEvent(), 'G'), + 200, + ); + + expect(mocks.runElementIntent).not.toHaveBeenCalled(); + expect(mocks.applyPropertyIntentsEagerly).not.toHaveBeenCalled(); + expect(mocks.commitPatch).not.toHaveBeenCalled(); + }); + + it('드롭 대상 그룹이 mouseup 전에 삭제되면 무커밋한다', async () => { + const itemA = nativeItem(ID_A, 0, 3, 'G'); + const itemX = nativeItem(ID_X, 1, 2); + const itemM1 = nativeItem(ID_M1, 2, 1, 'H'); + const itemM2 = nativeItem(ID_M2, 3, 0, 'H'); + const staleDisplay: DisplayItem[] = [ + headerRow('G', 1), + { displayType: 'layer', item: itemA, groupDepth: 1, flatIndex: 0 }, + { displayType: 'layer', item: itemX, groupDepth: 0, flatIndex: 1 }, + headerRow('H', 2), + { displayType: 'layer', item: itemM1, groupDepth: 1, flatIndex: 2 }, + { displayType: 'layer', item: itemM2, groupDepth: 1, flatIndex: 3 }, + ]; + // 드래그 중 H 그룹 전체 삭제 + const liveItems = [itemA, itemX]; + const liveDisplay: DisplayItem[] = [ + headerRow('G', 1), + { displayType: 'layer', item: itemA, groupDepth: 1, flatIndex: 0 }, + { displayType: 'layer', item: itemX, groupDepth: 0, flatIndex: 1 }, + ]; + mocks.selectedGroupIds = ['G']; + mocks.selectedElements = [{ id: ID_A }, { id: ID_X }]; + await renderDnD({ + layerItems: [itemA, itemX, itemM1, itemM2], + displayItems: staleDisplay, + liveModel: { layerItems: liveItems, displayItems: liveDisplay }, + }); + + // m1과 m2 사이 슬롯(display 5)으로 드롭 - 앵커가 전부 H 소속 + await finishDrag( + () => api.handleGroupMouseDown(mouseDownEvent(), 'G'), + 126, + ); + + expect(mocks.runElementIntent).not.toHaveBeenCalled(); + expect(mocks.applyPropertyIntentsEagerly).not.toHaveBeenCalled(); + expect(mocks.commitPatch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.ts b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.ts index 2cd834cc..eeca3cab 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.ts @@ -3,6 +3,13 @@ * 아이템/그룹 드래그, 드롭 타깃 계산, 순서 재배치 */ +import { isSyntheticElementId } from '@src/renderer/editor/model/elementIdMap'; +import { + applyPropertyIntentsEagerly, + intentPatch, + reportElementOpError, + runElementIntent, +} from '@src/renderer/editor/runtime/elementIntent'; import { setPluginElementZIndexes } from '@plugins/rpc/pluginElementActions'; import { useState, useRef } from 'react'; import { useKeyStore } from '@stores/data/useKeyStore'; @@ -20,10 +27,146 @@ import { createRafLatestScheduler } from '@utils/animation/rafLatestScheduler'; // 파라미터 타입 // ============================================================================ +// 모드 한정 의도 재적용 - 레이어 순서는 mode-local이라 다른 모드로 이동한 +// 요소에 zIndex·groupId를 쓰면 안 된다 +export const generateModeScopedIntentPatch = ( + base: import('@src/types/editor').EditorDocumentV1, + intents: ReadonlyMap< + 'key' | 'stat' | 'graph' | 'knob', + ReadonlyMap> + >, + mode: string, +): import('@src/types/editor').EditorPatchV1 | null => { + const FIELD_BY_TYPE = { + key: 'keyPositions', + stat: 'statPositions', + graph: 'graphPositions', + knob: 'knobPositions', + } as const; + const patch: import('@src/types/editor').EditorPatchV1 = { + schemaVersion: 1, + }; + let touchedAny = false; + for (const [type, byId] of intents) { + const field = FIELD_BY_TYPE[type]; + const record = base[field] as Record< + string, + Array<{ id?: string } & Record> + >; + let touched = 0; + const modeList = record[mode] ?? []; + const nextList = modeList.map((position) => { + const id = position.id; + if (typeof id !== 'string') return position; + const intent = byId.get(id); + if (!intent) return position; + touched += 1; + return { ...position, ...intent, id }; + }); + if (touched > 0) { + patch[field] = { ...record, [mode]: nextList } as never; + touchedAny = true; + } + } + return touchedAny ? patch : null; +}; + +export interface DropAnchors { + toDisplayIndex: number; + targetGroupId: string | undefined; + anchorBeforeId?: string | null; + anchorAfterId?: string | null; + anchorHeaderGroupId?: string | null; + // 스캔이 layer 대신 그룹 헤더 경계에서 끝난 경우의 헤더 앵커 + anchorBeforeHeaderGroupId?: string | null; + anchorAfterHeaderGroupId?: string | null; +} + +export const resolveDropIndexFromAnchors = ( + target: DropAnchors, + draggedSet: ReadonlySet, + display: DisplayItem[], +): number | null => { + if ( + target.targetGroupId && + !display.some( + (di) => + di.displayType === 'group-header' && + di.groupId === target.targetGroupId, + ) + ) { + return null; + } + if (target.anchorHeaderGroupId) { + const headerIdx = display.findIndex( + (di) => + di.displayType === 'group-header' && + di.groupId === target.anchorHeaderGroupId, + ); + return headerIdx !== -1 ? headerIdx + 1 : null; + } + // 이동 집합에 편입된 앵커는 소실 취급 - 함께 움직이는 요소는 고정 + // 기준점이 될 수 없다 (캡처 후 선택 확장으로 편입된 경우 포함) + const findLayerIndex = (id: string | null | undefined): number => + id == null || draggedSet.has(id) + ? -1 + : display.findIndex( + (di) => di.displayType === 'layer' && di.item.id === id, + ); + const findHeaderIndex = (groupId: string | null | undefined): number => + groupId == null + ? -1 + : display.findIndex( + (di) => di.displayType === 'group-header' && di.groupId === groupId, + ); + // 각 측 앵커: layer 우선, 없으면 헤더 경계 + const beforeCaptured = + target.anchorBeforeId != null || target.anchorBeforeHeaderGroupId != null; + const afterCaptured = + target.anchorAfterId != null || target.anchorAfterHeaderGroupId != null; + const beforeIdx = + target.anchorBeforeId != null + ? findLayerIndex(target.anchorBeforeId) + : findHeaderIndex(target.anchorBeforeHeaderGroupId); + const afterIdx = + target.anchorAfterId != null + ? findLayerIndex(target.anchorAfterId) + : findHeaderIndex(target.anchorAfterHeaderGroupId); + if (beforeCaptured && afterCaptured) { + if (beforeIdx !== -1 && afterIdx !== -1) { + // 순서 역전 = 병행 재정렬이 두 앵커 관계를 갈랐다 - 무커밋 + if (beforeIdx >= afterIdx) return null; + for (let i = beforeIdx + 1; i < afterIdx; i++) { + const di = display[i]; + if (di.displayType === 'group-header') return null; + if (di.displayType === 'layer' && !draggedSet.has(di.item.id)) { + return null; + } + } + return beforeIdx + 1; + } + if (beforeIdx !== -1) return beforeIdx + 1; + if (afterIdx !== -1) return afterIdx; + return null; + } + if (beforeCaptured) { + return beforeIdx !== -1 ? beforeIdx + 1 : null; + } + if (afterCaptured) { + return afterIdx !== -1 ? afterIdx : null; + } + // 앵커가 원래 없던 경계(빈 목록 최상단 등)는 캡처 index 유지 + return target.toDisplayIndex; +}; + interface UseLayerDnDParams { selectedKeyType: string; layerItemsRef: React.MutableRefObject; displayItemsRef: React.MutableRefObject; + buildLiveLayerModel: () => { + layerItems: LayerItem[]; + displayItems: DisplayItem[]; + }; scrollElementRef: React.MutableRefObject; clearPendingDeselect: () => void; } @@ -36,6 +179,7 @@ export function useLayerDnD({ selectedKeyType, layerItemsRef, displayItemsRef, + buildLiveLayerModel, scrollElementRef, clearPendingDeselect, }: UseLayerDnDParams) { @@ -58,16 +202,16 @@ export function useLayerDnD({ const didDragRef = useRef(false); const dragStateRef = useRef<{ itemHeight: number; - currentDropTarget: { - toDisplayIndex: number; - targetGroupId: string | undefined; - } | null; + currentDropTarget: DropAnchors | null; } | null>(null); const draggedItemIdsRef = useRef([]); const groupDragStateRef = useRef<{ groupId: string; itemHeight: number; currentOverIndex: number | null; + anchors: DropAnchors | null; + // 앵커 후보에서 제외한 id들 - mouseup의 이동 집합과 대조해 축소 감지 + excludedIds: string[]; } | null>(null); // ────────────────────────────────────────────────────────────────────────── @@ -77,9 +221,11 @@ export function useLayerDnD({ const resolveItemDropTarget = ( displaySlotIndex: number, draggingItemIds: ReadonlySet, + model?: { layerItems: LayerItem[]; displayItems: DisplayItem[] }, ) => { - const items = layerItemsRef.current; - const currentDisplay = displayItemsRef.current; + // 커밋 판정은 mouseup의 live 모델을 받는다 - ref는 드래그 중 프리뷰 전용 + const items = model?.layerItems ?? layerItemsRef.current; + const currentDisplay = model?.displayItems ?? displayItemsRef.current; const safeSlotIndex = Math.max( 0, Math.min(currentDisplay.length, displaySlotIndex), @@ -243,16 +389,33 @@ export function useLayerDnD({ // 다중 아이템 드롭 처리 // ────────────────────────────────────────────────────────────────────────── + // 앵커 소실 정책: 양생존·인접이면 사이, 하나 생존이면 그 기준, 양소실 + // 또는 비인접이면 무커밋. 그룹 헤더 앵커는 그룹이 살아 있을 때만. + // 대상 그룹이 삭제됐으면 무커밋 const performMultiDrop = async ( draggedIds: string[], toDisplayIndex: number, dropContext?: { targetGroupId: string | undefined; preserveFullGroups?: boolean; + liveModel?: { layerItems: LayerItem[]; displayItems: DisplayItem[] }; }, ) => { - const items = [...layerItemsRef.current]; - const currentDisplay = displayItemsRef.current; + // 순서 계산 입력도 authoritative 재구성 목록 - effect 지연 ref 금지 + const liveModel = dropContext?.liveModel ?? buildLiveLayerModel(); + const items = [...liveModel.layerItems]; + const currentDisplay = liveModel.displayItems; + // 대상 그룹이 mouseup까지 살아있는지 최종 검증 - 삭제됐으면 ghost + // groupId 커밋 대신 무커밋 (anchors 해석을 안 거치는 호출 경로 방어) + if (dropContext?.targetGroupId) { + const targetAlive = + currentDisplay.some( + (di) => + di.displayType === 'group-header' && + di.groupId === dropContext.targetGroupId, + ) || items.some((item) => item.groupId === dropContext.targetGroupId); + if (!targetAlive) return; + } const draggedIdSet = new Set(draggedIds); const draggedItems = items.filter((item) => draggedIdSet.has(item.id)); @@ -405,85 +568,113 @@ export function useLayerDnD({ ); if (!orderChanged && !groupChanged) return; - // 커밋 base는 canonical - rendered에는 다른 세션의 미커밋 프리뷰가 섞일 수 있음 - const currentPositions = useKeyStore.getState().canonicalPositions; - const currentStatPositions = useStatItemStore.getState().positions; - const currentGraphPositions = useGraphItemStore.getState().positions; - const currentKnobPositions = useKnobItemStore.getState().positions; - const currentLayerGroups = useLayerGroupStore.getState().layerGroups; - - // z-index 재계산 및 적용 + // 새 표시 순서를 id 의도로 변환 - effect 지연 ref의 item.index로 현재 + // 배열을 인덱싱하면 canonical 적용과 effect 사이 창에서 다른 요소를 + // 수정한다. 적용은 전부 position.id 매칭 const maxZIndex = newItems.length - 1; - - const updatedPositions = { ...currentPositions }; - const currentModePositions = [...(updatedPositions[selectedKeyType] || [])]; - const updatedStatPositions = { ...currentStatPositions }; - const currentStatModePositions = [ - ...(updatedStatPositions[selectedKeyType] || []), - ]; - const updatedGraphPositions = { ...currentGraphPositions }; - const currentGraphModePositions = [ - ...(updatedGraphPositions[selectedKeyType] || []), - ]; - const updatedKnobPositions = { ...currentKnobPositions }; - const currentKnobModePositions = [ - ...(updatedKnobPositions[selectedKeyType] || []), - ]; - const pluginZIndexUpdates: Array<{ fullId: string; zIndex: number }> = []; + const nativeIntents = new Map< + 'key' | 'stat' | 'graph' | 'knob', + Map> + >(); newItems.forEach((item, idx) => { const newZIndex = maxZIndex - idx; - const isDraggedItem = draggedIdSet.has(item.id); - - if (item.type === 'key' && item.index !== undefined) { - if (currentModePositions[item.index]) { - currentModePositions[item.index] = { - ...currentModePositions[item.index], - zIndex: newZIndex, - ...(isDraggedItem && !preserveGroupIds.has(item.id) - ? { groupId: newGroupId } - : {}), - }; - } - } else if (item.type === 'stat' && item.index !== undefined) { - if (currentStatModePositions[item.index]) { - currentStatModePositions[item.index] = { - ...currentStatModePositions[item.index], - zIndex: newZIndex, - ...(isDraggedItem && !preserveGroupIds.has(item.id) - ? { groupId: newGroupId } - : {}), - }; - } - } else if (item.type === 'graph' && item.index !== undefined) { - if (currentGraphModePositions[item.index]) { - currentGraphModePositions[item.index] = { - ...currentGraphModePositions[item.index], - zIndex: newZIndex, - ...(isDraggedItem && !preserveGroupIds.has(item.id) - ? { groupId: newGroupId } - : {}), - }; - } - } else if (item.type === 'knob' && item.index !== undefined) { - if (currentKnobModePositions[item.index]) { - currentKnobModePositions[item.index] = { - ...currentKnobModePositions[item.index], - zIndex: newZIndex, - ...(isDraggedItem && !preserveGroupIds.has(item.id) - ? { groupId: newGroupId } - : {}), - }; - } - } else if (item.type === 'plugin') { + if (item.type === 'plugin') { pluginZIndexUpdates.push({ fullId: item.id, zIndex: newZIndex }); + return; + } + const intent: Record = { zIndex: newZIndex }; + if (draggedIdSet.has(item.id) && !preserveGroupIds.has(item.id)) { + intent.groupId = newGroupId; } + const byId = nativeIntents.get(item.type) ?? new Map(); + byId.set(item.id, intent); + nativeIntents.set(item.type, byId); }); - updatedPositions[selectedKeyType] = currentModePositions; - updatedStatPositions[selectedKeyType] = currentStatModePositions; - updatedGraphPositions[selectedKeyType] = currentGraphModePositions; - updatedKnobPositions[selectedKeyType] = currentKnobModePositions; + const modeNativeOnly = items.every( + (item) => + item.type !== 'plugin' && + item.id.length > 0 && + !isSyntheticElementId(item.id), + ); + + if (modeNativeOnly) { + // native 전용: eager·receipt는 속성 의도가 소유하고, layerGroups + // 정규화는 슬롯의 base+의도에서 재계산해 생성 patch에만 싣는다 + // (편입 시 낙관 적용이 그룹 정의를 반영) + void runElementIntent({ + applyEager: () => applyPropertyIntentsEagerly(nativeIntents), + generate: (base) => { + // 모드 한정 재적용 - 대기 중 다른 모드로 이동한 요소는 skip + const propertyPatch = generateModeScopedIntentPatch( + base, + nativeIntents, + selectedKeyType, + ); + if (!propertyPatch) return { kind: 'targetLost' }; + const renormalized = normalizeLayerGroupsForMode({ + mode: selectedKeyType, + keyPositions: (propertyPatch.keyPositions ?? + base.keyPositions) as never, + statPositions: (propertyPatch.statPositions ?? + base.statPositions) as never, + graphPositions: (propertyPatch.graphPositions ?? + base.graphPositions) as never, + knobPositions: (propertyPatch.knobPositions ?? + base.knobPositions) as never, + layerGroups: base.layerGroups as never, + }); + return intentPatch({ + schemaVersion: 1, + keyPositions: renormalized.keyPositions as never, + statPositions: renormalized.statPositions as never, + graphPositions: renormalized.graphPositions as never, + knobPositions: renormalized.knobPositions as never, + ...(renormalized.groupsChanged + ? { layerGroups: renormalized.layerGroups as never } + : {}), + }); + }, + }).catch(reportElementOpError); + return; + } + + // plugin 포함 모드: 기존 full-record 경로 유지 (id 매칭 적용으로 개선) + const applyIntentsToMode = ( + record: Record, + type: 'key' | 'stat' | 'graph' | 'knob', + ): Record => { + const byId = nativeIntents.get(type); + if (!byId || byId.size === 0) return record; + return { + ...record, + [selectedKeyType]: (record[selectedKeyType] ?? []).map((position) => { + const id = position.id; + if (typeof id !== 'string') return position; + const intent = byId.get(id); + return intent ? { ...position, ...intent, id } : position; + }), + }; + }; + + const updatedPositions = applyIntentsToMode( + useKeyStore.getState().canonicalPositions, + 'key', + ); + const updatedStatPositions = applyIntentsToMode( + useStatItemStore.getState().positions, + 'stat', + ); + const updatedGraphPositions = applyIntentsToMode( + useGraphItemStore.getState().positions, + 'graph', + ); + const updatedKnobPositions = applyIntentsToMode( + useKnobItemStore.getState().positions, + 'knob', + ); + const currentLayerGroups = useLayerGroupStore.getState().layerGroups; const normalized = normalizeLayerGroupsForMode({ mode: selectedKeyType, @@ -507,6 +698,7 @@ export function useLayerDnD({ await editorCoordinator.commitPatch({ schemaVersion: 1, keyPositions: normalized.keyPositions, + statPositions: normalized.statPositions, graphPositions: normalized.graphPositions, knobPositions: normalized.knobPositions, @@ -524,9 +716,11 @@ export function useLayerDnD({ const performGroupDrop = async ( groupId: string, targetDisplayIndex: number, + liveModelInput?: { layerItems: LayerItem[]; displayItems: DisplayItem[] }, ) => { - const items = [...layerItemsRef.current]; - const currentDisplay = displayItemsRef.current; + const liveModel = liveModelInput ?? buildLiveLayerModel(); + const items = [...liveModel.layerItems]; + const currentDisplay = liveModel.displayItems; const groupChildren = items.filter((item) => item.groupId === groupId); const remainingItems = items.filter((item) => item.groupId !== groupId); @@ -579,73 +773,78 @@ export function useLayerDnD({ ); if (!orderChanged) return; - // z-index 재계산 + // 새 표시 순서를 id 의도로 변환 (그룹 이동은 zIndex만) - index 인덱싱 금지 const maxZIndex = newItems.length - 1; - - const updatedPositions = { ...useKeyStore.getState().canonicalPositions }; - const currentModePositions = [...(updatedPositions[selectedKeyType] || [])]; - const updatedStatPositions = { - ...useStatItemStore.getState().positions, - }; - const currentStatModePositions = [ - ...(updatedStatPositions[selectedKeyType] || []), - ]; - const updatedGraphPositions = { - ...useGraphItemStore.getState().positions, - }; - const currentGraphModePositions = [ - ...(updatedGraphPositions[selectedKeyType] || []), - ]; - const updatedKnobPositions = { - ...useKnobItemStore.getState().positions, - }; - const currentKnobModePositions = [ - ...(updatedKnobPositions[selectedKeyType] || []), - ]; - const pluginZIndexUpdates: Array<{ fullId: string; zIndex: number }> = []; + const nativeIntents = new Map< + 'key' | 'stat' | 'graph' | 'knob', + Map> + >(); newItems.forEach((item, idx) => { const newZIndex = maxZIndex - idx; - if (item.type === 'key' && item.index !== undefined) { - if (currentModePositions[item.index]) { - currentModePositions[item.index] = { - ...currentModePositions[item.index], - zIndex: newZIndex, - }; - } - } else if (item.type === 'stat' && item.index !== undefined) { - if (currentStatModePositions[item.index]) { - currentStatModePositions[item.index] = { - ...currentStatModePositions[item.index], - zIndex: newZIndex, - }; - } - } else if (item.type === 'graph' && item.index !== undefined) { - if (currentGraphModePositions[item.index]) { - currentGraphModePositions[item.index] = { - ...currentGraphModePositions[item.index], - zIndex: newZIndex, - }; - } - } else if (item.type === 'knob' && item.index !== undefined) { - if (currentKnobModePositions[item.index]) { - currentKnobModePositions[item.index] = { - ...currentKnobModePositions[item.index], - zIndex: newZIndex, - }; - } - } else if (item.type === 'plugin') { + if (item.type === 'plugin') { pluginZIndexUpdates.push({ fullId: item.id, zIndex: newZIndex }); + return; } + const byId = nativeIntents.get(item.type) ?? new Map(); + byId.set(item.id, { zIndex: newZIndex }); + nativeIntents.set(item.type, byId); }); - updatedPositions[selectedKeyType] = currentModePositions; + const modeNativeOnly = items.every( + (item) => + item.type !== 'plugin' && + item.id.length > 0 && + !isSyntheticElementId(item.id), + ); + if (modeNativeOnly) { + void runElementIntent({ + applyEager: () => applyPropertyIntentsEagerly(nativeIntents), + generate: (base) => + intentPatch( + generateModeScopedIntentPatch(base, nativeIntents, selectedKeyType), + ), + }).catch(reportElementOpError); + return; + } + + // plugin 포함 모드: 기존 full-record 경로 유지 (id 매칭 적용으로 개선) + const applyGroupIntents = ( + record: Record, + type: 'key' | 'stat' | 'graph' | 'knob', + ): Record => { + const byId = nativeIntents.get(type); + if (!byId || byId.size === 0) return record; + return { + ...record, + [selectedKeyType]: (record[selectedKeyType] ?? []).map((position) => { + const id = position.id; + if (typeof id !== 'string') return position; + const intent = byId.get(id); + return intent ? { ...position, ...intent, id } : position; + }), + }; + }; + + const updatedPositions = applyGroupIntents( + useKeyStore.getState().canonicalPositions, + 'key', + ); + const updatedStatPositions = applyGroupIntents( + useStatItemStore.getState().positions, + 'stat', + ); + const updatedGraphPositions = applyGroupIntents( + useGraphItemStore.getState().positions, + 'graph', + ); + const updatedKnobPositions = applyGroupIntents( + useKnobItemStore.getState().positions, + 'knob', + ); useKeyStore.getState().setPositions(updatedPositions); - updatedStatPositions[selectedKeyType] = currentStatModePositions; useStatItemStore.getState().setPositions(updatedStatPositions); - updatedGraphPositions[selectedKeyType] = currentGraphModePositions; useGraphItemStore.getState().setPositions(updatedGraphPositions); - updatedKnobPositions[selectedKeyType] = currentKnobModePositions; useKnobItemStore.getState().setPositions(updatedKnobPositions); setPluginElementZIndexes(pluginZIndexUpdates); @@ -760,9 +959,45 @@ export function useLayerDnD({ } else if (dropDisplayIndex == null) { dropDisplayIndex = dropTarget.toIndex; } + // 드롭 위치를 숫자 index가 아니라 이웃 앵커 id로도 캡처 - mouseup까지 + // 외부 재정렬이 끼면 index는 다른 슬롯을 가리킨다 (그룹 헤더 아래는 + // 명시적 그룹 앵커로 별도 보존) + const display = displayItemsRef.current; + let anchorBeforeId: string | null = null; + let anchorAfterId: string | null = null; + let anchorBeforeHeaderGroupId: string | null = null; + let anchorAfterHeaderGroupId: string | null = null; + for (let i = dropDisplayIndex - 1; i >= 0; i--) { + const di = display[i]; + if (di.displayType === 'group-header') { + // 그룹 경계 자체를 앵커로 - 그룹 사이 빈 슬롯의 숫자 fallback 방지 + anchorBeforeHeaderGroupId = di.groupId; + break; + } + if (di.displayType === 'layer' && !draggingSet.has(di.item.id)) { + anchorBeforeId = di.item.id; + break; + } + } + for (let i = dropDisplayIndex; i < display.length; i++) { + const di = display[i]; + if (di.displayType === 'group-header') { + anchorAfterHeaderGroupId = di.groupId; + break; + } + if (di.displayType === 'layer' && !draggingSet.has(di.item.id)) { + anchorAfterId = di.item.id; + break; + } + } dragStateRef.current.currentDropTarget = { toDisplayIndex: dropDisplayIndex, targetGroupId: dropTarget.targetGroupId, + anchorBeforeId, + anchorAfterId, + anchorHeaderGroupId: dropTarget.indicatorHeaderBottomGroupId ?? null, + anchorBeforeHeaderGroupId, + anchorAfterHeaderGroupId, }; setDragOverItemDisplayIndex(dropTarget.indicatorDisplayIndex); setDragOverHeaderBottomGroupId(dropTarget.indicatorHeaderBottomGroupId); @@ -779,9 +1014,20 @@ export function useLayerDnD({ if (target) { const draggedIds = draggedItemIdsRef.current; - performMultiDrop(draggedIds, target.toDisplayIndex, { - targetGroupId: target.targetGroupId, - }); + // authoritative 재구성 목록에서 앵커를 재해석 - effect 지연 ref는 + // 외부 재정렬을 한 렌더 늦게 본다. 소실·역전·비인접이면 무커밋 + const liveModel = buildLiveLayerModel(); + const resolvedIndex = resolveDropIndexFromAnchors( + target, + new Set(draggedIds), + liveModel.displayItems, + ); + if (resolvedIndex != null) { + performMultiDrop(draggedIds, resolvedIndex, { + targetGroupId: target.targetGroupId, + liveModel, + }); + } } } @@ -817,6 +1063,8 @@ export function useLayerDnD({ groupId, itemHeight: rect.height, currentOverIndex: null, + anchors: null, + excludedIds: [], }; dragStartRef.current = { x: e.clientX, y: e.clientY }; isDraggingRef.current = false; @@ -864,6 +1112,66 @@ export function useLayerDnD({ ); groupDragStateRef.current.currentOverIndex = newIndex; + // 그룹 드래그도 이웃 앵커를 캡처 - 숫자 index는 mouseup까지의 외부 + // 재정렬을 모른다. 그룹이 선택된 상태면 함께 이동할 추가 선택도 + // 앵커 후보에서 제외 (이동 요소는 고정 기준점이 될 수 없다) + const groupDraggingSet = new Set( + layerItemsRef.current + .filter((item) => item.groupId === groupDragStateRef.current!.groupId) + .map((item) => item.id), + ); + const captureSelection = useGridSelectionStore.getState(); + if ( + captureSelection.selectedGroupIds.includes( + groupDragStateRef.current.groupId, + ) + ) { + for (const el of captureSelection.selectedElements) { + groupDraggingSet.add(el.id); + } + } + const groupDisplay = displayItemsRef.current; + let groupAnchorBeforeId: string | null = null; + let groupAnchorAfterId: string | null = null; + let groupAnchorBeforeHeaderId: string | null = null; + let groupAnchorAfterHeaderId: string | null = null; + const draggedHeaderGroupId = groupDragStateRef.current.groupId; + for (let i = newIndex - 1; i >= 0; i--) { + const di = groupDisplay[i]; + if (di.displayType === 'group-header') { + if (di.groupId !== draggedHeaderGroupId) { + groupAnchorBeforeHeaderId = di.groupId; + } + break; + } + if (di.displayType === 'layer' && !groupDraggingSet.has(di.item.id)) { + groupAnchorBeforeId = di.item.id; + break; + } + } + for (let i = newIndex; i < groupDisplay.length; i++) { + const di = groupDisplay[i]; + if (di.displayType === 'group-header') { + if (di.groupId !== draggedHeaderGroupId) { + groupAnchorAfterHeaderId = di.groupId; + } + break; + } + if (di.displayType === 'layer' && !groupDraggingSet.has(di.item.id)) { + groupAnchorAfterId = di.item.id; + break; + } + } + groupDragStateRef.current.anchors = { + toDisplayIndex: newIndex, + targetGroupId: undefined, + anchorBeforeId: groupAnchorBeforeId, + anchorAfterId: groupAnchorAfterId, + anchorHeaderGroupId: null, + anchorBeforeHeaderGroupId: groupAnchorBeforeHeaderId, + anchorAfterHeaderGroupId: groupAnchorAfterHeaderId, + }; + groupDragStateRef.current.excludedIds = [...groupDraggingSet]; setDragOverDisplayIndex(newIndex); }; const moveScheduler = createRafLatestScheduler(applyMouseMove); @@ -874,45 +1182,66 @@ export function useLayerDnD({ moveScheduler.flush(); moveScheduler.cancel(); if (groupDragStateRef.current && isDraggingRef.current) { - const targetIdx = groupDragStateRef.current.currentOverIndex; - if (targetIdx !== null) { + const anchors = groupDragStateRef.current.anchors; + // 커밋 판정 순서: live 모델 → 그룹 생존 검증 → 이동 집합 확정 → + // 그 집합으로 앵커 해석. 앵커를 그룹 구성원만으로 먼저 풀면 함께 + // 이동하는 추가 선택이 고정 기준점으로 해석된다 + const liveModel = buildLiveLayerModel(); + const liveGroupMemberIds = new Set( + liveModel.layerItems + .filter((item) => item.groupId === groupId) + .map((item) => item.id), + ); + // 드래그 손잡이였던 그룹이 소실됐으면 무커밋 - 잔존 추가 선택만 + // 단독 이동시키지 않는다 + if (liveGroupMemberIds.size > 0) { const currentSel = useGridSelectionStore.getState().selectedElements; const currentGroupIds = useGridSelectionStore.getState().selectedGroupIds; const isGroupSelected = currentGroupIds.includes(groupId); - - if (isGroupSelected && currentSel.length > 0) { - const groupChildIds = new Set( - layerItemsRef.current - .filter((item) => item.groupId === groupId) - .map((c) => c.id), - ); - const hasExtraSelection = currentSel.some( - (el) => !groupChildIds.has(el.id), - ); - - if (hasExtraSelection) { - const allIds = [ - ...layerItemsRef.current - .filter((item) => item.groupId === groupId) - .map((c) => c.id), - ...currentSel - .filter((el) => !groupChildIds.has(el.id)) - .map((el) => el.id), - ]; + const extraIds = + isGroupSelected && currentSel.length > 0 + ? currentSel + .filter((el) => !liveGroupMemberIds.has(el.id)) + .map((el) => el.id) + : []; + const movingIds = [...liveGroupMemberIds, ...extraIds]; + const movingSet = new Set(movingIds); + // 캡처 때 이동 예정이라 앵커에서 제외했지만 mouseup에 이동 집합에서 + // 빠진(선택 축소) 생존 요소가 있으면 무커밋 - 앵커가 그 요소의 + // 잔류를 모르는 채 해석돼 단일 앵커 경로에서 오배치가 된다 + const liveIdSet = new Set( + liveModel.layerItems.map((item) => item.id), + ); + const excludedShrank = groupDragStateRef.current.excludedIds.some( + (id) => liveIdSet.has(id) && !movingSet.has(id), + ); + const targetIdx = excludedShrank + ? null + : anchors + ? resolveDropIndexFromAnchors( + anchors, + movingSet, + liveModel.displayItems, + ) + : groupDragStateRef.current.currentOverIndex; + if (targetIdx !== null) { + if (extraIds.length > 0) { + // live index를 live 모델로 재해석 - 지연 ref display로 풀면 + // 옛 이웃 기준 targetGroupId가 나온다 const dropTarget = resolveItemDropTarget( targetIdx, - new Set(allIds), + movingSet, + liveModel, ); - performMultiDrop(allIds, targetIdx, { + performMultiDrop(movingIds, targetIdx, { targetGroupId: dropTarget.targetGroupId, preserveFullGroups: true, + liveModel, }); } else { - performGroupDrop(groupId, targetIdx); + performGroupDrop(groupId, targetIdx, liveModel); } - } else { - performGroupDrop(groupId, targetIdx); } } } From 7d28e708ef5df23ff29a9cc1dc909ba46f11ef67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 12 Aug 2026 20:17:37 +0900 Subject: [PATCH 27/35] =?UTF-8?q?fix:=20=EA=B2=8C=EC=8A=A4=EC=B2=98=20?= =?UTF-8?q?=EC=BB=A4=EB=B0=8B=EC=9D=84=20=EC=8A=AC=EB=A1=AF=20=EC=9E=AC?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20generator=EB=A1=9C=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../editor/runtime/editorCoordinator.ts | 59 ++++++- .../runtime/mixedCommitRegeneration.test.ts | 156 ++++++++++++++++++ 2 files changed, 209 insertions(+), 6 deletions(-) create mode 100644 src/renderer/editor/runtime/mixedCommitRegeneration.test.ts diff --git a/src/renderer/editor/runtime/editorCoordinator.ts b/src/renderer/editor/runtime/editorCoordinator.ts index d2773653..6b844eff 100644 --- a/src/renderer/editor/runtime/editorCoordinator.ts +++ b/src/renderer/editor/runtime/editorCoordinator.ts @@ -70,6 +70,12 @@ export interface EditorCoordinatorState { export type EditorReadyUnsubscribe = (() => void) & { ready: Promise }; +// 직렬 슬롯 안에서 최신 base로 patch를 재생성하는 게스처 커밋 입력. +// null = editorChanges 없음 (plugin transaction은 실행) +export type EditorPatchGenerator = ( + base: EditorDocumentV1, +) => EditorPatchV1 | null; + export interface EditorCoordinatorTransport { get(): Promise; commit(request: EditorCommitRequest): Promise; @@ -628,16 +634,18 @@ export class EditorSaveCoordinator { } commitGesture( - changes: EditorPatchV1 | undefined, + changes: EditorPatchV1 | EditorPatchGenerator | undefined, gestureId: string, commit: ( context: EditorGestureCommitContext, ) => Promise, + meta?: { onEnrolled?: () => void; prepare?: () => Promise }, ): Promise { this.assertWritable(); const previous = this.gestureCommitTail; // 앞선 gesture 실패가 다음 gesture로 전파되지 않게 양쪽 경로 모두 실행 - const runInner = () => this.commitGestureInner(changes, gestureId, commit); + const runInner = () => + this.commitGestureInner(changes, gestureId, commit, meta); const run = previous.then(runInner, runInner); this.gestureCommitTail = run; void run.then( @@ -939,11 +947,12 @@ export class EditorSaveCoordinator { } private async commitGestureInner( - changes: EditorPatchV1 | undefined, + changes: EditorPatchV1 | EditorPatchGenerator | undefined, gestureId: string, commit: ( context: EditorGestureCommitContext, ) => Promise, + meta?: { onEnrolled?: () => void; prepare?: () => Promise }, ): Promise { await this.start(); await this.drainUntilSettled(); @@ -951,12 +960,27 @@ export class EditorSaveCoordinator { if (this.conflict) { throw this.error ?? new Error('editor conflict pending'); } + if (meta?.prepare) { + // 슬롯 안 준비 단계(plugin 큐 drain·projection 봉인 등) - 대기 중 + // 들어온 이벤트를 반영한 뒤 base를 동결해야 projection과 정렬된다 + await meta.prepare(); + await this.eventQueue; + if (this.conflict) { + throw this.error ?? new Error('editor conflict pending'); + } + } - const canonicalChanges = changes - ? canonicalizeEditorGradients(changes) + const baseDocument = clone(this.requireLastAck()); + // generator는 직렬 슬롯 안에서 최신 base로 평가한다 - 호출 시점 캡처 + // full-record는 대기 중 정산된 다른 커밋의 컬렉션 값을 되돌린다. + // null 반환은 editorChanges 없음일 뿐 transaction callback은 실행된다 + // (plugin 변경만 커밋하는 혼합 게스처) + const resolvedChanges = + typeof changes === 'function' ? changes(clone(baseDocument)) : changes; + const canonicalChanges = resolvedChanges + ? canonicalizeEditorGradients(resolvedChanges) : undefined; if (canonicalChanges) assertEditorPatch(canonicalChanges); - const baseDocument = clone(this.requireLastAck()); const target = canonicalChanges ? applyEditorPatch(baseDocument, canonicalChanges) : baseDocument; @@ -964,6 +988,22 @@ export class EditorSaveCoordinator { ? EDITOR_FIELDS.filter((field) => canonicalChanges[field] !== undefined) : []; const localFields = getChangedEditorFields(baseDocument, target); + // 슬롯 내 로컬 낙관 재적용 - 선행 커밋(격리 plugin 쓰기 등)이 호출 + // 시점의 eager 값을 canonical 적용으로 지웠을 수 있다. wire만 고치면 + // 백엔드는 맞고 UI 스토어는 옛 값에 남는다 + if (canonicalChanges) { + const currentDocument = this.readDocument(); + assertEditorDocument(currentDocument); + const optimisticDocument = applyEditorPatch( + currentDocument, + canonicalChanges, + ); + if ( + getChangedEditorFields(currentDocument, optimisticDocument).length > 0 + ) { + this.applyDocument(clone(optimisticDocument), 'localPatch'); + } + } const mutationId = this.createMutationId(); const inFlight: InFlightCommit = { mutationId, @@ -978,6 +1018,13 @@ export class EditorSaveCoordinator { this.rememberOwnMutation(inFlight); this.phase = 'saving'; this.notify(); + try { + // 편입 관측점 - 이후 실패는 gesture 실패 경로(pending·conflict)가 + // 소유한다. no-throw 계약이지만 방어적으로 격리 + meta?.onEnrolled?.(); + } catch (error) { + console.error('onEnrolled callback failed', error); + } try { const result = await commit({ diff --git a/src/renderer/editor/runtime/mixedCommitRegeneration.test.ts b/src/renderer/editor/runtime/mixedCommitRegeneration.test.ts new file mode 100644 index 00000000..85ea3dfb --- /dev/null +++ b/src/renderer/editor/runtime/mixedCommitRegeneration.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createDefaultKeyPosition } from '@src/renderer/editor/model/keys'; + +import { + applyEditorPatch, + createEditorCoordinator, + getChangedEditorFields, +} from './editorCoordinator'; +import { generatePropertyIntentPatch } from './elementIntent'; + +import type { + EditorCommitRequest, + EditorCommitResult, + EditorDocumentV1, + EditorGetResult, +} from '@src/types/editor'; +import type { + EditorCoordinatorTransport, + EditorReadyUnsubscribe, +} from './editorCoordinator'; + +const ID_K = '11111111-1111-4111-8111-111111111111'; + +const makeDocument = (): EditorDocumentV1 => ({ + schemaVersion: 1, + keys: { '4key': ['A'] }, + keyPositions: { '4key': [{ ...createDefaultKeyPosition(), id: ID_K }] }, + statPositions: {}, + graphPositions: {}, + knobPositions: {}, + layerGroups: {}, +}); + +class FakeTransport implements EditorCoordinatorTransport { + canonical: EditorGetResult; + readonly commitMock = + vi.fn<(request: EditorCommitRequest) => Promise>(); + + constructor(document: EditorDocumentV1) { + this.canonical = { revision: 0, document: structuredClone(document) }; + this.commitMock.mockImplementation(async (request) => { + const before = this.canonical.document; + const next = applyEditorPatch(before, request.changes); + const changedFields = getChangedEditorFields(before, next); + if (changedFields.length > 0) this.canonical.revision += 1; + this.canonical.document = next; + return { revision: this.canonical.revision, changedFields }; + }); + } + + get(): Promise { + return Promise.resolve(structuredClone(this.canonical)); + } + + commit(request: EditorCommitRequest): Promise { + return this.commitMock(request); + } + + onCommitted(): EditorReadyUnsubscribe { + return Object.assign(() => {}, { ready: Promise.resolve() }); + } +} + +const createHarness = () => { + const transport = new FakeTransport(makeDocument()); + let local = makeDocument(); + let mutationSequence = 0; + const coordinator = createEditorCoordinator({ + transport, + readDocument: () => structuredClone(local), + applyDocument: (document) => { + local = structuredClone(document); + }, + createMutationId: () => + `00000000-0000-4000-8000-${String(++mutationSequence).padStart(12, '0')}`, + focusTarget: null, + visibilityTarget: null, + }); + return { coordinator, transport }; +}; + +describe('mixed commit in-slot regeneration', () => { + it('슬롯 generator 재생성은 대기 중 정산된 격리 커밋을 되돌리지 않는다', async () => { + const harness = createHarness(); + await harness.coordinator.start(); + + // 격리 플러그인 쓰기가 큐에 먼저 - noteWidth=111 정산 + const isolated = harness.coordinator.commitIsolatedPluginPatch( + { + schemaVersion: 1, + keyPositions: { + '4key': [{ ...createDefaultKeyPosition(), id: ID_K, noteWidth: 111 }], + }, + }, + { multiKey: false }, + ); + // 혼합 게스처 커밋이 뒤에 - 시작 동결 의도(id별 width=120)를 슬롯 + // 최신 base에 재적용하는 generator 전달 + const gesture = harness.coordinator.commitGesture( + (base) => + generatePropertyIntentPatch( + base, + new Map([['key', new Map([[ID_K, { width: 120 }]])]]), + ), + 'gesture-mixed', + async (context) => + harness.transport.commit({ + baseRevision: context.editorBaseRevision, + mutationId: context.mutationId, + changes: context.editorChanges!, + }), + ); + + await isolated; + await gesture; + + const finalPosition = + harness.transport.canonical.document.keyPositions['4key'][0]; + // 두 변경 모두 생존해야 한다 + expect(finalPosition.width).toBe(120); + expect(finalPosition.noteWidth).toBe(111); + }); + + it('generator null은 editor 변경 없이 transaction callback을 실행한다', async () => { + const harness = createHarness(); + await harness.coordinator.start(); + + const commitCallback = vi.fn(async (context: { editorChanges?: unknown }) => + harness.transport.commit({ + baseRevision: 0, + mutationId: 'm-plugin-only', + changes: { schemaVersion: 1 }, + ...(context.editorChanges ? {} : {}), + }), + ); + let enrolled = false; + await harness.coordinator.commitGesture( + () => null, + 'gesture-null', + commitCallback, + { + onEnrolled: () => { + enrolled = true; + }, + }, + ); + + expect(commitCallback).toHaveBeenCalledTimes(1); + expect(commitCallback.mock.calls[0][0].editorChanges).toBeUndefined(); + expect(enrolled).toBe(true); + // 문서는 변하지 않는다 + expect( + harness.transport.canonical.document.keyPositions['4key'][0].noteWidth, + ).toBeUndefined(); + }); +}); From fcef0b419f7ae75bbf6cbff21417b9ead1f9d320 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 12 Aug 2026 20:17:52 +0900 Subject: [PATCH 28/35] =?UTF-8?q?fix:=20=ED=95=A9=EC=84=B1=20index=20?= =?UTF-8?q?=EC=9D=98=EB=8F=84=EC=99=80=20=EB=B4=89=EC=9D=B8=20receipt=20?= =?UTF-8?q?=EA=B3=84=EC=97=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../editor/runtime/elementIntent.test.ts | 212 ++++++++ src/renderer/editor/runtime/elementIntent.ts | 490 ++++++++++++++++-- 2 files changed, 671 insertions(+), 31 deletions(-) diff --git a/src/renderer/editor/runtime/elementIntent.test.ts b/src/renderer/editor/runtime/elementIntent.test.ts index 909cc978..126bd109 100644 --- a/src/renderer/editor/runtime/elementIntent.test.ts +++ b/src/renderer/editor/runtime/elementIntent.test.ts @@ -10,8 +10,14 @@ vi.mock('./editorStateCoordinator', () => ({ })); import { useKeyStore } from '@stores/data/useKeyStore'; +import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; import { + applyGestureIntentsEagerly, + applySealedSliceMutation, + applyIndexIntentsEagerly, applyPropertyIntentsEagerly, + captureIndexIntentBaseline, + generateIndexIntentPatch, intentPatch, runElementIntent, } from './elementIntent'; @@ -150,6 +156,212 @@ describe('elementIntent', () => { expect(rollback).not.toHaveBeenCalled(); }); + const baselineDocument = (overrides?: { + keys?: Record; + layerGroups?: Record; + positions?: Array>; + }) => ({ + schemaVersion: 1, + keys: overrides?.keys ?? { '4key': ['KeyA', 'KeyB'] }, + keyPositions: { + '4key': overrides?.positions ?? [ + { ...createDefaultKeyPosition(), width: 40 }, + { ...createDefaultKeyPosition(), width: 80 }, + ], + }, + statPositions: {}, + graphPositions: {}, + knobPositions: {}, + layerGroups: overrides?.layerGroups ?? {}, + }); + + const syncStoreToBaselineDocument = ( + document: ReturnType, + ) => { + useKeyStore.setState({ + selectedKeyType: '4key', + keyMappings: document.keys as never, + canonicalPositions: structuredClone(document.keyPositions) as never, + positions: structuredClone(document.keyPositions) as never, + }); + useLayerGroupStore.setState({ + layerGroups: structuredClone(document.layerGroups) as never, + }); + }; + + it('index eager는 keys pair만 변경돼도 fail-closed로 무적용한다', () => { + const document = baselineDocument(); + const baseline = captureIndexIntentBaseline(document, '4key', [ + 'keyPositions', + 'keys', + ]); + syncStoreToBaselineDocument(document); + // 대기 중 rebind가 keys[mode]만 교체 + useKeyStore.setState({ + keyMappings: { '4key': ['KeyC', 'KeyB'] } as never, + }); + + const eager = applyIndexIntentsEagerly( + baseline, + new Map([['key', new Map([[0, { width: 120 }]])]]), + ); + + expect(eager.matched).toBe(false); + expect(eager.receipt).toBeNull(); + expect(useKeyStore.getState().canonicalPositions['4key'][0].width).toBe(40); + }); + + it('index wire는 layerGroups만 변경돼도 null이다', () => { + const document = baselineDocument(); + const baseline = captureIndexIntentBaseline(document, '4key', [ + 'keyPositions', + 'layerGroups', + ]); + const movedBase = { + ...structuredClone(document), + layerGroups: { '4key': [{ id: 'g1', name: 'g1' }] }, + }; + + const patch = generateIndexIntentPatch( + movedBase as never, + baseline, + new Map([['key', new Map([[0, { width: 120 }]])]]), + ); + + expect(patch).toBeNull(); + }); + + it('index receipt는 재정렬 후 값이 충돌해도 다른 요소를 오염시키지 않는다', () => { + const document = baselineDocument(); + const baseline = captureIndexIntentBaseline(document, '4key', [ + 'keyPositions', + 'keys', + ]); + syncStoreToBaselineDocument(document); + + // index 0을 80으로 eager - 이제 index 1(원래 80)과 값이 같다 + const eager = applyIndexIntentsEagerly( + baseline, + new Map([['key', new Map([[0, { width: 80 }]])]]), + ); + expect(eager.matched).toBe(true); + + // 격리 커밋이 두 요소를 재정렬 (index 0 자리에 다른 요소, width 80) + const current = useKeyStore.getState().canonicalPositions['4key']; + useKeyStore.setState({ + canonicalPositions: { '4key': [current[1], current[0]] } as never, + }); + + eager.receipt!.rollback(); + + // 신원 증명 실패 - 어느 요소도 40으로 되돌리지 않는다 + const after = useKeyStore.getState().canonicalPositions['4key']; + expect(after[0].width).toBe(80); + expect(after[1].width).toBe(80); + }); + + it('index receipt는 무간섭이면 baseline 값으로 복원한다', () => { + const document = baselineDocument(); + const baseline = captureIndexIntentBaseline(document, '4key', [ + 'keyPositions', + 'keys', + ]); + syncStoreToBaselineDocument(document); + + const eager = applyIndexIntentsEagerly( + baseline, + new Map([['key', new Map([[0, { width: 120 }]])]]), + ); + expect(eager.matched).toBe(true); + expect(useKeyStore.getState().canonicalPositions['4key'][0].width).toBe( + 120, + ); + + eager.receipt!.rollback(); + + expect(useKeyStore.getState().canonicalPositions['4key'][0].width).toBe(40); + expect(useKeyStore.getState().canonicalPositions['4key'][1].width).toBe(80); + }); + + it('결합 eager 복원은 stable과 합성을 함께 되돌린다', () => { + const stablePosition = { + ...createDefaultKeyPosition(), + id: ID_A, + width: 40, + }; + const syntheticPosition = { ...createDefaultKeyPosition(), width: 80 }; + const document = baselineDocument({ + positions: [stablePosition, syntheticPosition], + }); + const baseline = captureIndexIntentBaseline(document, '4key', [ + 'keyPositions', + 'keys', + ]); + syncStoreToBaselineDocument(document); + + const eager = applyGestureIntentsEagerly({ + baseline, + indexIntents: new Map([['key', new Map([[1, { width: 200 }]])]]), + propertyIntents: new Map([['key', new Map([[ID_A, { width: 150 }]])]]), + }); + expect(eager.matched).toBe(true); + const applied = useKeyStore.getState().canonicalPositions['4key']; + expect(applied[0].width).toBe(150); + expect(applied[1].width).toBe(200); + + // targetLost·편입 전 실패의 복원 - stable 변경이 봉인 이전이므로 + // 합성 신원 검사가 자기 자신을 외부 개입으로 오판하지 않아야 한다 + eager.receipt!.rollback(); + + const after = useKeyStore.getState().canonicalPositions['4key']; + expect(after[0].width).toBe(40); + expect(after[1].width).toBe(80); + }); + + it('index wire는 keys pair만 변경돼도 null이다', () => { + const document = baselineDocument(); + const baseline = captureIndexIntentBaseline(document, '4key', [ + 'keyPositions', + 'keys', + ]); + const reboundBase = { + ...structuredClone(document), + keys: { '4key': ['KeyC', 'KeyB'] }, + }; + + const patch = generateIndexIntentPatch( + reboundBase as never, + baseline, + new Map([['key', new Map([[0, { width: 120 }]])]]), + ); + + expect(patch).toBeNull(); + }); + + it('봉인 mutate가 도중에 throw하면 부분 변경을 즉시 복원한다', () => { + const document = baselineDocument(); + syncStoreToBaselineDocument(document); + + expect(() => + applySealedSliceMutation({ + modes: ['4key'], + fields: ['keys', 'keyPositions'], + mutate: () => { + const state = useKeyStore.getState(); + state.setPositions({ + '4key': [{ ...state.canonicalPositions['4key'][0], width: 999 }], + } as never); + throw new Error('mid-mutation failure'); + }, + }), + ).toThrow('mid-mutation failure'); + + // 부분 적용이 남지 않는다 + const positions = useKeyStore.getState().canonicalPositions['4key']; + expect(positions).toHaveLength(2); + expect(positions[0].width).toBe(40); + }); + it('대상 소실(null)은 receipt 호출 후 committed false', async () => { const rollback = vi.fn(); api.commitGeneratedPatch.mockImplementation( diff --git a/src/renderer/editor/runtime/elementIntent.ts b/src/renderer/editor/runtime/elementIntent.ts index daadda84..b1d451e4 100644 --- a/src/renderer/editor/runtime/elementIntent.ts +++ b/src/renderer/editor/runtime/elementIntent.ts @@ -1,8 +1,11 @@ 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 { stableStringify } from '@utils/core/stableStringify'; + import { enqueueEditorCompatibilityOperation } from './editorCompatibilityQueue'; import { editorCoordinator } from './editorStateCoordinator'; @@ -47,6 +50,22 @@ export interface ElementIntentResult { document: EditorDocumentV1 | null; } +// destructive 의도의 전체 중단 sentinel - generator 평가는 coordinator의 +// inFlight 등록·낙관 적용 전이라 throw가 상태를 남기지 않고, mixed에서는 +// transaction callback(plugin 커밋)까지 실행을 막는다. 러너가 잡아서 +// receipt 복원 + skip 관측 후 정상 resolve한다 +export class ElementIntentAbort extends Error { + constructor(reason: string) { + super(`element intent aborted: ${reason}`); + this.name = 'ElementIntentAbort'; + } +} + +export const isElementIntentAbort = ( + error: unknown, +): error is ElementIntentAbort => + error instanceof Error && error.name === 'ElementIntentAbort'; + export const runElementIntent = async (options: { applyEager: () => ElementIntentReceipt | null; generate: (base: EditorDocumentV1) => ElementIntentGeneration; @@ -81,6 +100,10 @@ export const runElementIntent = async (options: { return { committed: true, satisfied: true, document }; } catch (error) { if (!enrolled) receipt?.rollback(); + if (isElementIntentAbort(error) && !enrolled) { + // 전체 중단은 오류가 아니라 fail-closed 무커밋 + return { committed: false, satisfied: false, document: null }; + } throw error; } }; @@ -122,7 +145,7 @@ const writeRecord = (type: NativeElementType, next: LooseRecord): void => { } }; -interface PropertyReceiptEntry { +export interface PropertyReceiptEntry { type: NativeElementType; id: string; field: string; @@ -130,37 +153,11 @@ interface PropertyReceiptEntry { expected: unknown; } -export const applyPropertyIntentsEagerly = ( - intents: PropertyIntents, +// id 키 필드 CAS receipt - eager 적용과 분리해 before를 외부 기준 +// (coordinator lastAck 등)으로 구성하는 호출자도 재사용한다 +export const createPropertyReceipt = ( + entries: PropertyReceiptEntry[], ): ElementIntentReceipt | null => { - const entries: PropertyReceiptEntry[] = []; - - for (const [type, byId] of intents) { - const record = readRecord(type); - let touched = false; - const next: LooseRecord = {}; - for (const [mode, list] of Object.entries(record)) { - next[mode] = list.map((position) => { - const id = position.id; - if (typeof id !== 'string') return position; - const patch = byId.get(id); - if (!patch) return position; - touched = true; - for (const [field, expected] of Object.entries(patch)) { - entries.push({ - type, - id, - field, - before: position[field], - expected, - }); - } - return { ...position, ...patch, id }; - }); - } - if (touched) writeRecord(type, next); - } - if (entries.length === 0) return null; return { rollback: () => { @@ -196,6 +193,56 @@ export const applyPropertyIntentsEagerly = ( }; }; +// 여러 receipt를 역순 롤백 하나로 결합 +export const combineReceipts = ( + ...receipts: Array +): ElementIntentReceipt | null => { + const active = receipts.filter( + (receipt): receipt is ElementIntentReceipt => receipt !== null, + ); + if (active.length === 0) return null; + if (active.length === 1) return active[0]; + return { + rollback: () => { + for (const receipt of [...active].reverse()) receipt.rollback(); + }, + }; +}; + +export const applyPropertyIntentsEagerly = ( + intents: PropertyIntents, +): ElementIntentReceipt | null => { + const entries: PropertyReceiptEntry[] = []; + + for (const [type, byId] of intents) { + const record = readRecord(type); + let touched = false; + const next: LooseRecord = {}; + for (const [mode, list] of Object.entries(record)) { + next[mode] = list.map((position) => { + const id = position.id; + if (typeof id !== 'string') return position; + const patch = byId.get(id); + if (!patch) return position; + touched = true; + for (const [field, expected] of Object.entries(patch)) { + entries.push({ + type, + id, + field, + before: position[field], + expected, + }); + } + return { ...position, ...patch, id }; + }); + } + if (touched) writeRecord(type, next); + } + + return createPropertyReceipt(entries); +}; + // 최신 base에서 속성 의도를 재적용하는 표준 generator export const generatePropertyIntentPatch = ( base: EditorDocumentV1, @@ -241,3 +288,384 @@ export const generatePropertyIntentPatch = ( export const reportElementOpError = (error: unknown): void => { console.error('Element operation failed', error); }; + +// fail-closed 무커밋은 오류가 아니라 정상 resolve - 별도 관측 경로 +export const reportElementOpSkipped = (context: string): void => { + console.warn('Element operation skipped (fail-closed)', context); +}; + +// --------------------------------------------------------------------------- +// 합성 index 의도: 안정 id가 없는 요소는 게스처 시작 시점의 컬렉션 구조 +// fingerprint와 index를 함께 동결하고, 이후 모든 적용(eager·wire 생성)을 +// "구조가 시작과 정확히 같다"는 증명 아래에서만 수행한다. 완료 시점 캡처는 +// 시작과 완료 사이에 정산된 외부 재정렬을 통과시키므로 금지 +// --------------------------------------------------------------------------- + +export type IndexBaselineField = + | 'keys' + | 'keyPositions' + | 'statPositions' + | 'graphPositions' + | 'knobPositions' + | 'layerGroups'; + +export interface IndexIntentBaseline { + mode: string; + fields: readonly IndexBaselineField[]; + // 시작 시점 mode 한정 스냅샷 - fingerprint 비교와 receipt before 값의 원천 + slices: Partial>; + fingerprint: string; +} + +const POSITION_FIELD_BY_TYPE: Record< + NativeElementType, + Extract< + IndexBaselineField, + 'keyPositions' | 'statPositions' | 'graphPositions' | 'knobPositions' + > +> = { + key: 'keyPositions', + stat: 'statPositions', + graph: 'graphPositions', + knob: 'knobPositions', +}; + +const sliceOf = ( + document: Record, + field: IndexBaselineField, + mode: string, +): unknown => (document[field] as Record | undefined)?.[mode]; + +const fingerprintOf = ( + slices: Partial>, + fields: readonly IndexBaselineField[], +): string => + stableStringify(fields.map((field) => [field, slices[field] ?? null])); + +// 게스처 시작 시점에 coordinator lastAck 문서로 호출한다. +// document가 없으면(start 전) baseline 없음 - 합성 경로는 무커밋 fail-closed +export const captureIndexIntentBaseline = ( + document: unknown, + mode: string, + fields: readonly IndexBaselineField[], +): IndexIntentBaseline | null => { + if (!document || typeof document !== 'object') return null; + const slices: Partial> = {}; + for (const field of fields) { + slices[field] = structuredClone( + sliceOf(document as Record, field, mode), + ); + } + return { + mode, + fields, + slices, + fingerprint: fingerprintOf(slices, fields), + }; +}; + +export type IndexIntents = ReadonlyMap< + NativeElementType, + ReadonlyMap> +>; + +export const indexBaselineMatches = ( + baseline: IndexIntentBaseline, + document: Record, +): boolean => { + const slices: Partial> = {}; + for (const field of baseline.fields) { + slices[field] = sliceOf(document, field, baseline.mode); + } + return fingerprintOf(slices, baseline.fields) === baseline.fingerprint; +}; + +// 스토어 측 문서 뷰 - baseline의 모든 필드(keys·layerGroups 포함)를 +// 표현해야 한다. 일부만 비교하면 key pair·그룹 구조 변경이 eager를 통과한다 +const storeDocumentView = (): Record => ({ + keys: useKeyStore.getState().keyMappings, + keyPositions: useKeyStore.getState().canonicalPositions, + statPositions: useStatItemStore.getState().positions, + graphPositions: useGraphItemStore.getState().positions, + knobPositions: useKnobItemStore.getState().positions, + layerGroups: useLayerGroupStore.getState().layerGroups, +}); + +const storeFingerprintOf = (baseline: IndexIntentBaseline): string => { + const document = storeDocumentView(); + const slices: Partial> = {}; + for (const field of baseline.fields) { + slices[field] = sliceOf(document, field, baseline.mode); + } + return fingerprintOf(slices, baseline.fields); +}; + +export interface IndexEagerResult { + // false = 시작 구조와 스토어 불일치 또는 baseline 부재 - 호출자는 이 + // intent 전체(eager·wire)를 fail-closed 무커밋해야 한다 + matched: boolean; + receipt: ElementIntentReceipt | null; +} + +interface IndexEagerEntry { + type: NativeElementType; + index: number; + field: string; + before: unknown; +} + +// index 쓰기만 수행 - fingerprint 봉인은 호출자가 모든 eager를 마친 뒤 한다 +const applyIndexWrites = ( + baseline: IndexIntentBaseline, + intents: IndexIntents, +): IndexEagerEntry[] => { + const entries: IndexEagerEntry[] = []; + for (const [type, byIndex] of intents) { + const record = readRecord(type); + const list = record[baseline.mode]; + if (!list) continue; + let touched = false; + const nextList = list.map((position, index) => { + const patch = byIndex.get(index); + if (!patch) return position; + touched = true; + const baselineList = baseline.slices[POSITION_FIELD_BY_TYPE[type]] as + | Array> + | undefined; + for (const field of Object.keys(patch)) { + entries.push({ + type, + index, + field, + before: baselineList?.[index]?.[field], + }); + } + return { ...position, ...patch, id: position.id }; + }); + if (touched) { + writeRecord(type, { ...record, [baseline.mode]: nextList }); + } + } + return entries; +}; + +// 봉인 시점의 스토어 상태와 정확히 같을 때만 복원 - 값 CAS는 재정렬 후 +// 우연히 같은 값을 가진 다른 요소를 오염시킬 수 있어 신원 증명이 못 된다 +const sealIndexReceipt = ( + baseline: IndexIntentBaseline, + entries: IndexEagerEntry[], +): ElementIntentReceipt => { + const sealedFingerprint = storeFingerprintOf(baseline); + return { + rollback: () => { + if (storeFingerprintOf(baseline) !== sealedFingerprint) return; + const byType = new Map(); + for (const entry of entries) { + const group = byType.get(entry.type) ?? []; + group.push(entry); + byType.set(entry.type, group); + } + for (const [type, group] of byType) { + const record = readRecord(type); + const list = record[baseline.mode]; + if (!list) continue; + const nextList = list.map((position, index) => { + const owned = group.filter((entry) => entry.index === index); + if (owned.length === 0) return position; + let restored = position; + for (const entry of owned) { + restored = { ...restored, [entry.field]: entry.before }; + } + return restored; + }); + writeRecord(type, { ...record, [baseline.mode]: nextList }); + } + }, + }; +}; + +// 게스처 eager 단일 소유: preflight 게이트 → stable id eager → 합성 index +// eager → 최종 상태에서 fingerprint 한 번 봉인 → 결합 receipt. +// 봉인을 중간에 하면 뒤따르는 stable eager가 같은 슬라이스를 바꿔 합성 +// receipt의 신원 검사가 자기 자신을 외부 개입으로 오판한다. +// rollback은 index(봉인 상태 증명 필요)가 먼저, stable CAS가 나중 +export const applyGestureIntentsEagerly = (options: { + baseline: IndexIntentBaseline | null; + indexIntents: IndexIntents; + propertyIntents?: PropertyIntents; +}): IndexEagerResult => { + const hasIndex = options.indexIntents.size > 0; + if (hasIndex) { + const baseline = options.baseline; + if (!baseline || storeFingerprintOf(baseline) !== baseline.fingerprint) { + return { matched: false, receipt: null }; + } + } + const propertyReceipt = + options.propertyIntents && options.propertyIntents.size > 0 + ? applyPropertyIntentsEagerly(options.propertyIntents) + : null; + let indexReceipt: ElementIntentReceipt | null = null; + if (hasIndex && options.baseline) { + const entries = applyIndexWrites(options.baseline, options.indexIntents); + if (entries.length > 0) { + indexReceipt = sealIndexReceipt(options.baseline, entries); + } + } + return { + matched: true, + // 역순 롤백: index 먼저(봉인 상태 그대로일 때), stable CAS 나중 + receipt: combineReceipts(propertyReceipt, indexReceipt), + }; +}; + +// 순수 합성 경로용 - 결합 applier의 property 없는 특수형 +export const applyIndexIntentsEagerly = ( + baseline: IndexIntentBaseline | null, + intents: IndexIntents, +): IndexEagerResult => + applyGestureIntentsEagerly({ baseline, indexIntents: intents }); + +// 슬롯 base가 시작 baseline과 정확히 일치할 때만 index 적용 patch를 생성. +// 불일치·baseline 부재는 null - 호출자는 targetLost(무커밋)로 다룬다 +export const generateIndexIntentPatch = ( + base: EditorDocumentV1, + baseline: IndexIntentBaseline | null, + intents: IndexIntents, + // 결합 generator가 base에서 fingerprint를 이미 검증하고 자기 의도를 + // 먼저 적용한 문서를 넘길 때만 true - 단독 사용 금지 + options?: { skipFingerprint?: boolean }, +): EditorPatchV1 | null => { + if (!baseline) return null; + if ( + !options?.skipFingerprint && + !indexBaselineMatches(baseline, base as unknown as Record) + ) { + return null; + } + const patch: EditorPatchV1 = { schemaVersion: 1 }; + let touchedAny = false; + for (const [type, byIndex] of intents) { + const field = POSITION_FIELD_BY_TYPE[type]; + const record = base[field] as unknown as LooseRecord; + const list = record[baseline.mode]; + if (!list) continue; + let touched = false; + const nextList = list.map((position, index) => { + const intentPatchFields = byIndex.get(index); + if (!intentPatchFields) return position; + touched = true; + return { ...position, ...intentPatchFields, id: position.id }; + }); + if (touched) { + patch[field] = { ...record, [baseline.mode]: nextList } as never; + touchedAny = true; + } + } + return touchedAny ? patch : null; +}; + +// --------------------------------------------------------------------------- +// 봉인 구조 변경 receipt: 삭제·paste처럼 배열 구조 자체가 바뀌는 eager는 +// 필드 CAS로 복원할 수 없다. 변경 전 mode 슬라이스를 통째로 캡처하고, +// 적용 직후 상태를 봉인해 "우리 이후 아무도 개입하지 않았다"가 증명될 때만 +// 캡처본을 통복원한다. 외부 개입 시 복원 포기(보수적 소유권) +// --------------------------------------------------------------------------- + +export const readFieldRecord = ( + field: IndexBaselineField, +): Record => + (field === 'keys' + ? useKeyStore.getState().keyMappings + : field === 'keyPositions' + ? useKeyStore.getState().canonicalPositions + : field === 'statPositions' + ? useStatItemStore.getState().positions + : field === 'graphPositions' + ? useGraphItemStore.getState().positions + : field === 'knobPositions' + ? useKnobItemStore.getState().positions + : useLayerGroupStore.getState().layerGroups) as Record; + +export const writeFieldModeSlices = ( + field: IndexBaselineField, + slices: ReadonlyMap, +): void => { + const merged = { ...readFieldRecord(field) }; + for (const [mode, slice] of slices) { + if (slice === undefined) { + delete merged[mode]; + } else { + merged[mode] = slice; + } + } + if (field === 'keys') { + useKeyStore.getState().setKeyMappings(merged as never); + } else if (field === 'keyPositions') { + useKeyStore.getState().setPositions(merged as never); + } else if (field === 'statPositions') { + useStatItemStore.getState().setPositions(merged as never); + } else if (field === 'graphPositions') { + useGraphItemStore.getState().setPositions(merged as never); + } else if (field === 'knobPositions') { + useKnobItemStore.getState().setPositions(merged as never); + } else { + useLayerGroupStore.getState().setLayerGroups(merged as never); + } +}; + +export const editorSliceFingerprint = ( + modes: readonly string[], + fields: readonly IndexBaselineField[], +): string => + stableStringify( + fields.map((field) => { + const record = readFieldRecord(field); + return [field, modes.map((mode) => [mode, record[mode] ?? null])]; + }), + ); + +// mutate(스토어 eager 적용)를 감싸 before 캡처와 봉인을 한 소유 단위로 묶는다 +export const applySealedSliceMutation = (options: { + modes: readonly string[]; + fields: readonly IndexBaselineField[]; + mutate: () => void; +}): ElementIntentReceipt => { + const before = new Map>(); + for (const field of options.fields) { + const record = readFieldRecord(field); + const byMode = new Map(); + for (const mode of options.modes) { + byMode.set(mode, structuredClone(record[mode])); + } + before.set(field, byMode); + } + try { + options.mutate(); + } catch (error) { + // 부분 적용 잔존 방지 - 캡처본으로 즉시 복원 후 원 오류 전파 + for (const [field, byMode] of before) { + writeFieldModeSlices(field, byMode); + } + throw error; + } + const sealedFingerprint = editorSliceFingerprint( + options.modes, + options.fields, + ); + return { + rollback: () => { + if ( + editorSliceFingerprint(options.modes, options.fields) !== + sealedFingerprint + ) { + return; + } + // keys와 keyPositions는 index 결합 - 함께 복원되도록 필드 순회가 + // 두 필드를 모두 포함해야 한다 (호출자가 fields에 pair를 넣는다) + for (const [field, byMode] of before) { + writeFieldModeSlices(field, byMode); + } + }, + }; +}; From 2c3ece0188192e38223f195593b64d7fa98ebbfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 12 Aug 2026 20:17:55 +0900 Subject: [PATCH 29/35] =?UTF-8?q?fix:=20=ED=98=BC=ED=95=A9=20=EA=B2=8C?= =?UTF-8?q?=EC=8A=A4=EC=B2=98=EB=A5=BC=20=EC=8A=AC=EB=A1=AF=20=EC=A0=95?= =?UTF-8?q?=ED=95=A9=20=EC=9D=98=EB=8F=84=20=EC=BB=A4=EB=B0=8B=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../editor/runtime/mixedElementIntent.test.ts | 138 +++++++ .../editor/runtime/mixedElementIntent.ts | 218 ++++++++++ .../commitMixedGestureIntent.test.ts | 379 ++++++++++++++++++ .../displayElement/gestureTransaction.ts | 349 ++++++++++++++-- 4 files changed, 1041 insertions(+), 43 deletions(-) create mode 100644 src/renderer/editor/runtime/mixedElementIntent.test.ts create mode 100644 src/renderer/editor/runtime/mixedElementIntent.ts create mode 100644 src/renderer/plugins/runtime/displayElement/commitMixedGestureIntent.test.ts diff --git a/src/renderer/editor/runtime/mixedElementIntent.test.ts b/src/renderer/editor/runtime/mixedElementIntent.test.ts new file mode 100644 index 00000000..388b25cf --- /dev/null +++ b/src/renderer/editor/runtime/mixedElementIntent.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + commitMixed: vi.fn(), + reportSkipped: vi.fn(), +})); + +vi.mock('@plugins/runtime/displayElement/gestureTransaction', () => ({ + commitMixedGestureTransaction: mocks.commitMixed, +})); + +vi.mock('./elementIntent', () => ({ + reportElementOpSkipped: mocks.reportSkipped, +})); + +import { runMixedElementIntent } from './mixedElementIntent'; + +import type { EditorDocumentV1, EditorPatchV1 } from '@src/types/editor'; + +type Generate = (base: EditorDocumentV1) => EditorPatchV1 | null; +type Meta = { onEnrolled?: () => void }; + +const baseOptions = (rollback: () => void, generate: Generate) => ({ + gestureId: 'gesture-1', + pluginIds: ['plugin-a'], + applyEager: () => ({ rollback }), + generate, + skipContext: 'test settlement', +}); + +describe('runMixedElementIntent receipt 소유권', () => { + beforeEach(() => { + mocks.commitMixed.mockReset(); + mocks.reportSkipped.mockClear(); + }); + + it('generatedNull은 성공해도 receipt를 복원하고 skip을 관측한다', async () => { + const rollback = vi.fn(); + mocks.commitMixed.mockImplementation( + async ( + _gestureId: string, + generate: Generate, + _ids: unknown, + meta: Meta, + ) => { + generate({} as EditorDocumentV1); + meta.onEnrolled?.(); + }, + ); + + await runMixedElementIntent(baseOptions(rollback, () => null)); + + expect(rollback).toHaveBeenCalledTimes(1); + expect(mocks.reportSkipped).toHaveBeenCalledWith('test settlement'); + }); + + it('expectNull은 복원하되 skip 관측을 생략한다', async () => { + const rollback = vi.fn(); + mocks.commitMixed.mockImplementation( + async ( + _gestureId: string, + generate: Generate, + _ids: unknown, + meta: Meta, + ) => { + generate({} as EditorDocumentV1); + meta.onEnrolled?.(); + }, + ); + + await runMixedElementIntent({ + ...baseOptions(rollback, () => null), + expectNull: true, + }); + + expect(rollback).toHaveBeenCalledTimes(1); + expect(mocks.reportSkipped).not.toHaveBeenCalled(); + }); + + it('편입 전 실패는 receipt를 복원하고 원 오류를 전파한다', async () => { + const rollback = vi.fn(); + mocks.commitMixed.mockRejectedValue(new Error('drain failed')); + + await expect( + runMixedElementIntent( + baseOptions(rollback, () => ({ schemaVersion: 1 })), + ), + ).rejects.toThrow('drain failed'); + + expect(rollback).toHaveBeenCalledTimes(1); + }); + + it('편입 후 실패는 gesture 실패 경로가 소유한다 - 복원 금지', async () => { + const rollback = vi.fn(); + mocks.commitMixed.mockImplementation( + async ( + _gestureId: string, + generate: Generate, + _ids: unknown, + meta: Meta, + ) => { + generate({} as EditorDocumentV1); + meta.onEnrolled?.(); + throw new Error('transaction failed'); + }, + ); + + await expect( + runMixedElementIntent( + baseOptions(rollback, () => ({ schemaVersion: 1 })), + ), + ).rejects.toThrow('transaction failed'); + + expect(rollback).not.toHaveBeenCalled(); + }); + + it('generatedNull 후 plugin 실패도 receipt를 복원한다 - null 판정 우선', async () => { + const rollback = vi.fn(); + mocks.commitMixed.mockImplementation( + async ( + _gestureId: string, + generate: Generate, + _ids: unknown, + meta: Meta, + ) => { + generate({} as EditorDocumentV1); + meta.onEnrolled?.(); + throw new Error('plugin commit failed'); + }, + ); + + await expect( + runMixedElementIntent(baseOptions(rollback, () => null)), + ).rejects.toThrow('plugin commit failed'); + + expect(rollback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/renderer/editor/runtime/mixedElementIntent.ts b/src/renderer/editor/runtime/mixedElementIntent.ts new file mode 100644 index 00000000..5ef7cf50 --- /dev/null +++ b/src/renderer/editor/runtime/mixedElementIntent.ts @@ -0,0 +1,218 @@ +import { + commitMixedGestureIntent, + commitMixedGestureTransaction, + type MixedIntentGeneration, +} from '@plugins/runtime/displayElement/gestureTransaction'; + +import { isElementIntentAbort, reportElementOpSkipped } from './elementIntent'; + +import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; + +import type { EditorDocumentV1, EditorPatchV1 } from '@src/types/editor'; +import type { PluginDisplayElementInternal } from '@src/types/plugin/api'; + +import type { ElementIntentReceipt } from './elementIntent'; + +// 혼합 게스처용 러너: runElementIntent의 편입 3상태 계약을 mixed +// transaction에 적용한다. generator는 coordinator 직렬 슬롯 안에서 최신 +// base로 평가되고, null은 editorChanges 없음(plugin 변경만 커밋)이다. +// +// 복원 판정은 generatedNull이 enrolled보다 우선한다 - editor 의도가 +// fail-closed로 무커밋된 뒤 plugin callback이 실패해도 eager 잔존은 +// 이 러너가 복원해야 한다 +export const runMixedElementIntent = async (options: { + gestureId: string; + pluginIds: readonly string[]; + applyEager: () => ElementIntentReceipt | null; + generate: (base: EditorDocumentV1) => EditorPatchV1 | null; + skipContext: string; + // 의도적으로 editor를 생략하는 호출자(plugin만 커밋)는 skip 관측 생략 + expectNull?: boolean; +}): Promise => { + const receipt = options.applyEager(); + let generatedNull = false; + let enrolled = false; + try { + await commitMixedGestureTransaction( + options.gestureId, + (base) => { + const patch = options.generate(base); + if (patch === null) generatedNull = true; + return patch; + }, + options.pluginIds, + { + onEnrolled: () => { + enrolled = true; + }, + }, + ); + if (generatedNull) { + receipt?.rollback(); + if (!options.expectNull) reportElementOpSkipped(options.skipContext); + } + } catch (error) { + if (generatedNull || !enrolled) receipt?.rollback(); + throw error; + } +}; + +// destructive(삭제·paste) 혼합 러너: 슬롯 정합 projection 위에서 3상태 +// generator를 실행하고, 전체 중단(sentinel)·prepare 실패의 receipt 복원을 +// staged 해제 전에 완료한다. targetLost 대신 sentinel을 쓰는 이유: +// null 반환은 plugin 변경만 커밋하는 슬라이스 A 의미론이라 destructive의 +// 부분 성공(plugin만 삭제·추가)을 만든다 - 전체 중단이 안전하다 +export const runMixedGestureElementIntent = async (options: { + gestureId: string; + initialPluginIds: readonly string[]; + pluginScope: ( + elements: readonly PluginDisplayElementInternal[], + ) => readonly string[]; + // eager는 호출 직전 이미 적용됨 - receipt만 전달 + receipt: ElementIntentReceipt | null; + generate: (context: { + base: EditorDocumentV1; + pluginProjection: readonly PluginDisplayElementInternal[]; + }) => MixedIntentGeneration; + skipContext: string; +}): Promise<{ committed: boolean; satisfied: boolean }> => { + let enrolled = false; + let lastKind: MixedIntentGeneration['kind'] | null = null; + let rolledBack = false; + const rollbackOnce = () => { + if (rolledBack) return; + rolledBack = true; + options.receipt?.rollback(); + }; + try { + await commitMixedGestureIntent({ + gestureId: options.gestureId, + initialPluginIds: options.initialPluginIds, + pluginScope: options.pluginScope, + generate: (context) => { + const generation = options.generate(context); + lastKind = generation.kind; + return generation; + }, + onEnrolled: () => { + enrolled = true; + }, + onFailureBeforeSettle: (error) => { + // 편입 전 실패·전체 중단만 이 러너가 소유 - staged 해제가 + // stagedSavePending 재저장을 예약하기 전에 동기 복원 + if (!enrolled || isElementIntentAbort(error)) rollbackOnce(); + }, + }); + if (lastKind === 'satisfied') { + return { committed: false, satisfied: true }; + } + return { committed: true, satisfied: true }; + } catch (error) { + if (isElementIntentAbort(error)) { + rollbackOnce(); + reportElementOpSkipped(options.skipContext); + return { committed: false, satisfied: false }; + } + if (!enrolled) rollbackOnce(); + throw error; + } +}; + +// --------------------------------------------------------------------------- +// plugin eager semantic receipt: staged 해제가 stagedSavePending 재저장을 +// 예약하므로 plugin UI 스토어의 eager도 편입 전 실패에서 동기 복원해야 +// 영속 유출이 없다. canonical pull은 편입 후 실패의 보조 복구 +// --------------------------------------------------------------------------- + +// 제거 membership: mutate 직전 원본·index를 캡처, 복원은 fullId 부재 시 재삽입 +export const applyPluginRemovalEagerly = ( + fullIds: readonly string[], + mutate: () => void, +): ElementIntentReceipt | null => { + const wanted = new Set(fullIds); + if (wanted.size === 0) { + mutate(); + return null; + } + const before = usePluginDisplayElementStore.getState().elements; + const removed = before + .map((element, index) => ({ element, index })) + .filter(({ element }) => wanted.has(element.fullId)); + try { + mutate(); + } catch (error) { + // 부분 적용·리스너 실패 잔존 방지 - 캡처본 통복원 후 원 오류 전파 + usePluginDisplayElementStore + .getState() + .setElements([...before], { skipSync: true }); + throw error; + } + if (removed.length === 0) return null; + return { + rollback: () => { + const store = usePluginDisplayElementStore.getState(); + const current = [...store.elements]; + const present = new Set(current.map((element) => element.fullId)); + let touched = false; + for (const { element, index } of removed) { + if (present.has(element.fullId)) continue; + current.splice(Math.min(index, current.length), 0, element); + touched = true; + } + if (touched) store.setElements(current, { skipSync: true }); + }, + }; +}; + +// 추가 membership + 기존 요소 zIndex CAS: 신규 fullId는 제거로, 기존 요소의 +// zIndex 변경은 expected 일치 시에만 before로 복원 +export const applyPluginAdditionEagerly = ( + addedFullIds: readonly string[], + zChanges: ReadonlyArray<{ + fullId: string; + before: number | undefined; + expected: number; + }>, + mutate: () => void, +): ElementIntentReceipt | null => { + const before = usePluginDisplayElementStore.getState().elements; + try { + mutate(); + } catch (error) { + // 부분 적용·리스너 실패 잔존 방지 - 캡처본 통복원 후 원 오류 전파 + usePluginDisplayElementStore + .getState() + .setElements([...before], { skipSync: true }); + throw error; + } + if (addedFullIds.length === 0 && zChanges.length === 0) return null; + const added = new Set(addedFullIds); + return { + rollback: () => { + const store = usePluginDisplayElementStore.getState(); + let current = store.elements; + let touched = false; + if (added.size > 0) { + const filtered = current.filter( + (element) => !added.has(element.fullId), + ); + if (filtered.length !== current.length) { + current = filtered; + touched = true; + } + } + if (zChanges.length > 0) { + const byId = new Map(zChanges.map((change) => [change.fullId, change])); + current = current.map((element) => { + const change = byId.get(element.fullId); + if (!change) return element; + // CAS: 우리가 쓴 값 그대로일 때만 복원 + if (element.zIndex !== change.expected) return element; + touched = true; + return { ...element, zIndex: change.before }; + }) as typeof current; + } + if (touched) store.setElements([...current], { skipSync: true }); + }, + }; +}; diff --git a/src/renderer/plugins/runtime/displayElement/commitMixedGestureIntent.test.ts b/src/renderer/plugins/runtime/displayElement/commitMixedGestureIntent.test.ts new file mode 100644 index 00000000..278d2523 --- /dev/null +++ b/src/renderer/plugins/runtime/displayElement/commitMixedGestureIntent.test.ts @@ -0,0 +1,379 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + drainQueues: vi.fn(() => Promise.resolve()), + stageGesture: vi.fn(), + unstageGesture: vi.fn(), + gestureCommit: vi.fn(() => + Promise.resolve({ + editorRevision: 1, + changedFields: [], + changedPluginIds: [], + pluginModelRevision: 1, + }), + ), + buildSaved: vi.fn( + (elements: Array<{ fullId: string; zIndex?: number }>, pluginId: string) => + elements.map( + (element) => `${pluginId}:${element.fullId}:${element.zIndex}`, + ), + ), + applyCanonical: vi.fn(() => Promise.resolve()), + setElements: vi.fn(), + rotateSession: vi.fn(), + elements: [] as Array>, +})); + +vi.mock('@api/modules/gestureApi', () => ({ + gestureApi: { commit: mocks.gestureCommit }, +})); + +vi.mock('@src/renderer/editor/runtime/editorStateCoordinator', () => ({ + editorCoordinator: { + // 실제 순서 재현: prepare → generator 평가 → onEnrolled → transaction callback + commitGesture: vi.fn( + async ( + changes: unknown, + _gestureId: string, + commit: (context: { + editorBaseRevision: number; + mutationId: string; + editorChanges?: unknown; + }) => Promise, + meta?: { onEnrolled?: () => void; prepare?: () => Promise }, + ) => { + await meta?.prepare?.(); + const patch = + typeof changes === 'function' + ? (changes as (base: unknown) => unknown)({ schemaVersion: 1 }) + : changes; + meta?.onEnrolled?.(); + await commit({ + editorBaseRevision: 0, + mutationId: 'mutation-1', + ...(patch ? { editorChanges: patch } : {}), + }); + return {}; + }, + ), + }, +})); + +vi.mock('@src/renderer/editor/runtime/editorWriteBarrier', () => ({ + trackEditorWrite: (promise: T) => promise, +})); + +vi.mock('@stores/plugin/usePluginDisplayElementStore', () => ({ + usePluginDisplayElementStore: { + getState: () => ({ + elements: mocks.elements, + definitions: new Map(), + setElements: mocks.setElements, + }), + }, +})); + +vi.mock('@stores/data/useHistoryStatusStore', () => ({ + useHistoryStatusStore: { getState: () => ({ historyEpoch: 0 }) }, +})); + +vi.mock('@plugins/rpc/pluginRpcClient', () => ({ + getPluginAuthorityGeneration: () => 0, +})); + +vi.mock('@plugins/rpc/pluginModelRevision', () => ({ + getBackendPluginRevision: () => 0, + noteBackendPluginRevision: vi.fn(), +})); + +vi.mock('./instancesUndoSync', () => ({ + applyCanonicalPluginInstances: mocks.applyCanonical, + notePluginInstancesMutation: vi.fn(), +})); + +vi.mock('../api/defineElement', () => ({ + buildSavedPluginInstances: mocks.buildSaved, +})); + +vi.mock('./instancesCommitQueue', () => ({ + drainPluginInstancesCommitQueues: mocks.drainQueues, + getStagedPluginInstancesGestureId: () => null, + hasConflictingPluginInstancesGesture: () => false, + rotatePluginInstancesEditSession: mocks.rotateSession, + stagePluginInstancesGesture: mocks.stageGesture, + unstagePluginInstancesGesture: mocks.unstageGesture, +})); + +vi.mock('@utils/plugin/panelModelSync', () => ({ + schedulePluginPanelModelSync: vi.fn(), +})); + +import { isElementIntentAbort } from '@src/renderer/editor/runtime/elementIntent'; + +import { commitMixedGestureIntent } from './gestureTransaction'; + +describe('commitMixedGestureIntent', () => { + beforeEach(() => { + mocks.drainQueues.mockClear(); + mocks.stageGesture.mockClear(); + mocks.unstageGesture.mockClear(); + mocks.gestureCommit.mockClear(); + mocks.buildSaved.mockClear(); + mocks.applyCanonical.mockClear(); + mocks.setElements.mockClear(); + mocks.rotateSession.mockClear(); + mocks.drainQueues.mockReset(); + mocks.drainQueues.mockImplementation(() => Promise.resolve()); + mocks.elements = []; + }); + + it('prepare 고정점이 상한까지 수렴하지 않으면 전체 중단한다', async () => { + let round = 0; + const onFailure = vi.fn(); + await expect( + commitMixedGestureIntent({ + gestureId: 'gesture-cap', + initialPluginIds: ['plugin-0'], + // 매 라운드 새 definition 출현 - 고정점 미성립 + pluginScope: () => { + round += 1; + return [`plugin-${round}`]; + }, + generate: () => ({ kind: 'patch', patch: { schemaVersion: 1 } }), + onFailureBeforeSettle: onFailure, + }), + ).rejects.toSatisfy((error: unknown) => isElementIntentAbort(error)); + + // 복원 훅이 staged 해제보다 먼저 + expect(onFailure).toHaveBeenCalledTimes(1); + const failureOrder = onFailure.mock.invocationCallOrder[0]; + const unstageOrder = mocks.unstageGesture.mock.invocationCallOrder[0]; + expect(failureOrder).toBeLessThan(unstageOrder); + // editor 커밋 미도달 + expect(mocks.gestureCommit).not.toHaveBeenCalled(); + }); + + it('transaction은 raw 봉인본이 아니라 generator의 desired projection을 저장한다', async () => { + mocks.elements = [ + { fullId: 'plugin-a:one', definitionId: 'plugin-a', zIndex: 0 }, + ]; + await commitMixedGestureIntent({ + gestureId: 'gesture-desired', + initialPluginIds: ['plugin-a'], + pluginScope: () => ['plugin-a'], + generate: ({ pluginProjection }) => ({ + kind: 'patch', + patch: { schemaVersion: 1 }, + desiredPluginProjection: pluginProjection.map((element) => ({ + ...element, + zIndex: 42, + })), + }), + }); + + expect(mocks.buildSaved).toHaveBeenCalledTimes(1); + const built = mocks.buildSaved.mock.results[0].value as string[]; + expect(built).toEqual(['plugin-a:plugin-a:one:42']); + // 성공 후 main store도 desired로 정렬 (prepare 동적 편입분 포함 계약) + expect(mocks.setElements).toHaveBeenCalledTimes(1); + const [mergedElements] = mocks.setElements.mock.calls[0] as unknown as [ + Array<{ fullId: string; zIndex?: number }>, + ]; + expect(mergedElements[0]).toMatchObject({ + fullId: 'plugin-a:one', + zIndex: 42, + }); + }); + + it('동적 발견 plugin은 stage 후 rotate되고 다음 drain이 관측한다', async () => { + // 첫 drain 뒤 신규 plugin-b 출현 - scope가 성장하는 순서 구성 + let drainCount = 0; + mocks.drainQueues.mockImplementation(() => { + drainCount += 1; + return Promise.resolve(); + }); + const generatorSpy = vi.fn(() => ({ + kind: 'patch' as const, + patch: { schemaVersion: 1 as const }, + })); + await commitMixedGestureIntent({ + gestureId: 'gesture-discover', + initialPluginIds: ['plugin-a'], + pluginScope: () => + drainCount >= 1 ? ['plugin-a', 'plugin-b'] : ['plugin-a'], + generate: generatorSpy, + }); + + // 발견분 rotate가 같은 게스처로 - 이후 drain·generator보다 앞선다 + expect(mocks.rotateSession).toHaveBeenCalledWith( + 'plugin-b', + 'gesture-discover', + ); + const stageBIndex = mocks.stageGesture.mock.calls.findIndex( + (call) => (call as unknown[])[0] === 'plugin-b', + ); + expect(stageBIndex).toBeGreaterThanOrEqual(0); + const stageBOrder = + mocks.stageGesture.mock.invocationCallOrder[stageBIndex]; + const rotateOrder = mocks.rotateSession.mock.invocationCallOrder[0]; + const lastDrainOrder = + mocks.drainQueues.mock.invocationCallOrder[ + mocks.drainQueues.mock.calls.length - 1 + ]; + const generatorOrder = generatorSpy.mock.invocationCallOrder[0]; + expect(stageBOrder).toBeLessThan(rotateOrder); + expect(rotateOrder).toBeLessThan(lastDrainOrder); + expect(lastDrainOrder).toBeLessThan(generatorOrder); + }); + + it('비영속 필드만 바뀐 요소도 영속 필드 CAS로 desired z를 적용한다', async () => { + const handler = () => 'runtime'; + mocks.elements = [ + { + fullId: 'plugin-a:one', + definitionId: 'plugin-a', + zIndex: 0, + state: { tick: 1 }, + onClick: handler, + }, + ]; + // commit IPC 사이 런타임 필드만 갱신 (영속 필드는 봉인과 동일) + mocks.gestureCommit.mockImplementationOnce(async () => { + mocks.elements = [ + { + fullId: 'plugin-a:one', + definitionId: 'plugin-a', + zIndex: 0, + state: { tick: 99 }, + onClick: handler, + }, + ]; + return { + editorRevision: 1, + changedFields: [], + changedPluginIds: [], + pluginModelRevision: 1, + }; + }); + await commitMixedGestureIntent({ + gestureId: 'gesture-runtime-churn', + initialPluginIds: ['plugin-a'], + pluginScope: () => ['plugin-a'], + generate: ({ pluginProjection }) => ({ + kind: 'patch', + patch: { schemaVersion: 1 }, + desiredPluginProjection: pluginProjection.map((element) => ({ + ...element, + zIndex: 42, + })), + }), + }); + + // 전체 객체 비교라면 state 변경이 소유 판정을 깨 z가 미적용된다 + expect(mocks.setElements).toHaveBeenCalledTimes(1); + const [aligned] = mocks.setElements.mock.calls[0] as unknown as [ + Array<{ fullId: string; zIndex?: number; state?: { tick: number } }>, + ]; + expect(aligned[0].zIndex).toBe(42); + // 비영속 필드는 현재 값 보존 (전체 교체라면 tick 1로 되돌아간다) + expect(aligned[0].state?.tick).toBe(99); + }); + + it('봉인 이후 병행 편집된 요소는 desired 정렬이 덮어쓰지 않는다', async () => { + mocks.elements = [ + { fullId: 'plugin-a:one', definitionId: 'plugin-a', zIndex: 0 }, + ]; + // commit IPC 사이에 병행 편집 발생 (봉인과 다른 현재값) + mocks.gestureCommit.mockImplementationOnce(async () => { + mocks.elements = [ + { fullId: 'plugin-a:one', definitionId: 'plugin-a', zIndex: 7 }, + ]; + return { + editorRevision: 1, + changedFields: [], + changedPluginIds: [], + pluginModelRevision: 1, + }; + }); + await commitMixedGestureIntent({ + gestureId: 'gesture-concurrent', + initialPluginIds: ['plugin-a'], + pluginScope: () => ['plugin-a'], + generate: ({ pluginProjection }) => ({ + kind: 'patch', + patch: { schemaVersion: 1 }, + desiredPluginProjection: pluginProjection.map((element) => ({ + ...element, + zIndex: 42, + })), + }), + }); + + // 현재(7) ≠ 봉인(0) - 외부 소유라 stale desired(42)로 덮지 않는다 + expect(mocks.setElements).not.toHaveBeenCalled(); + }); + + it('desired 정렬은 커밋된 삭제를 적용하고 봉인 후 신규 요소는 보존한다', async () => { + mocks.elements = [ + { fullId: 'plugin-a:gone', definitionId: 'plugin-a', zIndex: 0 }, + ]; + // commit IPC 사이: 삭제 대상이 재주입되고 봉인 후 신규 요소도 등장 + mocks.gestureCommit.mockImplementationOnce(async () => { + mocks.elements = [ + { fullId: 'plugin-a:gone', definitionId: 'plugin-a', zIndex: 0 }, + { fullId: 'plugin-a:new', definitionId: 'plugin-a', zIndex: 3 }, + ]; + return { + editorRevision: 1, + changedFields: [], + changedPluginIds: [], + pluginModelRevision: 1, + }; + }); + await commitMixedGestureIntent({ + gestureId: 'gesture-delete-align', + initialPluginIds: ['plugin-a'], + pluginScope: () => ['plugin-a'], + // 삭제 의도: desired가 대상을 제외 + generate: () => ({ + kind: 'patch', + patch: { schemaVersion: 1 }, + desiredPluginProjection: [], + }), + }); + + expect(mocks.setElements).toHaveBeenCalledTimes(1); + const [aligned] = mocks.setElements.mock.calls[0] as unknown as [ + Array<{ fullId: string }>, + ]; + const ids = aligned.map((element) => element.fullId); + // 커밋된 삭제 적용 + 봉인 후 신규 보존 + expect(ids).not.toContain('plugin-a:gone'); + expect(ids).toContain('plugin-a:new'); + }); + + it('편입 후 실패는 onFailureBeforeSettle에 알리고 canonical pull 후 settle한다', async () => { + mocks.elements = [ + { fullId: 'plugin-a:one', definitionId: 'plugin-a', zIndex: 0 }, + ]; + mocks.gestureCommit.mockRejectedValueOnce(new Error('backend failed')); + const onFailure = vi.fn(); + + await expect( + commitMixedGestureIntent({ + gestureId: 'gesture-fail', + initialPluginIds: ['plugin-a'], + pluginScope: () => ['plugin-a'], + generate: () => ({ kind: 'patch', patch: { schemaVersion: 1 } }), + onFailureBeforeSettle: onFailure, + }), + ).rejects.toThrow('backend failed'); + + expect(onFailure).toHaveBeenCalledTimes(1); + const failureOrder = onFailure.mock.invocationCallOrder[0]; + const canonicalOrder = mocks.applyCanonical.mock.invocationCallOrder[0]; + const unstageOrder = mocks.unstageGesture.mock.invocationCallOrder[0]; + expect(failureOrder).toBeLessThan(canonicalOrder); + expect(canonicalOrder).toBeLessThan(unstageOrder); + }); +}); diff --git a/src/renderer/plugins/runtime/displayElement/gestureTransaction.ts b/src/renderer/plugins/runtime/displayElement/gestureTransaction.ts index d45e5555..c10121ea 100644 --- a/src/renderer/plugins/runtime/displayElement/gestureTransaction.ts +++ b/src/renderer/plugins/runtime/displayElement/gestureTransaction.ts @@ -1,6 +1,8 @@ import { gestureApi } from '@api/modules/gestureApi'; import { editorCoordinator } from '@src/renderer/editor/runtime/editorStateCoordinator'; import { trackEditorWrite } from '@src/renderer/editor/runtime/editorWriteBarrier'; +import { ElementIntentAbort } from '@src/renderer/editor/runtime/elementIntent'; +import { stableStringify } from '@utils/core/stableStringify'; import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; import { useHistoryStatusStore } from '@stores/data/useHistoryStatusStore'; import { getPluginAuthorityGeneration } from '@plugins/rpc/pluginRpcClient'; @@ -17,12 +19,14 @@ import { drainPluginInstancesCommitQueues, getStagedPluginInstancesGestureId, hasConflictingPluginInstancesGesture, + rotatePluginInstancesEditSession, stagePluginInstancesGesture, unstagePluginInstancesGesture, } from './instancesCommitQueue'; import { schedulePluginPanelModelSync } from '@utils/plugin/panelModelSync'; -import type { EditorPatchV1 } from '@src/types/editor'; +import type { EditorPatchGenerator } from '@src/renderer/editor/runtime/editorCoordinator'; +import type { EditorDocumentV1, EditorPatchV1 } from '@src/types/editor'; import type { PluginDefinitionInternal, PluginDisplayElementInternal, @@ -129,10 +133,264 @@ export const cancelUncommittedMixedGestureTransaction = ( settleMixedGestureTransaction(gestureId); }; +// 슬롯 정합 mixed 의도 커밋: prepare 고정점이 plugin 큐를 drain하고 스코프를 +// 확정한 뒤 projection을 봉인하면, generator가 editor base와 같은 시점의 +// projection으로 patch와 desired plugin 상태를 함께 산출한다. transaction은 +// raw 봉인본이 아니라 desired를 저장한다 - 호출 시점 스냅샷과 슬롯 base를 +// 섞으면 stale z-order가 재발한다 +export interface MixedIntentGeneration { + kind: 'patch' | 'satisfied'; + patch?: EditorPatchV1 | null; + // 계약: scope 전체 projection을 표현해야 한다 - 성공 후 3-way 정렬이 + // "봉인에 있었고 desired에 없음"을 커밋된 삭제로 해석하므로, 부분 + // projection을 반환하면 scope 요소가 조용히 삭제된다 + desiredPluginProjection?: PluginDisplayElementInternal[]; +} + +export const commitMixedGestureIntent = (options: { + gestureId: string; + initialPluginIds: readonly string[]; + // prepare 고정점에서 스코프 재계산 (예: 현재 mode의 전체 definition id). + // drain 사이 신규 definition 출현을 흡수한다 + pluginScope: ( + elements: readonly PluginDisplayElementInternal[], + ) => readonly string[]; + generate: (context: { + base: EditorDocumentV1; + pluginProjection: readonly PluginDisplayElementInternal[]; + }) => MixedIntentGeneration; + onEnrolled?: () => void; + // 실패 시 staged 해제·canonical pull 전에 동기 실행 - eager receipt + // 복원의 소유 지점 (해제 후 복원은 stagedSavePending 재저장과 경합) + onFailureBeforeSettle?: (error: unknown) => void; +}): Promise => { + const scope = new Set(normalizePluginIds(options.initialPluginIds)); + const gestureId = options.gestureId; + if (scope.size > 0) { + beginMixedGestureTransaction(gestureId, [...scope]); + const staged = stagedGestures.get(gestureId); + if (staged) staged.committing = true; + } + + let sealedProjection: readonly PluginDisplayElementInternal[] = []; + let lastGeneration: MixedIntentGeneration | null = null; + let gestureResult: Awaited> | null = + null; + + const prepare = async (): Promise => { + // 고정점: drain 중 나타난 신규 definition을 stage하고 다시 drain. + // 상한 도달 = 고정점 미성립 - 미drain 스코프를 봉인하면 fail-open이므로 + // 전체 중단한다 + for (let round = 0; round < 8; round += 1) { + if (scope.size > 0) { + await drainPluginInstancesCommitQueues([...scope]); + } + const elements = usePluginDisplayElementStore.getState().elements; + const wanted = normalizePluginIds([ + ...options.pluginScope(elements), + ...scope, + ]); + const grew = wanted.some((pluginId) => !scope.has(pluginId)); + if (!grew) { + sealedProjection = elements; + return; + } + const discovered = wanted.filter((pluginId) => !scope.has(pluginId)); + wanted.forEach((pluginId) => scope.add(pluginId)); + beginMixedGestureTransaction(gestureId, wanted); + // 발견 전에 예약된 debounce는 아직 큐에 없다 - 같은 게스처로 rotate해 + // 큐로 밀어 넣어야 다음 라운드 drain이 관측한다 + discovered.forEach((pluginId) => { + rotatePluginInstancesEditSession(pluginId, gestureId); + }); + const staged = stagedGestures.get(gestureId); + if (staged) staged.committing = true; + } + throw new ElementIntentAbort('plugin scope fixed point not reached'); + }; + + let commitWork: Promise; + try { + commitWork = editorCoordinator + .commitGesture( + (base) => { + const generation = options.generate({ + base, + pluginProjection: sealedProjection, + }); + lastGeneration = generation; + return generation.kind === 'patch' ? generation.patch ?? null : null; + }, + gestureId, + async (context) => { + const projectionSource = + lastGeneration?.desiredPluginProjection ?? sealedProjection; + const scopeIds = normalizePluginIds([...scope]); + const pluginElements = new Map( + scopeIds.map((pluginId) => [ + pluginId, + projectionSource.filter( + (element) => element.definitionId === pluginId, + ), + ]), + ); + const pluginChanges = scopeIds.map((pluginId) => ({ + pluginId, + instances: buildSavedPluginInstances( + pluginElements.get(pluginId) ?? [], + pluginId, + ), + })); + notePluginInstancesMutation(context.mutationId); + const result = await gestureApi.commit({ + gestureId, + mutationId: context.mutationId, + editorBaseRevision: context.editorBaseRevision, + pluginBaseRevision: getBackendPluginRevision(), + observedHistoryEpoch: useHistoryStatusStore.getState().historyEpoch, + authorityGeneration: getPluginAuthorityGeneration(), + editorChanges: context.editorChanges, + pluginChanges, + }); + noteBackendPluginRevision(result.pluginModelRevision); + gestureResult = result; + return { + revision: result.editorRevision, + changedFields: result.changedFields, + }; + }, + { onEnrolled: options.onEnrolled, prepare }, + ) + .then(() => { + // prepare가 동적으로 편입한 plugin의 재계산 상태를 main store에도 + // 정렬 - 자기 mutation 이벤트는 무시되므로 여기서 반영하지 않으면 + // main·overlay·backend가 갈라진다. 3-way 정렬의 소유 단위는 영속 + // 필드(defineElement의 saved projection)다 - 전체 객체 비교는 + // 봉인 후 state·핸들러 갱신만으로 소유 판정을 깨뜨린다. + // membership 삭제(봉인에 있었고 desired에 없음)는 커밋된 canonical + // 사실이라 소유 증명 없이 제거하고, 봉인 후 신규 요소는 보존한다 + const PERSISTED_FIELDS = [ + 'position', + 'settings', + 'measuredSize', + 'tabId', + 'hidden', + 'zIndex', + ] as const; + const desired = lastGeneration?.desiredPluginProjection; + if (desired) { + const store = usePluginDisplayElementStore.getState(); + const scopeIds = new Set(scope); + const desiredById = new Map( + desired.map((element) => [element.fullId, element]), + ); + const sealedById = new Map( + sealedProjection.map((element) => [element.fullId, element]), + ); + let touched = false; + const merged: PluginDisplayElementInternal[] = []; + for (const element of store.elements) { + if (!element.definitionId || !scopeIds.has(element.definitionId)) { + merged.push(element); + continue; + } + const want = desiredById.get(element.fullId); + if (!want) { + if (sealedById.has(element.fullId)) { + // 커밋된 삭제 - 재주입 잔존 제거 + touched = true; + continue; + } + merged.push(element); + continue; + } + const sealed = sealedById.get(element.fullId); + if (!sealed) { + merged.push(element); + continue; + } + // 영속 필드별 CAS: 의도가 실제로 바꾼 필드만, 현재 값이 봉인과 + // 같을 때(우리 소유 증명) desired로 + let next = element; + let changed = false; + for (const field of PERSISTED_FIELDS) { + const sealedValue = (sealed as Record)[field]; + const wantValue = (want as Record)[field]; + if (stableStringify(sealedValue) === stableStringify(wantValue)) { + continue; + } + const currentValue = (element as Record)[field]; + if ( + stableStringify(currentValue) !== stableStringify(sealedValue) + ) { + continue; + } + next = { ...next, [field]: wantValue }; + changed = true; + } + if (changed) touched = true; + merged.push(next); + } + if (touched) { + // overlay까지 정렬 - skipSync로 main만 갱신하면 삭제 재주입의 + // ghost가 overlay에 남는다 + store.setElements(merged); + } + } + if (gestureResult && gestureResult.changedPluginIds.length > 0) { + try { + // panel에는 정렬 후 최종 상태를 발행 - elements와 definitions를 + // 같은 getState 스냅샷에서 읽는다. IPC 전 캡처 definitions는 + // 도중의 plugin reload가 예약한 최신 모델을 되덮는다 + const latest = usePluginDisplayElementStore.getState(); + schedulePluginPanelModelSync( + latest.elements, + latest.definitions, + gestureResult.pluginModelRevision, + ); + } catch (error) { + console.error('Failed to publish committed plugin model', error); + } + } + }); + } catch (error) { + commitWork = Promise.reject(error); + } + + const committed = commitWork + .catch(async (error) => { + // eager 복원은 staged 해제 전에 - 해제가 stagedSavePending 재저장을 + // 예약해 eager 잔존을 영속시킬 수 있다 + try { + options.onFailureBeforeSettle?.(error); + } catch (rollbackError) { + console.error('mixed intent rollback failed', rollbackError); + } + await Promise.allSettled( + [...scope].map(async (pluginId) => { + if (hasConflictingPluginInstancesGesture(pluginId, gestureId)) { + return; + } + await applyCanonicalPluginInstances(pluginId, true); + }), + ); + throw error; + }) + .finally(() => { + settleMixedGestureTransaction(gestureId); + }); + + return trackEditorWrite(committed); +}; + export const commitMixedGestureTransaction = ( gestureId: string, - editorChanges: EditorPatchV1, + // generator는 coordinator 직렬 슬롯 안에서 최신 base로 평가된다 - + // 호출 시점 캡처 full-record는 대기 중 정산된 커밋을 되돌린다. + // null 반환 시 editorChanges 없이 plugin 변경만 커밋 + editorChanges: EditorPatchV1 | EditorPatchGenerator, pluginIds: readonly string[], + meta?: { onEnrolled?: () => void }, ): Promise => { const normalizedPluginIds = normalizePluginIds(pluginIds); beginMixedGestureTransaction(gestureId, normalizedPluginIds); @@ -146,48 +404,53 @@ export const commitMixedGestureTransaction = ( try { commitWork = editorCoordinator - .commitGesture(editorChanges, gestureId, async (context) => { - await drainPluginInstancesCommitQueues(normalizedPluginIds); - const { elements, definitions } = - usePluginDisplayElementStore.getState(); - const stagedAtCommit = stagedGestures.get(gestureId); - const pluginElements = new Map( - normalizedPluginIds.map((pluginId) => [ + .commitGesture( + editorChanges, + gestureId, + async (context) => { + await drainPluginInstancesCommitQueues(normalizedPluginIds); + const { elements, definitions } = + usePluginDisplayElementStore.getState(); + const stagedAtCommit = stagedGestures.get(gestureId); + const pluginElements = new Map( + normalizedPluginIds.map((pluginId) => [ + pluginId, + stagedAtCommit?.sealedPluginElements.get(pluginId) ?? + elements.filter((element) => element.definitionId === pluginId), + ]), + ); + const pluginChanges = normalizedPluginIds.map((pluginId) => ({ pluginId, - stagedAtCommit?.sealedPluginElements.get(pluginId) ?? - elements.filter((element) => element.definitionId === pluginId), - ]), - ); - const pluginChanges = normalizedPluginIds.map((pluginId) => ({ - pluginId, - instances: buildSavedPluginInstances( - pluginElements.get(pluginId) ?? [], - pluginId, - ), - })); - committedElements = buildCommittedElementProjection( - elements, - pluginElements, - ); - committedDefinitions = definitions; - notePluginInstancesMutation(context.mutationId); - const result = await gestureApi.commit({ - gestureId, - mutationId: context.mutationId, - editorBaseRevision: context.editorBaseRevision, - pluginBaseRevision: getBackendPluginRevision(), - observedHistoryEpoch: useHistoryStatusStore.getState().historyEpoch, - authorityGeneration: getPluginAuthorityGeneration(), - editorChanges: context.editorChanges, - pluginChanges, - }); - noteBackendPluginRevision(result.pluginModelRevision); - gestureResult = result; - return { - revision: result.editorRevision, - changedFields: result.changedFields, - }; - }) + instances: buildSavedPluginInstances( + pluginElements.get(pluginId) ?? [], + pluginId, + ), + })); + committedElements = buildCommittedElementProjection( + elements, + pluginElements, + ); + committedDefinitions = definitions; + notePluginInstancesMutation(context.mutationId); + const result = await gestureApi.commit({ + gestureId, + mutationId: context.mutationId, + editorBaseRevision: context.editorBaseRevision, + pluginBaseRevision: getBackendPluginRevision(), + observedHistoryEpoch: useHistoryStatusStore.getState().historyEpoch, + authorityGeneration: getPluginAuthorityGeneration(), + editorChanges: context.editorChanges, + pluginChanges, + }); + noteBackendPluginRevision(result.pluginModelRevision); + gestureResult = result; + return { + revision: result.editorRevision, + changedFields: result.changedFields, + }; + }, + meta, + ) .then(() => { if ( gestureResult && From 58db1031ab4c925bf3873f3255cc656d77f2671d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 12 Aug 2026 20:18:10 +0900 Subject: [PATCH 30/35] =?UTF-8?q?fix:=20=EB=A6=AC=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=EC=A6=88=EC=99=80=20DnD=20=EC=A0=95=EC=82=B0=EC=9D=98=20full-r?= =?UTF-8?q?ecord=20=EC=BB=A4=EB=B0=8B=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../layer/useLayerDnD.routing.test.tsx | 97 +++- .../Grid/PropertiesPanel/layer/useLayerDnD.ts | 408 ++++++-------- .../hooks/Grid/useGridResize.test.tsx | 59 +- src/renderer/hooks/Grid/useGridResize.ts | 511 +++++++++--------- 4 files changed, 558 insertions(+), 517 deletions(-) diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.routing.test.tsx b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.routing.test.tsx index d2b02104..96855bc0 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.routing.test.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.routing.test.tsx @@ -11,7 +11,11 @@ const mocks = vi.hoisted(() => ({ (_options: unknown): Promise => Promise.resolve({ committed: true }), ), - applyPropertyIntentsEagerly: vi.fn(() => ({ rollback: vi.fn() })), + applyGestureEagerly: vi.fn(() => ({ + matched: true, + receipt: { rollback: vi.fn() }, + })), + captureBaseline: vi.fn(() => null), reportElementOpError: vi.fn(), setPluginZIndexes: vi.fn(), commitPatch: vi.fn(() => Promise.resolve()), @@ -23,10 +27,14 @@ const mocks = vi.hoisted(() => ({ vi.mock('@src/renderer/editor/runtime/elementIntent', () => ({ runElementIntent: mocks.runElementIntent, - applyPropertyIntentsEagerly: mocks.applyPropertyIntentsEagerly, + applyGestureIntentsEagerly: mocks.applyGestureEagerly, + captureIndexIntentBaseline: mocks.captureBaseline, + generateIndexIntentPatch: vi.fn(() => null), + indexBaselineMatches: vi.fn(() => true), intentPatch: (patch: unknown) => patch === null ? { kind: 'targetLost' } : { kind: 'patch', patch }, reportElementOpError: mocks.reportElementOpError, + reportElementOpSkipped: vi.fn(), })); vi.mock('@plugins/rpc/pluginElementActions', () => ({ @@ -34,7 +42,10 @@ vi.mock('@plugins/rpc/pluginElementActions', () => ({ })); vi.mock('@src/renderer/editor/runtime/editorStateCoordinator', () => ({ - editorCoordinator: { commitPatch: mocks.commitPatch }, + editorCoordinator: { + commitPatch: mocks.commitPatch, + getState: () => ({ lastAck: null }), + }, })); vi.mock('@stores/data/useKeyStore', () => ({ @@ -193,7 +204,12 @@ describe('useLayerDnD 커밋 경로 라우팅', () => { (options as { applyEager: () => unknown }).applyEager(); return Promise.resolve({ committed: true }); }); - mocks.applyPropertyIntentsEagerly.mockClear(); + mocks.applyGestureEagerly.mockClear(); + mocks.captureBaseline.mockClear(); + mocks.applyGestureEagerly.mockImplementation(() => ({ + matched: true, + receipt: { rollback: vi.fn() }, + })); mocks.reportElementOpError.mockClear(); mocks.setPluginZIndexes.mockClear(); mocks.commitPatch.mockClear(); @@ -246,7 +262,7 @@ describe('useLayerDnD 커밋 경로 라우팅', () => { }); }; - it('mouseup 시점 live 모델에 plugin이 있으면 native intent 경로에 진입하지 않는다', async () => { + it('plugin 포함 native 드롭도 id 의도 러너로 커밋하고 full-record를 보내지 않는다', async () => { const startItems = [nativeItem(ID_A, 0, 2), nativeItem(ID_B, 1, 1)]; // 드래그 중 plugin 요소가 추가된 라이브 모델 const liveItems = [...startItems, pluginItem('plugin-x:one', 0)]; @@ -255,10 +271,16 @@ describe('useLayerDnD 커밋 경로 라우팅', () => { displayItems: toDisplay(liveItems), }); - expect(mocks.runElementIntent).not.toHaveBeenCalled(); - expect(mocks.applyPropertyIntentsEagerly).not.toHaveBeenCalled(); - expect(mocks.commitPatch).toHaveBeenCalledTimes(1); + // native intent는 러너가 소유, plugin z-index는 별도 authority 쓰기, + // 호출 시점 full-record commitPatch는 금지 + expect(mocks.runElementIntent).toHaveBeenCalledTimes(1); + expect(mocks.applyGestureEagerly).toHaveBeenCalledTimes(1); + expect(mocks.commitPatch).not.toHaveBeenCalled(); expect(mocks.setPluginZIndexes).toHaveBeenCalledTimes(1); + const byId = eagerIntents(); + // 최종 순서 [B, A, plugin] - live 순서 기준 zIndex + expect(byId.get(ID_B)).toMatchObject({ zIndex: 2 }); + expect(byId.get(ID_A)).toMatchObject({ zIndex: 1 }); }); it('native 전용 편입 전 실패는 runner가 소유하고 layerGroups는 eager를 건드리지 않는다', async () => { @@ -275,12 +297,8 @@ describe('useLayerDnD 커밋 경로 라우팅', () => { expect(mocks.runElementIntent).toHaveBeenCalledTimes(1); // eager는 속성 의도만 - 그룹 정의·포지션 스토어 직접 쓰기 없음 - expect(mocks.applyPropertyIntentsEagerly).toHaveBeenCalledTimes(1); - const [intents] = mocks.applyPropertyIntentsEagerly.mock - .calls[0] as unknown as [ - Map>>, - ]; - const byId = intents.get('key')!; + expect(mocks.applyGestureEagerly).toHaveBeenCalledTimes(1); + const byId = eagerIntents() as Map>; expect(byId.get(ID_B)).toMatchObject({ zIndex: 1 }); expect(byId.get(ID_A)).toMatchObject({ zIndex: 0 }); expect(mocks.setLayerGroups).not.toHaveBeenCalled(); @@ -336,13 +354,50 @@ describe('useLayerDnD 커밋 경로 라우팅', () => { }; const eagerIntents = () => { - const [intents] = mocks.applyPropertyIntentsEagerly.mock - .calls[0] as unknown as [ - Map>>, + const [options] = mocks.applyGestureEagerly.mock.calls[0] as unknown as [ + { propertyIntents: Map>> }, ]; - return intents.get('key')!; + return options.propertyIntents.get('key')!; }; + it('합성 항목 드래그의 baseline은 paired keys를 포함해 캡처한다', async () => { + // 합성 id 항목 - 드래그 시작 시 baseline 캡처가 발화 + const syntheticItem: LayerItem = { + type: 'key', + id: 'key-0', + index: 0, + name: 'legacy', + zIndex: 1, + hidden: false, + }; + const itemB = nativeItem(ID_B, 1, 0); + const startItems = [syntheticItem, itemB]; + await dragItemToEnd(startItems, { + layerItems: startItems, + displayItems: toDisplay(startItems), + }); + + expect(mocks.captureBaseline).toHaveBeenCalledWith( + null, + '4key', + expect.arrayContaining(['keys', 'keyPositions', 'layerGroups']), + ); + }); + + it('plugin-only 드롭은 editor를 커밋하지 않는다', async () => { + const pluginA = pluginItem('plugin-x:one', 1); + const pluginB = pluginItem('plugin-y:one', 0); + const startItems = [pluginA, pluginB]; + await dragItemToEnd(startItems, { + layerItems: startItems, + displayItems: toDisplay(startItems), + }); + + expect(mocks.setPluginZIndexes).toHaveBeenCalledTimes(1); + expect(mocks.runElementIntent).not.toHaveBeenCalled(); + expect(mocks.commitPatch).not.toHaveBeenCalled(); + }); + it('외부 재정렬 후 드롭은 최신 순서 기준으로 커밋한다', async () => { const itemA = nativeItem(ID_A, 0, 2); const itemB = nativeItem(ID_B, 1, 1); @@ -483,7 +538,7 @@ describe('useLayerDnD 커밋 경로 라우팅', () => { // X 잔류를 앵커가 모르는 채 해석하면 [X, A, Y] 오배치 - 무커밋이어야 한다 expect(mocks.runElementIntent).not.toHaveBeenCalled(); - expect(mocks.applyPropertyIntentsEagerly).not.toHaveBeenCalled(); + expect(mocks.applyGestureEagerly).not.toHaveBeenCalled(); expect(mocks.commitPatch).not.toHaveBeenCalled(); }); @@ -514,7 +569,7 @@ describe('useLayerDnD 커밋 경로 라우팅', () => { ); expect(mocks.runElementIntent).not.toHaveBeenCalled(); - expect(mocks.applyPropertyIntentsEagerly).not.toHaveBeenCalled(); + expect(mocks.applyGestureEagerly).not.toHaveBeenCalled(); expect(mocks.commitPatch).not.toHaveBeenCalled(); }); @@ -553,7 +608,7 @@ describe('useLayerDnD 커밋 경로 라우팅', () => { ); expect(mocks.runElementIntent).not.toHaveBeenCalled(); - expect(mocks.applyPropertyIntentsEagerly).not.toHaveBeenCalled(); + expect(mocks.applyGestureEagerly).not.toHaveBeenCalled(); expect(mocks.commitPatch).not.toHaveBeenCalled(); }); }); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.ts b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.ts index eeca3cab..6c3f1b7b 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.ts @@ -5,18 +5,19 @@ import { isSyntheticElementId } from '@src/renderer/editor/model/elementIdMap'; import { - applyPropertyIntentsEagerly, + applyGestureIntentsEagerly, + captureIndexIntentBaseline, + generateIndexIntentPatch, + indexBaselineMatches, intentPatch, reportElementOpError, + reportElementOpSkipped, runElementIntent, + type IndexIntentBaseline, } from '@src/renderer/editor/runtime/elementIntent'; +import { applyEditorPatch } from '@src/renderer/editor/runtime/editorCoordinator'; import { setPluginElementZIndexes } from '@plugins/rpc/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 { normalizeLayerGroupsForMode } from '@utils/layerGroupUtils'; import { editorCoordinator } from '@src/renderer/editor/runtime/editorStateCoordinator'; @@ -213,6 +214,9 @@ export function useLayerDnD({ // 앵커 후보에서 제외한 id들 - mouseup의 이동 집합과 대조해 축소 감지 excludedIds: string[]; } | null>(null); + // 합성 id 항목용 드래그 시작 시점 구조 fingerprint - index 적용은 시작과 + // 슬롯 base가 정확히 같다는 증명 아래에서만 허용 + const dndBaselineRef = useRef(null); // ────────────────────────────────────────────────────────────────────────── // 아이템 드롭 타깃 계산 @@ -392,6 +396,162 @@ export function useLayerDnD({ // 앵커 소실 정책: 양생존·인접이면 사이, 하나 생존이면 그 기준, 양소실 // 또는 비인접이면 무커밋. 그룹 헤더 앵커는 그룹이 살아 있을 때만. // 대상 그룹이 삭제됐으면 무커밋 + // 드래그 시작 시점 baseline 캡처 - 현재 목록에 합성 native가 있을 때만 + const captureDnDBaseline = (): void => { + const hasSynthetic = layerItemsRef.current.some( + (item) => + item.type !== 'plugin' && + (item.id.length === 0 || isSyntheticElementId(item.id)), + ); + dndBaselineRef.current = hasSynthetic + ? captureIndexIntentBaseline( + editorCoordinator.getState().lastAck, + selectedKeyType, + [ + 'keys', + 'keyPositions', + 'statPositions', + 'graphPositions', + 'knobPositions', + 'layerGroups', + ], + ) + : null; + }; + + interface DropIntentSets { + nativeIntents: Map< + 'key' | 'stat' | 'graph' | 'knob', + Map> + >; + syntheticIndexIntents: Map< + 'key' | 'stat' | 'graph' | 'knob', + Map> + >; + pluginZIndexUpdates: Array<{ fullId: string; zIndex: number }>; + } + + // 새 표시 순서를 의도 집합으로 변환 - 안정 id는 id 의도, 합성은 시작 + // index 의도(시작 fingerprint 증명 필요), plugin은 별도 authority 쓰기 + const buildDropIntentSets = ( + newItems: LayerItem[], + intentFieldsFor?: (item: LayerItem) => Record, + ): DropIntentSets => { + const maxZIndex = newItems.length - 1; + const sets: DropIntentSets = { + nativeIntents: new Map(), + syntheticIndexIntents: new Map(), + pluginZIndexUpdates: [], + }; + newItems.forEach((item, idx) => { + const newZIndex = maxZIndex - idx; + if (item.type === 'plugin') { + sets.pluginZIndexUpdates.push({ fullId: item.id, zIndex: newZIndex }); + return; + } + const intent: Record = { + zIndex: newZIndex, + ...(intentFieldsFor ? intentFieldsFor(item) : {}), + }; + if (item.id.length > 0 && !isSyntheticElementId(item.id)) { + const byId = sets.nativeIntents.get(item.type) ?? new Map(); + byId.set(item.id, intent); + sets.nativeIntents.set(item.type, byId); + } else if (item.index !== undefined) { + const byIndex = sets.syntheticIndexIntents.get(item.type) ?? new Map(); + byIndex.set(item.index, intent); + sets.syntheticIndexIntents.set(item.type, byIndex); + } + }); + return sets; + }; + + // 드롭 커밋 단일 소유: eager receipt와 wire 생성 전부 러너 계약으로. + // plugin-only는 editor 무커밋, wire patch는 슬롯 base에서 재생성한다 - + // 호출 시점 full-record는 대기 중 정산된 격리 plugin 쓰기를 되돌린다. + // plugin z-index는 별도 authority 쓰기로 editor 커밋과 비원자(기존 의미론) + const commitDropIntents = (sets: DropIntentSets, skipContext: string) => { + if (sets.pluginZIndexUpdates.length > 0) { + setPluginElementZIndexes(sets.pluginZIndexUpdates); + } + const hasNativeIntent = + sets.nativeIntents.size > 0 || sets.syntheticIndexIntents.size > 0; + if (!hasNativeIntent) return; + const baseline = dndBaselineRef.current; + const hasSynthetic = sets.syntheticIndexIntents.size > 0; + // 결합 eager 단일 소유 - preflight 게이트, 양쪽 적용, 최종 봉인이 + // 한 호출 안에서 일어난다. 불일치면 아무것도 적용하지 않고 fail-closed + const eager = applyGestureIntentsEagerly({ + baseline, + indexIntents: sets.syntheticIndexIntents, + propertyIntents: sets.nativeIntents, + }); + if (!eager.matched) { + reportElementOpSkipped(skipContext); + return; + } + void runElementIntent({ + applyEager: () => eager.receipt, + generate: (base) => { + if ( + hasSynthetic && + (!baseline || + !indexBaselineMatches( + baseline, + base as unknown as Record, + )) + ) { + return { kind: 'targetLost' }; + } + let working = base; + if (sets.nativeIntents.size > 0) { + const propertyPatch = generateModeScopedIntentPatch( + working, + sets.nativeIntents, + selectedKeyType, + ); + if (!propertyPatch) return { kind: 'targetLost' }; + working = applyEditorPatch(working, propertyPatch); + } + if (hasSynthetic && baseline) { + const syntheticPatch = generateIndexIntentPatch( + working, + baseline, + sets.syntheticIndexIntents, + { skipFingerprint: true }, + ); + if (syntheticPatch) { + working = applyEditorPatch(working, syntheticPatch); + } + } + const renormalized = normalizeLayerGroupsForMode({ + mode: selectedKeyType, + keyPositions: working.keyPositions as never, + statPositions: working.statPositions as never, + graphPositions: working.graphPositions as never, + knobPositions: working.knobPositions as never, + layerGroups: working.layerGroups as never, + }); + return intentPatch({ + schemaVersion: 1, + keyPositions: renormalized.keyPositions as never, + statPositions: renormalized.statPositions as never, + graphPositions: renormalized.graphPositions as never, + knobPositions: renormalized.knobPositions as never, + ...(renormalized.groupsChanged + ? { layerGroups: renormalized.layerGroups as never } + : {}), + }); + }, + }) + .then((result) => { + if (!result.committed && !result.satisfied) { + reportElementOpSkipped(skipContext); + } + }) + .catch(reportElementOpError); + }; + const performMultiDrop = async ( draggedIds: string[], toDisplayIndex: number, @@ -568,145 +728,14 @@ export function useLayerDnD({ ); if (!orderChanged && !groupChanged) return; - // 새 표시 순서를 id 의도로 변환 - effect 지연 ref의 item.index로 현재 - // 배열을 인덱싱하면 canonical 적용과 effect 사이 창에서 다른 요소를 - // 수정한다. 적용은 전부 position.id 매칭 - const maxZIndex = newItems.length - 1; - const pluginZIndexUpdates: Array<{ fullId: string; zIndex: number }> = []; - const nativeIntents = new Map< - 'key' | 'stat' | 'graph' | 'knob', - Map> - >(); - newItems.forEach((item, idx) => { - const newZIndex = maxZIndex - idx; - if (item.type === 'plugin') { - pluginZIndexUpdates.push({ fullId: item.id, zIndex: newZIndex }); - return; - } - const intent: Record = { zIndex: newZIndex }; - if (draggedIdSet.has(item.id) && !preserveGroupIds.has(item.id)) { - intent.groupId = newGroupId; - } - const byId = nativeIntents.get(item.type) ?? new Map(); - byId.set(item.id, intent); - nativeIntents.set(item.type, byId); - }); - - const modeNativeOnly = items.every( - (item) => - item.type !== 'plugin' && - item.id.length > 0 && - !isSyntheticElementId(item.id), + // 새 표시 순서를 의도로 변환 - 안정 id는 id 매칭, 합성은 시작 + // fingerprint가 증명될 때만 index 적용. full-record 캡처 커밋 금지 + const sets = buildDropIntentSets(newItems, (item) => + draggedIdSet.has(item.id) && !preserveGroupIds.has(item.id) + ? { groupId: newGroupId } + : {}, ); - - if (modeNativeOnly) { - // native 전용: eager·receipt는 속성 의도가 소유하고, layerGroups - // 정규화는 슬롯의 base+의도에서 재계산해 생성 patch에만 싣는다 - // (편입 시 낙관 적용이 그룹 정의를 반영) - void runElementIntent({ - applyEager: () => applyPropertyIntentsEagerly(nativeIntents), - generate: (base) => { - // 모드 한정 재적용 - 대기 중 다른 모드로 이동한 요소는 skip - const propertyPatch = generateModeScopedIntentPatch( - base, - nativeIntents, - selectedKeyType, - ); - if (!propertyPatch) return { kind: 'targetLost' }; - const renormalized = normalizeLayerGroupsForMode({ - mode: selectedKeyType, - keyPositions: (propertyPatch.keyPositions ?? - base.keyPositions) as never, - statPositions: (propertyPatch.statPositions ?? - base.statPositions) as never, - graphPositions: (propertyPatch.graphPositions ?? - base.graphPositions) as never, - knobPositions: (propertyPatch.knobPositions ?? - base.knobPositions) as never, - layerGroups: base.layerGroups as never, - }); - return intentPatch({ - schemaVersion: 1, - keyPositions: renormalized.keyPositions as never, - statPositions: renormalized.statPositions as never, - graphPositions: renormalized.graphPositions as never, - knobPositions: renormalized.knobPositions as never, - ...(renormalized.groupsChanged - ? { layerGroups: renormalized.layerGroups as never } - : {}), - }); - }, - }).catch(reportElementOpError); - return; - } - - // plugin 포함 모드: 기존 full-record 경로 유지 (id 매칭 적용으로 개선) - const applyIntentsToMode = ( - record: Record, - type: 'key' | 'stat' | 'graph' | 'knob', - ): Record => { - const byId = nativeIntents.get(type); - if (!byId || byId.size === 0) return record; - return { - ...record, - [selectedKeyType]: (record[selectedKeyType] ?? []).map((position) => { - const id = position.id; - if (typeof id !== 'string') return position; - const intent = byId.get(id); - return intent ? { ...position, ...intent, id } : position; - }), - }; - }; - - const updatedPositions = applyIntentsToMode( - useKeyStore.getState().canonicalPositions, - 'key', - ); - const updatedStatPositions = applyIntentsToMode( - useStatItemStore.getState().positions, - 'stat', - ); - const updatedGraphPositions = applyIntentsToMode( - useGraphItemStore.getState().positions, - 'graph', - ); - const updatedKnobPositions = applyIntentsToMode( - useKnobItemStore.getState().positions, - 'knob', - ); - const currentLayerGroups = useLayerGroupStore.getState().layerGroups; - - const normalized = normalizeLayerGroupsForMode({ - mode: selectedKeyType, - keyPositions: updatedPositions, - statPositions: updatedStatPositions, - graphPositions: updatedGraphPositions, - knobPositions: updatedKnobPositions, - layerGroups: currentLayerGroups, - }); - - useGraphItemStore.getState().setPositions(normalized.graphPositions); - useKeyStore.getState().setPositions(normalized.keyPositions); - useStatItemStore.getState().setPositions(normalized.statPositions); - useKnobItemStore.getState().setPositions(normalized.knobPositions); - setPluginElementZIndexes(pluginZIndexUpdates); - if (normalized.groupsChanged) { - useLayerGroupStore.getState().setLayerGroups(normalized.layerGroups); - } - - try { - await editorCoordinator.commitPatch({ - schemaVersion: 1, - keyPositions: normalized.keyPositions, - - statPositions: normalized.statPositions, - graphPositions: normalized.graphPositions, - knobPositions: normalized.knobPositions, - layerGroups: normalized.layerGroups, - }); - } catch (error) { - console.error('Failed to reorder layers', error); - } + commitDropIntents(sets, 'layer drop settlement'); }; // ────────────────────────────────────────────────────────────────────────── @@ -773,92 +802,9 @@ export function useLayerDnD({ ); if (!orderChanged) return; - // 새 표시 순서를 id 의도로 변환 (그룹 이동은 zIndex만) - index 인덱싱 금지 - const maxZIndex = newItems.length - 1; - const pluginZIndexUpdates: Array<{ fullId: string; zIndex: number }> = []; - const nativeIntents = new Map< - 'key' | 'stat' | 'graph' | 'knob', - Map> - >(); - newItems.forEach((item, idx) => { - const newZIndex = maxZIndex - idx; - if (item.type === 'plugin') { - pluginZIndexUpdates.push({ fullId: item.id, zIndex: newZIndex }); - return; - } - const byId = nativeIntents.get(item.type) ?? new Map(); - byId.set(item.id, { zIndex: newZIndex }); - nativeIntents.set(item.type, byId); - }); - - const modeNativeOnly = items.every( - (item) => - item.type !== 'plugin' && - item.id.length > 0 && - !isSyntheticElementId(item.id), - ); - if (modeNativeOnly) { - void runElementIntent({ - applyEager: () => applyPropertyIntentsEagerly(nativeIntents), - generate: (base) => - intentPatch( - generateModeScopedIntentPatch(base, nativeIntents, selectedKeyType), - ), - }).catch(reportElementOpError); - return; - } - - // plugin 포함 모드: 기존 full-record 경로 유지 (id 매칭 적용으로 개선) - const applyGroupIntents = ( - record: Record, - type: 'key' | 'stat' | 'graph' | 'knob', - ): Record => { - const byId = nativeIntents.get(type); - if (!byId || byId.size === 0) return record; - return { - ...record, - [selectedKeyType]: (record[selectedKeyType] ?? []).map((position) => { - const id = position.id; - if (typeof id !== 'string') return position; - const intent = byId.get(id); - return intent ? { ...position, ...intent, id } : position; - }), - }; - }; - - const updatedPositions = applyGroupIntents( - useKeyStore.getState().canonicalPositions, - 'key', - ); - const updatedStatPositions = applyGroupIntents( - useStatItemStore.getState().positions, - 'stat', - ); - const updatedGraphPositions = applyGroupIntents( - useGraphItemStore.getState().positions, - 'graph', - ); - const updatedKnobPositions = applyGroupIntents( - useKnobItemStore.getState().positions, - 'knob', - ); - useKeyStore.getState().setPositions(updatedPositions); - useStatItemStore.getState().setPositions(updatedStatPositions); - useGraphItemStore.getState().setPositions(updatedGraphPositions); - useKnobItemStore.getState().setPositions(updatedKnobPositions); - setPluginElementZIndexes(pluginZIndexUpdates); - - try { - await editorCoordinator.commitPatch({ - schemaVersion: 1, - keyPositions: updatedPositions, - statPositions: updatedStatPositions, - graphPositions: updatedGraphPositions, - knobPositions: updatedKnobPositions, - }); - } catch (error) { - console.error('Failed to reorder group', error); - } + // 새 표시 순서를 의도로 변환 (그룹 이동은 zIndex만) + const sets = buildDropIntentSets(newItems); + commitDropIntents(sets, 'group drop settlement'); }; // ────────────────────────────────────────────────────────────────────────── @@ -899,6 +845,7 @@ export function useLayerDnD({ if (Math.abs(dx) < 3 && Math.abs(dy) < 3) return; isDraggingRef.current = true; didDragRef.current = true; + captureDnDBaseline(); const currentSel = useGridSelectionStore.getState().selectedElements; const isInSelection = currentSel.some((el) => el.id === item.id); @@ -1084,6 +1031,7 @@ export function useLayerDnD({ if (Math.abs(dx) < 3 && Math.abs(dy) < 3) return; isDraggingRef.current = true; didDragRef.current = true; + captureDnDBaseline(); setDraggedGroupId(groupId); setIsDragging(true); } diff --git a/src/renderer/hooks/Grid/useGridResize.test.tsx b/src/renderer/hooks/Grid/useGridResize.test.tsx index 551aed08..a3bebe79 100644 --- a/src/renderer/hooks/Grid/useGridResize.test.tsx +++ b/src/renderer/hooks/Grid/useGridResize.test.tsx @@ -2,6 +2,8 @@ import React, { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createDefaultKeyPosition } from '@src/renderer/editor/model/keys'; + import type { SelectedElement } from '@stores/grid/useGridSelectionStore'; import { useGridResize } from './useGridResize'; @@ -116,7 +118,10 @@ vi.mock('@stores/data/useKnobItemStore', () => ({ })); vi.mock('@src/renderer/editor/runtime/editorStateCoordinator', () => ({ - editorCoordinator: { commitPatch: mocks.commitPatch }, + editorCoordinator: { + commitPatch: mocks.commitPatch, + getState: () => ({ lastAck: null }), + }, })); type ResizeApi = ReturnType; @@ -307,10 +312,12 @@ describe('useGridResize plugin gesture lifecycle', () => { expect(mocks.beginMixedGesture).toHaveBeenCalledWith(pluginGestureIds[0], [ 'plugin-a', ]); + // wire patch는 호출 시점 full-record가 아니라 슬롯 generator로 전달된다 expect(mocks.commitMixedGesture).toHaveBeenCalledWith( pluginGestureIds[0], - expect.objectContaining({ schemaVersion: 1 }), + expect.any(Function), ['plugin-a'], + expect.anything(), ); }); @@ -347,9 +354,39 @@ describe('useGridResize plugin gesture lifecycle', () => { expect(mocks.commitMixedGesture).toHaveBeenCalledTimes(1); expect(mocks.commitMixedGesture).toHaveBeenCalledWith( pluginGestureIds[0], - expect.objectContaining({ schemaVersion: 1 }), + expect.any(Function), ['plugin-a'], + expect.anything(), ); + // generator는 슬롯 base에서 시작 동결 A의 id 의도만 재적용한다 + const generate = ( + mocks.commitMixedGesture.mock.calls[0] as unknown[] + )[1] as (base: unknown) => { + keyPositions?: Record>>; + }; + const base = { + schemaVersion: 1, + keys: { '4key': ['A'] }, + keyPositions: { + '4key': [ + { ...createDefaultKeyPosition(), id: STABLE_A, noteWidth: 111 }, + ], + }, + statPositions: {}, + graphPositions: {}, + knobPositions: {}, + layerGroups: {}, + }; + const patch = generate(base); + // 시작 동결 bounds가 base에 재적용되고 base의 다른 필드는 보존된다 + expect(patch?.keyPositions?.['4key'][0]).toMatchObject({ + id: STABLE_A, + dx: 10, + dy: 20, + width: 80, + height: 80, + noteWidth: 111, + }); expect(mocks.commitBounds).not.toHaveBeenCalled(); expect(mocks.commitPatch).not.toHaveBeenCalled(); }); @@ -428,6 +465,22 @@ describe('useGridResize plugin gesture lifecycle', () => { expect(byId.get(STABLE_A)).toMatchObject({ width: 120, height: 80 }); }); + it('시작 baseline이 없는 합성 단일 resize는 eager와 wire 모두 무커밋한다', async () => { + // coordinator lastAck가 null - 합성 index 의도는 시작 증명 없이는 + // 어떤 경로로도 커밋되지 않는다 (wire 부활 금지) + await renderHarness([keySelection()]); + + await act(async () => { + api.handleResizeStart(); + api.handleResize({ x: 10, y: 20, width: 120, height: 80 }); + api.handleResizeComplete(); + }); + + expect(mocks.commitBounds).not.toHaveBeenCalled(); + expect(mocks.commitPatch).not.toHaveBeenCalled(); + expect(mocks.commitMixedGesture).not.toHaveBeenCalled(); + }); + it('active resize 중 unmount하면 보관한 token을 종료한다', async () => { mocks.elements = [{ fullId: 'plugin-a:one', pluginId: 'plugin-a' }]; await renderHarness([pluginSelection('plugin-a:one')]); diff --git a/src/renderer/hooks/Grid/useGridResize.ts b/src/renderer/hooks/Grid/useGridResize.ts index 3c2610b0..fd47958d 100644 --- a/src/renderer/hooks/Grid/useGridResize.ts +++ b/src/renderer/hooks/Grid/useGridResize.ts @@ -1,10 +1,26 @@ import { isSyntheticElementId } from '@src/renderer/editor/model/elementIdMap'; import { - applyPropertyIntentsEagerly, + applyGestureIntentsEagerly, + applyIndexIntentsEagerly, + captureIndexIntentBaseline, + generateIndexIntentPatch, + generatePropertyIntentPatch, + indexBaselineMatches, + intentPatch, reportElementOpError, + reportElementOpSkipped, + runElementIntent, + type ElementIntentReceipt, + type IndexBaselineField, + type IndexIntentBaseline, + type IndexIntents, + type PropertyIntents, } from '@src/renderer/editor/runtime/elementIntent'; +import type { EditorDocumentV1, EditorPatchV1 } from '@src/types/editor'; +import { runMixedElementIntent } from '@src/renderer/editor/runtime/mixedElementIntent'; import { sendBridgeMessageBestEffort } from '@utils/plugin/bridgeMessages'; import { editorCoordinator } from '@src/renderer/editor/runtime/editorStateCoordinator'; +import { applyEditorPatch } from '@src/renderer/editor/runtime/editorCoordinator'; import { commitElementBoundsById } from '@src/renderer/editor/runtime/elementOps'; import { useEffect, useRef, useState } from 'react'; import { useKeyStore } from '@stores/data/useKeyStore'; @@ -22,10 +38,6 @@ import { import { selectionElementId } from '@stores/grid/useGridSelectionStore'; import type { SelectedElement } from '@stores/grid/useGridSelectionStore'; import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; -import type { KeyPositions } from '@src/types/key/keys'; -import type { StatItemPositions } from '@src/types/key/statItems'; -import type { GraphItemPositions } from '@src/types/key/graphItems'; -import type { KnobItemPositions } from '@src/types/key/knobs'; import type { ElementBounds } from '@utils/grid/smartGuides'; import { beginPluginInstancesEditSession, @@ -33,7 +45,6 @@ import { } from '@plugins/runtime/displayElement/instancesCommitQueue'; import { beginMixedGestureTransaction, - commitMixedGestureTransaction, cancelUncommittedMixedGestureTransaction, } from '@plugins/runtime/displayElement/gestureTransaction'; @@ -89,6 +100,9 @@ export function useGridResize({ const frozenResizeTargetsRef = useRef< Array<{ type: string; id: string; index?: number }> >([]); + // 합성 id 대상용 시작 시점 구조 fingerprint - 완료 시점 캡처는 시작과 + // 완료 사이 정산된 외부 재정렬을 통과시킨다 + const syntheticBaselineRef = useRef(null); // 그룹 리사이즈용 상태 const [previewGroupBounds, setPreviewGroupBounds] = @@ -120,6 +134,97 @@ export function useGridResize({ }); }; + // 합성 대상이 있으면 시작 시점 lastAck에서 관련 컬렉션 fingerprint 동결 + const captureSyntheticBaseline = ( + elements: ReadonlyArray<{ type: string; id: string }>, + ): IndexIntentBaseline | null => { + const syntheticTypes = new Set( + elements + .filter( + (element) => + element.type !== 'plugin' && + (element.id.length === 0 || isSyntheticElementId(element.id)), + ) + .map((element) => element.type as 'key' | 'stat' | 'graph' | 'knob'), + ); + if (syntheticTypes.size === 0) return null; + const fields: IndexBaselineField[] = []; + for (const type of syntheticTypes) { + if (type === 'key') { + fields.push('keyPositions', 'keys'); + } else if (type === 'stat') { + fields.push('statPositions'); + } else if (type === 'graph') { + fields.push('graphPositions'); + } else { + fields.push('knobPositions'); + } + } + return captureIndexIntentBaseline( + editorCoordinator.getState().lastAck, + selectedKeyType, + fields, + ); + }; + + const boundsFieldsOf = (bounds: { + x: number; + y: number; + width: number; + height: number; + }): Record => ({ + dx: bounds.x, + dy: bounds.y, + width: bounds.width, + height: bounds.height, + }); + + // 안정 id 의도 + 합성 index 의도를 슬롯 base에서 결합 재생성. + // 합성이 있으면 base fingerprint가 시작과 정확히 일치해야 하고, + // 불일치 시 편집 전체 무커밋(null) + const generateCombinedBoundsPatch = ( + base: EditorDocumentV1, + stableIntents: PropertyIntents, + syntheticIntents: IndexIntents, + baseline: IndexIntentBaseline | null, + ): EditorPatchV1 | null => { + const hasSynthetic = syntheticIntents.size > 0; + if (hasSynthetic) { + if ( + !baseline || + !indexBaselineMatches( + baseline, + base as unknown as Record, + ) + ) { + return null; + } + } + let patch: EditorPatchV1 | null = null; + let working = base; + if (stableIntents.size > 0) { + const stablePatch = generatePropertyIntentPatch(working, stableIntents); + if (stablePatch) { + patch = stablePatch; + working = applyEditorPatch(working, stablePatch); + } + } + if (hasSynthetic && baseline) { + const syntheticPatch = generateIndexIntentPatch( + working, + baseline, + syntheticIntents, + { skipFingerprint: true }, + ); + if (syntheticPatch) { + patch = patch + ? { ...patch, ...syntheticPatch, schemaVersion: 1 } + : syntheticPatch; + } + } + return patch; + }; + // plugin-only·혼합 완료의 오버레이 동기화 - editor 커밋과 분리 const syncPluginElementsToOverlay = () => { sendBridgeMessageBestEffort('overlay', 'plugin:displayElements:sync', { @@ -160,6 +265,7 @@ export function useGridResize({ id: element.id, index: element.index, })); + syntheticBaselineRef.current = captureSyntheticBaseline(selectedElements); beginPluginResizeSessions(gestureId); if ( pluginResizeTokensRef.current.size > 0 && @@ -934,97 +1040,37 @@ export function useGridResize({ ]), resizeGestureIdRef.current ?? undefined, ).catch(reportElementOpError); - } else if (element.type === 'key' && element.index !== undefined) { - // 키 요소에 최종 크기 적용 - 커밋 base는 canonical - const positions = useKeyStore.getState().canonicalPositions; - const setPositions = useKeyStore.getState().setPositions; - const current = positions[selectedKeyType] || []; - const nextPositions: KeyPositions = { - ...positions, - [selectedKeyType]: current.map((pos, i) => - i === element.index - ? { - ...pos, - dx: finalBounds.x, - dy: finalBounds.y, - width: finalBounds.width, - height: finalBounds.height, - } - : pos, - ), - }; - setPositions(nextPositions); - - // 백엔드에 저장 - window.api.keys.updatePositions(nextPositions).catch((error) => { - console.error('Failed to update key positions after resize', error); - }); - } else if (element.type === 'stat' && element.index !== undefined) { - const statStore = useStatItemStore.getState(); - const statPositions = statStore.positions; - const current = statPositions[selectedKeyType] || []; - const nextPositions: StatItemPositions = { - ...statPositions, - [selectedKeyType]: current.map((pos, i) => - i === element.index - ? { - ...pos, - dx: finalBounds.x, - dy: finalBounds.y, - width: finalBounds.width, - height: finalBounds.height, - } - : pos, - ), - }; - statStore.setPositions(nextPositions); - window.api.statItems.updatePositions(nextPositions).catch((error) => { - console.error('Failed to update stat positions after resize', error); - }); - } else if (element.type === 'graph' && element.index !== undefined) { - const graphStore = useGraphItemStore.getState(); - const graphPositions = graphStore.positions; - const current = graphPositions[selectedKeyType] || []; - const nextPositions: GraphItemPositions = { - ...graphPositions, - [selectedKeyType]: current.map((pos, i) => - i === element.index - ? { - ...pos, - dx: finalBounds.x, - dy: finalBounds.y, - width: finalBounds.width, - height: finalBounds.height, - } - : pos, - ), - }; - graphStore.setPositions(nextPositions); - window.api.graphItems.updatePositions(nextPositions).catch((error) => { - console.error('Failed to update graph positions after resize', error); - }); - } else if (element.type === 'knob' && element.index !== undefined) { - const knobStore = useKnobItemStore.getState(); - const knobPositions = knobStore.positions; - const current = knobPositions[selectedKeyType] || []; - const nextPositions: KnobItemPositions = { - ...knobPositions, - [selectedKeyType]: current.map((pos, i) => - i === element.index - ? { - ...pos, - dx: finalBounds.x, - dy: finalBounds.y, - width: finalBounds.width, - height: finalBounds.height, - } - : pos, - ), - }; - knobStore.setPositions(nextPositions); - window.api.knobItems.updatePositions(nextPositions).catch((error) => { - console.error('Failed to update knob positions after resize', error); - }); + } else if (element.type !== 'plugin' && element.index !== undefined) { + // 합성 id: 시작 fingerprint가 증명될 때만 index 적용 - full-record + // 캡처 커밋은 대기 중 정산된 다른 커밋을 되돌린다. eager 불일치는 + // 전체 intent fail-closed (wire로 부활 금지) + const baseline = syntheticBaselineRef.current; + const indexIntents: IndexIntents = new Map([ + [ + element.type, + new Map([[element.index, boundsFieldsOf(finalBounds)]]), + ], + ]); + const eager = applyIndexIntentsEagerly(baseline, indexIntents); + if (!eager.matched) { + reportElementOpSkipped('synthetic resize settlement'); + } else { + const gestureId = resizeGestureIdRef.current ?? undefined; + void runElementIntent({ + applyEager: () => eager.receipt, + generate: (base) => + intentPatch( + generateIndexIntentPatch(base, baseline, indexIntents), + ), + ...(gestureId ? { gestureId } : {}), + }) + .then((result) => { + if (!result.committed && !result.satisfied) { + reportElementOpSkipped('synthetic resize settlement'); + } + }) + .catch(reportElementOpError); + } } else if (element.type === 'plugin') { // 플러그인 요소에 최종 크기 적용 const pluginStore = usePluginDisplayElementStore.getState(); @@ -1044,11 +1090,12 @@ export function useGridResize({ // 정산은 시작 시 동결한 구성으로 여기서 완결 - 완료 시점 live 선택을 // 읽는 외부 콜백 금지. plugin이 움직였으면 오버레이만 동기화 - // (plugin-only는 editor 무커밋 계약). 합성 native 단일은 위 legacy - // 경로의 updatePositions가 이미 저장했다 + // (plugin-only는 editor 무커밋 계약). 합성 native 단일은 위에서 시작 + // fingerprint 증명 아래 index 러너로 정산했다 if (frozenTargets.some((target) => target.type === 'plugin')) { syncPluginElementsToOverlay(); } + syntheticBaselineRef.current = null; endPluginResizeSessions(); }; @@ -1068,6 +1115,15 @@ export function useGridResize({ let groupHandledNatively = false; let groupPluginInvolved = false; let groupHasNative = false; + let groupSettlement: + | { + kind: 'intents'; + stableIntents: PropertyIntents; + syntheticIntents: IndexIntents; + receipt: ElementIntentReceipt | null; + } + | { kind: 'failClosed' } + | null = null; frozenResizeTargetsRef.current = []; // 스마트 가이드 클리어 @@ -1078,27 +1134,14 @@ export function useGridResize({ const finalData = finalGroupBoundsRef.current; if (finalData && finalData.elementBounds.length > 0) { - // 커밋 base는 canonical - rendered에는 다른 세션의 미커밋 프리뷰가 섞일 수 있음 - const positions = useKeyStore.getState().canonicalPositions; - const setPositions = useKeyStore.getState().setPositions; - const current = positions[selectedKeyType] || []; const pluginStore = usePluginDisplayElementStore.getState(); - const statStore = useStatItemStore.getState(); - const statPositions = statStore.positions; - const currentStats = statPositions[selectedKeyType] || []; - const graphStore = useGraphItemStore.getState(); - const graphPositions = graphStore.positions; - const currentGraphs = graphPositions[selectedKeyType] || []; - const knobStore = useKnobItemStore.getState(); - const knobPositions = knobStore.positions; - const currentKnobs = knobPositions[selectedKeyType] || []; // 프리뷰 값을 그대로 사용 (스냅은 이미 드래그 중에 적용됨) // 추가 스냅 적용 시 프리뷰와 최종 위치가 달라지는 문제 발생 // 시작 시 동결된 entries(elementBounds)의 안정 id에 최종 bounds 의도 // 구성. 플러그인 없고 전원 안정 id면 전용 의도 커밋이 eager와 wire를 - // 함께 소유, 혼합이면 eager만 반영 후 기존 mixed 경로가 보정된 - // 스토어에서 full record를 만든다. 합성 id는 index 경로 유지 + // 함께 소유, 혼합·합성 포함이면 eager receipt를 결합해 두고 wire는 + // 슬롯 generator가 소유한다 const stableBoundsIntents = new Map< 'key' | 'stat' | 'graph' | 'knob', Map> @@ -1129,137 +1172,46 @@ export function useGridResize({ groupHasNative = finalData.elementBounds.some( ({ element }) => element.type !== 'plugin', ); + // 합성 entries는 시작 fingerprint 아래 index 의도로 - full-record + // 캡처·직접 스토어 쓰기 금지 (대기 중 정산 커밋을 되돌린다) + const syntheticIndexIntents = new Map< + 'key' | 'stat' | 'graph' | 'knob', + Map> + >(); + for (const { element, bounds } of finalData.elementBounds) { + if (element.type === 'plugin' || isStableEntry(element)) continue; + if (element.index === undefined) continue; + const type = element.type as 'key' | 'stat' | 'graph' | 'knob'; + const byIndex = syntheticIndexIntents.get(type) ?? new Map(); + byIndex.set(element.index, boundsFieldsOf(bounds)); + syntheticIndexIntents.set(type, byIndex); + } + if (!pluginInvolved && allStable && stableBoundsIntents.size > 0) { groupHandledNatively = true; void commitElementBoundsById( stableBoundsIntents, resizeGestureIdRef.current ?? undefined, ).catch(reportElementOpError); - } else if (stableBoundsIntents.size > 0) { - applyPropertyIntentsEagerly(stableBoundsIntents); - } - - // 키 요소들 업데이트 (합성 id 폴백) - const keyUpdates = finalData.elementBounds.filter( - ({ element }) => - element.type === 'key' && - element.index !== undefined && - !isStableEntry(element), - ); - - if (keyUpdates.length > 0) { - const nextPositions: KeyPositions = { - ...positions, - [selectedKeyType]: current.map((pos, i) => { - const update = keyUpdates.find( - ({ element }) => element.index === i, - ); - if (update) { - return { - ...pos, - dx: update.bounds.x, - dy: update.bounds.y, - width: update.bounds.width, - height: update.bounds.height, - }; - } - return pos; - }), - }; - setPositions(nextPositions); - } - - // 통계 요소들 업데이트 - const statUpdates = finalData.elementBounds.filter( - ({ element }) => - element.type === 'stat' && - element.index !== undefined && - !isStableEntry(element), - ); - - if (statUpdates.length > 0) { - const nextStatPositions: StatItemPositions = { - ...statPositions, - [selectedKeyType]: currentStats.map((pos, i) => { - const update = statUpdates.find( - ({ element }) => element.index === i, - ); - if (update) { - return { - ...pos, - dx: update.bounds.x, - dy: update.bounds.y, - width: update.bounds.width, - height: update.bounds.height, - }; - } - return pos; - }), - }; - - statStore.setPositions(nextStatPositions); - } - - // 그래프 요소들 업데이트 - const graphUpdates = finalData.elementBounds.filter( - ({ element }) => - element.type === 'graph' && - element.index !== undefined && - !isStableEntry(element), - ); - - if (graphUpdates.length > 0) { - const nextGraphPositions: GraphItemPositions = { - ...graphPositions, - [selectedKeyType]: currentGraphs.map((pos, i) => { - const update = graphUpdates.find( - ({ element }) => element.index === i, - ); - if (update) { - return { - ...pos, - dx: update.bounds.x, - dy: update.bounds.y, - width: update.bounds.width, - height: update.bounds.height, - }; - } - return pos; - }), - }; - - graphStore.setPositions(nextGraphPositions); - } - - // 노브 요소들 업데이트 - const knobUpdates = finalData.elementBounds.filter( - ({ element }) => - element.type === 'knob' && - element.index !== undefined && - !isStableEntry(element), - ); - - if (knobUpdates.length > 0) { - const nextKnobPositions: KnobItemPositions = { - ...knobPositions, - [selectedKeyType]: currentKnobs.map((pos, i) => { - const update = knobUpdates.find( - ({ element }) => element.index === i, - ); - if (update) { - return { - ...pos, - dx: update.bounds.x, - dy: update.bounds.y, - width: update.bounds.width, - height: update.bounds.height, - }; - } - return pos; - }), - }; - - knobStore.setPositions(nextKnobPositions); + } else { + // 결합 eager 단일 소유 - preflight 게이트, 양쪽 적용, 최종 봉인이 + // 한 호출 안. 불일치면 stable 포함 아무것도 적용하지 않고 정산 + // 전체 fail-closed (혼합이면 plugin 변경만 커밋) + const eager = applyGestureIntentsEagerly({ + baseline: syntheticBaselineRef.current, + indexIntents: syntheticIndexIntents, + propertyIntents: stableBoundsIntents, + }); + if (!eager.matched) { + groupSettlement = { kind: 'failClosed' }; + } else { + groupSettlement = { + kind: 'intents', + stableIntents: stableBoundsIntents, + syntheticIntents: syntheticIndexIntents, + receipt: eager.receipt, + }; + } } // 플러그인 요소들 업데이트 @@ -1283,40 +1235,73 @@ export function useGridResize({ setPreviewElementBounds(null); finalGroupBoundsRef.current = null; - // 정산 완결 - 완료 시점 live 선택 금지. 혼합: 보정된 스토어 full-record를 - // 시작 시점 plugin ID 집합과 mixed 트랜잭션으로 / plugin-only: editor - // 무커밋 + 오버레이 동기화 / 합성 포함 native: full-record 커밋(기록된 - // legacy 이연 계열, 크기 저장 보존) + // 정산 완결 - 완료 시점 live 선택 금지. wire patch는 coordinator 직렬 + // 슬롯 안에서 시작 동결 의도(안정 id + fingerprint 증명된 index)를 최신 + // base에 재생성한다 - 호출 시점 full-record 캡처는 대기 중 정산된 격리 + // plugin 쓰기의 다른 필드를 되돌린다. 혼합은 시작 plugin ID 집합과 mixed + // 트랜잭션으로 / plugin-only: editor 무커밋 + 오버레이 동기화 const settlementGestureId = resizeGestureIdRef.current ?? undefined; - if (!groupHandledNatively && groupHasNative) { - const editorChanges = { - schemaVersion: 1 as const, - keyPositions: useKeyStore.getState().canonicalPositions, - statPositions: useStatItemStore.getState().positions, - graphPositions: useGraphItemStore.getState().positions, - knobPositions: useKnobItemStore.getState().positions, - }; + if ( + !groupHandledNatively && + groupHasNative && + groupSettlement && + groupSettlement.kind === 'failClosed' + ) { + // eager 게이트 불일치 - editor 무커밋. 혼합이면 시작된 mixed + // 트랜잭션으로 plugin 변경만 정산 + reportElementOpSkipped('group resize settlement'); + if (groupPluginInvolved && settlementGestureId) { + void runMixedElementIntent({ + gestureId: settlementGestureId, + pluginIds: [...pluginResizeTokensRef.current.keys()], + applyEager: () => null, + generate: () => null, + skipContext: 'group resize settlement', + expectNull: true, + }).catch(reportElementOpError); + } + } else if ( + !groupHandledNatively && + groupHasNative && + groupSettlement && + groupSettlement.kind === 'intents' + ) { + const settlement = groupSettlement; + const baseline = syntheticBaselineRef.current; + const generate = (base: EditorDocumentV1): EditorPatchV1 | null => + generateCombinedBoundsPatch( + base, + settlement.stableIntents, + settlement.syntheticIntents, + baseline, + ); if (groupPluginInvolved && settlementGestureId) { const frozenPluginIds = [...pluginResizeTokensRef.current.keys()]; - void commitMixedGestureTransaction( - settlementGestureId, - editorChanges, - frozenPluginIds, - ).catch(reportElementOpError); + void runMixedElementIntent({ + gestureId: settlementGestureId, + pluginIds: frozenPluginIds, + applyEager: () => settlement.receipt, + generate, + skipContext: 'mixed group resize settlement', + }).catch(reportElementOpError); } else { - void editorCoordinator - .commitPatch( - editorChanges, - settlementGestureId - ? { gestureId: settlementGestureId } - : undefined, - ) + void runElementIntent({ + applyEager: () => settlement.receipt, + generate: (base) => intentPatch(generate(base)), + ...(settlementGestureId ? { gestureId: settlementGestureId } : {}), + }) + .then((result) => { + if (!result.committed && !result.satisfied) { + reportElementOpSkipped('group resize settlement'); + } + }) .catch(reportElementOpError); } } if (groupPluginInvolved) { syncPluginElementsToOverlay(); } + syntheticBaselineRef.current = null; endPluginResizeSessions(); }; From 9e866524da52a55f7a9d8065d9c0ecd7f4e1414c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 12 Aug 2026 20:18:13 +0900 Subject: [PATCH 31/35] =?UTF-8?q?fix:=20=EC=82=AD=EC=A0=9C=EC=99=80=20?= =?UTF-8?q?=EB=B6=99=EC=97=AC=EB=84=A3=EA=B8=B0=EB=A5=BC=20=EB=8F=99?= =?UTF-8?q?=EA=B2=B0=20=EA=B3=84=ED=9A=8D=EA=B3=BC=20=EC=8A=AC=EB=A1=AF=20?= =?UTF-8?q?=EC=9E=AC=EC=83=9D=EC=84=B1=EC=9C=BC=EB=A1=9C=20=EC=9E=AC?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/main/Grid/core/Grid.tsx | 4 +- .../hooks/Grid/useGridKeyboard.test.tsx | 66 +- src/renderer/hooks/Grid/useGridKeyboard.ts | 7 +- .../hooks/Grid/useGridSelection.test.tsx | 364 +++- src/renderer/hooks/Grid/useGridSelection.ts | 1461 ++++++++++++----- 5 files changed, 1434 insertions(+), 468 deletions(-) diff --git a/src/renderer/components/main/Grid/core/Grid.tsx b/src/renderer/components/main/Grid/core/Grid.tsx index 20666fd5..87bba182 100644 --- a/src/renderer/components/main/Grid/core/Grid.tsx +++ b/src/renderer/components/main/Grid/core/Grid.tsx @@ -695,7 +695,7 @@ const Grid = ({ const duplicateSelectedFromContextMenu = async () => { copySelectedElements(); - await pasteElements(); + await pasteElements().catch(reportElementOpError); }; // 합성 id(`${type}-${index}`)가 아닌 안정 id를 가진 native 선택 판별 @@ -2225,7 +2225,7 @@ const Grid = ({ onSelect={async (id: string) => { if (contextType === 'mixed') { if (id === 'delete') { - await deleteSelectedElements(); + await deleteSelectedElements().catch(reportElementOpError); } else if (id === 'duplicate') { await duplicateSelectedFromContextMenu(); } else if (id === 'bringToFront') { diff --git a/src/renderer/hooks/Grid/useGridKeyboard.test.tsx b/src/renderer/hooks/Grid/useGridKeyboard.test.tsx index cad019d4..a0316002 100644 --- a/src/renderer/hooks/Grid/useGridKeyboard.test.tsx +++ b/src/renderer/hooks/Grid/useGridKeyboard.test.tsx @@ -7,6 +7,7 @@ import { useKeyStore } from '@stores/data/useKeyStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; +import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; import { useGridKeyboard } from './useGridKeyboard'; import { useGridSelection } from './useGridSelection'; @@ -21,7 +22,41 @@ const { commitPatchMock, rotateSessionMock, sendBridgeMessageMock } = })); vi.mock('@src/renderer/editor/runtime/editorStateCoordinator', () => ({ - editorCoordinator: { commitPatch: commitPatchMock }, + editorCoordinator: { + commitPatch: commitPatchMock, + // 안정 id 기하 정산은 generator 경로 - gestureId 기록만 동일 recorder로 + commitGeneratedPatch: vi.fn( + ( + _generate: unknown, + meta?: { gestureId?: string; onEnrolled?: () => void }, + ) => { + meta?.onEnrolled?.(); + commitPatchMock({ schemaVersion: 1 }, { gestureId: meta?.gestureId }); + return Promise.resolve({}); + }, + ), + getState: () => ({ lastAck: null }), + }, +})); + +vi.mock('@src/renderer/editor/runtime/elementOps', () => ({ + // 게스처 버스트 검증 대상은 gestureId 운반 - 동기 recorder로 기록 + commitSelectedGeometryByIds: vi.fn( + (targets: unknown[], gestureId?: string) => { + commitPatchMock({ schemaVersion: 1 }, { gestureId }); + return Promise.resolve(targets.length); + }, + ), +})); + +vi.mock('@src/renderer/editor/runtime/mixedElementIntent', () => ({ + runMixedElementIntent: vi.fn( + (options: { gestureId: string; applyEager: () => unknown }) => { + options.applyEager(); + commitPatchMock({ schemaVersion: 1 }, { gestureId: options.gestureId }); + return Promise.resolve(); + }, + ), })); vi.mock('@utils/plugin/bridgeMessages', () => ({ @@ -37,8 +72,19 @@ vi.mock('@utils/core/platform', () => ({ isMac: () => false })); const firstGestureId = '00000000-0000-4000-8000-000000000001'; const secondGestureId = '00000000-0000-4000-8000-000000000002'; -const position = (): KeyPosition => - ({ dx: 0, dy: 0, width: 40, height: 40 } as KeyPosition); +const STABLE_IDS = [ + 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', +]; + +const position = (index = 0): KeyPosition => + ({ + id: STABLE_IDS[index], + dx: 0, + dy: 0, + width: 40, + height: 40, + } as KeyPosition); interface HarnessProps { includePlugin?: boolean; @@ -52,16 +98,22 @@ const Harness = ({ continuousInputStrategy = 'sync', }: HarnessProps) => { const selectedElements = [ - { type: 'key' as const, id: `key-${selectedIndex}`, index: selectedIndex }, + { + type: 'key' as const, + id: STABLE_IDS[selectedIndex], + index: selectedIndex, + }, ...(includePlugin ? [{ type: 'plugin' as const, id: 'plugin-a:element' }] : []), ]; + // 정산 라우팅은 선택 스토어를 읽는다 - 하네스 선택과 동기화 + useGridSelectionStore.setState({ selectedElements }); const { moveSelectedElements } = useGridSelection({ selectedElements, selectedKeyType: '4key', keyMappings: { '4key': ['KeyA', 'KeyB'] }, - positions: { '4key': [position(), position()] }, + positions: { '4key': [position(0), position(1)] }, }); useGridKeyboard({ @@ -97,8 +149,8 @@ describe('useGridKeyboard arrow history burst', () => { useKeyStore.setState({ selectedKeyType: '4key', keyMappings: { '4key': ['KeyA', 'KeyB'] }, - positions: { '4key': [position(), position()] }, - canonicalPositions: { '4key': [position(), position()] }, + positions: { '4key': [position(0), position(1)] }, + canonicalPositions: { '4key': [position(0), position(1)] }, }); useStatItemStore.setState({ positions: {} }); useGraphItemStore.setState({ positions: {} }); diff --git a/src/renderer/hooks/Grid/useGridKeyboard.ts b/src/renderer/hooks/Grid/useGridKeyboard.ts index 2ec2b77a..a6e87f5e 100644 --- a/src/renderer/hooks/Grid/useGridKeyboard.ts +++ b/src/renderer/hooks/Grid/useGridKeyboard.ts @@ -12,6 +12,7 @@ import { useGridSelectionStore, type SelectedElement, } from '@stores/grid/useGridSelectionStore'; +import { reportElementOpError } from '@src/renderer/editor/runtime/elementIntent'; import { useKeyStore } from '@stores/data/useKeyStore'; import { ARROW_KEY_HISTORY_DELAY } from './constants'; import { isMac } from '@utils/core/platform'; @@ -165,7 +166,7 @@ export function useGridKeyboard({ const currentClipboard = useGridSelectionStore.getState().clipboard; if (currentClipboard.length > 0) { e.preventDefault(); - pasteElements(); + void Promise.resolve(pasteElements()).catch(reportElementOpError); } return; } @@ -215,7 +216,9 @@ export function useGridKeyboard({ // Delete 키로 선택 요소 삭제 if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); - deleteSelectedElements(); + void Promise.resolve(deleteSelectedElements()).catch( + reportElementOpError, + ); return; } diff --git a/src/renderer/hooks/Grid/useGridSelection.test.tsx b/src/renderer/hooks/Grid/useGridSelection.test.tsx index d9a65896..31fc6994 100644 --- a/src/renderer/hooks/Grid/useGridSelection.test.tsx +++ b/src/renderer/hooks/Grid/useGridSelection.test.tsx @@ -15,6 +15,21 @@ import type { PluginDisplayElementInternal } from '@src/types/plugin/api'; import { useGridSelection } from './useGridSelection'; const mocks = vi.hoisted(() => ({ + commitGeometry: vi.fn(() => Promise.resolve(1)), + runMixedIntent: vi.fn(() => Promise.resolve()), + pluginAdditionThrows: false, + commitGeneratedPatch: vi.fn( + ( + _generate: (base: unknown) => unknown, + meta?: { onEnrolled?: () => void }, + ) => { + meta?.onEnrolled?.(); + return Promise.resolve({}); + }, + ), + runMixedGestureIntent: vi.fn(() => + Promise.resolve({ committed: true, satisfied: true }), + ), commitPatch: vi.fn((_patch: unknown, _options?: { gestureId?: string }) => Promise.resolve(), ), @@ -27,7 +42,42 @@ const mocks = vi.hoisted(() => ({ })); vi.mock('@src/renderer/editor/runtime/editorStateCoordinator', () => ({ - editorCoordinator: { commitPatch: mocks.commitPatch }, + editorCoordinator: { + commitPatch: mocks.commitPatch, + commitGeneratedPatch: mocks.commitGeneratedPatch, + getState: () => ({ lastAck: null }), + }, +})); + +vi.mock('@src/renderer/editor/runtime/elementOps', () => ({ + commitSelectedGeometryByIds: mocks.commitGeometry, +})); + +vi.mock('@src/renderer/editor/runtime/mixedElementIntent', () => ({ + applyPluginRemovalEagerly: ( + _fullIds: readonly string[], + mutate: () => void, + ) => { + mutate(); + return null; + }, + applyPluginAdditionEagerly: ( + _added: readonly string[], + _z: unknown[], + mutate: () => void, + ) => { + if (mocks.pluginAdditionThrows) { + throw new Error('plugin eager failed'); + } + mutate(); + return null; + }, + applySealedMixedMutation: (options: { mutate: () => void }) => { + options.mutate(); + return { rollback: vi.fn() }; + }, + runMixedElementIntent: mocks.runMixedIntent, + runMixedGestureElementIntent: mocks.runMixedGestureIntent, })); vi.mock('@plugins/rpc/pluginElementActions', () => ({ @@ -49,7 +99,9 @@ vi.mock('@utils/plugin/bridgeMessages', () => ({ })); const gestureId = '00000000-0000-4000-8000-0000000000f4'; +const STABLE_KEY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; const keyPosition = { + id: STABLE_KEY_ID, dx: 10, dy: 20, width: 60, @@ -97,6 +149,11 @@ describe('useGridSelection compound history gesture', () => { beforeEach(async () => { globalThis.IS_REACT_ACT_ENVIRONMENT = true; mocks.commitPatch.mockClear(); + mocks.commitGeometry.mockClear(); + mocks.runMixedIntent.mockClear(); + mocks.commitGeneratedPatch.mockClear(); + mocks.pluginAdditionThrows = false; + mocks.runMixedGestureIntent.mockClear(); mocks.deletePluginElements.mockClear(); mocks.rotateSession.mockClear(); mocks.beginMixedGesture.mockClear(); @@ -142,7 +199,7 @@ describe('useGridSelection compound history gesture', () => { it('혼합 삭제는 editor와 plugin에 같은 gestureId를 전달한다', async () => { await act(async () => { useGridSelectionStore.getState().setSelectedElements([ - { type: 'key', id: 'key-0', index: 0 }, + { type: 'key', id: STABLE_KEY_ID, index: 0 }, { type: 'plugin', id: 'plugin-a:element' }, ]); }); @@ -153,25 +210,29 @@ describe('useGridSelection compound history gesture', () => { ['plugin-a:element'], gestureId, ); - expect(mocks.beginMixedGesture).toHaveBeenCalledWith(gestureId, [ - 'plugin-a', - ]); - expect(mocks.commitMixedGesture).toHaveBeenCalledWith( - gestureId, - expect.objectContaining({ schemaVersion: 1 }), - ['plugin-a'], - ); + // wire는 슬롯 정합 mixed intent - full-record 커밋 금지 + expect(mocks.runMixedGestureIntent).toHaveBeenCalledTimes(1); + const intentOptions = ( + mocks.runMixedGestureIntent.mock.calls[0] as unknown[] + )[0] as { + gestureId: string; + initialPluginIds: readonly string[]; + }; + expect(intentOptions.gestureId).toBe(gestureId); + expect(intentOptions.initialPluginIds).toEqual(['plugin-a']); expect(mocks.commitPatch).not.toHaveBeenCalled(); }); - it('혼합 삭제 중 동기 예외가 나도 staged transaction을 정산한다', async () => { + it('혼합 삭제 중 동기 예외가 나도 staged를 정산하고 editor eager를 복원한다', async () => { const error = new Error('delete projection failed'); + const mappingsBefore = useKeyStore.getState().keyMappings; + const positionsBefore = useKeyStore.getState().canonicalPositions; mocks.deletePluginElements.mockImplementationOnce(() => { throw error; }); await act(async () => { useGridSelectionStore.getState().setSelectedElements([ - { type: 'key', id: 'key-0', index: 0 }, + { type: 'key', id: STABLE_KEY_ID, index: 0 }, { type: 'plugin', id: 'plugin-a:element' }, ]); }); @@ -187,6 +248,9 @@ describe('useGridSelection compound history gesture', () => { expect(caught).toBe(error); expect(mocks.cancelUncommittedMixedGesture).toHaveBeenCalledWith(gestureId); + // plugin eager 실패 시 editor eager(삭제)도 복원 - key가 부활한다 + expect(useKeyStore.getState().keyMappings).toEqual(mappingsBefore); + expect(useKeyStore.getState().canonicalPositions).toEqual(positionsBefore); }); it('혼합 붙여넣기는 editor와 plugin에 같은 gestureId를 전달한다', async () => { @@ -203,14 +267,16 @@ describe('useGridSelection compound history gesture', () => { await act(async () => api.pasteElements()); expect(mocks.rotateSession).toHaveBeenCalledWith('plugin-a', gestureId); - expect(mocks.beginMixedGesture).toHaveBeenCalledWith(gestureId, [ - 'plugin-a', - ]); - expect(mocks.commitMixedGesture).toHaveBeenCalledWith( - gestureId, - expect.objectContaining({ schemaVersion: 1 }), - ['plugin-a'], - ); + // paste는 항상 mixed-capable 슬롯 정합 러너 - full-record 커밋 금지 + expect(mocks.runMixedGestureIntent).toHaveBeenCalledTimes(1); + const pasteOptions = ( + mocks.runMixedGestureIntent.mock.calls[0] as unknown[] + )[0] as { + gestureId: string; + initialPluginIds: readonly string[]; + }; + expect(pasteOptions.gestureId).toBe(gestureId); + expect(pasteOptions.initialPluginIds).toEqual(['plugin-a']); expect(mocks.commitPatch).not.toHaveBeenCalled(); }); @@ -246,10 +312,262 @@ describe('useGridSelection compound history gesture', () => { expect(mocks.cancelUncommittedMixedGesture).toHaveBeenCalledWith(gestureId); }); - it('resize 종료 callback은 전달받은 gestureId로 editor를 저장한다', () => { + it('안정 id 이동 정산은 기하 의도 커밋에 gestureId를 전달한다', async () => { + await act(async () => { + useGridSelectionStore + .getState() + .setSelectedElements([{ type: 'key', id: STABLE_KEY_ID, index: 0 }]); + }); + api.syncSelectedElementsToOverlay(gestureId); + + expect(mocks.commitGeometry).toHaveBeenCalledTimes(1); + expect(mocks.commitGeometry).toHaveBeenCalledWith( + [{ type: 'key', id: STABLE_KEY_ID }], + gestureId, + ); + expect(mocks.commitPatch).not.toHaveBeenCalled(); + }); + + it('빈 선택 정산은 editor를 커밋하지 않는다', () => { api.syncSelectedElementsToOverlay(gestureId); - expect(mocks.commitPatch).toHaveBeenCalledTimes(1); - expect(mocks.commitPatch.mock.calls[0]?.[1]).toEqual({ gestureId }); + expect(mocks.commitGeometry).not.toHaveBeenCalled(); + expect(mocks.commitPatch).not.toHaveBeenCalled(); + }); + + const deletionBase = () => ({ + schemaVersion: 1, + keys: { '4key': ['KeyA', 'KeyB'] }, + keyPositions: { + '4key': [ + { ...keyPosition, id: STABLE_KEY_ID }, + { + ...keyPosition, + id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + noteWidth: 111, + }, + ], + }, + statPositions: {}, + graphPositions: {}, + knobPositions: {}, + layerGroups: {}, + }); + + it('editor 전용 삭제는 슬롯 base에서 id 재해석으로 pair mask 재생성한다', async () => { + await act(async () => { + useGridSelectionStore + .getState() + .setSelectedElements([{ type: 'key', id: STABLE_KEY_ID, index: 0 }]); + }); + await act(async () => api.deleteSelectedElements()); + + expect(mocks.commitGeneratedPatch).toHaveBeenCalledTimes(1); + const generate = ( + mocks.commitGeneratedPatch.mock.calls[0] as unknown[] + )[0] as (base: unknown) => { + keys?: Record; + keyPositions?: Record>>; + } | null; + const patch = generate(deletionBase()); + // 대상만 제거되고 pair가 같은 mask로 - 대기 중 정산된 noteWidth는 생존 + expect(patch?.keys?.['4key']).toEqual(['KeyB']); + expect(patch?.keyPositions?.['4key']).toHaveLength(1); + expect(patch?.keyPositions?.['4key'][0]).toMatchObject({ noteWidth: 111 }); + expect(mocks.commitPatch).not.toHaveBeenCalled(); + }); + + it('삭제 대상이 base에서 이미 사라졌으면 무패치(satisfied)다', async () => { + await act(async () => { + useGridSelectionStore + .getState() + .setSelectedElements([ + { type: 'key', id: '99999999-9999-4999-8999-999999999999', index: 0 }, + ]); + }); + await act(async () => api.deleteSelectedElements()); + + const generate = ( + mocks.commitGeneratedPatch.mock.calls[0] as unknown[] + )[0] as (base: unknown) => unknown; + expect(generate(deletionBase())).toBeNull(); + }); + + it('paste generator는 슬롯 base에 동결 payload를 재적용하고 멱등·충돌을 판별한다', async () => { + act(() => { + useGridSelectionStore + .getState() + .setClipboard([ + { type: 'key', keyCode: 'KeyB', position: keyPosition }, + ]); + }); + await act(async () => api.pasteElements()); + + expect(mocks.runMixedGestureIntent).toHaveBeenCalledTimes(1); + const options = ( + mocks.runMixedGestureIntent.mock.calls[0] as unknown[] + )[0] as { + generate: (context: { base: unknown; pluginProjection: unknown[] }) => { + kind: string; + patch?: { + keys?: Record; + keyPositions?: Record>>; + }; + }; + }; + const emptyBase = { + schemaVersion: 1, + keys: { '4key': [] }, + keyPositions: { + '4key': [], + }, + statPositions: {}, + graphPositions: {}, + knobPositions: {}, + layerGroups: {}, + }; + const result = options.generate({ base: emptyBase, pluginProjection: [] }); + expect(result.kind).toBe('patch'); + expect(result.patch?.keys?.['4key']).toEqual(['KeyB']); + expect(result.patch?.keyPositions?.['4key']).toHaveLength(1); + const pastedId = result.patch?.keyPositions?.['4key'][0].id as string; + + // 멱등 재시도: 같은 payload가 이미 base에 있으면 satisfied + const appliedBase = { + ...emptyBase, + keys: { '4key': ['KeyB'] }, + keyPositions: { + '4key': [result.patch!.keyPositions!['4key'][0]], + }, + }; + expect( + options.generate({ base: appliedBase, pluginProjection: [] }).kind, + ).toBe('satisfied'); + + // 충돌: 같은 id에 다른 payload면 전체 중단 sentinel + const conflictedBase = { + ...appliedBase, + keyPositions: { + '4key': [ + { ...result.patch!.keyPositions!['4key'][0], dx: 987, id: pastedId }, + ], + }, + }; + expect(() => + options.generate({ base: conflictedBase, pluginProjection: [] }), + ).toThrowError(/paste id collision/); + }); + + it('그룹 앵커는 당시 자식이 사라져도 살아있는 그룹 경계로 재해석한다', async () => { + // 그룹 g1의 최상단 자식과 함께 그룹 선택 상태에서 paste + const memberId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; + await act(async () => { + useKeyStore.setState({ + canonicalPositions: { + '4key': [ + keyPosition, + { ...keyPosition, id: memberId, groupId: 'g1', zIndex: 5 }, + ], + } as never, + keyMappings: { '4key': ['KeyA', 'KeyB'] } as never, + }); + // 실사용 그룹 클릭 상태: 자식 전체 + groupId 동시 선택 (tie에서 + // 그룹 앵커가 이겨야 한다) + useGridSelectionStore + .getState() + .setFullSelection([{ type: 'key', id: memberId, index: 1 }], ['g1']); + useGridSelectionStore + .getState() + .setClipboard([ + { type: 'key', keyCode: 'KeyC', position: keyPosition }, + ]); + }); + await act(async () => api.pasteElements()); + + const options = ( + mocks.runMixedGestureIntent.mock.calls[0] as unknown[] + )[0] as { + generate: (context: { base: unknown; pluginProjection: unknown[] }) => { + patch?: { + keyPositions?: Record>>; + }; + }; + }; + // 슬롯 base: 당시 자식(memberId)은 삭제됐지만 g1에 새 자식이 존재 + const survivorId = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'; + const topId = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'; + const base = { + schemaVersion: 1, + keys: { '4key': ['KeyA', 'KeyZ', 'KeyT'] }, + keyPositions: { + '4key': [ + { ...keyPosition, id: STABLE_KEY_ID, zIndex: 1 }, + { ...keyPosition, id: survivorId, groupId: 'g1', zIndex: 9 }, + { ...keyPosition, id: topId, zIndex: 20 }, + ], + }, + statPositions: {}, + graphPositions: {}, + knobPositions: {}, + layerGroups: { '4key': [{ id: 'g1', name: 'g1' }] }, + }; + const result = options.generate({ base, pluginProjection: [] }); + const positions = result.patch?.keyPositions?.['4key'] ?? []; + const pasted = positions.find( + (position) => + position.id !== STABLE_KEY_ID && + position.id !== survivorId && + position.id !== topId, + ); + const survivor = positions.find((position) => position.id === survivorId); + const top = positions.find((position) => position.id === topId); + // 그룹 경계 위에 삽입 - 살아있는 g1 자식보다 위지만 그룹 밖 최상단 + // 요소보다는 아래 (element 앵커 소실의 전역 최상단 폴백과 구별) + expect(pasted).toBeDefined(); + expect((pasted!.zIndex as number) > (survivor!.zIndex as number)).toBe( + true, + ); + expect((pasted!.zIndex as number) < (top!.zIndex as number)).toBe(true); + }); + + it('plugin eager가 실패하면 editor eager도 함께 복원한다', async () => { + mocks.pluginAdditionThrows = true; + const mappingsBefore = useKeyStore.getState().keyMappings; + const positionsBefore = useKeyStore.getState().canonicalPositions; + act(() => { + useGridSelectionStore + .getState() + .setClipboard([ + { type: 'key', keyCode: 'KeyB', position: keyPosition }, + ]); + }); + let caught: unknown; + await act(async () => { + try { + await api.pasteElements(); + } catch (error) { + caught = error; + } + }); + + expect((caught as Error).message).toBe('plugin eager failed'); + expect(useKeyStore.getState().keyMappings).toEqual(mappingsBefore); + expect(useKeyStore.getState().canonicalPositions).toEqual(positionsBefore); + }); + + it('plugin scope staging은 eager 스토어 변이보다 앞선다', async () => { + await act(async () => { + useGridSelectionStore.getState().setSelectedElements([ + { type: 'key', id: STABLE_KEY_ID, index: 0 }, + { type: 'plugin', id: 'plugin-a:element' }, + ]); + }); + await act(async () => api.deleteSelectedElements()); + + const beginOrder = + mocks.beginMixedGesture.mock.invocationCallOrder[0] ?? Infinity; + const deleteOrder = + mocks.deletePluginElements.mock.invocationCallOrder[0] ?? -1; + expect(beginOrder).toBeLessThan(deleteOrder); }); }); diff --git a/src/renderer/hooks/Grid/useGridSelection.ts b/src/renderer/hooks/Grid/useGridSelection.ts index 2bf04a3e..15c8b6d3 100644 --- a/src/renderer/hooks/Grid/useGridSelection.ts +++ b/src/renderer/hooks/Grid/useGridSelection.ts @@ -13,7 +13,6 @@ import { useKnobItemStore } from '@stores/data/useKnobItemStore'; import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; import { - selectionElementId, useGridSelectionStore, type SelectedElement, type ClipboardItem, @@ -26,24 +25,41 @@ import type { KeySlot, } from '@src/types/key/keys'; import { cloneSlot } from '@utils/keySlot'; -import type { - StatItemPosition, - StatItemPositions, -} from '@src/types/key/statItems'; -import type { - GraphItemPosition, - GraphItemPositions, -} from '@src/types/key/graphItems'; -import type { KnobItemPosition, KnobItemPositions } from '@src/types/key/knobs'; +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 { PluginDisplayElementInternal } from '@src/types/plugin/api'; import { normalizeLayerGroupsForMode, buildNextLayerGroupName, buildLayerItemsForMode, - findPasteAnchorIndex, applyZIndexToLayerOrder, } from '@utils/layerGroupUtils'; import { commitSelectedGeometryByIds } from '@src/renderer/editor/runtime/elementOps'; +import { + ElementIntentAbort, + applySealedSliceMutation, + captureIndexIntentBaseline, + combineReceipts, + createPropertyReceipt, + generatePropertyIntentPatch, + indexBaselineMatches, + reportElementOpError, + reportElementOpSkipped, + runElementIntent, + type ElementIntentReceipt, + type PropertyIntents, + type PropertyReceiptEntry, +} from '@src/renderer/editor/runtime/elementIntent'; +import { + applyPluginAdditionEagerly, + applyPluginRemovalEagerly, + runMixedElementIntent, + runMixedGestureElementIntent, +} from '@src/renderer/editor/runtime/mixedElementIntent'; +import { stableStringify } from '@utils/core/stableStringify'; +import type { EditorDocumentV1, EditorPatchV1 } from '@src/types/editor'; +import { resolveElementById } from '@src/renderer/editor/model/elementIdMap'; import { isSyntheticElementId } from '@src/renderer/editor/model/elementIdMap'; import { editorCoordinator } from '@src/renderer/editor/runtime/editorStateCoordinator'; import { sendBridgeMessageBestEffort } from '@utils/plugin/bridgeMessages'; @@ -52,7 +68,6 @@ import { rotatePluginInstancesEditSession } from '@plugins/runtime/displayElemen import { beginMixedGestureTransaction, cancelUncommittedMixedGestureTransaction, - commitMixedGestureTransaction, } from '@plugins/runtime/displayElement/gestureTransaction'; interface UseGridSelectionParams { @@ -99,13 +114,6 @@ export function useGridSelection({ const currentStatPositions = useStatItemStore.getState().positions; const currentGraphPositions = useGraphItemStore.getState().positions; const currentKnobPositions = useKnobItemStore.getState().positions; - const editorChanges = { - schemaVersion: 1 as const, - keyPositions: currentPositions, - statPositions: currentStatPositions, - graphPositions: currentGraphPositions, - knobPositions: currentKnobPositions, - }; const currentSelection = useGridSelectionStore.getState().selectedElements; const selectedPluginElementIds = new Set( currentSelection @@ -142,18 +150,121 @@ export function useGridSelection({ nativeTargets.every( (target) => target.id.length > 0 && !isSyntheticElementId(target.id), ); - const persisted = - gestureId && isMixed - ? commitMixedGestureTransaction(gestureId, editorChanges, pluginIds) - : allStableIds - ? commitSelectedGeometryByIds(nativeTargets, gestureId) - : editorCoordinator.commitPatch( - editorChanges, - gestureId ? { gestureId } : undefined, - ); - void persisted.catch((error: Error) => { - console.error('Failed to persist selected element positions', error); - }); + // plugin-only 선택은 editor 의도가 없다 - editor 무커밋 + if (nativeTargets.length > 0) { + if (!allStableIds) { + // 합성 선택 정산은 시작 fingerprint 배관이 없어 fail-closed 무커밋. + // v1 어댑터가 로드 시 id를 backfill하므로 실도달 불가 경로 + if (gestureId && isMixed) { + // plugin 변경은 시작된 mixed 트랜잭션으로 커밋하되 editor는 생략 + reportElementOpSkipped('synthetic selection settlement'); + void runMixedElementIntent({ + gestureId, + pluginIds, + applyEager: () => null, + generate: () => null, + skipContext: 'synthetic selection settlement', + expectNull: true, + }).catch(reportElementOpError); + } else { + reportElementOpSkipped('synthetic selection settlement'); + } + } else if (gestureId && isMixed) { + // 이동 정산은 dx·dy만 동결 - width·height까지 실으면 병행 리사이즈를 + // 되돌린다. wire는 슬롯 generator가 최신 base에 id 의도를 재적용 + const geometryIntents: PropertyIntents = new Map( + (['key', 'stat', 'graph', 'knob'] as const).map((type) => [ + type, + new Map( + nativeTargets + .filter((target) => target.type === type) + .flatMap((target) => { + const locator = resolveElementById(type, target.id); + const record = + type === 'key' + ? currentPositions + : type === 'stat' + ? currentStatPositions + : type === 'graph' + ? currentGraphPositions + : currentKnobPositions; + const position = locator + ? ( + record as Record< + string, + Array<{ dx?: number; dy?: number }> + > + )[locator.mode]?.[locator.index] + : undefined; + if (!position) return []; + return [ + [ + target.id, + { dx: position.dx ?? 0, dy: position.dy ?? 0 }, + ] as const, + ]; + }), + ), + ]), + ); + // receipt before는 완료 시점 lastAck 값 - 드래그 중 이미 eager된 + // 스토어 값을 다시 읽으면 before===expected 무효 receipt가 된다 + const lastAck = editorCoordinator.getState().lastAck; + const receiptEntries: PropertyReceiptEntry[] = []; + if (lastAck) { + for (const [type, byId] of geometryIntents) { + const field = + type === 'key' + ? 'keyPositions' + : type === 'stat' + ? 'statPositions' + : type === 'graph' + ? 'graphPositions' + : 'knobPositions'; + const collections = lastAck[field] as Record< + string, + Array<{ id?: string } & Record> + >; + for (const list of Object.values(collections)) { + for (const position of list) { + const id = position.id; + if (typeof id !== 'string') continue; + const intent = byId.get(id); + if (!intent) continue; + for (const [fieldName, expected] of Object.entries(intent)) { + receiptEntries.push({ + type, + id, + field: fieldName, + before: position[fieldName], + expected, + }); + } + } + } + } + } + void runMixedElementIntent({ + gestureId, + pluginIds, + applyEager: () => createPropertyReceipt(receiptEntries), + generate: (base) => + generatePropertyIntentPatch(base, geometryIntents), + skipContext: 'mixed selection settlement', + }).catch((error: Error) => { + console.error('Failed to persist selected element positions', error); + }); + } else { + void commitSelectedGeometryByIds(nativeTargets, gestureId).catch( + (error: Error) => { + console.error( + 'Failed to persist selected element positions', + error, + ); + }, + ); + } + } // 플러그인 요소도 명시적으로 동기화 (드래그 종료 시 skipSync로 인해 동기화되지 않았을 수 있음) const currentPluginElements = @@ -319,29 +430,35 @@ export function useGridSelection({ }; // 선택된 요소들 삭제 함수 (배치 삭제) + // 선택된 요소들 삭제 (배치): 대상은 호출 시점 동결(안정 id 전역 재해석, + // 합성은 invocation baseline), eager는 봉인 구조 receipt, wire는 슬롯 + // base에서 재생성. full-record 캡처 커밋 금지 - 대기 중 정산된 다른 + // 커밋을 되돌린다. destructive라 fingerprint 불일치는 전체 중단 const deleteSelectedElements = async () => { if (selectedElements.length === 0) return; + const gestureId = crypto.randomUUID(); - const keysToDelete = selectedElements - .filter((el) => el.type === 'key' && el.index !== undefined) - .map((el) => el.index as number); - - const statsToDelete = selectedElements - .filter((el) => el.type === 'stat' && el.index !== undefined) - .map((el) => el.index as number); - - const graphsToDelete = selectedElements - .filter((el) => el.type === 'graph' && el.index !== undefined) - .map((el) => el.index as number); - - const knobsToDelete = selectedElements - .filter((el) => el.type === 'knob' && el.index !== undefined) - .map((el) => el.index as number); - + // 삭제 계획 동결 + const stableTargets: Array<{ + type: 'key' | 'stat' | 'graph' | 'knob'; + id: string; + }> = []; + const syntheticIndexTargets: Array<{ + type: 'key' | 'stat' | 'graph' | 'knob'; + index: number; + }> = []; + for (const element of selectedElements) { + if (element.type === 'plugin') continue; + const type = element.type as 'key' | 'stat' | 'graph' | 'knob'; + if (element.id.length > 0 && !isSyntheticElementId(element.id)) { + stableTargets.push({ type, id: element.id }); + } else if (element.index !== undefined) { + syntheticIndexTargets.push({ type, index: element.index }); + } + } const pluginsToDelete = selectedElements .filter((el) => el.type === 'plugin') .map((el) => el.id); - const gestureId = crypto.randomUUID(); const pluginIdsToDelete = [ ...new Set( usePluginDisplayElementStore @@ -353,153 +470,361 @@ export function useGridSelection({ ), ]; const hasEditorDeletion = - keysToDelete.length > 0 || - statsToDelete.length > 0 || - graphsToDelete.length > 0 || - knobsToDelete.length > 0; - const isMixedDeletion = hasEditorDeletion && pluginIdsToDelete.length > 0; - try { - if (isMixedDeletion) { - pluginIdsToDelete.forEach((pluginId) => { - rotatePluginInstancesEditSession(pluginId, gestureId); - }); - beginMixedGestureTransaction(gestureId, pluginIdsToDelete); + stableTargets.length > 0 || syntheticIndexTargets.length > 0; + + // 합성 대상은 invocation 시점 구조 증명 아래에서만 index 삭제 + const syntheticBaseline = + syntheticIndexTargets.length > 0 + ? captureIndexIntentBaseline( + editorCoordinator.getState().lastAck, + selectedKeyType, + [ + 'keys', + 'keyPositions', + 'statPositions', + 'graphPositions', + 'knobPositions', + 'layerGroups', + ], + ) + : null; + if (syntheticIndexTargets.length > 0 && !syntheticBaseline) { + reportElementOpSkipped('batch delete (no baseline)'); + return; + } + if (syntheticIndexTargets.length > 0 && syntheticBaseline) { + // eager 게이트: 스토어 구조가 invocation baseline과 다르면 index + // 신원이 무효 - 아무것도 적용하지 않고 전체 fail-closed + const storeDocument = { + keys: useKeyStore.getState().keyMappings, + keyPositions: useKeyStore.getState().canonicalPositions, + statPositions: useStatItemStore.getState().positions, + graphPositions: useGraphItemStore.getState().positions, + knobPositions: useKnobItemStore.getState().positions, + layerGroups: useLayerGroupStore.getState().layerGroups, + } as unknown as Record; + if (!indexBaselineMatches(syntheticBaseline, storeDocument)) { + reportElementOpSkipped('batch delete (baseline mismatch)'); + return; } + } - // 먼저 선택 해제 (삭제된 인덱스 참조 방지) - clearSelection(); - - // 키 배치 삭제 (atomic update로 한 번의 리렌더링만 발생) - if (keysToDelete.length > 0) { - const { keyMappings: km, canonicalPositions: pos } = - useKeyStore.getState(); - const mapping = km[selectedKeyType] || []; - const posArray = pos[selectedKeyType] || []; - - // 삭제할 인덱스를 Set으로 변환 (O(1) 조회) - const deleteSet = new Set(keysToDelete); - - const updatedMappings = { - ...km, - [selectedKeyType]: mapping.filter( - (_, index) => !deleteSet.has(index), - ), - }; - - const updatedPositions = { - ...pos, - [selectedKeyType]: posArray.filter( - (_, index) => !deleteSet.has(index), - ), - }; - - // Atomic update: mappings, positions 동시 업데이트로 중간 상태 방지 - useKeyStore - .getState() - .setKeyMappingsAndPositions(updatedMappings, updatedPositions); + // 삭제 mask 계산: 문서(base 또는 스토어 뷰)에서 (type, mode) → index 집합 + const collectRemoval = (document: { + keys: Record; + keyPositions: Record>; + statPositions: Record>; + graphPositions: Record>; + knobPositions: Record>; + layerGroups: Record; + }) => { + const FIELD_OF = { + key: 'keyPositions', + stat: 'statPositions', + graph: 'graphPositions', + knob: 'knobPositions', + } as const; + const removal = new Map< + 'key' | 'stat' | 'graph' | 'knob', + Map> + >(); + let found = 0; + const mark = ( + type: 'key' | 'stat' | 'graph' | 'knob', + mode: string, + index: number, + ) => { + const byMode = removal.get(type) ?? new Map>(); + const set = byMode.get(mode) ?? new Set(); + set.add(index); + byMode.set(mode, set); + removal.set(type, byMode); + found += 1; + }; + for (const target of stableTargets) { + const record = document[FIELD_OF[target.type]]; + for (const [mode, list] of Object.entries(record)) { + const index = list.findIndex((position) => position.id === target.id); + if (index !== -1) { + mark(target.type, mode, index); + break; + } + } } - - // 플러그인 요소 배치 삭제 - if (pluginsToDelete.length > 0) { - deletePluginElements(pluginsToDelete, gestureId); + for (const target of syntheticIndexTargets) { + const list = document[FIELD_OF[target.type]][selectedKeyType]; + if (list && target.index < list.length) { + mark(target.type, selectedKeyType, target.index); + } } + return { removal, found }; + }; - // 통계 요소 배치 삭제 - if (statsToDelete.length > 0) { - const current = useStatItemStore.getState().positions; - const tabPositions = current[selectedKeyType] || []; - const deleteSet = new Set(statsToDelete); - const updatedPositions = { - ...current, - [selectedKeyType]: tabPositions.filter( - (_, idx) => !deleteSet.has(idx), - ), - }; - - useStatItemStore.getState().setPositions(updatedPositions); + const applyRemoval = ( + document: Parameters[0], + removal: ReadonlyMap< + 'key' | 'stat' | 'graph' | 'knob', + ReadonlyMap> + >, + ) => { + const next = { + keys: { ...document.keys }, + keyPositions: { ...document.keyPositions }, + statPositions: { ...document.statPositions }, + graphPositions: { ...document.graphPositions }, + knobPositions: { ...document.knobPositions }, + layerGroups: document.layerGroups, + }; + const affectedModes = new Set(); + for (const [type, byMode] of removal) { + const field = + type === 'key' + ? 'keyPositions' + : type === 'stat' + ? 'statPositions' + : type === 'graph' + ? 'graphPositions' + : 'knobPositions'; + for (const [mode, indexSet] of byMode) { + affectedModes.add(mode); + next[field] = { + ...next[field], + [mode]: (next[field][mode] ?? []).filter( + (_, index) => !indexSet.has(index), + ), + }; + if (type === 'key') { + // pair 결합: 같은 index mask를 keys에도 적용 - mask 전 길이 + // 일치와 index 유효성이 증명돼야 한다 + const pairLength = (next.keys[mode] ?? []).length; + const positionLength = (document.keyPositions[mode] ?? []).length; + if (pairLength !== positionLength) { + throw new ElementIntentAbort('key pair length mismatch'); + } + for (const index of indexSet) { + if (index < 0 || index >= pairLength) { + throw new ElementIntentAbort('key pair index out of range'); + } + } + next.keys = { + ...next.keys, + [mode]: (next.keys[mode] ?? []).filter( + (_, index) => !indexSet.has(index), + ), + }; + } + } } - - // 그래프 요소 배치 삭제 - if (graphsToDelete.length > 0) { - const current = useGraphItemStore.getState().positions; - const tabPositions = current[selectedKeyType] || []; - const deleteSet = new Set(graphsToDelete); - const updatedPositions = { - ...current, - [selectedKeyType]: tabPositions.filter( - (_, idx) => !deleteSet.has(idx), - ), - }; - - useGraphItemStore.getState().setPositions(updatedPositions); + // 삭제가 발생한 모든 mode에 그룹 재정규화 + let layerGroups = document.layerGroups; + let groupsChanged = false; + for (const mode of affectedModes) { + const normalized = normalizeLayerGroupsForMode({ + mode, + keyPositions: next.keyPositions as never, + statPositions: next.statPositions as never, + graphPositions: next.graphPositions as never, + knobPositions: next.knobPositions as never, + layerGroups: layerGroups as never, + }); + next.keyPositions = normalized.keyPositions as never; + next.statPositions = normalized.statPositions as never; + next.graphPositions = normalized.graphPositions as never; + next.knobPositions = normalized.knobPositions as never; + if (normalized.groupsChanged) { + layerGroups = normalized.layerGroups as never; + groupsChanged = true; + } } + return { next, layerGroups, groupsChanged, affectedModes }; + }; - // 노브 요소 배치 삭제 - if (knobsToDelete.length > 0) { - const current = useKnobItemStore.getState().positions; - const tabPositions = current[selectedKeyType] || []; - const deleteSet = new Set(knobsToDelete); - const updatedPositions = { - ...current, - [selectedKeyType]: tabPositions.filter( - (_, idx) => !deleteSet.has(idx), - ), - }; + // 먼저 선택 해제 (삭제된 참조 방지) - 실패 시 선택 미복원(기록된 정책) + clearSelection(); - useKnobItemStore.getState().setPositions(updatedPositions); - } + // eager 단계 동기 예외도 staged 정산 안전망을 거친다 + try { + await deleteWithFrozenPlan(); + } finally { + cancelUncommittedMixedGestureTransaction(gestureId); + } - const normalized = normalizeLayerGroupsForMode({ - mode: selectedKeyType, - keyPositions: useKeyStore.getState().canonicalPositions, - statPositions: useStatItemStore.getState().positions, - graphPositions: useGraphItemStore.getState().positions, - knobPositions: useKnobItemStore.getState().positions, - layerGroups: useLayerGroupStore.getState().layerGroups, + async function deleteWithFrozenPlan(): Promise { + // eager 전에 삭제 대상 plugin scope를 stage - staging 전 스토어 변이는 + // debounce 저장이 abort보다 먼저 영속시킬 수 있다 + if (pluginIdsToDelete.length > 0) { + beginMixedGestureTransaction(gestureId, pluginIdsToDelete); + } + // eager: 스토어 뷰에서 mask 계산·적용, 봉인 구조 receipt로 복원 소유 + const storeView = () => ({ + keys: useKeyStore.getState().keyMappings as Record, + keyPositions: useKeyStore.getState().canonicalPositions as Record< + string, + Array<{ id?: string }> + >, + statPositions: useStatItemStore.getState().positions as Record< + string, + Array<{ id?: string }> + >, + graphPositions: useGraphItemStore.getState().positions as Record< + string, + Array<{ id?: string }> + >, + knobPositions: useKnobItemStore.getState().positions as Record< + string, + Array<{ id?: string }> + >, + layerGroups: useLayerGroupStore.getState().layerGroups as Record< + string, + unknown[] + >, }); - - if (normalized.positionsChanged || normalized.groupsChanged) { - useKeyStore.getState().setPositions(normalized.keyPositions); - useStatItemStore.getState().setPositions(normalized.statPositions); - useGraphItemStore.getState().setPositions(normalized.graphPositions); - useKnobItemStore.getState().setPositions(normalized.knobPositions); - if (normalized.groupsChanged) { - useLayerGroupStore.getState().setLayerGroups(normalized.layerGroups); - } + const eagerView = storeView(); + const eagerPlan = collectRemoval(eagerView); + const eagerModes = new Set([selectedKeyType]); + for (const byMode of eagerPlan.removal.values()) { + for (const mode of byMode.keys()) eagerModes.add(mode); } - - if ( - hasEditorDeletion || - normalized.positionsChanged || - normalized.groupsChanged - ) { - const keyState = useKeyStore.getState(); - const editorChanges = { - schemaVersion: 1 as const, - ...(keysToDelete.length > 0 ? { keys: keyState.keyMappings } : {}), - keyPositions: keyState.canonicalPositions, - statPositions: useStatItemStore.getState().positions, - graphPositions: useGraphItemStore.getState().positions, - knobPositions: useKnobItemStore.getState().positions, - layerGroups: useLayerGroupStore.getState().layerGroups, - }; - try { - if (isMixedDeletion) { - await commitMixedGestureTransaction( - gestureId, - editorChanges, - pluginIdsToDelete, - ); - } else { - await editorCoordinator.commitPatch(editorChanges, { gestureId }); + const editorReceipt = hasEditorDeletion + ? applySealedSliceMutation({ + modes: [...eagerModes], + fields: [ + 'keys', + 'keyPositions', + 'statPositions', + 'graphPositions', + 'knobPositions', + 'layerGroups', + ], + mutate: () => { + if (eagerPlan.found === 0) return; + const applied = applyRemoval(eagerView, eagerPlan.removal); + useKeyStore + .getState() + .setKeyMappingsAndPositions( + applied.next.keys as never, + applied.next.keyPositions as never, + ); + useStatItemStore + .getState() + .setPositions(applied.next.statPositions as never); + useGraphItemStore + .getState() + .setPositions(applied.next.graphPositions as never); + useKnobItemStore + .getState() + .setPositions(applied.next.knobPositions as never); + if (applied.groupsChanged) { + useLayerGroupStore + .getState() + .setLayerGroups(applied.layerGroups as never); + } + }, + }) + : null; + let pluginReceipt: ElementIntentReceipt | null = null; + try { + pluginReceipt = applyPluginRemovalEagerly(pluginsToDelete, () => { + if (pluginsToDelete.length > 0) { + deletePluginElements(pluginsToDelete, gestureId); } - } catch (error) { - console.error('Failed to persist selected element deletion', error); - } + }); + } catch (error) { + // plugin eager 실패 시 editor eager 잔존 방지 + editorReceipt?.rollback(); + throw error; } - } finally { - if (isMixedDeletion) { - cancelUncommittedMixedGestureTransaction(gestureId); + const receipt = combineReceipts(editorReceipt, pluginReceipt); + + // wire generator: 슬롯 base에서 동결 대상 재해석 + const generateDeletionPatch = ( + base: EditorDocumentV1, + ): { patch: EditorPatchV1 | null; satisfied: boolean } => { + const baseView = { + keys: base.keys as Record, + keyPositions: base.keyPositions as never, + statPositions: base.statPositions as never, + graphPositions: base.graphPositions as never, + knobPositions: base.knobPositions as never, + layerGroups: base.layerGroups as Record, + }; + if ( + syntheticIndexTargets.length > 0 && + (!syntheticBaseline || + !indexBaselineMatches( + syntheticBaseline, + base as unknown as Record, + )) + ) { + throw new ElementIntentAbort('batch delete baseline mismatch'); + } + const plan = collectRemoval(baseView); + if (plan.found === 0) { + // 전부 이미 삭제됨 - canonical 기실현 + return { patch: null, satisfied: true }; + } + const applied = applyRemoval(baseView, plan.removal); + const patch: EditorPatchV1 = { + schemaVersion: 1, + keys: applied.next.keys as never, + keyPositions: applied.next.keyPositions as never, + statPositions: applied.next.statPositions as never, + graphPositions: applied.next.graphPositions as never, + knobPositions: applied.next.knobPositions as never, + ...(applied.groupsChanged + ? { layerGroups: applied.layerGroups as never } + : {}), + }; + return { patch, satisfied: false }; + }; + + try { + if (pluginsToDelete.length > 0) { + // 혼합: 삭제된 fullId를 뺀 desired projection을 transaction이 저장 + const deletedSet = new Set(pluginsToDelete); + await runMixedGestureElementIntent({ + gestureId, + initialPluginIds: pluginIdsToDelete, + pluginScope: () => pluginIdsToDelete, + receipt, + generate: ({ base, pluginProjection }) => { + const result = generateDeletionPatch(base); + const desired = pluginProjection.filter( + (element) => !deletedSet.has(element.fullId), + ); + if ( + result.satisfied && + desired.length === pluginProjection.length + ) { + return { kind: 'satisfied' }; + } + return { + kind: 'patch', + patch: result.patch, + desiredPluginProjection: desired, + }; + }, + skipContext: 'batch delete settlement', + }); + } else if (hasEditorDeletion) { + await runElementIntent({ + applyEager: () => receipt, + generate: (base) => { + const result = generateDeletionPatch(base); + if (result.satisfied) return { kind: 'satisfied' }; + return result.patch + ? { kind: 'patch', patch: result.patch } + : { kind: 'satisfied' }; + }, + gestureId, + }).then((result) => { + if (!result.committed && !result.satisfied) { + reportElementOpSkipped('batch delete settlement'); + } + }); + } + } catch (error) { + console.error('Failed to persist selected element deletion', error); } } }; @@ -605,64 +930,62 @@ export function useGridSelection({ } }; - // 클립보드에서 붙여넣기 + // 클립보드에서 붙여넣기: 계획(신규 id·payload·그룹·plugin fullId·앵커)을 + // 호출 시점에 동결하고, eager는 결합 봉인 receipt, wire는 슬롯 base와 + // 봉인 plugin projection의 결합 순서에서 재생성한다. 초기 plugin이 없어도 + // 항상 mixed-capable primitive를 탄다 - 슬롯 대기 중 추가된 plugin이 + // z 재부여에서 빠지는 TOCTOU를 막는다 const pasteElements = async () => { - // 최신 클립보드 상태를 직접 스토어에서 가져오기 (클로저 문제 방지) const currentClipboard = useGridSelectionStore.getState().clipboard; if (currentClipboard.length === 0) return; const gestureId = crypto.randomUUID(); const clipboardGroups = useGridSelectionStore.getState().clipboardGroups; - - // 최신 상태를 직접 스토어에서 가져오기 (클로저 문제 방지) const currentLayerGroups = useLayerGroupStore.getState().layerGroups; - - // 현재 선택 상태 캡처 (paste 후 선택이 바뀌기 전에 앵커 계산용) const currentSelectedElements = useGridSelectionStore.getState().selectedElements; const currentSelectedGroupIds = useGridSelectionStore.getState().selectedGroupIds; - // 그룹 복사인 경우: 새 그룹 생성 + groupId 매핑 + // 신규 그룹 동결 (스토어 쓰기는 eager 봉인 안에서) const groupIdMap = new Map(); + const frozenNewGroups: Array<{ + id: string; + name: string; + collapsed: boolean; + }> = []; if (clipboardGroups.length > 0) { const modeGroups = [...(currentLayerGroups[selectedKeyType] || [])]; - for (const cg of clipboardGroups) { + for (const clipboardGroup of clipboardGroups) { const newGroupId = crypto.randomUUID(); - const newGroupName = buildNextLayerGroupName(cg.name, modeGroups); - groupIdMap.set(cg.id, newGroupId); + const newGroupName = buildNextLayerGroupName( + clipboardGroup.name, + modeGroups, + ); + groupIdMap.set(clipboardGroup.id, newGroupId); + const frozen = { + id: newGroupId, + name: newGroupName, + collapsed: Boolean(clipboardGroup.collapsed), + }; + frozenNewGroups.push(frozen); modeGroups.push({ id: newGroupId, name: newGroupName }); } - const updatedLayerGroups = { - ...currentLayerGroups, - [selectedKeyType]: modeGroups, - }; - useLayerGroupStore.getState().setLayerGroups(updatedLayerGroups); - - // 원본 그룹의 collapsed 상태 복원 - for (const cg of clipboardGroups) { - if (cg.collapsed) { - const newGroupId = groupIdMap.get(cg.id); - if (newGroupId) { - useLayerGroupStore.getState().setCollapsed(newGroupId, true); - } - } - } } - - // groupId 리매핑 헬퍼 (삭제된 그룹 참조 방지) - const modeGroups = currentLayerGroups[selectedKeyType] || []; + const callModeGroups = currentLayerGroups[selectedKeyType] || []; const remapGroupId = (groupId: string | undefined) => { if (!groupId) return groupId; if (groupIdMap.has(groupId)) return groupIdMap.get(groupId); - return modeGroups.some((g) => g.id === groupId) ? groupId : undefined; + return callModeGroups.some((group) => group.id === groupId) + ? groupId + : undefined; }; + // 신규 native payload 동결 const keysToAdd: { keyCode: KeySlot; position: KeyPosition }[] = []; const statsToAdd: { position: StatItemPosition }[] = []; const graphsToAdd: { position: GraphItemPosition }[] = []; const knobsToAdd: { position: KnobItemPosition }[] = []; - const pluginsToAdd: Omit[] = []; - + const pluginPayloads: Omit[] = []; for (const item of currentClipboard) { if (item.type === 'key') { keysToAdd.push({ @@ -706,7 +1029,7 @@ export function useGridSelection({ }, }); } else if (item.type === 'plugin') { - pluginsToAdd.push({ + pluginPayloads.push({ ...item.element, groupId: remapGroupId(item.element.groupId), position: { @@ -718,8 +1041,16 @@ export function useGridSelection({ } } + // plugin fullId 사전 동결 - eager 루프 생성은 retry·receipt를 비결정적으로 만든다 + const frozenPluginElements: PluginDisplayElementInternal[] = + pluginPayloads.map((element) => ({ + ...element, + fullId: `${element.pluginId}:${element.id}:${Date.now()}-${Math.random() + .toString(36) + .slice(2, 11)}`, + })); const pluginIdsToAdd = [ - ...new Set(pluginsToAdd.map((element) => element.pluginId)), + ...new Set(frozenPluginElements.map((element) => element.pluginId)), ]; const hasEditorPaste = keysToAdd.length > 0 || @@ -727,263 +1058,525 @@ export function useGridSelection({ graphsToAdd.length > 0 || knobsToAdd.length > 0 || clipboardGroups.length > 0; - const isMixedPaste = hasEditorPaste && pluginIdsToAdd.length > 0; - try { - if (isMixedPaste) { - pluginIdsToAdd.forEach((pluginId) => { - rotatePluginInstancesEditSession(pluginId, gestureId); - }); - beginMixedGestureTransaction(gestureId, pluginIdsToAdd); + if (!hasEditorPaste && frozenPluginElements.length === 0) return; + + // 앵커 동결: 호출 시점 결합 순서에서 삽입 위치의 기존 아이템 id + const callOrderItems = buildLayerItemsForMode( + selectedKeyType, + useKeyStore.getState().canonicalPositions, + useStatItemStore.getState().positions, + useGraphItemStore.getState().positions, + useKnobItemStore.getState().positions, + usePluginDisplayElementStore.getState().elements, + ); + // 앵커 descriptor: 선택 요소와 선택 그룹 최상단 중 더 위를 동결하되, + // 그룹이 이기면 groupId로 동결한다 - 당시 최상단 자식이 삭제돼도 살아 + // 있는 그룹 경계를 재해석할 수 있다 + let anchorElementIdx = Number.POSITIVE_INFINITY; + let anchorElementId: string | null = null; + for (const element of currentSelectedElements) { + const index = callOrderItems.findIndex((item) => item.id === element.id); + if (index !== -1 && index < anchorElementIdx) { + anchorElementIdx = index; + anchorElementId = element.id; } + } + let anchorGroupIdx = Number.POSITIVE_INFINITY; + let anchorGroupId: string | null = null; + for (const groupId of currentSelectedGroupIds) { + const index = callOrderItems.findIndex( + (item) => item.groupId === groupId, + ); + if (index !== -1 && index < anchorGroupIdx) { + anchorGroupIdx = index; + anchorGroupId = groupId; + } + } + // tie는 그룹 우선 - 그룹 클릭은 자식 전체와 groupId를 함께 선택하므로 + // 최상단 자식 index와 그룹 index가 같다 + const frozenAnchor: { elementId?: string; groupId?: string } | null = + anchorGroupIdx <= anchorElementIdx && anchorGroupId + ? { groupId: anchorGroupId } + : anchorElementId + ? { elementId: anchorElementId } + : null; + + // z 재부여는 mode 내 모든 plugin에 닿는다 - scope는 신규 ∪ mode 전체 + const pluginScope = ( + elements: readonly PluginDisplayElementInternal[], + ): string[] => [ + ...new Set([ + ...pluginIdsToAdd, + ...elements + .filter((element) => element.tabId === selectedKeyType) + .map((element) => element.pluginId) + .filter((pluginId): pluginId is string => Boolean(pluginId)), + ]), + ]; - // 새로 추가된 요소들의 선택을 위한 인덱스 추적 - const newSelectedElements: SelectedElement[] = []; - - // 붙여넣은 키 매핑 — 저장은 zIndex 확정 후 마지막에 1회만 (중간 저장은 순서 역전·패딩 위험) - let pastedKeyMappings: KeyMappings | null = null; - - // 키 추가 - if (keysToAdd.length > 0) { - const km = useKeyStore.getState().keyMappings; - const pos = useKeyStore.getState().canonicalPositions; - const mapping = [...(km[selectedKeyType] || [])]; - const posArray = [...(pos[selectedKeyType] || [])]; + interface PasteDocView { + keys: Record; + keyPositions: Record; + statPositions: Record; + graphPositions: Record; + knobPositions: Record; + layerGroups: Record>; + } - const startIndex = mapping.length; + const payloadFingerprint = (value: unknown): string => + stableStringify({ + ...(value as Record), + zIndex: undefined, + }); - for (let i = 0; i < keysToAdd.length; i++) { - mapping.push(keysToAdd[i].keyCode); - posArray.push(keysToAdd[i].position); - newSelectedElements.push({ - type: 'key', - id: selectionElementId( - 'key', - keysToAdd[i].position, - startIndex + i, - ), - index: startIndex + i, - }); + // 문서 뷰(base 또는 스토어)에서 동결 계획을 재적용해 결과를 산출. + // 충돌(같은 id에 다른 payload)은 전체 중단, 동일 payload는 skip(멱등) + const computePaste = ( + view: PasteDocView, + projection: readonly PluginDisplayElementInternal[], + ): { + appended: boolean; + keys: Record; + zPatch: ReturnType; + layerGroups: Record>; + groupsChanged: boolean; + desiredProjection: PluginDisplayElementInternal[]; + } => { + const mode = selectedKeyType; + let appended = false; + + const findNativeById = ( + id: string, + ): { field: keyof PasteDocView; mode: string; index: number } | null => { + const fields = [ + 'keyPositions', + 'statPositions', + 'graphPositions', + 'knobPositions', + ] as const; + for (const field of fields) { + for (const [ownMode, list] of Object.entries(view[field])) { + const index = (list as Array<{ id?: string }>).findIndex( + (position) => position.id === id, + ); + if (index !== -1) return { field, mode: ownMode, index }; + } } + return null; + }; - const updatedMappings = { ...km, [selectedKeyType]: mapping }; - const updatedPositions = { ...pos, [selectedKeyType]: posArray }; - - useKeyStore - .getState() - .setKeyMappingsAndPositions(updatedMappings, updatedPositions); - - pastedKeyMappings = updatedMappings; - } - - // 통계 요소 추가 - if (statsToAdd.length > 0) { - const current = useStatItemStore.getState().positions; - const posArray = [...(current[selectedKeyType] || [])]; - const startIndex = posArray.length; - - for (let i = 0; i < statsToAdd.length; i++) { - posArray.push(statsToAdd[i].position); - newSelectedElements.push({ - type: 'stat', - id: selectionElementId( - 'stat', - statsToAdd[i].position, - startIndex + i, - ), - index: startIndex + i, - }); + const nextKeys = { ...view.keys }; + const nextKeyPositions = { ...view.keyPositions }; + const nextStatPositions = { ...view.statPositions }; + const nextGraphPositions = { ...view.graphPositions }; + const nextKnobPositions = { ...view.knobPositions }; + + const appendedNativeIds = new Set(); + for (const entry of keysToAdd) { + const existing = findNativeById(entry.position.id!); + if (existing) { + const position = ( + view[existing.field][existing.mode] as Array< + Record + > + )[existing.index]; + const pairedSlot = view.keys[existing.mode]?.[existing.index]; + if ( + existing.field !== 'keyPositions' || + payloadFingerprint(position) !== + payloadFingerprint(entry.position) || + stableStringify(pairedSlot) !== stableStringify(entry.keyCode) + ) { + throw new ElementIntentAbort('paste id collision'); + } + continue; } - - const updatedPositions: StatItemPositions = { - ...current, - [selectedKeyType]: posArray, - }; - useStatItemStore.getState().setPositions(updatedPositions); + nextKeys[mode] = [...(nextKeys[mode] ?? []), entry.keyCode]; + nextKeyPositions[mode] = [ + ...(nextKeyPositions[mode] ?? []), + entry.position, + ]; + appendedNativeIds.add(entry.position.id!); + appended = true; } - - // 그래프 요소 추가 - if (graphsToAdd.length > 0) { - const current = useGraphItemStore.getState().positions; - const posArray = [...(current[selectedKeyType] || [])]; - const startIndex = posArray.length; - - for (let i = 0; i < graphsToAdd.length; i++) { - posArray.push(graphsToAdd[i].position); - newSelectedElements.push({ - type: 'graph', - id: selectionElementId( - 'graph', - graphsToAdd[i].position, - startIndex + i, - ), - index: startIndex + i, - }); + const appendSimple = ( + record: Record, + entries: Array<{ position: T }>, + field: keyof PasteDocView, + ): Record => { + let next = record; + for (const entry of entries) { + const existing = findNativeById(entry.position.id!); + if (existing) { + const position = ( + view[existing.field][existing.mode] as Array< + Record + > + )[existing.index]; + if ( + existing.field !== field || + payloadFingerprint(position) !== + payloadFingerprint(entry.position) + ) { + throw new ElementIntentAbort('paste id collision'); + } + continue; + } + next = { ...next, [mode]: [...(next[mode] ?? []), entry.position] }; + appendedNativeIds.add(entry.position.id!); + appended = true; } + return next; + }; + const statNext = appendSimple( + nextStatPositions, + statsToAdd, + 'statPositions', + ); + const graphNext = appendSimple( + nextGraphPositions, + graphsToAdd, + 'graphPositions', + ); + const knobNext = appendSimple( + nextKnobPositions, + knobsToAdd, + 'knobPositions', + ); - const updatedPositions: GraphItemPositions = { - ...current, - [selectedKeyType]: posArray, - }; - useGraphItemStore.getState().setPositions(updatedPositions); - } - - // 노브 요소 추가 (zIndex 레이어 재배치 대상 외 — 별도 영속/동기화) - if (knobsToAdd.length > 0) { - const current = useKnobItemStore.getState().positions; - const posArray = [...(current[selectedKeyType] || [])]; - const startIndex = posArray.length; - - for (let i = 0; i < knobsToAdd.length; i++) { - posArray.push(knobsToAdd[i].position); - newSelectedElements.push({ - type: 'knob', - id: selectionElementId( - 'knob', - knobsToAdd[i].position, - startIndex + i, - ), - index: startIndex + i, - }); + // 신규 그룹 append (id 기준 멱등) + let layerGroups = view.layerGroups; + let groupsChanged = false; + if (frozenNewGroups.length > 0) { + const modeGroups = [...(layerGroups[mode] ?? [])]; + for (const group of frozenNewGroups) { + if (modeGroups.some((existing) => existing.id === group.id)) continue; + modeGroups.push({ id: group.id, name: group.name }); + groupsChanged = true; + } + if (groupsChanged) { + layerGroups = { ...layerGroups, [mode]: modeGroups }; } - - const updatedPositions: KnobItemPositions = { - ...current, - [selectedKeyType]: posArray, - }; - useKnobItemStore.getState().setPositions(updatedPositions); } - // 플러그인 요소 추가 - if (pluginsToAdd.length > 0) { - new Set(pluginsToAdd.map((element) => element.pluginId)).forEach( - (pluginId) => { - rotatePluginInstancesEditSession(pluginId, gestureId); - }, + // plugin append (fullId 멱등·충돌 검사) + const appendedPlugins: PluginDisplayElementInternal[] = []; + for (const element of frozenPluginElements) { + const existing = projection.find( + (candidate) => candidate.fullId === element.fullId, ); - const currentElements = - usePluginDisplayElementStore.getState().elements; - const newElements = [...currentElements]; - - for (const elementData of pluginsToAdd) { - // 새로운 고유 ID 생성 - const newFullId = `${elementData.pluginId}:${ - elementData.id - }:${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - const newElement = { - ...elementData, - fullId: newFullId, - }; - newElements.push(newElement); - newSelectedElements.push({ - type: 'plugin', - id: newFullId, - }); + if (existing) { + if (payloadFingerprint(existing) !== payloadFingerprint(element)) { + throw new ElementIntentAbort('paste plugin fullId collision'); + } + continue; } - - usePluginDisplayElementStore.getState().setElements(newElements); + appendedPlugins.push(element); + appended = true; } + const combinedProjection = [...projection, ...appendedPlugins]; - // === Phase 2: paste 위치 결정 + zIndex 재계산 === - const freshKeyPos = useKeyStore.getState().canonicalPositions; - const freshStatPos = useStatItemStore.getState().positions; - const freshGraphPos = useGraphItemStore.getState().positions; - const freshKnobPos = useKnobItemStore.getState().positions; - const freshPluginEls = usePluginDisplayElementStore.getState().elements; - - // 전체 레이어 목록 구성 (새로 push된 아이템 포함) + // 결합 순서 재구성 + 동결 앵커 재해석 (소실 시 최상단 fallback) const allItems = buildLayerItemsForMode( - selectedKeyType, - freshKeyPos, - freshStatPos, - freshGraphPos, - freshKnobPos, - freshPluginEls, + mode, + nextKeyPositions as never, + statNext as never, + graphNext as never, + knobNext as never, + combinedProjection, ); - - // 새 아이템과 기존 아이템 분리 - const newIds = new Set(newSelectedElements.map((el) => el.id)); - const existing = allItems.filter((item) => !newIds.has(item.id)); - const pasted = allItems.filter((item) => newIds.has(item.id)); - - // 앵커 위치 계산 (paste 전 선택 기준) - const anchor = findPasteAnchorIndex( - existing, - currentSelectedElements, - currentSelectedGroupIds, + const newIdSet = new Set([ + ...appendedNativeIds, + ...appendedPlugins.map((element) => element.fullId), + ]); + const existingItems = allItems.filter((item) => !newIdSet.has(item.id)); + const pastedById = new Map( + allItems + .filter((item) => newIdSet.has(item.id)) + .map((item) => [item.id, item]), ); - - // 새 아이템을 앵커 위치에 삽입 + // 붙여넣기 블록 내부 순서는 동결 payload 순서 유지 + const pastedOrdered = [ + ...keysToAdd.map((entry) => entry.position.id!), + ...statsToAdd.map((entry) => entry.position.id!), + ...graphsToAdd.map((entry) => entry.position.id!), + ...knobsToAdd.map((entry) => entry.position.id!), + ...frozenPluginElements.map((element) => element.fullId), + ] + .map((id) => pastedById.get(id)) + .filter((item): item is NonNullable => Boolean(item)); + let anchorIndex = 0; + if (frozenAnchor?.groupId) { + const index = existingItems.findIndex( + (item) => item.groupId === frozenAnchor.groupId, + ); + anchorIndex = index !== -1 ? index : 0; + } else if (frozenAnchor?.elementId) { + const index = existingItems.findIndex( + (item) => item.id === frozenAnchor.elementId, + ); + anchorIndex = index !== -1 ? index : 0; + } const reordered = [ - ...existing.slice(0, anchor), - ...pasted, - ...existing.slice(anchor), + ...existingItems.slice(0, anchorIndex), + ...pastedOrdered, + ...existingItems.slice(anchorIndex), ]; - - // zIndex 일괄 재부여 - const patch = applyZIndexToLayerOrder( + const zPatch = applyZIndexToLayerOrder( reordered, - selectedKeyType, - freshKeyPos, - freshStatPos, - freshGraphPos, - freshKnobPos, + mode, + nextKeyPositions as never, + statNext as never, + graphNext as never, + knobNext as never, + ); + const zByFullId = new Map( + zPatch.pluginUpdates.map((update) => [update.fullId, update.zIndex]), ); + const desiredProjection = combinedProjection.map((element) => { + const zIndex = zByFullId.get(element.fullId); + return zIndex === undefined ? element : { ...element, zIndex }; + }); - // 스토어 업데이트 (동기 — 배칭으로 한 번에 렌더) - useKeyStore.getState().setPositions(patch.keyPositions); - useStatItemStore.getState().setPositions(patch.statPositions); - useGraphItemStore.getState().setPositions(patch.graphPositions); - useKnobItemStore.getState().setPositions(patch.knobPositions); - for (const { fullId, zIndex } of patch.pluginUpdates) { - usePluginDisplayElementStore - .getState() - .updateElement(fullId, { zIndex }, { skipSync: true }); + return { + appended, + keys: nextKeys, + zPatch, + layerGroups, + groupsChanged, + desiredProjection, + }; + }; + + try { + await pasteWithFrozenPlan(); + } finally { + cancelUncommittedMixedGestureTransaction(gestureId); + } + + async function pasteWithFrozenPlan(): Promise { + // eager 전에 초기 scope를 stage - staging 전 스토어 변이는 200ms + // debounce 저장이 abort보다 먼저 영속시킬 수 있다 + const initialScope = pluginScope( + usePluginDisplayElementStore.getState().elements, + ); + if (initialScope.length > 0) { + beginMixedGestureTransaction(gestureId, initialScope); } + // 기존 plugin의 zIndex eager도 이 게스처 세션으로 - 별도 세션이 생기면 + // 편입 후 실패의 canonical pull이 외부 충돌로 오판해 건너뛴다 + initialScope.forEach((pluginId) => { + rotatePluginInstancesEditSession(pluginId, gestureId); + }); - // 선택 업데이트도 동기 구간에서 처리 (await 전에 실행해야 깜빡임 방지) - if (newSelectedElements.length > 0) { - useGridSelectionStore.getState().setSkipPanelModeSwitch(true); - if (groupIdMap.size > 0) { - const newGroupIds = Array.from(groupIdMap.values()); - useGridSelectionStore + // eager 계획을 쓰기 전에 확정 (충돌 abort가 쓰기 전에 발생) + const eagerElementsBefore = + usePluginDisplayElementStore.getState().elements; + const eagerPlan = computePaste( + { + keys: useKeyStore.getState().keyMappings as never, + keyPositions: useKeyStore.getState().canonicalPositions as never, + statPositions: useStatItemStore.getState().positions as never, + graphPositions: useGraphItemStore.getState().positions as never, + knobPositions: useKnobItemStore.getState().positions as never, + layerGroups: useLayerGroupStore.getState().layerGroups as never, + }, + eagerElementsBefore, + ); + // editor와 plugin은 독립 소유권 - 결합 봉인은 무관한 plugin 변경 + // 하나로 editor 복원까지 거부한다 + const editorReceipt = applySealedSliceMutation({ + modes: [selectedKeyType], + fields: [ + 'keys', + 'keyPositions', + 'statPositions', + 'graphPositions', + 'knobPositions', + 'layerGroups', + ], + mutate: () => { + useKeyStore .getState() - .setFullSelection(newSelectedElements, newGroupIds); - } else { - setSelectedElements(newSelectedElements); + .setKeyMappingsAndPositions( + eagerPlan.keys as never, + eagerPlan.zPatch.keyPositions as never, + ); + useStatItemStore + .getState() + .setPositions(eagerPlan.zPatch.statPositions as never); + useGraphItemStore + .getState() + .setPositions(eagerPlan.zPatch.graphPositions as never); + useKnobItemStore + .getState() + .setPositions(eagerPlan.zPatch.knobPositions as never); + if (eagerPlan.groupsChanged) { + useLayerGroupStore + .getState() + .setLayerGroups(eagerPlan.layerGroups as never); + } + }, + }); + // plugin semantic receipt: 신규 fullId membership + 기존 zIndex CAS + const beforeZByFullId = new Map( + eagerElementsBefore.map((element) => [element.fullId, element.zIndex]), + ); + // 멱등 skip된 동결 id를 receipt가 소유하면 실패 rollback이 기존 + // 요소를 삭제한다 - eager 전에 없던 id만 membership 대상 + const eagerBeforeIds = new Set( + eagerElementsBefore.map((element) => element.fullId), + ); + const addedFullIds = frozenPluginElements + .map((element) => element.fullId) + .filter((fullId) => !eagerBeforeIds.has(fullId)); + const addedSet = new Set(addedFullIds); + const zChanges = eagerPlan.desiredProjection + .filter((element) => !addedSet.has(element.fullId)) + .filter( + (element) => beforeZByFullId.get(element.fullId) !== element.zIndex, + ) + .map((element) => ({ + fullId: element.fullId, + before: beforeZByFullId.get(element.fullId), + expected: element.zIndex as number, + })); + let pluginReceipt: ElementIntentReceipt | null = null; + try { + pluginReceipt = applyPluginAdditionEagerly( + addedFullIds, + zChanges, + () => { + usePluginDisplayElementStore + .getState() + .setElements(eagerPlan.desiredProjection, { skipSync: true }); + }, + ); + } catch (error) { + // plugin eager 실패 시 editor eager 잔존 방지 + editorReceipt.rollback(); + throw error; + } + const receipt = combineReceipts(editorReceipt, pluginReceipt); + + // 접힘 상태는 문서 밖 UI - 즉시 적용, 실패 미복원(기록된 정책) + for (const group of frozenNewGroups) { + if (group.collapsed) { + useLayerGroupStore.getState().setCollapsed(group.id, true); } } - // 붙여넣기 전체를 한 revision으로 저장 - const editorChanges = { - schemaVersion: 1 as const, - ...(pastedKeyMappings ? { keys: pastedKeyMappings } : {}), - keyPositions: patch.keyPositions, - statPositions: patch.statPositions, - graphPositions: patch.graphPositions, - knobPositions: patch.knobPositions, - layerGroups: useLayerGroupStore.getState().layerGroups, - }; + let result: { committed: boolean; satisfied: boolean }; try { - if (isMixedPaste) { - await commitMixedGestureTransaction( - gestureId, - editorChanges, - pluginIdsToAdd, - ); - } else { - await editorCoordinator.commitPatch(editorChanges, { gestureId }); - } + result = await runMixedGestureElementIntent({ + gestureId, + initialPluginIds: pluginScope( + usePluginDisplayElementStore.getState().elements, + ), + pluginScope, + receipt, + generate: ({ base, pluginProjection }) => { + const plan = computePaste( + { + keys: base.keys as never, + keyPositions: base.keyPositions as never, + statPositions: base.statPositions as never, + graphPositions: base.graphPositions as never, + knobPositions: base.knobPositions as never, + layerGroups: base.layerGroups as never, + }, + pluginProjection, + ); + if (!plan.appended) { + // 전부 이미 반영됨(재시도 멱등) - z 재부여도 이전 성공분 + return { kind: 'satisfied' }; + } + return { + kind: 'patch', + patch: { + schemaVersion: 1, + keys: plan.keys as never, + keyPositions: plan.zPatch.keyPositions as never, + statPositions: plan.zPatch.statPositions as never, + graphPositions: plan.zPatch.graphPositions as never, + knobPositions: plan.zPatch.knobPositions as never, + ...(plan.groupsChanged + ? { layerGroups: plan.layerGroups as never } + : {}), + }, + desiredPluginProjection: plan.desiredProjection, + }; + }, + skipContext: 'paste settlement', + }); } catch (error) { + // 편입 후 실패의 상태 정합은 projection·canonical pull이 소유 - + // 호출부 경계에서는 기록만 (삭제 경로와 대칭) console.error('Failed to persist pasted elements', error); + result = { committed: false, satisfied: false }; + } + + if (result.committed || result.satisfied) { + // 선택 이동은 성공 후 - eager 유지 대신 단순화(수렴 결정) + const newSelectedElements: SelectedElement[] = []; + const collect = ( + type: 'key' | 'stat' | 'graph' | 'knob', + record: Record>, + ids: readonly string[], + ) => { + const list = record[selectedKeyType] ?? []; + for (const id of ids) { + const index = list.findIndex((position) => position.id === id); + if (index !== -1) { + newSelectedElements.push({ type, id, index }); + } + } + }; + collect( + 'key', + useKeyStore.getState().canonicalPositions as never, + keysToAdd.map((entry) => entry.position.id!), + ); + collect( + 'stat', + useStatItemStore.getState().positions as never, + statsToAdd.map((entry) => entry.position.id!), + ); + collect( + 'graph', + useGraphItemStore.getState().positions as never, + graphsToAdd.map((entry) => entry.position.id!), + ); + collect( + 'knob', + useKnobItemStore.getState().positions as never, + knobsToAdd.map((entry) => entry.position.id!), + ); + const presentPluginIds = new Set( + usePluginDisplayElementStore + .getState() + .elements.map((element) => element.fullId), + ); + for (const element of frozenPluginElements) { + if (presentPluginIds.has(element.fullId)) { + newSelectedElements.push({ type: 'plugin', id: element.fullId }); + } + } + if (newSelectedElements.length > 0) { + useGridSelectionStore.getState().setSkipPanelModeSwitch(true); + if (groupIdMap.size > 0) { + useGridSelectionStore + .getState() + .setFullSelection(newSelectedElements, [...groupIdMap.values()]); + } else { + setSelectedElements(newSelectedElements); + } + } } - const pluginEls = usePluginDisplayElementStore.getState().elements; + sendBridgeMessageBestEffort('overlay', 'plugin:displayElements:sync', { - elements: pluginEls, + elements: usePluginDisplayElementStore.getState().elements, }); - } finally { - if (isMixedPaste) { - cancelUncommittedMixedGestureTransaction(gestureId); - } } }; From c00b0b42967506c7a659d25242a0b36ddcd9bcb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 12 Aug 2026 23:41:55 +0900 Subject: [PATCH 32/35] =?UTF-8?q?fix:=20=EC=9E=AC=EC=8B=9C=EB=8F=84?= =?UTF-8?q?=EC=99=80=20=EC=B6=A9=EB=8F=8C=20=EC=A0=95=EC=82=B0=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EA=B1=B0=EC=A0=88=20=ED=94=84=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=EC=99=80=20=EA=B2=8C=EC=8A=A4=EC=B2=98=20ID=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 --- .../editor/runtime/editorCoordinator.test.ts | 266 ++++++++++++++++++ .../editor/runtime/editorCoordinator.ts | 74 ++++- 2 files changed, 328 insertions(+), 12 deletions(-) diff --git a/src/renderer/editor/runtime/editorCoordinator.test.ts b/src/renderer/editor/runtime/editorCoordinator.test.ts index ba495f3e..4c28005a 100644 --- a/src/renderer/editor/runtime/editorCoordinator.test.ts +++ b/src/renderer/editor/runtime/editorCoordinator.test.ts @@ -1012,6 +1012,196 @@ describe('EditorSaveCoordinator', () => { harness.coordinator.stop(); }); + it('editor 전용 gesture의 IO 실패는 최신 문서에서 자동 재시도한다', async () => { + const base = makeDocument('A'); + const target = makeDocument('B'); + target.keyPositions = structuredClone(base.keyPositions); + const harness = createHarness(base); + const transientError = ioError(); + harness.transport.commitMock + .mockRejectedValueOnce(transientError) + .mockResolvedValueOnce({ revision: 1, changedFields: ['keys'] }); + await harness.coordinator.start(); + + await expect( + harness.coordinator.commitGesture( + { schemaVersion: 1, keys: target.keys }, + 'gesture-native-only', + (context) => + harness.transport.commit({ + baseRevision: context.editorBaseRevision, + mutationId: context.mutationId, + changes: context.editorChanges!, + }), + { reconcileRetryableEditorIntent: () => true }, + ), + ).resolves.toEqual(target); + + expect(harness.getLocal()).toEqual(target); + expect(harness.coordinator.getState()).toMatchObject({ + dirty: false, + failureKind: null, + }); + expect(harness.transport.commitMock).toHaveBeenCalledTimes(2); + harness.coordinator.stop(); + }); + + it('혼합 gesture의 재시도 가능 실패는 editor만 따로 재시도하지 않는다', async () => { + const base = makeDocument('A'); + const target = makeDocument('B'); + target.keyPositions = structuredClone(base.keyPositions); + const harness = createHarness(base); + const transientError = ioError(); + await harness.coordinator.start(); + + await expect( + harness.coordinator.commitGesture( + { schemaVersion: 1, keys: target.keys }, + 'gesture-mixed', + () => Promise.reject(transientError), + { reconcileRetryableEditorIntent: () => false }, + ), + ).rejects.toBe(transientError); + + expect(harness.getLocal()).toEqual(base); + expect(harness.coordinator.getState()).toMatchObject({ + dirty: false, + pendingLocal: null, + failureKind: 'transient', + }); + await expect(harness.coordinator.retryPending()).resolves.toEqual(base); + harness.coordinator.stop(); + }); + + it('editor 전용 gesture가 다른 필드의 외부 변경과 안전하게 합쳐진다', async () => { + const base = makeDocument('A'); + const target = makeDocument('B'); + target.keyPositions = structuredClone(base.keyPositions); + const remote = withGroups(base, 'remote'); + const expected = withGroups(target, 'remote'); + const harness = createHarness(base); + harness.transport.commitMock.mockResolvedValueOnce({ + revision: 2, + changedFields: ['keys'], + }); + await harness.coordinator.start(); + harness.transport.canonical = { revision: 1, document: remote }; + + await expect( + harness.coordinator.commitGesture( + { schemaVersion: 1, keys: target.keys }, + 'gesture-unrelated-rebase', + () => Promise.reject(revisionConflict()), + { reconcileRetryableEditorIntent: () => true }, + ), + ).resolves.toEqual(expected); + + expect(harness.getLocal()).toEqual(expected); + expect(harness.transport.commitMock).toHaveBeenCalledWith( + expect.objectContaining({ + baseRevision: 1, + gestureId: 'gesture-unrelated-rebase', + gestureIds: ['gesture-unrelated-rebase'], + }), + ); + harness.coordinator.stop(); + }); + + it('editor 전용 gesture와 같은 필드의 외부 변경은 충돌로 전환한다', async () => { + const base = makeDocument('A'); + const target = makeDocument('B'); + target.keyPositions = structuredClone(base.keyPositions); + const remote = makeDocument('C'); + remote.keyPositions = structuredClone(base.keyPositions); + const harness = createHarness(base); + await harness.coordinator.start(); + harness.transport.canonical = { revision: 1, document: remote }; + + await expect( + harness.coordinator.commitGesture( + { schemaVersion: 1, keys: target.keys }, + 'gesture-overlap', + () => Promise.reject(revisionConflict()), + { reconcileRetryableEditorIntent: () => true }, + ), + ).rejects.toMatchObject({ errorCode: 'REVISION_CONFLICT' }); + + expect(harness.coordinator.getState()).toMatchObject({ + phase: 'conflict', + failureKind: null, + conflict: { + pendingLocal: target, + canonical: remote, + localFields: ['keys'], + overlappingFields: ['keys'], + }, + }); + expect(harness.transport.commitMock).not.toHaveBeenCalled(); + harness.coordinator.stop(); + }); + + it('editor 전용 gesture 충돌에서 내 편집을 유지하면 같은 gesture ID로 저장한다', async () => { + const base = makeDocument('A'); + const target = makeDocument('B'); + target.keyPositions = structuredClone(base.keyPositions); + const remote = makeDocument('C'); + remote.keyPositions = structuredClone(base.keyPositions); + const harness = createHarness(base); + harness.transport.commitMock.mockResolvedValueOnce({ + revision: 2, + changedFields: ['keys'], + }); + await harness.coordinator.start(); + harness.transport.canonical = { revision: 1, document: remote }; + + await expect( + harness.coordinator.commitGesture( + { schemaVersion: 1, keys: target.keys }, + 'gesture-keep-local', + () => Promise.reject(revisionConflict()), + { reconcileRetryableEditorIntent: () => true }, + ), + ).rejects.toMatchObject({ errorCode: 'REVISION_CONFLICT' }); + + await harness.coordinator.resolveConflict('keepLocal'); + expect(harness.transport.commitMock).toHaveBeenCalledWith( + expect.objectContaining({ + baseRevision: 1, + gestureId: 'gesture-keep-local', + gestureIds: ['gesture-keep-local'], + }), + ); + harness.coordinator.stop(); + }); + + it('IO 응답 유실 뒤 같은 필드가 더 바뀌면 옛 목표로 덮지 않는다', async () => { + const base = makeDocument('A'); + const target = makeDocument('B'); + target.keyPositions = structuredClone(base.keyPositions); + const remote = makeDocument('C'); + remote.keyPositions = structuredClone(base.keyPositions); + const harness = createHarness(base); + await harness.coordinator.start(); + harness.transport.canonical = { revision: 2, document: remote }; + + await expect( + harness.coordinator.commitGesture( + { schemaVersion: 1, keys: target.keys }, + 'gesture-io-overlap', + () => Promise.reject(ioError()), + { reconcileRetryableEditorIntent: () => true }, + ), + ).rejects.toBeDefined(); + + expect(harness.transport.commitMock).not.toHaveBeenCalled(); + expect(harness.coordinator.getState()).toMatchObject({ + phase: 'conflict', + failureKind: null, + }); + expect(harness.coordinator.getState().conflict?.canonical).toEqual(remote); + harness.coordinator.stop(); + }); + it('gesture 실패가 이후 진행 중인 낙관 편집을 되돌리지 않는다', async () => { const base = makeDocument('A'); const gestureTarget = makeDocument('B'); @@ -1576,6 +1766,34 @@ describe('EditorSaveCoordinator', () => { harness.coordinator.stop(); }); + it('외부 변경 수용은 충돌한 gesture preview를 폐기한다', async () => { + const base = makeDocument('A'); + const local = makeDocument('B'); + local.keyPositions = structuredClone(base.keyPositions); + const remote = makeDocument('C'); + remote.keyPositions = structuredClone(base.keyPositions); + const onGestureIdsDiscarded = + vi.fn<(gestureIds: readonly string[]) => void>(); + const harness = createHarness(base, { onGestureIdsDiscarded }); + harness.transport.commitMock.mockRejectedValueOnce(revisionConflict()); + await harness.coordinator.start(); + harness.transport.canonical = { revision: 1, document: remote }; + + await expect( + harness.coordinator.commitPatch( + { schemaVersion: 1, keys: local.keys }, + { gestureId: 'gesture-accept-external' }, + ), + ).rejects.toMatchObject({ errorCode: 'REVISION_CONFLICT' }); + + await harness.coordinator.resolveConflict('acceptCanonical'); + expect(harness.getLocal()).toEqual(remote); + expect(onGestureIdsDiscarded).toHaveBeenCalledWith([ + 'gesture-accept-external', + ]); + harness.coordinator.stop(); + }); + it('can keep the local side of an overlap and recommit it on the canonical revision', async () => { const base = makeDocument(); const local = { ...base, keys: { '4key': ['L'] } }; @@ -1745,6 +1963,54 @@ describe('EditorSaveCoordinator', () => { }, ); + it('영구 거절은 in-flight gesture preview도 함께 폐기한다', async () => { + const base = makeDocument(); + const target = { ...base, keys: { '4key': ['REJECTED'] } }; + const error = validationError(); + const onGestureIdsDiscarded = + vi.fn<(gestureIds: readonly string[]) => void>(); + const harness = createHarness(base, { onGestureIdsDiscarded }); + harness.transport.commitMock.mockRejectedValueOnce(error); + await harness.coordinator.start(); + + await expect( + harness.coordinator.commitPatch( + { schemaVersion: 1, keys: target.keys }, + { gestureId: 'gesture-rejected-preview' }, + ), + ).rejects.toBe(error); + + expect(harness.getLocal()).toEqual(base); + expect(onGestureIdsDiscarded).toHaveBeenCalledOnce(); + expect(onGestureIdsDiscarded).toHaveBeenCalledWith([ + 'gesture-rejected-preview', + ]); + harness.coordinator.stop(); + }); + + it('혼합 gesture의 영구 거절도 editor preview를 폐기한다', async () => { + const base = makeDocument(); + const error = validationError(); + const onGestureIdsDiscarded = + vi.fn<(gestureIds: readonly string[]) => void>(); + const harness = createHarness(base, { onGestureIdsDiscarded }); + await harness.coordinator.start(); + + await expect( + harness.coordinator.commitGesture( + { schemaVersion: 1, keys: { '4key': ['REJECTED'] } }, + 'gesture-mixed-rejected', + () => Promise.reject(error), + ), + ).rejects.toBe(error); + + expect(harness.getLocal()).toEqual(base); + expect(onGestureIdsDiscarded).toHaveBeenCalledWith([ + 'gesture-mixed-rejected', + ]); + harness.coordinator.stop(); + }); + it('uses commitPatch as an optimistic compatibility adapter and skips full-state no-ops', async () => { const base = makeDocument(); const target = withGroups(base, 'group-1'); diff --git a/src/renderer/editor/runtime/editorCoordinator.ts b/src/renderer/editor/runtime/editorCoordinator.ts index 6b844eff..0480ecb3 100644 --- a/src/renderer/editor/runtime/editorCoordinator.ts +++ b/src/renderer/editor/runtime/editorCoordinator.ts @@ -639,7 +639,11 @@ export class EditorSaveCoordinator { commit: ( context: EditorGestureCommitContext, ) => Promise, - meta?: { onEnrolled?: () => void; prepare?: () => Promise }, + meta?: { + onEnrolled?: () => void; + prepare?: () => Promise; + reconcileRetryableEditorIntent?: () => boolean; + }, ): Promise { this.assertWritable(); const previous = this.gestureCommitTail; @@ -699,10 +703,14 @@ export class EditorSaveCoordinator { this.lastAck = clone(conflict.canonical); if (resolution === 'acceptCanonical') { + const discardedGestureIds = [...this.pendingGestureIds]; this.pendingLocal = null; this.pendingFields = []; this.pendingRequestFields = []; this.pendingGestureIds = []; + if (discardedGestureIds.length > 0) { + this.onGestureIdsDiscarded?.(discardedGestureIds); + } this.phase = 'idle'; this.applyDocument(clone(conflict.canonical), 'acceptCanonical'); this.notify(); @@ -900,6 +908,7 @@ export class EditorSaveCoordinator { isEditorCommitError(error) && error.errorCode === 'REVISION_CONFLICT' ) { + this.restorePendingGestureIds(inFlight.gestureIds); let didRebase: boolean; try { didRebase = await this.handleRevisionConflict( @@ -913,7 +922,6 @@ export class EditorSaveCoordinator { inFlight.localFields, inFlight.requestFields, ); - this.restorePendingGestureIds(inFlight.gestureIds); this.phase = 'error'; this.error = syncError; this.failureKind = 'transient'; @@ -922,7 +930,6 @@ export class EditorSaveCoordinator { } if (didRebase) { rebaseAttempts += 1; - this.restorePendingGestureIds(inFlight.gestureIds); continue; } } else if (isEditorCommitError(error) && error.retryable) { @@ -937,7 +944,7 @@ export class EditorSaveCoordinator { this.failureKind = 'transient'; this.notify(); } else { - this.discardRejectedPending(error, mutationId); + this.discardRejectedPending(error, mutationId, inFlight.gestureIds); } throw error; } finally { @@ -952,7 +959,11 @@ export class EditorSaveCoordinator { commit: ( context: EditorGestureCommitContext, ) => Promise, - meta?: { onEnrolled?: () => void; prepare?: () => Promise }, + meta?: { + onEnrolled?: () => void; + prepare?: () => Promise; + reconcileRetryableEditorIntent?: () => boolean; + }, ): Promise { await this.start(); await this.drainUntilSettled(); @@ -1050,7 +1061,36 @@ export class EditorSaveCoordinator { return clone(this.requireLastAck()); } catch (error) { this.ownMutations.delete(mutationId); - if (isEditorCommitError(error) && error.retryable) { + const retryable = isEditorCommitError(error) && error.retryable; + const reconcileEditorIntent = + retryable && meta?.reconcileRetryableEditorIntent?.() === true; + if (reconcileEditorIntent) { + this.restorePendingGestureIds(inFlight.gestureIds); + let didRebase: boolean; + try { + didRebase = await this.handleRevisionConflict(inFlight, error, 0); + } catch (syncError) { + this.preservePending( + inFlight.target, + inFlight.localFields, + inFlight.requestFields, + ); + this.phase = 'error'; + this.error = syncError; + this.failureKind = 'transient'; + this.notify(); + throw syncError; + } + if (!didRebase) throw error; + + await this.drainUntilSettled(); + this.error = null; + this.failureKind = null; + this.phase = 'idle'; + this.notify(); + return clone(this.requireLastAck()); + } + if (retryable) { try { const canonical = await this.transport.get(); assertEditorGetResult(canonical); @@ -1062,13 +1102,13 @@ export class EditorSaveCoordinator { // 원래 transaction 오류를 유지 } } + if (!retryable && inFlight.gestureIds.length > 0) { + this.onGestureIdsDiscarded?.(inFlight.gestureIds); + } + this.applyRejectedGestureProjection(inFlight); this.error = error; - this.failureKind = - isEditorCommitError(error) && error.retryable - ? 'transient' - : 'permanent'; + this.failureKind = retryable ? 'transient' : 'permanent'; this.phase = 'error'; - this.applyRejectedGestureProjection(inFlight); this.notify(); throw error; } finally { @@ -1441,12 +1481,22 @@ export class EditorSaveCoordinator { } } - private discardRejectedPending(error: unknown, mutationId: string): void { + private discardRejectedPending( + error: unknown, + mutationId: string, + rejectedGestureIds: readonly string[] = [], + ): void { this.ownMutations.delete(mutationId); + const discardedGestureIds = [ + ...new Set([...rejectedGestureIds, ...this.pendingGestureIds]), + ]; this.pendingLocal = null; this.pendingFields = []; this.pendingRequestFields = []; this.pendingGestureIds = []; + if (discardedGestureIds.length > 0) { + this.onGestureIdsDiscarded?.(discardedGestureIds); + } this.phase = 'error'; this.error = error; this.failureKind = 'permanent'; From 1a44c28df0c99316553d930cc8a048006693f67d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 12 Aug 2026 23:41:55 +0900 Subject: [PATCH 33/35] =?UTF-8?q?fix:=20=EB=84=A4=EC=9D=B4=ED=8B=B0?= =?UTF-8?q?=EB=B8=8C=20=EC=A0=84=EC=9A=A9=20=EB=B6=99=EC=97=AC=EB=84=A3?= =?UTF-8?q?=EA=B8=B0=EB=A5=BC=20editor=20=EB=8B=A8=EB=8F=85=20=EC=BB=A4?= =?UTF-8?q?=EB=B0=8B=EC=9C=BC=EB=A1=9C=20=EB=9D=BC=EC=9A=B0=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/state/gesture.rs | 63 +++++++++++++++++++ .../commitMixedGestureIntent.test.ts | 41 +++++++++++- .../displayElement/gestureTransaction.ts | 23 ++++++- 3 files changed, 125 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/state/gesture.rs b/src-tauri/src/state/gesture.rs index d5fed392..0c006a03 100644 --- a/src-tauri/src/state/gesture.rs +++ b/src-tauri/src/state/gesture.rs @@ -88,3 +88,66 @@ pub(crate) fn validate_gesture_commit_request( } Ok(size) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::GesturePluginInstancesChange; + + 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, + 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) + } + + #[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_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") + ); + } +} diff --git a/src/renderer/plugins/runtime/displayElement/commitMixedGestureIntent.test.ts b/src/renderer/plugins/runtime/displayElement/commitMixedGestureIntent.test.ts index 278d2523..bc9f9d43 100644 --- a/src/renderer/plugins/runtime/displayElement/commitMixedGestureIntent.test.ts +++ b/src/renderer/plugins/runtime/displayElement/commitMixedGestureIntent.test.ts @@ -12,6 +12,12 @@ const mocks = vi.hoisted(() => ({ pluginModelRevision: 1, }), ), + editorCommit: vi.fn(() => + Promise.resolve({ + revision: 1, + changedFields: [], + }), + ), buildSaved: vi.fn( (elements: Array<{ fullId: string; zIndex?: number }>, pluginId: string) => elements.map( @@ -28,6 +34,10 @@ vi.mock('@api/modules/gestureApi', () => ({ gestureApi: { commit: mocks.gestureCommit }, })); +vi.mock('@api/modules/editorApi', () => ({ + editorApi: { commit: mocks.editorCommit }, +})); + vi.mock('@src/renderer/editor/runtime/editorStateCoordinator', () => ({ editorCoordinator: { // 실제 순서 재현: prepare → generator 평가 → onEnrolled → transaction callback @@ -51,7 +61,14 @@ vi.mock('@src/renderer/editor/runtime/editorStateCoordinator', () => ({ await commit({ editorBaseRevision: 0, mutationId: 'mutation-1', - ...(patch ? { editorChanges: patch } : {}), + ...(patch + ? { + editorChanges: { + ...(patch as Record), + schemaVersion: 2, + }, + } + : {}), }); return {}; }, @@ -118,6 +135,7 @@ describe('commitMixedGestureIntent', () => { mocks.stageGesture.mockClear(); mocks.unstageGesture.mockClear(); mocks.gestureCommit.mockClear(); + mocks.editorCommit.mockClear(); mocks.buildSaved.mockClear(); mocks.applyCanonical.mockClear(); mocks.setElements.mockClear(); @@ -127,6 +145,27 @@ describe('commitMixedGestureIntent', () => { mocks.elements = []; }); + it('plugin scope가 비어 있으면 일반 editor 커밋으로 저장한다', async () => { + await commitMixedGestureIntent({ + gestureId: 'gesture-native-only', + initialPluginIds: [], + pluginScope: () => [], + generate: () => ({ + kind: 'patch', + patch: { schemaVersion: 1, statPositions: {} }, + }), + }); + + expect(mocks.editorCommit).toHaveBeenCalledWith({ + baseRevision: 0, + mutationId: 'mutation-1', + changes: { schemaVersion: 2, statPositions: {} }, + gestureId: 'gesture-native-only', + }); + expect(mocks.gestureCommit).not.toHaveBeenCalled(); + expect(mocks.buildSaved).not.toHaveBeenCalled(); + }); + it('prepare 고정점이 상한까지 수렴하지 않으면 전체 중단한다', async () => { let round = 0; const onFailure = vi.fn(); diff --git a/src/renderer/plugins/runtime/displayElement/gestureTransaction.ts b/src/renderer/plugins/runtime/displayElement/gestureTransaction.ts index c10121ea..bf7e5f23 100644 --- a/src/renderer/plugins/runtime/displayElement/gestureTransaction.ts +++ b/src/renderer/plugins/runtime/displayElement/gestureTransaction.ts @@ -1,4 +1,5 @@ import { gestureApi } from '@api/modules/gestureApi'; +import { editorApi } from '@api/modules/editorApi'; import { editorCoordinator } from '@src/renderer/editor/runtime/editorStateCoordinator'; import { trackEditorWrite } from '@src/renderer/editor/runtime/editorWriteBarrier'; import { ElementIntentAbort } from '@src/renderer/editor/runtime/elementIntent'; @@ -176,6 +177,7 @@ export const commitMixedGestureIntent = (options: { let lastGeneration: MixedIntentGeneration | null = null; let gestureResult: Awaited> | null = null; + let editorOnlyCommit = false; const prepare = async (): Promise => { // 고정점: drain 중 나타난 신규 definition을 stage하고 다시 drain. @@ -226,6 +228,21 @@ export const commitMixedGestureIntent = (options: { const projectionSource = lastGeneration?.desiredPluginProjection ?? sealedProjection; const scopeIds = normalizePluginIds([...scope]); + if (scopeIds.length === 0) { + editorOnlyCommit = true; + if (!context.editorChanges) { + return { + revision: context.editorBaseRevision, + changedFields: [], + }; + } + return editorApi.commit({ + baseRevision: context.editorBaseRevision, + mutationId: context.mutationId, + changes: context.editorChanges, + gestureId, + }); + } const pluginElements = new Map( scopeIds.map((pluginId) => [ pluginId, @@ -259,7 +276,11 @@ export const commitMixedGestureIntent = (options: { changedFields: result.changedFields, }; }, - { onEnrolled: options.onEnrolled, prepare }, + { + onEnrolled: options.onEnrolled, + prepare, + reconcileRetryableEditorIntent: () => editorOnlyCommit, + }, ) .then(() => { // prepare가 동적으로 편입한 plugin의 재계산 상태를 main store에도 From 43e82af0a38af2d3112ba6e059761adf8abba1c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 12 Aug 2026 23:41:55 +0900 Subject: [PATCH 34/35] =?UTF-8?q?fix:=20=EC=9E=AC=EC=A0=95=EB=A0=AC=20?= =?UTF-8?q?=EC=A4=91=20=EB=A6=AC=EC=82=AC=EC=9D=B4=EC=A6=88=20=ED=94=84?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=EB=A5=BC=20=EB=8F=99=EA=B2=B0=20=EB=8C=80?= =?UTF-8?q?=EC=83=81=EC=9C=BC=EB=A1=9C=20=EB=9D=BC=EC=9A=B0=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hooks/Grid/useGridResize.test.tsx | 75 ++++++++++++++++--- src/renderer/hooks/Grid/useGridResize.ts | 11 +++ 2 files changed, 77 insertions(+), 9 deletions(-) diff --git a/src/renderer/hooks/Grid/useGridResize.test.tsx b/src/renderer/hooks/Grid/useGridResize.test.tsx index a3bebe79..02295183 100644 --- a/src/renderer/hooks/Grid/useGridResize.test.tsx +++ b/src/renderer/hooks/Grid/useGridResize.test.tsx @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createDefaultKeyPosition } from '@src/renderer/editor/model/keys'; import type { SelectedElement } from '@stores/grid/useGridSelectionStore'; +import type { ElementBounds } from '@utils/grid/smartGuides'; import { useGridResize } from './useGridResize'; const mocks = vi.hoisted(() => ({ @@ -20,6 +21,18 @@ const mocks = vi.hoisted(() => ({ sendBridge: vi.fn(), commitBounds: vi.fn(() => Promise.resolve(true)), elements: [] as Array<{ fullId: string; pluginId: string }>, + keyPositions: [{ dx: 0, dy: 0, width: 40, height: 40 }] as Array<{ + id?: string; + dx: number; + dy: number; + width: number; + height: number; + }>, + gridSettings: { + alignmentGuides: false, + spacingGuides: false, + sizeMatchGuides: false, + }, })); vi.mock('@plugins/runtime/displayElement/instancesCommitQueue', () => ({ @@ -79,11 +92,7 @@ vi.mock('@stores/grid/useGridSelectionStore', () => ({ vi.mock('@stores/useSettingsStore', () => ({ useSettingsStore: { getState: () => ({ - gridSettings: { - alignmentGuides: false, - spacingGuides: false, - sizeMatchGuides: false, - }, + gridSettings: mocks.gridSettings, }), }, })); @@ -92,10 +101,10 @@ vi.mock('@stores/data/useKeyStore', () => ({ useKeyStore: { getState: () => ({ positions: { - '4key': [{ dx: 0, dy: 0, width: 40, height: 40 }], + '4key': mocks.keyPositions, }, canonicalPositions: { - '4key': [{ dx: 0, dy: 0, width: 40, height: 40 }], + '4key': mocks.keyPositions, }, setPositions: vi.fn(), }), @@ -129,12 +138,18 @@ type ResizeApi = ReturnType; interface HarnessProps { selectedElements: SelectedElement[]; expose: (api: ResizeApi) => void; + getOtherElements?: (excludeId: string) => ElementBounds[]; } -const Harness = ({ selectedElements, expose }: HarnessProps) => { +const Harness = ({ + selectedElements, + expose, + getOtherElements, +}: HarnessProps) => { const api = useGridResize({ selectedElements, selectedKeyType: '4key', + getOtherElements, }); expose(api); return null; @@ -168,11 +183,15 @@ describe('useGridResize plugin gesture lifecycle', () => { let events: string[]; let pluginGestureIds: string[]; - const renderHarness = async (selectedElements: SelectedElement[]) => { + const renderHarness = async ( + selectedElements: SelectedElement[], + getOtherElements?: (excludeId: string) => ElementBounds[], + ) => { await act(async () => { root.render( { api = nextApi; }} @@ -201,6 +220,12 @@ describe('useGridResize plugin gesture lifecycle', () => { mocks.beginMixedGesture.mockClear(); mocks.cancelMixedGesture.mockClear(); mocks.elements = []; + mocks.keyPositions = [{ dx: 0, dy: 0, width: 40, height: 40 }]; + mocks.gridSettings = { + alignmentGuides: false, + spacingGuides: false, + sizeMatchGuides: false, + }; mocks.begin.mockImplementation((pluginId: string, gestureId: string) => { const token = `token-${++tokenSequence}`; pluginGestureIds.push(gestureId); @@ -465,6 +490,38 @@ describe('useGridResize plugin gesture lifecycle', () => { expect(byId.get(STABLE_A)).toMatchObject({ width: 120, height: 80 }); }); + it('리사이즈 중 재정렬돼도 프리뷰 가이드는 시작 요소를 제외한다', async () => { + const getOtherElements = vi.fn(() => [] as ElementBounds[]); + mocks.gridSettings = { + alignmentGuides: true, + spacingGuides: true, + sizeMatchGuides: true, + }; + mocks.keyPositions = [ + { id: STABLE_A, dx: 0, dy: 0, width: 120, height: 60 }, + { id: STABLE_B, dx: 200, dy: 0, width: 120, height: 60 }, + ]; + await renderHarness([stableKeySelection(STABLE_A)], getOtherElements); + const activeResizeApi = api; + + await act(async () => { + activeResizeApi.handleResizeStart(); + }); + mocks.keyPositions = [mocks.keyPositions[1], mocks.keyPositions[0]]; + await renderHarness([stableKeySelection(STABLE_A, 1)], getOtherElements); + await act(async () => { + activeResizeApi.handleResize({ + x: 0, + y: 0, + width: 118, + height: 60, + handle: { id: 'e', dx: 1, dy: 0 }, + }); + }); + + expect(getOtherElements).toHaveBeenLastCalledWith(STABLE_A); + }); + it('시작 baseline이 없는 합성 단일 resize는 eager와 wire 모두 무커밋한다', async () => { // coordinator lastAck가 null - 합성 index 의도는 시작 증명 없이는 // 어떤 경로로도 커밋되지 않는다 (wire 부활 금지) diff --git a/src/renderer/hooks/Grid/useGridResize.ts b/src/renderer/hooks/Grid/useGridResize.ts index fd47958d..34ee7387 100644 --- a/src/renderer/hooks/Grid/useGridResize.ts +++ b/src/renderer/hooks/Grid/useGridResize.ts @@ -980,6 +980,17 @@ export function useGridResize({ if (selectedElements.length !== 1) return; const element = selectedElements[0]; + const frozenTarget = frozenResizeTargetsRef.current[0]; + if ( + frozenResizeTargetsRef.current.length === 1 && + frozenTarget && + frozenTarget.type !== 'plugin' && + frozenTarget.id.length > 0 && + !isSyntheticElementId(frozenTarget.id) + ) { + handleElementResizePreview(frozenTarget.id, newBounds); + return; + } if (element.type === 'key' && element.index !== undefined) { handleKeyResizePreview(element.index, newBounds); } else if (element.type === 'stat' && element.index !== undefined) { From 13a161544dd86562162854644a0784c07105172c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 12 Aug 2026 23:41:55 +0900 Subject: [PATCH 35/35] =?UTF-8?q?fix:=20=EC=98=81=EA=B5=AC=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=8B=A4=ED=8C=A8=20=EC=95=88=EB=82=B4=EB=A5=BC=20?= =?UTF-8?q?=EC=9A=A9=EB=9F=89=20=EC=B4=88=EA=B3=BC=EC=99=80=20=EC=9D=BC?= =?UTF-8?q?=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...seAppBootstrap.modeSelectionReset.test.tsx | 97 +++++++++++++++++-- src/renderer/hooks/app/useAppBootstrap.ts | 47 +++++++-- src/renderer/locales/en.json | 3 +- src/renderer/locales/ko.json | 3 +- src/renderer/locales/ru.json | 3 +- src/renderer/locales/zh-Hant.json | 3 +- src/renderer/locales/zh-cn.json | 3 +- 7 files changed, 138 insertions(+), 21 deletions(-) diff --git a/src/renderer/hooks/app/useAppBootstrap.modeSelectionReset.test.tsx b/src/renderer/hooks/app/useAppBootstrap.modeSelectionReset.test.tsx index e6f073a1..68c6f7c1 100644 --- a/src/renderer/hooks/app/useAppBootstrap.modeSelectionReset.test.tsx +++ b/src/renderer/hooks/app/useAppBootstrap.modeSelectionReset.test.tsx @@ -3,6 +3,7 @@ import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useSettingsStore } from '@stores/useSettingsStore'; import type { BootstrapPayload } from '@src/types/app'; +import type { EditorCoordinatorState } from '@src/renderer/editor/runtime/editorCoordinator'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -25,6 +26,13 @@ const mocks = vi.hoisted(() => ({ presetSnapshotListener: null as null | ((payload: unknown) => void), resyncListener: null as null | (() => void), bootstrap: vi.fn(), + dialogAlert: vi.fn(() => Promise.resolve()), + editorStateListener: null as null | ((state: EditorCoordinatorState) => void), + editorState: { + conflict: null, + failureKind: null, + error: null, + } as EditorCoordinatorState, keyState: { selectedKeyType: '4key', isBootstrapped: true, @@ -116,12 +124,11 @@ vi.mock('@api/modules/appApi', () => ({ })); vi.mock('@src/renderer/editor/runtime/editorStateCoordinator', () => ({ editorCoordinator: { - subscribe: vi.fn(() => vi.fn()), - getState: vi.fn(() => ({ - conflict: null, - failureKind: null, - error: null, - })), + subscribe: vi.fn((listener: (state: EditorCoordinatorState) => void) => { + mocks.editorStateListener = listener; + return vi.fn(); + }), + getState: vi.fn(() => mocks.editorState), resolveConflict: vi.fn(), start: vi.fn(), sync: vi.fn(), @@ -287,6 +294,7 @@ const makeApiMock = () => return vi.fn(); }), }, + ui: { dialog: { alert: mocks.dialogAlert } }, overlay: { onLock: vi.fn(() => vi.fn()), onAnchor: vi.fn(() => vi.fn()) }, css: { onUse: vi.fn(() => vi.fn()), onContent: vi.fn(() => vi.fn()) }, js: { onUse: vi.fn(() => vi.fn()), onState: vi.fn(() => vi.fn()) }, @@ -323,6 +331,13 @@ describe('모드 전환 선택 리셋', () => { mocks.bootstrap.mockReset(); mocks.customTabsListener = null; mocks.presetSnapshotListener = null; + mocks.dialogAlert.mockClear(); + mocks.editorStateListener = null; + mocks.editorState = { + conflict: null, + failureKind: null, + error: null, + } as EditorCoordinatorState; mocks.keyState = { selectedKeyType: '4key', isBootstrapped: false, @@ -398,4 +413,74 @@ describe('모드 전환 선택 리셋', () => { expect(resetSelectionForModeChange).not.toHaveBeenCalled(); }); + + it('영구 저장 실패를 짧은 두 줄 문구로 한 번 알린다', async () => { + useSettingsStore.setState({ language: 'ko' }); + const error = new Error('invalid editor document'); + const permanentState = { + ...mocks.editorState, + phase: 'error', + failureKind: 'permanent', + error, + } as EditorCoordinatorState; + + act(() => { + mocks.editorStateListener?.(permanentState); + mocks.editorStateListener?.(permanentState); + }); + + expect(mocks.dialogAlert).toHaveBeenCalledOnce(); + expect(mocks.dialogAlert).toHaveBeenCalledWith( + '저장하지 못해 변경 내용을 되돌렸습니다.\n방금 바꾼 값을 확인해 주세요.', + { confirmText: '확인' }, + ); + }); + + it('저장 한도 실패는 원인에 맞는 짧은 문구로 알린다', async () => { + useSettingsStore.setState({ language: 'ko' }); + const permanentState = { + ...mocks.editorState, + phase: 'error', + failureKind: 'permanent', + error: { + errorCode: 'VALIDATION_FAILED', + message: 'collection too large', + details: { validationCode: 'COLLECTION_TOO_LARGE' }, + retryable: false, + }, + } as EditorCoordinatorState; + + act(() => { + mocks.editorStateListener?.(permanentState); + }); + + expect(mocks.dialogAlert).toHaveBeenCalledWith( + '저장 한도를 넘어 변경을 되돌렸습니다.\n일부 요소를 줄이고 다시 시도해 주세요.', + { confirmText: '확인' }, + ); + }); + + it('알 수 없는 검증 코드는 일반 저장 실패 문구로 알린다', async () => { + useSettingsStore.setState({ language: 'ko' }); + const permanentState = { + ...mocks.editorState, + phase: 'error', + failureKind: 'permanent', + error: { + errorCode: 'VALIDATION_FAILED', + message: 'unknown validation', + details: { validationCode: 'NEW_VALIDATION_CODE' }, + retryable: false, + }, + } as EditorCoordinatorState; + + act(() => { + mocks.editorStateListener?.(permanentState); + }); + + expect(mocks.dialogAlert).toHaveBeenCalledWith( + '저장하지 못해 변경 내용을 되돌렸습니다.\n방금 바꾼 값을 확인해 주세요.', + { confirmText: '확인' }, + ); + }); }); diff --git a/src/renderer/hooks/app/useAppBootstrap.ts b/src/renderer/hooks/app/useAppBootstrap.ts index c85aadf7..dcac96be 100644 --- a/src/renderer/hooks/app/useAppBootstrap.ts +++ b/src/renderer/hooks/app/useAppBootstrap.ts @@ -73,6 +73,29 @@ import { } from '@utils/grid/cursorUtils'; import type { CustomJs, JsPlugin } from '@src/types/plugin/js'; +const CAPACITY_VALIDATION_CODES = new Set([ + 'COLLECTION_TOO_LARGE', + 'TOO_MANY_RENDER_ITEMS', + 'TOO_MANY_LAYER_GROUPS', + 'TOO_MANY_SLOTS_PER_MEMBER', + 'REQUEST_TOO_LARGE', + 'HISTORY_ENTRY_TOO_LARGE', +]); + +const isEditorCapacityFailure = (error: unknown): boolean => + typeof error === 'object' && + error !== null && + 'errorCode' in error && + error.errorCode === 'VALIDATION_FAILED' && + 'retryable' in error && + error.retryable === false && + 'details' in error && + typeof error.details === 'object' && + error.details !== null && + 'validationCode' in error.details && + typeof error.details.validationCode === 'string' && + CAPACITY_VALIDATION_CODES.has(error.details.validationCode); + function clonePlugins(source?: CustomJs | null): JsPlugin[] { if (!source) return []; const fromPlugins = Array.isArray(source.plugins) ? source.plugins : []; @@ -424,17 +447,21 @@ export function useAppBootstrap() { '저장할 수 없는 편집 내용을 마지막 저장 상태로 되돌렸습니다', state.error, ); - void window.api.ui.dialog - .alert( - getEditorCopy( + const message = isEditorCapacityFailure(state.error) + ? getEditorCopy( + 'editorSave.capacityFailure', + '저장 한도를 넘어 변경을 되돌렸습니다.\n일부 요소를 줄이고 다시 시도해 주세요.', + 'This edit exceeded the save limit and was undone.\nRemove some elements and try again.', + ) + : getEditorCopy( 'editorSave.permanentFailure', - '저장할 수 없는 편집 내용이라 마지막으로 저장된 상태로 되돌렸습니다. 방금 변경한 값을 확인해 주세요.', - 'This edit could not be saved, so the editor was restored to the last saved state. Please check the value you just changed.', - ), - { - confirmText: getEditorCopy('common.ok', '확인', 'OK'), - }, - ) + '저장하지 못해 변경 내용을 되돌렸습니다.\n방금 바꾼 값을 확인해 주세요.', + "Couldn't save this edit, so it was undone.\nCheck the value you just changed.", + ); + void window.api.ui.dialog + .alert(message, { + confirmText: getEditorCopy('common.ok', '확인', 'OK'), + }) .catch((error) => { console.error('편집 저장 실패 안내를 표시하지 못했습니다', error); }); diff --git a/src/renderer/locales/en.json b/src/renderer/locales/en.json index 94de466b..7dc7ff37 100644 --- a/src/renderer/locales/en.json +++ b/src/renderer/locales/en.json @@ -205,7 +205,8 @@ "acceptExternal": "Accept External Change" }, "editorSave": { - "permanentFailure": "This edit could not be saved, so the editor was restored to the last saved state. Please check the value you just changed." + "permanentFailure": "Couldn't save this edit, so it was undone.\nCheck the value you just changed.", + "capacityFailure": "This edit exceeded the save limit and was undone.\nRemove some elements and try again." }, "common": { "confirm": "Confirm", diff --git a/src/renderer/locales/ko.json b/src/renderer/locales/ko.json index bd359061..84844520 100644 --- a/src/renderer/locales/ko.json +++ b/src/renderer/locales/ko.json @@ -205,7 +205,8 @@ "acceptExternal": "외부 변경 수용" }, "editorSave": { - "permanentFailure": "저장할 수 없는 편집 내용이라 마지막으로 저장된 상태로 되돌렸습니다. 방금 변경한 값을 확인해 주세요." + "permanentFailure": "저장하지 못해 변경 내용을 되돌렸습니다.\n방금 바꾼 값을 확인해 주세요.", + "capacityFailure": "저장 한도를 넘어 변경을 되돌렸습니다.\n일부 요소를 줄이고 다시 시도해 주세요." }, "common": { "confirm": "확인", diff --git a/src/renderer/locales/ru.json b/src/renderer/locales/ru.json index 96c8c8e5..3b9b13a8 100644 --- a/src/renderer/locales/ru.json +++ b/src/renderer/locales/ru.json @@ -205,7 +205,8 @@ "acceptExternal": "Принять внешнее изменение" }, "editorSave": { - "permanentFailure": "Это изменение не удалось сохранить, поэтому редактор вернулся к последнему сохранённому состоянию. Проверьте значение, которое вы только что изменили." + "permanentFailure": "Не удалось сохранить изменение, поэтому оно отменено.\nПроверьте последнее изменённое значение.", + "capacityFailure": "Превышен лимит сохранения, поэтому изменение отменено.\nУдалите часть элементов и повторите попытку." }, "common": { "confirm": "Подтвердить", diff --git a/src/renderer/locales/zh-Hant.json b/src/renderer/locales/zh-Hant.json index 881f0c02..8121e46f 100644 --- a/src/renderer/locales/zh-Hant.json +++ b/src/renderer/locales/zh-Hant.json @@ -205,7 +205,8 @@ "acceptExternal": "接受外部變更" }, "editorSave": { - "permanentFailure": "此編輯無法儲存,編輯器已還原到上次儲存的狀態。請檢查您剛剛變更的值。" + "permanentFailure": "無法儲存,已復原這次變更。\n請檢查剛才變更的值。", + "capacityFailure": "超過儲存上限,已復原這次變更。\n請刪除部分元素後再試。" }, "common": { "confirm": "確認", diff --git a/src/renderer/locales/zh-cn.json b/src/renderer/locales/zh-cn.json index 2f5905d6..456da724 100644 --- a/src/renderer/locales/zh-cn.json +++ b/src/renderer/locales/zh-cn.json @@ -205,7 +205,8 @@ "acceptExternal": "接受外部更改" }, "editorSave": { - "permanentFailure": "此编辑无法保存,编辑器已恢复到上次保存的状态。请检查您刚刚更改的值。" + "permanentFailure": "无法保存,已撤销这次更改。\n请检查刚才更改的值。", + "capacityFailure": "超出保存上限,已撤销这次更改。\n请删除部分元素后重试。" }, "common": { "confirm": "确认",