src/preferences/PreferencesMigrationsSpecs.md
Line coverage:
PreferencesMigrations.swift49% — the per-migration transforms are covered; themigratePreferences/updateToNewPreferencesorchestrator,migrateLoginItem, andmigrateShortcutPreferencesToSecureCodingare intentionally excluded (see "Not covered" below). refreshed 2026-05-27 by/coverage-explore
PreferencesMigrations upgrades a user's stored UserDefaults from an older AltTab version to the current schema. It runs once per launch (migratePreferences()), comparing the stored preferencesVersion to the app version and applying each registered migration whose version threshold the stored version is at or below. Most migrations are small, self-contained UserDefaults transforms (rename a key, remap an index, split one key into two, convert a Bool string to an enum index).
Why this is the highest-value safety net: these run on every upgrade against real users' data. A mistake silently corrupts settings for the entire installed base — and there's no UI signal when it goes wrong. The tests pin each transform's exact input→output.
shouldRun) uses String.compare(_, options: .numeric). A migration with threshold T runs iff the stored version is ≤ T (i.e. compare is not .orderedDescending). The .numeric option is load-bearing: lexically "9" > "10", but numerically 9 < 10, so a user on 9.x still gets a 10.x migration.updateToNewPreferences runs migrations newest-threshold-first; some depend on keys earlier ones leave behind. The per-migration tests isolate each, but the registration list in updateToNewPreferences is the integration contract.migrateExceptionsTitleArray must be safe to re-run — already-migrated (array-form) data fails to decode into the legacy (String?) shape, triggering an early return that leaves data untouched.UserDefaults.standard; the tests inject an isolated suite via PreferencesMigrations.defaults (reset in tearDown) so they never touch the dev machine's real prefs.migrateShortcutPreferencesToSecureCoding (needs the real NSKeyedArchiver/ShortcutRecorder codec, stubbed compile-only) and migrateLoginItem (mutates real Login Items via deprecated LaunchServices APIs).Mirrors PreferencesMigrationsTests.swift 1:1.
shouldRun)6.0.0 ≤ threshold 10.13.0 → runs.11.0.0 > 10.13.0 → skipped.9.0.0 < 10.0.0 numerically → runs (guards the .numeric option).showAppsOrWindows2…10; the global key is removed (slot 0 ends nil — the documented quirk)."false" → "0" across indexed keys; global removed."true" per-shortcut Bool string → "1".5 → 2 via the remap table.58 → 21 (table boundary).0.blacklist value copied to exceptions; blacklist removed.exceptions preserved; blacklist still removed.dontShowBlacklist entry → ExceptionEntry(hide: .always, ignore: .none); old key removed.disableShortcutsBlacklist + …OnlyFullscreen → ignore: .whenFullscreen.windowTitleContains String → [String]"abc" → ["abc"].nil.showWindowlessApps value remap"0" (showAtTheEnd) → "2"."1"."true" → "0" (.show)."false" → "1" (.hide)."2" (4-finger) → "3" (4-finger-horizontal)."true" → 1."false" → 0.menubarIcon == "3" → menubarIcon "0" + menubarIconShown "false".windowMinWidthInRow "0" → "1".maxScreenUsage → both maxWidthOnScreen + maxHeightOnScreen.nextWindowShortcut ("⌥⇥" → "⇥").4 → 10; shortcutCount set to 3 when a 3rd shortcut exists.appsToShow "Active app" → "1"; theme "❖ Windows 10" → "1".