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:
236
OpenAppLockTests/AppListTests.swift
Normal file
236
OpenAppLockTests/AppListTests.swift
Normal file
@@ -0,0 +1,236 @@
|
||||
//
|
||||
// AppListTests.swift
|
||||
// OpenAppLockTests
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
import Testing
|
||||
|
||||
@testable import OpenAppLock
|
||||
|
||||
// Note: every test wires `rule.appList` only after both models are inserted —
|
||||
// SwiftData relationships must not be written on unmanaged instances
|
||||
// (see BlockingRule.appList).
|
||||
|
||||
@MainActor
|
||||
@Suite("AppList model & relationship")
|
||||
struct AppListModelTests {
|
||||
@Test("App lists persist and fetch through SwiftData")
|
||||
func persistence() throws {
|
||||
let context = try makeInMemoryContext()
|
||||
let list = AppList(name: "Distractions", selectionData: Data([1, 2]), selectionCount: 2)
|
||||
context.insert(list)
|
||||
try context.save()
|
||||
|
||||
let fetched = try context.fetch(FetchDescriptor<AppList>())
|
||||
#expect(fetched.count == 1)
|
||||
let saved = try #require(fetched.first)
|
||||
#expect(saved.name == "Distractions")
|
||||
#expect(saved.selectionCount == 2)
|
||||
#expect(saved.selectionData == Data([1, 2]))
|
||||
}
|
||||
|
||||
@Test("Deleting a list detaches it from its rules")
|
||||
func deletingListDetachesRules() throws {
|
||||
let context = try makeInMemoryContext()
|
||||
let list = AppList(name: "Distractions")
|
||||
let rule = BlockingRule(name: "Work Time")
|
||||
context.insert(list)
|
||||
context.insert(rule)
|
||||
rule.appList = list
|
||||
try context.save()
|
||||
|
||||
context.delete(list)
|
||||
try context.save()
|
||||
|
||||
let rules = try context.fetch(FetchDescriptor<BlockingRule>())
|
||||
#expect(rules.count == 1)
|
||||
#expect(rules.first?.appList == nil)
|
||||
}
|
||||
|
||||
@Test("Deleting a rule keeps its list")
|
||||
func deletingRuleKeepsList() throws {
|
||||
let context = try makeInMemoryContext()
|
||||
let list = AppList(name: "Distractions")
|
||||
let rule = BlockingRule(name: "Work Time")
|
||||
context.insert(list)
|
||||
context.insert(rule)
|
||||
rule.appList = list
|
||||
try context.save()
|
||||
|
||||
context.delete(rule)
|
||||
try context.save()
|
||||
|
||||
let lists = try context.fetch(FetchDescriptor<AppList>())
|
||||
#expect(lists.count == 1)
|
||||
}
|
||||
|
||||
@Test("Lists report whether any rule uses them")
|
||||
func usageQuery() throws {
|
||||
let context = try makeInMemoryContext()
|
||||
let used = AppList(name: "Used")
|
||||
let unused = AppList(name: "Unused")
|
||||
let rule = BlockingRule(name: "Work Time")
|
||||
context.insert(used)
|
||||
context.insert(unused)
|
||||
context.insert(rule)
|
||||
rule.appList = used
|
||||
try context.save()
|
||||
|
||||
#expect(AppList.isInUse(used, context: context))
|
||||
#expect(!AppList.isInUse(unused, context: context))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Suite("Legacy selection → AppList migration")
|
||||
struct AppListMigrationTests {
|
||||
@Test("Rules with legacy inline selections get a list named after them")
|
||||
func createsListsFromLegacySelections() throws {
|
||||
let context = try makeInMemoryContext()
|
||||
let rule = BlockingRule(name: "Work Time", selectionData: Data([1]), selectionCount: 3)
|
||||
context.insert(rule)
|
||||
try context.save()
|
||||
|
||||
AppListMigration.run(in: context)
|
||||
|
||||
let lists = try context.fetch(FetchDescriptor<AppList>())
|
||||
#expect(lists.count == 1)
|
||||
let list = try #require(rule.appList)
|
||||
#expect(list.name == "Work Time Apps")
|
||||
#expect(list.selectionData == Data([1]))
|
||||
#expect(list.selectionCount == 3)
|
||||
// The legacy inline copy is cleared so migration never re-runs.
|
||||
#expect(rule.selectionData == nil)
|
||||
}
|
||||
|
||||
@Test("Rules with identical selections share one list")
|
||||
func sharesListForIdenticalSelections() throws {
|
||||
let context = try makeInMemoryContext()
|
||||
let first = BlockingRule(name: "Work Time", selectionData: Data([7]), selectionCount: 2)
|
||||
let second = BlockingRule(name: "Sleep", selectionData: Data([7]), selectionCount: 2)
|
||||
let different = BlockingRule(name: "Gym", selectionData: Data([9]), selectionCount: 1)
|
||||
context.insert(first)
|
||||
context.insert(second)
|
||||
context.insert(different)
|
||||
try context.save()
|
||||
|
||||
AppListMigration.run(in: context)
|
||||
|
||||
let lists = try context.fetch(FetchDescriptor<AppList>())
|
||||
#expect(lists.count == 2)
|
||||
#expect(first.appList === second.appList)
|
||||
#expect(first.appList !== different.appList)
|
||||
}
|
||||
|
||||
@Test("Migration is idempotent and skips selection-less rules")
|
||||
func idempotentAndSkipsEmpty() throws {
|
||||
let context = try makeInMemoryContext()
|
||||
let legacy = BlockingRule(name: "Work Time", selectionData: Data([1]), selectionCount: 1)
|
||||
let empty = BlockingRule(name: "No Apps")
|
||||
context.insert(legacy)
|
||||
context.insert(empty)
|
||||
try context.save()
|
||||
|
||||
AppListMigration.run(in: context)
|
||||
AppListMigration.run(in: context)
|
||||
|
||||
let lists = try context.fetch(FetchDescriptor<AppList>())
|
||||
#expect(lists.count == 1)
|
||||
#expect(empty.appList == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Suite("Rule drafts with app lists")
|
||||
struct AppListDraftTests {
|
||||
@Test("Drafts carry the rule's app list and apply it back")
|
||||
func draftCarriesAppList() throws {
|
||||
let context = try makeInMemoryContext()
|
||||
let list = AppList(name: "Distractions", selectionCount: 4)
|
||||
let rule = BlockingRule(name: "Work Time")
|
||||
context.insert(list)
|
||||
context.insert(rule)
|
||||
rule.appList = list
|
||||
|
||||
var draft = RuleDraft(rule: rule)
|
||||
#expect(draft.appList === list)
|
||||
|
||||
draft.name = "Other"
|
||||
let other = draft.insertRule(into: context)
|
||||
#expect(other.appList === list)
|
||||
#expect(other.name == "Other")
|
||||
}
|
||||
|
||||
@Test("Sanitizing forces Block mode for time- and open-limit rules")
|
||||
func sanitizedForcesBlockForLimitKinds() {
|
||||
var timeDraft = RuleDraft(kind: .timeLimit)
|
||||
timeDraft.selectionMode = .allowOnly
|
||||
#expect(timeDraft.sanitized().selectionMode == .block)
|
||||
|
||||
var openDraft = RuleDraft(kind: .openLimit)
|
||||
openDraft.selectionMode = .allowOnly
|
||||
#expect(openDraft.sanitized().selectionMode == .block)
|
||||
}
|
||||
|
||||
@Test("Sanitizing keeps Allow Only on schedule rules")
|
||||
func sanitizedKeepsAllowOnlyForSchedule() {
|
||||
var draft = RuleDraft(kind: .schedule)
|
||||
draft.selectionMode = .allowOnly
|
||||
#expect(draft.sanitized().selectionMode == .allowOnly)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Suite("App-list editing under Hard Mode")
|
||||
struct AppListEditingPolicyTests {
|
||||
let mondayDuringWork = date(2025, 1, 6, 10, 0)
|
||||
let mondayEvening = date(2025, 1, 6, 19, 0)
|
||||
|
||||
@Test("App lists are locked while any hard-mode rule is actively blocking")
|
||||
func lockedDuringHardModeSession() {
|
||||
let hard = BlockingRule(name: "Locked In", hardMode: true)
|
||||
let soft = BlockingRule(name: "Work Time")
|
||||
#expect(
|
||||
!RulePolicy.canEditAppLists(rules: [hard, soft], at: mondayDuringWork, calendar: utc))
|
||||
}
|
||||
|
||||
@Test("App lists stay editable when no hard-mode rule is blocking")
|
||||
func editableWithoutHardSession() {
|
||||
let softActive = BlockingRule(name: "Work Time")
|
||||
let hardInactive = BlockingRule(name: "Locked In", hardMode: true)
|
||||
#expect(
|
||||
RulePolicy.canEditAppLists(
|
||||
rules: [softActive, hardInactive], at: mondayEvening, calendar: utc))
|
||||
#expect(RulePolicy.canEditAppLists(rules: [], at: mondayDuringWork, calendar: utc))
|
||||
}
|
||||
|
||||
@Test("A disabled hard-mode rule does not lock app lists")
|
||||
func disabledHardRuleDoesNotLock() {
|
||||
let rule = BlockingRule(name: "Locked In", isEnabled: false, hardMode: true)
|
||||
#expect(RulePolicy.canEditAppLists(rules: [rule], at: mondayDuringWork, calendar: utc))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Suite("Enforcement reads selections through app lists")
|
||||
struct AppListEnforcementTests {
|
||||
let mondayDuringWork = date(2025, 1, 6, 10, 0)
|
||||
|
||||
@Test("The rule's app-list selection reaches the shield layer")
|
||||
func forwardsAppListSelection() throws {
|
||||
let context = try makeInMemoryContext()
|
||||
let shields = MockShieldController()
|
||||
let enforcer = RuleEnforcer(shields: shields)
|
||||
let list = AppList(name: "Distractions", selectionData: Data([1, 2, 3]))
|
||||
let rule = BlockingRule(name: "Work Time")
|
||||
context.insert(list)
|
||||
context.insert(rule)
|
||||
rule.appList = list
|
||||
|
||||
enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
|
||||
|
||||
#expect(shields.appliedSelectionData[rule.id] == Data([1, 2, 3]))
|
||||
}
|
||||
}
|
||||
@@ -47,11 +47,7 @@ struct RuleModelTests {
|
||||
|
||||
@Test("Rules persist and fetch through SwiftData")
|
||||
func persistence() throws {
|
||||
let container = try ModelContainer(
|
||||
for: BlockingRule.self,
|
||||
configurations: ModelConfiguration(isStoredInMemoryOnly: true)
|
||||
)
|
||||
let context = container.mainContext
|
||||
let context = try makeInMemoryContext()
|
||||
let rule = BlockingRule(
|
||||
name: "Deep Sleep", hardMode: true,
|
||||
days: Weekday.everyDay, startMinutes: 22 * 60, endMinutes: 6 * 60
|
||||
@@ -83,7 +79,11 @@ struct RuleDraftTests {
|
||||
}
|
||||
|
||||
@Test("Draft → rule → draft round-trips every field")
|
||||
func roundTrip() {
|
||||
func roundTrip() throws {
|
||||
let context = try makeInMemoryContext()
|
||||
let list = AppList(name: "Distractions", selectionCount: 3)
|
||||
context.insert(list)
|
||||
|
||||
var draft = RuleDraft(kind: .schedule)
|
||||
draft.name = "Locked In"
|
||||
draft.days = Weekday.everyDay
|
||||
@@ -92,16 +92,18 @@ struct RuleDraftTests {
|
||||
draft.hardMode = true
|
||||
draft.blockAdultContent = true
|
||||
draft.selectionMode = .allowOnly
|
||||
draft.selectionCount = 3
|
||||
draft.appList = list
|
||||
|
||||
let rule = draft.makeRule()
|
||||
let rule = draft.insertRule(into: context)
|
||||
#expect(rule.blockAdultContent)
|
||||
#expect(RuleDraft(rule: rule) == draft)
|
||||
}
|
||||
|
||||
@Test("Applying a draft updates an existing rule")
|
||||
func applyToExisting() {
|
||||
func applyToExisting() throws {
|
||||
let context = try makeInMemoryContext()
|
||||
let rule = BlockingRule(name: "Old Name")
|
||||
context.insert(rule)
|
||||
var draft = RuleDraft(rule: rule)
|
||||
draft.name = "New Name"
|
||||
draft.hardMode = true
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user