feat: introduce reusable app lists for rules

- AppList @Model with launch migration from legacy inline selections
  (rules with identical selections share one list; idempotent)
- rules point at one app list; Block/Allow Only belongs to the rule and
  is offered only in the Schedule editor (limit kinds sanitize to Block)
- app-list picker sheet (select / create / edit / delete with in-use
  protection) replaces the per-rule selection sheet
- Hard Mode locks app-list editing while any hard rule is blocking
- SwiftData stability: relationships are only wired between managed
  models, and unit tests share one in-memory container (fresh context +
  data wipe per test) — per-test container creation trapped
  intermittently inside SwiftData

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-12 20:02:35 -04:00
parent ee92b1b248
commit d8d7028d60
22 changed files with 880 additions and 138 deletions

View File

@@ -4,9 +4,45 @@
//
import Foundation
import SwiftData
@testable import OpenAppLock
/// One in-memory ModelContainer shared by the whole test process.
///
/// Repeatedly creating containers for this schema (inverse relationships)
/// trips an intermittent EXC_BREAKPOINT inside SwiftData's configuration
/// setup observed both in test runs and in a sequential snippet that made
/// six containers. Tests therefore share a single container; isolation comes
/// from a fresh ModelContext plus a data wipe per test.
@MainActor
private let sharedTestContainer: ModelContainer = {
do {
return try ModelContainer(
for: Schema([BlockingRule.self, AppList.self]),
configurations: ModelConfiguration(isStoredInMemoryOnly: true)
)
} catch {
fatalError("Could not create the shared test ModelContainer: \(error)")
}
}()
/// Fresh, empty SwiftData context for model tests. The wipe deletes object
/// by object batch `delete(model:)` refuses to fire the appList nullify
/// inverse ("Constraint trigger violation").
@MainActor
func makeInMemoryContext() throws -> ModelContext {
let context = ModelContext(sharedTestContainer)
for rule in try context.fetch(FetchDescriptor<BlockingRule>()) {
context.delete(rule)
}
for list in try context.fetch(FetchDescriptor<AppList>()) {
context.delete(list)
}
try context.save()
return context
}
/// Fixed UTC gregorian calendar so schedule math is deterministic regardless
/// of the machine running the tests.
let utc: Calendar = {