diff --git a/docs/content/en/api-reference/editor/page.mdx b/docs/content/en/api-reference/editor/page.mdx index 46c23c3a..0be32660 100644 --- a/docs/content/en/api-reference/editor/page.mdx +++ b/docs/content/en/api-reference/editor/page.mdx @@ -56,8 +56,10 @@ that top-level collection. It is not an item-level diff. 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 + invent one. A written element with a missing or unknown `id` gets a fresh one + from the backend. If the same `id` appears more than once — for example when + you copy an element you read and append it — the element in its original slot + keeps the identity and the remaining copies get fresh ones. See the [Keys API](/docs/api-reference/keys) for the identity rules. diff --git a/docs/content/en/api-reference/keys/page.mdx b/docs/content/en/api-reference/keys/page.mdx index 0af203c0..0e9ff8c7 100644 --- a/docs/content/en/api-reference/keys/page.mdx +++ b/docs/content/en/api-reference/keys/page.mdx @@ -296,10 +296,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. +you read and never invent one. A write whose `id` is missing or unknown gets a +fresh one assigned by the backend. When the same `id` appears more than once — +for example when you copy an element you read and append it — the element in +its original slot keeps the identity and the remaining copies get fresh ones. +Loading a full preset re-issues every `id`; a tab preset re-issues only the +collections that preset supplies positions for. 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. diff --git a/docs/content/ko/api-reference/editor/page.mdx b/docs/content/ko/api-reference/editor/page.mdx index 692fb1e1..44df6ec5 100644 --- a/docs/content/ko/api-reference/editor/page.mdx +++ b/docs/content/ko/api-reference/editor/page.mdx @@ -55,9 +55,10 @@ type EditorPatchV1 = { 위치 컬렉션(`keyPositions`, `statPositions`, `graphPositions`, `knobPositions`)의 모든 요소는 앱이 발급·소유하는 안정 `id`(UUID)를 가집니다. 불투명 값으로 다루세요. 읽은 값을 그대로 되돌려 보내고, 직접 - 만들거나 중복시키지 마세요. `id`가 없거나 미확인인 요소를 쓰면 백엔드가 새 - 값을 발급합니다. 신원 규칙은 [Keys API](/docs/api-reference/keys)를 - 참고하세요. + 만들지 마세요. `id`가 없거나 미확인인 요소를 쓰면 백엔드가 새 값을 + 발급합니다. 읽은 요소를 복사해 배열에 덧붙이는 경우처럼 같은 `id`가 여러 번 + 나오면, 원래 자리의 요소가 신원을 지키고 나머지 사본이 새 값을 받습니다. + 신원 규칙은 [Keys API](/docs/api-reference/keys)를 참고하세요. ## 현재 문서 조회 diff --git a/docs/content/ko/api-reference/keys/page.mdx b/docs/content/ko/api-reference/keys/page.mdx index 4237d347..f5bb1ac1 100644 --- a/docs/content/ko/api-reference/keys/page.mdx +++ b/docs/content/ko/api-reference/keys/page.mdx @@ -163,10 +163,12 @@ interface ElementShadowSpec { 모든 요소 위치(`keyPositions`, `statPositions`, `graphPositions`, `knobPositions`)는 안정 `id`를 가집니다. 불투명 값으로 다루세요. 읽은 값을 -그대로 되돌려 보내고, 직접 만들거나 다른 요소에 복사하지 마세요. `id`가 -없거나 미확인인 쓰기는 백엔드가 새 값을 발급하며, 프리셋을 불러오면 모든 -`id`가 재발급됩니다. 재정렬을 가로질러 요소를 추적할 때는 배열 index 대신 -`id`를 사용하세요. +그대로 되돌려 보내고, 직접 만들지 마세요. `id`가 없거나 미확인인 쓰기는 +백엔드가 새 값을 발급합니다. 같은 `id`가 여러 번 나오면(읽은 요소를 복사해 +덧붙이는 경우처럼) 원래 자리의 요소가 신원을 지키고 나머지 사본이 새 값을 +받습니다. 전체 프리셋을 불러오면 모든 `id`가 재발급되고, 탭 프리셋은 그 +프리셋이 위치를 준 컬렉션만 재발급됩니다. 재정렬을 가로질러 요소를 추적할 +때는 배열 index 대신 `id`를 사용하세요. 그라데이션 필드가 있으면 렌더에서 대응 단색 필드보다 우선하며, 저장 시 대응 단색 필드는 첫 스톱 색으로 자동 동기화됩니다. 단색으로 되돌리려면 diff --git a/src-tauri/src/commands/preset/load.rs b/src-tauri/src/commands/preset/load.rs index 4421e2f5..328ac47c 100644 --- a/src-tauri/src/commands/preset/load.rs +++ b/src-tauri/src/commands/preset/load.rs @@ -611,6 +611,7 @@ pub fn preset_load_tab( admission, move |store| { let previous_tab_css_overrides = store.tab_css_overrides.clone(); + let key_positions_written = imported_key_positions.is_some(); 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(); @@ -633,6 +634,7 @@ pub fn preset_load_tab( rekey_tab_preset_elements( store, ¤t_tab_id, + key_positions_written, stat_positions_written, graph_positions_written, knob_positions_written, @@ -908,6 +910,7 @@ fn rekey_full_preset_elements(store: &mut AppStoreData) { fn rekey_tab_preset_elements( store: &mut AppStoreData, tab_id: &str, + key_positions_written: bool, stat_positions_written: bool, graph_positions_written: bool, knob_positions_written: bool, @@ -915,11 +918,21 @@ fn rekey_tab_preset_elements( crate::state::native_element_id::rekey_mode_element_ids_for_collections( store, tab_id, - true, + key_positions_written, stat_positions_written, graph_positions_written, knob_positions_written, ); + // 프리셋이 위치를 주지 않은 컬렉션은 기존 요소가 값 그대로 남는다 - + // 신원을 회전시키지 않고 정렬이 덧붙인 빈 항목만 채운다 + crate::state::native_element_id::backfill_mode_element_ids_for_collections( + store, + tab_id, + !key_positions_written, + !stat_positions_written, + !graph_positions_written, + !knob_positions_written, + ); } fn merge_tab_preset_key_pair( @@ -2365,9 +2378,9 @@ mod tests { 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); + rekey_tab_preset_elements(&mut store, "target", true, true, true, false); let first_ids = target_preset_ids(&store); - rekey_tab_preset_elements(&mut store, "target", true, true, false); + rekey_tab_preset_elements(&mut store, "target", true, true, true, false); let second_ids = target_preset_ids(&store); assert!(original_ids[..3] @@ -2387,6 +2400,35 @@ mod tests { .unwrap(); } + #[test] + fn tab_preset_without_key_positions_keeps_existing_key_ids() { + let mut store = old_preset_store(); + crate::state::native_element_id::backfill_store_element_ids(&mut store); + let original_ids = target_preset_ids(&store); + // keys만 담긴 탭 프리셋: merge가 기존 위치를 값 그대로 되삽입하고 + // 슬롯 정렬이 빈 위치 하나를 덧붙인 상태 + store + .key_positions + .get_mut("target") + .unwrap() + .push(KeyPosition::default()); + + rekey_tab_preset_elements(&mut store, "target", false, false, false, false); + + // 값이 그대로인 기존 키는 신원을 지키고, 덧붙은 슬롯만 새 id를 받는다 + assert_eq!(store.key_positions["target"][0].id, original_ids[0]); + let appended = &store.key_positions["target"][1].id; + assert!(crate::state::native_element_id::is_valid_element_id( + appended + )); + assert_ne!(appended, &original_ids[0]); + assert_eq!(target_preset_ids(&store)[1..], original_ids[1..]); + 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/state/editor.rs b/src-tauri/src/state/editor.rs index 9b81550f..e148eb1f 100644 --- a/src-tauri/src/state/editor.rs +++ b/src-tauri/src/state/editor.rs @@ -10,8 +10,8 @@ use crate::{ errors::EditorCommitError, models::{ AppStoreData, CustomTab, EditorCommitRequest, EditorDocumentV1, EditorField, - ElementShadowSpec, KeyCounters, KeyMappings, KeyPosition, KeySlot, - EDITOR_COMMIT_SCHEMA_VERSION_V2, EDITOR_SCHEMA_VERSION, + ElementShadowSpec, GraphPosition, KeyCounters, KeyMappings, KeyPosition, KeySlot, + KnobPosition, StatPosition, EDITOR_COMMIT_SCHEMA_VERSION_V2, EDITOR_SCHEMA_VERSION, }, }; @@ -448,11 +448,36 @@ fn key_position_id_order( } /// 기존 store에 있던 손실 없는 비정상 데이터는 유지하되 새 비정상 상태는 만들지 않음 +// 한도 초과 관용의 신원 기준. 평상시에는 안정 ID로 요소를 짝지어, 새 요소가 +// 남의 관용을 물려받지 못하게 한다. 프리셋 트랜잭션은 커밋 직전 모든 id를 +// 재발급하므로 ID 짝짓기가 성립하지 않아 (모드, index)로 되돌린다 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum GrandfatherKeying { + ById, + ByModeIndex, +} + pub(crate) fn validate_document_transition( current: &EditorDocumentV1, candidate: &EditorDocumentV1, current_store: &AppStoreData, candidate_store: &AppStoreData, +) -> Result<(), EditorCommitError> { + validate_document_transition_with_keying( + current, + candidate, + current_store, + candidate_store, + GrandfatherKeying::ById, + ) +} + +pub(crate) fn validate_document_transition_with_keying( + current: &EditorDocumentV1, + candidate: &EditorDocumentV1, + current_store: &AppStoreData, + candidate_store: &AppStoreData, + keying: GrandfatherKeying, ) -> Result<(), EditorCommitError> { if candidate.schema_version != EDITOR_SCHEMA_VERSION { return Err(EditorCommitError::validation( @@ -470,11 +495,15 @@ pub(crate) fn validate_document_transition( .iter() .map(|violation| violation.key.clone()) .collect::>(); - validate_metric_limits(current, candidate)?; + validate_metric_limits(current, candidate, keying)?; + let native_id_alias = match keying { + GrandfatherKeying::ById => HashMap::new(), + GrandfatherKeying::ByModeIndex => native_id_alias_by_slot(current, candidate), + }; if let Some(violation) = candidate_violations.iter().find(|violation| { is_unconditional_structural_violation(violation.code()) - || !is_grandfathered(¤t_violation_keys, violation) + || !is_grandfathered(¤t_violation_keys, violation, &native_id_alias) }) { return Err(EditorCommitError::validation( violation.code(), @@ -485,11 +514,129 @@ pub(crate) fn validate_document_transition( Ok(()) } +// 후보 요소 id → 같은 (모드, 자리)의 현재 요소 id. 프리셋 트랜잭션은 커밋 +// 직전 id를 재발급하므로 신원으로는 관용 상대를 찾을 수 없다 +fn native_id_alias_by_slot( + current: &EditorDocumentV1, + candidate: &EditorDocumentV1, +) -> HashMap { + let mut alias = HashMap::new(); + let mut collect = |current_ids: Vec<(&String, Vec<&str>)>, + candidate_ids: Vec<(&String, Vec<&str>)>| { + let current_by_mode = current_ids.into_iter().collect::>(); + for (mode, ids) in candidate_ids { + let Some(current_mode_ids) = current_by_mode.get(mode) else { + continue; + }; + for (index, id) in ids.into_iter().enumerate() { + if let Some(current_id) = current_mode_ids.get(index) { + alias.insert(id.to_string(), current_id.to_string()); + } + } + } + }; + + collect( + key_position_ids(¤t.key_positions), + key_position_ids(&candidate.key_positions), + ); + collect( + nested_position_ids(¤t.stat_positions), + nested_position_ids(&candidate.stat_positions), + ); + collect( + nested_position_ids(¤t.graph_positions), + nested_position_ids(&candidate.graph_positions), + ); + collect( + nested_position_ids(¤t.knob_positions), + nested_position_ids(&candidate.knob_positions), + ); + alias +} + +fn key_position_ids(collection: &HashMap>) -> Vec<(&String, Vec<&str>)> { + collection + .iter() + .map(|(mode, positions)| { + ( + mode, + positions + .iter() + .map(|position| position.id.as_str()) + .collect(), + ) + }) + .collect() +} + +fn nested_position_ids( + collection: &HashMap>, +) -> Vec<(&String, Vec<&str>)> { + collection + .iter() + .map(|(mode, positions)| { + ( + mode, + positions + .iter() + .map(|element| element.key_position().id.as_str()) + .collect(), + ) + }) + .collect() +} + +trait HasKeyPosition { + fn key_position(&self) -> &KeyPosition; +} + +impl HasKeyPosition for StatPosition { + fn key_position(&self) -> &KeyPosition { + &self.position + } +} + +impl HasKeyPosition for GraphPosition { + fn key_position(&self) -> &KeyPosition { + &self.position + } +} + +impl HasKeyPosition for KnobPosition { + fn key_position(&self) -> &KeyPosition { + &self.position + } +} + fn is_grandfathered( current_violation_keys: &BTreeSet, candidate: &ValidationViolation, + native_id_alias: &HashMap, ) -> bool { - current_violation_keys.contains(&candidate.key) + if current_violation_keys.contains(&candidate.key) { + return true; + } + if native_id_alias.is_empty() { + return false; + } + // 재발급된 id는 같은 자리의 이전 신원으로 바꿔 한 번 더 대조한다 + let ViolationOwner::NativeElement { kind, id } = &candidate.key.owner else { + return false; + }; + let Some(current_id) = native_id_alias.get(id) else { + return false; + }; + let aliased = ViolationKey { + owner: ViolationOwner::NativeElement { + kind: *kind, + id: current_id.clone(), + }, + code: candidate.key.code, + property_path: candidate.key.property_path.clone(), + invalid_value: candidate.key.invalid_value.clone(), + }; + current_violation_keys.contains(&aliased) } fn is_unconditional_structural_violation(code: &str) -> bool { @@ -823,10 +970,11 @@ fn collect_collection_violations( fn validate_metric_limits( current: &EditorDocumentV1, candidate: &EditorDocumentV1, + keying: GrandfatherKeying, ) -> Result<(), EditorCommitError> { validate_aggregate_metric_limits(current, candidate)?; validate_mode_metric_limits(current, candidate)?; - validate_per_owner_metric_limits(current, candidate) + validate_per_owner_metric_limits(current, candidate, keying) } fn validate_aggregate_metric_limits( @@ -908,9 +1056,30 @@ fn validate_mode_metric_limits( Ok(()) } +// keying에 따라 관용 상대를 찾는다. ById는 안정 ID로, ByModeIndex는 같은 +// 모드의 같은 자리로 짝짓는다 +fn grandfather_counterpart<'a, T>( + keying: GrandfatherKeying, + by_id: &HashMap<&str, &'a T>, + current_collection: &'a HashMap>, + mode: &str, + index: usize, + id: &str, + position_of: impl Fn(&'a T) -> &'a KeyPosition, +) -> Option<&'a KeyPosition> { + match keying { + GrandfatherKeying::ById => by_id.get(id).map(|element| position_of(element)), + GrandfatherKeying::ByModeIndex => current_collection + .get(mode) + .and_then(|elements| elements.get(index)) + .map(position_of), + } +} + fn validate_per_owner_metric_limits( current: &EditorDocumentV1, candidate: &EditorDocumentV1, + keying: GrandfatherKeying, ) -> Result<(), EditorCommitError> { let mut current_key_slots = HashMap::new(); for (mode, positions) in ¤t.key_positions { @@ -924,12 +1093,18 @@ fn validate_per_owner_metric_limits( for (mode, keys) in &candidate.keys { for (slot_index, slot) in keys.iter().enumerate() { - 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(); + let current_slot = match keying { + GrandfatherKeying::ById => candidate + .key_positions + .get(mode) + .and_then(|positions| positions.get(slot_index)) + .and_then(|position| current_key_slots.get(position.id.as_str())) + .copied(), + GrandfatherKeying::ByModeIndex => current + .keys + .get(mode) + .and_then(|slots| slots.get(slot_index)), + }; validate_key_slot_label_limits(mode, slot_index, current_slot, slot)?; } } @@ -977,7 +1152,15 @@ fn validate_per_owner_metric_limits( "keyPositions", mode, index, - current_key_positions.get(position.id.as_str()).copied(), + grandfather_counterpart( + keying, + ¤t_key_positions, + ¤t.key_positions, + mode, + index, + &position.id, + |position| position, + ), position, )?; } @@ -987,7 +1170,7 @@ fn validate_per_owner_metric_limits( .stat_positions .values() .flatten() - .map(|position| (position.position.id.as_str(), &position.position)) + .map(|position| (position.position.id.as_str(), position)) .collect::>(); for (mode, positions) in &candidate.stat_positions { for (index, position) in positions.iter().enumerate() { @@ -995,9 +1178,15 @@ fn validate_per_owner_metric_limits( "statPositions", mode, index, - current_stat_positions - .get(position.position.id.as_str()) - .copied(), + grandfather_counterpart( + keying, + ¤t_stat_positions, + ¤t.stat_positions, + mode, + index, + &position.position.id, + |element| &element.position, + ), &position.position, )?; } @@ -1007,7 +1196,7 @@ fn validate_per_owner_metric_limits( .graph_positions .values() .flatten() - .map(|position| (position.position.id.as_str(), &position.position)) + .map(|position| (position.position.id.as_str(), position)) .collect::>(); for (mode, positions) in &candidate.graph_positions { for (index, position) in positions.iter().enumerate() { @@ -1015,9 +1204,15 @@ fn validate_per_owner_metric_limits( "graphPositions", mode, index, - current_graph_positions - .get(position.position.id.as_str()) - .copied(), + grandfather_counterpart( + keying, + ¤t_graph_positions, + ¤t.graph_positions, + mode, + index, + &position.position.id, + |element| &element.position, + ), &position.position, )?; } @@ -1027,7 +1222,7 @@ fn validate_per_owner_metric_limits( .knob_positions .values() .flatten() - .map(|position| (position.position.id.as_str(), &position.position)) + .map(|position| (position.position.id.as_str(), position)) .collect::>(); for (mode, positions) in &candidate.knob_positions { for (index, position) in positions.iter().enumerate() { @@ -1035,9 +1230,15 @@ fn validate_per_owner_metric_limits( "knobPositions", mode, index, - current_knob_positions - .get(position.position.id.as_str()) - .copied(), + grandfather_counterpart( + keying, + ¤t_knob_positions, + ¤t.knob_positions, + mode, + index, + &position.position.id, + |element| &element.position, + ), &position.position, )?; } @@ -1981,7 +2182,8 @@ mod tests { assert!(is_grandfathered( ¤t, - &ValidationViolation::new(key, "different diagnostic message") + &ValidationViolation::new(key, "different diagnostic message"), + &HashMap::new() )); } @@ -2148,6 +2350,93 @@ mod tests { ); } + #[test] + fn preset_keying_grandfathers_rekeyed_elements_by_mode_and_index() { + let mut store = default_editor_store(); + store.key_positions.get_mut("4key").unwrap()[0].dx = MAX_ABS_COORDINATE + 1.0; + let current = EditorDocumentV1::from_store(&store); + + // 프리셋 로드는 검증 전에 모든 요소의 id를 재발급한다 - 값이 그대로여도 + // id 조회로는 관용 대상을 찾을 수 없다 + let mut candidate_store = store.clone(); + crate::state::native_element_id::rekey_store_element_ids(&mut candidate_store); + let candidate = EditorDocumentV1::from_store(&candidate_store); + + let error = validate_document_transition(¤t, &candidate, &store, &candidate_store) + .unwrap_err(); + assert_eq!( + error.details.unwrap().validation_code.as_deref(), + Some("COORDINATE_OUT_OF_RANGE") + ); + + validate_document_transition_with_keying( + ¤t, + &candidate, + &store, + &candidate_store, + GrandfatherKeying::ByModeIndex, + ) + .unwrap(); + } + + #[test] + fn preset_keying_grandfathers_structural_and_label_violations_too() { + let mut store = default_editor_store(); + // 그림자 위반과 과길이 라벨을 함께 가진 관용 store + store.key_positions.get_mut("4key").unwrap()[0].shadow = Some(ElementShadowSpec { + color: String::new(), + ..valid_shadow() + }); + store.keys.get_mut("4key").unwrap()[0] = KeySlot::from("x".repeat(MAX_KEY_LABEL_BYTES + 1)); + let current = EditorDocumentV1::from_store(&store); + + let mut candidate_store = store.clone(); + crate::state::native_element_id::rekey_store_element_ids(&mut candidate_store); + let candidate = EditorDocumentV1::from_store(&candidate_store); + + // ID 기준으로는 관용 상대를 못 찾아 거부된다 + assert!( + validate_document_transition(¤t, &candidate, &store, &candidate_store).is_err() + ); + + // 자리 기준이면 그림자·라벨 관용이 모두 유지된다 + validate_document_transition_with_keying( + ¤t, + &candidate, + &store, + &candidate_store, + GrandfatherKeying::ByModeIndex, + ) + .unwrap(); + } + + #[test] + fn preset_keying_still_rejects_newly_raised_metrics() { + let mut store = default_editor_store(); + store.key_positions.get_mut("4key").unwrap()[0].dx = MAX_ABS_COORDINATE + 1.0; + let current = EditorDocumentV1::from_store(&store); + + let mut candidate_store = store.clone(); + crate::state::native_element_id::rekey_store_element_ids(&mut candidate_store); + candidate_store.key_positions.get_mut("4key").unwrap()[1].dx = MAX_ABS_COORDINATE + 1.0; + let candidate = EditorDocumentV1::from_store(&candidate_store); + + // (mode,index) 관용은 같은 자리의 기존 위반만 물려받는다 - 멀쩡하던 + // 자리가 새로 초과되면 프리셋 경로에서도 거부한다 + let error = validate_document_transition_with_keying( + ¤t, + &candidate, + &store, + &candidate_store, + GrandfatherKeying::ByModeIndex, + ) + .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(); diff --git a/src-tauri/src/state/native_element_id.rs b/src-tauri/src/state/native_element_id.rs index 157c4e32..0c18d5aa 100644 --- a/src-tauri/src/state/native_element_id.rs +++ b/src-tauri/src/state/native_element_id.rs @@ -114,33 +114,65 @@ fn backfill_collection( } pub(crate) fn backfill_store_element_ids(store: &mut AppStoreData) -> BackfillOutcome { + backfill_element_ids_for_collections(store, true, true, true, true) +} + +// 건너뛰는 컬렉션의 id를 seen에 먼저 채워, 대상 컬렉션의 교차 중복도 복구된다 +pub(crate) fn backfill_element_ids_for_collections( + store: &mut AppStoreData, + key_positions: bool, + stat_positions: bool, + graph_positions: bool, + knob_positions: bool, +) -> BackfillOutcome { let mut seen = HashSet::new(); + if !key_positions { + collect_collection_ids(&store.key_positions, &mut seen); + } + if !stat_positions { + collect_collection_ids(&store.stat_positions, &mut seen); + } + if !graph_positions { + collect_collection_ids(&store.graph_positions, &mut seen); + } + if !knob_positions { + collect_collection_ids(&store.knob_positions, &mut seen); + } + 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, - ); + if key_positions { + backfill_collection( + &mut store.key_positions, + &mut seen, + &mut reserved, + &mut outcome, + ); + } + if stat_positions { + backfill_collection( + &mut store.stat_positions, + &mut seen, + &mut reserved, + &mut outcome, + ); + } + if graph_positions { + backfill_collection( + &mut store.graph_positions, + &mut seen, + &mut reserved, + &mut outcome, + ); + } + if knob_positions { + backfill_collection( + &mut store.knob_positions, + &mut seen, + &mut reserved, + &mut outcome, + ); + } outcome } @@ -226,6 +258,108 @@ pub(crate) fn rekey_mode_element_ids_for_collections( } } +fn backfill_collection_mode( + collection: &mut HashMap>, + mode: &str, + seen: &mut HashSet, + reserved: &mut HashSet, + outcome: &mut BackfillOutcome, +) { + let Some(elements) = collection.get_mut(mode) else { + return; + }; + 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; + } +} + +// 한 모드의 선택된 컬렉션만 채운다. 대상 밖 요소의 id를 seen에 먼저 담아 +// 대상 안의 중복도 복구되고 새 id가 문서 전체에서 유일하도록 유지한다 +pub(crate) fn backfill_mode_element_ids_for_collections( + store: &mut AppStoreData, + mode: &str, + key_positions: bool, + stat_positions: bool, + graph_positions: bool, + knob_positions: bool, +) -> BackfillOutcome { + let mut seen = HashSet::new(); + collect_collection_ids_outside_target(&store.key_positions, mode, key_positions, &mut seen); + collect_collection_ids_outside_target(&store.stat_positions, mode, stat_positions, &mut seen); + collect_collection_ids_outside_target(&store.graph_positions, mode, graph_positions, &mut seen); + collect_collection_ids_outside_target(&store.knob_positions, mode, knob_positions, &mut seen); + let mut reserved = collect_store_ids(store); + let mut outcome = BackfillOutcome::default(); + if key_positions { + backfill_collection_mode( + &mut store.key_positions, + mode, + &mut seen, + &mut reserved, + &mut outcome, + ); + } + if stat_positions { + backfill_collection_mode( + &mut store.stat_positions, + mode, + &mut seen, + &mut reserved, + &mut outcome, + ); + } + if graph_positions { + backfill_collection_mode( + &mut store.graph_positions, + mode, + &mut seen, + &mut reserved, + &mut outcome, + ); + } + if knob_positions { + backfill_collection_mode( + &mut store.knob_positions, + mode, + &mut seen, + &mut reserved, + &mut outcome, + ); + } + outcome +} + +// 백필 대상(선택된 컬렉션 × 대상 모드) 밖의 id만 모은다 +fn collect_collection_ids_outside_target( + collection: &HashMap>, + target_mode: &str, + targeted: bool, + ids: &mut HashSet, +) { + for (mode, elements) in collection { + if targeted && mode == target_mode { + continue; + } + for element in elements { + if is_valid_element_id(&element.position().id) { + ids.insert(element.position().id.clone()); + } + } + } +} + fn validate_supplied_collection_ids( collection: &HashMap>, require_id: bool, @@ -283,6 +417,99 @@ fn validate_supplied_patch_ids( Ok(seen) } +// v1은 id를 모르는 입력을 계속 받는 계약이라, 형식이 유효한데 소유가 어긋나거나 +// (다른 컬렉션·삭제된 신원) 패치 안에서 중복된 id는 거부 대신 비워서 뒤의 승계· +// 신규 발급 경로로 넘긴다. 중복은 canonical 자리를 원본으로 보고 사본만 비운다 +fn sanitize_v1_supplied_collection_ids( + candidate: &mut HashMap>, + current: &HashMap>, +) -> Result<(), EditorCommitError> { + let mut owned = HashMap::new(); + for mode in sorted_modes(current) { + let Some(elements) = current.get(&mode) else { + continue; + }; + for (index, element) in elements.iter().enumerate() { + let id = &element.position().id; + if !id.is_empty() { + owned.insert(id.clone(), (mode.clone(), index)); + } + } + } + + let mut occurrences: HashMap> = HashMap::new(); + for mode in sorted_modes(candidate) { + let Some(elements) = candidate.get(&mode) else { + continue; + }; + for (index, element) in elements.iter().enumerate() { + let id = &element.position().id; + if id.is_empty() { + continue; + } + if !is_valid_element_id(id) { + return Err(EditorCommitError::validation( + INVALID_ELEMENT_ID, + format!("native element {mode}[{index}] has an invalid ID"), + )); + } + occurrences + .entry(id.clone()) + .or_default() + .push((mode.clone(), index)); + } + } + + let mut cleared = HashSet::new(); + for (id, slots) in &occurrences { + let Some(canonical_slot) = owned.get(id) else { + cleared.extend(slots.iter().cloned()); + continue; + }; + if slots.len() == 1 { + continue; + } + let keeper = slots + .iter() + .find(|slot| *slot == canonical_slot) + .unwrap_or(&slots[0]); + for slot in slots { + if slot != keeper { + cleared.insert(slot.clone()); + } + } + } + + for (mode, index) in cleared { + if let Some(element) = candidate + .get_mut(&mode) + .and_then(|elements| elements.get_mut(index)) + { + element.position_mut().id.clear(); + } + } + Ok(()) +} + +fn sanitize_v1_supplied_patch_ids( + store: &AppStoreData, + patch: &mut EditorPatchV1, +) -> Result<(), EditorCommitError> { + if let Some(collection) = patch.key_positions.as_mut() { + sanitize_v1_supplied_collection_ids(collection, &store.key_positions)?; + } + if let Some(collection) = patch.stat_positions.as_mut() { + sanitize_v1_supplied_collection_ids(collection, &store.stat_positions)?; + } + if let Some(collection) = patch.graph_positions.as_mut() { + sanitize_v1_supplied_collection_ids(collection, &store.graph_positions)?; + } + if let Some(collection) = patch.knob_positions.as_mut() { + sanitize_v1_supplied_collection_ids(collection, &store.knob_positions)?; + } + Ok(()) +} + fn same_value_without_id(left: &T, right: &T) -> bool { let mut left = left.clone(); let mut right = right.clone(); @@ -325,6 +552,38 @@ fn keep_or_rekey_supplied_ids( } } +// 같은 모드 안에서 빈 ID를 값으로 승계한다. 모드를 넘는 승계는 무관한 모드의 +// 신원을 빼앗으므로 이 패스에서 제외한다 +fn inherit_ids_by_value_within_mode( + current: &HashMap>, + candidate: &mut HashMap>, + consumed_current_ids: &mut HashSet, +) { + for mode in sorted_modes(candidate) { + let Some(elements) = candidate.get_mut(&mode) else { + continue; + }; + let Some(current_elements) = current.get(&mode) else { + continue; + }; + 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; + } + } + } +} + fn inherit_ids_by_value( current_elements: &[T], candidate: &mut HashMap>, @@ -355,6 +614,40 @@ fn inherit_ids_by_value( } } +// keys를 동반하지 않는 keyPositions 패치에서만 쓴다. 그 경우 candidate는 +// validate_paired_update가 형태 불변을 강제하므로 같은 길이 = 같은 슬롯 집합의 +// 제자리 편집이고, index가 곧 신원이다(master 의미론). 형태 제약이 없는 +// stat/graph/knob에 쓰면 같은 길이의 삭제+추가에서 새 요소가 삭제된 요소의 +// 신원을 물려받는다 +fn inherit_ids_by_index( + current: &HashMap>, + candidate: &mut HashMap>, + consumed_current_ids: &mut HashSet, +) { + for mode in sorted_modes(candidate) { + let Some(elements) = candidate.get_mut(&mode) else { + continue; + }; + let Some(current_elements) = current.get(&mode) else { + continue; + }; + if current_elements.len() != elements.len() { + continue; + } + for (element, current_element) in elements.iter_mut().zip(current_elements) { + if !element.id.is_empty() { + continue; + } + let id = current_element.id.clone(); + if id.is_empty() || consumed_current_ids.contains(&id) { + continue; + } + consumed_current_ids.insert(id.clone()); + element.id = id; + } + } +} + fn adapt_v1_collection( current: &HashMap>, candidate: &mut HashMap>, @@ -363,6 +656,9 @@ fn adapt_v1_collection( reserved: &mut HashSet, ) { keep_or_rekey_supplied_ids(candidate, canonical_ids, consumed_current_ids, reserved); + // 승계 순서: 같은 모드 값 → 전역 값 폴백. index 승계는 형태가 고정된 + // keyPositions 단독 패치 전용이라 여기서는 쓰지 않는다 + inherit_ids_by_value_within_mode(current, candidate, consumed_current_ids); inherit_ids_by_value( &ordered_current_elements(current), candidate, @@ -373,6 +669,7 @@ fn adapt_v1_collection( struct SlotPairedPosition { mode: String, + index: usize, slot: Option, position: KeyPosition, } @@ -390,6 +687,7 @@ fn slot_paired_current_positions( for (index, element) in elements.iter().enumerate() { pairs.push(SlotPairedPosition { mode: mode.clone(), + index, slot: slots.and_then(|slots| slots.get(index)).cloned(), position: element.clone(), }); @@ -438,6 +736,42 @@ fn consume_slot_paired_ids( } } +// 값을 무시하고 같은 모드·같은 index의 슬롯 일치만으로 승계한다. 값 일치를 +// 요구하는 앞 웨이브들이 모두 실패하는 경우 - 슬롯은 그대로인데 위치 값만 +// 바뀐 이동 - 를 담당한다. index를 고정해 중복 슬롯끼리 교차 승계하지 않는다 +fn consume_slot_only_ids( + candidate: &mut HashMap>, + patch_keys: &KeyMappings, + current_pairs: &[SlotPairedPosition], + consumed_current_ids: &mut HashSet, +) { + for mode in sorted_modes(candidate) { + let Some(elements) = candidate.get_mut(&mode) else { + continue; + }; + let slots = patch_keys.get(&mode); + for (index, element) in elements.iter_mut().enumerate() { + if !element.id.is_empty() { + continue; + } + let Some(slot) = slots.and_then(|slots| slots.get(index)) else { + continue; + }; + let inherited = current_pairs.iter().find(|pair| { + pair.mode == mode + && pair.index == index + && pair.slot.as_ref() == Some(slot) + && !consumed_current_ids.contains(&pair.position.id) + }); + if let Some(pair) = inherited { + let id = pair.position.id.clone(); + consumed_current_ids.insert(id.clone()); + element.id = id; + } + } + } +} + // v1 paired patch는 keys[i]-keyPositions[i] 결합이 신원 단서다. 값이 같은 // 위치가 여럿일 때 값만으로 승계하면 재정렬에서 ID가 다른 키 슬롯에 붙으므로 // 같은 모드의 (슬롯, 값) 정확 일치부터 소진하고, 모드 이동·재바인딩은 @@ -451,10 +785,14 @@ fn adapt_v1_key_position_ids( reserved: &mut HashSet, ) { let Some(patch_keys) = patch_keys else { - adapt_v1_collection( - &store.key_positions, + // keys 미동반 패치는 형태가 고정된다 - index가 신원이므로 값 승계보다 + // 먼저 제자리 id를 확정해 값 편집이 신원 회전으로 번지지 않게 한다 + keep_or_rekey_supplied_ids(candidate, canonical_ids, consumed_current_ids, reserved); + inherit_ids_by_index(&store.key_positions, candidate, consumed_current_ids); + inherit_ids_by_value_within_mode(&store.key_positions, candidate, consumed_current_ids); + inherit_ids_by_value( + &ordered_current_elements(&store.key_positions), candidate, - canonical_ids, consumed_current_ids, reserved, ); @@ -465,8 +803,12 @@ fn adapt_v1_key_position_ids( 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)] { + // 같은 모드 index 슬롯(값 변경 이동) → 같은 모드 값(재바인딩) → + // 마지막 전역 값 폴백과 신규 발급. + // 슬롯 고정이 우연한 값 일치보다 강한 신원 단서라 값 웨이브보다 앞선다 - + // 그래야 두 키의 좌표를 맞바꾸는 패치가 keys 동반 여부와 무관하게 같은 + // 결과(제자리 값 편집)를 낸다 + for (match_slot, same_mode_only) in [(true, true), (true, false)] { consume_slot_paired_ids( candidate, patch_keys, @@ -476,7 +818,17 @@ fn adapt_v1_key_position_ids( same_mode_only, ); } + consume_slot_only_ids(candidate, patch_keys, ¤t_pairs, consumed_current_ids); + consume_slot_paired_ids( + candidate, + patch_keys, + ¤t_pairs, + consumed_current_ids, + false, + true, + ); + inherit_ids_by_value_within_mode(&store.key_positions, candidate, consumed_current_ids); inherit_ids_by_value( &ordered_current_elements(&store.key_positions), candidate, @@ -489,6 +841,9 @@ fn adapt_v1_patch_ids( store: &AppStoreData, patch: &mut EditorPatchV1, ) -> Result<(), EditorCommitError> { + sanitize_v1_supplied_patch_ids(store, patch)?; + // 정화 후에는 남은 id가 모두 자기 컬렉션 소유이자 유일 - 이 호출은 수집과 + // 불변식 확인을 겸한다 let supplied_ids = validate_supplied_patch_ids(patch, false)?; let canonical_ids = collect_store_ids(store); let mut consumed_current_ids = supplied_ids @@ -634,6 +989,13 @@ mod tests { } } + fn stat_position(stat_type: StatType, dx: f64) -> StatPosition { + StatPosition { + stat_type, + position: position(dx), + } + } + fn store_with_all_collections() -> AppStoreData { let mut store = AppStoreData { key_positions: HashMap::from([( @@ -917,6 +1279,164 @@ mod tests { assert_ne!(positions[2].id, second_id); } + #[test] + fn v1_idless_positions_only_value_change_keeps_ids_by_index() { + let store = store_with_all_collections(); + let original = store.key_positions["mode"] + .iter() + .map(|position| position.id.clone()) + .collect::>(); + let mut patch = EditorPatchV1 { + key_positions: Some(HashMap::from([( + "mode".to_string(), + vec![position(50.0), position(2.0)], + )])), + ..EditorPatchV1::default() + }; + + prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); + + // 같은 길이 positions 단독 패치는 index가 신원 - 값이 바뀐 요소도 ID를 + // 유지해 id 순서가 보존되고 paired id-order 검사와 충돌하지 않는다 + let positions = &patch.key_positions.unwrap()["mode"]; + assert_eq!(positions[0].id, original[0]); + assert_eq!(positions[1].id, original[1]); + } + + #[test] + fn v1_idless_positions_only_swap_is_treated_as_in_place_edits() { + let store = store_with_all_collections(); + let original = store.key_positions["mode"] + .iter() + .map(|position| position.id.clone()) + .collect::>(); + let mut patch = EditorPatchV1 { + key_positions: Some(HashMap::from([( + "mode".to_string(), + vec![position(2.0), position(1.0)], + )])), + ..EditorPatchV1::default() + }; + + prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); + + // 같은 길이 값 스왑은 두 건의 제자리 값 편집으로 해석 - keys 미동반 + // 패치가 신원 재배열로 번지지 않는다 + let positions = &patch.key_positions.unwrap()["mode"]; + assert_eq!(positions[0].id, original[0]); + assert_eq!(positions[1].id, original[1]); + } + + #[test] + fn v1_idless_equal_length_delete_and_append_does_not_reuse_the_deleted_id() { + let mut store = AppStoreData { + stat_positions: HashMap::from([( + "mode".to_string(), + vec![ + stat_position(StatType::Kps, 1.0), + stat_position(StatType::Kps, 2.0), + stat_position(StatType::Kps, 3.0), + ], + )]), + ..AppStoreData::default() + }; + rekey_store_element_ids(&mut store); + let ids = store.stat_positions["mode"] + .iter() + .map(|element| element.position.id.clone()) + .collect::>(); + // 가운데를 지우고 새 요소를 덧붙인다 - 길이는 그대로다 + let mut patch = EditorPatchV1 { + stat_positions: Some(HashMap::from([( + "mode".to_string(), + vec![ + stat_position(StatType::Kps, 1.0), + stat_position(StatType::Kps, 3.0), + stat_position(StatType::Kps, 9.0), + ], + )])), + ..EditorPatchV1::default() + }; + + prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); + + // 살아남은 요소는 값으로 자기 신원을 지키고, 신규 요소는 삭제된 + // 요소의 신원을 물려받지 않는다 + let positions = &patch.stat_positions.unwrap()["mode"]; + assert_eq!(positions[0].position.id, ids[0]); + assert_eq!(positions[1].position.id, ids[2]); + assert!(!ids.contains(&positions[2].position.id)); + assert!(is_valid_element_id(&positions[2].position.id)); + } + + #[test] + fn v1_paired_value_swap_keeps_ids_with_their_slots() { + let store = keyed_store( + vec![KeySlot::from("A"), KeySlot::from("B")], + vec![position(1.0), position(2.0)], + ); + let id_a = store.key_positions["mode"][0].id.clone(); + let id_b = store.key_positions["mode"][1].id.clone(); + let mut patch = paired_patch( + vec![KeySlot::from("A"), KeySlot::from("B")], + vec![position(2.0), position(1.0)], + ); + + prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); + + // 슬롯이 그대로면 좌표 맞바꿈은 제자리 값 편집이다 - keys 미동반 + // 패치와 같은 결과를 낸다 + let positions = &patch.key_positions.unwrap()["mode"]; + assert_eq!(positions[0].id, id_a); + assert_eq!(positions[1].id, id_b); + } + + #[test] + fn v1_idless_patch_prefers_same_mode_and_never_steals_across_modes() { + let mut store = AppStoreData { + stat_positions: HashMap::from([ + ("modeA".to_string(), Vec::new()), + ( + "modeB".to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: position(10.0), + }], + ), + ]), + ..AppStoreData::default() + }; + rekey_store_element_ids(&mut store); + let id_b = store.stat_positions["modeB"][0].position.id.clone(); + let mut patch = EditorPatchV1 { + stat_positions: Some(HashMap::from([ + ( + "modeA".to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: position(10.0), + }], + ), + ( + "modeB".to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: position(10.0), + }], + ), + ])), + ..EditorPatchV1::default() + }; + + prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); + + // 레이아웃을 다른 모드로 복사해도 원본 모드가 자기 ID를 지킨다 + let positions = patch.stat_positions.unwrap(); + assert_eq!(positions["modeB"][0].position.id, id_b); + assert_ne!(positions["modeA"][0].position.id, id_b); + assert!(is_valid_element_id(&positions["modeA"][0].position.id)); + } + fn keyed_store(slots: Vec, positions: Vec) -> AppStoreData { let mut store = AppStoreData { keys: HashMap::from([("mode".to_string(), slots)]), @@ -996,6 +1516,49 @@ mod tests { assert_eq!(positions[0].id, id_b); } + #[test] + fn v1_paired_slot_keeps_id_when_only_the_position_value_changes() { + let store = keyed_store( + vec![KeySlot::from("A"), KeySlot::from("B")], + vec![position(1.0), position(2.0)], + ); + let id_a = store.key_positions["mode"][0].id.clone(); + let id_b = store.key_positions["mode"][1].id.clone(); + let mut patch = paired_patch( + vec![KeySlot::from("A"), KeySlot::from("B")], + vec![position(50.0), position(2.0)], + ); + + prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); + + // 슬롯이 그대로면 위치 값이 바뀌어도 신원이 유지된다 - 이동은 신원 + // 회전이 아니다 + let positions = &patch.key_positions.unwrap()["mode"]; + assert_eq!(positions[0].id, id_a); + assert_eq!(positions[1].id, id_b); + } + + #[test] + fn v1_paired_duplicate_slots_keep_their_own_ids_by_index() { + let store = keyed_store( + vec![KeySlot::from("A"), KeySlot::from("A")], + vec![position(1.0), position(2.0)], + ); + let first = store.key_positions["mode"][0].id.clone(); + let second = store.key_positions["mode"][1].id.clone(); + let mut patch = paired_patch( + vec![KeySlot::from("A"), KeySlot::from("A")], + vec![position(10.0), position(20.0)], + ); + + prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); + + // 슬롯이 겹쳐도 index 정렬로 각자 자기 ID를 지킨다 + let positions = &patch.key_positions.unwrap()["mode"]; + assert_eq!(positions[0].id, first); + assert_eq!(positions[1].id, second); + } + #[test] fn v1_paired_rebind_never_steals_ids_from_other_modes() { let mut store = AppStoreData { @@ -1080,7 +1643,7 @@ mod tests { } #[test] - fn v1_rejects_nil_non_uuid_and_duplicate_supplied_ids() { + fn v1_rejects_nil_and_non_uuid_supplied_ids() { let store = store_with_all_collections(); for invalid_id in [Uuid::nil().to_string(), "not-a-uuid".to_string()] { let mut positions = store.key_positions.clone(); @@ -1094,17 +1657,46 @@ mod tests { INVALID_ELEMENT_ID ); } + } + #[test] + fn v1_duplicating_a_read_element_rekeys_the_copy_instead_of_failing() { + let store = store_with_all_collections(); + let original = store.key_positions["mode"][0].id.clone(); + // 플러그인의 관용 패턴: 읽어온 위치 객체를 펼쳐 복사해 배열에 덧붙인다 let mut positions = store.key_positions.clone(); - let duplicate = positions["mode"][0].id.clone(); - positions.get_mut("mode").unwrap()[1].id = duplicate; + let mut copy = positions["mode"][0].clone(); + copy.dx = 300.0; + positions.get_mut("mode").unwrap().push(copy); let mut patch = EditorPatchV1 { key_positions: Some(positions), ..EditorPatchV1::default() }; - assert_eq!( - validation_code(prepare_commit_patch_element_ids(&store, &mut patch).unwrap_err()), - DUPLICATE_ELEMENT_ID - ); + + prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); + + let positions = &patch.key_positions.unwrap()["mode"]; + assert_eq!(positions[0].id, original); + assert_ne!(positions[2].id, original); + assert!(is_valid_element_id(&positions[2].id)); + } + + #[test] + fn v1_supplied_id_from_another_collection_is_rekeyed() { + let store = store_with_all_collections(); + let stat_id = store.stat_positions["mode"][0].position.id.clone(); + let mut positions = store.key_positions.clone(); + positions.get_mut("mode").unwrap()[0].id = stat_id.clone(); + let mut patch = EditorPatchV1 { + key_positions: Some(positions), + ..EditorPatchV1::default() + }; + + prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); + + // 다른 컬렉션의 신원을 키 위치로 이식할 수 없다 + let positions = &patch.key_positions.unwrap()["mode"]; + assert_ne!(positions[0].id, stat_id); + assert!(is_valid_element_id(&positions[0].id)); } } diff --git a/src-tauri/src/state/store.rs b/src-tauri/src/state/store.rs index 2924713e..75d4a80b 100644 --- a/src-tauri/src/state/store.rs +++ b/src-tauri/src/state/store.rs @@ -27,8 +27,9 @@ use super::builtin_sounds::seed_builtin_sounds; use super::editor::{ canonical_request_fingerprint, next_revision, repair_selected_mode, request_fingerprint, request_payload_size, sync_key_counters, touched_pair, validate_document_transition, - validate_history_restore_metadata, validate_paired_update, validate_request_envelope, - RequestFingerprint, MUTATION_ACK_CAPACITY, + validate_document_transition_with_keying, validate_history_restore_metadata, + validate_paired_update, validate_request_envelope, GrandfatherKeying, RequestFingerprint, + MUTATION_ACK_CAPACITY, }; use super::gesture::validate_gesture_commit_request; use super::history::{ @@ -1426,7 +1427,14 @@ impl AppStore { let candidate = EditorDocumentV1::from_store(&scratch); validate_paired_update(¤t, &candidate, true, true)?; scratch.editor_revision = current_store.editor_revision; - validate_document_transition(¤t, &candidate, ¤t_store, &scratch)?; + // 프리셋 스냅샷은 현재 store와 id 세대가 달라 ID 짝짓기가 성립하지 않는다 + validate_document_transition_with_keying( + ¤t, + &candidate, + ¤t_store, + &scratch, + GrandfatherKeying::ByModeIndex, + )?; let changed_fields = current.changed_fields(&candidate); let revision = if changed_fields.is_empty() { @@ -1731,6 +1739,16 @@ impl AppStore { } let value = updater(&mut scratch)?; crate::state::migration::canonicalize_gradient_pairs(&mut scratch); + // 레거시 경로는 호출부가 넘긴 전체 레코드를 그대로 대입한다 - 선언된 + // 컬렉션에 한해 빈·중복·형식 오류 id를 채워 canonical 오염을 막는다 + // (선언 밖 컬렉션을 건드리면 UNDECLARED_EDITOR_FIELD가 된다) + crate::state::native_element_id::backfill_element_ids_for_collections( + &mut scratch, + touched_fields.contains(&EditorField::KeyPositions), + touched_fields.contains(&EditorField::StatPositions), + touched_fields.contains(&EditorField::GraphPositions), + touched_fields.contains(&EditorField::KnobPositions), + ); // editorRevision은 이 트랜잭션만 관리 scratch.editor_revision = current_store.editor_revision; @@ -1751,7 +1769,20 @@ impl AppStore { let (keys_touched, key_positions_touched) = touched_pair(touched_fields); validate_paired_update(¤t, &candidate, keys_touched, key_positions_touched)?; - validate_document_transition(¤t, &candidate, ¤t_store, &scratch)?; + // 프리셋 로드는 커밋 직전 모든 요소의 id를 재발급하므로 ID로는 관용 + // 상대를 찾을 수 없다 - 그 트랜잭션만 (모드, index) 짝짓기를 쓴다 + let keying = if history_options.scope == Some(HistoryScope::PresetFull) { + GrandfatherKeying::ByModeIndex + } else { + GrandfatherKeying::ById + }; + validate_document_transition_with_keying( + ¤t, + &candidate, + ¤t_store, + &scratch, + keying, + )?; if changed_fields.contains(&EditorField::Keys) { sync_key_counters(&mut scratch.key_counters, &candidate.keys); @@ -8211,6 +8242,52 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[test] + fn legacy_full_record_write_backfills_element_ids_before_they_reach_canonical() { + let dir = test_directory("editor-legacy-id-backfill-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let mode = store.snapshot().selected_key_type; + + // 레거시 전체 레코드 커맨드는 호출부가 준 요소를 그대로 대입한다 - + // id 없는 stat이 canonical에 남으면 이후 v2 커밋이 전부 막힌다 + store + .commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("stat_positions_update".to_string()), + &[EditorField::StatPositions], + |data| { + data.stat_positions.insert( + mode.clone(), + vec![StatPosition { + stat_type: StatType::Kps, + position: KeyPosition::default(), + }], + ); + Ok(()) + }, + ) + .unwrap(); + + let snapshot = store.snapshot(); + let stored_id = snapshot.stat_positions[&mode][0].position.id.clone(); + assert!(crate::state::native_element_id::is_valid_element_id( + &stored_id + )); + + // 오염이 없으니 후속 v2 커밋이 통과한다 + store + .commit_editor_document(editor_request( + store.snapshot().editor_revision, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, 42.0), + )) + .unwrap(); + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn strict_editor_commit_preserves_existing_ghost_modes_losslessly() { let dir = test_directory("editor-grandfather-test"); @@ -8391,7 +8468,18 @@ mod tests { let positions = change.document.key_positions; assert_eq!(store.writer.persist_count(), persist_count + 1); assert_eq!(keys["4key"].last().unwrap(), &KeySlot::from("F5")); - assert_eq!(positions["4key"].last().unwrap(), &KeyPosition::default()); + // 덧붙은 위치는 기본값 그대로이되 id만 경계에서 채워진다 + let appended = positions["4key"].last().unwrap(); + assert!(crate::state::native_element_id::is_valid_element_id( + &appended.id + )); + assert_eq!( + &KeyPosition { + id: String::new(), + ..appended.clone() + }, + &KeyPosition::default() + ); assert!(keys["5key"].last().unwrap().is_unassigned()); assert_eq!(keys["4key"].len(), positions["4key"].len()); assert_eq!(keys["5key"].len(), positions["5key"].len()); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx index 51a22d8f..82fce44e 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx @@ -239,7 +239,11 @@ const LayerTabContent: React.FC = ({ statPositions: useStatItemStore.getState().positions, graphPositions: useGraphItemStore.getState().positions, knobPositions: useKnobItemStore.getState().positions, - pluginElements: usePluginDisplayElementStore.getState().elements, + // 렌더 목록과 같은 창 인지 selector - 분리 패널은 elements가 비어 있어 + // raw로 읽으면 드롭 모델에서 plugin 행이 통째로 빠진다 + pluginElements: selectPropertyPanelPluginElements( + usePluginDisplayElementStore.getState(), + ), }); const groupState = useLayerGroupStore.getState(); const liveDisplayItems = buildDisplayItems({ 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 96855bc0..da9c1fc7 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 @@ -17,12 +17,14 @@ const mocks = vi.hoisted(() => ({ })), captureBaseline: vi.fn(() => null), reportElementOpError: vi.fn(), + reportElementOpSkipped: vi.fn(), setPluginZIndexes: vi.fn(), commitPatch: vi.fn(() => Promise.resolve()), setKeyPositions: vi.fn(), setLayerGroups: vi.fn(), selectedElements: [] as Array<{ id: string }>, selectedGroupIds: [] as string[], + pluginElements: [] as Array<{ fullId: string; zIndex: number }>, })); vi.mock('@src/renderer/editor/runtime/elementIntent', () => ({ @@ -34,7 +36,7 @@ vi.mock('@src/renderer/editor/runtime/elementIntent', () => ({ intentPatch: (patch: unknown) => patch === null ? { kind: 'targetLost' } : { kind: 'patch', patch }, reportElementOpError: mocks.reportElementOpError, - reportElementOpSkipped: vi.fn(), + reportElementOpSkipped: mocks.reportElementOpSkipped, })); vi.mock('@plugins/rpc/pluginElementActions', () => ({ @@ -48,6 +50,15 @@ vi.mock('@src/renderer/editor/runtime/editorStateCoordinator', () => ({ }, })); +vi.mock('@stores/plugin/usePluginDisplayElementStore', () => ({ + usePluginDisplayElementStore: { + getState: () => ({ elements: mocks.pluginElements }), + }, + selectPropertyPanelPluginElements: (state: { + elements: Array<{ fullId: string; zIndex: number }>; + }) => state.elements, +})); + vi.mock('@stores/data/useKeyStore', () => ({ useKeyStore: { getState: () => ({ @@ -217,6 +228,8 @@ describe('useLayerDnD 커밋 경로 라우팅', () => { mocks.setLayerGroups.mockClear(); mocks.selectedElements = []; mocks.selectedGroupIds = []; + mocks.pluginElements = []; + mocks.reportElementOpSkipped.mockClear(); }); afterEach(async () => { @@ -283,6 +296,106 @@ describe('useLayerDnD 커밋 경로 라우팅', () => { expect(byId.get(ID_A)).toMatchObject({ zIndex: 1 }); }); + it('eager 게이트가 닫히면 plugin z도 쓰지 않는다', async () => { + mocks.reportElementOpSkipped.mockClear(); + mocks.applyGestureEagerly.mockImplementationOnce(() => ({ + matched: false, + receipt: null, + })); + const startItems = [nativeItem(ID_A, 0, 2), nativeItem(ID_B, 1, 1)]; + const liveItems = [...startItems, pluginItem('plugin-x:one', 0)]; + await dragItemToEnd(startItems, { + layerItems: liveItems, + displayItems: toDisplay(liveItems), + }); + + // native가 하나도 적용되지 않았으므로 plugin만 옮겨진 반쪽 순서가 + // 영속되면 안 된다 + expect(mocks.setPluginZIndexes).not.toHaveBeenCalled(); + expect(mocks.runElementIntent).not.toHaveBeenCalled(); + expect(mocks.reportElementOpSkipped).toHaveBeenCalledTimes(1); + }); + + it('native receipt가 되돌아가면 plugin z도 이전 값으로 되돌린다', async () => { + // 러너 계약: 편입 전 실패는 receipt를 되돌리고 onRolledBack을 부른다 + mocks.runElementIntent.mockImplementation((options: unknown) => { + const runner = options as { + applyEager: () => unknown; + onRolledBack?: () => void; + }; + runner.applyEager(); + runner.onRolledBack?.(); + return Promise.reject(new Error('start failed')); + }); + const startItems = [nativeItem(ID_A, 0, 2), nativeItem(ID_B, 1, 1)]; + const liveItems = [...startItems, pluginItem('plugin-x:one', 0)]; + // 우리가 쓴 값이 그대로 남아 있는 상태 (CAS 통과) + mocks.pluginElements = [{ fullId: 'plugin-x:one', zIndex: 0 }]; + mocks.setPluginZIndexes.mockImplementation( + (updates: Array<{ fullId: string; zIndex: number }>) => { + mocks.pluginElements = updates.map((update) => ({ ...update })); + }, + ); + await dragItemToEnd(startItems, { + layerItems: liveItems, + displayItems: toDisplay(liveItems), + }); + await act(async () => { + await Promise.resolve(); + }); + mocks.setPluginZIndexes.mockImplementation(() => {}); + + expect(mocks.setPluginZIndexes).toHaveBeenCalledTimes(2); + expect(mocks.setPluginZIndexes.mock.calls[1][0]).toEqual([ + { fullId: 'plugin-x:one', zIndex: 0 }, + ]); + }); + + it('편입 후 실패는 plugin z를 되돌리지 않는다', async () => { + // 편입 후 transient 실패는 pendingLocal 재시도가 소유한다 - 러너도 + // native receipt를 되돌리지 않으므로 plugin도 그대로 둔다 + mocks.runElementIntent.mockImplementation((options: unknown) => { + (options as { applyEager: () => unknown }).applyEager(); + return Promise.reject(new Error('transient after enrollment')); + }); + const startItems = [nativeItem(ID_A, 0, 2), nativeItem(ID_B, 1, 1)]; + const liveItems = [...startItems, pluginItem('plugin-x:one', 0)]; + await dragItemToEnd(startItems, { + layerItems: liveItems, + displayItems: toDisplay(liveItems), + }); + await act(async () => { + await Promise.resolve(); + }); + + expect(mocks.setPluginZIndexes).toHaveBeenCalledTimes(1); + }); + + it('다른 writer가 z를 바꿨으면 복원하지 않는다', async () => { + mocks.runElementIntent.mockImplementation((options: unknown) => { + const runner = options as { + applyEager: () => unknown; + onRolledBack?: () => void; + }; + runner.applyEager(); + runner.onRolledBack?.(); + return Promise.reject(new Error('start failed')); + }); + const startItems = [nativeItem(ID_A, 0, 2), nativeItem(ID_B, 1, 1)]; + const liveItems = [...startItems, pluginItem('plugin-x:one', 0)]; + // 복원 시점에 우리가 쓴 값이 아니다 - 그쪽 소유이므로 건드리지 않는다 + mocks.pluginElements = [{ fullId: 'plugin-x:one', zIndex: 99 }]; + await dragItemToEnd(startItems, { + layerItems: liveItems, + displayItems: toDisplay(liveItems), + }); + await act(async () => { + await Promise.resolve(); + }); + + expect(mocks.setPluginZIndexes).toHaveBeenCalledTimes(1); + }); + it('native 전용 편입 전 실패는 runner가 소유하고 layerGroups는 eager를 건드리지 않는다', async () => { mocks.runElementIntent.mockImplementation((options: unknown) => { // 러너 계약 재현: eager 적용 후 편입 전 실패 @@ -422,6 +535,46 @@ describe('useLayerDnD 커밋 경로 라우팅', () => { expect(byId.get(ID_B)).toMatchObject({ zIndex: 0 }); }); + it('접힌 그룹 헤더 하단 드롭은 그룹 편입 의도를 유지한다', async () => { + const child = nativeItem(ID_M1, 0, 3, 'G'); + const outsider = nativeItem(ID_X, 1, 2); + // 드롭 자리 다음에 그룹 밖 행이 있어야 재유도가 undefined를 낸다 - + // 드래그 항목이 마지막이면 두 경로가 우연히 같은 값을 낸다 + const tail = nativeItem(ID_Y, 2, 1); + const collapsedHeader: DisplayItem = { + displayType: 'group-header', + groupId: 'G', + groupName: 'G', + isCollapsed: true, + childCount: 1, + allHidden: false, + }; + // 접힌 그룹은 자식 행을 렌더하지 않는다 - 헤더 다음은 무그룹 레이어다 + const display: DisplayItem[] = [ + collapsedHeader, + { displayType: 'layer', item: outsider, groupDepth: 0, flatIndex: 1 }, + { displayType: 'layer', item: tail, groupDepth: 0, flatIndex: 2 }, + ]; + await renderDnD({ + layerItems: [child, outsider, tail], + displayItems: display, + liveModel: { + layerItems: [child, outsider, tail], + displayItems: display, + }, + }); + + // 헤더 행(높이 24)의 하단 절반에 드롭 + await finishDrag( + () => api.handleMouseDown(mouseDownEvent(), outsider, 1), + 18, + ); + + expect(mocks.applyGestureEagerly).toHaveBeenCalledTimes(1); + // 재유도가 헤더의 명시 의도를 덮으면 groupId가 undefined로 커밋된다 + expect(eagerIntents().get(ID_X)).toMatchObject({ groupId: 'G' }); + }); + it('그룹+추가 선택 드래그는 mouseup 시점 live 구성원 전체를 함께 옮긴다', async () => { const itemA = nativeItem(ID_A, 0, 3, 'G'); const itemX = nativeItem(ID_X, 1, 2); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.ts b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.ts index 6c3f1b7b..e4dade38 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.ts @@ -17,6 +17,10 @@ import { } from '@src/renderer/editor/runtime/elementIntent'; import { applyEditorPatch } from '@src/renderer/editor/runtime/editorCoordinator'; import { setPluginElementZIndexes } from '@plugins/rpc/pluginElementActions'; +import { + selectPropertyPanelPluginElements, + usePluginDisplayElementStore, +} from '@stores/plugin/usePluginDisplayElementStore'; import { useState, useRef } from 'react'; import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; import { normalizeLayerGroupsForMode } from '@utils/layerGroupUtils'; @@ -428,7 +432,12 @@ export function useLayerDnD({ 'key' | 'stat' | 'graph' | 'knob', Map> >; - pluginZIndexUpdates: Array<{ fullId: string; zIndex: number }>; + // beforeZIndex는 편입 후 실패 시 CAS 복원용 + pluginZIndexUpdates: Array<{ + fullId: string; + zIndex: number; + beforeZIndex: number; + }>; } // 새 표시 순서를 의도 집합으로 변환 - 안정 id는 id 의도, 합성은 시작 @@ -446,7 +455,11 @@ export function useLayerDnD({ newItems.forEach((item, idx) => { const newZIndex = maxZIndex - idx; if (item.type === 'plugin') { - sets.pluginZIndexUpdates.push({ fullId: item.id, zIndex: newZIndex }); + sets.pluginZIndexUpdates.push({ + fullId: item.id, + zIndex: newZIndex, + beforeZIndex: item.zIndex, + }); return; } const intent: Record = { @@ -470,13 +483,18 @@ export function useLayerDnD({ // plugin-only는 editor 무커밋, wire patch는 슬롯 base에서 재생성한다 - // 호출 시점 full-record는 대기 중 정산된 격리 plugin 쓰기를 되돌린다. // plugin z-index는 별도 authority 쓰기로 editor 커밋과 비원자(기존 의미론) + // 렌더 목록과 같은 창 인지 selector - 분리 패널은 panelElements만 채운다 + const pluginElementsForZ = () => + selectPropertyPanelPluginElements(usePluginDisplayElementStore.getState()); + const commitDropIntents = (sets: DropIntentSets, skipContext: string) => { - if (sets.pluginZIndexUpdates.length > 0) { - setPluginElementZIndexes(sets.pluginZIndexUpdates); - } + const hasPluginZ = sets.pluginZIndexUpdates.length > 0; const hasNativeIntent = sets.nativeIntents.size > 0 || sets.syntheticIndexIntents.size > 0; - if (!hasNativeIntent) return; + if (!hasNativeIntent) { + if (hasPluginZ) setPluginElementZIndexes(sets.pluginZIndexUpdates); + return; + } const baseline = dndBaselineRef.current; const hasSynthetic = sets.syntheticIndexIntents.size > 0; // 결합 eager 단일 소유 - preflight 게이트, 양쪽 적용, 최종 봉인이 @@ -487,11 +505,35 @@ export function useLayerDnD({ propertyIntents: sets.nativeIntents, }); if (!eager.matched) { + // native가 하나도 적용되지 않았으므로 plugin 쓰기도 하지 않는다 - + // 게이트보다 먼저 쓰면 반쪽 순서가 그대로 영속된다 reportElementOpSkipped(skipContext); return; } + // plugin z는 별도 authority 쓰기라 editor 커밋과 비원자다(기존 의미론). + // native receipt가 실제로 되돌아간 경우에만 함께 되돌린다 - 편입 후 + // transient 실패는 pendingLocal 재시도가 소유하므로 복원하면 안 된다 + if (hasPluginZ) { + setPluginElementZIndexes(sets.pluginZIndexUpdates); + } + const restorePluginZ = () => { + if (!hasPluginZ) return; + // 우리가 쓴 값이 그대로일 때만 되돌린다 - 그 사이 다른 writer가 바꿨으면 + // 그쪽 소유다 (native 속성 receipt의 CAS와 같은 규칙) + const current = new Map( + pluginElementsForZ().map((element) => [element.fullId, element.zIndex]), + ); + const reverts = sets.pluginZIndexUpdates + .filter((update) => current.get(update.fullId) === update.zIndex) + .map((update) => ({ + fullId: update.fullId, + zIndex: update.beforeZIndex, + })); + if (reverts.length > 0) setPluginElementZIndexes(reverts); + }; void runElementIntent({ applyEager: () => eager.receipt, + onRolledBack: restorePluginZ, generate: (base) => { if ( hasSynthetic && @@ -970,8 +1012,19 @@ export function useLayerDnD({ liveModel.displayItems, ); if (resolvedIndex != null) { + // 접힌 그룹 헤더 하단 드롭은 그룹 편입이 사용자의 명시적 의도이고 + // 헤더 생존은 앵커 해석이 이미 확인했다. 그 외에는 live 모델로 + // 재유도 - 캡처 값을 그대로 쓰면 앵커가 그 사이 그룹을 떠났을 때 + // 사용자가 넣지 않은 그룹에 편입된다 + const targetGroupId = target.anchorHeaderGroupId + ? target.anchorHeaderGroupId + : resolveItemDropTarget( + resolvedIndex, + new Set(draggedIds), + liveModel, + ).targetGroupId; performMultiDrop(draggedIds, resolvedIndex, { - targetGroupId: target.targetGroupId, + targetGroupId, liveModel, }); } diff --git a/src/renderer/components/main/Grid/core/Grid.tsx b/src/renderer/components/main/Grid/core/Grid.tsx index 87bba182..04716ad2 100644 --- a/src/renderer/components/main/Grid/core/Grid.tsx +++ b/src/renderer/components/main/Grid/core/Grid.tsx @@ -710,6 +710,7 @@ const Grid = ({ el.id.length > 0 && !isSyntheticElementId(el.id); + // Grid는 분리 패널 창에 마운트되지 않으므로 elements를 그대로 읽는다 const pluginZIndexesForMode = (): number[] => usePluginDisplayElementStore .getState() diff --git a/src/renderer/editor/runtime/elementIntent.ts b/src/renderer/editor/runtime/elementIntent.ts index b1d451e4..5723c284 100644 --- a/src/renderer/editor/runtime/elementIntent.ts +++ b/src/renderer/editor/runtime/elementIntent.ts @@ -70,10 +70,17 @@ export const runElementIntent = async (options: { applyEager: () => ElementIntentReceipt | null; generate: (base: EditorDocumentV1) => ElementIntentGeneration; gestureId?: string; + // eager receipt를 실제로 되돌린 시점에만 불린다. editor 밖 authority 쓰기를 + // 같이 되돌려야 하는 호출부가 편입 전/후 정책을 러너와 일치시키는 용도 + onRolledBack?: () => void; }): Promise => { const receipt = options.applyEager(); let enrolled = false; let lastKind: ElementIntentGeneration['kind'] | null = null; + const rollback = () => { + receipt?.rollback(); + options.onRolledBack?.(); + }; try { const document = await enqueueEditorCompatibilityOperation(() => editorCoordinator.commitGeneratedPatch( @@ -91,7 +98,7 @@ export const runElementIntent = async (options: { ), ); if (lastKind === 'targetLost') { - receipt?.rollback(); + rollback(); return { committed: false, satisfied: false, document: null }; } if (lastKind === 'satisfied') { @@ -99,7 +106,7 @@ export const runElementIntent = async (options: { } return { committed: true, satisfied: true, document }; } catch (error) { - if (!enrolled) receipt?.rollback(); + if (!enrolled) rollback(); if (isElementIntentAbort(error) && !enrolled) { // 전체 중단은 오류가 아니라 fail-closed 무커밋 return { committed: false, satisfied: false, document: null }; diff --git a/src/renderer/hooks/Grid/useGridSelection.test.tsx b/src/renderer/hooks/Grid/useGridSelection.test.tsx index 31fc6994..88b66b4e 100644 --- a/src/renderer/hooks/Grid/useGridSelection.test.tsx +++ b/src/renderer/hooks/Grid/useGridSelection.test.tsx @@ -280,6 +280,48 @@ describe('useGridSelection compound history gesture', () => { expect(mocks.commitPatch).not.toHaveBeenCalled(); }); + it('붙여넣기 선택은 커밋 정산을 기다리지 않고 옮겨간다', async () => { + act(() => { + useGridSelectionStore + .getState() + .setSelectedElements([{ type: 'key', id: STABLE_KEY_ID, index: 0 }]); + useGridSelectionStore + .getState() + .setClipboard([ + { type: 'key', keyCode: 'KeyB', position: keyPosition }, + ]); + }); + // 커밋 정산을 붙잡아 라운드트립 중 상태를 관찰한다 + let settle: (value: { + committed: boolean; + satisfied: boolean; + }) => void = () => {}; + mocks.runMixedGestureIntent.mockImplementationOnce( + () => + new Promise((resolve) => { + settle = resolve; + }), + ); + + let pasting: Promise; + await act(async () => { + pasting = api.pasteElements(); + await Promise.resolve(); + }); + + // 정산 전에 이미 사본을 가리켜야 한다 - 원본에 남아 있으면 라운드트립 + // 중의 Delete가 원본을 지운다 + const duringRoundTrip = useGridSelectionStore.getState().selectedElements; + expect(duringRoundTrip).toHaveLength(1); + expect(duringRoundTrip[0].id).not.toBe(STABLE_KEY_ID); + expect(duringRoundTrip[0].index).toBe(1); + + await act(async () => { + settle({ committed: true, satisfied: true }); + await pasting; + }); + }); + it('혼합 붙여넣기 중 동기 예외가 나도 staged transaction을 정산한다', async () => { act(() => { useGridSelectionStore.getState().setClipboard([ diff --git a/src/renderer/hooks/Grid/useGridSelection.ts b/src/renderer/hooks/Grid/useGridSelection.ts index 15c8b6d3..b6039a7b 100644 --- a/src/renderer/hooks/Grid/useGridSelection.ts +++ b/src/renderer/hooks/Grid/useGridSelection.ts @@ -34,6 +34,7 @@ import { buildNextLayerGroupName, buildLayerItemsForMode, applyZIndexToLayerOrder, + orderPastedItemsByFrozenZ, } from '@utils/layerGroupUtils'; import { commitSelectedGeometryByIds } from '@src/renderer/editor/runtime/elementOps'; import { @@ -1295,16 +1296,19 @@ export function useGridSelection({ .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)); + // 블록 내부는 원본의 상대 스택을 따른다 - payload는 타입별로 묶인 + // 순서라 그대로 쓰면 복사본의 위아래가 뒤집힌다 + const pastedOrdered = orderPastedItemsByFrozenZ( + [ + ...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( @@ -1466,9 +1470,66 @@ export function useGridSelection({ } } - let result: { committed: boolean; satisfied: boolean }; + // 선택 이동은 eager 직후 동기 구간에서 - await 뒤로 미루면 라운드트립 + // 동안 선택이 원본에 남아 Delete 같은 후속 조작이 원본을 지운다. + // 실패로 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); + } + } + try { - result = await runMixedGestureElementIntent({ + await runMixedGestureElementIntent({ gestureId, initialPluginIds: pluginScope( usePluginDisplayElementStore.getState().elements, @@ -1513,65 +1574,6 @@ export function useGridSelection({ // 편입 후 실패의 상태 정합은 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); - } - } } sendBridgeMessageBestEffort('overlay', 'plugin:displayElements:sync', { diff --git a/src/renderer/plugins/rpc/pluginElementActions.ts b/src/renderer/plugins/rpc/pluginElementActions.ts index 2c031698..19c3ec7b 100644 --- a/src/renderer/plugins/rpc/pluginElementActions.ts +++ b/src/renderer/plugins/rpc/pluginElementActions.ts @@ -24,6 +24,8 @@ export const PLUGIN_RPC_OPERATIONS = { const isPanelWindow = () => window.__dmn_window_type === 'panel'; +// 호출부가 모두 isPanelWindow() 조기 위임 뒤에만 도달하므로 elements를 그대로 +// 읽는다 (패널에서는 panelElements만 채워진다) const rotateTargetPluginSessions = ( fullIds: string[], gestureId?: string, diff --git a/src/renderer/stores/grid/useGridSelectionStore.idReconcile.test.ts b/src/renderer/stores/grid/useGridSelectionStore.idReconcile.test.ts index ee8bce3f..eaa3a4ec 100644 --- a/src/renderer/stores/grid/useGridSelectionStore.idReconcile.test.ts +++ b/src/renderer/stores/grid/useGridSelectionStore.idReconcile.test.ts @@ -59,6 +59,54 @@ describe('id 기반 선택 재조정', () => { expect(selected[0].index).toBe(0); }); + it('백엔드가 id를 재발급해도 같은 자리의 선택을 유지한다', () => { + useGridSelectionStore + .getState() + .setSelectedElements([{ type: 'key', id: a.id!, index: 1 }]); + // 탭 프리셋 rekey·v1 어댑터 재발급처럼 요소는 그대로인데 신원만 새로 + // 발급되는 경우 - 길이가 그대로면 자리로 재채택한다 + const rekeyedA = { ...a, id: 'rekeyed-a' }; + const rekeyedB = { ...b, id: 'rekeyed-b' }; + + invalidateSelectionForChangedIndexedElementArrays( + arraysOf([b, a]), + arraysOf([rekeyedB, rekeyedA]), + ); + + const selected = useGridSelectionStore.getState().selectedElements; + expect(selected).toHaveLength(1); + expect(selected[0].id).toBe('rekeyed-a'); + expect(selected[0].index).toBe(1); + }); + + it('같은 길이라도 그 자리 요소가 교체됐으면 선택을 푼다', () => { + useGridSelectionStore + .getState() + .setSelectedElements([{ type: 'key', id: a.id!, index: 1 }]); + // 삭제+삽입이 한 문서에 정산되면 길이는 그대로지만 자리의 요소는 다르다 + const replacement = { ...createDefaultKeyPosition(), dx: 999 }; + + invalidateSelectionForChangedIndexedElementArrays( + arraysOf([b, a]), + arraysOf([b, replacement]), + ); + + expect(useGridSelectionStore.getState().selectedElements).toHaveLength(0); + }); + + it('길이가 바뀌면 재채택 대신 경계 판정을 따른다', () => { + useGridSelectionStore + .getState() + .setSelectedElements([{ type: 'key', id: a.id!, index: 1 }]); + + invalidateSelectionForChangedIndexedElementArrays( + arraysOf([b, a]), + arraysOf([b]), + ); + + expect(useGridSelectionStore.getState().selectedElements).toHaveLength(0); + }); + it('변화가 없으면 선택 참조를 보존한다', () => { useGridSelectionStore .getState() diff --git a/src/renderer/stores/grid/useGridSelectionStore.ts b/src/renderer/stores/grid/useGridSelectionStore.ts index 88ee5ed3..b170ffcd 100644 --- a/src/renderer/stores/grid/useGridSelectionStore.ts +++ b/src/renderer/stores/grid/useGridSelectionStore.ts @@ -5,6 +5,7 @@ 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 { stableStringify } from '@utils/core/stableStringify'; +import { isSyntheticElementId } from '@src/renderer/editor/model/elementIdMap'; export type SelectableElementType = | 'key' @@ -396,25 +397,50 @@ export function invalidateSelectionForChangedIndexedElementArrays( id?: string; }[]; + const currentPositionsFor = ( + type: IndexedSelectableElementType, + ): readonly unknown[] => + type === 'key' ? current.keyPositions : current[type]; + + // 같은 자리의 요소가 id만 빼고 동일한지 - 신원 재발급과 요소 교체를 가른다 + const sameSlotPayload = (type: IndexedSelectableElementType, index: number) => + stableStringify(withoutElementId(currentPositionsFor(type)[index])) === + stableStringify(withoutElementId(nextPositionsFor(type)[index])); + 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( + if (!isSyntheticElementId(element.id)) { + const nextPositions = nextPositionsFor(element.type); + const newIndex = nextPositions.findIndex( (position) => position?.id === element.id, ); - if (newIndex === -1) { - changed = true; - return []; + if (newIndex !== -1) { + if (newIndex !== element.index) { + changed = true; + return [{ ...element, index: newIndex }]; + } + return [element]; } - if (newIndex !== element.index) { + + // id가 사라졌어도 그 자리의 요소가 id만 빼고 그대로면 신원만 재발급된 + // 것이다 (탭 프리셋 rekey, v1 어댑터 재발급) - 자리로 재채택한다. + // 내용이 다르면 요소가 교체된 것이므로 예전처럼 선택을 푼다 + const rekeyed = + boundaries.get(element.type) === undefined && + typeof element.index === 'number' && + sameSlotPayload(element.type, element.index) + ? nextPositions[element.index]?.id + : undefined; + if (rekeyed !== undefined) { changed = true; - return [{ ...element, index: newIndex }]; + return [{ ...element, id: rekeyed }]; } - return [element]; + changed = true; + return []; } // 합성 id 폴백 (backfill 전 데이터): 기존 경계 휴리스틱 유지 diff --git a/src/renderer/utils/layerGroupUtils.test.ts b/src/renderer/utils/layerGroupUtils.test.ts new file mode 100644 index 00000000..602a45c0 --- /dev/null +++ b/src/renderer/utils/layerGroupUtils.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { orderPastedItemsByFrozenZ } from './layerGroupUtils'; + +describe('orderPastedItemsByFrozenZ', () => { + it('원본 스택 순서를 따른다 (payload 타입 순서가 아니라)', () => { + // 마퀴 선택은 key를 먼저 담으므로 payload는 [key, stat]이지만 + // 원본에서는 stat이 위에 있다 + const ordered = orderPastedItemsByFrozenZ([ + { id: 'key', zIndex: 1 }, + { id: 'stat', zIndex: 5 }, + ]); + + expect(ordered.map((item) => item.id)).toEqual(['stat', 'key']); + }); + + it('같은 타입 안에서도 z 내림차순을 따른다', () => { + const ordered = orderPastedItemsByFrozenZ([ + { id: 'a', zIndex: 0 }, + { id: 'b', zIndex: 9 }, + { id: 'c', zIndex: 4 }, + ]); + + expect(ordered.map((item) => item.id)).toEqual(['b', 'c', 'a']); + }); + + it('z가 같으면 payload 순서를 유지한다', () => { + const ordered = orderPastedItemsByFrozenZ([ + { id: 'first', zIndex: 3 }, + { id: 'second', zIndex: 3 }, + { id: 'third', zIndex: 3 }, + ]); + + expect(ordered.map((item) => item.id)).toEqual([ + 'first', + 'second', + 'third', + ]); + }); +}); diff --git a/src/renderer/utils/layerGroupUtils.ts b/src/renderer/utils/layerGroupUtils.ts index 36d9c608..934d325d 100644 --- a/src/renderer/utils/layerGroupUtils.ts +++ b/src/renderer/utils/layerGroupUtils.ts @@ -413,6 +413,19 @@ export function buildLayerItemsForMode( return items; } +/** + * 붙여넣기 블록의 스택 순서 결정. 배열 위치가 곧 최종 z이므로 원본의 상대 + * 스택(zIndex 내림차순)을 따르고, 동률은 payload 순서로 안정 정렬한다 + */ +export function orderPastedItemsByFrozenZ( + items: readonly T[], +): T[] { + return items + .map((item, order) => ({ item, order })) + .sort((a, b) => b.item.zIndex - a.item.zIndex || a.order - b.order) + .map((entry) => entry.item); +} + /** 선택 상태 기반으로 paste 앵커 위치 결정 (선택된 레이어 바로 위에 삽입) */ export function findPasteAnchorIndex( orderedItems: LayerItemForOrder[],