diff --git a/gear/cameras.json b/gear/cameras.json index b0115dc6..00746260 100644 --- a/gear/cameras.json +++ b/gear/cameras.json @@ -1,4 +1,67 @@ [ + { + "id": "cam-leica-ma", + "make": "Leica", + "model": "M-A", + "displayName": "Leica M-A", + "notes": "35mm rangefinder." + }, + { + "id": "cam-leica-m2", + "make": "Leica", + "model": "M2", + "displayName": "Leica M2", + "notes": "35mm rangefinder." + }, + { + "id": "cam-leica-m3", + "make": "Leica", + "model": "M3", + "displayName": "Leica M3", + "notes": "35mm rangefinder." + }, + { + "id": "cam-leica-m4", + "make": "Leica", + "model": "M4", + "displayName": "Leica M4", + "notes": "35mm rangefinder." + }, + { + "id": "cam-leica-m42", + "make": "Leica", + "model": "M4-2", + "displayName": "Leica M4-2", + "notes": "35mm rangefinder." + }, + { + "id": "cam-leica-m4p", + "make": "Leica", + "model": "M4-P", + "displayName": "Leica M4-P", + "notes": "35mm rangefinder." + }, + { + "id": "cam-leica-m5", + "make": "Leica", + "model": "M5", + "displayName": "Leica M5", + "notes": "35mm rangefinder." + }, + { + "id": "cam-leica-m6", + "make": "Leica", + "model": "M6", + "displayName": "Leica M6", + "notes": "35mm rangefinder." + }, + { + "id": "cam-leica-mp", + "make": "Leica", + "model": "MP", + "displayName": "Leica MP", + "notes": "35mm rangefinder." + }, { "id": "cam-canon-ae1p", "make": "Canon", diff --git a/gear/film_stocks.json b/gear/film_stocks.json index 3d9d0296..fcac9834 100644 --- a/gear/film_stocks.json +++ b/gear/film_stocks.json @@ -45,6 +45,95 @@ "format": "35mm", "colorType": "B&W Negative" }, + { + "id": "film-fp4-125", + "displayName": "Ilford FP4 Plus", + "manufacturer": "Ilford", + "stockName": "FP4 Plus 125", + "iso": 125, + "format": "35mm", + "colorType": "B&W Negative" + }, + { + "id": "film-delta-100", + "displayName": "Ilford Delta 100", + "manufacturer": "Ilford", + "stockName": "Delta 100 Professional", + "iso": 100, + "format": "35mm", + "colorType": "B&W Negative", + "notes": "Core-shell tabular grain, fine grain and high sharpness." + }, + { + "id": "film-delta-400", + "displayName": "Ilford Delta 400", + "manufacturer": "Ilford", + "stockName": "Delta 400 Professional", + "iso": 400, + "format": "35mm", + "colorType": "B&W Negative", + "notes": "Core-shell tabular grain, fine grain and high sharpness." + }, + { + "id": "film-delta-3200", + "displayName": "Ilford Delta 3200", + "manufacturer": "Ilford", + "stockName": "Delta 3200 Professional", + "iso": 3200, + "format": "35mm", + "colorType": "B&W Negative", + "notes": "True speed ~1000, designed to be push processed." + }, + { + "id": "film-panf-50", + "displayName": "Ilford Pan F Plus", + "manufacturer": "Ilford", + "stockName": "Pan F Plus 50", + "iso": 50, + "format": "35mm", + "colorType": "B&W Negative", + "notes": "Ultra-fine grain, slow speed." + }, + { + "id": "film-xp2-400", + "displayName": "Ilford XP2 Super", + "manufacturer": "Ilford", + "stockName": "XP2 Super 400", + "iso": 400, + "format": "35mm", + "colorType": "B&W Negative", + "notes": "Chromogenic B&W on a C-41 process, wide exposure latitude." + }, + { + "id": "film-sfx-200", + "displayName": "Ilford SFX 200", + "manufacturer": "Ilford", + "stockName": "SFX 200", + "iso": 200, + "format": "35mm", + "colorType": "B&W Negative", + "notes": "Extended red sensitivity to ~740nm, near-infrared effects with a red filter." + }, + { + "id": "film-kentmere-100", + "displayName": "Kentmere 100", + "manufacturer": "Kentmere", + "stockName": "Kentmere 100", + "iso": 100, + "format": "35mm", + "colorType": "B&W Negative", + "notes": "Budget B&W, made by Ilford." + }, + { + "id": "film-kentmere-400", + "displayName": "Kentmere 400", + "manufacturer": "Kentmere", + "stockName": "Kentmere 400", + "iso": 400, + "format": "35mm", + "colorType": "B&W Negative", + "notes": "Budget B&W, made by Ilford." + }, { "id": "film-ektachrome-e100", "displayName": "Kodak Ektachrome E100", @@ -63,5 +152,55 @@ "format": "35mm", "colorType": "ColorNegative", "notes": "Tungsten-balanced color negative with characteristic halation." + }, + { + "id": "film-vision3-250d-remjet", + "displayName": "Kodak Vision3 250D", + "manufacturer": "Kodak", + "stockName": "Vision3 250D", + "iso": 250, + "format": "35mm", + "colorType": "ColorNegative", + "notes": "Motion picture stock, daylight balanced. With remjet layer." + }, + { + "id": "film-vision3-250d-ahu", + "displayName": "Kodak Vision3 250D AHU", + "manufacturer": "Kodak", + "stockName": "Vision3 250D AHU", + "iso": 250, + "format": "35mm", + "colorType": "ColorNegative", + "notes": "Motion picture stock, daylight balanced." + }, + { + "id": "film-vision3-500t-remjet", + "displayName": "Kodak Vision3 500T", + "manufacturer": "Kodak", + "stockName": "Vision3 500T", + "iso": 500, + "format": "35mm", + "colorType": "ColorNegative", + "notes": "Motion picture stock, tungsten balanced. With remjet layer" + }, + { + "id": "film-vision3-500t-ahu", + "displayName": "Kodak Vision3 500T AHU", + "manufacturer": "Kodak", + "stockName": "Vision3 500T AHU", + "iso": 500, + "format": "35mm", + "colorType": "ColorNegative", + "notes": "Motion picture stock, tungsten balanced." + }, + { + "id": "film-double-x-250", + "displayName": "Kodak Double-X", + "manufacturer": "Kodak", + "stockName": "Double-X 250", + "iso": 250, + "format": "35mm", + "colorType": "B&W Negative", + "notes": "Motion picture panchromatic negative." } ] diff --git a/gear/gear_presets.json b/gear/gear_presets.json index 0772a046..1860582c 100644 --- a/gear/gear_presets.json +++ b/gear/gear_presets.json @@ -6,11 +6,18 @@ "lensId": "lens-canon-fd-50-1.4", "filmStockId": "film-portra-400" }, + { + "id": "preset-lma-s50-vision500t-ahu", + "displayName": "M-A / 50 f/2.0 / 500T AHU", + "cameraId": "cam-leica-ma", + "lensId": "lens-leica-summicron-50-2.0", + "filmStockId": "film-vision3-500t-ahu" + }, { "id": "preset-fm2-50-trix", "displayName": "FM2 / Nikkor 50 f/1.8 / Tri-X 400", "cameraId": "cam-nikon-fm2", - "lensId": "lens-nikkor-50-1.8", + "lensId": "lens-leica-summicron-50-2.0", "filmStockId": "film-tri-x-400" } ] diff --git a/gear/lenses.json b/gear/lenses.json index 280509a6..a49df29e 100644 --- a/gear/lenses.json +++ b/gear/lenses.json @@ -15,6 +15,30 @@ "focalLength": 28, "maxAperture": 2.8 }, + { + "id": "lens-leica-summicron-28-2.0", + "displayName": "Leica Summicron 28mm f/2.0", + "lensModel": "Summicron 28mm f/2 ASPH", + "make": "Leica", + "focalLength": 28, + "maxAperture": 2.0 + }, + { + "id": "lens-leica-summilux-50-1.4", + "displayName": "Leica Summilux 50mm f/1.4", + "lensModel": "Summilux 50mm f/1.4 ASPH", + "make": "Leica", + "focalLength": 50, + "maxAperture": 1.4 + }, + { + "id": "lens-leica-summicron-50-2.0", + "displayName": "Leica Summicron 50mm f/2.0", + "lensModel": "Summicron 50mm f/2.0", + "make": "Leica", + "focalLength": 50, + "maxAperture": 2.0 + }, { "id": "lens-nikkor-50-1.8", "displayName": "Nikkor 50mm f/1.8 AI-S", diff --git a/negpy/desktop/main.py b/negpy/desktop/main.py index 061e99ed..9d7d4e6c 100644 --- a/negpy/desktop/main.py +++ b/negpy/desktop/main.py @@ -57,8 +57,8 @@ def _bootstrap_environment() -> None: ] for d in dirs: os.makedirs(d, exist_ok=True) - CrosstalkProfiles.seed_example() - GearProfiles.seed_example() + CrosstalkProfiles.ensure_user_dir() + GearProfiles.ensure_user_dir() def main() -> None: diff --git a/negpy/desktop/view/widgets/gear_library_dialog.py b/negpy/desktop/view/widgets/gear_library_dialog.py index ed2b53ec..6eef025e 100644 --- a/negpy/desktop/view/widgets/gear_library_dialog.py +++ b/negpy/desktop/view/widgets/gear_library_dialog.py @@ -290,10 +290,18 @@ def _refresh_preset_combos(self) -> None: def _on_item_changed(self, row: int) -> None: if row < 0 or row >= len(self._current_items()): self._selected_idx = -1 + self._set_form_editable(True) self._clear_form() return self._selected_idx = row - self._populate_form(self._current_items()[row]) + item = self._current_items()[row] + self._set_form_editable(not item.is_bundled) + self._populate_form(item) + + def _set_form_editable(self, enabled: bool) -> None: + for _label, widget in self._form_rows.values(): + widget.setEnabled(enabled) + self.del_btn.setEnabled(enabled) def _populate_form(self, item) -> None: self._updating = True @@ -421,6 +429,7 @@ def _duplicate_item(self) -> None: from negpy.features.metadata.gear_models import _new_id dup.id = _new_id() + dup.is_bundled = False items.append(dup) self._set_current_items(items) GearProfiles.save_library(self._library) diff --git a/negpy/features/metadata/gear_models.py b/negpy/features/metadata/gear_models.py index af4780c4..0ca880a1 100644 --- a/negpy/features/metadata/gear_models.py +++ b/negpy/features/metadata/gear_models.py @@ -69,6 +69,7 @@ class Camera: display_name: str = "" serial_number: str = "" notes: str = "" + is_bundled: bool = False @property def resolved_display_name(self) -> str: @@ -108,6 +109,7 @@ class Lens: max_aperture: Optional[float] = None serial_number: str = "" notes: str = "" + is_bundled: bool = False @property def resolved_display_name(self) -> str: @@ -158,6 +160,7 @@ class FilmStock: format: FilmFormat = FilmFormat.FORMAT_35MM color_type: FilmColorType = FilmColorType.COLOR_NEGATIVE notes: str = "" + is_bundled: bool = False @property def resolved_display_name(self) -> str: @@ -219,6 +222,7 @@ class GearPreset: lens_id: str = "" film_stock_id: str = "" notes: str = "" + is_bundled: bool = False def to_dict(self) -> dict[str, Any]: return { diff --git a/negpy/services/assets/crosstalk.py b/negpy/services/assets/crosstalk.py index c2ee995c..2f37d876 100644 --- a/negpy/services/assets/crosstalk.py +++ b/negpy/services/assets/crosstalk.py @@ -1,5 +1,4 @@ import os -import shutil import tomllib from typing import List, Optional @@ -21,16 +20,15 @@ class CrosstalkProfiles: DEFAULT_NAME = DEFAULT_NAME @staticmethod - def _scan() -> dict: - """Maps display-name -> flat 9-float matrix for valid custom .toml files.""" + def _scan_dir(directory: str) -> dict: + """Maps display-name -> flat 9-float matrix for valid .toml files in a directory.""" result: dict = {} - crosstalk_dir = APP_CONFIG.crosstalk_dir - if not os.path.isdir(crosstalk_dir): + if not os.path.isdir(directory): return result - for fname in os.listdir(crosstalk_dir): + for fname in os.listdir(directory): if not fname.endswith(".toml"): continue - path = os.path.join(crosstalk_dir, fname) + path = os.path.join(directory, fname) parsed = CrosstalkProfiles._parse_file(path) if parsed is None: continue @@ -40,6 +38,13 @@ def _scan() -> dict: result[name] = matrix return result + @staticmethod + def _scan() -> dict: + """Bundled ∪ user custom matrices, keyed by display name; bundled wins.""" + bundled = CrosstalkProfiles._scan_dir(get_resource_path("crosstalk")) + user = CrosstalkProfiles._scan_dir(APP_CONFIG.crosstalk_dir) + return {**user, **bundled} + @staticmethod def _parse_file(path: str) -> Optional[tuple]: """Parses a .toml file to (name, flat 9-float list), or None if invalid.""" @@ -79,24 +84,6 @@ def get_matrix(name: str) -> Optional[List[float]]: return CrosstalkProfiles._scan().get(name) @staticmethod - def seed_example() -> None: - """Copy bundled gallery matrices into the user folder, per file. - - Skips a file that already exists in the user folder, so user edits - are never overwritten. New matrices bundled in later releases still - get copied in on next startup. - """ - crosstalk_dir = APP_CONFIG.crosstalk_dir - bundled_dir = get_resource_path("crosstalk") - try: - if not os.path.isdir(crosstalk_dir) or not os.path.isdir(bundled_dir): - return - for fname in os.listdir(bundled_dir): - if not fname.endswith(".toml"): - continue - dest = os.path.join(crosstalk_dir, fname) - if os.path.exists(dest): - continue - shutil.copyfile(os.path.join(bundled_dir, fname), dest) - except OSError: - pass + def ensure_user_dir() -> None: + """Make sure the user's crosstalk directory exists; no seeding.""" + os.makedirs(APP_CONFIG.crosstalk_dir, exist_ok=True) diff --git a/negpy/services/assets/gear.py b/negpy/services/assets/gear.py index 2a8865db..53f7b538 100644 --- a/negpy/services/assets/gear.py +++ b/negpy/services/assets/gear.py @@ -4,7 +4,6 @@ import json import os -import shutil from typing import TypeVar from negpy.features.metadata.gear_models import ( @@ -59,14 +58,32 @@ def _write_list(path: str, items: list) -> None: f.write("\n") os.replace(tmp, path) + @staticmethod + def _read_bundled_list(fname: str, factory: type[T]) -> list[T]: + items = GearProfiles._read_list(os.path.join(get_resource_path("gear"), fname), factory) + for item in items: + item.is_bundled = True # type: ignore[attr-defined] + return items + + @staticmethod + def _merge(bundled: list[T], user: list[T]) -> list[T]: + """Bundled ∪ user, deduped by id, bundled wins.""" + bundled_ids = {item.id for item in bundled} # type: ignore[attr-defined] + return bundled + [item for item in user if item.id not in bundled_ids] # type: ignore[attr-defined] + + @staticmethod + def _load_merged(fname: str, factory: type[T]) -> list[T]: + bundled = GearProfiles._read_bundled_list(fname, factory) + user = GearProfiles._read_list(os.path.join(GearProfiles._gear_dir(), fname), factory) + return GearProfiles._merge(bundled, user) + @staticmethod def load_library() -> GearLibrary: - base = GearProfiles._gear_dir() return GearLibrary( - cameras=GearProfiles._read_list(os.path.join(base, _CAMERAS_FILE), Camera), - lenses=GearProfiles._read_list(os.path.join(base, _LENSES_FILE), Lens), - film_stocks=GearProfiles._read_list(os.path.join(base, _FILM_STOCKS_FILE), FilmStock), - gear_presets=GearProfiles._read_list(os.path.join(base, _GEAR_PRESETS_FILE), GearPreset), + cameras=GearProfiles._load_merged(_CAMERAS_FILE, Camera), + lenses=GearProfiles._load_merged(_LENSES_FILE, Lens), + film_stocks=GearProfiles._load_merged(_FILM_STOCKS_FILE, FilmStock), + gear_presets=GearProfiles._load_merged(_GEAR_PRESETS_FILE, GearPreset), ) @staticmethod @@ -87,26 +104,12 @@ def save_gear_presets(presets: list[GearPreset]) -> None: @staticmethod def save_library(library: GearLibrary) -> None: - GearProfiles.save_cameras(library.cameras) - GearProfiles.save_lenses(library.lenses) - GearProfiles.save_film_stocks(library.film_stocks) - GearProfiles.save_gear_presets(library.gear_presets) + GearProfiles.save_cameras([c for c in library.cameras if not c.is_bundled]) + GearProfiles.save_lenses([lens for lens in library.lenses if not lens.is_bundled]) + GearProfiles.save_film_stocks([f for f in library.film_stocks if not f.is_bundled]) + GearProfiles.save_gear_presets([p for p in library.gear_presets if not p.is_bundled]) @staticmethod - def seed_example() -> None: - """Copy bundled starter gear JSON into the user folder when missing.""" - gear_dir = APP_CONFIG.gear_dir - bundled_dir = get_resource_path("gear") - try: - os.makedirs(gear_dir, exist_ok=True) - if not os.path.isdir(bundled_dir): - return - for fname in (_CAMERAS_FILE, _LENSES_FILE, _FILM_STOCKS_FILE, _GEAR_PRESETS_FILE): - dest = os.path.join(gear_dir, fname) - if os.path.exists(dest): - continue - src = os.path.join(bundled_dir, fname) - if os.path.isfile(src): - shutil.copyfile(src, dest) - except OSError: - pass + def ensure_user_dir() -> None: + """Make sure the user's gear directory exists; no seeding.""" + GearProfiles._gear_dir() diff --git a/tests/metadata/test_gear_payload.py b/tests/metadata/test_gear_payload.py index da7aeee3..8deda1ff 100644 --- a/tests/metadata/test_gear_payload.py +++ b/tests/metadata/test_gear_payload.py @@ -1,5 +1,6 @@ """Tests for gear library and metadata payload resolution.""" +import json import os @@ -26,14 +27,81 @@ @pytest.fixture def gear_dir(tmp_path, monkeypatch): monkeypatch.setattr("negpy.services.assets.gear.APP_CONFIG.gear_dir", str(tmp_path)) + monkeypatch.setattr("negpy.services.assets.gear.get_resource_path", lambda _: str(tmp_path / "_no_bundled")) return tmp_path -def test_seed_example_copies_bundled_files(gear_dir): - GearProfiles.seed_example() +def test_ensure_user_dir_creates_directory(tmp_path, monkeypatch): + target = tmp_path / "nested" / "gear" + monkeypatch.setattr("negpy.services.assets.gear.APP_CONFIG.gear_dir", str(target)) - assert os.path.isfile(os.path.join(gear_dir, "cameras.json")) + assert not target.exists() + + GearProfiles.ensure_user_dir() + + assert target.is_dir() + + +def test_load_library_merges_bundled_and_user_deduped(tmp_path, monkeypatch): + bundled_dir = tmp_path / "bundled" + bundled_dir.mkdir() + user_dir = tmp_path / "user" + user_dir.mkdir() + monkeypatch.setattr("negpy.services.assets.gear.APP_CONFIG.gear_dir", str(user_dir)) + monkeypatch.setattr("negpy.services.assets.gear.get_resource_path", lambda _: str(bundled_dir)) + + with open(bundled_dir / "cameras.json", "w", encoding="utf-8") as f: + json.dump([{"id": "cam-shared", "make": "Leica", "model": "M6"}], f) + with open(user_dir / "cameras.json", "w", encoding="utf-8") as f: + json.dump( + [ + {"id": "cam-shared", "make": "Leica", "model": "M6-EDITED"}, + {"id": "user-1", "make": "Pentax", "model": "K1000"}, + ], + f, + ) + + library = GearProfiles.load_library() + + assert [c.id for c in library.cameras] == ["cam-shared", "user-1"] + assert library.cameras[0].model == "M6" + assert library.cameras[0].is_bundled is True + assert library.cameras[1].is_bundled is False + + +def test_save_library_excludes_bundled_items(gear_dir): + library = GearLibrary( + cameras=[ + Camera(id="cam-bundled", make="Leica", is_bundled=True), + Camera(id="user-1", make="Pentax"), + ] + ) + + GearProfiles.save_library(library) + + on_disk = GearProfiles._read_list(os.path.join(gear_dir, "cameras.json"), Camera) + assert [c.id for c in on_disk] == ["user-1"] + + +def test_duplicate_bundled_item_is_editable_and_persistable(gear_dir): + from negpy.desktop.view.widgets.gear_library_dialog import GearLibraryDialog + + library = GearLibrary(cameras=[Camera(id="cam-bundled", make="Leica", model="M6", is_bundled=True)]) + dlg = GearLibraryDialog(library) + + assert dlg.display_name_edit.isEnabled() is False + assert dlg.del_btn.isEnabled() is False + + dlg._duplicate_item() + + dup = dlg.library().cameras[-1] + assert dup.is_bundled is False + assert dup.id != "cam-bundled" + assert dlg.display_name_edit.isEnabled() is True + + on_disk = GearProfiles._read_list(os.path.join(gear_dir, "cameras.json"), Camera) + assert [c.id for c in on_disk] == [dup.id] def test_load_and_save_library(gear_dir): diff --git a/tests/test_crosstalk_profiles.py b/tests/test_crosstalk_profiles.py index 9e75fc4b..7651841b 100644 --- a/tests/test_crosstalk_profiles.py +++ b/tests/test_crosstalk_profiles.py @@ -1,5 +1,7 @@ import os +import pytest + from negpy.kernel.system.config import APP_CONFIG from negpy.services.assets.crosstalk import CrosstalkProfiles @@ -9,6 +11,11 @@ def _write(path, content): f.write(content) +@pytest.fixture(autouse=True) +def _isolate_bundled(tmp_path, monkeypatch): + monkeypatch.setattr("negpy.services.assets.crosstalk.get_resource_path", lambda _: str(tmp_path / "_no_bundled")) + + def test_list_and_get_custom(tmp_path, monkeypatch): monkeypatch.setattr(APP_CONFIG, "crosstalk_dir", str(tmp_path)) @@ -44,35 +51,55 @@ def test_malformed_skipped(tmp_path, monkeypatch): assert CrosstalkProfiles.list_profiles() == ["Default"] -def test_seed_example(tmp_path, monkeypatch): +def test_ensure_user_dir_creates_directory(tmp_path, monkeypatch): + target = tmp_path / "nested" / "crosstalk" + monkeypatch.setattr(APP_CONFIG, "crosstalk_dir", str(target)) + + assert not target.exists() + + CrosstalkProfiles.ensure_user_dir() + + assert target.is_dir() + + +def test_list_profiles_merges_bundled_and_user(tmp_path, monkeypatch): user_dir = tmp_path / "user" bundled_dir = tmp_path / "bundled" user_dir.mkdir() bundled_dir.mkdir() _write( - os.path.join(bundled_dir, "example.toml"), - 'name = "Example"\nmatrix = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]\n', + os.path.join(bundled_dir, "portra_400.toml"), + 'name = "Portra 400"\nmatrix = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]\n', + ) + _write( + os.path.join(user_dir, "my_film.toml"), + 'name = "My Film"\nmatrix = [[0.9, 0.1, 0.0], [0.0, 0.9, 0.1], [0.0, 0.0, 0.9]]\n', ) - _write(os.path.join(bundled_dir, "README.md"), "not a matrix\n") monkeypatch.setattr(APP_CONFIG, "crosstalk_dir", str(user_dir)) monkeypatch.setattr("negpy.services.assets.crosstalk.get_resource_path", lambda _: str(bundled_dir)) - CrosstalkProfiles.seed_example() - tomls = [f for f in os.listdir(user_dir) if f.endswith(".toml")] - assert tomls == ["example.toml"] # README.md not copied - assert CrosstalkProfiles.list_profiles() == ["Default", "Example"] + assert CrosstalkProfiles.list_profiles() == ["Default", "My Film", "Portra 400"] + assert CrosstalkProfiles.get_matrix("Portra 400") == [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0] + assert CrosstalkProfiles.get_matrix("My Film") == [0.9, 0.1, 0.0, 0.0, 0.9, 0.1, 0.0, 0.0, 0.9] - # Re-running is a no-op for a file the user already has. - _write(os.path.join(user_dir, "example.toml"), 'name = "Edited"\nmatrix = [[1,0,0],[0,1,0],[0,0,1]]\n') - CrosstalkProfiles.seed_example() - assert CrosstalkProfiles.list_profiles() == ["Default", "Edited"] - # A matrix added to the bundled dir in a later release still gets copied in. +def test_bundled_wins_on_name_collision_dedup(tmp_path, monkeypatch): + user_dir = tmp_path / "user" + bundled_dir = tmp_path / "bundled" + user_dir.mkdir() + bundled_dir.mkdir() + _write( + os.path.join(bundled_dir, "portra_400.toml"), + 'name = "Portra 400"\nmatrix = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]\n', + ) _write( - os.path.join(bundled_dir, "second.toml"), - 'name = "Second"\nmatrix = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]\n', + os.path.join(user_dir, "old_seeded_copy.toml"), + 'name = "Portra 400"\nmatrix = [[9.0, 9.0, 9.0], [9.0, 9.0, 9.0], [9.0, 9.0, 9.0]]\n', ) - CrosstalkProfiles.seed_example() - assert sorted(f for f in os.listdir(user_dir) if f.endswith(".toml")) == ["example.toml", "second.toml"] - assert CrosstalkProfiles.list_profiles() == ["Default", "Edited", "Second"] + + monkeypatch.setattr(APP_CONFIG, "crosstalk_dir", str(user_dir)) + monkeypatch.setattr("negpy.services.assets.crosstalk.get_resource_path", lambda _: str(bundled_dir)) + + assert CrosstalkProfiles.list_profiles() == ["Default", "Portra 400"] + assert CrosstalkProfiles.get_matrix("Portra 400") == [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]