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..0af203c0 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
@@ -315,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/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/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/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..4237d347 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`로 두고 단색 필드를 갱신하세요.
@@ -186,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/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)
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-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/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);
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/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-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..157c4e32
--- /dev/null
+++ b/src-tauri/src/state/native_element_id.rs
@@ -0,0 +1,1110 @@
+use std::collections::{HashMap, HashSet};
+
+use uuid::Uuid;
+
+use crate::{
+ errors::EditorCommitError,
+ models::{
+ AppStoreData, EditorDocumentV1, EditorPatchV1, GraphPosition, KeyMappings, KeyPosition,
+ KeySlot, 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 keep_or_rekey_supplied_ids(
+ candidate: &mut HashMap>,
+ canonical_ids: &HashSet,
+ consumed_current_ids: &mut HashSet,
+ reserved: &mut HashSet,
+) {
+ for mode in sorted_modes(candidate) {
+ let Some(elements) = candidate.get_mut(&mode) else {
+ continue;
+ };
+ for element in elements {
+ let id = element.position().id.clone();
+ if id.is_empty() {
+ continue;
+ }
+ if canonical_ids.contains(&id) {
+ consumed_current_ids.insert(id);
+ } else {
+ element.position_mut().id = new_unique_id(reserved);
+ }
+ }
+ }
+}
+
+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;
+ };
+ 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_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,
+) -> 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);
+
+ let patch_keys = patch.keys.clone();
+ if let Some(collection) = patch.key_positions.as_mut() {
+ adapt_v1_key_position_ids(
+ store,
+ patch_keys.as_ref(),
+ collection,
+ &canonical_ids,
+ &mut consumed_current_ids,
+ &mut reserved,
+ );
+ }
+ if let Some(collection) = patch.stat_positions.as_mut() {
+ adapt_v1_collection(
+ &store.stat_positions,
+ collection,
+ &canonical_ids,
+ &mut consumed_current_ids,
+ &mut reserved,
+ );
+ }
+ if let Some(collection) = patch.graph_positions.as_mut() {
+ adapt_v1_collection(
+ &store.graph_positions,
+ collection,
+ &canonical_ids,
+ &mut consumed_current_ids,
+ &mut reserved,
+ );
+ }
+ if let Some(collection) = patch.knob_positions.as_mut() {
+ adapt_v1_collection(
+ &store.knob_positions,
+ collection,
+ &canonical_ids,
+ &mut consumed_current_ids,
+ &mut reserved,
+ );
+ }
+ Ok(())
+}
+
+fn validate_document_collection_ids(
+ collection: &HashMap>,
+ seen: &mut HashSet,
+) -> Result<(), EditorCommitError> {
+ for mode in sorted_modes(collection) {
+ let Some(elements) = collection.get(&mode) else {
+ continue;
+ };
+ for (index, element) in elements.iter().enumerate() {
+ let id = &element.position().id;
+ if id.is_empty() {
+ return Err(EditorCommitError::validation(
+ MISSING_ELEMENT_ID,
+ format!("native element {mode}[{index}] is missing an ID"),
+ ));
+ }
+ if !is_valid_element_id(id) {
+ return Err(EditorCommitError::validation(
+ INVALID_ELEMENT_ID,
+ format!("native element {mode}[{index}] has an invalid ID"),
+ ));
+ }
+ if !seen.insert(id.clone()) {
+ return Err(EditorCommitError::validation(
+ DUPLICATE_ELEMENT_ID,
+ format!("native element ID {id} is not globally unique"),
+ ));
+ }
+ }
+ }
+ Ok(())
+}
+
+pub(crate) fn validate_document_element_ids(
+ document: &EditorDocumentV1,
+) -> Result<(), EditorCommitError> {
+ let mut seen = HashSet::new();
+ validate_document_collection_ids(&document.key_positions, &mut seen)?;
+ validate_document_collection_ids(&document.stat_positions, &mut seen)?;
+ validate_document_collection_ids(&document.graph_positions, &mut seen)?;
+ validate_document_collection_ids(&document.knob_positions, &mut seen)
+}
+
+fn patch_includes_native_elements(patch: &EditorPatchV1) -> bool {
+ patch.key_positions.is_some()
+ || patch.stat_positions.is_some()
+ || patch.graph_positions.is_some()
+ || patch.knob_positions.is_some()
+}
+
+pub(crate) fn prepare_commit_patch_element_ids(
+ store: &AppStoreData,
+ patch: &mut EditorPatchV1,
+) -> Result<(), EditorCommitError> {
+ if !patch_includes_native_elements(patch) {
+ return Ok(());
+ }
+
+ match patch.schema_version {
+ EDITOR_SCHEMA_VERSION => adapt_v1_patch_ids(store, patch)?,
+ EDITOR_COMMIT_SCHEMA_VERSION_V2 => {
+ validate_supplied_patch_ids(patch, true)?;
+ }
+ _ => {
+ return Err(EditorCommitError::validation(
+ "UNSUPPORTED_SCHEMA_VERSION",
+ format!("unsupported editor schema version {}", patch.schema_version),
+ ));
+ }
+ }
+
+ let mut candidate = EditorDocumentV1::from_store(store);
+ candidate.apply_patch(patch);
+ validate_document_element_ids(&candidate)
+}
+
+#[cfg(test)]
+mod tests {
+ use std::collections::{HashMap, HashSet};
+
+ use crate::models::{
+ AppStoreData, EditorPatchV1, GraphPosition, GraphStatType, GraphType, KeyPosition,
+ KnobPosition, SlotMatch, StatPosition, StatType,
+ };
+
+ use super::*;
+
+ fn position(dx: f64) -> KeyPosition {
+ KeyPosition {
+ dx,
+ ..KeyPosition::default()
+ }
+ }
+
+ fn 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);
+ }
+
+ 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 {
+ 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