v0.6.1
Example document / build (push) Has been cancelled
Python application / build (push) Has been cancelled

Fix gui_schemes
This commit is contained in:
Igor20264
2026-10-20 21:53:34 +03:00
parent 8223e0ac14
commit 8fc1f9e096
6 changed files with 253 additions and 86 deletions
+1 -1
View File
@@ -655,7 +655,7 @@ Rel_Triggering(investigate, close)
- Полная Appendix B в sample отсутствует — матрица пар здесь практическая, не нормативная таблица B.5. - Полная Appendix B в sample отсутствует — матрица пар здесь практическая, не нормативная таблица B.5.
- PDF спецификации в git не хранятся. - PDF спецификации в git не хранятся.
- Старый PlantUML/Kroki без stdlib 3.2: fallback — `` ```uml `` + keyword `archimate`. - Старый PlantUML/Kroki без stdlib 3.2: fallback — `` ```uml `` + keyword `archimate`.
- Пользовательский `md2gost.schemes.json`: встроенные схемы с `author: md2gost` синхронизируются с бандлом; свои правки (другой author) не затираются. - Пользовательский `md2gost.schemes.json` при запуске только дополняется отсутствующими встроенными схемами. Уже записанные правки не затираются.
--- ---
+2 -2
View File
@@ -38,7 +38,7 @@ Rel(user, app, "логин")
## Встроенные схемы ## Встроенные схемы
Файл шаблона: [`md2gost/diagrams/schemes.json`](../md2gost/diagrams/schemes.json). При первом запуске копируется в `md2gost.schemes.json` рядом с приложением. При каждом запуске встроенные схемы (`author: md2gost`) синхронизируются с шаблоном: новые id добавляются, устаревшие (например удалённый `bpmn`) убираются, содержимое обновляется. Схемы с пустым или другим `author` не трогаются. Если правите встроенную в GUI и сохраняете — `author` сбрасывается, чтобы миграция не затёрла правки. Файл шаблона: [`md2gost/diagrams/schemes.json`](../md2gost/diagrams/schemes.json). При первом запуске копируется в `md2gost.schemes.json` рядом с приложением. При каждом запуске в файл добавляются только отсутствующие встроенные схемы. Уже записанные схемы не перезаписываются и не удаляются: правки и свои шаблоны сохраняются. Файл, который не удалось разобрать, не затирается. Если правите встроенную схему в GUI и сохраняете — `author` сбрасывается, схема помечается как ваша.
| id | Название | Оградка | | id | Название | Оградка |
|----|----------|---------| |----|----------|---------|
@@ -291,4 +291,4 @@ id схемы: латиница, цифры, `_`; начинается с бук
2. Или править `md2gost.schemes.json` вручную («Открыть JSON»). 2. Или править `md2gost.schemes.json` вручную («Открыть JSON»).
3. CLI: `--schemes путь.json`. 3. CLI: `--schemes путь.json`.
Чтобы правка встроенной схемы не сбрасывалась при обновлении приложения, оставьте пустой `author` (GUI делает это сама при сохранении изменённой встроенной схемы). Запуск программы не сбрасывает правки в `md2gost.schemes.json`. В GUI кнопка «Добавить» сразу записывает новую схему (`custom1`, `custom2`, …) в этот файл.
+95 -55
View File
@@ -114,67 +114,48 @@ def is_app_owned_scheme(scheme: DiagramScheme) -> bool:
return scheme.author.strip().lower() == APP_SCHEME_AUTHOR return scheme.author.strip().lower() == APP_SCHEME_AUTHOR
def _schemes_payload(schemes: dict[str, DiagramScheme]) -> dict[str, Any]:
return {sid: scheme.to_dict() for sid, scheme in sorted(schemes.items())}
def migrate_user_schemes(base: Path | None = None) -> SchemesMigrationResult: def migrate_user_schemes(base: Path | None = None) -> SchemesMigrationResult:
""" """
Sync app-owned schemes in the user file with the bundled template. Add bundled schemes that are missing from the user file.
- Bundled ids: always present; overwrite user entries with author=md2gost. Existing entries are kept as written: edits of built-in ids, custom
- User overrides of bundled ids (empty/other author): kept as-is. schemes, and schemes with author=md2gost are not overwritten or removed.
- Orphaned author=md2gost ids no longer in the template: removed. A file that cannot be parsed is left untouched.
- Other custom schemes: kept.
""" """
result = SchemesMigrationResult() result = SchemesMigrationResult()
path = user_schemes_path(base) path = user_schemes_path(base)
if not path.is_file(): if not path.is_file():
return result return result
bundled = _load_schemes_file(bundled_schemes_path()) raw, err = _read_json_object(path)
user = _load_schemes_file(path) if err or raw is None:
out: dict[str, DiagramScheme] = {} _log.warning("Миграция схем пропущена, файл не изменён (%s): %s", path, err)
for sid, scheme in user.items():
if sid in bundled:
if is_app_owned_scheme(scheme):
continue # step below writes bundled
out[sid] = scheme
continue
if is_app_owned_scheme(scheme):
result.removed.append(sid)
_log.info("Миграция схем: удалена устаревшая встроенная «%s»", sid)
continue
out[sid] = scheme
for sid, scheme in bundled.items():
if sid in out:
continue # user-owned override
prev = user.get(sid)
if prev is None:
result.added.append(sid)
_log.info("Миграция схем: добавлена «%s»", sid)
elif prev.to_dict() != scheme.to_dict():
result.updated.append(sid)
_log.info("Миграция схем: обновлена «%s»", sid)
out[sid] = scheme
if _schemes_payload(out) == _schemes_payload(user):
result.added.clear()
result.updated.clear()
result.removed.clear()
return result return result
save_schemes(out, path) bundled = _load_schemes_file(bundled_schemes_path())
existing = {str(key).lower().strip() for key in raw}
for sid, scheme in bundled.items():
if sid in existing:
continue
raw[sid] = scheme.to_dict()
result.added.append(sid)
_log.info("Миграция схем: добавлена «%s»", sid)
if not result.added:
return result
path.write_text(
json.dumps(raw, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
result.written = True result.written = True
return result return result
def ensure_user_schemes(base: Path | None = None) -> Path: def ensure_user_schemes(base: Path | None = None) -> Path:
""" """
Ensure md2gost.schemes.json exists next to the app, then sync app-owned Ensure md2gost.schemes.json exists next to the app, then add any
schemes from the bundled template (add / update / remove orphans). bundled schemes that are not already in the user file.
""" """
dest = user_schemes_path(base) dest = user_schemes_path(base)
if not dest.is_file(): if not dest.is_file():
@@ -190,16 +171,21 @@ def ensure_user_schemes(base: Path | None = None) -> Path:
return dest return dest
def _load_schemes_file(path: Path) -> dict[str, DiagramScheme]: def _read_json_object(path: Path) -> tuple[dict[str, Any] | None, str | None]:
"""Return (object, None). Missing file is ({}, None). Unreadable is (None, error)."""
if not path.is_file(): if not path.is_file():
return {} return {}, None
try: try:
raw = json.loads(path.read_text(encoding="utf-8")) # utf-8-sig: Notepad on Windows often writes a BOM, which json.loads rejects.
except (OSError, json.JSONDecodeError) as exc: raw = json.loads(path.read_text(encoding="utf-8-sig"))
_log.warning("Не удалось прочитать схемы %s: %s", path, exc) except (OSError, UnicodeError, json.JSONDecodeError) as exc:
return {} return None, str(exc)
if not isinstance(raw, dict): if not isinstance(raw, dict):
return {} return None, "корень JSON должен быть объектом"
return raw, None
def _schemes_from_raw(raw: dict[str, Any]) -> dict[str, DiagramScheme]:
out: dict[str, DiagramScheme] = {} out: dict[str, DiagramScheme] = {}
for key, val in raw.items(): for key, val in raw.items():
sid = str(key).lower().strip() sid = str(key).lower().strip()
@@ -212,16 +198,70 @@ def _load_schemes_file(path: Path) -> dict[str, DiagramScheme]:
return out return out
def _load_schemes_file(path: Path) -> dict[str, DiagramScheme]:
raw, err = _read_json_object(path)
if err or raw is None:
if path.is_file():
_log.warning("Не удалось прочитать схемы %s: %s", path, err)
return {}
return _schemes_from_raw(raw)
def load_schemes_from_path(path: Path) -> dict[str, DiagramScheme]: def load_schemes_from_path(path: Path) -> dict[str, DiagramScheme]:
return _load_schemes_file(path) return _load_schemes_file(path)
def save_schemes(schemes: dict[str, DiagramScheme], path: Path | None = None) -> Path: def load_schemes_report(path: Path) -> tuple[dict[str, DiagramScheme], str | None]:
"""Load schemes. Error string is set when the file exists but cannot be parsed."""
raw, err = _read_json_object(path)
if err or raw is None:
if path.is_file():
_log.warning("Не удалось прочитать схемы %s: %s", path, err)
return {}, err
return _schemes_from_raw(raw), None
def save_schemes(
schemes: dict[str, DiagramScheme],
path: Path | None = None,
*,
removed_ids: set[str] | None = None,
) -> Path:
"""
Write schemes, keeping JSON entries the loader does not understand
(unknown ids, extra fields). ``removed_ids`` are normalized ids the user
deleted or renamed away.
"""
dest = path or user_schemes_path() dest = path or user_schemes_path()
payload = _schemes_payload(schemes)
dest.parent.mkdir(parents=True, exist_ok=True) dest.parent.mkdir(parents=True, exist_ok=True)
raw, err = _read_json_object(dest)
if err or raw is None:
raw = {}
removed = {sid.lower() for sid in (removed_ids or set())}
norm_to_key: dict[str, str] = {}
for key in list(raw):
sid = str(key).lower().strip()
if sid in removed:
del raw[key]
continue
norm_to_key.setdefault(sid, str(key))
for sid in sorted(schemes):
scheme = schemes[sid]
data = scheme.to_dict()
key = norm_to_key.get(sid)
if key is None:
raw[sid] = data
continue
base = raw.get(key)
merged = dict(base) if isinstance(base, dict) else {}
merged.update(data)
if not scheme.theme:
merged.pop("theme", None)
raw[key] = merged
dest.write_text( dest.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n", json.dumps(raw, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8", encoding="utf-8",
) )
return dest return dest
@@ -234,7 +274,7 @@ def claim_user_scheme_if_diverged(
) -> DiagramScheme: ) -> DiagramScheme:
""" """
If scheme id is in the app template and content differs while still If scheme id is in the app template and content differs while still
author=md2gost, clear author so migration will not overwrite user edits. author=md2gost, clear author so the copy is marked as the user's.
""" """
stock = (bundled if bundled is not None else _load_schemes_file(bundled_schemes_path())).get( stock = (bundled if bundled is not None else _load_schemes_file(bundled_schemes_path())).get(
scheme.id scheme.id
+69 -17
View File
@@ -16,7 +16,7 @@ from .diagram_schemes import (
claim_user_scheme_if_diverged, claim_user_scheme_if_diverged,
ensure_user_schemes, ensure_user_schemes,
get_schemes, get_schemes,
load_schemes_from_path, load_schemes_report,
reload_schemes, reload_schemes,
save_schemes, save_schemes,
user_schemes_path, user_schemes_path,
@@ -263,7 +263,9 @@ class Md2GostApp:
self._last_md = "" self._last_md = ""
self._schemes: dict[str, DiagramScheme] = {} self._schemes: dict[str, DiagramScheme] = {}
self._scheme_ids: list[str] = [] self._scheme_ids: list[str] = []
self._scheme_file_keys: set[str] = set()
self._scheme_selecting = False self._scheme_selecting = False
self._schemes_unreadable: str | None = None
self._debug_next = False self._debug_next = False
self._win_files: tk.Toplevel | None = None self._win_files: tk.Toplevel | None = None
@@ -1336,12 +1338,23 @@ class Md2GostApp:
if not hasattr(self, "_scheme_list"): if not hasattr(self, "_scheme_list"):
return return
path = ensure_user_schemes() path = ensure_user_schemes()
self._schemes = load_schemes_from_path(path) self._schemes, err = load_schemes_report(path)
self._schemes_unreadable = err
self._scheme_file_keys = set() if err else set(self._schemes)
self._scheme_ids = sorted(self._schemes.keys()) self._scheme_ids = sorted(self._schemes.keys())
self._scheme_list.delete(0, tk.END) self._scheme_list.delete(0, tk.END)
for sid in self._scheme_ids: for sid in self._scheme_ids:
title = self._schemes[sid].title or sid title = self._schemes[sid].title or sid
self._scheme_list.insert(tk.END, title) self._scheme_list.insert(tk.END, title)
if err:
self._clear_scheme_form()
self._scheme_fence_hint.configure(
text=f"Файл не разобран и не изменён: {err}"
)
self._append_log(
f"Файл схем не прочитан, он не перезаписан: {path} ({err})"
)
return
if not self._scheme_ids: if not self._scheme_ids:
self._clear_scheme_form() self._clear_scheme_form()
return return
@@ -1352,6 +1365,22 @@ class Md2GostApp:
self._scheme_list.see(idx) self._scheme_list.see(idx)
self._fill_scheme_form(self._schemes[target]) self._fill_scheme_form(self._schemes[target])
def _persist_schemes(self, select_id: str | None = None) -> str | None:
"""Write self._schemes to disk. Refuses to overwrite a file that did not parse."""
if self._schemes_unreadable:
messagebox.showerror(
"md2gost",
"Файл схем не разобран, поэтому он не перезаписан.\n"
f"{self._schemes_unreadable}",
)
return None
path = user_schemes_path()
removed = set(self._scheme_file_keys) - set(self._schemes)
save_schemes(self._schemes, path, removed_ids=removed)
reload_schemes()
self._reload_schemes_ui(select_id=select_id)
return str(path)
def _clear_scheme_form(self) -> None: def _clear_scheme_form(self) -> None:
self._scheme_selecting = True self._scheme_selecting = True
self._sch_id.set("") self._sch_id.set("")
@@ -1396,6 +1425,9 @@ class Md2GostApp:
return None return None
includes_raw = self._text_get(self._sch_includes) includes_raw = self._text_get(self._sch_includes)
includes = [ln.strip() for ln in includes_raw.splitlines() if ln.strip()] includes = [ln.strip() for ln in includes_raw.splitlines() if ln.strip()]
theme = ""
if old_id and old_id in self._schemes:
theme = self._schemes[old_id].theme
return DiagramScheme( return DiagramScheme(
id=sid, id=sid,
title=self._sch_title.get().strip() or sid, title=self._sch_title.get().strip() or sid,
@@ -1406,6 +1438,7 @@ class Md2GostApp:
includes=includes, includes=includes,
prefix=self._text_get(self._sch_prefix), prefix=self._text_get(self._sch_prefix),
postfix=self._text_get(self._sch_postfix), postfix=self._text_get(self._sch_postfix),
theme=theme,
) )
def _scheme_commit_current(self) -> str | None: def _scheme_commit_current(self) -> str | None:
@@ -1422,6 +1455,15 @@ class Md2GostApp:
return scheme.id return scheme.id
def _scheme_add(self) -> None: def _scheme_add(self) -> None:
if self._schemes_unreadable:
messagebox.showerror(
"md2gost",
"Файл схем не разобран, новая схема не записана.\n"
f"{self._schemes_unreadable}",
)
return
if self._scheme_ids and self._scheme_commit_current() is None:
return
n = 1 n = 1
while f"custom{n}" in self._schemes: while f"custom{n}" in self._schemes:
n += 1 n += 1
@@ -1437,13 +1479,24 @@ class Md2GostApp:
prefix="@startuml\n", prefix="@startuml\n",
postfix="\n@enduml", postfix="\n@enduml",
) )
self._reload_schemes_ui(select_id=sid) path = self._persist_schemes(select_id=sid)
if path:
self._append_log(f"Схема добавлена: {sid} ({path})")
def _scheme_dup(self) -> None: def _scheme_dup(self) -> None:
sel = self._scheme_list.curselection() if not self._scheme_list.curselection():
if not sel:
return return
src = self._schemes[self._scheme_ids[sel[0]]] if self._schemes_unreadable:
messagebox.showerror(
"md2gost",
"Файл схем не разобран, копия не записана.\n"
f"{self._schemes_unreadable}",
)
return
src_id = self._scheme_commit_current()
if src_id is None:
return
src = self._schemes[src_id]
n = 1 n = 1
while f"{src.id}_copy{n}" in self._schemes: while f"{src.id}_copy{n}" in self._schemes:
n += 1 n += 1
@@ -1452,7 +1505,7 @@ class Md2GostApp:
id=sid, id=sid,
title=src.title, title=src.title,
version=src.version, version=src.version,
author=src.author, author="",
docs=src.docs, docs=src.docs,
ai_prompt=src.ai_prompt, ai_prompt=src.ai_prompt,
includes=list(src.includes), includes=list(src.includes),
@@ -1460,7 +1513,9 @@ class Md2GostApp:
postfix=src.postfix, postfix=src.postfix,
theme=src.theme, theme=src.theme,
) )
self._reload_schemes_ui(select_id=sid) path = self._persist_schemes(select_id=sid)
if path:
self._append_log(f"Схема скопирована: {sid} ({path})")
def _scheme_del(self) -> None: def _scheme_del(self) -> None:
sel = self._scheme_list.curselection() sel = self._scheme_list.curselection()
@@ -1470,20 +1525,17 @@ class Md2GostApp:
if not messagebox.askyesno("md2gost", f"Удалить схему «{sid}» из файла пользователя?"): if not messagebox.askyesno("md2gost", f"Удалить схему «{sid}» из файла пользователя?"):
return return
del self._schemes[sid] del self._schemes[sid]
path = user_schemes_path() path = self._persist_schemes()
save_schemes(self._schemes, path) if path:
reload_schemes() self._append_log(f"Схема удалена: {sid}")
self._reload_schemes_ui()
self._append_log(f"Схема удалена: {sid}")
def _scheme_save_all(self) -> None: def _scheme_save_all(self) -> None:
sid = self._scheme_commit_current() sid = self._scheme_commit_current()
if sid is None and self._scheme_ids: if sid is None and self._scheme_ids:
return return
path = user_schemes_path() path = self._persist_schemes(select_id=sid)
save_schemes(self._schemes, path) if not path:
reload_schemes() return
self._reload_schemes_ui(select_id=sid)
self._append_log(f"Схемы сохранены: {path}") self._append_log(f"Схемы сохранены: {path}")
messagebox.showinfo("md2gost", f"Сохранено:\n{path}") messagebox.showinfo("md2gost", f"Сохранено:\n{path}")
+4 -4
View File
@@ -205,11 +205,11 @@ SCHEMES_HELP = """СХЕМЫ ДИАГРАММ (PlantUML)
Первый запуск Первый запуск
Рядом с программой (рядом с md2gost.exe или в текущей папке при python -m) Рядом с программой (рядом с md2gost.exe или в текущей папке при python -m)
создаётся md2gost.schemes.json из встроенного шаблона. создаётся md2gost.schemes.json из встроенного шаблона.
При каждом запуске схемы с author=md2gost синхронизируются с шаблоном приложения При каждом запуске в файл добавляются только те встроенные схемы, которых ещё нет.
(добавление новых id, обновление, удаление исчезнувших вроде bpmn). Уже записанные схемы не перезаписываются и не удаляются: правки и свои шаблоны
Схемы с пустым или другим author не трогаются. сохраняются. Файл с ошибкой JSON или с BOM не затирается.
Если правите встроенную схему в GUI и сохраняете — author сбрасывается, Если правите встроенную схему в GUI и сохраняете — author сбрасывается,
чтобы следующая миграция не затёрла ваши правки. схема помечается как ваша.
Оградка в markdown Оградка в markdown
```uml — обычный PlantUML (или ```plantuml) ```uml — обычный PlantUML (или ```plantuml)
+82 -7
View File
@@ -39,6 +39,7 @@ from md2gost.diagram_schemes import (
ensure_user_schemes, ensure_user_schemes,
load_schemes_from_path, load_schemes_from_path,
migrate_user_schemes, migrate_user_schemes,
save_schemes,
prepare_with_schemes, prepare_with_schemes,
scheme_id_from_lang, scheme_id_from_lang,
user_schemes_path, user_schemes_path,
@@ -125,7 +126,7 @@ def test_ensure_user_schemes_once(tmp_path):
assert path == user_schemes_path(tmp_path) assert path == user_schemes_path(tmp_path)
def test_migrate_removes_orphaned_builtin_and_adds_missing(tmp_path, monkeypatch): def test_migrate_keeps_custom_schemes_and_adds_missing(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
path = user_schemes_path(tmp_path) path = user_schemes_path(tmp_path)
path.write_text( path.write_text(
@@ -147,10 +148,11 @@ def test_migrate_removes_orphaned_builtin_and_adds_missing(tmp_path, monkeypatch
) )
result = migrate_user_schemes(tmp_path) result = migrate_user_schemes(tmp_path)
assert result.written assert result.written
assert "bpmn" in result.removed assert result.removed == []
assert "c4" in result.added assert "c4" in result.added
schemes = load_schemes_from_path(path) schemes = load_schemes_from_path(path)
assert "bpmn" not in schemes assert schemes["bpmn"].title == "BPMN 2.0"
assert schemes["bpmn"].author == "md2gost"
assert "mine" in schemes assert "mine" in schemes
assert schemes["mine"].title == "x" assert schemes["mine"].title == "x"
bundled = load_schemes_from_path(bundled_schemes_path()) bundled = load_schemes_from_path(bundled_schemes_path())
@@ -186,7 +188,7 @@ def test_migrate_preserves_user_override_of_builtin(tmp_path, monkeypatch):
assert "usecase" in schemes # other bundled ids still added assert "usecase" in schemes # other bundled ids still added
def test_migrate_updates_app_owned_builtin(tmp_path, monkeypatch): def test_migrate_keeps_edited_builtin(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
path = user_schemes_path(tmp_path) path = user_schemes_path(tmp_path)
path.write_text( path.write_text(
@@ -208,10 +210,83 @@ def test_migrate_updates_app_owned_builtin(tmp_path, monkeypatch):
encoding="utf-8", encoding="utf-8",
) )
result = migrate_user_schemes(tmp_path) result = migrate_user_schemes(tmp_path)
assert "c4" in result.updated assert "c4" not in result.updated
assert "c4" not in result.added
schemes = load_schemes_from_path(path) schemes = load_schemes_from_path(path)
bundled = load_schemes_from_path(bundled_schemes_path()) assert schemes["c4"].title == "Old C4"
assert schemes["c4"].to_dict() == bundled["c4"].to_dict() assert schemes["c4"].docs == "stale"
assert schemes["c4"].author == "md2gost"
assert "usecase" in schemes
def test_migrate_does_not_overwrite_broken_json(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
path = user_schemes_path(tmp_path)
original = "{\n \"c4\": {\"title\": \"x\",}\n}\n"
path.write_text(original, encoding="utf-8")
result = migrate_user_schemes(tmp_path)
assert not result.written
assert not result.changed
assert path.read_text(encoding="utf-8") == original
def test_migrate_reads_utf8_bom(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
path = user_schemes_path(tmp_path)
body = json.dumps(
{
"c4": {
"title": "My C4",
"author": "md2gost",
"docs": "mine",
"prefix": "",
"postfix": "",
}
},
ensure_ascii=False,
)
path.write_bytes(b"\xef\xbb\xbf" + body.encode("utf-8"))
result = migrate_user_schemes(tmp_path)
assert "usecase" in result.added
schemes = load_schemes_from_path(path)
assert schemes["c4"].title == "My C4"
assert schemes["c4"].docs == "mine"
text = path.read_text(encoding="utf-8-sig")
assert "mine" in text
def test_save_schemes_keeps_unknown_entries(tmp_path):
path = user_schemes_path(tmp_path)
path.write_text(
json.dumps(
{
"my-scheme": {"title": "hyphen", "note": "keep"},
"mine": {"title": "x", "author": "", "extra": "field", "prefix": "A"},
}
)
+ "\n",
encoding="utf-8",
)
loaded = load_schemes_from_path(path)
assert "mine" in loaded
assert "my-scheme" not in loaded
loaded["mine"] = DiagramScheme(
id="mine",
title="y",
author="",
prefix="A",
)
save_schemes(loaded, path, removed_ids=set())
raw = json.loads(path.read_text(encoding="utf-8"))
assert raw["my-scheme"]["title"] == "hyphen"
assert raw["my-scheme"]["note"] == "keep"
assert raw["mine"]["title"] == "y"
assert raw["mine"]["extra"] == "field"
del loaded["mine"]
save_schemes(loaded, path, removed_ids={"mine"})
raw = json.loads(path.read_text(encoding="utf-8"))
assert "mine" not in raw
assert raw["my-scheme"]["note"] == "keep"
def test_migrate_idempotent(tmp_path, monkeypatch): def test_migrate_idempotent(tmp_path, monkeypatch):